|
1 |
| -import {copyFile, mkdir, readFile, readdir, stat, writeFile} from "node:fs/promises"; |
2 |
| -import {basename, dirname, join, normalize} from "node:path"; |
| 1 | +import {access, constants, copyFile, mkdir, readFile, writeFile} from "node:fs/promises"; |
| 2 | +import {basename, dirname, join, normalize, relative} from "node:path"; |
| 3 | +import {cwd} from "node:process"; |
3 | 4 | import {fileURLToPath} from "node:url";
|
4 | 5 | import {parseArgs} from "node:util";
|
5 |
| -import {isNodeError} from "./error.js"; |
| 6 | +import {visitFiles, visitMarkdownFiles} from "./files.js"; |
6 | 7 | import {renderServerless} from "./render.js";
|
| 8 | +import {readPages} from "./navigation.js"; |
7 | 9 |
|
8 | 10 | const EXTRA_FILES = new Map([["node_modules/@observablehq/runtime/dist/runtime.js", "_observablehq/runtime.js"]]);
|
9 | 11 |
|
10 | 12 | async function build(context: CommandContext) {
|
11 |
| - const {root = "./docs", output = "dist", files} = context; |
12 |
| - |
13 |
| - const sourceRootDirectory = normalize(root); |
14 |
| - const outputDirectory = normalize(output); |
15 |
| - |
16 |
| - if (files.length === 0) { |
17 |
| - files.push(sourceRootDirectory); |
18 |
| - } |
19 |
| - |
20 |
| - const sources: { |
21 |
| - outputPath: string; |
22 |
| - sourcePath: string; |
23 |
| - content: string; |
24 |
| - }[] = []; |
| 13 | + const {sourceRoot, outputRoot} = context; |
25 | 14 |
|
26 | 15 | // Make sure all files are readable before starting to write output files.
|
27 |
| - await visitFiles(files, outputDirectory, sourceRootDirectory, async (sourcePath, outputPath) => { |
28 |
| - if (!sourcePath.endsWith(".md")) return; |
29 |
| - outputPath = outputPath.replace(/\.md$/, ".html"); |
30 |
| - try { |
31 |
| - const content = await readFile(sourcePath, "utf-8"); |
32 |
| - sources.push({sourcePath, outputPath, content}); |
33 |
| - } catch (error) { |
34 |
| - throw new Error(`Unable to read ${sourcePath}: ${isNodeError(error) ? error.message : "unknown error"}`); |
35 |
| - } |
36 |
| - }); |
| 16 | + for await (const sourceFile of visitMarkdownFiles(sourceRoot)) { |
| 17 | + await access(join(sourceRoot, sourceFile), constants.R_OK); |
| 18 | + } |
37 | 19 |
|
38 | 20 | // Render .md files, building a list of file attachments as we go.
|
39 |
| - const fileAttachments: {name: string; mimeType: string}[] = []; |
40 |
| - for (const {content, outputPath, sourcePath} of sources) { |
| 21 | + const pages = await readPages(sourceRoot); |
| 22 | + const files: string[] = []; |
| 23 | + for await (const sourceFile of visitMarkdownFiles(sourceRoot)) { |
| 24 | + const sourcePath = join(sourceRoot, sourceFile); |
| 25 | + const outputPath = join(outputRoot, join(dirname(sourceFile), basename(sourceFile, ".md") + ".html")); |
41 | 26 | console.log("render", sourcePath, "→", outputPath);
|
42 |
| - const render = renderServerless(content); |
43 |
| - fileAttachments.push(...render.files.map((f) => ({...f, sourcePath}))); |
44 |
| - const outputDirectory = outputPath.lastIndexOf("/") > 0 ? outputPath.slice(0, outputPath.lastIndexOf("/")) : null; |
45 |
| - if (outputDirectory) { |
46 |
| - try { |
47 |
| - await mkdir(outputDirectory, {recursive: true}); |
48 |
| - } catch (error) { |
49 |
| - throw new Error( |
50 |
| - `Unable to create output directory ${outputDirectory}: ${ |
51 |
| - isNodeError(error) ? error.message : "unknown error" |
52 |
| - }` |
53 |
| - ); |
54 |
| - } |
55 |
| - } |
| 27 | + const path = `/${join(dirname(sourceFile), basename(sourceFile, ".md"))}`; |
| 28 | + const render = renderServerless(await readFile(sourcePath, "utf-8"), {path, pages}); |
| 29 | + files.push(...render.files.map((f) => join(sourceFile, "..", f.name))); |
| 30 | + await prepareOutput(outputPath); |
56 | 31 | await writeFile(outputPath, render.html);
|
57 | 32 | }
|
58 | 33 |
|
59 |
| - // Copy over the ../public directory. |
60 |
| - const publicPath = join(dirname(fileURLToPath(import.meta.url)), "..", "public"); |
61 |
| - await visitFiles(publicPath, outputDirectory + "/_observablehq", publicPath, (sourcePath, outputPath) => { |
| 34 | + // Copy over the public directory. |
| 35 | + const publicRoot = join(dirname(relative(cwd(), fileURLToPath(import.meta.url))), "..", "public"); |
| 36 | + for await (const publicFile of visitFiles(publicRoot)) { |
| 37 | + const sourcePath = join(publicRoot, publicFile); |
| 38 | + const outputPath = join(outputRoot, "_observablehq", publicFile); |
62 | 39 | console.log("copy", sourcePath, "→", outputPath);
|
63 |
| - return copyFile(sourcePath, outputPath); |
64 |
| - }); |
| 40 | + await prepareOutput(outputPath); |
| 41 | + await copyFile(sourcePath, outputPath); |
| 42 | + } |
65 | 43 |
|
66 |
| - // Copy over the referenced files |
67 |
| - // TODO: This needs more work and consideration for nested directories. |
68 |
| - await visitFiles(files, outputDirectory + "/_file", sourceRootDirectory, async (sourcePath, outputPath) => { |
69 |
| - const sourceName = basename(sourcePath); |
70 |
| - if (fileAttachments.some((f) => f.name === sourceName)) { |
71 |
| - console.log("copy", sourcePath, "→", outputPath); |
72 |
| - return copyFile(sourcePath, outputPath); |
73 |
| - } |
74 |
| - }); |
| 44 | + // Copy over the referenced files. |
| 45 | + for (const file of files) { |
| 46 | + const sourcePath = join(sourceRoot, file); |
| 47 | + const outputPath = join(outputRoot, "_file", file); |
| 48 | + console.log("copy", sourcePath, "→", outputPath); |
| 49 | + await prepareOutput(outputPath); |
| 50 | + await copyFile(sourcePath, outputPath); |
| 51 | + } |
75 | 52 |
|
76 | 53 | // Copy over required distribution files from node_modules.
|
77 | 54 | // TODO: Note that this requires that the build command be run relative to the node_modules directory.
|
78 |
| - for (const [sourcePath, targetPath] of EXTRA_FILES) { |
79 |
| - const outputPath = join(outputDirectory, targetPath); |
| 55 | + for (const [sourcePath, targetFile] of EXTRA_FILES) { |
| 56 | + const outputPath = join(outputRoot, targetFile); |
80 | 57 | console.log("copy", sourcePath, "→", outputPath);
|
| 58 | + await prepareOutput(outputPath); |
81 | 59 | await copyFile(sourcePath, outputPath);
|
82 | 60 | }
|
83 | 61 | }
|
84 | 62 |
|
85 |
| -async function visitFiles( |
86 |
| - source: string | string[], |
87 |
| - output: string, |
88 |
| - root: string, |
89 |
| - visitor: (sourcePath: string, outputPath: string) => Promise<void> |
90 |
| -) { |
91 |
| - const sourceRootDirectory = normalize(root); |
92 |
| - const outputDirectory = normalize(output); |
93 |
| - |
94 |
| - const visited = new Set<number>(); |
95 |
| - const files: string[] = Array.isArray(source) ? source.map((file) => normalize(file)) : [normalize(source)]; |
96 |
| - |
97 |
| - for (const file of files) { |
98 |
| - const sourcePath = normalize(file); |
99 |
| - const status = await stat(sourcePath); |
100 |
| - if (status.isDirectory()) { |
101 |
| - if (visited.has(status.ino)) throw new Error("Circular directory structure with " + sourcePath); |
102 |
| - visited.add(status.ino); |
103 |
| - for (const entry of await readdir(sourcePath)) { |
104 |
| - files.push(join(sourcePath, entry)); |
105 |
| - } |
106 |
| - continue; |
107 |
| - } |
108 |
| - |
109 |
| - const subPath = sourcePath.startsWith(sourceRootDirectory + "/") |
110 |
| - ? sourcePath.slice(sourceRootDirectory.length + 1) |
111 |
| - : sourcePath; |
112 |
| - const outputPath = join(outputDirectory, subPath); |
113 |
| - |
114 |
| - const dest = outputPath.lastIndexOf("/") > 0 ? outputPath.slice(0, outputPath.lastIndexOf("/")) : null; |
115 |
| - if (dest) { |
116 |
| - try { |
117 |
| - await mkdir(dest, {recursive: true}); |
118 |
| - } catch (error) { |
119 |
| - throw new Error( |
120 |
| - `Unable to create output directory ${dest}: ${isNodeError(error) ? error.message : "unknown error"}` |
121 |
| - ); |
122 |
| - } |
123 |
| - } |
124 |
| - |
125 |
| - await visitor(sourcePath, outputPath); |
126 |
| - } |
| 63 | +async function prepareOutput(outputPath: string): Promise<void> { |
| 64 | + const outputDir = dirname(outputPath); |
| 65 | + if (outputDir === ".") return; |
| 66 | + await mkdir(outputDir, {recursive: true}); |
127 | 67 | }
|
128 | 68 |
|
129 |
| -// TODO We also need to copy over any referenced file attachments; these live in |
130 |
| -// ./dist/_file (currently; perhaps they should be somewhere else)? |
131 |
| - |
132 |
| -const USAGE = `Usage: observable build [--root dir] [--output dir] [files...]`; |
| 69 | +const USAGE = `Usage: observable build [--root dir] [--output dir]`; |
133 | 70 |
|
134 | 71 | interface CommandContext {
|
135 |
| - root?: string; |
136 |
| - output?: string; |
137 |
| - files: string[]; |
| 72 | + sourceRoot: string; |
| 73 | + outputRoot: string; |
138 | 74 | }
|
139 | 75 |
|
140 | 76 | function makeCommandContext(): CommandContext {
|
141 |
| - const {values, positionals} = parseArgs({ |
142 |
| - allowPositionals: true, |
| 77 | + const {values} = parseArgs({ |
143 | 78 | options: {
|
144 | 79 | root: {
|
145 | 80 | type: "string",
|
146 |
| - short: "r" |
| 81 | + short: "r", |
| 82 | + default: "docs" |
147 | 83 | },
|
148 | 84 | output: {
|
149 | 85 | type: "string",
|
150 |
| - short: "o" |
| 86 | + short: "o", |
| 87 | + default: "dist" |
151 | 88 | }
|
152 | 89 | }
|
153 | 90 | });
|
154 |
| - |
| 91 | + if (!values.root || !values.output) { |
| 92 | + console.error(USAGE); |
| 93 | + process.exit(1); |
| 94 | + } |
155 | 95 | return {
|
156 |
| - root: values.root, |
157 |
| - output: values.output, |
158 |
| - files: positionals |
| 96 | + sourceRoot: normalize(values.root), |
| 97 | + outputRoot: normalize(values.output) |
159 | 98 | };
|
160 | 99 | }
|
161 | 100 |
|
162 | 101 | await (async function () {
|
163 | 102 | const context = makeCommandContext();
|
164 |
| - if (!context.files.length && !context.root) { |
165 |
| - console.error(USAGE); |
166 |
| - process.exit(1); |
167 |
| - } |
168 | 103 | await build(context);
|
169 | 104 | process.exit(0);
|
170 | 105 | })();
|
0 commit comments