diff --git a/docs/getting-started.md b/docs/getting-started.md index ca2b1a871..0d3a6e374 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -267,7 +267,7 @@ display(1 + 2); ``` ```` -To see the new page in the sidebar, you must restart the preview server. In the terminal, use Control-C (⌃C) to kill the preview server. Then use up arrow (↑) to re-run the command to start the preview server (`npm run dev` or `yarn dev`). Lastly, reload your browser. A bit of rigamarole, but you won’t have to do it often… 😓 Upvote #645 and #646 if you’d like this to be better. +To see the new page in the sidebar, reload the page. If you click on the **Weather report** link in the sidebar, it’ll take you to , where you should see: diff --git a/src/bin/observable.ts b/src/bin/observable.ts index 877cc6de0..84426b080 100755 --- a/src/bin/observable.ts +++ b/src/bin/observable.ts @@ -179,7 +179,8 @@ try { const {config, root, host, port, open} = values; await import("../preview.js").then(async (preview) => preview.preview({ - config: await readConfig(config, root), + config, + root, hostname: host!, port: port === undefined ? undefined : +port, open diff --git a/src/build.ts b/src/build.ts index 52fdc7697..602fd637e 100644 --- a/src/build.ts +++ b/src/build.ts @@ -52,7 +52,7 @@ export async function build( // Make sure all files are readable before starting to write output files. let pageCount = 0; - for await (const sourceFile of visitMarkdownFiles(root)) { + for (const sourceFile of visitMarkdownFiles(root)) { await access(join(root, sourceFile), constants.R_OK); pageCount++; } @@ -65,7 +65,7 @@ export async function build( const localImports = new Set(); const globalImports = new Set(); const stylesheets = new Set(); - for await (const sourceFile of visitMarkdownFiles(root)) { + for (const sourceFile of visitMarkdownFiles(root)) { const sourcePath = join(root, sourceFile); const path = join("/", dirname(sourceFile), basename(sourceFile, ".md")); const options = {path, ...config}; diff --git a/src/config.ts b/src/config.ts index b5d4be342..66c8ce20d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ -import {existsSync} from "node:fs"; -import {readFile} from "node:fs/promises"; +import {existsSync, readFileSync} from "node:fs"; +import {stat} from "node:fs/promises"; import op from "node:path"; import {basename, dirname, join} from "node:path/posix"; import {cwd} from "node:process"; @@ -67,25 +67,32 @@ function resolveConfig(configPath: string, root = "."): string { return op.join(cwd(), root, configPath); } +// By using the modification time of the config, we ensure that we pick up any +// changes to the config on reload. +async function importConfig(path: string): Promise { + const {mtimeMs} = await stat(path); + return (await import(`${pathToFileURL(path).href}?${mtimeMs}`)).default; +} + export async function readConfig(configPath?: string, root?: string): Promise { if (configPath === undefined) return readDefaultConfig(root); - return normalizeConfig((await import(pathToFileURL(resolveConfig(configPath, root)).href)).default, root); + return normalizeConfig(await importConfig(resolveConfig(configPath, root)), root); } export async function readDefaultConfig(root?: string): Promise { const jsPath = resolveConfig("observablehq.config.js", root); - if (existsSync(jsPath)) return normalizeConfig((await import(pathToFileURL(jsPath).href)).default, root); + if (existsSync(jsPath)) return normalizeConfig(await importConfig(jsPath), root); const tsPath = resolveConfig("observablehq.config.ts", root); if (!existsSync(tsPath)) return normalizeConfig(undefined, root); await import("tsx/esm"); // lazy tsx - return normalizeConfig((await import(pathToFileURL(tsPath).href)).default, root); + return normalizeConfig(await importConfig(tsPath), root); } -async function readPages(root: string, md: MarkdownIt): Promise { +function readPages(root: string, md: MarkdownIt): Page[] { const pages: Page[] = []; - for await (const file of visitMarkdownFiles(root)) { + for (const file of visitMarkdownFiles(root)) { if (file === "index.md" || file === "404.md") continue; - const source = await readFile(join(root, file), "utf8"); + const source = readFileSync(join(root, file), "utf8"); const parsed = parseMarkdown(source, {path: file, md}); if (parsed?.data?.draft) continue; const name = basename(file, ".md"); @@ -102,7 +109,7 @@ export function setCurrentDate(date = new Date()): void { currentDate = date; } -export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Promise { +export function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Config { let { root = defaultRoot, output = "dist", @@ -127,10 +134,10 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro else if (style !== undefined) style = {path: String(style)}; else style = {theme: (theme = normalizeTheme(theme))}; const md = createMarkdownIt(spec); - let {title, pages = await readPages(root, md), pager = true, toc = true} = spec; + let {title, pages, pager = true, toc = true} = spec; if (title !== undefined) title = String(title); - pages = Array.from(pages, normalizePageOrSection); - sidebar = sidebar === undefined ? pages.length > 0 : Boolean(sidebar); + if (pages !== undefined) pages = Array.from(pages, normalizePageOrSection); + if (sidebar !== undefined) sidebar = Boolean(sidebar); pager = Boolean(pager); scripts = Array.from(scripts, normalizeScript); head = String(head); @@ -140,7 +147,7 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null; search = Boolean(search); interpreters = normalizeInterpreters(interpreters); - return { + const config = { root, output, base, @@ -159,6 +166,9 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro md, loaders: new LoaderResolver({root, interpreters}) }; + if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md)}); + if (sidebar === undefined) Object.defineProperty(config, "sidebar", {get: () => config.pages.length > 0}); + return config; } function normalizeBase(base: any): string { diff --git a/src/deploy.ts b/src/deploy.ts index 19601cfe2..961ae5227 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -58,7 +58,7 @@ export interface DeployEffects extends ConfigEffects, TtyEffects, AuthEffects { logger: Logger; input: NodeJS.ReadableStream; output: NodeJS.WritableStream; - visitFiles: (root: string) => AsyncGenerator; + visitFiles: (root: string) => Generator; stat: (path: string) => Promise; } diff --git a/src/files.ts b/src/files.ts index 432f39d04..3298444a9 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,6 +1,6 @@ import type {Stats} from "node:fs"; -import {existsSync} from "node:fs"; -import {mkdir, readdir, stat} from "node:fs/promises"; +import {existsSync, readdirSync, statSync} from "node:fs"; +import {mkdir, stat} from "node:fs/promises"; import op from "node:path"; import {extname, join, normalize, relative, sep} from "node:path/posix"; import {cwd} from "node:process"; @@ -41,23 +41,23 @@ export function getStylePath(entry: string): string { } /** Yields every Markdown (.md) file within the given root, recursively. */ -export async function* visitMarkdownFiles(root: string): AsyncGenerator { - for await (const file of visitFiles(root)) { +export function* visitMarkdownFiles(root: string): Generator { + for (const file of visitFiles(root)) { if (extname(file) !== ".md") continue; yield file; } } /** Yields every file within the given root, recursively. */ -export async function* visitFiles(root: string): AsyncGenerator { +export function* visitFiles(root: string): Generator { const visited = new Set(); const queue: string[] = [(root = normalize(root))]; for (const path of queue) { - const status = await stat(path); + const status = statSync(path); if (status.isDirectory()) { if (visited.has(status.ino)) continue; // circular symlink visited.add(status.ino); - for (const entry of await readdir(path)) { + for (const entry of readdirSync(path)) { queue.push(join(path, entry)); } } else { diff --git a/src/preview.ts b/src/preview.ts index 903013e1a..75c4f7113 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -14,6 +14,7 @@ import send from "send"; import type {WebSocket} from "ws"; import {WebSocketServer} from "ws"; import type {Config} from "./config.js"; +import {readConfig} from "./config.js"; import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js"; import {getClientPath} from "./files.js"; import type {FileWatchers} from "./fileWatchers.js"; @@ -32,7 +33,8 @@ import {Telemetry} from "./telemetry.js"; import {bold, faint, green, link} from "./tty.js"; export interface PreviewOptions { - config: Config; + config?: string; + root?: string; hostname: string; open?: boolean; port?: number; @@ -44,13 +46,25 @@ export async function preview(options: PreviewOptions): Promise { } export class PreviewServer { - private readonly _config: Config; + private readonly _config: string | undefined; + private readonly _root: string | undefined; private readonly _server: ReturnType; private readonly _socketServer: WebSocketServer; private readonly _verbose: boolean; - private constructor({config, server, verbose}: {config: Config; server: Server; verbose: boolean}) { + private constructor({ + config, + root, + server, + verbose + }: { + config?: string; + root?: string; + server: Server; + verbose: boolean; + }) { this._config = config; + this._root = root; this._verbose = verbose; this._server = server; this._server.on("request", this._handleRequest); @@ -86,8 +100,12 @@ export class PreviewServer { return new PreviewServer({server, verbose, ...options}); } + async _readConfig() { + return readConfig(this._config, this._root); + } + _handleRequest: RequestListener = async (req, res) => { - const config = this._config; + const config = await this._readConfig(); const {root, loaders} = config; if (this._verbose) console.log(faint(req.method!), req.url); try { @@ -207,7 +225,7 @@ export class PreviewServer { _handleConnection = async (socket: WebSocket, req: IncomingMessage) => { if (req.url === "/_observablehq") { - handleWatch(socket, req, this._config); + handleWatch(socket, req, await this._readConfig()); } else { socket.close(); } diff --git a/src/search.ts b/src/search.ts index 6f23e3f1d..593008830 100644 --- a/src/search.ts +++ b/src/search.ts @@ -40,7 +40,7 @@ export async function searchIndex(config: Config, effects = defaultEffects): Pro // Index the pages const index = new MiniSearch(indexOptions); - for await (const file of visitMarkdownFiles(root)) { + for (const file of visitMarkdownFiles(root)) { const sourcePath = join(root, file); const source = await readFile(sourcePath, "utf8"); const path = `/${join(dirname(file), basename(file, ".md"))}`; diff --git a/test/config-test.ts b/test/config-test.ts index 6f1ca7068..85b0c8313 100644 --- a/test/config-test.ts +++ b/test/config-test.ts @@ -63,16 +63,16 @@ describe("readConfig(undefined, root)", () => { describe("normalizeConfig(spec, root)", () => { const root = "test/input/build/config"; - it("coerces the title to a string", async () => { - assert.strictEqual((await config({title: 42, pages: []}, root)).title, "42"); - assert.strictEqual((await config({title: null, pages: []}, root)).title, "null"); + it("coerces the title to a string", () => { + assert.strictEqual(config({title: 42, pages: []}, root).title, "42"); + assert.strictEqual(config({title: null, pages: []}, root).title, "null"); }); - it("considers the title optional", async () => { - assert.strictEqual((await config({pages: []}, root)).title, undefined); - assert.strictEqual((await config({title: undefined, pages: []}, root)).title, undefined); + it("considers the title optional", () => { + assert.strictEqual(config({pages: []}, root).title, undefined); + assert.strictEqual(config({title: undefined, pages: []}, root).title, undefined); }); - it("populates default pages", async () => { - assert.deepStrictEqual((await config({}, root)).pages, [ + it("populates default pages", () => { + assert.deepStrictEqual(config({}, root).pages, [ {name: "One", path: "/one"}, {name: "H1: Section", path: "/toc-override"}, {name: "H1: Section", path: "/toc"}, @@ -80,10 +80,10 @@ describe("normalizeConfig(spec, root)", () => { {name: "Two", path: "/sub/two"} ]); }); - it("coerces pages to an array", async () => { - assert.deepStrictEqual((await config({pages: new Set()}, root)).pages, []); + it("coerces pages to an array", () => { + assert.deepStrictEqual(config({pages: new Set()}, root).pages, []); }); - it("coerces and normalizes page paths", async () => { + it("coerces and normalizes page paths", () => { const inpages = [ {name: 42, path: true}, {name: null, path: {toString: () => "yes"}}, @@ -98,13 +98,13 @@ describe("normalizeConfig(spec, root)", () => { {name: "Index.html", path: "/foo/index"}, {name: "Page.html", path: "/foo"} ]; - assert.deepStrictEqual((await config({pages: inpages}, root)).pages, outpages); + assert.deepStrictEqual(config({pages: inpages}, root).pages, outpages); }); - it("allows external page paths", async () => { + it("allows external page paths", () => { const pages = [{name: "Example.com", path: "https://example.com"}]; - assert.deepStrictEqual((await config({pages}, root)).pages, pages); + assert.deepStrictEqual(config({pages}, root).pages, pages); }); - it("allows page paths to have query strings and anchor fragments", async () => { + it("allows page paths to have query strings and anchor fragments", () => { const inpages = [ {name: "Anchor fragment on index", path: "/test/index#foo=bar"}, {name: "Anchor fragment on index.html", path: "/test/index.html#foo=bar"}, @@ -129,51 +129,45 @@ describe("normalizeConfig(spec, root)", () => { {name: "Query string on slash", path: "/test/index?foo=bar"}, {name: "Query string", path: "/test?foo=bar"} ]; - assert.deepStrictEqual((await config({pages: inpages}, root)).pages, outpages); + assert.deepStrictEqual(config({pages: inpages}, root).pages, outpages); }); - it("coerces sections", async () => { + it("coerces sections", () => { const inpages = [{name: 42, pages: new Set([{name: null, path: {toString: () => "yes"}}])}]; const outpages = [{name: "42", open: true, pages: [{name: "null", path: "/yes"}]}]; - assert.deepStrictEqual((await config({pages: inpages}, root)).pages, outpages); + assert.deepStrictEqual(config({pages: inpages}, root).pages, outpages); }); - it("coerces toc", async () => { - assert.deepStrictEqual((await config({pages: [], toc: {}}, root)).toc, {label: "Contents", show: true}); - assert.deepStrictEqual((await config({pages: [], toc: {label: null}}, root)).toc, {label: "null", show: true}); + it("coerces toc", () => { + assert.deepStrictEqual(config({pages: [], toc: {}}, root).toc, {label: "Contents", show: true}); + assert.deepStrictEqual(config({pages: [], toc: {label: null}}, root).toc, {label: "null", show: true}); }); - it("populates default toc", async () => { - assert.deepStrictEqual((await config({pages: []}, root)).toc, {label: "Contents", show: true}); + it("populates default toc", () => { + assert.deepStrictEqual(config({pages: []}, root).toc, {label: "Contents", show: true}); }); - it("promotes boolean toc to toc.show", async () => { - assert.deepStrictEqual((await config({pages: [], toc: true}, root)).toc, {label: "Contents", show: true}); - assert.deepStrictEqual((await config({pages: [], toc: false}, root)).toc, {label: "Contents", show: false}); + it("promotes boolean toc to toc.show", () => { + assert.deepStrictEqual(config({pages: [], toc: true}, root).toc, {label: "Contents", show: true}); + assert.deepStrictEqual(config({pages: [], toc: false}, root).toc, {label: "Contents", show: false}); }); - it("coerces pager", async () => { - assert.strictEqual((await config({pages: [], pager: 0}, root)).pager, false); - assert.strictEqual((await config({pages: [], pager: 1}, root)).pager, true); - assert.strictEqual((await config({pages: [], pager: ""}, root)).pager, false); - assert.strictEqual((await config({pages: [], pager: "0"}, root)).pager, true); + it("coerces pager", () => { + assert.strictEqual(config({pages: [], pager: 0}, root).pager, false); + assert.strictEqual(config({pages: [], pager: 1}, root).pager, true); + assert.strictEqual(config({pages: [], pager: ""}, root).pager, false); + assert.strictEqual(config({pages: [], pager: "0"}, root).pager, true); }); - it("populates default pager", async () => { - assert.strictEqual((await config({pages: []}, root)).pager, true); + it("populates default pager", () => { + assert.strictEqual(config({pages: []}, root).pager, true); }); describe("deploy", () => { - it("considers deploy optional", async () => { - assert.strictEqual((await config({pages: []}, root)).deploy, null); + it("considers deploy optional", () => { + assert.strictEqual(config({pages: []}, root).deploy, null); }); - it("coerces workspace", async () => { - assert.strictEqual( - (await config({pages: [], deploy: {workspace: 538, project: "bi"}}, root)).deploy?.workspace, - "538" - ); + it("coerces workspace", () => { + assert.strictEqual(config({pages: [], deploy: {workspace: 538, project: "bi"}}, root).deploy?.workspace, "538"); }); - it("strips leading @ from workspace", async () => { - assert.strictEqual((await config({pages: [], deploy: {workspace: "@acme"}}, root)).deploy?.workspace, "acme"); + it("strips leading @ from workspace", () => { + assert.strictEqual(config({pages: [], deploy: {workspace: "@acme"}}, root).deploy?.workspace, "acme"); }); - it("coerces project", async () => { - assert.strictEqual( - (await config({pages: [], deploy: {workspace: "adams", project: 42}}, root)).deploy?.project, - "42" - ); + it("coerces project", () => { + assert.strictEqual(config({pages: [], deploy: {workspace: "adams", project: 42}}, root).deploy?.project, "42"); }); }); }); @@ -181,7 +175,7 @@ describe("normalizeConfig(spec, root)", () => { describe("mergeToc(spec, toc)", () => { const root = "test/input/build/config"; it("merges page- and project-level toc config", async () => { - const toc = (await config({pages: [], toc: true}, root)).toc; + const toc = config({pages: [], toc: true}, root).toc; assert.deepStrictEqual(mergeToc({show: false}, toc), {label: "Contents", show: false}); assert.deepStrictEqual(mergeToc({label: "On this page"}, toc), {label: "On this page", show: true}); assert.deepStrictEqual(mergeToc(false, toc), {label: "Contents", show: false}); diff --git a/test/deploy-test.ts b/test/deploy-test.ts index 5bc4fbbf6..a027e1762 100644 --- a/test/deploy-test.ts +++ b/test/deploy-test.ts @@ -102,11 +102,11 @@ class MockDeployEffects extends MockAuthEffects implements DeployEffects { this.deployConfig = config; } - async *visitFiles(path) { + *visitFiles(path: string) { yield* visitFiles(path); } - async stat(path) { + async stat(path: string) { const s = await stat(path); if (this.fixedStatTime) { for (const key of ["a", "c", "m", "birth"] as const) { @@ -136,7 +136,7 @@ class MockDeployEffects extends MockAuthEffects implements DeployEffects { // This test should have exactly one index.md in it, and nothing else; that one // page is why we +1 to the number of extra files. const TEST_SOURCE_ROOT = "test/input/build/simple-public"; -const TEST_CONFIG = await normalizeConfig({ +const TEST_CONFIG = normalizeConfig({ root: TEST_SOURCE_ROOT, output: "test/output/build/simple-public", title: "Mock BI" @@ -295,7 +295,7 @@ describe("deploy", () => { }); it("prompts for title when a deploy target is configured, project doesn't exist, and config has no title", async () => { - const config = await normalizeConfig({ + const config = normalizeConfig({ root: TEST_SOURCE_ROOT // no title! }); @@ -318,7 +318,7 @@ describe("deploy", () => { it("throws an error if project doesn't exist and workspace doesn't exist", async () => { const deployConfig = DEPLOY_CONFIG; - const config = await normalizeConfig({ + const config = normalizeConfig({ root: TEST_SOURCE_ROOT, title: "Some title" }); @@ -344,7 +344,7 @@ describe("deploy", () => { }); it("throws an error if workspace is invalid", async () => { - const config = await normalizeConfig({root: TEST_SOURCE_ROOT}); + const config = normalizeConfig({root: TEST_SOURCE_ROOT}); const deployConfig = { ...DEPLOY_CONFIG, workspaceLogin: "ACME Inc." @@ -362,7 +362,7 @@ describe("deploy", () => { }); it("throws an error if project is invalid", async () => { - const config = await normalizeConfig({ + const config = normalizeConfig({ root: TEST_SOURCE_ROOT }); const deployConfig = { diff --git a/test/files-test.ts b/test/files-test.ts index 75dccc1dc..02a2dfa0c 100644 --- a/test/files-test.ts +++ b/test/files-test.ts @@ -46,8 +46,8 @@ describe("maybeStat(path)", () => { }); describe("visitFiles(root)", () => { - it("visits all files in a directory, return the relative path from the root", async () => { - assert.deepStrictEqual(await collect(visitFiles("test/input/build/files")), [ + it("visits all files in a directory, return the relative path from the root", () => { + assert.deepStrictEqual(collect(visitFiles("test/input/build/files")), [ "custom-styles.css", "file-top.csv", "files.md", @@ -58,24 +58,24 @@ describe("visitFiles(root)", () => { "subsection/subfiles.md" ]); }); - it("handles circular symlinks, visiting files only once", async function () { + it("handles circular symlinks, visiting files only once", function () { if (os.platform() === "win32") this.skip(); // symlinks are not the same on Windows - assert.deepStrictEqual(await collect(visitFiles("test/input/circular-files")), ["a/a.txt", "b/b.txt"]); + assert.deepStrictEqual(collect(visitFiles("test/input/circular-files")), ["a/a.txt", "b/b.txt"]); }); }); describe("visitMarkdownFiles(root)", () => { - it("visits all Markdown files in a directory, return the relative path from the root", async () => { - assert.deepStrictEqual(await collect(visitMarkdownFiles("test/input/build/files")), [ + it("visits all Markdown files in a directory, return the relative path from the root", () => { + assert.deepStrictEqual(collect(visitMarkdownFiles("test/input/build/files")), [ "files.md", "subsection/subfiles.md" ]); }); }); -async function collect(generator: AsyncGenerator): Promise { +function collect(generator: Generator): string[] { const values: string[] = []; - for await (const value of generator) { + for (const value of generator) { if (value.startsWith(".observablehq/cache/")) continue; values.push(value); } diff --git a/test/preview/preview-test.ts b/test/preview/preview-test.ts index 9231dd5df..136ea2c8d 100644 --- a/test/preview/preview-test.ts +++ b/test/preview/preview-test.ts @@ -1,6 +1,5 @@ import chai, {assert, expect} from "chai"; import chaiHttp from "chai-http"; -import {normalizeConfig} from "../../src/config.js"; import {preview} from "../../src/preview.js"; import type {PreviewOptions, PreviewServer} from "../../src/preview.js"; import {mockJsDelivr} from "../mocks/jsdelivr.js"; @@ -19,7 +18,7 @@ describe("preview server", () => { before(async () => { const testServerOptions: PreviewOptions = { - config: await normalizeConfig({root: testHostRoot}), + root: testHostRoot, hostname: testHostName, port: testPort, verbose: false