Skip to content

Commit 3962950

Browse files
mbostockFil
andauthored
lazy config (#695)
* lazy config * update docs * fix tests * lazy readPages --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent 80da9cb commit 3962950

File tree

12 files changed

+118
-96
lines changed

12 files changed

+118
-96
lines changed

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ display(1 + 2);
267267
```
268268
````
269269

270-
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 <a href="https://github.com/observablehq/framework/issues/645">#645</a> and <a href="https://github.com/observablehq/framework/issues/646">#646</a> if you’d like this to be better.
270+
To see the new page in the sidebar, reload the page.
271271

272272
If you click on the **Weather report** link in the sidebar, it’ll take you to <http://127.0.0.1:3000/weather>, where you should see:
273273

src/bin/observable.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ try {
179179
const {config, root, host, port, open} = values;
180180
await import("../preview.js").then(async (preview) =>
181181
preview.preview({
182-
config: await readConfig(config, root),
182+
config,
183+
root,
183184
hostname: host!,
184185
port: port === undefined ? undefined : +port,
185186
open

src/build.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export async function build(
5252

5353
// Make sure all files are readable before starting to write output files.
5454
let pageCount = 0;
55-
for await (const sourceFile of visitMarkdownFiles(root)) {
55+
for (const sourceFile of visitMarkdownFiles(root)) {
5656
await access(join(root, sourceFile), constants.R_OK);
5757
pageCount++;
5858
}
@@ -65,7 +65,7 @@ export async function build(
6565
const localImports = new Set<string>();
6666
const globalImports = new Set<string>();
6767
const stylesheets = new Set<string>();
68-
for await (const sourceFile of visitMarkdownFiles(root)) {
68+
for (const sourceFile of visitMarkdownFiles(root)) {
6969
const sourcePath = join(root, sourceFile);
7070
const path = join("/", dirname(sourceFile), basename(sourceFile, ".md"));
7171
const options = {path, ...config};

src/config.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {existsSync} from "node:fs";
2-
import {readFile} from "node:fs/promises";
1+
import {existsSync, readFileSync} from "node:fs";
2+
import {stat} from "node:fs/promises";
33
import op from "node:path";
44
import {basename, dirname, join} from "node:path/posix";
55
import {cwd} from "node:process";
@@ -67,25 +67,32 @@ function resolveConfig(configPath: string, root = "."): string {
6767
return op.join(cwd(), root, configPath);
6868
}
6969

70+
// By using the modification time of the config, we ensure that we pick up any
71+
// changes to the config on reload.
72+
async function importConfig(path: string): Promise<any> {
73+
const {mtimeMs} = await stat(path);
74+
return (await import(`${pathToFileURL(path).href}?${mtimeMs}`)).default;
75+
}
76+
7077
export async function readConfig(configPath?: string, root?: string): Promise<Config> {
7178
if (configPath === undefined) return readDefaultConfig(root);
72-
return normalizeConfig((await import(pathToFileURL(resolveConfig(configPath, root)).href)).default, root);
79+
return normalizeConfig(await importConfig(resolveConfig(configPath, root)), root);
7380
}
7481

7582
export async function readDefaultConfig(root?: string): Promise<Config> {
7683
const jsPath = resolveConfig("observablehq.config.js", root);
77-
if (existsSync(jsPath)) return normalizeConfig((await import(pathToFileURL(jsPath).href)).default, root);
84+
if (existsSync(jsPath)) return normalizeConfig(await importConfig(jsPath), root);
7885
const tsPath = resolveConfig("observablehq.config.ts", root);
7986
if (!existsSync(tsPath)) return normalizeConfig(undefined, root);
8087
await import("tsx/esm"); // lazy tsx
81-
return normalizeConfig((await import(pathToFileURL(tsPath).href)).default, root);
88+
return normalizeConfig(await importConfig(tsPath), root);
8289
}
8390

84-
async function readPages(root: string, md: MarkdownIt): Promise<Page[]> {
91+
function readPages(root: string, md: MarkdownIt): Page[] {
8592
const pages: Page[] = [];
86-
for await (const file of visitMarkdownFiles(root)) {
93+
for (const file of visitMarkdownFiles(root)) {
8794
if (file === "index.md" || file === "404.md") continue;
88-
const source = await readFile(join(root, file), "utf8");
95+
const source = readFileSync(join(root, file), "utf8");
8996
const parsed = parseMarkdown(source, {path: file, md});
9097
if (parsed?.data?.draft) continue;
9198
const name = basename(file, ".md");
@@ -102,7 +109,7 @@ export function setCurrentDate(date = new Date()): void {
102109
currentDate = date;
103110
}
104111

105-
export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Promise<Config> {
112+
export function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Config {
106113
let {
107114
root = defaultRoot,
108115
output = "dist",
@@ -127,10 +134,10 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
127134
else if (style !== undefined) style = {path: String(style)};
128135
else style = {theme: (theme = normalizeTheme(theme))};
129136
const md = createMarkdownIt(spec);
130-
let {title, pages = await readPages(root, md), pager = true, toc = true} = spec;
137+
let {title, pages, pager = true, toc = true} = spec;
131138
if (title !== undefined) title = String(title);
132-
pages = Array.from(pages, normalizePageOrSection);
133-
sidebar = sidebar === undefined ? pages.length > 0 : Boolean(sidebar);
139+
if (pages !== undefined) pages = Array.from(pages, normalizePageOrSection);
140+
if (sidebar !== undefined) sidebar = Boolean(sidebar);
134141
pager = Boolean(pager);
135142
scripts = Array.from(scripts, normalizeScript);
136143
head = String(head);
@@ -140,7 +147,7 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
140147
deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null;
141148
search = Boolean(search);
142149
interpreters = normalizeInterpreters(interpreters);
143-
return {
150+
const config = {
144151
root,
145152
output,
146153
base,
@@ -159,6 +166,9 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
159166
md,
160167
loaders: new LoaderResolver({root, interpreters})
161168
};
169+
if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md)});
170+
if (sidebar === undefined) Object.defineProperty(config, "sidebar", {get: () => config.pages.length > 0});
171+
return config;
162172
}
163173

164174
function normalizeBase(base: any): string {

src/deploy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export interface DeployEffects extends ConfigEffects, TtyEffects, AuthEffects {
5858
logger: Logger;
5959
input: NodeJS.ReadableStream;
6060
output: NodeJS.WritableStream;
61-
visitFiles: (root: string) => AsyncGenerator<string>;
61+
visitFiles: (root: string) => Generator<string>;
6262
stat: (path: string) => Promise<Stats>;
6363
}
6464

src/files.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {Stats} from "node:fs";
2-
import {existsSync} from "node:fs";
3-
import {mkdir, readdir, stat} from "node:fs/promises";
2+
import {existsSync, readdirSync, statSync} from "node:fs";
3+
import {mkdir, stat} from "node:fs/promises";
44
import op from "node:path";
55
import {extname, join, normalize, relative, sep} from "node:path/posix";
66
import {cwd} from "node:process";
@@ -41,23 +41,23 @@ export function getStylePath(entry: string): string {
4141
}
4242

4343
/** Yields every Markdown (.md) file within the given root, recursively. */
44-
export async function* visitMarkdownFiles(root: string): AsyncGenerator<string> {
45-
for await (const file of visitFiles(root)) {
44+
export function* visitMarkdownFiles(root: string): Generator<string> {
45+
for (const file of visitFiles(root)) {
4646
if (extname(file) !== ".md") continue;
4747
yield file;
4848
}
4949
}
5050

5151
/** Yields every file within the given root, recursively. */
52-
export async function* visitFiles(root: string): AsyncGenerator<string> {
52+
export function* visitFiles(root: string): Generator<string> {
5353
const visited = new Set<number>();
5454
const queue: string[] = [(root = normalize(root))];
5555
for (const path of queue) {
56-
const status = await stat(path);
56+
const status = statSync(path);
5757
if (status.isDirectory()) {
5858
if (visited.has(status.ino)) continue; // circular symlink
5959
visited.add(status.ino);
60-
for (const entry of await readdir(path)) {
60+
for (const entry of readdirSync(path)) {
6161
queue.push(join(path, entry));
6262
}
6363
} else {

src/preview.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import send from "send";
1414
import type {WebSocket} from "ws";
1515
import {WebSocketServer} from "ws";
1616
import type {Config} from "./config.js";
17+
import {readConfig} from "./config.js";
1718
import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js";
1819
import {getClientPath} from "./files.js";
1920
import type {FileWatchers} from "./fileWatchers.js";
@@ -32,7 +33,8 @@ import {Telemetry} from "./telemetry.js";
3233
import {bold, faint, green, link} from "./tty.js";
3334

3435
export interface PreviewOptions {
35-
config: Config;
36+
config?: string;
37+
root?: string;
3638
hostname: string;
3739
open?: boolean;
3840
port?: number;
@@ -44,13 +46,25 @@ export async function preview(options: PreviewOptions): Promise<PreviewServer> {
4446
}
4547

4648
export class PreviewServer {
47-
private readonly _config: Config;
49+
private readonly _config: string | undefined;
50+
private readonly _root: string | undefined;
4851
private readonly _server: ReturnType<typeof createServer>;
4952
private readonly _socketServer: WebSocketServer;
5053
private readonly _verbose: boolean;
5154

52-
private constructor({config, server, verbose}: {config: Config; server: Server; verbose: boolean}) {
55+
private constructor({
56+
config,
57+
root,
58+
server,
59+
verbose
60+
}: {
61+
config?: string;
62+
root?: string;
63+
server: Server;
64+
verbose: boolean;
65+
}) {
5366
this._config = config;
67+
this._root = root;
5468
this._verbose = verbose;
5569
this._server = server;
5670
this._server.on("request", this._handleRequest);
@@ -86,8 +100,12 @@ export class PreviewServer {
86100
return new PreviewServer({server, verbose, ...options});
87101
}
88102

103+
async _readConfig() {
104+
return readConfig(this._config, this._root);
105+
}
106+
89107
_handleRequest: RequestListener = async (req, res) => {
90-
const config = this._config;
108+
const config = await this._readConfig();
91109
const {root, loaders} = config;
92110
if (this._verbose) console.log(faint(req.method!), req.url);
93111
try {
@@ -207,7 +225,7 @@ export class PreviewServer {
207225

208226
_handleConnection = async (socket: WebSocket, req: IncomingMessage) => {
209227
if (req.url === "/_observablehq") {
210-
handleWatch(socket, req, this._config);
228+
handleWatch(socket, req, await this._readConfig());
211229
} else {
212230
socket.close();
213231
}

src/search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export async function searchIndex(config: Config, effects = defaultEffects): Pro
4040

4141
// Index the pages
4242
const index = new MiniSearch(indexOptions);
43-
for await (const file of visitMarkdownFiles(root)) {
43+
for (const file of visitMarkdownFiles(root)) {
4444
const sourcePath = join(root, file);
4545
const source = await readFile(sourcePath, "utf8");
4646
const path = `/${join(dirname(file), basename(file, ".md"))}`;

test/config-test.ts

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -63,27 +63,27 @@ describe("readConfig(undefined, root)", () => {
6363

6464
describe("normalizeConfig(spec, root)", () => {
6565
const root = "test/input/build/config";
66-
it("coerces the title to a string", async () => {
67-
assert.strictEqual((await config({title: 42, pages: []}, root)).title, "42");
68-
assert.strictEqual((await config({title: null, pages: []}, root)).title, "null");
66+
it("coerces the title to a string", () => {
67+
assert.strictEqual(config({title: 42, pages: []}, root).title, "42");
68+
assert.strictEqual(config({title: null, pages: []}, root).title, "null");
6969
});
70-
it("considers the title optional", async () => {
71-
assert.strictEqual((await config({pages: []}, root)).title, undefined);
72-
assert.strictEqual((await config({title: undefined, pages: []}, root)).title, undefined);
70+
it("considers the title optional", () => {
71+
assert.strictEqual(config({pages: []}, root).title, undefined);
72+
assert.strictEqual(config({title: undefined, pages: []}, root).title, undefined);
7373
});
74-
it("populates default pages", async () => {
75-
assert.deepStrictEqual((await config({}, root)).pages, [
74+
it("populates default pages", () => {
75+
assert.deepStrictEqual(config({}, root).pages, [
7676
{name: "One", path: "/one"},
7777
{name: "H1: Section", path: "/toc-override"},
7878
{name: "H1: Section", path: "/toc"},
7979
{name: "A page…", path: "/closed/page"},
8080
{name: "Two", path: "/sub/two"}
8181
]);
8282
});
83-
it("coerces pages to an array", async () => {
84-
assert.deepStrictEqual((await config({pages: new Set()}, root)).pages, []);
83+
it("coerces pages to an array", () => {
84+
assert.deepStrictEqual(config({pages: new Set()}, root).pages, []);
8585
});
86-
it("coerces and normalizes page paths", async () => {
86+
it("coerces and normalizes page paths", () => {
8787
const inpages = [
8888
{name: 42, path: true},
8989
{name: null, path: {toString: () => "yes"}},
@@ -98,13 +98,13 @@ describe("normalizeConfig(spec, root)", () => {
9898
{name: "Index.html", path: "/foo/index"},
9999
{name: "Page.html", path: "/foo"}
100100
];
101-
assert.deepStrictEqual((await config({pages: inpages}, root)).pages, outpages);
101+
assert.deepStrictEqual(config({pages: inpages}, root).pages, outpages);
102102
});
103-
it("allows external page paths", async () => {
103+
it("allows external page paths", () => {
104104
const pages = [{name: "Example.com", path: "https://example.com"}];
105-
assert.deepStrictEqual((await config({pages}, root)).pages, pages);
105+
assert.deepStrictEqual(config({pages}, root).pages, pages);
106106
});
107-
it("allows page paths to have query strings and anchor fragments", async () => {
107+
it("allows page paths to have query strings and anchor fragments", () => {
108108
const inpages = [
109109
{name: "Anchor fragment on index", path: "/test/index#foo=bar"},
110110
{name: "Anchor fragment on index.html", path: "/test/index.html#foo=bar"},
@@ -129,59 +129,53 @@ describe("normalizeConfig(spec, root)", () => {
129129
{name: "Query string on slash", path: "/test/index?foo=bar"},
130130
{name: "Query string", path: "/test?foo=bar"}
131131
];
132-
assert.deepStrictEqual((await config({pages: inpages}, root)).pages, outpages);
132+
assert.deepStrictEqual(config({pages: inpages}, root).pages, outpages);
133133
});
134-
it("coerces sections", async () => {
134+
it("coerces sections", () => {
135135
const inpages = [{name: 42, pages: new Set([{name: null, path: {toString: () => "yes"}}])}];
136136
const outpages = [{name: "42", open: true, pages: [{name: "null", path: "/yes"}]}];
137-
assert.deepStrictEqual((await config({pages: inpages}, root)).pages, outpages);
137+
assert.deepStrictEqual(config({pages: inpages}, root).pages, outpages);
138138
});
139-
it("coerces toc", async () => {
140-
assert.deepStrictEqual((await config({pages: [], toc: {}}, root)).toc, {label: "Contents", show: true});
141-
assert.deepStrictEqual((await config({pages: [], toc: {label: null}}, root)).toc, {label: "null", show: true});
139+
it("coerces toc", () => {
140+
assert.deepStrictEqual(config({pages: [], toc: {}}, root).toc, {label: "Contents", show: true});
141+
assert.deepStrictEqual(config({pages: [], toc: {label: null}}, root).toc, {label: "null", show: true});
142142
});
143-
it("populates default toc", async () => {
144-
assert.deepStrictEqual((await config({pages: []}, root)).toc, {label: "Contents", show: true});
143+
it("populates default toc", () => {
144+
assert.deepStrictEqual(config({pages: []}, root).toc, {label: "Contents", show: true});
145145
});
146-
it("promotes boolean toc to toc.show", async () => {
147-
assert.deepStrictEqual((await config({pages: [], toc: true}, root)).toc, {label: "Contents", show: true});
148-
assert.deepStrictEqual((await config({pages: [], toc: false}, root)).toc, {label: "Contents", show: false});
146+
it("promotes boolean toc to toc.show", () => {
147+
assert.deepStrictEqual(config({pages: [], toc: true}, root).toc, {label: "Contents", show: true});
148+
assert.deepStrictEqual(config({pages: [], toc: false}, root).toc, {label: "Contents", show: false});
149149
});
150-
it("coerces pager", async () => {
151-
assert.strictEqual((await config({pages: [], pager: 0}, root)).pager, false);
152-
assert.strictEqual((await config({pages: [], pager: 1}, root)).pager, true);
153-
assert.strictEqual((await config({pages: [], pager: ""}, root)).pager, false);
154-
assert.strictEqual((await config({pages: [], pager: "0"}, root)).pager, true);
150+
it("coerces pager", () => {
151+
assert.strictEqual(config({pages: [], pager: 0}, root).pager, false);
152+
assert.strictEqual(config({pages: [], pager: 1}, root).pager, true);
153+
assert.strictEqual(config({pages: [], pager: ""}, root).pager, false);
154+
assert.strictEqual(config({pages: [], pager: "0"}, root).pager, true);
155155
});
156-
it("populates default pager", async () => {
157-
assert.strictEqual((await config({pages: []}, root)).pager, true);
156+
it("populates default pager", () => {
157+
assert.strictEqual(config({pages: []}, root).pager, true);
158158
});
159159
describe("deploy", () => {
160-
it("considers deploy optional", async () => {
161-
assert.strictEqual((await config({pages: []}, root)).deploy, null);
160+
it("considers deploy optional", () => {
161+
assert.strictEqual(config({pages: []}, root).deploy, null);
162162
});
163-
it("coerces workspace", async () => {
164-
assert.strictEqual(
165-
(await config({pages: [], deploy: {workspace: 538, project: "bi"}}, root)).deploy?.workspace,
166-
"538"
167-
);
163+
it("coerces workspace", () => {
164+
assert.strictEqual(config({pages: [], deploy: {workspace: 538, project: "bi"}}, root).deploy?.workspace, "538");
168165
});
169-
it("strips leading @ from workspace", async () => {
170-
assert.strictEqual((await config({pages: [], deploy: {workspace: "@acme"}}, root)).deploy?.workspace, "acme");
166+
it("strips leading @ from workspace", () => {
167+
assert.strictEqual(config({pages: [], deploy: {workspace: "@acme"}}, root).deploy?.workspace, "acme");
171168
});
172-
it("coerces project", async () => {
173-
assert.strictEqual(
174-
(await config({pages: [], deploy: {workspace: "adams", project: 42}}, root)).deploy?.project,
175-
"42"
176-
);
169+
it("coerces project", () => {
170+
assert.strictEqual(config({pages: [], deploy: {workspace: "adams", project: 42}}, root).deploy?.project, "42");
177171
});
178172
});
179173
});
180174

181175
describe("mergeToc(spec, toc)", () => {
182176
const root = "test/input/build/config";
183177
it("merges page- and project-level toc config", async () => {
184-
const toc = (await config({pages: [], toc: true}, root)).toc;
178+
const toc = config({pages: [], toc: true}, root).toc;
185179
assert.deepStrictEqual(mergeToc({show: false}, toc), {label: "Contents", show: false});
186180
assert.deepStrictEqual(mergeToc({label: "On this page"}, toc), {label: "On this page", show: true});
187181
assert.deepStrictEqual(mergeToc(false, toc), {label: "Contents", show: false});

0 commit comments

Comments
 (0)