Skip to content

Commit 5fd5703

Browse files
Filmbostock
andauthored
note loaders (#1776)
Add source of pages, exported files, and modules, to the build manifest. --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent fb6566c commit 5fd5703

39 files changed

+164
-73
lines changed

src/build.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {copyFile, readFile, rm, stat, writeFile} from "node:fs/promises";
44
import {basename, dirname, extname, join} from "node:path/posix";
55
import type {Config} from "./config.js";
66
import {getDuckDBManifest} from "./duckdb.js";
7-
import {CliError} from "./error.js";
7+
import {CliError, enoent} from "./error.js";
88
import {getClientPath, prepareOutput} from "./files.js";
99
import {findModule, getModuleHash, readJavaScript} from "./javascript/module.js";
1010
import {transpileModule} from "./javascript/transpile.js";
@@ -54,7 +54,7 @@ export async function build(
5454
{config}: BuildOptions,
5555
effects: BuildEffects = new FileBuildEffects(config.output, join(config.root, ".observablehq", "cache"))
5656
): Promise<void> {
57-
const {root, loaders, duckdb} = config;
57+
const {root, loaders, title, duckdb} = config;
5858
Telemetry.record({event: "build", step: "start"});
5959

6060
// Prepare for build (such as by emptying the existing output root).
@@ -75,6 +75,25 @@ export async function build(
7575
let assetCount = 0;
7676
let pageCount = 0;
7777
const pagePaths = new Set<string>();
78+
79+
const buildManifest: BuildManifest = {
80+
...(title && {title}),
81+
config: {root},
82+
pages: [],
83+
modules: [],
84+
files: []
85+
};
86+
87+
// file is the serving path relative to the base (e.g., /foo)
88+
// path is the source file relative to the source root (e.g., /foo.md)
89+
const addToManifest = (type: string, file: string, {title, path}: {title?: string | null; path: string}) => {
90+
buildManifest[type].push({
91+
path: config.normalizePath(file),
92+
source: join("/", path), // TODO have route return path with leading slash?
93+
...(title != null && {title})
94+
});
95+
};
96+
7897
for await (const path of config.paths()) {
7998
effects.output.write(`${faint("load")} ${path} `);
8099
const start = performance.now();
@@ -91,6 +110,7 @@ export async function build(
91110
effects.output.write(`${faint("in")} ${(elapsed >= 100 ? yellow : faint)(`${elapsed}ms`)}\n`);
92111
outputs.set(path, {type: "module", resolvers});
93112
++assetCount;
113+
addToManifest("modules", path, module);
94114
continue;
95115
}
96116
}
@@ -99,6 +119,7 @@ export async function build(
99119
effects.output.write(`${faint("copy")} ${join(root, path)} ${faint("→")} `);
100120
const sourcePath = join(root, await file.load({useStale: true}, effects));
101121
await effects.copyFile(sourcePath, path);
122+
addToManifest("files", path, file);
102123
++assetCount;
103124
continue;
104125
}
@@ -209,7 +230,10 @@ export async function build(
209230
// Copy over referenced files, accumulating hashed aliases.
210231
for (const file of files) {
211232
effects.output.write(`${faint("copy")} ${join(root, file)} ${faint("→")} `);
212-
const sourcePath = join(root, await loaders.loadFile(join("/", file), {useStale: true}, effects));
233+
const path = join("/", file);
234+
const loader = loaders.find(path);
235+
if (!loader) throw enoent(path);
236+
const sourcePath = join(root, await loader.load({useStale: true}, effects));
213237
const contents = await readFile(sourcePath);
214238
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
215239
const alias = applyHash(join("/_file", file), hash);
@@ -338,15 +362,13 @@ export async function build(
338362
}
339363

340364
// Render pages!
341-
const buildManifest: BuildManifest = {pages: []};
342-
if (config.title) buildManifest.title = config.title;
343365
for (const [path, output] of outputs) {
344366
effects.output.write(`${faint("render")} ${path} ${faint("→")} `);
345367
if (output.type === "page") {
346368
const {page, resolvers} = output;
347369
const html = await renderPage(page, {...config, path, resolvers});
348370
await effects.writeFile(`${path}.html`, html);
349-
buildManifest.pages.push({path: config.normalizePath(path), title: page.title});
371+
addToManifest("pages", path, page);
350372
} else {
351373
const {resolvers} = output;
352374
const source = await renderModule(root, path, resolvers);
@@ -507,5 +529,8 @@ export class FileBuildEffects implements BuildEffects {
507529

508530
export interface BuildManifest {
509531
title?: string;
510-
pages: {path: string; title: string | null}[];
532+
config: {root: string};
533+
pages: {path: string; title?: string | null; source?: string}[];
534+
modules: {path: string; source?: string}[];
535+
files: {path: string; source?: string}[];
511536
}

src/loader.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,25 +73,15 @@ export class LoaderResolver {
7373
);
7474
}
7575

76-
/**
77-
* Loads the file at the specified path, returning a promise to the path to
78-
* the (possibly generated) file relative to the source root.
79-
*/
80-
async loadFile(path: string, options?: LoadOptions, effects?: LoadEffects): Promise<string> {
81-
const loader = this.find(path);
82-
if (!loader) throw enoent(path);
83-
return await loader.load(options, effects);
84-
}
85-
8676
/**
8777
* Loads the page at the specified path, returning a promise to the parsed
8878
* page object.
8979
*/
9080
async loadPage(path: string, options: LoadOptions & ParseOptions, effects?: LoadEffects): Promise<MarkdownPage> {
9181
const loader = this.findPage(path);
9282
if (!loader) throw enoent(path);
93-
const source = await readFile(join(this.root, await loader.load(options, effects)), "utf8");
94-
return parseMarkdown(source, {params: loader.params, ...options});
83+
const input = await readFile(join(this.root, await loader.load(options, effects)), "utf8");
84+
return parseMarkdown(input, {source: loader.path, params: loader.params, ...options});
9585
}
9686

9787
/**

src/markdown.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface MarkdownPage {
4040
data: FrontMatter;
4141
style: string | null;
4242
code: MarkdownCode[];
43+
path: string;
4344
params?: Params;
4445
}
4546

@@ -216,6 +217,7 @@ export interface ParseOptions {
216217
head?: Config["head"];
217218
header?: Config["header"];
218219
footer?: Config["footer"];
220+
source?: string;
219221
params?: Params;
220222
}
221223

@@ -242,7 +244,7 @@ export function createMarkdownIt({
242244
}
243245

244246
export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
245-
const {md, path, params} = options;
247+
const {md, path, source = path, params} = options;
246248
const {content, data} = readFrontMatter(input);
247249
const code: MarkdownCode[] = [];
248250
const context: ParseContext = {code, startLine: 0, currentLine: 0, path, params};
@@ -258,6 +260,7 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
258260
title,
259261
style: getStyle(data, options),
260262
code,
263+
path: source,
261264
params
262265
};
263266
}

src/observableApiClient.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs/promises";
2+
import type {BuildManifest} from "./build.js";
23
import type {ClackEffects} from "./clack.js";
34
import {CliError, HttpError, isApiError} from "./error.js";
45
import {formatByteSize} from "./format.js";
@@ -196,7 +197,7 @@ export class ObservableApiClient {
196197
});
197198
}
198199

199-
async postDeployUploaded(deployId: string, buildManifest: PostDeployUploadedRequest | null): Promise<DeployInfo> {
200+
async postDeployUploaded(deployId: string, buildManifest: BuildManifest | null): Promise<DeployInfo> {
200201
return await this._fetch<DeployInfo>(new URL(`/cli/deploy/${deployId}/uploaded`, this._apiOrigin), {
201202
method: "POST",
202203
headers: {"content-type": "application/json"},
@@ -317,10 +318,3 @@ export interface PostDeployManifestResponse {
317318
detail: string | null;
318319
}[];
319320
}
320-
321-
export interface PostDeployUploadedRequest {
322-
pages: {
323-
path: string;
324-
title: string | null;
325-
}[];
326-
}

src/preview.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ export class PreviewServer {
177177
}
178178
throw enoent(path);
179179
} else if (pathname.startsWith("/_file/")) {
180-
send(req, await loaders.loadFile(pathname.slice("/_file".length)), {root}).pipe(res);
180+
const path = pathname.slice("/_file".length);
181+
const loader = loaders.find(path);
182+
if (!loader) throw enoent(path);
183+
send(req, await loader.load(), {root}).pipe(res);
181184
} else {
182185
if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname);
183186

test/build-test.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,28 +124,67 @@ describe("build", () => {
124124
join(inputDir, "weather.md"),
125125
"# It's going to be ${weather}!" +
126126
"\n\n" +
127-
"```js\nconst weather = await FileAttachment('weather.txt').text(); display(weather);\n```"
127+
"```js\nconst weather = await FileAttachment('weather.txt').text(); display(weather);\n```" +
128+
"\n\n" +
129+
"```js\nconst generated = await FileAttachment('generated.txt').text(); display(generated);\n```" +
130+
"\n\n" +
131+
"```js\nconst internal = await FileAttachment('internal.txt').text(); display(internal);\n```" +
132+
"\n\n" +
133+
"```js\nconst thing = await FileAttachment('parameterized-thing.txt').text(); display(thing);\n```" +
134+
"\n\n" +
135+
"```js\nimport * from '/module-internal.js';\n```"
128136
);
129137
await mkdir(join(inputDir, "cities"));
130138
await writeFile(join(inputDir, "cities", "index.md"), "# Cities");
131139
await writeFile(join(inputDir, "cities", "portland.md"), "# Portland");
132-
// A non-page file that should not be included
140+
// exported files
133141
await writeFile(join(inputDir, "weather.txt"), "sunny");
142+
await writeFile(join(inputDir, "generated.txt.ts"), "process.stdout.write('hello');");
143+
await writeFile(join(inputDir, "parameterized-[page].txt.ts"), "process.stdout.write('hello');");
144+
// /module-exported.js, /module-internal.js
145+
await writeFile(join(inputDir, "module-[type].js"), "console.log(observable.params.type);");
146+
// not exported
147+
await writeFile(join(inputDir, "internal.txt.ts"), "process.stdout.write('hello');");
134148

135149
const outputDir = await mkdtemp(tmpPrefix + "output-");
136150
const cacheDir = await mkdtemp(tmpPrefix + "output-");
137151

138-
const config = normalizeConfig({root: inputDir, output: outputDir}, inputDir);
152+
const config = normalizeConfig(
153+
{
154+
root: inputDir,
155+
output: outputDir,
156+
dynamicPaths: [
157+
"/module-exported.js",
158+
"/weather.txt",
159+
"/generated.txt",
160+
"/parameterized-thing.txt",
161+
"/parameterized-[page].txt"
162+
]
163+
},
164+
inputDir
165+
);
139166
const effects = new LoggingBuildEffects(outputDir, cacheDir);
140167
await build({config}, effects);
141168
effects.buildManifest!.pages.sort((a, b) => ascending(a.path, b.path));
142-
assert.deepEqual(effects.buildManifest, {
169+
const {
170+
config: {root},
171+
...manifest
172+
} = effects.buildManifest!;
173+
assert.equal(typeof root, "string");
174+
assert.deepEqual(manifest, {
143175
pages: [
144-
{path: "/", title: "Hello, world!"},
145-
{path: "/cities/", title: "Cities"},
146-
{path: "/cities/portland", title: "Portland"},
147-
{path: "/weather", title: "It's going to be !"}
148-
]
176+
{path: "/", title: "Hello, world!", source: "/index.md"},
177+
{path: "/cities/", title: "Cities", source: "/cities/index.md"},
178+
{path: "/cities/portland", title: "Portland", source: "/cities/portland.md"},
179+
{path: "/weather", title: "It's going to be !", source: "/weather.md"}
180+
],
181+
files: [
182+
{path: "/weather.txt", source: "/weather.txt"},
183+
{path: "/generated.txt", source: "/generated.txt.ts"},
184+
{path: "/parameterized-thing.txt", source: "/parameterized-[page].txt.ts"},
185+
{path: "/parameterized-[page].txt", source: "/parameterized-[page].txt.ts"}
186+
],
187+
modules: [{path: "/module-exported.js", source: "/module-[type].js"}]
149188
});
150189

151190
await Promise.all([inputDir, cacheDir, outputDir].map((dir) => rm(dir, {recursive: true}))).catch(() => {});

test/deploy-test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {DeployEffects, DeployOptions} from "../src/deploy.js";
88
import {deploy, promptDeployTarget} from "../src/deploy.js";
99
import {CliError, isHttpError} from "../src/error.js";
1010
import {visitFiles} from "../src/files.js";
11-
import type {ObservableApiClientOptions, PostDeployUploadedRequest} from "../src/observableApiClient.js";
11+
import type {ObservableApiClientOptions} from "../src/observableApiClient.js";
1212
import type {GetCurrentUserResponse} from "../src/observableApiClient.js";
1313
import {ObservableApiClient} from "../src/observableApiClient.js";
1414
import type {DeployConfig} from "../src/observableApiConfig.js";
@@ -724,7 +724,7 @@ describe("deploy", () => {
724724

725725
it("includes a build manifest if one was generated", async () => {
726726
const deployId = "deploy456";
727-
let buildManifestPages: PostDeployUploadedRequest["pages"] | null = null;
727+
let buildManifestPages: BuildManifest["pages"] | null = null;
728728
getCurrentObservableApi()
729729
.handleGetCurrentUser()
730730
.handleGetProject(DEPLOY_CONFIG)
@@ -744,7 +744,12 @@ describe("deploy", () => {
744744
deployConfig: DEPLOY_CONFIG,
745745
fixedInputStatTime: new Date("2024-03-09"),
746746
fixedOutputStatTime: new Date("2024-03-10"),
747-
buildManifest: {pages: [{path: "/", title: "Build test case"}]}
747+
buildManifest: {
748+
config: {root: "src"},
749+
pages: [{path: "/", title: "Build test case"}],
750+
modules: [],
751+
files: []
752+
}
748753
});
749754
effects.clack.inputs = ["fix some bugs"]; // "what changed?"
750755
await deploy(TEST_OPTIONS, effects);

test/output/block-expression.md.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@
3636
},
3737
"mode": "inline"
3838
}
39-
]
39+
],
40+
"path": "block-expression.md"
4041
}

test/output/comment.md.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"data": {},
33
"title": null,
44
"style": null,
5-
"code": []
5+
"code": [],
6+
"path": "comment.md"
67
}

test/output/dollar-expression.md.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@
3636
},
3737
"mode": "inline"
3838
}
39-
]
39+
],
40+
"path": "dollar-expression.md"
4041
}

0 commit comments

Comments
 (0)