@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 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
+ }
@@ -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
+ }