@docpensieve/core 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +58 -0
- package/src/compiler.js +415 -0
- package/src/config.js +265 -0
- package/src/generator.js +517 -0
- package/src/index.js +35 -0
- package/src/loader.js +260 -0
- package/src/sidebar.js +105 -0
- package/src/structured-data.js +334 -0
- package/templates/layout.hbs +94 -0
- package/templates/nav-items.hbs +14 -0
- package/templates/toc-items.hbs +10 -0
- package/types/compiler.d.ts +108 -0
- package/types/config.d.ts +149 -0
- package/types/generator.d.ts +82 -0
- package/types/index.d.ts +34 -0
- package/types/loader.d.ts +66 -0
- package/types/sidebar.d.ts +60 -0
- package/types/structured-data.d.ts +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valentin Chevoleau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @docpensieve/core
|
|
2
|
+
|
|
3
|
+
> Generation engine
|
|
4
|
+
|
|
5
|
+
Part of [DocPensieve](https://github.com/Juniors017/docpensieve), a static documentation site generator:
|
|
6
|
+
Markdown and MDX in, static HTML out, one version per orphan branch, JSON-LD
|
|
7
|
+
structured data from the frontmatter.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @docpensieve/core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Source loading, MDX compilation, structured data and site writing. The engine
|
|
18
|
+
depends neither on the theme nor on the components: they are **injected**,
|
|
19
|
+
which keeps it testable without React or CSS.
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { loadConfig, DocLoader, Compiler, SiteGenerator } from '@docpensieve/core';
|
|
23
|
+
|
|
24
|
+
const config = await loadConfig();
|
|
25
|
+
const generator = new SiteGenerator(config, { theme, components });
|
|
26
|
+
await generator.buildAll();
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`.md` and `.mdx` both go through MDX, then `react-dom/server`. React is a
|
|
30
|
+
**build-only** dependency: the produced HTML loads no runtime.
|
|
31
|
+
|
|
32
|
+
## Documentation
|
|
33
|
+
|
|
34
|
+
See the [repository](https://github.com/Juniors017/docpensieve#readme).
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@docpensieve/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.0.0"
|
|
9
|
+
},
|
|
10
|
+
"main": "./src/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./types/index.d.ts",
|
|
14
|
+
"default": "./src/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"templates",
|
|
20
|
+
"types"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@docpensieve/shared": "^0.1.0",
|
|
24
|
+
"@mdx-js/mdx": "^3.1.1",
|
|
25
|
+
"@shikijs/rehype": "^4.4.3",
|
|
26
|
+
"gray-matter": "^4.0.3",
|
|
27
|
+
"handlebars": "^4.7.9",
|
|
28
|
+
"react": "^19.3.0",
|
|
29
|
+
"react-dom": "^19.3.0",
|
|
30
|
+
"remark-gfm": "^4.0.1"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"docpensieve",
|
|
34
|
+
"documentation",
|
|
35
|
+
"static-site-generator",
|
|
36
|
+
"mdx",
|
|
37
|
+
"markdown",
|
|
38
|
+
"json-ld",
|
|
39
|
+
"seo"
|
|
40
|
+
],
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Juniors017/docpensieve.git",
|
|
44
|
+
"directory": "packages/core"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/Juniors017/docpensieve#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/Juniors017/docpensieve/issues"
|
|
49
|
+
},
|
|
50
|
+
"author": "Valentin Chevoleau (Juniors017)",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"types": "./types/index.d.ts",
|
|
55
|
+
"scripts": {
|
|
56
|
+
"prepack": "tsc -b tsconfig.build.json"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/compiler.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles Markdown/MDX content into static HTML.
|
|
3
|
+
*
|
|
4
|
+
* Chosen pipeline: `.md` and `.mdx` both go through @mdx-js/mdx, then the
|
|
5
|
+
* resulting component is rendered by `react-dom/server`. React only serves
|
|
6
|
+
* the build; the HTML produced contains no React runtime.
|
|
7
|
+
*
|
|
8
|
+
* @module @docpensieve/core/compiler
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createElement } from 'react';
|
|
12
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
13
|
+
import * as runtime from 'react/jsx-runtime';
|
|
14
|
+
|
|
15
|
+
import { CompileError, slugify } from '@docpensieve/shared';
|
|
16
|
+
import { evaluate } from '@mdx-js/mdx';
|
|
17
|
+
import rehypeShiki from '@shikijs/rehype';
|
|
18
|
+
import remarkGfm from 'remark-gfm';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default Shiki themes: the dual theme follows dark mode without JS.
|
|
22
|
+
*
|
|
23
|
+
* The plugin keeps its highlighter in a module singleton: loading the engine
|
|
24
|
+
* and the grammars (~4 s) is paid once per process, not per page. No need,
|
|
25
|
+
* then, to cache a highlighter on the instance.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_HIGHLIGHT = { themes: { light: 'github-light', dark: 'github-dark' } };
|
|
28
|
+
|
|
29
|
+
/** Heading depths kept for the table of contents. */
|
|
30
|
+
const DEFAULT_TOC_DEPTH = [2, 3];
|
|
31
|
+
|
|
32
|
+
/** Targets left as they are: external link, anchor, mailto, data:. */
|
|
33
|
+
const EXTERNAL_TARGET = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attributes carrying a target, per tag.
|
|
37
|
+
* @type {Record<string, string>}
|
|
38
|
+
*/
|
|
39
|
+
const LINK_ATTRIBUTES = { img: 'src', a: 'href', source: 'src', video: 'src', audio: 'src' };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Preload tags that React 19 hoists to the top of its output.
|
|
43
|
+
*
|
|
44
|
+
* React emits one `<link rel="preload">` per image, before the content, with
|
|
45
|
+
* no way to turn it off. They belong in the `<head>`: take them out of the
|
|
46
|
+
* fragment and hand them back to the caller rather than lose them.
|
|
47
|
+
*/
|
|
48
|
+
const PRELOAD_TAG = /<link\b[^>]*\brel="preload"[^>]*>/g;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extracts the value of an HTML attribute from a serialised tag.
|
|
52
|
+
* @param {string} tag
|
|
53
|
+
* @param {string} name
|
|
54
|
+
*/
|
|
55
|
+
const attribute = (tag, name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} TocEntry
|
|
59
|
+
* @property {string} id Heading anchor (`'installation'`).
|
|
60
|
+
* @property {string} text Heading text, tags removed.
|
|
61
|
+
* @property {number} depth Heading level (2 for `h2`).
|
|
62
|
+
* @property {TocEntry[]} children Nested sub-headings.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {object} Preload
|
|
67
|
+
* @property {string} href Resource to preload.
|
|
68
|
+
* @property {string} as Resource type (`'image'`).
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} CompileResult
|
|
73
|
+
* @property {string} html HTML fragment of the content alone.
|
|
74
|
+
* @property {TocEntry[]} toc Nested table of contents.
|
|
75
|
+
* @property {Preload[]} preloads Resources to preload, meant for the `<head>`.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walks a hast tree depth first.
|
|
80
|
+
*
|
|
81
|
+
* Avoids a dependency on `unist-util-visit` for a dozen lines.
|
|
82
|
+
*
|
|
83
|
+
* @param {any} node
|
|
84
|
+
* @param {(node: any) => void} visitor
|
|
85
|
+
*/
|
|
86
|
+
function walk(node, visitor) {
|
|
87
|
+
visitor(node);
|
|
88
|
+
for (const child of node.children ?? []) walk(child, visitor);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Concatenates the text of a hast node, inline tags included.
|
|
93
|
+
*
|
|
94
|
+
* @param {any} node
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function textOf(node) {
|
|
98
|
+
if (node.type === 'text') return node.value;
|
|
99
|
+
return (node.children ?? []).map(textOf).join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Rehype plugin: sets an anchor on every heading and collects the table of
|
|
104
|
+
* contents.
|
|
105
|
+
*
|
|
106
|
+
* Anchors go through `slugify`, like page slugs: a project written in an
|
|
107
|
+
* accented language should not have to choose between accented URLs here
|
|
108
|
+
* and clean ones elsewhere. That is also why `rehype-slug` is not used, since
|
|
109
|
+
* it keeps accents.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ id: string, text: string, depth: number }[]} collected Filled in place.
|
|
112
|
+
* @returns {() => (tree: any) => void}
|
|
113
|
+
*/
|
|
114
|
+
function rehypeHeadingIds(collected) {
|
|
115
|
+
return () => (tree) => {
|
|
116
|
+
/** @type {Map<string, number>} */
|
|
117
|
+
const counts = new Map();
|
|
118
|
+
|
|
119
|
+
walk(tree, (node) => {
|
|
120
|
+
if (node.type !== 'element' || !/^h[1-6]$/.test(node.tagName)) return;
|
|
121
|
+
|
|
122
|
+
const text = textOf(node).trim();
|
|
123
|
+
const base = slugify(text) || 'section';
|
|
124
|
+
|
|
125
|
+
// Two identical headings in a page are legitimate: suffix the second
|
|
126
|
+
// rather than produce two colliding anchors.
|
|
127
|
+
const seen = counts.get(base) ?? 0;
|
|
128
|
+
counts.set(base, seen + 1);
|
|
129
|
+
const id = seen === 0 ? base : `${base}-${seen}`;
|
|
130
|
+
|
|
131
|
+
node.properties = { ...node.properties, id };
|
|
132
|
+
collected.push({ id, text, depth: Number(node.tagName.slice(1)) });
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rehype plugin: wraps every table in a scrolling container.
|
|
139
|
+
*
|
|
140
|
+
* A table wider than its column must scroll on its own, without widening the
|
|
141
|
+
* page. Scrolling the table itself required `display: block`, which stripped
|
|
142
|
+
* its table role: a screen reader no longer announced rows or columns. The
|
|
143
|
+
* wrapper carries the scrolling, the table stays a table. It is reachable
|
|
144
|
+
* from the keyboard, otherwise a table too wide could only be read with a
|
|
145
|
+
* mouse.
|
|
146
|
+
*
|
|
147
|
+
* @returns {() => (tree: any) => void}
|
|
148
|
+
*/
|
|
149
|
+
function rehypeTableScroll() {
|
|
150
|
+
return () => (tree) => {
|
|
151
|
+
/** @param {any} parent */
|
|
152
|
+
const wrap = (parent) => {
|
|
153
|
+
const children = parent.children ?? [];
|
|
154
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
155
|
+
const node = children[i];
|
|
156
|
+
if (node.type === 'element' && node.tagName === 'table') {
|
|
157
|
+
children[i] = {
|
|
158
|
+
type: 'element',
|
|
159
|
+
tagName: 'div',
|
|
160
|
+
properties: {
|
|
161
|
+
className: ['dp-table-scroll'],
|
|
162
|
+
tabIndex: 0,
|
|
163
|
+
role: 'region',
|
|
164
|
+
ariaLabel: 'Scrollable table',
|
|
165
|
+
},
|
|
166
|
+
children: [node],
|
|
167
|
+
};
|
|
168
|
+
} else {
|
|
169
|
+
wrap(node);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
wrap(tree);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rehype plugin: rewrites internal links and media into site URLs.
|
|
179
|
+
*
|
|
180
|
+
* Two rules, from the point of view of a page's author:
|
|
181
|
+
*
|
|
182
|
+
* - a **relative** target (`./diagram.png`, `../guide/`) resolves against the
|
|
183
|
+
* page's folder, as in any document;
|
|
184
|
+
* - an **absolute** target (`/guide/installation/`) is read as starting from
|
|
185
|
+
* the version root, not from the domain root. That is what the author
|
|
186
|
+
* means: their documentation does not know it may be served under
|
|
187
|
+
* `/docpensieve/versions/v1.0/`.
|
|
188
|
+
*
|
|
189
|
+
* Without the second rule, every absolute internal link would break as soon
|
|
190
|
+
* as a `baseUrl` or a version prefix comes into play. To target a real domain
|
|
191
|
+
* URL, the full URL remains available.
|
|
192
|
+
*
|
|
193
|
+
* @param {{ url?: string, dirUrl?: string, basePath?: string }} context
|
|
194
|
+
* `dirUrl` is the source file's folder mapped into URL space. It is the base
|
|
195
|
+
* of relative targets, not the page URL: the latter has one more level, so
|
|
196
|
+
* `./diagram.png` written in `guide/install.md` would point to
|
|
197
|
+
* `/guide/install/diagram.png` instead of `/guide/diagram.png`.
|
|
198
|
+
* @returns {() => (tree: any) => void}
|
|
199
|
+
*/
|
|
200
|
+
function rehypeSiteLinks({ url, dirUrl, basePath }) {
|
|
201
|
+
const base = basePath ?? '/';
|
|
202
|
+
const from = dirUrl ?? url;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Applies both rules to a target.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} target
|
|
208
|
+
* @returns {string | null} The rewritten target, or `null` when there is nothing to do.
|
|
209
|
+
*/
|
|
210
|
+
const rewrite = (target) => {
|
|
211
|
+
if (typeof target !== 'string' || target === '' || EXTERNAL_TARGET.test(target)) return null;
|
|
212
|
+
|
|
213
|
+
if (target.startsWith('/')) {
|
|
214
|
+
if (base === '/' || target.startsWith(base)) return null;
|
|
215
|
+
return `${base.replace(/\/$/, '')}${target}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!from) return null;
|
|
219
|
+
|
|
220
|
+
// The origin is throwaway: only the resolved pathname matters. Going
|
|
221
|
+
// through URL handles "./" and "../" without hand-writing a normalisation.
|
|
222
|
+
const resolved = new URL(target, `https://docpensieve.invalid${from}`);
|
|
223
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return () => (tree) => {
|
|
227
|
+
walk(tree, (node) => {
|
|
228
|
+
if (node.type === 'element') {
|
|
229
|
+
const attribute = LINK_ATTRIBUTES[node.tagName];
|
|
230
|
+
if (!attribute) return;
|
|
231
|
+
|
|
232
|
+
const rewritten = rewrite(node.properties?.[attribute]);
|
|
233
|
+
if (rewritten !== null) node.properties[attribute] = rewritten;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// An HTML tag written in JSX — `<a href="…">` in an `.mdx` page — is
|
|
238
|
+
// not an `element` node: without this second case, it would escape the
|
|
239
|
+
// rewrite and point outside the deployment prefix.
|
|
240
|
+
if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return;
|
|
241
|
+
|
|
242
|
+
// Components resolve their own targets: their name starts with a
|
|
243
|
+
// capital letter, and what they do with the value cannot be guessed
|
|
244
|
+
// from here.
|
|
245
|
+
const attribute = LINK_ATTRIBUTES[node.name];
|
|
246
|
+
if (!attribute) return;
|
|
247
|
+
|
|
248
|
+
for (const attr of node.attributes ?? []) {
|
|
249
|
+
if (attr.type !== 'mdxJsxAttribute' || attr.name !== attribute) continue;
|
|
250
|
+
// A value in braces is an expression: its result does not exist yet
|
|
251
|
+
// at this stage.
|
|
252
|
+
const rewritten = rewrite(attr.value);
|
|
253
|
+
if (rewritten !== null) attr.value = rewritten;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Nests a flat list of headings into a tree, by depth.
|
|
261
|
+
*
|
|
262
|
+
* @param {{ id: string, text: string, depth: number }[]} headings
|
|
263
|
+
* @returns {TocEntry[]}
|
|
264
|
+
*/
|
|
265
|
+
function nest(headings) {
|
|
266
|
+
/** @type {TocEntry[]} */
|
|
267
|
+
const root = [];
|
|
268
|
+
/** @type {TocEntry[]} */
|
|
269
|
+
const stack = [];
|
|
270
|
+
|
|
271
|
+
for (const heading of headings) {
|
|
272
|
+
const entry = { ...heading, children: [] };
|
|
273
|
+
// Loop on the index rather than `.at(-1)`: to the analyser, the result of
|
|
274
|
+
// `at` is always possibly `undefined`, even under a length check, and
|
|
275
|
+
// writing it this way makes the invariant readable.
|
|
276
|
+
while (stack.length > 0 && stack[stack.length - 1].depth >= entry.depth) stack.pop();
|
|
277
|
+
(stack.length > 0 ? stack[stack.length - 1].children : root).push(entry);
|
|
278
|
+
stack.push(entry);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return root;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Enriches an MDX compilation error with the file and the offending position.
|
|
286
|
+
*
|
|
287
|
+
* @param {any} cause Error raised by MDX, whose shape is not typed.
|
|
288
|
+
* @param {string} [filepath]
|
|
289
|
+
* @returns {CompileError}
|
|
290
|
+
*/
|
|
291
|
+
function compileError(cause, filepath) {
|
|
292
|
+
const where = filepath ?? 'MDX source';
|
|
293
|
+
const line = cause?.line ?? cause?.place?.start?.line;
|
|
294
|
+
const column = cause?.column ?? cause?.place?.start?.column;
|
|
295
|
+
const position = line ? `${where}:${line}${column ? `:${column}` : ''}` : where;
|
|
296
|
+
const reason = cause?.reason ?? (cause instanceof Error ? cause.message : String(cause));
|
|
297
|
+
|
|
298
|
+
return new CompileError(`Compilation error in ${position} — ${reason}`, {
|
|
299
|
+
cause,
|
|
300
|
+
hint: cause?.ruleId === 'acorn' ? 'Check the JSX syntax of the block concerned.' : undefined,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Compiles an MDX/Markdown source into an HTML fragment. */
|
|
305
|
+
export class Compiler {
|
|
306
|
+
/**
|
|
307
|
+
* @param {{
|
|
308
|
+
* components?: Record<string, Function>,
|
|
309
|
+
* remarkPlugins?: any[],
|
|
310
|
+
* rehypePlugins?: any[],
|
|
311
|
+
* highlight?: false | Record<string, any>,
|
|
312
|
+
* tocDepth?: [number, number],
|
|
313
|
+
* }} [options]
|
|
314
|
+
* `components` is the table of global components injected into MDX: it is
|
|
315
|
+
* what lets a page write `<Card>` without an import. `highlight` takes the
|
|
316
|
+
* @shikijs/rehype options, or `false` to turn highlighting off. `tocDepth`
|
|
317
|
+
* bounds the headings kept in the table of contents.
|
|
318
|
+
*/
|
|
319
|
+
constructor(options = {}) {
|
|
320
|
+
this.options = options;
|
|
321
|
+
this.components = options.components ?? {};
|
|
322
|
+
this.remarkPlugins = options.remarkPlugins ?? [];
|
|
323
|
+
this.rehypePlugins = options.rehypePlugins ?? [];
|
|
324
|
+
this.highlight = options.highlight === undefined ? DEFAULT_HIGHLIGHT : options.highlight;
|
|
325
|
+
this.tocDepth = options.tocDepth ?? DEFAULT_TOC_DEPTH;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Compiles a source into an HTML fragment and a table of contents.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} source Markdown/MDX content, frontmatter already removed.
|
|
332
|
+
* @param {{ filepath?: string, url?: string, dirUrl?: string, basePath?: string }} [context]
|
|
333
|
+
* `filepath` locates errors, `dirUrl` is the base of relative targets and
|
|
334
|
+
* `basePath` prefixes absolute targets (the version root).
|
|
335
|
+
* @returns {Promise<CompileResult>}
|
|
336
|
+
* @throws {CompileError} Invalid syntax, or a component unknown at use.
|
|
337
|
+
*/
|
|
338
|
+
async compile(source, context = {}) {
|
|
339
|
+
const { filepath, url, dirUrl, basePath } = context;
|
|
340
|
+
|
|
341
|
+
/** @type {{ id: string, text: string, depth: number }[]} */
|
|
342
|
+
const headings = [];
|
|
343
|
+
|
|
344
|
+
const rehypePlugins = [
|
|
345
|
+
rehypeHeadingIds(headings),
|
|
346
|
+
rehypeSiteLinks({ url, dirUrl, basePath }),
|
|
347
|
+
rehypeTableScroll(),
|
|
348
|
+
...(this.highlight ? [[rehypeShiki, this.highlight]] : []),
|
|
349
|
+
...this.rehypePlugins,
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
/** @type {any} */
|
|
353
|
+
let MDXContent;
|
|
354
|
+
try {
|
|
355
|
+
({ default: MDXContent } = await evaluate(source, {
|
|
356
|
+
...runtime,
|
|
357
|
+
remarkPlugins: [remarkGfm, ...this.remarkPlugins],
|
|
358
|
+
rehypePlugins,
|
|
359
|
+
}));
|
|
360
|
+
} catch (cause) {
|
|
361
|
+
throw compileError(cause, filepath);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** @type {string} */
|
|
365
|
+
let rendered;
|
|
366
|
+
try {
|
|
367
|
+
rendered = renderToStaticMarkup(createElement(MDXContent, { components: this.components }));
|
|
368
|
+
} catch (cause) {
|
|
369
|
+
throw this.#renderError(cause, filepath);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const preloads = [...rendered.matchAll(PRELOAD_TAG)].map(([tag]) => ({
|
|
373
|
+
href: attribute(tag, 'href') ?? '',
|
|
374
|
+
as: attribute(tag, 'as') ?? '',
|
|
375
|
+
}));
|
|
376
|
+
const html = rendered.replace(PRELOAD_TAG, '');
|
|
377
|
+
|
|
378
|
+
const [min, max] = this.tocDepth;
|
|
379
|
+
const toc = nest(headings.filter((heading) => heading.depth >= min && heading.depth <= max));
|
|
380
|
+
|
|
381
|
+
return { html, toc, preloads };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Turns a React rendering error into an actionable message.
|
|
386
|
+
*
|
|
387
|
+
* The common case is a `<Thing>` used in a page while no component of that
|
|
388
|
+
* name is registered: MDX then raises an error that does not say what *is*
|
|
389
|
+
* available.
|
|
390
|
+
*
|
|
391
|
+
* @param {unknown} cause
|
|
392
|
+
* @param {string} [filepath]
|
|
393
|
+
* @returns {CompileError}
|
|
394
|
+
*/
|
|
395
|
+
#renderError(cause, filepath) {
|
|
396
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
397
|
+
const missing = message.match(/Expected component `([^`]+)`/);
|
|
398
|
+
|
|
399
|
+
if (missing) {
|
|
400
|
+
const available = Object.keys(this.components).sort();
|
|
401
|
+
return new CompileError(
|
|
402
|
+
`Unknown component "${missing[1]}" in ${filepath ?? 'the MDX source'}.`,
|
|
403
|
+
{
|
|
404
|
+
cause,
|
|
405
|
+
hint:
|
|
406
|
+
available.length > 0
|
|
407
|
+
? `Available components: ${available.join(', ')}.`
|
|
408
|
+
: 'No global component is registered for this compilation.',
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return compileError(cause, filepath);
|
|
414
|
+
}
|
|
415
|
+
}
|