From 3f949b5bf939da6bff9908167df8e91ec7fe5b9f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 25 Aug 2024 09:13:19 -0700 Subject: [PATCH 1/4] page loaders --- docs/[dir]/dynamic.md.js | 11 ++++ docs/dynamic.md.js | 1 + src/build.ts | 26 ++++---- src/dataloader.ts | 128 +++++++++++++++++++++++++-------------- src/preview.ts | 77 ++++++++++++----------- src/route.ts | 5 -- test/dataloaders-test.ts | 10 +-- 7 files changed, 150 insertions(+), 108 deletions(-) create mode 100644 docs/[dir]/dynamic.md.js create mode 100644 docs/dynamic.md.js diff --git a/docs/[dir]/dynamic.md.js b/docs/[dir]/dynamic.md.js new file mode 100644 index 000000000..a236de659 --- /dev/null +++ b/docs/[dir]/dynamic.md.js @@ -0,0 +1,11 @@ +import {parseArgs} from "node:util"; + +const {values} = parseArgs({ + options: { + dir: { + type: "string" + } + } +}); + +console.log(JSON.stringify(values)); diff --git a/docs/dynamic.md.js b/docs/dynamic.md.js new file mode 100644 index 000000000..577d6397d --- /dev/null +++ b/docs/dynamic.md.js @@ -0,0 +1 @@ +console.log(Date.now()); diff --git a/src/build.ts b/src/build.ts index e415dedfc..968c7c11b 100644 --- a/src/build.ts +++ b/src/build.ts @@ -17,7 +17,6 @@ import {getModuleResolver, getResolvers} from "./resolvers.js"; import {resolveImportPath, resolveStylesheetPath} from "./resolvers.js"; import {bundleStyles, rollupClient} from "./rollup.js"; import type {Params} from "./route.js"; -import {find} from "./route.js"; import {searchIndex} from "./search.js"; import {Telemetry} from "./telemetry.js"; import {tree} from "./tree.js"; @@ -75,9 +74,10 @@ export async function build( const globalImports = new Set(); // e.g., "/_observablehq/search.js" const stylesheets = new Set(); // e.g., "/style.css" for (const path of paths.map(normalizePagePath)) { - const found = find(root, `${path}.md`); - if (!found) throw new Error(`page not found: ${path}`); - const {path: sourceFile, params} = found; + const loader = loaders.find(`${path}.md`); + if (!loader) throw new Error(`page not found: ${path}`); + const {params} = loader; + const sourceFile = await loader.load(effects); const sourcePath = join(root, sourceFile); const options = {...config, params, path}; effects.output.write(`${faint("parse")} ${sourcePath} `); @@ -165,23 +165,19 @@ export async function build( // Copy over referenced files, accumulating hashed aliases. for (const file of files) { - let sourcePath: string; effects.output.write(`${faint("copy")} ${join(root, file)} ${faint("→")} `); const loader = loaders.find(join("/", file), {useStale: true}); if (!loader) { effects.logger.error(red("error: missing referenced file")); continue; } - if ("load" in loader) { - try { - sourcePath = join(root, await loader.load(effects)); - } catch (error) { - if (!isEnoent(error)) throw error; - effects.logger.error(red("error: missing referenced file")); - continue; - } - } else { - sourcePath = loader.path; + let sourcePath: string; + try { + sourcePath = join(root, await loader.load(effects)); + } catch (error) { + if (!isEnoent(error)) throw error; + effects.logger.error(red("error: missing referenced file")); + continue; } const contents = await readFile(sourcePath); const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8); diff --git a/src/dataloader.ts b/src/dataloader.ts index c9202517b..145740fe6 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -2,7 +2,7 @@ import {createHash} from "node:crypto"; import type {WriteStream} from "node:fs"; import {createReadStream, existsSync, statSync} from "node:fs"; import {open, readFile, rename, unlink} from "node:fs/promises"; -import {dirname, extname, join, relative} from "node:path/posix"; +import {dirname, extname, join} from "node:path/posix"; import {createGunzip} from "node:zlib"; import {spawn} from "cross-spawn"; import JSZip from "jszip"; @@ -46,6 +46,7 @@ const defaultEffects: LoadEffects = { export interface LoaderOptions { root: string; path: string; + params?: Params; targetPath: string; useStale: boolean; } @@ -70,7 +71,7 @@ export class LoaderResolver { * abort if we find a matching folder or reach the source root; for example, * if src/data exists, we won’t look for a src/data.zip. */ - find(targetPath: string, {useStale = false} = {}): Asset | Loader | undefined { + find(targetPath: string, {useStale = false} = {}): Loader | undefined { return this.findFile(targetPath, {useStale}) ?? this.findArchive(targetPath, {useStale}); } @@ -91,20 +92,21 @@ export class LoaderResolver { // - /[param1]/[param2]/file.csv.js // - /[param1]/[param2]/[param3].csv // - /[param1]/[param2]/[param3].csv.js - private findFile(targetPath: string, {useStale}): Asset | Loader | undefined { + private findFile(targetPath: string, {useStale}): Loader | undefined { const ext = extname(targetPath); const exts = [ext, ...Array.from(this.interpreters.keys(), (iext) => ext + iext)]; const found = route(this.root, targetPath.slice(0, -ext.length), exts); if (!found) return; const {path, params, ext: fext} = found; - const foundPath = join(this.root, path); - if (fext === ext) return {path: foundPath}; + if (fext === ext) return new StaticLoader({root: this.root, path, params}); + const commandPath = join(this.root, path); const [command, ...args] = this.interpreters.get(fext.slice(ext.length))!; - if (command != null) args.push(foundPath); + if (command != null) args.push(commandPath); return new CommandLoader({ - command: command ?? foundPath, + command: command ?? commandPath, args: params ? args.concat(defineParams(params)) : args, - path: foundPath, + path, + params, root: this.root, targetPath, useStale @@ -136,33 +138,35 @@ export class LoaderResolver { // - /[param].tgz // - /[param].zip.js // - /[param].tgz.js - private findArchive(targetPath: string, {useStale}): Asset | Loader | undefined { + private findArchive(targetPath: string, {useStale}): Loader | undefined { const exts = this.getArchiveExtensions(); for (let dir = dirname(targetPath), parent: string; (parent = dirname(dir)) !== dir; dir = parent) { const found = route(this.root, dir, exts); if (!found) continue; const {path, params, ext: fext} = found; - const foundPath = join(this.root, path); const inflatePath = targetPath.slice(dir.length + 1); // file.jpeg if (extractors.has(fext)) { const Extractor = extractors.get(fext)!; return new Extractor({ preload: async () => path, // /path/to.zip inflatePath, - path: foundPath, + path, + params, root: this.root, targetPath, // /path/to/file.jpg useStale }); } const iext = extname(fext); + const commandPath = join(this.root, path); const [command, ...args] = this.interpreters.get(iext)!; - if (command != null) args.push(foundPath); + if (command != null) args.push(commandPath); const eext = fext.slice(0, -iext.length); // .zip const loader = new CommandLoader({ - command: command ?? foundPath, + command: command ?? commandPath, args: params ? args.concat(defineParams(params)) : args, - path: foundPath, + path, + params, root: this.root, targetPath: dir + eext, // /path/to.zip useStale @@ -172,6 +176,7 @@ export class LoaderResolver { preload: async (options) => loader.load(options), // /path/to.zip.js inflatePath, path: loader.path, + params, root: this.root, targetPath, useStale @@ -186,14 +191,20 @@ export class LoaderResolver { return exts; } + /** + * Returns the path to watch, relative to the current working directory, for + * the specified source path, relative to the source root. + */ getWatchPath(path: string): string | undefined { const exactPath = join(this.root, path); if (existsSync(exactPath)) return exactPath; if (exactPath.endsWith(".js")) { const jsxPath = exactPath + "x"; if (existsSync(jsxPath)) return jsxPath; + return; // loaders aren’t supported for .js } - return this.find(path)?.path; + const foundPath = this.find(path)?.path; + if (foundPath) return join(this.root, foundPath); } watchFiles(path: string, watchPaths: Iterable, callback: (name: string) => void) { @@ -201,27 +212,26 @@ export class LoaderResolver { } /** - * Returns the path to the backing file during preview, which is the source - * file for the associated data loader if the file is generated by a loader. + * Returns the path to the backing file during preview, relative to the source + * root, which is the source file for the associated data loader if the file + * is generated by a loader. */ - private getSourceFilePath(name: string): string { - let path = name; + private getSourceFilePath(path: string): string { if (!existsSync(join(this.root, path))) { const loader = this.find(path); - if (loader) path = relative(this.root, loader.path); + if (loader) return loader.path; } return path; } /** - * Returns the path to the backing file during build, which is the cached - * output file if the file is generated by a loader. + * Returns the path to the backing file during build, relative to the source + * root, which is the cached output file if the file is generated by a loader. */ - private getOutputFilePath(name: string): string { - let path = name; + private getOutputFilePath(path: string): string { if (!existsSync(join(this.root, path))) { const loader = this.find(path); - if (loader) path = join(".observablehq", "cache", name); + if (loader) return join(".observablehq", "cache", path); // TODO Is this true for static files? } return path; } @@ -262,25 +272,52 @@ function defineParams(params: Params): string[] { .flatMap(([name, value]) => [`--${name}`, value]); } -/** Used by LoaderResolver.find to represent a static file resolution. */ -export interface Asset { - /** The path to the file relative to the current working directory. */ - path: string; -} - -export abstract class Loader { +export interface Loader { /** * The source root relative to the current working directory, such as src. */ readonly root: string; /** - * The path to the loader script or executable relative to the current working - * directory. This is exposed so that clients can check which file to watch to - * see if the loader is edited (and in which case it needs to be re-run). + * The path to the loader script or executable relative to the source root. + * This is exposed so that clients can check which file to watch to see if the + * loader is edited (and in which case it needs to be re-run). */ readonly path: string; + /** TODO */ + readonly params: Params | undefined; + + /** + * Runs this loader, returning the path to the generated output file relative + * to the source root; this is typically within the .observablehq/cache folder + * within the source root. + */ + load(effects?: LoadEffects): Promise; +} + +/** Used by LoaderResolver.find to represent a static file resolution. */ +class StaticLoader implements Loader { + readonly root: string; + readonly path: string; + readonly params: Params | undefined; + + constructor({root, path, params}: Omit) { + this.root = root; + this.path = path; + this.params = params; + } + + async load() { + return this.path; + } +} + +abstract class AbstractLoader implements Loader { + readonly root: string; + readonly path: string; + readonly params: Params | undefined; + /** * The path to the loader script’s output relative to the destination root. * This is where the loader’s output is served, but the loader generates the @@ -289,30 +326,27 @@ export abstract class Loader { readonly targetPath: string; /** - * Should the loader use a stale cache. true when building. + * Whether the loader should use a stale cache; true when building. */ readonly useStale?: boolean; - constructor({root, path, targetPath, useStale}: LoaderOptions) { + constructor({root, path, params, targetPath, useStale}: LoaderOptions) { this.root = root; this.path = path; + this.params = params; this.targetPath = targetPath; this.useStale = useStale; } - /** - * Runs this loader, returning the path to the generated output file relative - * to the source root; this is within the .observablehq/cache folder within - * the source root. - */ async load(effects = defaultEffects): Promise { + const loaderPath = join(this.root, this.path); const key = join(this.root, this.targetPath); let command = runningCommands.get(key); if (!command) { command = (async () => { const outputPath = join(".observablehq", "cache", this.targetPath); const cachePath = join(this.root, outputPath); - const loaderStat = await maybeStat(this.path); + const loaderStat = await maybeStat(loaderPath); const cacheStat = await maybeStat(cachePath); if (!cacheStat) effects.output.write(faint("[missing] ")); else if (cacheStat.mtimeMs < loaderStat!.mtimeMs) { @@ -344,7 +378,7 @@ export abstract class Loader { command.finally(() => runningCommands.delete(key)).catch(() => {}); runningCommands.set(key, command); } - effects.output.write(`${cyan("load")} ${this.path} ${faint("→")} `); + effects.output.write(`${cyan("load")} ${loaderPath} ${faint("→")} `); const start = performance.now(); command.then( (path) => { @@ -370,7 +404,7 @@ interface CommandLoaderOptions extends LoaderOptions { args: string[]; } -class CommandLoader extends Loader { +class CommandLoader extends AbstractLoader { /** * The command to run, such as "node" for a JavaScript loader, "tsx" for * TypeScript, and "sh" for a shell script. "noop" when we only need to @@ -408,7 +442,7 @@ interface ZipExtractorOptions extends LoaderOptions { inflatePath: string; } -class ZipExtractor extends Loader { +class ZipExtractor extends AbstractLoader { private readonly preload: Loader["load"]; private readonly inflatePath: string; @@ -433,7 +467,7 @@ interface TarExtractorOptions extends LoaderOptions { gunzip?: boolean; } -class TarExtractor extends Loader { +class TarExtractor extends AbstractLoader { private readonly preload: Loader["load"]; private readonly inflatePath: string; private readonly gunzip: boolean; diff --git a/src/preview.ts b/src/preview.ts index 692da2d26..29f689d61 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -16,7 +16,7 @@ import type {WebSocket} from "ws"; import {WebSocketServer} from "ws"; import type {Config} from "./config.js"; import {readConfig} from "./config.js"; -import type {LoaderResolver} from "./dataloader.js"; +import type {Loader, LoaderResolver} from "./dataloader.js"; import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js"; import {getClientPath} from "./files.js"; import type {FileWatchers} from "./fileWatchers.js"; @@ -32,7 +32,7 @@ import type {Resolvers} from "./resolvers.js"; import {getResolvers} from "./resolvers.js"; import {bundleStyles, rollupClient} from "./rollup.js"; import type {Params} from "./route.js"; -import {find, route} from "./route.js"; +import {route} from "./route.js"; import {searchIndex} from "./search.js"; import {Telemetry} from "./telemetry.js"; import {bold, faint, green, link} from "./tty.js"; @@ -163,16 +163,14 @@ export class PreviewServer { } else if (pathname.startsWith("/_file/")) { const loader = loaders.find(pathname.slice("/_file".length)); if (!loader) throw new HttpError(`Not found: ${pathname}`, 404); - if ("load" in loader) { - try { - send(req, await loader.load(), {root}).pipe(res); - } catch (error) { - if (!isEnoent(error)) throw error; - throw new HttpError(`Not found: ${pathname}`, 404); - } - } else { - send(req, loader.path).pipe(res); + let sourcePath: string; + try { + sourcePath = await loader.load(); + } catch (error) { + if (!isEnoent(error)) throw error; + throw new HttpError(`Not found: ${pathname}`, 404); } + send(req, sourcePath, {root}).pipe(res); } else { if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname); @@ -193,10 +191,16 @@ export class PreviewServer { // Lastly, serve the corresponding Markdown file, if it exists. // Anything else should 404; static files should be matched above. - const found = find(root, `${pathname}.md`); - if (!found) throw new HttpError("Not found", 404); - const {path: sourcePath, params} = found; - const options = {...config, params, path: pathname, preview: true}; + const loader = loaders.find(`${pathname}.md`); + if (!loader) throw new HttpError(`Not found: ${pathname}`, 404); + let sourcePath: string; + try { + sourcePath = await loader.load(); + } catch (error) { + if (!isEnoent(error)) throw error; + throw new HttpError(`Not found: ${pathname}`, 404); + } + const options = {...config, params: loader.params, path: pathname, preview: true}; const source = await readFile(join(root, sourcePath), "utf8"); const parse = parseMarkdown(source, options); const html = await renderPage(parse, options); @@ -291,9 +295,8 @@ interface HtmlPart { function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Promise) { let config: Config | null = null; - let sourcePath: string | null = null; + let loader: Loader | null = null; let path: string | null = null; - let params: Params | undefined | null = null; let hash: string | null = null; let html: HtmlPart[] | null = null; let code: Map | null = null; @@ -301,20 +304,20 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro let tables: Map | null = null; let stylesheets: string[] | null = null; let configWatcher: FSWatcher | null = null; - let markdownWatcher: FSWatcher | null = null; + let loaderWatcher: FSWatcher | null = null; let attachmentWatcher: FileWatchers | null = null; let emptyTimeout: ReturnType | null = null; console.log(faint("socket open"), req.url); async function watcher(event: WatchEventType, force = false) { - if (sourcePath === null || path === null || params === null || config === null) throw new Error("not initialized"); - const {loaders} = config; + if (loader === null || path === null || config === null) throw new Error("not initialized"); + const {root, loaders} = config; switch (event) { case "rename": { - markdownWatcher?.close(); + loaderWatcher?.close(); try { - markdownWatcher = watch(sourcePath, (event) => watcher(event)); + loaderWatcher = watch(join(root, loader.path), (event) => watcher(event)); } catch (error) { if (!isEnoent(error)) throw error; console.error(`file no longer exists: ${path}`); @@ -325,8 +328,9 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro break; } case "change": { + const sourcePath = join(root, await loader.load()); const source = await readFile(sourcePath, "utf8"); - const page = parseMarkdown(source, {path, params, ...config}); + const page = parseMarkdown(source, {path, params: loader.params, ...config}); // delay to avoid a possibly-empty file if (!force && page.body === "") { if (!emptyTimeout) { @@ -350,7 +354,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro const previousStylesheets = stylesheets!; hash = resolvers.hash; html = getHtml(page, resolvers); - code = getCode(page, resolvers, params); + code = getCode(page, resolvers, loader.params); files = getFiles(resolvers); tables = getTables(page); stylesheets = Array.from(resolvers.stylesheets, resolvers.resolveStylesheet); @@ -364,36 +368,35 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro hash: {previous: previousHash, current: hash} }); attachmentWatcher?.close(); - attachmentWatcher = await loaders.watchFiles(path, getWatchFiles(resolvers), () => watcher("change")); + attachmentWatcher = await loaders.watchFiles(sourcePath, getWatchFiles(resolvers), () => watcher("change")); break; } } } async function hello({path: initialPath, hash: initialHash}: {path: string; hash: string}): Promise { - if (markdownWatcher || configWatcher || attachmentWatcher) throw new Error("already watching"); + if (loaderWatcher || configWatcher || attachmentWatcher) throw new Error("already watching"); path = decodeURI(initialPath); if (!(path = normalize(path)).startsWith("/")) throw new Error(`Invalid path: ${initialPath}`); if (path.endsWith("/")) path += "index"; path = join(dirname(path), `${basename(path, ".html")}.md`); config = await configPromise; const {root, loaders, normalizePath} = config; - const found = find(root, path); - if (!found) throw new Error(`Page not found: ${path}`); - sourcePath = join(root, found.path); - params = found.params; + loader = loaders.find(path)!; // TODO fix non-null assertion + if (!loader) throw new Error(`Page not found: ${path}`); // TODO HttpError? + const sourcePath = join(root, await loader.load()); const source = await readFile(sourcePath, "utf8"); - const page = parseMarkdown(source, {path, params, ...config}); + const page = parseMarkdown(source, {path, params: loader.params, ...config}); const resolvers = await getResolvers(page, {root, path, loaders, normalizePath}); if (resolvers.hash !== initialHash) return void send({type: "reload"}); hash = resolvers.hash; html = getHtml(page, resolvers); - code = getCode(page, resolvers, params); + code = getCode(page, resolvers, loader.params); files = getFiles(resolvers); tables = getTables(page); stylesheets = Array.from(resolvers.stylesheets, resolvers.resolveStylesheet); - attachmentWatcher = await loaders.watchFiles(path, getWatchFiles(resolvers), () => watcher("change")); - markdownWatcher = watch(sourcePath, (event) => watcher(event)); + attachmentWatcher = await loaders.watchFiles(sourcePath, getWatchFiles(resolvers), () => watcher("change")); + loaderWatcher = watch(join(root, loader.path), (event) => watcher(event)); if (config.watchPath) configWatcher = watch(config.watchPath, () => send({type: "reload"})); } @@ -422,9 +425,9 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro attachmentWatcher.close(); attachmentWatcher = null; } - if (markdownWatcher) { - markdownWatcher.close(); - markdownWatcher = null; + if (loaderWatcher) { + loaderWatcher.close(); + loaderWatcher = null; } if (configWatcher) { configWatcher.close(); diff --git a/src/route.ts b/src/route.ts index dd00c638b..4800b1487 100644 --- a/src/route.ts +++ b/src/route.ts @@ -10,11 +10,6 @@ export function isParameterizedPath(path: string): boolean { return path.split("/").some((name) => /\[.+\]/.test(name)); } -export function find(root: string, path: string): RouteResult | undefined { - const ext = extname(path); - return route(root, path.slice(0, -ext.length), [ext]); -} - /** * Finds a parameterized file (dynamic route). * diff --git a/test/dataloaders-test.ts b/test/dataloaders-test.ts index d95321f4a..1b47b59fd 100644 --- a/test/dataloaders-test.ts +++ b/test/dataloaders-test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import {mkdir, readFile, rm, stat, unlink, utimes, writeFile} from "node:fs/promises"; import os from "node:os"; +import {join} from "node:path/posix"; import type {LoadEffects, Loader} from "../src/dataloader.js"; import {LoaderResolver} from "../src/dataloader.js"; @@ -64,11 +65,12 @@ describe("LoaderResolver.find(path, {useStale: true})", () => { } }; const loader = findLoader(loaders, "dataloaders/data1.txt"); + const loaderPath = join(loader.root, loader.path); // save the loader times. - const {atime, mtime} = await stat(loader.path); + const {atime, mtime} = await stat(loaderPath); // set the loader mtime to Dec. 1st, 2023. const time = new Date(2023, 11, 1); - await utimes(loader.path, atime, time); + await utimes(loaderPath, atime, time); // remove the cache set by another test (unless we it.only this test). try { await unlink("test/.observablehq/cache/dataloaders/data1.txt"); @@ -80,14 +82,14 @@ describe("LoaderResolver.find(path, {useStale: true})", () => { // run again (fresh) await loader.load(outputEffects); // touch the loader - await utimes(loader.path, atime, new Date(Date.now() + 100)); + await utimes(loaderPath, atime, new Date(Date.now() + 100)); // run it with useStale=true (using stale) const loader2 = findLoader(loaders, "dataloaders/data1.txt", {useStale: true}); await loader2.load(outputEffects); // run it with useStale=false (stale) await loader.load(outputEffects); // revert the loader to its original mtime - await utimes(loader.path, atime, mtime); + await utimes(loaderPath, atime, mtime); assert.deepStrictEqual( // eslint-disable-next-line no-control-regex out.map((l) => l.replaceAll(/\x1b\[[0-9]+m/g, "")), From 44751b68bc75fcfdb5fe37900f39cb765979cac6 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 25 Aug 2024 13:53:09 -0700 Subject: [PATCH 2/4] remove generate-themes script --- .gitignore | 2 -- docs/theme/generate-themes.ts | 25 ------------------------- observablehq.config.ts | 13 ++++++++++++- package.json | 7 +++---- src/build.ts | 2 +- test/output/build/params/bar/index.html | 2 +- 6 files changed, 17 insertions(+), 34 deletions(-) delete mode 100644 docs/theme/generate-themes.ts diff --git a/.gitignore b/.gitignore index 84617d1de..d54c8bae1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ /coverage/ /dist/ /docs/.observablehq/dist/ -/docs/theme/*.md -/docs/themes.md /test/build/ /test/output/**/*-changed.* /test/output/build/**/*-changed/ diff --git a/docs/theme/generate-themes.ts b/docs/theme/generate-themes.ts deleted file mode 100644 index 4f3a7e700..000000000 --- a/docs/theme/generate-themes.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {writeFile} from "node:fs/promises"; -import {faint} from "../../src/tty.js"; -import renderIndex, {themes} from "../themes.md.js"; -import renderTheme from "./[theme].md.js"; - -async function generateFile(path: string, contents: string): Promise { - console.log(`${faint("generating")} ${path}`); - await writeFile(path, contents); -} - -await generateFile("./docs/themes.md", renderIndex()); - -for (const theme of themes.light) { - await generateFile(`./docs/theme/${theme}.md`, renderTheme(theme)); -} -for (const theme of themes.dark) { - await generateFile(`./docs/theme/${theme}.md`, renderTheme(theme)); -} - -await generateFile("./docs/theme/light.md", renderTheme("light")); -await generateFile("./docs/theme/light-alt.md", renderTheme("[light, alt]")); -await generateFile("./docs/theme/dark.md", renderTheme("dark")); -await generateFile("./docs/theme/dark-alt.md", renderTheme("[dark, alt]")); -await generateFile("./docs/theme/wide.md", renderTheme("wide")); -await generateFile("./docs/theme/dashboard.md", renderTheme("dashboard")); diff --git a/observablehq.config.ts b/observablehq.config.ts index 7bd2ff7ea..77567ced7 100644 --- a/observablehq.config.ts +++ b/observablehq.config.ts @@ -2,6 +2,7 @@ import {existsSync} from "node:fs"; import {readFile, readdir, stat} from "node:fs/promises"; import {join} from "node:path/posix"; import {formatPrefix} from "d3-format"; +import {themes} from "./docs/themes.md.ts"; let stargazers_count: number; try { @@ -86,7 +87,17 @@ export default { {name: "Converting notebooks", path: "/convert"}, {name: "Contributing", path: "/contributing", pager: false} ], - paths: ["/foo/index", "/bar/index"], + paths: [ + "/theme/dark", + "/theme/dark-alt", + "/theme/dashboard", + "/theme/light", + "/theme/light-alt", + "/theme/wide", + "/themes", + ...themes.dark.map((theme) => `/theme/${theme}`), + ...themes.light.map((theme) => `/theme/${theme}`) + ], base: "/framework", globalStylesheets: [ "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Spline+Sans+Mono:ital,wght@0,300..700;1,300..700&display=swap" diff --git a/package.json b/package.json index bd4c369c6..4468d27bc 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,9 @@ "observable": "dist/bin/observable.js" }, "scripts": { - "dev": "rimraf --glob docs/themes.md docs/theme/*.md && (tsx watch docs/theme/generate-themes.ts & tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open)", - "docs:themes": "rimraf --glob docs/themes.md docs/theme/*.md && tsx docs/theme/generate-themes.ts", - "docs:build": "yarn docs:themes && rimraf docs/.observablehq/dist && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build", - "docs:deploy": "yarn docs:themes && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy", + "dev": "tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open", + "docs:build": "rimraf docs/.observablehq/dist && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build", + "docs:deploy": "tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy", "build": "rimraf dist && node build.js --outdir=dist --outbase=src \"src/**/*.{ts,js,css}\" --ignore \"**/*.d.ts\"", "test": "concurrently npm:test:mocha npm:test:tsc npm:test:lint npm:test:prettier", "test:coverage": "c8 --check-coverage --lines 80 --per-file yarn test:mocha", diff --git a/src/build.ts b/src/build.ts index 968c7c11b..de2f32516 100644 --- a/src/build.ts +++ b/src/build.ts @@ -88,7 +88,7 @@ export async function build( effects.logger.log(faint("(skipped)")); continue; } - const resolvers = await getResolvers(page, {path: sourceFile, ...config}); + const resolvers = await getResolvers(page, {path, ...config}); const elapsed = Math.floor(performance.now() - start); for (const f of resolvers.assets) files.add(resolvePath(path, f)); for (const f of resolvers.files) files.add(resolvePath(path, f)); diff --git a/test/output/build/params/bar/index.html b/test/output/build/params/bar/index.html index cdff97649..42d85de87 100644 --- a/test/output/build/params/bar/index.html +++ b/test/output/build/params/bar/index.html @@ -35,7 +35,7 @@
    - +
From 39a58d9eb4a48773108dbcab1ced692b06551262 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 25 Aug 2024 14:39:32 -0700 Subject: [PATCH 3/4] remove obsolete comment --- src/dataloader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataloader.ts b/src/dataloader.ts index 145740fe6..b7ad56a1b 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -231,7 +231,7 @@ export class LoaderResolver { private getOutputFilePath(path: string): string { if (!existsSync(join(this.root, path))) { const loader = this.find(path); - if (loader) return join(".observablehq", "cache", path); // TODO Is this true for static files? + if (loader) return join(".observablehq", "cache", path); } return path; } From 6139567584003c91c4a75805b5c69b917f40baa6 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 25 Aug 2024 14:42:21 -0700 Subject: [PATCH 4/4] fix watchFiles path --- src/preview.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/preview.ts b/src/preview.ts index 29f689d61..3bac464e7 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -368,7 +368,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro hash: {previous: previousHash, current: hash} }); attachmentWatcher?.close(); - attachmentWatcher = await loaders.watchFiles(sourcePath, getWatchFiles(resolvers), () => watcher("change")); + attachmentWatcher = await loaders.watchFiles(path, getWatchFiles(resolvers), () => watcher("change")); break; } } @@ -395,7 +395,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro files = getFiles(resolvers); tables = getTables(page); stylesheets = Array.from(resolvers.stylesheets, resolvers.resolveStylesheet); - attachmentWatcher = await loaders.watchFiles(sourcePath, getWatchFiles(resolvers), () => watcher("change")); + attachmentWatcher = await loaders.watchFiles(path, getWatchFiles(resolvers), () => watcher("change")); loaderWatcher = watch(join(root, loader.path), (event) => watcher(event)); if (config.watchPath) configWatcher = watch(config.watchPath, () => send({type: "reload"})); }