Skip to content

Commit a94ce04

Browse files
Filmbostock
andauthored
centralize md instance as part of the normalized configuration (#1034)
* centralize md instance as part of the normalized configuration * md is mandatory * mdparser → createMarkdownIt; move hook later * remove unused root option * destructure --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 4ffdaf4 commit a94ce04

File tree

6 files changed

+58
-47
lines changed

6 files changed

+58
-47
lines changed

src/config.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {pathToFileURL} from "node:url";
77
import type MarkdownIt from "markdown-it";
88
import {visitMarkdownFiles} from "./files.js";
99
import {formatIsoDate, formatLocaleDate} from "./format.js";
10-
import {parseMarkdown} from "./markdown.js";
10+
import {createMarkdownIt, parseMarkdown} from "./markdown.js";
1111
import {resolvePath} from "./path.js";
1212
import {resolveTheme} from "./theme.js";
1313

@@ -53,7 +53,7 @@ export interface Config {
5353
style: null | Style; // defaults to {theme: ["light", "dark"]}
5454
deploy: null | {workspace: string; project: string};
5555
search: boolean; // default to false
56-
markdownIt?: (md: MarkdownIt) => MarkdownIt;
56+
md: MarkdownIt;
5757
}
5858

5959
/**
@@ -79,12 +79,12 @@ export async function readDefaultConfig(root?: string): Promise<Config> {
7979
return normalizeConfig((await import(pathToFileURL(tsPath).href)).default, root);
8080
}
8181

82-
async function readPages(root: string): Promise<Page[]> {
82+
async function readPages(root: string, md: MarkdownIt): Promise<Page[]> {
8383
const pages: Page[] = [];
8484
for await (const file of visitMarkdownFiles(root)) {
8585
if (file === "index.md" || file === "404.md") continue;
8686
const source = await readFile(join(root, file), "utf8");
87-
const parsed = parseMarkdown(source, {root, path: file});
87+
const parsed = parseMarkdown(source, {path: file, md});
8888
if (parsed?.data?.draft) continue;
8989
const name = basename(file, ".md");
9090
const page = {path: join("/", dirname(file), name), name: parsed.title ?? "Untitled"};
@@ -117,14 +117,14 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
117117
currentDate
118118
)}">${formatLocaleDate(currentDate)}</a>.`
119119
} = spec;
120-
const {markdownIt} = spec;
121120
root = String(root);
122121
output = String(output);
123122
base = normalizeBase(base);
124123
if (style === null) style = null;
125124
else if (style !== undefined) style = {path: String(style)};
126125
else style = {theme: (theme = normalizeTheme(theme))};
127-
let {title, pages = await readPages(root), pager = true, toc = true} = spec;
126+
const md = createMarkdownIt(spec);
127+
let {title, pages = await readPages(root, md), pager = true, toc = true} = spec;
128128
if (title !== undefined) title = String(title);
129129
pages = Array.from(pages, normalizePageOrSection);
130130
sidebar = sidebar === undefined ? pages.length > 0 : Boolean(sidebar);
@@ -136,7 +136,6 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
136136
toc = normalizeToc(toc);
137137
deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null;
138138
search = Boolean(search);
139-
if (markdownIt !== undefined && typeof markdownIt !== "function") throw new Error("markdownIt must be a function");
140139
return {
141140
root,
142141
output,
@@ -153,7 +152,7 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
153152
style,
154153
deploy,
155154
search,
156-
markdownIt
155+
md
157156
};
158157
}
159158

src/markdown.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface ParseContext {
3535
code: MarkdownCode[];
3636
startLine: number;
3737
currentLine: number;
38+
path: string;
3839
}
3940

4041
function uniqueCodeId(context: ParseContext, content: string): string {
@@ -85,8 +86,9 @@ function getLiveSource(content: string, tag: string, attributes: Record<string,
8586
// console.error(red(`${error.name}: ${warning}`));
8687
// }
8788

88-
function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: string): RenderRule {
89+
function makeFenceRenderer(baseRenderer: RenderRule): RenderRule {
8990
return (tokens, idx, options, context: ParseContext, self) => {
91+
const {path} = context;
9092
const token = tokens[idx];
9193
const {tag, attributes} = parseInfo(token.info);
9294
token.info = tag;
@@ -97,7 +99,7 @@ function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: s
9799
if (source != null) {
98100
const id = uniqueCodeId(context, source);
99101
// TODO const sourceLine = context.startLine + context.currentLine;
100-
const node = parseJavaScript(source, {path: sourcePath});
102+
const node = parseJavaScript(source, {path});
101103
context.code.push({id, node});
102104
html += `<div id="cell-${id}" class="observablehq observablehq--block${
103105
node.expression ? " observablehq--loading" : ""
@@ -177,7 +179,7 @@ function parsePlaceholder(content: string, replacer: (i: number, j: number) => v
177179

178180
function transformPlaceholderBlock(token) {
179181
const input = token.content;
180-
if (/^\s*<script(\s|>)/.test(input)) return [token]; // ignore <script> elements
182+
if (/^\s*<script[\s>]/.test(input)) return [token]; // ignore <script> elements
181183
const output: any[] = [];
182184
let i = 0;
183185
parsePlaceholder(input, (j, k) => {
@@ -245,13 +247,14 @@ const transformPlaceholderCore: RuleCore = (state) => {
245247
state.tokens = output;
246248
};
247249

248-
function makePlaceholderRenderer(root: string, sourcePath: string): RenderRule {
250+
function makePlaceholderRenderer(): RenderRule {
249251
return (tokens, idx, options, context: ParseContext) => {
252+
const {path} = context;
250253
const token = tokens[idx];
251254
const id = uniqueCodeId(context, token.content);
252255
try {
253256
// TODO sourceLine: context.startLine + context.currentLine
254-
const node = parseJavaScript(token.content, {path: sourcePath, inline: true});
257+
const node = parseJavaScript(token.content, {path, inline: true});
255258
context.code.push({id, node});
256259
return `<span id="cell-${id}" class="observablehq--loading"></span>`;
257260
} catch (error) {
@@ -273,32 +276,34 @@ function makeSoftbreakRenderer(baseRenderer: RenderRule): RenderRule {
273276
}
274277

275278
export interface ParseOptions {
276-
root: string;
279+
md: MarkdownIt;
277280
path: string;
278-
markdownIt?: Config["markdownIt"];
279281
style?: Config["style"];
280282
}
281283

282-
export function parseMarkdown(input: string, {root, path, markdownIt, style: configStyle}: ParseOptions): MarkdownPage {
283-
const parts = matter(input, {});
284-
let md = MarkdownIt({html: true, linkify: true});
284+
export function createMarkdownIt({markdownIt}: {markdownIt?: (md: MarkdownIt) => MarkdownIt} = {}): MarkdownIt {
285+
const md = MarkdownIt({html: true, linkify: true});
285286
md.linkify.set({fuzzyLink: false, fuzzyEmail: false});
286-
if (markdownIt !== undefined) md = markdownIt(md);
287287
md.use(MarkdownItAnchor, {permalink: MarkdownItAnchor.permalink.headerLink({class: "observablehq-header-anchor"})});
288288
md.inline.ruler.push("placeholder", transformPlaceholderInline);
289289
md.core.ruler.before("linkify", "placeholder", transformPlaceholderCore);
290-
md.renderer.rules.placeholder = makePlaceholderRenderer(root, path);
291-
md.renderer.rules.fence = makeFenceRenderer(root, md.renderer.rules.fence!, path);
290+
md.renderer.rules.placeholder = makePlaceholderRenderer();
291+
md.renderer.rules.fence = makeFenceRenderer(md.renderer.rules.fence!);
292292
md.renderer.rules.softbreak = makeSoftbreakRenderer(md.renderer.rules.softbreak!);
293+
return markdownIt === undefined ? md : markdownIt(md);
294+
}
295+
296+
export function parseMarkdown(input: string, {md, path, style: configStyle}: ParseOptions): MarkdownPage {
297+
const {content, data} = matter(input, {});
293298
const code: MarkdownCode[] = [];
294-
const context: ParseContext = {code, startLine: 0, currentLine: 0};
295-
const tokens = md.parse(parts.content, context);
299+
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
300+
const tokens = md.parse(content, context);
296301
const html = md.renderer.render(tokens, md.options, context); // Note: mutates code, assets!
297-
const style = getStylesheet(path, parts.data, configStyle);
302+
const style = getStylesheet(path, data, configStyle);
298303
return {
299304
html,
300-
data: isEmpty(parts.data) ? null : parts.data,
301-
title: parts.data?.title ?? findTitle(tokens) ?? null,
305+
data: isEmpty(data) ? null : data,
306+
title: data?.title ?? findTitle(tokens) ?? null,
302307
style,
303308
code
304309
};

src/search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export async function searchIndex(config: Config, effects = defaultEffects): Pro
4343
for await (const file of visitMarkdownFiles(root)) {
4444
const path = join(root, file);
4545
const source = await readFile(path, "utf8");
46-
const {html, title, data} = parseMarkdown(source, {root, path: "/" + file.slice(0, -3)});
46+
const {html, title, data} = parseMarkdown(source, {...config, path: "/" + file.slice(0, -3)});
4747

4848
// Skip pages that opt-out of indexing, and skip unlisted pages unless
4949
// opted-in. We only log the first case.

test/config-test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import assert from "node:assert";
2+
import MarkdownIt from "markdown-it";
23
import {normalizeConfig as config, mergeToc, readConfig, setCurrentDate} from "../src/config.js";
34

45
const root = "test/input/build/config";
56

67
describe("readConfig(undefined, root)", () => {
78
before(() => setCurrentDate(new Date("2024-01-11T01:02:03")));
89
it("imports the config file at the specified root", async () => {
9-
assert.deepStrictEqual(await readConfig(undefined, "test/input/build/config"), {
10+
const {md, ...config} = await readConfig(undefined, "test/input/build/config");
11+
assert(md instanceof MarkdownIt);
12+
assert.deepStrictEqual(config, {
1013
root: "test/input/build/config",
1114
output: "dist",
1215
base: "/",
@@ -30,12 +33,13 @@ describe("readConfig(undefined, root)", () => {
3033
workspace: "acme",
3134
project: "bi"
3235
},
33-
search: false,
34-
markdownIt: undefined
36+
search: false
3537
});
3638
});
3739
it("returns the default config if no config file is found", async () => {
38-
assert.deepStrictEqual(await readConfig(undefined, "test/input/build/simple"), {
40+
const {md, ...config} = await readConfig(undefined, "test/input/build/simple");
41+
assert(md instanceof MarkdownIt);
42+
assert.deepStrictEqual(config, {
3943
root: "test/input/build/simple",
4044
output: "dist",
4145
base: "/",
@@ -51,8 +55,7 @@ describe("readConfig(undefined, root)", () => {
5155
footer:
5256
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-11T01:02:03">Jan 11, 2024</a>.',
5357
deploy: null,
54-
search: false,
55-
markdownIt: undefined
58+
search: false
5659
});
5760
});
5861
});

test/markdown-test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import {readdirSync, statSync} from "node:fs";
33
import {mkdir, readFile, unlink, writeFile} from "node:fs/promises";
44
import {basename, join, resolve} from "node:path/posix";
55
import deepEqual from "fast-deep-equal";
6+
import {normalizeConfig} from "../src/config.js";
67
import {isEnoent} from "../src/error.js";
78
import type {MarkdownPage} from "../src/markdown.js";
89
import {parseMarkdown} from "../src/markdown.js";
910

10-
describe("parseMarkdown(input)", () => {
11+
describe("parseMarkdown(input)", async () => {
12+
const {md} = await normalizeConfig();
1113
const inputRoot = "test/input";
1214
const outputRoot = "test/output";
1315
for (const name of readdirSync(inputRoot)) {
@@ -20,7 +22,7 @@ describe("parseMarkdown(input)", () => {
2022

2123
(only ? it.only : skip ? it.skip : it)(`test/input/${name}`, async () => {
2224
const source = await readFile(path, "utf8");
23-
const snapshot = parseMarkdown(source, {root: "test/input", path: name});
25+
const snapshot = parseMarkdown(source, {path: name, md});
2426
let allequal = true;
2527
for (const ext of ["html", "json"]) {
2628
const actual = ext === "json" ? jsonMeta(snapshot) : snapshot[ext];

test/resolvers-test.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,80 @@
11
import assert from "node:assert";
2+
import {normalizeConfig} from "../src/config.js";
23
import {parseMarkdown} from "../src/markdown.js";
34
import {getResolvers} from "../src/resolvers.js";
45

5-
describe("getResolvers(page, {root, path})", () => {
6+
describe("getResolvers(page, {root, path})", async () => {
7+
const {md} = await normalizeConfig();
68
const builtins = ["npm:@observablehq/runtime", "npm:@observablehq/stdlib", "observablehq:client"];
79
it("resolves directly-attached files", async () => {
8-
const options = {root: "test/input", path: "attached.md"};
10+
const options = {root: "test/input", path: "attached.md", md};
911
const page = parseMarkdown("${FileAttachment('foo.csv')}", options);
1012
const resolvers = await getResolvers(page, options);
1113
assert.deepStrictEqual(resolvers.files, new Set(["./foo.csv"]));
1214
});
1315
it("ignores files that are outside of the source root", async () => {
14-
const options = {root: "test/input", path: "attached.md"};
16+
const options = {root: "test/input", path: "attached.md", md};
1517
const page = parseMarkdown("${FileAttachment('../foo.csv')}", options);
1618
const resolvers = await getResolvers(page, options);
1719
assert.deepStrictEqual(resolvers.files, new Set([]));
1820
});
1921
it("detects file methods", async () => {
20-
const options = {root: "test/input", path: "attached.md"};
22+
const options = {root: "test/input", path: "attached.md", md};
2123
const page = parseMarkdown("${FileAttachment('foo.csv').csv}", options);
2224
const resolvers = await getResolvers(page, options);
2325
assert.deepStrictEqual(resolvers.staticImports, new Set(["npm:d3-dsv", ...builtins]));
2426
});
2527
it("detects local static imports", async () => {
26-
const options = {root: "test/input/imports", path: "attached.md"};
28+
const options = {root: "test/input/imports", path: "attached.md", md};
2729
const page = parseMarkdown("```js\nimport './bar.js';\n```", options);
2830
const resolvers = await getResolvers(page, options);
2931
assert.deepStrictEqual(resolvers.staticImports, new Set(["./bar.js", ...builtins]));
3032
assert.deepStrictEqual(resolvers.localImports, new Set(["./bar.js"]));
3133
});
3234
it("detects local transitive static imports", async () => {
33-
const options = {root: "test/input/imports", path: "attached.md"};
35+
const options = {root: "test/input/imports", path: "attached.md", md};
3436
const page = parseMarkdown("```js\nimport './other/foo.js';\n```", options);
3537
const resolvers = await getResolvers(page, options);
3638
assert.deepStrictEqual(resolvers.staticImports, new Set(["./other/foo.js", "./bar.js", ...builtins]));
3739
assert.deepStrictEqual(resolvers.localImports, new Set(["./other/foo.js", "./bar.js"]));
3840
});
3941
it("detects local transitive static imports (2)", async () => {
40-
const options = {root: "test/input/imports", path: "attached.md"};
42+
const options = {root: "test/input/imports", path: "attached.md", md};
4143
const page = parseMarkdown("```js\nimport './transitive-static-import.js';\n```", options);
4244
const resolvers = await getResolvers(page, options);
4345
assert.deepStrictEqual(resolvers.staticImports, new Set(["./transitive-static-import.js", "./other/foo.js", "./bar.js", ...builtins])); // prettier-ignore
4446
assert.deepStrictEqual(resolvers.localImports, new Set(["./transitive-static-import.js", "./other/foo.js", "./bar.js"])); // prettier-ignore
4547
});
4648
it("detects local transitive dynamic imports", async () => {
47-
const options = {root: "test/input/imports", path: "attached.md"};
49+
const options = {root: "test/input/imports", path: "attached.md", md};
4850
const page = parseMarkdown("```js\nimport './dynamic-import.js';\n```", options);
4951
const resolvers = await getResolvers(page, options);
5052
assert.deepStrictEqual(resolvers.staticImports, new Set(["./dynamic-import.js", ...builtins]));
5153
assert.deepStrictEqual(resolvers.localImports, new Set(["./dynamic-import.js", "./bar.js"]));
5254
});
5355
it("detects local transitive dynamic imports (2)", async () => {
54-
const options = {root: "test/input/imports", path: "attached.md"};
56+
const options = {root: "test/input/imports", path: "attached.md", md};
5557
const page = parseMarkdown("```js\nimport('./dynamic-import.js');\n```", options);
5658
const resolvers = await getResolvers(page, options);
5759
assert.deepStrictEqual(resolvers.staticImports, new Set(builtins));
5860
assert.deepStrictEqual(resolvers.localImports, new Set(["./dynamic-import.js", "./bar.js"]));
5961
});
6062
it("detects local transitive dynamic imports (3)", async () => {
61-
const options = {root: "test/input/imports", path: "attached.md"};
63+
const options = {root: "test/input/imports", path: "attached.md", md};
6264
const page = parseMarkdown("```js\nimport('./transitive-dynamic-import.js');\n```", options);
6365
const resolvers = await getResolvers(page, options);
6466
assert.deepStrictEqual(resolvers.staticImports, new Set(builtins));
6567
assert.deepStrictEqual(resolvers.localImports, new Set(["./transitive-dynamic-import.js", "./other/foo.js", "./bar.js"])); // prettier-ignore
6668
});
6769
it("detects local transitive dynamic imports (4)", async () => {
68-
const options = {root: "test/input/imports", path: "attached.md"};
70+
const options = {root: "test/input/imports", path: "attached.md", md};
6971
const page = parseMarkdown("```js\nimport('./transitive-static-import.js');\n```", options);
7072
const resolvers = await getResolvers(page, options);
7173
assert.deepStrictEqual(resolvers.staticImports, new Set(builtins));
7274
assert.deepStrictEqual(resolvers.localImports, new Set(["./transitive-static-import.js", "./other/foo.js", "./bar.js"])); // prettier-ignore
7375
});
7476
it("detects local dynamic imports", async () => {
75-
const options = {root: "test/input", path: "attached.md"};
77+
const options = {root: "test/input", path: "attached.md", md};
7678
const page = parseMarkdown("${import('./foo.js')}", options);
7779
const resolvers = await getResolvers(page, options);
7880
assert.deepStrictEqual(resolvers.staticImports, new Set(builtins));

0 commit comments

Comments
 (0)