Skip to content

Commit 3f949b5

Browse files
committed
page loaders
1 parent cdef02c commit 3f949b5

File tree

7 files changed

+150
-108
lines changed

7 files changed

+150
-108
lines changed

docs/[dir]/dynamic.md.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {parseArgs} from "node:util";
2+
3+
const {values} = parseArgs({
4+
options: {
5+
dir: {
6+
type: "string"
7+
}
8+
}
9+
});
10+
11+
console.log(JSON.stringify(values));

docs/dynamic.md.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log(Date.now());

src/build.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {getModuleResolver, getResolvers} from "./resolvers.js";
1717
import {resolveImportPath, resolveStylesheetPath} from "./resolvers.js";
1818
import {bundleStyles, rollupClient} from "./rollup.js";
1919
import type {Params} from "./route.js";
20-
import {find} from "./route.js";
2120
import {searchIndex} from "./search.js";
2221
import {Telemetry} from "./telemetry.js";
2322
import {tree} from "./tree.js";
@@ -75,9 +74,10 @@ export async function build(
7574
const globalImports = new Set<string>(); // e.g., "/_observablehq/search.js"
7675
const stylesheets = new Set<string>(); // e.g., "/style.css"
7776
for (const path of paths.map(normalizePagePath)) {
78-
const found = find(root, `${path}.md`);
79-
if (!found) throw new Error(`page not found: ${path}`);
80-
const {path: sourceFile, params} = found;
77+
const loader = loaders.find(`${path}.md`);
78+
if (!loader) throw new Error(`page not found: ${path}`);
79+
const {params} = loader;
80+
const sourceFile = await loader.load(effects);
8181
const sourcePath = join(root, sourceFile);
8282
const options = {...config, params, path};
8383
effects.output.write(`${faint("parse")} ${sourcePath} `);
@@ -165,23 +165,19 @@ export async function build(
165165

166166
// Copy over referenced files, accumulating hashed aliases.
167167
for (const file of files) {
168-
let sourcePath: string;
169168
effects.output.write(`${faint("copy")} ${join(root, file)} ${faint("→")} `);
170169
const loader = loaders.find(join("/", file), {useStale: true});
171170
if (!loader) {
172171
effects.logger.error(red("error: missing referenced file"));
173172
continue;
174173
}
175-
if ("load" in loader) {
176-
try {
177-
sourcePath = join(root, await loader.load(effects));
178-
} catch (error) {
179-
if (!isEnoent(error)) throw error;
180-
effects.logger.error(red("error: missing referenced file"));
181-
continue;
182-
}
183-
} else {
184-
sourcePath = loader.path;
174+
let sourcePath: string;
175+
try {
176+
sourcePath = join(root, await loader.load(effects));
177+
} catch (error) {
178+
if (!isEnoent(error)) throw error;
179+
effects.logger.error(red("error: missing referenced file"));
180+
continue;
185181
}
186182
const contents = await readFile(sourcePath);
187183
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);

src/dataloader.ts

Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {createHash} from "node:crypto";
22
import type {WriteStream} from "node:fs";
33
import {createReadStream, existsSync, statSync} from "node:fs";
44
import {open, readFile, rename, unlink} from "node:fs/promises";
5-
import {dirname, extname, join, relative} from "node:path/posix";
5+
import {dirname, extname, join} from "node:path/posix";
66
import {createGunzip} from "node:zlib";
77
import {spawn} from "cross-spawn";
88
import JSZip from "jszip";
@@ -46,6 +46,7 @@ const defaultEffects: LoadEffects = {
4646
export interface LoaderOptions {
4747
root: string;
4848
path: string;
49+
params?: Params;
4950
targetPath: string;
5051
useStale: boolean;
5152
}
@@ -70,7 +71,7 @@ export class LoaderResolver {
7071
* abort if we find a matching folder or reach the source root; for example,
7172
* if src/data exists, we won’t look for a src/data.zip.
7273
*/
73-
find(targetPath: string, {useStale = false} = {}): Asset | Loader | undefined {
74+
find(targetPath: string, {useStale = false} = {}): Loader | undefined {
7475
return this.findFile(targetPath, {useStale}) ?? this.findArchive(targetPath, {useStale});
7576
}
7677

@@ -91,20 +92,21 @@ export class LoaderResolver {
9192
// - /[param1]/[param2]/file.csv.js
9293
// - /[param1]/[param2]/[param3].csv
9394
// - /[param1]/[param2]/[param3].csv.js
94-
private findFile(targetPath: string, {useStale}): Asset | Loader | undefined {
95+
private findFile(targetPath: string, {useStale}): Loader | undefined {
9596
const ext = extname(targetPath);
9697
const exts = [ext, ...Array.from(this.interpreters.keys(), (iext) => ext + iext)];
9798
const found = route(this.root, targetPath.slice(0, -ext.length), exts);
9899
if (!found) return;
99100
const {path, params, ext: fext} = found;
100-
const foundPath = join(this.root, path);
101-
if (fext === ext) return {path: foundPath};
101+
if (fext === ext) return new StaticLoader({root: this.root, path, params});
102+
const commandPath = join(this.root, path);
102103
const [command, ...args] = this.interpreters.get(fext.slice(ext.length))!;
103-
if (command != null) args.push(foundPath);
104+
if (command != null) args.push(commandPath);
104105
return new CommandLoader({
105-
command: command ?? foundPath,
106+
command: command ?? commandPath,
106107
args: params ? args.concat(defineParams(params)) : args,
107-
path: foundPath,
108+
path,
109+
params,
108110
root: this.root,
109111
targetPath,
110112
useStale
@@ -136,33 +138,35 @@ export class LoaderResolver {
136138
// - /[param].tgz
137139
// - /[param].zip.js
138140
// - /[param].tgz.js
139-
private findArchive(targetPath: string, {useStale}): Asset | Loader | undefined {
141+
private findArchive(targetPath: string, {useStale}): Loader | undefined {
140142
const exts = this.getArchiveExtensions();
141143
for (let dir = dirname(targetPath), parent: string; (parent = dirname(dir)) !== dir; dir = parent) {
142144
const found = route(this.root, dir, exts);
143145
if (!found) continue;
144146
const {path, params, ext: fext} = found;
145-
const foundPath = join(this.root, path);
146147
const inflatePath = targetPath.slice(dir.length + 1); // file.jpeg
147148
if (extractors.has(fext)) {
148149
const Extractor = extractors.get(fext)!;
149150
return new Extractor({
150151
preload: async () => path, // /path/to.zip
151152
inflatePath,
152-
path: foundPath,
153+
path,
154+
params,
153155
root: this.root,
154156
targetPath, // /path/to/file.jpg
155157
useStale
156158
});
157159
}
158160
const iext = extname(fext);
161+
const commandPath = join(this.root, path);
159162
const [command, ...args] = this.interpreters.get(iext)!;
160-
if (command != null) args.push(foundPath);
163+
if (command != null) args.push(commandPath);
161164
const eext = fext.slice(0, -iext.length); // .zip
162165
const loader = new CommandLoader({
163-
command: command ?? foundPath,
166+
command: command ?? commandPath,
164167
args: params ? args.concat(defineParams(params)) : args,
165-
path: foundPath,
168+
path,
169+
params,
166170
root: this.root,
167171
targetPath: dir + eext, // /path/to.zip
168172
useStale
@@ -172,6 +176,7 @@ export class LoaderResolver {
172176
preload: async (options) => loader.load(options), // /path/to.zip.js
173177
inflatePath,
174178
path: loader.path,
179+
params,
175180
root: this.root,
176181
targetPath,
177182
useStale
@@ -186,42 +191,47 @@ export class LoaderResolver {
186191
return exts;
187192
}
188193

194+
/**
195+
* Returns the path to watch, relative to the current working directory, for
196+
* the specified source path, relative to the source root.
197+
*/
189198
getWatchPath(path: string): string | undefined {
190199
const exactPath = join(this.root, path);
191200
if (existsSync(exactPath)) return exactPath;
192201
if (exactPath.endsWith(".js")) {
193202
const jsxPath = exactPath + "x";
194203
if (existsSync(jsxPath)) return jsxPath;
204+
return; // loaders aren’t supported for .js
195205
}
196-
return this.find(path)?.path;
206+
const foundPath = this.find(path)?.path;
207+
if (foundPath) return join(this.root, foundPath);
197208
}
198209

199210
watchFiles(path: string, watchPaths: Iterable<string>, callback: (name: string) => void) {
200211
return FileWatchers.of(this, path, watchPaths, callback);
201212
}
202213

203214
/**
204-
* Returns the path to the backing file during preview, which is the source
205-
* file for the associated data loader if the file is generated by a loader.
215+
* Returns the path to the backing file during preview, relative to the source
216+
* root, which is the source file for the associated data loader if the file
217+
* is generated by a loader.
206218
*/
207-
private getSourceFilePath(name: string): string {
208-
let path = name;
219+
private getSourceFilePath(path: string): string {
209220
if (!existsSync(join(this.root, path))) {
210221
const loader = this.find(path);
211-
if (loader) path = relative(this.root, loader.path);
222+
if (loader) return loader.path;
212223
}
213224
return path;
214225
}
215226

216227
/**
217-
* Returns the path to the backing file during build, which is the cached
218-
* output file if the file is generated by a loader.
228+
* Returns the path to the backing file during build, relative to the source
229+
* root, which is the cached output file if the file is generated by a loader.
219230
*/
220-
private getOutputFilePath(name: string): string {
221-
let path = name;
231+
private getOutputFilePath(path: string): string {
222232
if (!existsSync(join(this.root, path))) {
223233
const loader = this.find(path);
224-
if (loader) path = join(".observablehq", "cache", name);
234+
if (loader) return join(".observablehq", "cache", path); // TODO Is this true for static files?
225235
}
226236
return path;
227237
}
@@ -262,25 +272,52 @@ function defineParams(params: Params): string[] {
262272
.flatMap(([name, value]) => [`--${name}`, value]);
263273
}
264274

265-
/** Used by LoaderResolver.find to represent a static file resolution. */
266-
export interface Asset {
267-
/** The path to the file relative to the current working directory. */
268-
path: string;
269-
}
270-
271-
export abstract class Loader {
275+
export interface Loader {
272276
/**
273277
* The source root relative to the current working directory, such as src.
274278
*/
275279
readonly root: string;
276280

277281
/**
278-
* The path to the loader script or executable relative to the current working
279-
* directory. This is exposed so that clients can check which file to watch to
280-
* see if the loader is edited (and in which case it needs to be re-run).
282+
* The path to the loader script or executable relative to the source root.
283+
* This is exposed so that clients can check which file to watch to see if the
284+
* loader is edited (and in which case it needs to be re-run).
281285
*/
282286
readonly path: string;
283287

288+
/** TODO */
289+
readonly params: Params | undefined;
290+
291+
/**
292+
* Runs this loader, returning the path to the generated output file relative
293+
* to the source root; this is typically within the .observablehq/cache folder
294+
* within the source root.
295+
*/
296+
load(effects?: LoadEffects): Promise<string>;
297+
}
298+
299+
/** Used by LoaderResolver.find to represent a static file resolution. */
300+
class StaticLoader implements Loader {
301+
readonly root: string;
302+
readonly path: string;
303+
readonly params: Params | undefined;
304+
305+
constructor({root, path, params}: Omit<LoaderOptions, "targetPath" | "useStale">) {
306+
this.root = root;
307+
this.path = path;
308+
this.params = params;
309+
}
310+
311+
async load() {
312+
return this.path;
313+
}
314+
}
315+
316+
abstract class AbstractLoader implements Loader {
317+
readonly root: string;
318+
readonly path: string;
319+
readonly params: Params | undefined;
320+
284321
/**
285322
* The path to the loader script’s output relative to the destination root.
286323
* This is where the loader’s output is served, but the loader generates the
@@ -289,30 +326,27 @@ export abstract class Loader {
289326
readonly targetPath: string;
290327

291328
/**
292-
* Should the loader use a stale cache. true when building.
329+
* Whether the loader should use a stale cache; true when building.
293330
*/
294331
readonly useStale?: boolean;
295332

296-
constructor({root, path, targetPath, useStale}: LoaderOptions) {
333+
constructor({root, path, params, targetPath, useStale}: LoaderOptions) {
297334
this.root = root;
298335
this.path = path;
336+
this.params = params;
299337
this.targetPath = targetPath;
300338
this.useStale = useStale;
301339
}
302340

303-
/**
304-
* Runs this loader, returning the path to the generated output file relative
305-
* to the source root; this is within the .observablehq/cache folder within
306-
* the source root.
307-
*/
308341
async load(effects = defaultEffects): Promise<string> {
342+
const loaderPath = join(this.root, this.path);
309343
const key = join(this.root, this.targetPath);
310344
let command = runningCommands.get(key);
311345
if (!command) {
312346
command = (async () => {
313347
const outputPath = join(".observablehq", "cache", this.targetPath);
314348
const cachePath = join(this.root, outputPath);
315-
const loaderStat = await maybeStat(this.path);
349+
const loaderStat = await maybeStat(loaderPath);
316350
const cacheStat = await maybeStat(cachePath);
317351
if (!cacheStat) effects.output.write(faint("[missing] "));
318352
else if (cacheStat.mtimeMs < loaderStat!.mtimeMs) {
@@ -344,7 +378,7 @@ export abstract class Loader {
344378
command.finally(() => runningCommands.delete(key)).catch(() => {});
345379
runningCommands.set(key, command);
346380
}
347-
effects.output.write(`${cyan("load")} ${this.path} ${faint("→")} `);
381+
effects.output.write(`${cyan("load")} ${loaderPath} ${faint("→")} `);
348382
const start = performance.now();
349383
command.then(
350384
(path) => {
@@ -370,7 +404,7 @@ interface CommandLoaderOptions extends LoaderOptions {
370404
args: string[];
371405
}
372406

373-
class CommandLoader extends Loader {
407+
class CommandLoader extends AbstractLoader {
374408
/**
375409
* The command to run, such as "node" for a JavaScript loader, "tsx" for
376410
* TypeScript, and "sh" for a shell script. "noop" when we only need to
@@ -408,7 +442,7 @@ interface ZipExtractorOptions extends LoaderOptions {
408442
inflatePath: string;
409443
}
410444

411-
class ZipExtractor extends Loader {
445+
class ZipExtractor extends AbstractLoader {
412446
private readonly preload: Loader["load"];
413447
private readonly inflatePath: string;
414448

@@ -433,7 +467,7 @@ interface TarExtractorOptions extends LoaderOptions {
433467
gunzip?: boolean;
434468
}
435469

436-
class TarExtractor extends Loader {
470+
class TarExtractor extends AbstractLoader {
437471
private readonly preload: Loader["load"];
438472
private readonly inflatePath: string;
439473
private readonly gunzip: boolean;

0 commit comments

Comments
 (0)