Skip to content

Commit f7e0ce6

Browse files
committed
visitFiles; build sidebar
1 parent 3cb2e17 commit f7e0ce6

File tree

6 files changed

+116
-146
lines changed

6 files changed

+116
-146
lines changed

public/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ main {
103103
#observablehq-sidebar {
104104
display: initial;
105105
}
106-
#observablehq-center {
106+
#observablehq-center.observablehq--sidebar {
107107
padding-left: calc(240px + 2rem);
108108
}
109109
}

src/build.ts

Lines changed: 55 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,170 +1,105 @@
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";
34
import {fileURLToPath} from "node:url";
45
import {parseArgs} from "node:util";
5-
import {isNodeError} from "./error.js";
6+
import {visitFiles, visitMarkdownFiles} from "./files.js";
67
import {renderServerless} from "./render.js";
8+
import {readPages} from "./navigation.js";
79

810
const EXTRA_FILES = new Map([["node_modules/@observablehq/runtime/dist/runtime.js", "_observablehq/runtime.js"]]);
911

1012
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;
2514

2615
// 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+
}
3719

3820
// 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"));
4126
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);
5631
await writeFile(outputPath, render.html);
5732
}
5833

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);
6239
console.log("copy", sourcePath, "→", outputPath);
63-
return copyFile(sourcePath, outputPath);
64-
});
40+
await prepareOutput(outputPath);
41+
await copyFile(sourcePath, outputPath);
42+
}
6543

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+
}
7552

7653
// Copy over required distribution files from node_modules.
7754
// 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);
8057
console.log("copy", sourcePath, "→", outputPath);
58+
await prepareOutput(outputPath);
8159
await copyFile(sourcePath, outputPath);
8260
}
8361
}
8462

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});
12767
}
12868

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]`;
13370

13471
interface CommandContext {
135-
root?: string;
136-
output?: string;
137-
files: string[];
72+
sourceRoot: string;
73+
outputRoot: string;
13874
}
13975

14076
function makeCommandContext(): CommandContext {
141-
const {values, positionals} = parseArgs({
142-
allowPositionals: true,
77+
const {values} = parseArgs({
14378
options: {
14479
root: {
14580
type: "string",
146-
short: "r"
81+
short: "r",
82+
default: "docs"
14783
},
14884
output: {
14985
type: "string",
150-
short: "o"
86+
short: "o",
87+
default: "dist"
15188
}
15289
}
15390
});
154-
91+
if (!values.root || !values.output) {
92+
console.error(USAGE);
93+
process.exit(1);
94+
}
15595
return {
156-
root: values.root,
157-
output: values.output,
158-
files: positionals
96+
sourceRoot: normalize(values.root),
97+
outputRoot: normalize(values.output)
15998
};
16099
}
161100

162101
await (async function () {
163102
const context = makeCommandContext();
164-
if (!context.files.length && !context.root) {
165-
console.error(USAGE);
166-
process.exit(1);
167-
}
168103
await build(context);
169104
process.exit(0);
170105
})();

src/files.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {readdir, stat} from "node:fs/promises";
2+
import {extname, join, normalize, relative} from "node:path";
3+
4+
export async function* visitMarkdownFiles(root: string): AsyncGenerator<string> {
5+
for await (const file of visitFiles(root)) {
6+
if (extname(file) !== ".md") continue;
7+
yield file;
8+
}
9+
}
10+
11+
export async function* visitFiles(root: string): AsyncGenerator<string> {
12+
const visited = new Set<number>();
13+
const queue: string[] = [(root = normalize(root))];
14+
for (const path of queue) {
15+
const status = await stat(path);
16+
if (status.isDirectory()) {
17+
if (visited.has(status.ino)) throw new Error(`Circular directory: ${path}`);
18+
visited.add(status.ino);
19+
for (const entry of await readdir(path)) {
20+
queue.push(join(path, entry));
21+
}
22+
} else {
23+
yield relative(root, path);
24+
}
25+
}
26+
}

src/navigation.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {readFile} from "node:fs/promises";
2+
import {basename, dirname, extname, join} from "node:path";
3+
import {isNodeError} from "./error.js";
4+
import {visitFiles} from "./files.js";
5+
import {parseMarkdown, type ParseResult} from "./markdown.js";
6+
import {type RenderOptions} from "./render.js";
7+
8+
// TODO Global configuration file? Watcher?
9+
export async function readPages(root: string): Promise<NonNullable<RenderOptions["pages"]>> {
10+
const pages: RenderOptions["pages"] = [];
11+
for await (const file of visitFiles(root)) {
12+
if (extname(file) !== ".md") continue;
13+
let parsed: ParseResult;
14+
try {
15+
parsed = parseMarkdown(await readFile(join(root, file), "utf-8"));
16+
} catch (error) {
17+
if (!isNodeError(error) || error.code !== "ENOENT") throw error; // internal error
18+
continue;
19+
}
20+
const name = basename(file, ".md");
21+
const page = {path: `/${join(dirname(file), name)}`, name: parsed.title ?? "Untitled"};
22+
if (name === "index") pages.unshift(page);
23+
else pages.push(page);
24+
}
25+
return pages;
26+
}

src/preview.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {watch, type FSWatcher} from "node:fs";
2-
import {readFile, readdir, stat} from "node:fs/promises";
2+
import {readFile, stat} from "node:fs/promises";
33
import type {IncomingMessage, RequestListener} from "node:http";
44
import {createServer} from "node:http";
55
import {basename, dirname, extname, join, normalize} from "node:path";
@@ -9,8 +9,8 @@ import send from "send";
99
import {WebSocketServer, type WebSocket} from "ws";
1010
import {HttpError, isHttpError, isNodeError} from "./error.js";
1111
import {computeHash} from "./hash.js";
12-
import {type ParseResult, parseMarkdown} from "./markdown.js";
13-
import {type RenderOptions, renderPreview} from "./render.js";
12+
import {readPages} from "./navigation.js";
13+
import {renderPreview} from "./render.js";
1414

1515
const DEFAULT_ROOT = "docs";
1616

@@ -76,7 +76,7 @@ class Server {
7676
// Otherwise, serve the corresponding Markdown file, if it exists.
7777
// Anything else should 404; static files should be matched above.
7878
try {
79-
const pages = await this._readPages(); // TODO cache
79+
const pages = await readPages(this.root); // TODO cache? watcher?
8080
res.end(renderPreview(await readFile(path + ".md", "utf-8"), {path: pathname, pages}).html);
8181
} catch (error) {
8282
if (!isNodeError(error) || error.code !== "ENOENT") throw error; // internal error
@@ -91,24 +91,6 @@ class Server {
9191
}
9292
};
9393

94-
async _readPages() {
95-
const pages: RenderOptions["pages"] = [];
96-
for (const file of await readdir(this.root)) {
97-
if (extname(file) !== ".md") continue;
98-
let parsed: ParseResult;
99-
try {
100-
parsed = parseMarkdown(await readFile(join(this.root, file), "utf-8"));
101-
} catch (error) {
102-
if (!isNodeError(error) || error.code !== "ENOENT") throw error; // internal error
103-
continue;
104-
}
105-
const page = {path: `/${basename(file, ".md")}`, name: parsed.title ?? "Untitled"};
106-
if (page.path === "/index") pages.unshift(page);
107-
else pages.push(page);
108-
}
109-
return pages;
110-
}
111-
11294
_handleConnection = (socket: WebSocket, req: IncomingMessage) => {
11395
if (req.url === "/_observablehq") {
11496
handleWatch(socket, this.root);

src/render.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import {computeHash} from "./hash.js";
12
import type {ParseResult} from "./markdown.js";
23
import {parseMarkdown} from "./markdown.js";
3-
import {computeHash} from "./hash.js";
44

55
export interface Render {
66
html: string;
@@ -27,6 +27,7 @@ type RenderInternalOptions =
2727
| {preview: true; hash: string}; // preview
2828

2929
function render(parseResult: ParseResult, {path, pages, preview, hash}: RenderOptions & RenderInternalOptions): string {
30+
const showSidebar = pages && pages.length > 1;
3031
return `<!DOCTYPE html>
3132
<meta charset="utf-8">
3233
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
@@ -46,7 +47,7 @@ ${JSON.stringify(parseResult.data)}
4647
: ""
4748
}
4849
${
49-
pages
50+
showSidebar
5051
? `<nav id="observablehq-sidebar">
5152
<ol>${pages
5253
?.map(
@@ -60,7 +61,7 @@ ${
6061
</nav>
6162
`
6263
: ""
63-
}<div id="observablehq-center">
64+
}<div id="observablehq-center"${showSidebar ? ` class="observablehq--sidebar"` : ""}>
6465
<main>
6566
${parseResult.html}</main>
6667
</div>

0 commit comments

Comments
 (0)