@docspack/sheaf-astro 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,17 +17,47 @@ const { html, headings } = await renderPage(page);
17
17
  `.md` file in `src/pages`. Your Markdown configuration keeps applying, and a document reaches
18
18
  a reader through one pipeline whichever route served it.
19
19
 
20
+ A site that is not Astro should use [`@docspack/sheaf-html`](../sheaf-html) instead: the same
21
+ API on a plain unified pipeline, without Astro's renderer and its dependencies.
22
+
20
23
  ## The part that matters
21
24
 
22
25
  A table of contents is built from the graph's heading slugs; the anchors it links to are the
23
26
  `id`s this renderer stamps. When those disagree the failure is silent — the browser scrolls to
24
27
  the top instead of erroring. This package asserts they agree for every page it renders.
25
28
 
29
+ ## Stability
30
+
31
+ Sheaf's packages are `0.x` and release together, as one version (see the repository's
32
+ *Releasing* section). Until 1.0, a breaking change to anything listed as stable below lands only in
33
+ a minor release, and its changeset says **Breaking** and what to change. A patch release never
34
+ breaks.
35
+
36
+ **Stable:** `createPageRenderer`, `createMarkdownRenderer`, the `RenderedPage` shape, and the
37
+ rule that a heading's `id` is the graph's slug for it. **Not yet stable:** the `links` option.
38
+
26
39
  ## Options
27
40
 
28
41
  | | |
29
42
  | --- | --- |
30
43
  | `syntaxHighlight` | `false`, `"shiki"` or `"prism"`. Turn it off if your site paints its own code blocks. |
44
+ | `shikiConfig` | Passed to Astro's Shiki integration: a theme, languages, transformers. |
31
45
  | `gfm` | GitHub-flavoured Markdown. On by default. |
46
+ | `remarkPlugins` | Run over the parsed page. Pass `sheafDirectives` from `@docspack/sheaf-emit` here for `:::` blocks, in place of `remark-directive`. |
47
+ | `rehypePlugins` | Run over the HTML tree, after heading ids are set. |
48
+ | `links` | `{ graph, target, resolve? }`. Rewrites links between pages to `target(page, "site")`, fragment checked; other relative paths go to `resolve`. A broken link fails the render. |
49
+
50
+ ```ts
51
+ const render = createPageRenderer({
52
+ links: {
53
+ graph,
54
+ target: (page) => `/docs/${page.slug}`,
55
+ resolve: (path) => `https://github.com/acme/repo/blob/main/docs/${path}`,
56
+ },
57
+ });
58
+ ```
59
+
60
+ `createMarkdownRenderer(options)` returns the same processor for Markdown that is not a page;
61
+ pass the page as a second argument when the Markdown is part of one, so its links resolve from it.
32
62
 
33
63
  MIT © docspack
package/dist/render.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type RehypePlugins, type RemarkPlugins, type ShikiConfig } from "@astrojs/markdown-remark";
2
- import type { Heading, Page } from "@docspack/sheaf";
2
+ import { type ContentGraph, type Heading, type LinkOptions, type Page } from "@docspack/sheaf";
3
3
  /**
4
4
  * Rendering a graph's pages with Astro's own Markdown pipeline.
5
5
  *
@@ -57,15 +57,29 @@ export interface RendererOptions {
57
57
  *
58
58
  * This is the seam a `:::` block needs, and the reason it is here rather than left to the
59
59
  * host's `astro.config`: the graph's pages are rendered by *this* processor, so a plugin
60
- * configured for `src/pages` never sees them. `remark-directive` belongs on both sides —
61
- * `@docspack/sheaf-emit` parses with it too — because a block that one half understands and
62
- * the other reads as literal text is a page and a chunk describing different documentation.
60
+ * configured for `src/pages` never sees them. Pass `sheafDirectives` from
61
+ * `@docspack/sheaf-emit` here rather than `remark-directive`: it is the vocabulary that side
62
+ * parses with, so a block that one half understands and the other reads as literal text — or
63
+ * a `15:16:24` one half keeps and the other drops — cannot happen.
63
64
  */
64
65
  readonly remarkPlugins?: RemarkPlugins;
66
+ /**
67
+ * Rewrites relative links between pages — `./TOKENS.md#colors` — to `target(page, "site")`,
68
+ * fragment kept and checked against the target's headings, and any other relative path through
69
+ * `resolve`. A broken fragment or an unresolvable path fails the render, naming the page.
70
+ *
71
+ * Applies to pages only: Markdown rendered without a page has nothing to be relative to.
72
+ */
73
+ readonly links?: LinkOptions & {
74
+ readonly graph: ContentGraph;
75
+ };
65
76
  }
66
77
  export type PageRenderer = (page: Page) => Promise<RenderedPage>;
67
- /** Renders Markdown that is not a page. */
68
- export type SourceRenderer = (markdown: string) => Promise<RenderedPage>;
78
+ /**
79
+ * Renders Markdown. Given the page it belongs to — a page's `content`, or a part of it — its
80
+ * relative links resolve from that page and its frontmatter reaches the plugins.
81
+ */
82
+ export type SourceRenderer = (markdown: string, page?: Page) => Promise<RenderedPage>;
69
83
  /**
70
84
  * Builds one processor and returns a function that renders Markdown with it.
71
85
  *
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,aAAa,EAElB,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CACvC;AAED,mDAAmD;AACnD,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,OAAO,CAAC;IACrD,6FAA6F;IAC7F,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;;;;;;;OAQG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;CACxC;AAED,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;AAEjE,2CAA2C;AAC3C,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;AAEzE;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,eAAoB,GAAG,cAAc,CAsBpF;AAED,iDAAiD;AACjD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,GAAG,YAAY,CAK9E"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,aAAa,EAElB,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,OAAO,EACZ,KAAK,WAAW,EAEhB,KAAK,IAAI,EACV,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CACvC;AAED,mDAAmD;AACnD,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,OAAO,CAAC;IACrD,6FAA6F;IAC7F,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;;;;;;;;OASG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,GAAG;QAAE,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;KAAE,CAAC;CACjE;AA+CD,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;AAEjE;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,IAAI,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;AAEtF;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,eAAoB,GAAG,cAAc,CA+BpF;AAED,iDAAiD;AACjD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,GAAG,YAAY,CAK9E"}
package/dist/render.js CHANGED
@@ -1,4 +1,38 @@
1
1
  import { createMarkdownProcessor, rehypeHeadingIds, } from "@astrojs/markdown-remark";
2
+ import { linkHref, } from "@docspack/sheaf";
3
+ /**
4
+ * The remark side of `links`. The processor is shared by every page, so the page being rendered
5
+ * arrives the way Astro hands any document's metadata to a plugin: in its frontmatter, where
6
+ * `render` puts the page's slug.
7
+ *
8
+ * A broken link is recorded against that frontmatter object rather than thrown. Astro catches
9
+ * anything a plugin throws, logs it and rethrows it prefixed `Failed to parse Markdown file
10
+ * "undefined":` — a page here has no file URL, and giving it one would switch on Astro's image
11
+ * collection, which rewrites every `<img>`. `render` throws the recorded error once Astro returns,
12
+ * with the message `linkHref` wrote.
13
+ */
14
+ function rewritePageLinks(links, failures) {
15
+ return () => (tree, file) => {
16
+ const frontmatter = file.data.astro?.frontmatter;
17
+ const slug = frontmatter?.slug;
18
+ const from = links.graph.pages.find((page) => page.slug === slug);
19
+ if (from === undefined || frontmatter === undefined)
20
+ return;
21
+ const visit = (node) => {
22
+ if ((node.type === "link" || node.type === "definition") && node.url) {
23
+ node.url = linkHref(links.graph, from, node.url, links, "site");
24
+ }
25
+ for (const child of node.children ?? [])
26
+ visit(child);
27
+ };
28
+ try {
29
+ visit(tree);
30
+ }
31
+ catch (error) {
32
+ failures.set(frontmatter, error);
33
+ }
34
+ };
35
+ }
2
36
  /**
3
37
  * Builds one processor and returns a function that renders Markdown with it.
4
38
  *
@@ -12,13 +46,17 @@ import { createMarkdownProcessor, rehypeHeadingIds, } from "@astrojs/markdown-re
12
46
  */
13
47
  export function createMarkdownRenderer(options = {}) {
14
48
  let pending;
49
+ const failures = new WeakMap();
15
50
  const renderer = () => {
16
51
  pending ??= createMarkdownProcessor({
17
52
  gfm: options.gfm ?? true,
18
53
  ...(options.syntaxHighlight === undefined
19
54
  ? {}
20
55
  : { syntaxHighlight: options.syntaxHighlight }),
21
- ...(options.remarkPlugins === undefined ? {} : { remarkPlugins: options.remarkPlugins }),
56
+ remarkPlugins: [
57
+ ...(options.remarkPlugins ?? []),
58
+ ...(options.links === undefined ? [] : [rewritePageLinks(options.links, failures)]),
59
+ ],
22
60
  ...(options.rehypePlugins === undefined
23
61
  ? {}
24
62
  : { rehypePlugins: [rehypeHeadingIds, ...options.rehypePlugins] }),
@@ -26,8 +64,11 @@ export function createMarkdownRenderer(options = {}) {
26
64
  });
27
65
  return pending;
28
66
  };
29
- return async (markdown) => {
30
- const { code, metadata } = await (await renderer()).render(markdown);
67
+ return async (markdown, page) => {
68
+ const frontmatter = page === undefined ? undefined : { ...page.frontmatter, slug: page.slug };
69
+ const { code, metadata } = await (await renderer()).render(markdown, frontmatter === undefined ? undefined : { frontmatter });
70
+ if (frontmatter !== undefined && failures.has(frontmatter))
71
+ throw failures.get(frontmatter);
31
72
  return { html: code, headings: metadata.headings };
32
73
  };
33
74
  }
@@ -36,6 +77,6 @@ export function createPageRenderer(options = {}) {
36
77
  const render = createMarkdownRenderer(options);
37
78
  // `page.content` rather than `page.body`: the shell renders the title itself, and a second `<h1>`
38
79
  // from the body would be a duplicate in the document outline.
39
- return (page) => render(page.content);
80
+ return (page) => render(page.content, page);
40
81
  }
41
82
  //# sourceMappingURL=render.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EAIvB,gBAAgB,GAEjB,MAAM,0BAA0B,CAAC;AA0ElC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,sBAAsB,CAAC,UAA2B,EAAE;IAClE,IAAI,OAA8C,CAAC;IAEnD,MAAM,QAAQ,GAAG,GAA8B,EAAE;QAC/C,OAAO,KAAK,uBAAuB,CAAC;YAClC,GAAG,EAAE,OAAO,CAAC,GAAG,IAAI,IAAI;YACxB,GAAG,CAAC,OAAO,CAAC,eAAe,KAAK,SAAS;gBACvC,CAAC,CAAC,EAAE;gBACJ,CAAC,CAAC,EAAE,eAAe,EAAE,OAAO,CAAC,eAAe,EAAE,CAAC;YACjD,GAAG,CAAC,OAAO,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,aAAa,EAAE,CAAC;YACxF,GAAG,CAAC,OAAO,CAAC,aAAa,KAAK,SAAS;gBACrC,CAAC,CAAC,EAAE;gBACJ,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,gBAAgB,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC,EAAE,CAAC;YACpE,GAAG,CAAC,OAAO,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC;SACnF,CAAC,CAAC;QACH,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IAEF,OAAO,KAAK,EAAE,QAAQ,EAAE,EAAE;QACxB,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,MAAM,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QACrE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC;IACrD,CAAC,CAAC;AACJ,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,kBAAkB,CAAC,UAA2B,EAAE;IAC9D,MAAM,MAAM,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAC/C,kGAAkG;IAClG,8DAA8D;IAC9D,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACxC,CAAC"}
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docspack/sheaf-astro",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Renders a Sheaf content graph with Astro's own Markdown pipeline, so a docs site and its agent index come from one parse.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@astrojs/markdown-remark": "7.2.0",
31
- "@docspack/sheaf": "^0.1.0"
31
+ "@docspack/sheaf": "^0.4.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@docspack/config": "0.1.0",
package/src/render.ts CHANGED
@@ -6,7 +6,13 @@ import {
6
6
  rehypeHeadingIds,
7
7
  type ShikiConfig,
8
8
  } from "@astrojs/markdown-remark";
9
- import type { Heading, Page } from "@docspack/sheaf";
9
+ import {
10
+ type ContentGraph,
11
+ type Heading,
12
+ type LinkOptions,
13
+ linkHref,
14
+ type Page,
15
+ } from "@docspack/sheaf";
10
16
 
11
17
  /**
12
18
  * Rendering a graph's pages with Astro's own Markdown pipeline.
@@ -67,17 +73,74 @@ export interface RendererOptions {
67
73
  *
68
74
  * This is the seam a `:::` block needs, and the reason it is here rather than left to the
69
75
  * host's `astro.config`: the graph's pages are rendered by *this* processor, so a plugin
70
- * configured for `src/pages` never sees them. `remark-directive` belongs on both sides —
71
- * `@docspack/sheaf-emit` parses with it too — because a block that one half understands and
72
- * the other reads as literal text is a page and a chunk describing different documentation.
76
+ * configured for `src/pages` never sees them. Pass `sheafDirectives` from
77
+ * `@docspack/sheaf-emit` here rather than `remark-directive`: it is the vocabulary that side
78
+ * parses with, so a block that one half understands and the other reads as literal text — or
79
+ * a `15:16:24` one half keeps and the other drops — cannot happen.
73
80
  */
74
81
  readonly remarkPlugins?: RemarkPlugins;
82
+ /**
83
+ * Rewrites relative links between pages — `./TOKENS.md#colors` — to `target(page, "site")`,
84
+ * fragment kept and checked against the target's headings, and any other relative path through
85
+ * `resolve`. A broken fragment or an unresolvable path fails the render, naming the page.
86
+ *
87
+ * Applies to pages only: Markdown rendered without a page has nothing to be relative to.
88
+ */
89
+ readonly links?: LinkOptions & { readonly graph: ContentGraph };
90
+ }
91
+
92
+ interface MdastNode {
93
+ type: string;
94
+ url?: string;
95
+ children?: MdastNode[];
96
+ }
97
+
98
+ interface AstroFile {
99
+ readonly data: { readonly astro?: { readonly frontmatter?: Record<string, unknown> } };
100
+ }
101
+
102
+ /**
103
+ * The remark side of `links`. The processor is shared by every page, so the page being rendered
104
+ * arrives the way Astro hands any document's metadata to a plugin: in its frontmatter, where
105
+ * `render` puts the page's slug.
106
+ *
107
+ * A broken link is recorded against that frontmatter object rather than thrown. Astro catches
108
+ * anything a plugin throws, logs it and rethrows it prefixed `Failed to parse Markdown file
109
+ * "undefined":` — a page here has no file URL, and giving it one would switch on Astro's image
110
+ * collection, which rewrites every `<img>`. `render` throws the recorded error once Astro returns,
111
+ * with the message `linkHref` wrote.
112
+ */
113
+ function rewritePageLinks(
114
+ links: NonNullable<RendererOptions["links"]>,
115
+ failures: WeakMap<object, unknown>,
116
+ ) {
117
+ return () => (tree: MdastNode, file: AstroFile) => {
118
+ const frontmatter = file.data.astro?.frontmatter;
119
+ const slug = frontmatter?.slug;
120
+ const from = links.graph.pages.find((page) => page.slug === slug);
121
+ if (from === undefined || frontmatter === undefined) return;
122
+
123
+ const visit = (node: MdastNode) => {
124
+ if ((node.type === "link" || node.type === "definition") && node.url) {
125
+ node.url = linkHref(links.graph, from, node.url, links, "site");
126
+ }
127
+ for (const child of node.children ?? []) visit(child);
128
+ };
129
+ try {
130
+ visit(tree);
131
+ } catch (error) {
132
+ failures.set(frontmatter, error);
133
+ }
134
+ };
75
135
  }
76
136
 
77
137
  export type PageRenderer = (page: Page) => Promise<RenderedPage>;
78
138
 
79
- /** Renders Markdown that is not a page. */
80
- export type SourceRenderer = (markdown: string) => Promise<RenderedPage>;
139
+ /**
140
+ * Renders Markdown. Given the page it belongs to — a page's `content`, or a part of it — its
141
+ * relative links resolve from that page and its frontmatter reaches the plugins.
142
+ */
143
+ export type SourceRenderer = (markdown: string, page?: Page) => Promise<RenderedPage>;
81
144
 
82
145
  /**
83
146
  * Builds one processor and returns a function that renders Markdown with it.
@@ -92,6 +155,7 @@ export type SourceRenderer = (markdown: string) => Promise<RenderedPage>;
92
155
  */
93
156
  export function createMarkdownRenderer(options: RendererOptions = {}): SourceRenderer {
94
157
  let pending: Promise<MarkdownRenderer> | undefined;
158
+ const failures = new WeakMap<object, unknown>();
95
159
 
96
160
  const renderer = (): Promise<MarkdownRenderer> => {
97
161
  pending ??= createMarkdownProcessor({
@@ -99,7 +163,10 @@ export function createMarkdownRenderer(options: RendererOptions = {}): SourceRen
99
163
  ...(options.syntaxHighlight === undefined
100
164
  ? {}
101
165
  : { syntaxHighlight: options.syntaxHighlight }),
102
- ...(options.remarkPlugins === undefined ? {} : { remarkPlugins: options.remarkPlugins }),
166
+ remarkPlugins: [
167
+ ...(options.remarkPlugins ?? []),
168
+ ...(options.links === undefined ? [] : [rewritePageLinks(options.links, failures)]),
169
+ ],
103
170
  ...(options.rehypePlugins === undefined
104
171
  ? {}
105
172
  : { rehypePlugins: [rehypeHeadingIds, ...options.rehypePlugins] }),
@@ -108,8 +175,13 @@ export function createMarkdownRenderer(options: RendererOptions = {}): SourceRen
108
175
  return pending;
109
176
  };
110
177
 
111
- return async (markdown) => {
112
- const { code, metadata } = await (await renderer()).render(markdown);
178
+ return async (markdown, page) => {
179
+ const frontmatter = page === undefined ? undefined : { ...page.frontmatter, slug: page.slug };
180
+ const { code, metadata } = await (await renderer()).render(
181
+ markdown,
182
+ frontmatter === undefined ? undefined : { frontmatter },
183
+ );
184
+ if (frontmatter !== undefined && failures.has(frontmatter)) throw failures.get(frontmatter);
113
185
  return { html: code, headings: metadata.headings };
114
186
  };
115
187
  }
@@ -119,5 +191,5 @@ export function createPageRenderer(options: RendererOptions = {}): PageRenderer
119
191
  const render = createMarkdownRenderer(options);
120
192
  // `page.content` rather than `page.body`: the shell renders the title itself, and a second `<h1>`
121
193
  // from the body would be a duplicate in the document outline.
122
- return (page) => render(page.content);
194
+ return (page) => render(page.content, page);
123
195
  }