Skip to content

Commit

Permalink
note loaders (#1776)
Browse files Browse the repository at this point in the history
Add source of pages, exported files, and modules, to the build manifest.

---------

Co-authored-by: Mike Bostock <[email protected]>
  • Loading branch information
Fil and mbostock authored Nov 6, 2024
1 parent fb6566c commit 5fd5703
Show file tree
Hide file tree
Showing 39 changed files with 164 additions and 73 deletions.
39 changes: 32 additions & 7 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {copyFile, readFile, rm, stat, writeFile} from "node:fs/promises";
import {basename, dirname, extname, join} from "node:path/posix";
import type {Config} from "./config.js";
import {getDuckDBManifest} from "./duckdb.js";
import {CliError} from "./error.js";
import {CliError, enoent} from "./error.js";
import {getClientPath, prepareOutput} from "./files.js";
import {findModule, getModuleHash, readJavaScript} from "./javascript/module.js";
import {transpileModule} from "./javascript/transpile.js";
Expand Down Expand Up @@ -54,7 +54,7 @@ export async function build(
{config}: BuildOptions,
effects: BuildEffects = new FileBuildEffects(config.output, join(config.root, ".observablehq", "cache"))
): Promise<void> {
const {root, loaders, duckdb} = config;
const {root, loaders, title, duckdb} = config;
Telemetry.record({event: "build", step: "start"});

// Prepare for build (such as by emptying the existing output root).
Expand All @@ -75,6 +75,25 @@ export async function build(
let assetCount = 0;
let pageCount = 0;
const pagePaths = new Set<string>();

const buildManifest: BuildManifest = {
...(title && {title}),
config: {root},
pages: [],
modules: [],
files: []
};

// file is the serving path relative to the base (e.g., /foo)
// path is the source file relative to the source root (e.g., /foo.md)
const addToManifest = (type: string, file: string, {title, path}: {title?: string | null; path: string}) => {
buildManifest[type].push({
path: config.normalizePath(file),
source: join("/", path), // TODO have route return path with leading slash?
...(title != null && {title})
});
};

for await (const path of config.paths()) {
effects.output.write(`${faint("load")} ${path} `);
const start = performance.now();
Expand All @@ -91,6 +110,7 @@ export async function build(
effects.output.write(`${faint("in")} ${(elapsed >= 100 ? yellow : faint)(`${elapsed}ms`)}\n`);
outputs.set(path, {type: "module", resolvers});
++assetCount;
addToManifest("modules", path, module);
continue;
}
}
Expand All @@ -99,6 +119,7 @@ export async function build(
effects.output.write(`${faint("copy")} ${join(root, path)} ${faint("→")} `);
const sourcePath = join(root, await file.load({useStale: true}, effects));
await effects.copyFile(sourcePath, path);
addToManifest("files", path, file);
++assetCount;
continue;
}
Expand Down Expand Up @@ -209,7 +230,10 @@ export async function build(
// Copy over referenced files, accumulating hashed aliases.
for (const file of files) {
effects.output.write(`${faint("copy")} ${join(root, file)} ${faint("→")} `);
const sourcePath = join(root, await loaders.loadFile(join("/", file), {useStale: true}, effects));
const path = join("/", file);
const loader = loaders.find(path);
if (!loader) throw enoent(path);
const sourcePath = join(root, await loader.load({useStale: true}, effects));
const contents = await readFile(sourcePath);
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
const alias = applyHash(join("/_file", file), hash);
Expand Down Expand Up @@ -338,15 +362,13 @@ export async function build(
}

// Render pages!
const buildManifest: BuildManifest = {pages: []};
if (config.title) buildManifest.title = config.title;
for (const [path, output] of outputs) {
effects.output.write(`${faint("render")} ${path} ${faint("→")} `);
if (output.type === "page") {
const {page, resolvers} = output;
const html = await renderPage(page, {...config, path, resolvers});
await effects.writeFile(`${path}.html`, html);
buildManifest.pages.push({path: config.normalizePath(path), title: page.title});
addToManifest("pages", path, page);
} else {
const {resolvers} = output;
const source = await renderModule(root, path, resolvers);
Expand Down Expand Up @@ -507,5 +529,8 @@ export class FileBuildEffects implements BuildEffects {

export interface BuildManifest {
title?: string;
pages: {path: string; title: string | null}[];
config: {root: string};
pages: {path: string; title?: string | null; source?: string}[];
modules: {path: string; source?: string}[];
files: {path: string; source?: string}[];
}
14 changes: 2 additions & 12 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,15 @@ export class LoaderResolver {
);
}

/**
* Loads the file at the specified path, returning a promise to the path to
* the (possibly generated) file relative to the source root.
*/
async loadFile(path: string, options?: LoadOptions, effects?: LoadEffects): Promise<string> {
const loader = this.find(path);
if (!loader) throw enoent(path);
return await loader.load(options, effects);
}

/**
* Loads the page at the specified path, returning a promise to the parsed
* page object.
*/
async loadPage(path: string, options: LoadOptions & ParseOptions, effects?: LoadEffects): Promise<MarkdownPage> {
const loader = this.findPage(path);
if (!loader) throw enoent(path);
const source = await readFile(join(this.root, await loader.load(options, effects)), "utf8");
return parseMarkdown(source, {params: loader.params, ...options});
const input = await readFile(join(this.root, await loader.load(options, effects)), "utf8");
return parseMarkdown(input, {source: loader.path, params: loader.params, ...options});
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface MarkdownPage {
data: FrontMatter;
style: string | null;
code: MarkdownCode[];
path: string;
params?: Params;
}

Expand Down Expand Up @@ -216,6 +217,7 @@ export interface ParseOptions {
head?: Config["head"];
header?: Config["header"];
footer?: Config["footer"];
source?: string;
params?: Params;
}

Expand All @@ -242,7 +244,7 @@ export function createMarkdownIt({
}

export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
const {md, path, params} = options;
const {md, path, source = path, params} = options;
const {content, data} = readFrontMatter(input);
const code: MarkdownCode[] = [];
const context: ParseContext = {code, startLine: 0, currentLine: 0, path, params};
Expand All @@ -258,6 +260,7 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
title,
style: getStyle(data, options),
code,
path: source,
params
};
}
Expand Down
10 changes: 2 additions & 8 deletions src/observableApiClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import type {BuildManifest} from "./build.js";
import type {ClackEffects} from "./clack.js";
import {CliError, HttpError, isApiError} from "./error.js";
import {formatByteSize} from "./format.js";
Expand Down Expand Up @@ -196,7 +197,7 @@ export class ObservableApiClient {
});
}

async postDeployUploaded(deployId: string, buildManifest: PostDeployUploadedRequest | null): Promise<DeployInfo> {
async postDeployUploaded(deployId: string, buildManifest: BuildManifest | null): Promise<DeployInfo> {
return await this._fetch<DeployInfo>(new URL(`/cli/deploy/${deployId}/uploaded`, this._apiOrigin), {
method: "POST",
headers: {"content-type": "application/json"},
Expand Down Expand Up @@ -317,10 +318,3 @@ export interface PostDeployManifestResponse {
detail: string | null;
}[];
}

export interface PostDeployUploadedRequest {
pages: {
path: string;
title: string | null;
}[];
}
5 changes: 4 additions & 1 deletion src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ export class PreviewServer {
}
throw enoent(path);
} else if (pathname.startsWith("/_file/")) {
send(req, await loaders.loadFile(pathname.slice("/_file".length)), {root}).pipe(res);
const path = pathname.slice("/_file".length);
const loader = loaders.find(path);
if (!loader) throw enoent(path);
send(req, await loader.load(), {root}).pipe(res);
} else {
if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname);

Expand Down
57 changes: 48 additions & 9 deletions test/build-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,28 +124,67 @@ describe("build", () => {
join(inputDir, "weather.md"),
"# It's going to be ${weather}!" +
"\n\n" +
"```js\nconst weather = await FileAttachment('weather.txt').text(); display(weather);\n```"
"```js\nconst weather = await FileAttachment('weather.txt').text(); display(weather);\n```" +
"\n\n" +
"```js\nconst generated = await FileAttachment('generated.txt').text(); display(generated);\n```" +
"\n\n" +
"```js\nconst internal = await FileAttachment('internal.txt').text(); display(internal);\n```" +
"\n\n" +
"```js\nconst thing = await FileAttachment('parameterized-thing.txt').text(); display(thing);\n```" +
"\n\n" +
"```js\nimport * from '/module-internal.js';\n```"
);
await mkdir(join(inputDir, "cities"));
await writeFile(join(inputDir, "cities", "index.md"), "# Cities");
await writeFile(join(inputDir, "cities", "portland.md"), "# Portland");
// A non-page file that should not be included
// exported files
await writeFile(join(inputDir, "weather.txt"), "sunny");
await writeFile(join(inputDir, "generated.txt.ts"), "process.stdout.write('hello');");
await writeFile(join(inputDir, "parameterized-[page].txt.ts"), "process.stdout.write('hello');");
// /module-exported.js, /module-internal.js
await writeFile(join(inputDir, "module-[type].js"), "console.log(observable.params.type);");
// not exported
await writeFile(join(inputDir, "internal.txt.ts"), "process.stdout.write('hello');");

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

const config = normalizeConfig({root: inputDir, output: outputDir}, inputDir);
const config = normalizeConfig(
{
root: inputDir,
output: outputDir,
dynamicPaths: [
"/module-exported.js",
"/weather.txt",
"/generated.txt",
"/parameterized-thing.txt",
"/parameterized-[page].txt"
]
},
inputDir
);
const effects = new LoggingBuildEffects(outputDir, cacheDir);
await build({config}, effects);
effects.buildManifest!.pages.sort((a, b) => ascending(a.path, b.path));
assert.deepEqual(effects.buildManifest, {
const {
config: {root},
...manifest
} = effects.buildManifest!;
assert.equal(typeof root, "string");
assert.deepEqual(manifest, {
pages: [
{path: "/", title: "Hello, world!"},
{path: "/cities/", title: "Cities"},
{path: "/cities/portland", title: "Portland"},
{path: "/weather", title: "It's going to be !"}
]
{path: "/", title: "Hello, world!", source: "/index.md"},
{path: "/cities/", title: "Cities", source: "/cities/index.md"},
{path: "/cities/portland", title: "Portland", source: "/cities/portland.md"},
{path: "/weather", title: "It's going to be !", source: "/weather.md"}
],
files: [
{path: "/weather.txt", source: "/weather.txt"},
{path: "/generated.txt", source: "/generated.txt.ts"},
{path: "/parameterized-thing.txt", source: "/parameterized-[page].txt.ts"},
{path: "/parameterized-[page].txt", source: "/parameterized-[page].txt.ts"}
],
modules: [{path: "/module-exported.js", source: "/module-[type].js"}]
});

await Promise.all([inputDir, cacheDir, outputDir].map((dir) => rm(dir, {recursive: true}))).catch(() => {});
Expand Down
11 changes: 8 additions & 3 deletions test/deploy-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {DeployEffects, DeployOptions} from "../src/deploy.js";
import {deploy, promptDeployTarget} from "../src/deploy.js";
import {CliError, isHttpError} from "../src/error.js";
import {visitFiles} from "../src/files.js";
import type {ObservableApiClientOptions, PostDeployUploadedRequest} from "../src/observableApiClient.js";
import type {ObservableApiClientOptions} from "../src/observableApiClient.js";
import type {GetCurrentUserResponse} from "../src/observableApiClient.js";
import {ObservableApiClient} from "../src/observableApiClient.js";
import type {DeployConfig} from "../src/observableApiConfig.js";
Expand Down Expand Up @@ -724,7 +724,7 @@ describe("deploy", () => {

it("includes a build manifest if one was generated", async () => {
const deployId = "deploy456";
let buildManifestPages: PostDeployUploadedRequest["pages"] | null = null;
let buildManifestPages: BuildManifest["pages"] | null = null;
getCurrentObservableApi()
.handleGetCurrentUser()
.handleGetProject(DEPLOY_CONFIG)
Expand All @@ -744,7 +744,12 @@ describe("deploy", () => {
deployConfig: DEPLOY_CONFIG,
fixedInputStatTime: new Date("2024-03-09"),
fixedOutputStatTime: new Date("2024-03-10"),
buildManifest: {pages: [{path: "/", title: "Build test case"}]}
buildManifest: {
config: {root: "src"},
pages: [{path: "/", title: "Build test case"}],
modules: [],
files: []
}
});
effects.clack.inputs = ["fix some bugs"]; // "what changed?"
await deploy(TEST_OPTIONS, effects);
Expand Down
3 changes: 2 additions & 1 deletion test/output/block-expression.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@
},
"mode": "inline"
}
]
],
"path": "block-expression.md"
}
3 changes: 2 additions & 1 deletion test/output/comment.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"data": {},
"title": null,
"style": null,
"code": []
"code": [],
"path": "comment.md"
}
3 changes: 2 additions & 1 deletion test/output/dollar-expression.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@
},
"mode": "inline"
}
]
],
"path": "dollar-expression.md"
}
3 changes: 2 additions & 1 deletion test/output/dot-graphviz.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@
},
"mode": "block"
}
]
],
"path": "dot-graphviz.md"
}
3 changes: 2 additions & 1 deletion test/output/double-quote-expression.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
},
"mode": "inline"
}
]
],
"path": "double-quote-expression.md"
}
3 changes: 2 additions & 1 deletion test/output/embedded-expression.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@
},
"mode": "inline"
}
]
],
"path": "embedded-expression.md"
}
3 changes: 2 additions & 1 deletion test/output/escaped-expression.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,6 @@
},
"mode": "inline"
}
]
],
"path": "escaped-expression.md"
}
3 changes: 2 additions & 1 deletion test/output/fenced-code-options.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,5 +275,6 @@
},
"mode": "block"
}
]
],
"path": "fenced-code-options.md"
}
3 changes: 2 additions & 1 deletion test/output/fenced-code.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,6 @@
},
"mode": "block"
}
]
],
"path": "fenced-code.md"
}
3 changes: 2 additions & 1 deletion test/output/fetch-parent-dir.md.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,5 +245,6 @@
},
"mode": "block"
}
]
],
"path": "fetch-parent-dir.md"
}
Loading

0 comments on commit 5fd5703

Please sign in to comment.