@docspack/sheaf-astro 0.0.1 → 0.3.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/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createPageRenderer, type PageRenderer, type RenderedPage, type RendererOptions, } from "./render.js";
1
+ export { createMarkdownRenderer, createPageRenderer, type PageRenderer, type RenderedPage, type RendererOptions, type SourceRenderer, } from "./render.js";
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,cAAc,GACpB,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { createPageRenderer, } from "./render.js";
1
+ export { createMarkdownRenderer, createPageRenderer, } from "./render.js";
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,GAInB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,kBAAkB,GAKnB,MAAM,aAAa,CAAC"}
package/dist/render.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Heading, Page } from "@docspack/sheaf";
1
+ import { type RehypePlugins, type RemarkPlugins, type ShikiConfig } from "@astrojs/markdown-remark";
2
+ import { type ContentGraph, type Heading, type LinkOptions, type Page } from "@docspack/sheaf";
2
3
  /**
3
4
  * Rendering a graph's pages with Astro's own Markdown pipeline.
4
5
  *
@@ -26,18 +27,71 @@ export interface RenderedPage {
26
27
  /** Options passed through to Astro's processor. */
27
28
  export interface RendererOptions {
28
29
  /**
29
- * Astro highlights code by default. A site that paints its own code-block style should turn
30
- * this off, or it ships a second theme's colours over the top of its own.
30
+ * Astro highlights code by default, in its own theme's colours.
31
+ *
32
+ * A site with a code-block palette of its own has a better option than turning this off:
33
+ * Shiki takes a theme whose colours are arbitrary CSS strings, `var(--…)` included, so the
34
+ * host can hand it the same custom properties the rest of the page reads. See `shikiConfig`.
31
35
  */
32
36
  readonly syntaxHighlight?: false | "shiki" | "prism";
37
+ /** Shiki's configuration, including the theme. Only read when `syntaxHighlight` is Shiki. */
38
+ readonly shikiConfig?: ShikiConfig;
33
39
  readonly gfm?: boolean;
40
+ /**
41
+ * Rehype plugins to run over the rendered tree.
42
+ *
43
+ * Passed through rather than curated here, because what a document's HTML needs is the
44
+ * host's decision — an anchor on every heading, a `tabindex` on a code block the host's CSS
45
+ * made scrollable. This package binds to the host's renderer; it does not have opinions
46
+ * about the host's markup.
47
+ *
48
+ * These run **after** heading ids exist. Astro stamps ids last, so a host plugin that wanted
49
+ * to work with one — the anchor case, which is most of why a docs site reaches for this —
50
+ * saw `undefined` and silently did nothing. This package exists for that seam, so it runs
51
+ * Astro's own `rehypeHeadingIds` first. Astro runs it again at the end and it is idempotent:
52
+ * an id already set is left alone.
53
+ */
54
+ readonly rehypePlugins?: RehypePlugins;
55
+ /**
56
+ * Remark plugins to run over the parsed document, before it becomes HTML.
57
+ *
58
+ * This is the seam a `:::` block needs, and the reason it is here rather than left to the
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. 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.
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
+ };
34
76
  }
35
77
  export type PageRenderer = (page: Page) => Promise<RenderedPage>;
36
78
  /**
37
- * Builds one processor and returns a function that renders pages with it.
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>;
83
+ /**
84
+ * Builds one processor and returns a function that renders Markdown with it.
85
+ *
86
+ * Not every piece of Markdown a documentation site renders is a page. An OpenAPI operation's
87
+ * `description` is Markdown, and so is a frontmatter lede; both have to go through *this*
88
+ * processor rather than a second one, or the site renders two dialects — a backtick that becomes a
89
+ * code chip on one route and stays a backtick on another.
38
90
  *
39
- * Building a processor compiles a plugin pipeline, which is slow enough that doing it per page
91
+ * Building a processor compiles a plugin pipeline, which is slow enough that doing it per document
40
92
  * is noticeable on a corpus of any size — so it is built once, lazily, and shared.
41
93
  */
94
+ export declare function createMarkdownRenderer(options?: RendererOptions): SourceRenderer;
95
+ /** The same processor, for the graph's pages. */
42
96
  export declare function createPageRenderer(options?: RendererOptions): PageRenderer;
43
97
  //# sourceMappingURL=render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AACA,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;;;OAGG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,OAAO,CAAC;IACrD,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;AAEjE;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,GAAG,YAAY,CAoB9E"}
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;AAiCD,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,CA4BpF;AAED,iDAAiD;AACjD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,GAAG,YAAY,CAK9E"}
package/dist/render.js CHANGED
@@ -1,11 +1,38 @@
1
- import { createMarkdownProcessor } from "@astrojs/markdown-remark";
1
+ import { createMarkdownProcessor, rehypeHeadingIds, } from "@astrojs/markdown-remark";
2
+ import { linkHref, } from "@docspack/sheaf";
2
3
  /**
3
- * Builds one processor and returns a function that renders pages with it.
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
+ function rewritePageLinks(links) {
9
+ return () => (tree, file) => {
10
+ const slug = file.data.astro?.frontmatter?.slug;
11
+ const from = links.graph.pages.find((page) => page.slug === slug);
12
+ if (from === undefined)
13
+ return;
14
+ const visit = (node) => {
15
+ if ((node.type === "link" || node.type === "definition") && node.url) {
16
+ node.url = linkHref(links.graph, from, node.url, links, "site");
17
+ }
18
+ for (const child of node.children ?? [])
19
+ visit(child);
20
+ };
21
+ visit(tree);
22
+ };
23
+ }
24
+ /**
25
+ * Builds one processor and returns a function that renders Markdown with it.
26
+ *
27
+ * Not every piece of Markdown a documentation site renders is a page. An OpenAPI operation's
28
+ * `description` is Markdown, and so is a frontmatter lede; both have to go through *this*
29
+ * processor rather than a second one, or the site renders two dialects — a backtick that becomes a
30
+ * code chip on one route and stays a backtick on another.
4
31
  *
5
- * Building a processor compiles a plugin pipeline, which is slow enough that doing it per page
32
+ * Building a processor compiles a plugin pipeline, which is slow enough that doing it per document
6
33
  * is noticeable on a corpus of any size — so it is built once, lazily, and shared.
7
34
  */
8
- export function createPageRenderer(options = {}) {
35
+ export function createMarkdownRenderer(options = {}) {
9
36
  let pending;
10
37
  const renderer = () => {
11
38
  pending ??= createMarkdownProcessor({
@@ -13,14 +40,27 @@ export function createPageRenderer(options = {}) {
13
40
  ...(options.syntaxHighlight === undefined
14
41
  ? {}
15
42
  : { syntaxHighlight: options.syntaxHighlight }),
43
+ remarkPlugins: [
44
+ ...(options.remarkPlugins ?? []),
45
+ ...(options.links === undefined ? [] : [rewritePageLinks(options.links)]),
46
+ ],
47
+ ...(options.rehypePlugins === undefined
48
+ ? {}
49
+ : { rehypePlugins: [rehypeHeadingIds, ...options.rehypePlugins] }),
50
+ ...(options.shikiConfig === undefined ? {} : { shikiConfig: options.shikiConfig }),
16
51
  });
17
52
  return pending;
18
53
  };
19
- return async (page) => {
20
- // `page.content` rather than `page.body`: the shell renders the title itself, and a second
21
- // `<h1>` from the body would be a duplicate in the document outline.
22
- const { code, metadata } = await (await renderer()).render(page.content);
54
+ return async (markdown, page) => {
55
+ const { code, metadata } = await (await renderer()).render(markdown, page === undefined ? undefined : { frontmatter: { ...page.frontmatter, slug: page.slug } });
23
56
  return { html: code, headings: metadata.headings };
24
57
  };
25
58
  }
59
+ /** The same processor, for the graph's pages. */
60
+ export function createPageRenderer(options = {}) {
61
+ const render = createMarkdownRenderer(options);
62
+ // `page.content` rather than `page.body`: the shell renders the title itself, and a second `<h1>`
63
+ // from the body would be a duplicate in the document outline.
64
+ return (page) => render(page.content, page);
65
+ }
26
66
  //# sourceMappingURL=render.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAyB,MAAM,0BAA0B,CAAC;AAyC1F;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,UAA2B,EAAE;IAC9D,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;SAClD,CAAC,CAAC;QACH,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IAEF,OAAO,KAAK,EAAE,IAAI,EAAE,EAAE;QACpB,2FAA2F;QAC3F,qEAAqE;QACrE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,MAAM,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAEzE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC;IACrD,CAAC,CAAC;AACJ,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.0.1",
3
+ "version": "0.3.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,13 +28,13 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@astrojs/markdown-remark": "7.2.0",
31
- "@docspack/sheaf": "^0.0.1"
31
+ "@docspack/sheaf": "^0.3.0"
32
32
  },
33
33
  "devDependencies": {
34
+ "@docspack/config": "0.1.0",
34
35
  "@types/node": "26.2.0",
35
36
  "typescript": "6.0.3",
36
- "vitest": "4.1.10",
37
- "@docspack/config": "0.1.0"
37
+ "vitest": "4.1.10"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export {
2
+ createMarkdownRenderer,
2
3
  createPageRenderer,
3
4
  type PageRenderer,
4
5
  type RenderedPage,
5
6
  type RendererOptions,
7
+ type SourceRenderer,
6
8
  } from "./render.js";
package/src/render.ts CHANGED
@@ -1,5 +1,18 @@
1
- import { createMarkdownProcessor, type MarkdownRenderer } from "@astrojs/markdown-remark";
2
- import type { Heading, Page } from "@docspack/sheaf";
1
+ import {
2
+ createMarkdownProcessor,
3
+ type MarkdownRenderer,
4
+ type RehypePlugins,
5
+ type RemarkPlugins,
6
+ rehypeHeadingIds,
7
+ type ShikiConfig,
8
+ } from "@astrojs/markdown-remark";
9
+ import {
10
+ type ContentGraph,
11
+ type Heading,
12
+ type LinkOptions,
13
+ linkHref,
14
+ type Page,
15
+ } from "@docspack/sheaf";
3
16
 
4
17
  /**
5
18
  * Rendering a graph's pages with Astro's own Markdown pipeline.
@@ -30,22 +43,103 @@ export interface RenderedPage {
30
43
  /** Options passed through to Astro's processor. */
31
44
  export interface RendererOptions {
32
45
  /**
33
- * Astro highlights code by default. A site that paints its own code-block style should turn
34
- * this off, or it ships a second theme's colours over the top of its own.
46
+ * Astro highlights code by default, in its own theme's colours.
47
+ *
48
+ * A site with a code-block palette of its own has a better option than turning this off:
49
+ * Shiki takes a theme whose colours are arbitrary CSS strings, `var(--…)` included, so the
50
+ * host can hand it the same custom properties the rest of the page reads. See `shikiConfig`.
35
51
  */
36
52
  readonly syntaxHighlight?: false | "shiki" | "prism";
53
+ /** Shiki's configuration, including the theme. Only read when `syntaxHighlight` is Shiki. */
54
+ readonly shikiConfig?: ShikiConfig;
37
55
  readonly gfm?: boolean;
56
+ /**
57
+ * Rehype plugins to run over the rendered tree.
58
+ *
59
+ * Passed through rather than curated here, because what a document's HTML needs is the
60
+ * host's decision — an anchor on every heading, a `tabindex` on a code block the host's CSS
61
+ * made scrollable. This package binds to the host's renderer; it does not have opinions
62
+ * about the host's markup.
63
+ *
64
+ * These run **after** heading ids exist. Astro stamps ids last, so a host plugin that wanted
65
+ * to work with one — the anchor case, which is most of why a docs site reaches for this —
66
+ * saw `undefined` and silently did nothing. This package exists for that seam, so it runs
67
+ * Astro's own `rehypeHeadingIds` first. Astro runs it again at the end and it is idempotent:
68
+ * an id already set is left alone.
69
+ */
70
+ readonly rehypePlugins?: RehypePlugins;
71
+ /**
72
+ * Remark plugins to run over the parsed document, before it becomes HTML.
73
+ *
74
+ * This is the seam a `:::` block needs, and the reason it is here rather than left to the
75
+ * host's `astro.config`: the graph's pages are rendered by *this* processor, so a plugin
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.
80
+ */
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
+ function rewritePageLinks(links: NonNullable<RendererOptions["links"]>) {
108
+ return () => (tree: MdastNode, file: AstroFile) => {
109
+ const slug = file.data.astro?.frontmatter?.slug;
110
+ const from = links.graph.pages.find((page) => page.slug === slug);
111
+ if (from === undefined) return;
112
+
113
+ const visit = (node: MdastNode) => {
114
+ if ((node.type === "link" || node.type === "definition") && node.url) {
115
+ node.url = linkHref(links.graph, from, node.url, links, "site");
116
+ }
117
+ for (const child of node.children ?? []) visit(child);
118
+ };
119
+ visit(tree);
120
+ };
38
121
  }
39
122
 
40
123
  export type PageRenderer = (page: Page) => Promise<RenderedPage>;
41
124
 
42
125
  /**
43
- * Builds one processor and returns a function that renders pages with it.
126
+ * Renders Markdown. Given the page it belongs to — a page's `content`, or a part of it — its
127
+ * relative links resolve from that page and its frontmatter reaches the plugins.
128
+ */
129
+ export type SourceRenderer = (markdown: string, page?: Page) => Promise<RenderedPage>;
130
+
131
+ /**
132
+ * Builds one processor and returns a function that renders Markdown with it.
44
133
  *
45
- * Building a processor compiles a plugin pipeline, which is slow enough that doing it per page
134
+ * Not every piece of Markdown a documentation site renders is a page. An OpenAPI operation's
135
+ * `description` is Markdown, and so is a frontmatter lede; both have to go through *this*
136
+ * processor rather than a second one, or the site renders two dialects — a backtick that becomes a
137
+ * code chip on one route and stays a backtick on another.
138
+ *
139
+ * Building a processor compiles a plugin pipeline, which is slow enough that doing it per document
46
140
  * is noticeable on a corpus of any size — so it is built once, lazily, and shared.
47
141
  */
48
- export function createPageRenderer(options: RendererOptions = {}): PageRenderer {
142
+ export function createMarkdownRenderer(options: RendererOptions = {}): SourceRenderer {
49
143
  let pending: Promise<MarkdownRenderer> | undefined;
50
144
 
51
145
  const renderer = (): Promise<MarkdownRenderer> => {
@@ -54,15 +148,31 @@ export function createPageRenderer(options: RendererOptions = {}): PageRenderer
54
148
  ...(options.syntaxHighlight === undefined
55
149
  ? {}
56
150
  : { syntaxHighlight: options.syntaxHighlight }),
151
+ remarkPlugins: [
152
+ ...(options.remarkPlugins ?? []),
153
+ ...(options.links === undefined ? [] : [rewritePageLinks(options.links)]),
154
+ ],
155
+ ...(options.rehypePlugins === undefined
156
+ ? {}
157
+ : { rehypePlugins: [rehypeHeadingIds, ...options.rehypePlugins] }),
158
+ ...(options.shikiConfig === undefined ? {} : { shikiConfig: options.shikiConfig }),
57
159
  });
58
160
  return pending;
59
161
  };
60
162
 
61
- return async (page) => {
62
- // `page.content` rather than `page.body`: the shell renders the title itself, and a second
63
- // `<h1>` from the body would be a duplicate in the document outline.
64
- const { code, metadata } = await (await renderer()).render(page.content);
65
-
163
+ return async (markdown, page) => {
164
+ const { code, metadata } = await (await renderer()).render(
165
+ markdown,
166
+ page === undefined ? undefined : { frontmatter: { ...page.frontmatter, slug: page.slug } },
167
+ );
66
168
  return { html: code, headings: metadata.headings };
67
169
  };
68
170
  }
171
+
172
+ /** The same processor, for the graph's pages. */
173
+ export function createPageRenderer(options: RendererOptions = {}): PageRenderer {
174
+ const render = createMarkdownRenderer(options);
175
+ // `page.content` rather than `page.body`: the shell renders the title itself, and a second `<h1>`
176
+ // from the body would be a duplicate in the document outline.
177
+ return (page) => render(page.content, page);
178
+ }