@classic-homes/theme-docs 0.0.50 → 0.2.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/dist/lib/components/Breadcrumbs.svelte +55 -0
- package/dist/lib/components/Breadcrumbs.svelte.d.ts +14 -0
- package/dist/lib/components/CategoryIndex.svelte +51 -0
- package/dist/lib/components/CategoryIndex.svelte.d.ts +13 -0
- package/dist/lib/components/DocPage.svelte +172 -0
- package/dist/lib/components/DocPage.svelte.d.ts +60 -0
- package/dist/lib/components/DocPager.svelte +49 -0
- package/dist/lib/components/DocPager.svelte.d.ts +12 -0
- package/dist/lib/components/MarkdownPage.svelte +3 -1
- package/dist/lib/components/MermaidDiagram.svelte +2 -0
- package/dist/lib/components/MermaidInit.svelte +2 -0
- package/dist/lib/components/TableOfContents.svelte +114 -125
- package/dist/lib/components/TableOfContents.svelte.d.ts +11 -4
- package/dist/lib/components/TagIndex.svelte +42 -0
- package/dist/lib/components/TagIndex.svelte.d.ts +14 -0
- package/dist/lib/components/TagList.svelte +45 -0
- package/dist/lib/components/TagList.svelte.d.ts +15 -0
- package/dist/lib/components/TocPanel.svelte +27 -9
- package/dist/lib/components/TocPanel.svelte.d.ts +10 -3
- package/dist/lib/components/enhance.d.ts +29 -0
- package/dist/lib/components/enhance.js +179 -0
- package/dist/lib/components/mount.d.ts +14 -0
- package/dist/lib/components/mount.js +36 -0
- package/dist/lib/components/sidebar.d.ts +33 -0
- package/dist/lib/components/sidebar.js +84 -0
- package/dist/lib/content/browser.d.ts +6 -0
- package/dist/lib/content/browser.js +5 -0
- package/dist/lib/content/index.d.ts +13 -0
- package/dist/lib/content/index.js +12 -0
- package/dist/lib/content/load.d.ts +77 -0
- package/dist/lib/content/load.js +366 -0
- package/dist/lib/content/nav.d.ts +36 -0
- package/dist/lib/content/nav.js +81 -0
- package/dist/lib/content/render.d.ts +38 -0
- package/dist/lib/content/render.js +84 -0
- package/dist/lib/content/types.d.ts +90 -0
- package/dist/lib/content/types.js +5 -0
- package/dist/lib/index.d.ts +16 -2
- package/dist/lib/index.js +16 -2
- package/dist/lib/parser/api.d.ts +12 -0
- package/dist/lib/parser/api.js +10 -0
- package/dist/lib/parser/extensions.d.ts +51 -17
- package/dist/lib/parser/extensions.js +133 -60
- package/dist/lib/parser/index.d.ts +9 -2
- package/dist/lib/parser/index.js +125 -35
- package/dist/lib/parser/slug.d.ts +19 -0
- package/dist/lib/parser/slug.js +55 -0
- package/dist/lib/sanitize/index.d.ts +11 -0
- package/dist/lib/sanitize/index.js +122 -0
- package/dist/lib/search/index.d.ts +57 -0
- package/dist/lib/search/index.js +82 -0
- package/dist/lib/styles/markdown.css +161 -1
- package/dist/lib/types/frontmatter.d.ts +32 -0
- package/dist/lib/vite/index.d.ts +17 -0
- package/dist/lib/vite/index.js +38 -0
- package/package.json +51 -4
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
* Marked Extensions for Enhanced Markdown Features
|
|
3
3
|
*
|
|
4
4
|
* Provides support for:
|
|
5
|
-
* - Admonitions/Callouts (note, tip, warning, important, caution)
|
|
5
|
+
* - Admonitions/Callouts (note, info, tip, warning, important, caution, danger)
|
|
6
6
|
* - Footnotes
|
|
7
7
|
* - Definition Lists
|
|
8
8
|
* - Mermaid diagram placeholders (rendered client-side)
|
|
9
|
+
* - Component placeholders for allowlisted `<Name prop="…" />` tags (mounted client-side)
|
|
9
10
|
*/
|
|
11
|
+
import { escapeHtml } from '../highlighter/index.js';
|
|
10
12
|
/**
|
|
11
13
|
* Admonition types and their corresponding icons/classes
|
|
12
14
|
*/
|
|
13
|
-
export const ADMONITION_TYPES = [
|
|
15
|
+
export const ADMONITION_TYPES = [
|
|
16
|
+
'note',
|
|
17
|
+
'info',
|
|
18
|
+
'tip',
|
|
19
|
+
'warning',
|
|
20
|
+
'important',
|
|
21
|
+
'caution',
|
|
22
|
+
'danger',
|
|
23
|
+
];
|
|
24
|
+
/** `:::type`, then an optional `[Title]` or ` Title`, the body, and a closing `:::`. */
|
|
25
|
+
const ADMONITION_PATTERN = new RegExp(`^:::(${ADMONITION_TYPES.join('|')})(?:\\[([^\\]\\n]*)\\]|[ \\t]+([^\\n]*))?[ \\t]*\\n([\\s\\S]*?)\\n:::`);
|
|
14
26
|
/**
|
|
15
27
|
* Admonition extension for marked
|
|
16
28
|
*
|
|
@@ -22,6 +34,12 @@ export const ADMONITION_TYPES = ['note', 'tip', 'warning', 'important', 'caution
|
|
|
22
34
|
* :::warning Title Here
|
|
23
35
|
* This is a warning with a custom title
|
|
24
36
|
* :::
|
|
37
|
+
*
|
|
38
|
+
* :::tip[Title Here]
|
|
39
|
+
* The bracketed title form, as written for Docusaurus (remark-directive)
|
|
40
|
+
* :::
|
|
41
|
+
*
|
|
42
|
+
* Titles are inline markdown, so `code` and *emphasis* render.
|
|
25
43
|
*/
|
|
26
44
|
export function admonitionExtension() {
|
|
27
45
|
return {
|
|
@@ -33,18 +51,22 @@ export function admonitionExtension() {
|
|
|
33
51
|
return src.match(/^:::/)?.index;
|
|
34
52
|
},
|
|
35
53
|
tokenizer(src) {
|
|
36
|
-
// Match :::type optional
|
|
37
|
-
const match = src.match(
|
|
54
|
+
// Match :::type[optional title] or :::type optional title, then content, then :::
|
|
55
|
+
const match = src.match(ADMONITION_PATTERN);
|
|
38
56
|
if (match) {
|
|
57
|
+
const type = match[1];
|
|
58
|
+
const title = (match[2] ?? match[3])?.trim() || type.charAt(0).toUpperCase() + type.slice(1);
|
|
39
59
|
const token = {
|
|
40
60
|
type: 'admonition',
|
|
41
61
|
raw: match[0],
|
|
42
|
-
admonitionType:
|
|
43
|
-
title
|
|
44
|
-
|
|
62
|
+
admonitionType: type,
|
|
63
|
+
title,
|
|
64
|
+
titleTokens: [],
|
|
65
|
+
content: match[4].trim(),
|
|
45
66
|
tokens: [],
|
|
46
67
|
};
|
|
47
|
-
// Parse the inner content as markdown
|
|
68
|
+
// Parse the title as inline markdown and the inner content as block markdown
|
|
69
|
+
this.lexer.inlineTokens(title, token.titleTokens);
|
|
48
70
|
this.lexer.blockTokens(token.content, token.tokens);
|
|
49
71
|
return token;
|
|
50
72
|
}
|
|
@@ -52,17 +74,20 @@ export function admonitionExtension() {
|
|
|
52
74
|
},
|
|
53
75
|
renderer(token) {
|
|
54
76
|
const innerHtml = this.parser.parse(token.tokens ?? []);
|
|
77
|
+
const titleHtml = this.parser.parseInline(token.titleTokens ?? []);
|
|
55
78
|
const iconMap = {
|
|
56
|
-
note: `<svg class="admonition-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
note: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
|
|
80
|
+
info: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`,
|
|
81
|
+
tip: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>`,
|
|
82
|
+
warning: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
|
83
|
+
important: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>`,
|
|
84
|
+
caution: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7.86 2h8.28L22 7.86v8.28L16.14 22H7.86L2 16.14V7.86L7.86 2z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
|
|
85
|
+
danger: `<svg class="admonition-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
|
|
61
86
|
};
|
|
62
87
|
return `<div class="admonition admonition-${token.admonitionType}">
|
|
63
88
|
<div class="admonition-heading">
|
|
64
89
|
${iconMap[token.admonitionType]}
|
|
65
|
-
<span class="admonition-title">${
|
|
90
|
+
<span class="admonition-title">${titleHtml}</span>
|
|
66
91
|
</div>
|
|
67
92
|
<div class="admonition-content">${innerHtml}</div>
|
|
68
93
|
</div>`;
|
|
@@ -71,18 +96,19 @@ export function admonitionExtension() {
|
|
|
71
96
|
],
|
|
72
97
|
};
|
|
73
98
|
}
|
|
74
|
-
|
|
75
|
-
definitions: new Map(),
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
export function createFootnoteStore() {
|
|
100
|
+
return { definitions: new Map(), references: new Set() };
|
|
101
|
+
}
|
|
102
|
+
/** Store used when an extension is created without one (the pre-0.2 global API). */
|
|
103
|
+
let defaultFootnoteStore = createFootnoteStore();
|
|
78
104
|
/**
|
|
79
|
-
* Reset footnote store
|
|
105
|
+
* Reset the default footnote store.
|
|
106
|
+
*
|
|
107
|
+
* @deprecated `parseMarkdown` now keeps a store per call. Only needed if you build a
|
|
108
|
+
* `Marked` instance yourself from `footnoteExtension()` without passing a store.
|
|
80
109
|
*/
|
|
81
110
|
export function resetFootnoteStore() {
|
|
82
|
-
|
|
83
|
-
definitions: new Map(),
|
|
84
|
-
references: new Set(),
|
|
85
|
-
};
|
|
111
|
+
defaultFootnoteStore = createFootnoteStore();
|
|
86
112
|
}
|
|
87
113
|
/**
|
|
88
114
|
* Footnotes extension for marked
|
|
@@ -92,7 +118,8 @@ export function resetFootnoteStore() {
|
|
|
92
118
|
*
|
|
93
119
|
* [^1]: This is the footnote content.
|
|
94
120
|
*/
|
|
95
|
-
export function footnoteExtension() {
|
|
121
|
+
export function footnoteExtension(store) {
|
|
122
|
+
const footnotes = () => store ?? defaultFootnoteStore;
|
|
96
123
|
return {
|
|
97
124
|
extensions: [
|
|
98
125
|
// Footnote definition: [^id]: content
|
|
@@ -116,7 +143,7 @@ export function footnoteExtension() {
|
|
|
116
143
|
tokens: [],
|
|
117
144
|
};
|
|
118
145
|
this.lexer.inline(content, token.tokens);
|
|
119
|
-
|
|
146
|
+
footnotes().definitions.set(id, { content, tokens: token.tokens });
|
|
120
147
|
return token;
|
|
121
148
|
}
|
|
122
149
|
return undefined;
|
|
@@ -137,7 +164,7 @@ export function footnoteExtension() {
|
|
|
137
164
|
const match = src.match(/^\[\^([^\]]+)\](?!:)/);
|
|
138
165
|
if (match) {
|
|
139
166
|
const id = match[1];
|
|
140
|
-
|
|
167
|
+
footnotes().references.add(id);
|
|
141
168
|
return {
|
|
142
169
|
type: 'footnoteRef',
|
|
143
170
|
raw: match[0],
|
|
@@ -147,7 +174,8 @@ export function footnoteExtension() {
|
|
|
147
174
|
return undefined;
|
|
148
175
|
},
|
|
149
176
|
renderer(token) {
|
|
150
|
-
|
|
177
|
+
const id = escapeHtml(token.id);
|
|
178
|
+
return `<sup class="footnote-ref"><a href="#fn-${id}" id="fnref-${id}" aria-label="Footnote ${id}">[${id}]</a></sup>`;
|
|
151
179
|
},
|
|
152
180
|
},
|
|
153
181
|
],
|
|
@@ -156,38 +184,33 @@ export function footnoteExtension() {
|
|
|
156
184
|
/**
|
|
157
185
|
* Render collected footnotes as a section
|
|
158
186
|
* Call this after parsing to get the footnotes HTML
|
|
187
|
+
*
|
|
188
|
+
* @param store - The store the footnote extension wrote to (default: the shared store)
|
|
189
|
+
* @param renderInline - Renders a footnote's inline tokens to HTML. Without it the
|
|
190
|
+
* footnote text is escaped and shown as written.
|
|
159
191
|
*/
|
|
160
|
-
export function renderFootnotes() {
|
|
161
|
-
if (
|
|
192
|
+
export function renderFootnotes(store = defaultFootnoteStore, renderInline) {
|
|
193
|
+
if (store.definitions.size === 0) {
|
|
162
194
|
return '';
|
|
163
195
|
}
|
|
164
196
|
const items = [];
|
|
165
|
-
for (const [
|
|
166
|
-
if (
|
|
197
|
+
for (const [rawId, { content, tokens }] of store.definitions) {
|
|
198
|
+
if (store.references.has(rawId)) {
|
|
199
|
+
const id = escapeHtml(rawId);
|
|
200
|
+
const body = renderInline ? renderInline(tokens) : escapeHtml(content);
|
|
167
201
|
items.push(`<li id="fn-${id}" class="footnote-item">
|
|
168
|
-
<p>${
|
|
202
|
+
<p>${body} <a href="#fnref-${id}" class="footnote-backref" aria-label="Back to reference ${id}">↩</a></p>
|
|
169
203
|
</li>`);
|
|
170
204
|
}
|
|
171
205
|
}
|
|
172
206
|
if (items.length === 0) {
|
|
173
207
|
return '';
|
|
174
208
|
}
|
|
175
|
-
return `<section class="footnotes">
|
|
209
|
+
return `<section class="footnotes" aria-label="Footnotes">
|
|
176
210
|
<hr class="footnotes-separator" />
|
|
177
211
|
<ol class="footnotes-list">${items.join('')}</ol>
|
|
178
212
|
</section>`;
|
|
179
213
|
}
|
|
180
|
-
/**
|
|
181
|
-
* Definition list extension for marked
|
|
182
|
-
*
|
|
183
|
-
* Syntax:
|
|
184
|
-
* Term 1
|
|
185
|
-
* : Definition for term 1
|
|
186
|
-
*
|
|
187
|
-
* Term 2
|
|
188
|
-
* : Definition for term 2
|
|
189
|
-
* : Another definition for term 2
|
|
190
|
-
*/
|
|
191
214
|
export function definitionListExtension() {
|
|
192
215
|
return {
|
|
193
216
|
extensions: [
|
|
@@ -229,6 +252,11 @@ export function definitionListExtension() {
|
|
|
229
252
|
if (items.length === 0) {
|
|
230
253
|
return undefined;
|
|
231
254
|
}
|
|
255
|
+
// Terms and definitions are inline markdown, like everywhere else in a page
|
|
256
|
+
for (const item of items) {
|
|
257
|
+
item.termTokens = this.lexer.inlineTokens(item.term);
|
|
258
|
+
item.definitionTokens = item.definitions.map((def) => this.lexer.inlineTokens(def));
|
|
259
|
+
}
|
|
232
260
|
return {
|
|
233
261
|
type: 'defList',
|
|
234
262
|
raw,
|
|
@@ -240,8 +268,10 @@ export function definitionListExtension() {
|
|
|
240
268
|
renderer(token) {
|
|
241
269
|
const itemsHtml = token.items
|
|
242
270
|
.map((item) => {
|
|
243
|
-
const defsHtml = item.
|
|
244
|
-
|
|
271
|
+
const defsHtml = item.definitionTokens
|
|
272
|
+
.map((tokens) => `<dd>${this.parser.parseInline(tokens)}</dd>`)
|
|
273
|
+
.join('');
|
|
274
|
+
return `<dt>${this.parser.parseInline(item.termTokens)}</dt>${defsHtml}`;
|
|
245
275
|
})
|
|
246
276
|
.join('');
|
|
247
277
|
return `<dl class="definition-list">${itemsHtml}</dl>`;
|
|
@@ -262,6 +292,14 @@ export function definitionListExtension() {
|
|
|
262
292
|
* A --> B
|
|
263
293
|
* ```
|
|
264
294
|
*/
|
|
295
|
+
export function renderMermaidPlaceholder(code) {
|
|
296
|
+
// A placeholder div with the mermaid code; the client-side Mermaid library picks it up.
|
|
297
|
+
// The <pre> shows the source until (or if) mermaid renders it.
|
|
298
|
+
const escapedCode = escapeHtml(code);
|
|
299
|
+
return `<div class="mermaid-diagram" data-mermaid="${escapedCode}">
|
|
300
|
+
<pre class="mermaid">${escapedCode}</pre>
|
|
301
|
+
</div>`;
|
|
302
|
+
}
|
|
265
303
|
export function mermaidExtension() {
|
|
266
304
|
return {
|
|
267
305
|
extensions: [
|
|
@@ -269,10 +307,10 @@ export function mermaidExtension() {
|
|
|
269
307
|
name: 'mermaidBlock',
|
|
270
308
|
level: 'block',
|
|
271
309
|
start(src) {
|
|
272
|
-
return src.match(/^```mermaid/)?.index;
|
|
310
|
+
return src.match(/^```mermaid[ \t]*\n/)?.index;
|
|
273
311
|
},
|
|
274
312
|
tokenizer(src) {
|
|
275
|
-
const match = src.match(/^```mermaid\n([\s\S]*?)```/);
|
|
313
|
+
const match = src.match(/^```mermaid[ \t]*\n([\s\S]*?)```/);
|
|
276
314
|
if (match) {
|
|
277
315
|
return {
|
|
278
316
|
type: 'mermaidBlock',
|
|
@@ -283,16 +321,47 @@ export function mermaidExtension() {
|
|
|
283
321
|
return undefined;
|
|
284
322
|
},
|
|
285
323
|
renderer(token) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
324
|
+
return renderMermaidPlaceholder(token.code);
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Component placeholder extension for marked
|
|
332
|
+
*
|
|
333
|
+
* Syntax (on a line of its own, string props only):
|
|
334
|
+
* <RequestForm id="ai-use-request" />
|
|
335
|
+
*
|
|
336
|
+
* Renders an empty placeholder that `mountComponents` fills with the real component:
|
|
337
|
+
* <div data-component="RequestForm" data-props="{"id":"ai-use-request"}"></div>
|
|
338
|
+
*
|
|
339
|
+
* Only names in `names` are recognised, so any other tag stays ordinary HTML. This lets
|
|
340
|
+
* MDX-style pages render without an MDX compiler.
|
|
341
|
+
*/
|
|
342
|
+
export function componentExtension(names) {
|
|
343
|
+
const allowed = new Set(names);
|
|
344
|
+
return {
|
|
345
|
+
extensions: [
|
|
346
|
+
{
|
|
347
|
+
name: 'component',
|
|
348
|
+
level: 'block',
|
|
349
|
+
start(src) {
|
|
350
|
+
return src.match(/^ {0,3}<[A-Z]/m)?.index;
|
|
351
|
+
},
|
|
352
|
+
tokenizer(src) {
|
|
353
|
+
const match = src.match(/^ {0,3}<([A-Z][A-Za-z0-9]*)((?:\s+[A-Za-z][\w-]*="[^"]*")*)\s*\/>[ \t]*(?:\n+|$)/);
|
|
354
|
+
if (!match || !allowed.has(match[1]))
|
|
355
|
+
return undefined;
|
|
356
|
+
const props = {};
|
|
357
|
+
for (const [, key, value] of match[2].matchAll(/([A-Za-z][\w-]*)="([^"]*)"/g)) {
|
|
358
|
+
props[key] = value;
|
|
359
|
+
}
|
|
360
|
+
return { type: 'component', raw: match[0], name: match[1], props };
|
|
361
|
+
},
|
|
362
|
+
renderer(token) {
|
|
363
|
+
const props = escapeHtml(JSON.stringify(token.props));
|
|
364
|
+
return `<div data-component="${token.name}" data-props="${props}"></div>\n`;
|
|
296
365
|
},
|
|
297
366
|
},
|
|
298
367
|
],
|
|
@@ -300,12 +369,16 @@ export function mermaidExtension() {
|
|
|
300
369
|
}
|
|
301
370
|
/**
|
|
302
371
|
* Get all markdown extensions
|
|
372
|
+
*
|
|
373
|
+
* @param components - Names of components to render as placeholders (see `componentExtension`)
|
|
374
|
+
* @param footnotes - Store for this document's footnotes (default: the shared store)
|
|
303
375
|
*/
|
|
304
|
-
export function getAllExtensions() {
|
|
376
|
+
export function getAllExtensions(components = [], footnotes) {
|
|
305
377
|
return [
|
|
306
378
|
admonitionExtension(),
|
|
307
|
-
footnoteExtension(),
|
|
379
|
+
footnoteExtension(footnotes),
|
|
308
380
|
definitionListExtension(),
|
|
309
381
|
mermaidExtension(),
|
|
382
|
+
...(components.length > 0 ? [componentExtension(components)] : []),
|
|
310
383
|
];
|
|
311
384
|
}
|
|
@@ -6,7 +6,9 @@ import type { ParsedMarkdown, ParseOptions } from '../types/index.js';
|
|
|
6
6
|
* applies Shiki syntax highlighting to code blocks, and optionally generates heading IDs.
|
|
7
7
|
*
|
|
8
8
|
* Supported extensions:
|
|
9
|
-
* - Admonitions/Callouts: :::note, :::tip, :::warning, :::important, :::caution
|
|
9
|
+
* - Admonitions/Callouts: :::note, :::info, :::tip, :::warning, :::important, :::caution, :::danger
|
|
10
|
+
* - Explicit heading IDs: ## Heading {#custom-id}
|
|
11
|
+
* - Component placeholders: <RequestForm id="x" /> when "RequestForm" is in options.components
|
|
10
12
|
* - Footnotes: [^1] references and [^1]: definitions
|
|
11
13
|
* - Definition Lists: Term followed by : Definition
|
|
12
14
|
* - Mermaid Diagrams: ```mermaid code blocks (rendered client-side)
|
|
@@ -15,7 +17,12 @@ import type { ParsedMarkdown, ParseOptions } from '../types/index.js';
|
|
|
15
17
|
* @param options - Parsing options
|
|
16
18
|
* @param options.theme - Shiki theme for code highlighting ('github-dark' | 'github-light' | 'one-dark-pro')
|
|
17
19
|
* @param options.generateHeadingIds - Whether to generate IDs for headings (default: true)
|
|
18
|
-
* @
|
|
20
|
+
* @param options.headingIdStyle - 'default', or 'github' for GitHub/Docusaurus-compatible IDs
|
|
21
|
+
* @param options.headingAnchors - Add a `#` permalink to each heading (default: false)
|
|
22
|
+
* @param options.components - Tag names to render as component placeholders (see `mountComponents`)
|
|
23
|
+
* @param options.externalLinks - Open http(s) links in a new tab, announced to screen readers
|
|
24
|
+
* @param options.langAlias - Map fence names to Shiki languages, e.g. `{ ios: 'text' }`
|
|
25
|
+
* @returns Parsed markdown with frontmatter data, cleaned markdown, rendered HTML, and TOC
|
|
19
26
|
*
|
|
20
27
|
* @example
|
|
21
28
|
* ```typescript
|
package/dist/lib/parser/index.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { Marked, Renderer } from 'marked';
|
|
1
|
+
import { Marked, Parser, Renderer } from 'marked';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import { getHighlighter, escapeHtml } from '../highlighter/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import { createFootnoteStore, getAllExtensions, renderFootnotes, renderMermaidPlaceholder, } from './extensions.js';
|
|
5
|
+
import { createSlugger, EXPLICIT_ID_PATTERN } from './slug.js';
|
|
6
|
+
/** Undo the entity escaping marked applies to heading text, so `A & B` slugs as `A & B`. */
|
|
7
|
+
function decodeEntities(text) {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/�?39;/g, "'")
|
|
13
|
+
.replace(/&/g, '&');
|
|
14
|
+
}
|
|
5
15
|
/**
|
|
6
16
|
* SECURITY NOTE: This parser renders markdown to HTML without sanitization.
|
|
7
17
|
* Only use with trusted markdown content from your own codebase or CMS.
|
|
@@ -34,7 +44,9 @@ function extractFrontmatter(content) {
|
|
|
34
44
|
* applies Shiki syntax highlighting to code blocks, and optionally generates heading IDs.
|
|
35
45
|
*
|
|
36
46
|
* Supported extensions:
|
|
37
|
-
* - Admonitions/Callouts: :::note, :::tip, :::warning, :::important, :::caution
|
|
47
|
+
* - Admonitions/Callouts: :::note, :::info, :::tip, :::warning, :::important, :::caution, :::danger
|
|
48
|
+
* - Explicit heading IDs: ## Heading {#custom-id}
|
|
49
|
+
* - Component placeholders: <RequestForm id="x" /> when "RequestForm" is in options.components
|
|
38
50
|
* - Footnotes: [^1] references and [^1]: definitions
|
|
39
51
|
* - Definition Lists: Term followed by : Definition
|
|
40
52
|
* - Mermaid Diagrams: ```mermaid code blocks (rendered client-side)
|
|
@@ -43,7 +55,12 @@ function extractFrontmatter(content) {
|
|
|
43
55
|
* @param options - Parsing options
|
|
44
56
|
* @param options.theme - Shiki theme for code highlighting ('github-dark' | 'github-light' | 'one-dark-pro')
|
|
45
57
|
* @param options.generateHeadingIds - Whether to generate IDs for headings (default: true)
|
|
46
|
-
* @
|
|
58
|
+
* @param options.headingIdStyle - 'default', or 'github' for GitHub/Docusaurus-compatible IDs
|
|
59
|
+
* @param options.headingAnchors - Add a `#` permalink to each heading (default: false)
|
|
60
|
+
* @param options.components - Tag names to render as component placeholders (see `mountComponents`)
|
|
61
|
+
* @param options.externalLinks - Open http(s) links in a new tab, announced to screen readers
|
|
62
|
+
* @param options.langAlias - Map fence names to Shiki languages, e.g. `{ ios: 'text' }`
|
|
63
|
+
* @returns Parsed markdown with frontmatter data, cleaned markdown, rendered HTML, and TOC
|
|
47
64
|
*
|
|
48
65
|
* @example
|
|
49
66
|
* ```typescript
|
|
@@ -61,56 +78,79 @@ function extractFrontmatter(content) {
|
|
|
61
78
|
* ```
|
|
62
79
|
*/
|
|
63
80
|
export async function parseMarkdown(content, options = {}) {
|
|
64
|
-
const { theme = 'github-dark', generateHeadingIds = true } = options;
|
|
81
|
+
const { theme: requestedTheme = 'github-dark', generateHeadingIds = true, headingIdStyle = 'default', headingAnchors = false, components = [], externalLinks = false, langAlias = {}, } = options;
|
|
65
82
|
// Extract frontmatter
|
|
66
83
|
const { data: frontmatter, content: markdownContent } = extractFrontmatter(content);
|
|
67
|
-
// Get Shiki highlighter
|
|
84
|
+
// Get Shiki highlighter, with this page's theme and code languages loaded
|
|
68
85
|
const highlighter = await getHighlighter();
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
const theme = await ensureTheme(highlighter, requestedTheme);
|
|
87
|
+
const languageOf = (name) => langAlias[name] ?? name;
|
|
88
|
+
const highlightable = await ensureLanguages(highlighter, fenceLanguages(markdownContent).map(languageOf));
|
|
89
|
+
// Footnotes collected for this document only
|
|
90
|
+
const footnotes = createFootnoteStore();
|
|
71
91
|
// Configure marked with custom code renderer
|
|
72
92
|
const renderer = new Renderer();
|
|
73
93
|
renderer.code = ({ text, lang }) => {
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
// The info string is the language, then optional meta: ```bash title="install.sh"
|
|
95
|
+
const [, name = '', meta = ''] = (lang ?? '').trim().match(/^(\S*)\s*(.*)$/) ?? [];
|
|
96
|
+
const language = languageOf(name) || 'text';
|
|
76
97
|
if (language === 'mermaid') {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const highlighted = highlighter.codeToHtml(text, {
|
|
81
|
-
lang: language,
|
|
82
|
-
theme: theme,
|
|
83
|
-
});
|
|
84
|
-
return highlighted;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Fallback for unsupported languages
|
|
88
|
-
return `<pre class="shiki"><code class="language-${language}">${escapeHtml(text)}</code></pre>`;
|
|
98
|
+
// Normally caught by the mermaid extension; this covers fences it doesn't match
|
|
99
|
+
return renderMermaidPlaceholder(text);
|
|
89
100
|
}
|
|
101
|
+
const pre = highlightable.has(language)
|
|
102
|
+
? highlighter.codeToHtml(text, { lang: language, theme })
|
|
103
|
+
: `<pre class="shiki"><code class="language-${escapeHtml(language)}">${escapeHtml(text)}</code></pre>`;
|
|
104
|
+
const title = meta.match(/\btitle=(?:"([^"]*)"|'([^']*)'|(\S+))/);
|
|
105
|
+
const titleText = title ? (title[1] ?? title[2] ?? title[3]) : '';
|
|
106
|
+
return titleText
|
|
107
|
+
? `<div class="code-block-wrapper"><div class="code-block-filename">${escapeHtml(titleText)}</div>${pre}</div>`
|
|
108
|
+
: pre;
|
|
90
109
|
};
|
|
91
110
|
if (generateHeadingIds) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
const slug = createSlugger(headingIdStyle);
|
|
112
|
+
renderer.heading = function ({ tokens, depth }) {
|
|
113
|
+
// Slug the heading's text, not its markdown: `## Use **sudo**` → `use-sudo`
|
|
114
|
+
const plain = this.parser.parseInline(tokens, this.parser.textRenderer);
|
|
115
|
+
const explicitId = plain.match(EXPLICIT_ID_PATTERN)?.[1];
|
|
116
|
+
// Raw inline HTML (`## A <b>&</b> B`) comes through as tags; slug only its text
|
|
117
|
+
const text = decodeEntities(plain.replace(EXPLICIT_ID_PATTERN, '').replace(/<[^>]*>/g, ''));
|
|
118
|
+
const id = escapeHtml(slug(text, explicitId));
|
|
100
119
|
// Render inline tokens so markdown inside headings (bold, code, links) works
|
|
101
|
-
|
|
120
|
+
const inner = this.parser.parseInline(tokens).replace(EXPLICIT_ID_PATTERN, '');
|
|
121
|
+
const anchor = headingAnchors
|
|
122
|
+
? `<a class="hash-link" href="#${id}" aria-label="Direct link to ${escapeHtml(text)}">​</a>`
|
|
123
|
+
: '';
|
|
124
|
+
return `<h${depth} id="${id}">${inner}${anchor}</h${depth}>`;
|
|
102
125
|
};
|
|
103
126
|
}
|
|
127
|
+
if (externalLinks) {
|
|
128
|
+
const { target = '_blank', rel = 'noopener noreferrer' } = externalLinks === true ? {} : externalLinks;
|
|
129
|
+
renderer.link = function (token) {
|
|
130
|
+
const html = Renderer.prototype.link.call(this, token);
|
|
131
|
+
if (!/^https?:\/\//i.test(token.href))
|
|
132
|
+
return html;
|
|
133
|
+
return html
|
|
134
|
+
.replace(/^<a /, `<a class="external-link" target="${escapeHtml(target)}" rel="${escapeHtml(rel)}" `)
|
|
135
|
+
.replace(/<\/a>$/, '<span class="external-link-hint"> (opens in new tab)</span></a>');
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Images below the fold shouldn't hold up the page
|
|
139
|
+
renderer.image = function (token) {
|
|
140
|
+
return Renderer.prototype.image
|
|
141
|
+
.call(this, token)
|
|
142
|
+
.replace(/^<img /, '<img loading="lazy" decoding="async" ');
|
|
143
|
+
};
|
|
104
144
|
// Use a per-call Marked instance: the renderer captures per-call state
|
|
105
145
|
// (theme, highlighter), and `marked.use` on the shared global instance
|
|
106
146
|
// accumulates extensions across calls.
|
|
107
147
|
const md = new Marked();
|
|
108
148
|
// Apply all markdown extensions (admonitions, footnotes, definition lists, mermaid)
|
|
109
|
-
md.use(...getAllExtensions());
|
|
149
|
+
md.use(...getAllExtensions(components, footnotes));
|
|
110
150
|
md.use({ renderer, gfm: true });
|
|
111
151
|
let html = await md.parse(markdownContent);
|
|
112
152
|
// Append footnotes section if any were referenced
|
|
113
|
-
const footnotesHtml = renderFootnotes();
|
|
153
|
+
const footnotesHtml = renderFootnotes(footnotes, (tokens) => Parser.parseInline(tokens, md.defaults));
|
|
114
154
|
if (footnotesHtml) {
|
|
115
155
|
html += footnotesHtml;
|
|
116
156
|
}
|
|
@@ -118,8 +158,55 @@ export async function parseMarkdown(content, options = {}) {
|
|
|
118
158
|
frontmatter,
|
|
119
159
|
markdown: markdownContent,
|
|
120
160
|
html,
|
|
161
|
+
toc: extractToc(html, 6),
|
|
121
162
|
};
|
|
122
163
|
}
|
|
164
|
+
/** Languages named on code fences (```bash, ~~~yaml), mermaid excluded. */
|
|
165
|
+
function fenceLanguages(markdown) {
|
|
166
|
+
const names = new Set();
|
|
167
|
+
for (const [, name] of markdown.matchAll(/^ {0,3}(?:`{3,}|~{3,})[ \t]*([^\s`{]+)/gm)) {
|
|
168
|
+
if (name !== 'mermaid')
|
|
169
|
+
names.add(name);
|
|
170
|
+
}
|
|
171
|
+
return [...names];
|
|
172
|
+
}
|
|
173
|
+
/** Shiki's built-in plain-text languages, which never need loading. */
|
|
174
|
+
const PLAIN_LANGUAGES = new Set(['text', 'txt', 'plain', 'plaintext', 'ansi']);
|
|
175
|
+
const warnedLanguages = new Set();
|
|
176
|
+
/**
|
|
177
|
+
* Load every language a page uses before rendering, since marked renders code blocks
|
|
178
|
+
* synchronously. Returns the names that can be highlighted; the rest render as plain code.
|
|
179
|
+
*/
|
|
180
|
+
async function ensureLanguages(highlighter, names) {
|
|
181
|
+
const ok = new Set(PLAIN_LANGUAGES);
|
|
182
|
+
await Promise.all(names.map(async (name) => {
|
|
183
|
+
if (ok.has(name))
|
|
184
|
+
return;
|
|
185
|
+
try {
|
|
186
|
+
await highlighter.loadLanguage(name);
|
|
187
|
+
ok.add(name);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
if (!warnedLanguages.has(name)) {
|
|
191
|
+
warnedLanguages.add(name);
|
|
192
|
+
console.warn(`[docs] No syntax highlighting for "${name}"; rendering as plain text`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}));
|
|
196
|
+
return ok;
|
|
197
|
+
}
|
|
198
|
+
async function ensureTheme(highlighter, theme) {
|
|
199
|
+
if (highlighter.getLoadedThemes().includes(theme))
|
|
200
|
+
return theme;
|
|
201
|
+
try {
|
|
202
|
+
await highlighter.loadTheme(theme);
|
|
203
|
+
return theme;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
console.warn(`[docs] Unknown theme "${theme}"; using github-dark`);
|
|
207
|
+
return 'github-dark';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
123
210
|
/**
|
|
124
211
|
* Extract table of contents entries from rendered HTML content.
|
|
125
212
|
*
|
|
@@ -148,8 +235,11 @@ export function extractToc(html, maxDepth = 3) {
|
|
|
148
235
|
if (level <= maxDepth) {
|
|
149
236
|
toc.push({
|
|
150
237
|
level,
|
|
151
|
-
id: match[2],
|
|
152
|
-
text: match[3]
|
|
238
|
+
id: decodeEntities(match[2]),
|
|
239
|
+
text: decodeEntities(match[3]
|
|
240
|
+
.replace(/<a class="hash-link"[\s\S]*?<\/a>/g, '')
|
|
241
|
+
.replace(/<[^>]*>/g, '')
|
|
242
|
+
.trim()),
|
|
153
243
|
});
|
|
154
244
|
}
|
|
155
245
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heading ID generation.
|
|
3
|
+
*
|
|
4
|
+
* Two styles:
|
|
5
|
+
* - `default`: this package's original slugs (periods become hyphens, so `v2.0` → `v2-0`).
|
|
6
|
+
* - `github`: GitHub's algorithm (github-slugger), which Docusaurus, VitePress and GitHub
|
|
7
|
+
* itself use. Pick it when migrating content whose `#anchor` links must keep working.
|
|
8
|
+
*
|
|
9
|
+
* Both styles dedupe repeated headings with a `-1`, `-2`… suffix (an explicit ID is never
|
|
10
|
+
* suffixed), and both honour an explicit `{#custom-id}` at the end of a heading.
|
|
11
|
+
*/
|
|
12
|
+
export type HeadingIdStyle = 'default' | 'github';
|
|
13
|
+
/** Trailing `{#custom-id}` on a heading, as Docusaurus and Pandoc write it. */
|
|
14
|
+
export declare const EXPLICIT_ID_PATTERN: RegExp;
|
|
15
|
+
/**
|
|
16
|
+
* Create a slugger for one document. IDs are unique within it: the second
|
|
17
|
+
* `## Setup` becomes `setup-1`, matching github-slugger.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createSlugger(style?: HeadingIdStyle): (text: string, explicitId?: string) => string;
|