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