Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/publish/common/publish.ts
6446 views
1
/*
2
* publish.ts
3
*
4
* Copyright (C) 2022 Posit Software, PBC
5
*/
6
7
import { info } from "../../deno_ral/log.ts";
8
import * as colors from "fmt/colors";
9
import { ensureDirSync, walkSync } from "../../deno_ral/fs.ts";
10
11
import { Input } from "cliffy/prompt/input.ts";
12
import { Select } from "cliffy/prompt/select.ts";
13
14
import { dirname, join, relative } from "../../deno_ral/path.ts";
15
import { crypto } from "crypto/crypto";
16
import { encodeHex } from "encoding/hex";
17
18
import { sleep } from "../../core/wait.ts";
19
import { pathWithForwardSlashes } from "../../core/path.ts";
20
import { completeMessage, withSpinner } from "../../core/console.ts";
21
import { fileProgress } from "../../core/progress.ts";
22
23
import { PublishRecord } from "../types.ts";
24
import { PublishFiles } from "../provider-types.ts";
25
import { gfmAutoIdentifier } from "../../core/pandoc/pandoc-id.ts";
26
import { randomHex } from "../../core/random.ts";
27
import { copyTo } from "../../core/copy.ts";
28
import { isHtmlContent, isPdfContent } from "../../core/mime.ts";
29
import { globalTempContext } from "../../core/temp.ts";
30
import { formatResourcePath } from "../../core/resources.ts";
31
import { encodeAttributeValue } from "../../core/html.ts";
32
import { capitalizeWord } from "../../core/text.ts";
33
import { RenderFlags } from "../../command/render/types.ts";
34
35
export interface PublishSite {
36
id?: string;
37
url?: string;
38
code?: boolean;
39
}
40
41
export interface PublishDeploy {
42
id?: string;
43
state?: string;
44
required?: string[];
45
url?: string;
46
admin_url?: string;
47
launch_url?: string;
48
}
49
50
export interface AccountSite {
51
url: string;
52
}
53
54
export interface PublishHandler<
55
Site extends PublishSite = PublishSite,
56
Deploy extends PublishDeploy = PublishDeploy,
57
> {
58
name: string;
59
slugAvailable?: (slug: string) => Promise<boolean>;
60
createSite: (
61
type: "document" | "site",
62
title: string,
63
slug: string,
64
) => Promise<Site>;
65
createDeploy: (
66
siteId: string,
67
files: Record<string, string>,
68
size: number,
69
) => Promise<Deploy>;
70
getDeploy: (deployId: string) => Promise<Deploy>;
71
uploadDeployFile: (
72
deployId: string,
73
path: string,
74
fileBody: Blob,
75
) => Promise<void>;
76
updateAccountSite?: () => Promise<AccountSite>;
77
}
78
79
export async function handlePublish<
80
Site extends PublishSite,
81
Deploy extends PublishDeploy,
82
>(
83
handler: PublishHandler<Site, Deploy>,
84
type: "document" | "site",
85
title: string,
86
slug: string,
87
render: (flags?: RenderFlags) => Promise<PublishFiles>,
88
target?: PublishRecord,
89
): Promise<[PublishRecord, URL | undefined]> {
90
// determine target (create new site if necessary)
91
if (!target?.id) {
92
// prompt for a slug if possible
93
if (handler.slugAvailable) {
94
slug = await promptForSlug(type, handler.slugAvailable, slug);
95
}
96
97
// create site
98
info("");
99
await withSpinner({
100
message: `Creating ${handler.name} ${type}`,
101
}, async () => {
102
const site = await handler.createSite(type, title, slug);
103
target = {
104
id: site.id!,
105
url: site.url!,
106
code: !!site.code,
107
};
108
});
109
info("");
110
}
111
target = target!;
112
113
// render
114
const publishFiles = await renderForPublish(
115
render,
116
handler.name,
117
type,
118
title,
119
type === "site" ? target.url : undefined,
120
);
121
122
// function to resolve the full path of a file
123
// (given that redirects could be in play)
124
const publishFilePath = (file: string) => {
125
return join(publishFiles.baseDir, file);
126
};
127
128
// build file list
129
let siteDeploy: Deploy | undefined;
130
const files: Array<[string, string]> = [];
131
await withSpinner({
132
message: `Preparing to publish ${type}`,
133
}, async () => {
134
let size = 0;
135
for (const file of publishFiles.files) {
136
const filePath = publishFilePath(file);
137
const fileBuffer = Deno.readFileSync(filePath);
138
size = size + fileBuffer.byteLength;
139
const sha1 = await crypto.subtle.digest("SHA-1", fileBuffer);
140
const encodedSha1 = encodeHex(new Uint8Array(sha1));
141
files.push([file, encodedSha1]);
142
}
143
144
// create deploy
145
const deploy = {
146
files: {} as Record<string, string>,
147
};
148
// On windows, be sure sure we normalize the slashes
149
for (const file of files) {
150
deploy.files[`/${pathWithForwardSlashes(file[0])}`] = file[1];
151
}
152
siteDeploy = await handler.createDeploy(
153
target!.id,
154
deploy.files,
155
size,
156
);
157
158
// wait for it to be ready
159
while (true) {
160
siteDeploy = await handler.getDeploy(siteDeploy.id!);
161
if (siteDeploy.state === "prepared" || siteDeploy.state === "ready") {
162
break;
163
}
164
await sleep(250);
165
}
166
});
167
168
// compute required files
169
const required = siteDeploy?.required!.map((sha1) => {
170
const file = files.find((file) => file[1] === sha1);
171
return file ? file[0] : null;
172
}).filter((file) => file) as string[];
173
174
// upload with progress
175
const progress = fileProgress(required);
176
await withSpinner({
177
message: () => `Uploading files ${progress.status()}`,
178
doneMessage: false,
179
}, async () => {
180
for (const requiredFile of required) {
181
const filePath = publishFilePath(requiredFile);
182
const fileArray = Deno.readFileSync(filePath);
183
await handler.uploadDeployFile(
184
siteDeploy?.id!,
185
requiredFile,
186
new Blob([fileArray.buffer]),
187
);
188
progress.next();
189
}
190
});
191
completeMessage(`Uploading files (complete)`);
192
193
// wait on ready
194
let targetUrl = target.url;
195
let adminUrl = target.url;
196
let launchUrl = target.url;
197
await withSpinner({
198
message: `Deploying published ${type}`,
199
}, async () => {
200
while (true) {
201
const deployReady = await handler.getDeploy(siteDeploy?.id!);
202
if (deployReady.state === "ready") {
203
targetUrl = deployReady.url || targetUrl;
204
adminUrl = deployReady.admin_url || targetUrl;
205
launchUrl = deployReady.launch_url || adminUrl;
206
break;
207
}
208
await sleep(500);
209
}
210
});
211
212
// Complete message.
213
completeMessage(`Published ${type}: ${targetUrl}`);
214
215
// If the handler provides an update account site function, call it.
216
if (handler.updateAccountSite) {
217
let accountSite: AccountSite;
218
await withSpinner({
219
message: `Updating account site`,
220
doneMessage: false,
221
}, async () => {
222
accountSite = await handler.updateAccountSite!();
223
});
224
completeMessage(`Account site updated: ${accountSite!.url}`);
225
}
226
227
// Spacer.
228
info("");
229
230
return [
231
{ ...target, url: targetUrl },
232
launchUrl ? new URL(launchUrl) : undefined,
233
];
234
}
235
236
export async function renderForPublish(
237
render: (flags?: RenderFlags) => Promise<PublishFiles>,
238
providerName: string,
239
type: "document" | "site",
240
title: string,
241
siteUrl?: string,
242
) {
243
// render
244
let publishFiles = await render({ siteUrl });
245
246
// validate that the main document is html or pdf
247
if (
248
type === "document" &&
249
!isHtmlContent(publishFiles.rootFile) &&
250
!isPdfContent(publishFiles.rootFile)
251
) {
252
throw new Error(
253
`Documents published to ${providerName} must be either HTML or PDF.`,
254
);
255
}
256
257
// if this is a document then stage the files
258
if (type === "document") {
259
publishFiles = stageDocumentPublish(title, publishFiles);
260
}
261
262
return publishFiles;
263
}
264
265
function stageDocumentPublish(title: string, publishFiles: PublishFiles) {
266
// create temp dir
267
const publishDir = globalTempContext().createDir();
268
269
// copy all files to it
270
const stagedFiles = globalThis.structuredClone(publishFiles) as PublishFiles;
271
stagedFiles.baseDir = publishDir;
272
for (const file of publishFiles.files) {
273
const src = join(publishFiles.baseDir, file);
274
const target = join(stagedFiles.baseDir, file);
275
ensureDirSync(dirname(target));
276
copyTo(src, target);
277
}
278
279
// if this is an html document that isn't index.html then
280
// create an index.html and add it to the staged dir
281
const kIndex = "index.html";
282
if (isHtmlContent(publishFiles.rootFile)) {
283
if (stagedFiles.rootFile !== "index.html") {
284
copyTo(
285
join(stagedFiles.baseDir, stagedFiles.rootFile),
286
join(stagedFiles.baseDir, kIndex),
287
);
288
}
289
} else if (isPdfContent(publishFiles.rootFile)) {
290
// copy pdf.js into the publish dir and add to staged files
291
const src = formatResourcePath("pdf", "pdfjs");
292
const dest = join(stagedFiles.baseDir, "pdfjs");
293
for (const walk of walkSync(src)) {
294
if (walk.isFile) {
295
const destFile = join(dest, relative(src, walk.path));
296
ensureDirSync(dirname(destFile));
297
copyTo(walk.path, destFile);
298
stagedFiles.files.push(relative(stagedFiles.baseDir, destFile));
299
}
300
}
301
// write an index file that serves the pdf
302
const indexHtml = `<!DOCTYPE html>
303
<html>
304
<head>
305
<title>${encodeAttributeValue(title)}</title>
306
<style type="text/css">
307
body, html {
308
margin: 0; padding: 0; height: 100%; overflow: hidden;
309
}
310
</style>
311
</head>
312
<body>
313
<iframe id="pdf-js-viewer" src="pdfjs/web/viewer.html?file=../../${
314
encodeAttributeValue(stagedFiles.rootFile)
315
}" title="${
316
encodeAttributeValue(title)
317
}" frameborder="0" width="100%" height="100%"></iframe>
318
319
</body>
320
</html>
321
`;
322
Deno.writeTextFileSync(join(stagedFiles.baseDir, kIndex), indexHtml);
323
}
324
325
// make sure the root file is index.html
326
if (!stagedFiles.files.includes(kIndex)) {
327
stagedFiles.files.push(kIndex);
328
}
329
stagedFiles.rootFile = kIndex;
330
331
// return staged directory
332
return stagedFiles;
333
}
334
335
async function promptForSlug(
336
type: "document" | "site",
337
slugAvailable: (slug: string) => Promise<boolean>,
338
slug: string,
339
) {
340
// if the generated slug is available then try to confirm it
341
// (for documents append random noise as a fallback)
342
const available = await slugAvailable(slug);
343
if (!available && type === "document") {
344
slug = slug + "-" + randomHex(4);
345
}
346
347
if (await slugAvailable(slug)) {
348
const kConfirmed = "confirmed";
349
const input = await Select.prompt({
350
indent: "",
351
message: `${typeName(type)} name:`,
352
options: [
353
{
354
name: slug,
355
value: kConfirmed,
356
},
357
{
358
name: "Use another name...",
359
value: "another",
360
},
361
],
362
});
363
if (input === kConfirmed) {
364
return slug;
365
}
366
}
367
368
// prompt until we get a name that isn't taken
369
let hint: string | undefined =
370
`The ${
371
typeName(type).toLowerCase()
372
} name is included within your published URL\n` +
373
` (e.g. https://username.quarto.pub/${slug}/)`;
374
375
while (true) {
376
// prompt for server
377
const input = await Input.prompt({
378
indent: "",
379
message: `Publish with name:`,
380
hint,
381
transform: (slug: string) => gfmAutoIdentifier(slug, false),
382
validate: (slug: string) => {
383
if (slug.length === 0) {
384
return true; // implies cancel
385
} else if (slug.length < 2) {
386
return `${typeName(type)} name must be at least 2 characters.`;
387
} else {
388
return true;
389
}
390
},
391
});
392
hint = undefined;
393
if (input.length === 0) {
394
throw new Error();
395
}
396
if (await slugAvailable(input)) {
397
return input;
398
} else {
399
info(
400
colors.red(
401
` The specified name is already in use within your account.`,
402
),
403
);
404
}
405
}
406
}
407
408
function typeName(type: string) {
409
return capitalizeWord(type);
410
}
411
412