import { info } from "../../deno_ral/log.ts";
import * as colors from "fmt/colors";
import { ensureDirSync, walkSync } from "../../deno_ral/fs.ts";
import { Input } from "cliffy/prompt/input.ts";
import { Select } from "cliffy/prompt/select.ts";
import { dirname, join, relative } from "../../deno_ral/path.ts";
import { crypto } from "crypto/crypto";
import { encodeHex } from "encoding/hex";
import { sleep } from "../../core/wait.ts";
import { pathWithForwardSlashes } from "../../core/path.ts";
import { completeMessage, withSpinner } from "../../core/console.ts";
import { fileProgress } from "../../core/progress.ts";
import { PublishRecord } from "../types.ts";
import { PublishFiles } from "../provider-types.ts";
import { gfmAutoIdentifier } from "../../core/pandoc/pandoc-id.ts";
import { randomHex } from "../../core/random.ts";
import { copyTo } from "../../core/copy.ts";
import { isHtmlContent, isPdfContent } from "../../core/mime.ts";
import { globalTempContext } from "../../core/temp.ts";
import { formatResourcePath } from "../../core/resources.ts";
import { encodeAttributeValue } from "../../core/html.ts";
import { capitalizeWord } from "../../core/text.ts";
import { RenderFlags } from "../../command/render/types.ts";
export interface PublishSite {
id?: string;
url?: string;
code?: boolean;
}
export interface PublishDeploy {
id?: string;
state?: string;
required?: string[];
url?: string;
admin_url?: string;
launch_url?: string;
}
export interface AccountSite {
url: string;
}
export interface PublishHandler<
Site extends PublishSite = PublishSite,
Deploy extends PublishDeploy = PublishDeploy,
> {
name: string;
slugAvailable?: (slug: string) => Promise<boolean>;
createSite: (
type: "document" | "site",
title: string,
slug: string,
) => Promise<Site>;
createDeploy: (
siteId: string,
files: Record<string, string>,
size: number,
) => Promise<Deploy>;
getDeploy: (deployId: string) => Promise<Deploy>;
uploadDeployFile: (
deployId: string,
path: string,
fileBody: Blob,
) => Promise<void>;
updateAccountSite?: () => Promise<AccountSite>;
}
export async function handlePublish<
Site extends PublishSite,
Deploy extends PublishDeploy,
>(
handler: PublishHandler<Site, Deploy>,
type: "document" | "site",
title: string,
slug: string,
render: (flags?: RenderFlags) => Promise<PublishFiles>,
target?: PublishRecord,
): Promise<[PublishRecord, URL | undefined]> {
if (!target?.id) {
if (handler.slugAvailable) {
slug = await promptForSlug(type, handler.slugAvailable, slug);
}
info("");
await withSpinner({
message: `Creating ${handler.name} ${type}`,
}, async () => {
const site = await handler.createSite(type, title, slug);
target = {
id: site.id!,
url: site.url!,
code: !!site.code,
};
});
info("");
}
target = target!;
const publishFiles = await renderForPublish(
render,
handler.name,
type,
title,
type === "site" ? target.url : undefined,
);
const publishFilePath = (file: string) => {
return join(publishFiles.baseDir, file);
};
let siteDeploy: Deploy | undefined;
const files: Array<[string, string]> = [];
await withSpinner({
message: `Preparing to publish ${type}`,
}, async () => {
let size = 0;
for (const file of publishFiles.files) {
const filePath = publishFilePath(file);
const fileBuffer = Deno.readFileSync(filePath);
size = size + fileBuffer.byteLength;
const sha1 = await crypto.subtle.digest("SHA-1", fileBuffer);
const encodedSha1 = encodeHex(new Uint8Array(sha1));
files.push([file, encodedSha1]);
}
const deploy = {
files: {} as Record<string, string>,
};
for (const file of files) {
deploy.files[`/${pathWithForwardSlashes(file[0])}`] = file[1];
}
siteDeploy = await handler.createDeploy(
target!.id,
deploy.files,
size,
);
while (true) {
siteDeploy = await handler.getDeploy(siteDeploy.id!);
if (siteDeploy.state === "prepared" || siteDeploy.state === "ready") {
break;
}
await sleep(250);
}
});
const required = siteDeploy?.required!.map((sha1) => {
const file = files.find((file) => file[1] === sha1);
return file ? file[0] : null;
}).filter((file) => file) as string[];
const progress = fileProgress(required);
await withSpinner({
message: () => `Uploading files ${progress.status()}`,
doneMessage: false,
}, async () => {
for (const requiredFile of required) {
const filePath = publishFilePath(requiredFile);
const fileArray = Deno.readFileSync(filePath);
await handler.uploadDeployFile(
siteDeploy?.id!,
requiredFile,
new Blob([fileArray.buffer]),
);
progress.next();
}
});
completeMessage(`Uploading files (complete)`);
let targetUrl = target.url;
let adminUrl = target.url;
let launchUrl = target.url;
await withSpinner({
message: `Deploying published ${type}`,
}, async () => {
while (true) {
const deployReady = await handler.getDeploy(siteDeploy?.id!);
if (deployReady.state === "ready") {
targetUrl = deployReady.url || targetUrl;
adminUrl = deployReady.admin_url || targetUrl;
launchUrl = deployReady.launch_url || adminUrl;
break;
}
await sleep(500);
}
});
completeMessage(`Published ${type}: ${targetUrl}`);
if (handler.updateAccountSite) {
let accountSite: AccountSite;
await withSpinner({
message: `Updating account site`,
doneMessage: false,
}, async () => {
accountSite = await handler.updateAccountSite!();
});
completeMessage(`Account site updated: ${accountSite!.url}`);
}
info("");
return [
{ ...target, url: targetUrl },
launchUrl ? new URL(launchUrl) : undefined,
];
}
export async function renderForPublish(
render: (flags?: RenderFlags) => Promise<PublishFiles>,
providerName: string,
type: "document" | "site",
title: string,
siteUrl?: string,
) {
let publishFiles = await render({ siteUrl });
if (
type === "document" &&
!isHtmlContent(publishFiles.rootFile) &&
!isPdfContent(publishFiles.rootFile)
) {
throw new Error(
`Documents published to ${providerName} must be either HTML or PDF.`,
);
}
if (type === "document") {
publishFiles = stageDocumentPublish(title, publishFiles);
}
return publishFiles;
}
function stageDocumentPublish(title: string, publishFiles: PublishFiles) {
const publishDir = globalTempContext().createDir();
const stagedFiles = globalThis.structuredClone(publishFiles) as PublishFiles;
stagedFiles.baseDir = publishDir;
for (const file of publishFiles.files) {
const src = join(publishFiles.baseDir, file);
const target = join(stagedFiles.baseDir, file);
ensureDirSync(dirname(target));
copyTo(src, target);
}
const kIndex = "index.html";
if (isHtmlContent(publishFiles.rootFile)) {
if (stagedFiles.rootFile !== "index.html") {
copyTo(
join(stagedFiles.baseDir, stagedFiles.rootFile),
join(stagedFiles.baseDir, kIndex),
);
}
} else if (isPdfContent(publishFiles.rootFile)) {
const src = formatResourcePath("pdf", "pdfjs");
const dest = join(stagedFiles.baseDir, "pdfjs");
for (const walk of walkSync(src)) {
if (walk.isFile) {
const destFile = join(dest, relative(src, walk.path));
ensureDirSync(dirname(destFile));
copyTo(walk.path, destFile);
stagedFiles.files.push(relative(stagedFiles.baseDir, destFile));
}
}
const indexHtml = `<!DOCTYPE html>
<html>
<head>
<title>${encodeAttributeValue(title)}</title>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
</style>
</head>
<body>
<iframe id="pdf-js-viewer" src="pdfjs/web/viewer.html?file=../../${
encodeAttributeValue(stagedFiles.rootFile)
}" title="${
encodeAttributeValue(title)
}" frameborder="0" width="100%" height="100%"></iframe>
</body>
</html>
`;
Deno.writeTextFileSync(join(stagedFiles.baseDir, kIndex), indexHtml);
}
if (!stagedFiles.files.includes(kIndex)) {
stagedFiles.files.push(kIndex);
}
stagedFiles.rootFile = kIndex;
return stagedFiles;
}
async function promptForSlug(
type: "document" | "site",
slugAvailable: (slug: string) => Promise<boolean>,
slug: string,
) {
const available = await slugAvailable(slug);
if (!available && type === "document") {
slug = slug + "-" + randomHex(4);
}
if (await slugAvailable(slug)) {
const kConfirmed = "confirmed";
const input = await Select.prompt({
indent: "",
message: `${typeName(type)} name:`,
options: [
{
name: slug,
value: kConfirmed,
},
{
name: "Use another name...",
value: "another",
},
],
});
if (input === kConfirmed) {
return slug;
}
}
let hint: string | undefined =
`The ${
typeName(type).toLowerCase()
} name is included within your published URL\n` +
` (e.g. https://username.quarto.pub/${slug}/)`;
while (true) {
const input = await Input.prompt({
indent: "",
message: `Publish with name:`,
hint,
transform: (slug: string) => gfmAutoIdentifier(slug, false),
validate: (slug: string) => {
if (slug.length === 0) {
return true;
} else if (slug.length < 2) {
return `${typeName(type)} name must be at least 2 characters.`;
} else {
return true;
}
},
});
hint = undefined;
if (input.length === 0) {
throw new Error();
}
if (await slugAvailable(input)) {
return input;
} else {
info(
colors.red(
` The specified name is already in use within your account.`,
),
);
}
}
}
function typeName(type: string) {
return capitalizeWord(type);
}