@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.
Files changed (56) hide show
  1. package/dist/lib/components/Breadcrumbs.svelte +55 -0
  2. package/dist/lib/components/Breadcrumbs.svelte.d.ts +14 -0
  3. package/dist/lib/components/CategoryIndex.svelte +51 -0
  4. package/dist/lib/components/CategoryIndex.svelte.d.ts +13 -0
  5. package/dist/lib/components/DocPage.svelte +172 -0
  6. package/dist/lib/components/DocPage.svelte.d.ts +60 -0
  7. package/dist/lib/components/DocPager.svelte +49 -0
  8. package/dist/lib/components/DocPager.svelte.d.ts +12 -0
  9. package/dist/lib/components/MarkdownPage.svelte +3 -1
  10. package/dist/lib/components/MermaidDiagram.svelte +2 -0
  11. package/dist/lib/components/MermaidInit.svelte +2 -0
  12. package/dist/lib/components/TableOfContents.svelte +114 -125
  13. package/dist/lib/components/TableOfContents.svelte.d.ts +11 -4
  14. package/dist/lib/components/TagIndex.svelte +42 -0
  15. package/dist/lib/components/TagIndex.svelte.d.ts +14 -0
  16. package/dist/lib/components/TagList.svelte +45 -0
  17. package/dist/lib/components/TagList.svelte.d.ts +15 -0
  18. package/dist/lib/components/TocPanel.svelte +27 -9
  19. package/dist/lib/components/TocPanel.svelte.d.ts +10 -3
  20. package/dist/lib/components/enhance.d.ts +29 -0
  21. package/dist/lib/components/enhance.js +179 -0
  22. package/dist/lib/components/mount.d.ts +14 -0
  23. package/dist/lib/components/mount.js +36 -0
  24. package/dist/lib/components/sidebar.d.ts +33 -0
  25. package/dist/lib/components/sidebar.js +84 -0
  26. package/dist/lib/content/browser.d.ts +6 -0
  27. package/dist/lib/content/browser.js +5 -0
  28. package/dist/lib/content/index.d.ts +13 -0
  29. package/dist/lib/content/index.js +12 -0
  30. package/dist/lib/content/load.d.ts +77 -0
  31. package/dist/lib/content/load.js +366 -0
  32. package/dist/lib/content/nav.d.ts +36 -0
  33. package/dist/lib/content/nav.js +81 -0
  34. package/dist/lib/content/render.d.ts +38 -0
  35. package/dist/lib/content/render.js +84 -0
  36. package/dist/lib/content/types.d.ts +90 -0
  37. package/dist/lib/content/types.js +5 -0
  38. package/dist/lib/index.d.ts +16 -2
  39. package/dist/lib/index.js +16 -2
  40. package/dist/lib/parser/api.d.ts +12 -0
  41. package/dist/lib/parser/api.js +10 -0
  42. package/dist/lib/parser/extensions.d.ts +51 -17
  43. package/dist/lib/parser/extensions.js +133 -60
  44. package/dist/lib/parser/index.d.ts +9 -2
  45. package/dist/lib/parser/index.js +125 -35
  46. package/dist/lib/parser/slug.d.ts +19 -0
  47. package/dist/lib/parser/slug.js +55 -0
  48. package/dist/lib/sanitize/index.d.ts +11 -0
  49. package/dist/lib/sanitize/index.js +122 -0
  50. package/dist/lib/search/index.d.ts +57 -0
  51. package/dist/lib/search/index.js +82 -0
  52. package/dist/lib/styles/markdown.css +161 -1
  53. package/dist/lib/types/frontmatter.d.ts +32 -0
  54. package/dist/lib/vite/index.d.ts +17 -0
  55. package/dist/lib/vite/index.js +38 -0
  56. 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 = ['note', 'tip', 'warning', 'important', 'caution'];
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-title\ncontent\n:::
37
- const match = src.match(/^:::(note|tip|warning|important|caution)(?:\s+([^\n]*))?\n([\s\S]*?)\n:::/);
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: match[1],
43
- title: match[2]?.trim() || match[1].charAt(0).toUpperCase() + match[1].slice(1),
44
- content: match[3].trim(),
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
- tip: `<svg class="admonition-icon" 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>`,
58
- warning: `<svg class="admonition-icon" 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>`,
59
- important: `<svg class="admonition-icon" 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>`,
60
- caution: `<svg class="admonition-icon" 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>`,
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">${token.title}</span>
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
- let footnoteStore = {
75
- definitions: new Map(),
76
- references: new Set(),
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 (call before parsing a new document)
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
- footnoteStore = {
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
- footnoteStore.definitions.set(id, { content, tokens: token.tokens });
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
- footnoteStore.references.add(id);
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
- return `<sup class="footnote-ref"><a href="#fn-${token.id}" id="fnref-${token.id}">[${token.id}]</a></sup>`;
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 (footnoteStore.definitions.size === 0) {
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 [id, { content }] of footnoteStore.definitions) {
166
- if (footnoteStore.references.has(id)) {
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>${content} <a href="#fnref-${id}" class="footnote-backref">↩</a></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.definitions.map((def) => `<dd>${def}</dd>`).join('');
244
- return `<dt>${item.term}</dt>${defsHtml}`;
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
- // Render as a placeholder div with the mermaid code
287
- // The client-side Mermaid library will pick this up
288
- const escapedCode = token.code
289
- .replace(/&/g, '&amp;')
290
- .replace(/</g, '&lt;')
291
- .replace(/>/g, '&gt;')
292
- .replace(/"/g, '&quot;');
293
- return `<div class="mermaid-diagram" data-mermaid="${escapedCode}">
294
- <pre class="mermaid">${token.code}</pre>
295
- </div>`;
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="{&quot;id&quot;:&quot;ai-use-request&quot;}"></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
- * @returns Parsed markdown with frontmatter data, cleaned markdown, and rendered HTML
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
@@ -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 { getAllExtensions, resetFootnoteStore, renderFootnotes } from './extensions.js';
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 &amp; B` slugs as `A & B`. */
7
+ function decodeEntities(text) {
8
+ return text
9
+ .replace(/&lt;/g, '<')
10
+ .replace(/&gt;/g, '>')
11
+ .replace(/&quot;/g, '"')
12
+ .replace(/&#0?39;/g, "'")
13
+ .replace(/&amp;/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
- * @returns Parsed markdown with frontmatter data, cleaned markdown, and rendered HTML
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
- // Reset footnote store for this document
70
- resetFootnoteStore();
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
- const language = lang || 'text';
75
- // Skip mermaid blocks - they're handled by the mermaid extension
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
- return '';
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
- renderer.heading = function ({ tokens, text, depth }) {
93
- const id = text
94
- .toLowerCase()
95
- .replace(/\./g, '-') // Convert periods to hyphens (preserves version numbers like v2.0 → v2-0)
96
- .replace(/[^\w\s-]+/g, '') // Remove other special characters
97
- .replace(/\s+/g, '-') // Convert spaces to hyphens
98
- .replace(/-+/g, '-') // Collapse multiple hyphens
99
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
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>&amp;</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
- return `<h${depth} id="${id}">${this.parser.parseInline(tokens)}</h${depth}>`;
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)}">&#8203;</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].replace(/<[^>]*>/g, ''),
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;