Skip to content

Commit be7fe26

Browse files
authored
fix config caching (#1129)
* fix config caching * prettier
1 parent 3962950 commit be7fe26

File tree

8 files changed

+51
-67
lines changed

8 files changed

+51
-67
lines changed

docs/display-race.md

Lines changed: 0 additions & 19 deletions
This file was deleted.

docs/number.md

Lines changed: 0 additions & 34 deletions
This file was deleted.

src/config.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {createHash} from "node:crypto";
12
import {existsSync, readFileSync} from "node:fs";
23
import {stat} from "node:fs/promises";
34
import op from "node:path";
@@ -8,7 +9,7 @@ import type MarkdownIt from "markdown-it";
89
import {LoaderResolver} from "./dataloader.js";
910
import {visitMarkdownFiles} from "./files.js";
1011
import {formatIsoDate, formatLocaleDate} from "./format.js";
11-
import {createMarkdownIt, parseMarkdown} from "./markdown.js";
12+
import {createMarkdownIt, parseMarkdownMetadata} from "./markdown.js";
1213
import {isAssetPath, parseRelativeUrl, resolvePath} from "./path.js";
1314
import {resolveTheme} from "./theme.js";
1415

@@ -88,18 +89,29 @@ export async function readDefaultConfig(root?: string): Promise<Config> {
8889
return normalizeConfig(await importConfig(tsPath), root);
8990
}
9091

92+
let cachedPages: {key: string; pages: Page[]} | null = null;
93+
9194
function readPages(root: string, md: MarkdownIt): Page[] {
92-
const pages: Page[] = [];
95+
const files: {file: string; source: string}[] = [];
96+
const hash = createHash("sha256");
9397
for (const file of visitMarkdownFiles(root)) {
9498
if (file === "index.md" || file === "404.md") continue;
9599
const source = readFileSync(join(root, file), "utf8");
96-
const parsed = parseMarkdown(source, {path: file, md});
100+
files.push({file, source});
101+
hash.update(file).update(source);
102+
}
103+
const key = hash.digest("hex");
104+
if (cachedPages?.key === key) return cachedPages.pages;
105+
const pages: Page[] = [];
106+
for (const {file, source} of files) {
107+
const parsed = parseMarkdownMetadata(source, {path: file, md});
97108
if (parsed?.data?.draft) continue;
98109
const name = basename(file, ".md");
99110
const page = {path: join("/", dirname(file), name), name: parsed.title ?? "Untitled"};
100111
if (name === "index") pages.unshift(page);
101112
else pages.push(page);
102113
}
114+
cachedPages = {key, pages};
103115
return pages;
104116
}
105117

@@ -109,7 +121,15 @@ export function setCurrentDate(date = new Date()): void {
109121
currentDate = date;
110122
}
111123

124+
// The config is used as a cache key for other operations; for example the pages
125+
// are used as a cache key for search indexing and the previous & next links in
126+
// the footer. When given the same spec (because import returned the same
127+
// module), we want to return the same Config instance.
128+
const configCache = new WeakMap<any, Config>();
129+
112130
export function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Config {
131+
const cachedConfig = configCache.get(spec);
132+
if (cachedConfig) return cachedConfig;
113133
let {
114134
root = defaultRoot,
115135
output = "dist",
@@ -168,6 +188,7 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Config {
168188
};
169189
if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md)});
170190
if (sidebar === undefined) Object.defineProperty(config, "sidebar", {get: () => config.pages.length > 0});
191+
configCache.set(spec, config);
171192
return config;
172193
}
173194

src/files.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export function* visitMarkdownFiles(root: string): Generator<string> {
5252
export function* visitFiles(root: string): Generator<string> {
5353
const visited = new Set<number>();
5454
const queue: string[] = [(root = normalize(root))];
55+
try {
56+
visited.add(statSync(join(root, ".observablehq")).ino);
57+
} catch {
58+
// ignore the .observablehq directory, if it exists
59+
}
5560
for (const path of queue) {
5661
const status = statSync(path);
5762
if (status.isDirectory()) {

src/markdown.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,16 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
343343
};
344344
}
345345

346+
/** Like parseMarkdown, but optimized to return only metadata. */
347+
export function parseMarkdownMetadata(input: string, options: ParseOptions): Pick<MarkdownPage, "data" | "title"> {
348+
const {md, path} = options;
349+
const {content, data} = matter(input, {});
350+
return {
351+
data: isEmpty(data) ? null : data,
352+
title: data.title ?? findTitle(md.parse(content, {code: [], startLine: 0, currentLine: 0, path})) ?? null
353+
};
354+
}
355+
346356
function getHtml(
347357
key: "head" | "header" | "footer",
348358
data: Record<string, any>,

src/search.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {parseMarkdown} from "./markdown.js";
99
import {faint, strikethrough} from "./tty.js";
1010

1111
// Avoid reindexing too often in preview.
12-
const indexCache = new WeakMap();
13-
const reindexingDelay = 10 * 60 * 1000; // 10 minutes
12+
const indexCache = new WeakMap<Config["pages"], {json: string; freshUntil: number}>();
13+
const reindexDelay = 10 * 60 * 1000; // 10 minutes
1414

1515
export interface SearchIndexEffects {
1616
logger: Logger;
@@ -19,17 +19,18 @@ export interface SearchIndexEffects {
1919
const defaultEffects: SearchIndexEffects = {logger: console};
2020

2121
const indexOptions = {
22-
fields: ["title", "text", "keywords"],
22+
fields: ["title", "text", "keywords"], // fields to return with search results
2323
storeFields: ["title"],
24-
processTerm(term) {
25-
return term.match(/\p{N}/gu)?.length > 6 ? null : term.slice(0, 15).toLowerCase(); // fields to return with search results
24+
processTerm(term: string) {
25+
return (term.match(/\p{N}/gu)?.length ?? 0) > 6 ? null : term.slice(0, 15).toLowerCase();
2626
}
2727
};
2828

2929
export async function searchIndex(config: Config, effects = defaultEffects): Promise<string> {
3030
const {root, pages, search, md} = config;
3131
if (!search) return "{}";
32-
if (indexCache.has(config) && indexCache.get(config).freshUntil > +new Date()) return indexCache.get(config).json;
32+
const cached = indexCache.get(pages);
33+
if (cached && cached.freshUntil > Date.now()) return cached.json;
3334

3435
// Get all the listed pages (which are indexed by default)
3536
const pagePaths = new Set(["/index"]);
@@ -84,7 +85,7 @@ export async function searchIndex(config: Config, effects = defaultEffects): Pro
8485
)
8586
);
8687

87-
indexCache.set(config, {json, freshUntil: +new Date() + reindexingDelay});
88+
indexCache.set(pages, {json, freshUntil: Date.now() + reindexDelay});
8889
return json;
8990
}
9091

test/build-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ describe("build", () => {
4242

4343
await rm(actualDir, {recursive: true, force: true});
4444
if (generate) console.warn(`! generating ${expectedDir}`);
45-
const config = Object.assign(await readConfig(undefined, path), {output: outputDir});
45+
const config = {...(await readConfig(undefined, path)), output: outputDir};
4646
await build({config, addPublic}, new TestEffects(outputDir));
4747

4848
// In the addPublic case, we don’t want to test the contents of the public

test/config-test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {normalizeConfig as config, mergeToc, readConfig, setCurrentDate} from ".
44
import {LoaderResolver} from "../src/dataloader.js";
55

66
describe("readConfig(undefined, root)", () => {
7-
before(() => setCurrentDate(new Date("2024-01-11T01:02:03")));
7+
before(() => setCurrentDate(new Date("2024-01-10T16:00:00")));
88
it("imports the config file at the specified root", async () => {
99
const {md, loaders, ...config} = await readConfig(undefined, "test/input/build/config");
1010
assert(md instanceof MarkdownIt);
@@ -28,7 +28,7 @@ describe("readConfig(undefined, root)", () => {
2828
head: "",
2929
header: "",
3030
footer:
31-
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-11T01:02:03">Jan 11, 2024</a>.',
31+
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.',
3232
deploy: {
3333
workspace: "acme",
3434
project: "bi"
@@ -54,7 +54,7 @@ describe("readConfig(undefined, root)", () => {
5454
head: "",
5555
header: "",
5656
footer:
57-
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-11T01:02:03">Jan 11, 2024</a>.',
57+
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.',
5858
deploy: null,
5959
search: false
6060
});

0 commit comments

Comments
 (0)