sourcey 3.4.4 → 3.4.7

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.
@@ -3,7 +3,7 @@ import { basename, extname, relative } from "node:path";
3
3
  import { load as parseYaml } from "js-yaml";
4
4
  import { htmlId } from "../utils/html-id.js";
5
5
  import { renderIcon } from "../utils/icons.js";
6
- import { renderMarkdown, extractHeadings } from "../utils/markdown.js";
6
+ import { renderCodeBlock, renderMarkdown, renderMarkdownInline, extractHeadings, } from "../utils/markdown.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Frontmatter parsing
9
9
  // ---------------------------------------------------------------------------
@@ -17,6 +17,11 @@ function parseFrontmatter(raw) {
17
17
  return { meta, body: match[2] };
18
18
  }
19
19
  const FENCED_BLOCK_TOKEN = "@@SOURCEY_FENCED_BLOCK_";
20
+ const INLINE_CODE_TOKEN = "@@SOURCEY_INLINE_CODE_";
21
+ /** Current page's protected fence blocks — used to restore placeholders in JSX children. */
22
+ let activeFenceBlocks = [];
23
+ /** Current page's protected inline code spans — used to restore placeholders in JSX children. */
24
+ let activeInlineSpans = [];
20
25
  function protectFencedCodeBlocks(input) {
21
26
  const blocks = [];
22
27
  const output = [];
@@ -57,100 +62,509 @@ function protectFencedCodeBlocks(input) {
57
62
  function restoreFencedCodeBlocks(input, blocks) {
58
63
  return blocks.reduce((text, block, index) => text.replaceAll(`${FENCED_BLOCK_TOKEN}${index}@@`, block), input);
59
64
  }
60
- // ---------------------------------------------------------------------------
61
- // Component preprocessor: transforms MDX-style JSX components into HTML
62
- // ---------------------------------------------------------------------------
65
+ function protectInlineCodeSpans(input) {
66
+ const spans = [];
67
+ let output = "";
68
+ let i = 0;
69
+ while (i < input.length) {
70
+ if (input[i] !== "`") {
71
+ output += input[i];
72
+ i += 1;
73
+ continue;
74
+ }
75
+ let tickEnd = i + 1;
76
+ while (tickEnd < input.length && input[tickEnd] === "`")
77
+ tickEnd += 1;
78
+ const delimiter = input.slice(i, tickEnd);
79
+ const closeIndex = input.indexOf(delimiter, tickEnd);
80
+ if (closeIndex === -1) {
81
+ output += input.slice(i);
82
+ break;
83
+ }
84
+ const span = input.slice(i, closeIndex + delimiter.length);
85
+ // If the content between delimiters spans multiple lines, it's a fenced
86
+ // code block (or block-level backticks), not an inline code span — skip it.
87
+ const inner = input.slice(tickEnd, closeIndex);
88
+ if (inner.includes("\n")) {
89
+ output += input[i];
90
+ i += 1;
91
+ continue;
92
+ }
93
+ const index = spans.push(span) - 1;
94
+ output += `${INLINE_CODE_TOKEN}${index}@@`;
95
+ i = closeIndex + delimiter.length;
96
+ }
97
+ return { text: output, spans };
98
+ }
99
+ function restoreInlineCodeSpans(input, spans) {
100
+ return spans.reduce((text, span, index) => text.replaceAll(`${INLINE_CODE_TOKEN}${index}@@`, span), input);
101
+ }
102
+ function skipWhitespace(input, index) {
103
+ let i = index;
104
+ while (i < input.length && /\s/.test(input[i]))
105
+ i += 1;
106
+ return i;
107
+ }
108
+ function parseQuotedAttrValue(input, index) {
109
+ const quote = input[index];
110
+ if (quote !== `"` && quote !== `'`)
111
+ return null;
112
+ let value = "";
113
+ let i = index + 1;
114
+ while (i < input.length) {
115
+ const ch = input[i];
116
+ if (ch === "\\") {
117
+ if (i + 1 < input.length) {
118
+ value += input[i + 1];
119
+ i += 2;
120
+ continue;
121
+ }
122
+ i += 1;
123
+ continue;
124
+ }
125
+ if (ch === quote) {
126
+ return { value, nextIndex: i + 1 };
127
+ }
128
+ value += ch;
129
+ i += 1;
130
+ }
131
+ return null;
132
+ }
133
+ function parseBracedAttrValue(input, index) {
134
+ if (input[index] !== "{")
135
+ return null;
136
+ let depth = 1;
137
+ let i = index + 1;
138
+ while (i < input.length) {
139
+ const ch = input[i];
140
+ if (ch === `"` || ch === `'`) {
141
+ const quoted = parseQuotedAttrValue(input, i);
142
+ if (!quoted)
143
+ return null;
144
+ i = quoted.nextIndex;
145
+ continue;
146
+ }
147
+ if (ch === "{") {
148
+ depth += 1;
149
+ }
150
+ else if (ch === "}") {
151
+ depth -= 1;
152
+ if (depth === 0) {
153
+ return {
154
+ value: input.slice(index + 1, i).trim(),
155
+ nextIndex: i + 1,
156
+ };
157
+ }
158
+ }
159
+ i += 1;
160
+ }
161
+ return null;
162
+ }
163
+ function parseKeyValueAttrs(raw, options) {
164
+ const attrs = {};
165
+ let i = 0;
166
+ while (i < raw.length) {
167
+ i = skipWhitespace(raw, i);
168
+ if (i >= raw.length)
169
+ break;
170
+ const keyMatch = raw.slice(i).match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
171
+ if (!keyMatch)
172
+ break;
173
+ const key = keyMatch[1];
174
+ i += key.length;
175
+ i = skipWhitespace(raw, i);
176
+ if (raw[i] !== "=")
177
+ break;
178
+ i += 1;
179
+ i = skipWhitespace(raw, i);
180
+ const parsedValue = raw[i] === "{" && options.allowBraces
181
+ ? parseBracedAttrValue(raw, i)
182
+ : parseQuotedAttrValue(raw, i);
183
+ if (!parsedValue)
184
+ break;
185
+ attrs[key] = parsedValue.value;
186
+ i = parsedValue.nextIndex;
187
+ }
188
+ return attrs;
189
+ }
190
+ const SUPPORTED_COMPONENT_TAGS = new Set([
191
+ "Steps",
192
+ "Step",
193
+ "CardGroup",
194
+ "Card",
195
+ "AccordionGroup",
196
+ "Accordion",
197
+ "Expandable",
198
+ "Tabs",
199
+ "Tab",
200
+ "CodeGroup",
201
+ "Note",
202
+ "Warning",
203
+ "Tip",
204
+ "Info",
205
+ "Video",
206
+ "Iframe",
207
+ ]);
208
+ const SELF_CLOSING_COMPONENT_TAGS = new Set(["Video", "Iframe"]);
209
+ function escapeDirectiveAttr(value) {
210
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
211
+ }
212
+ function buildDirectiveAttrList(entries) {
213
+ const attrs = entries
214
+ .filter(([, value]) => value !== undefined && value !== "")
215
+ .map(([key, value]) => `${key}="${escapeDirectiveAttr(value)}"`);
216
+ return attrs.length > 0 ? `{${attrs.join(" ")}}` : "";
217
+ }
218
+ function indentBlock(text, prefix) {
219
+ const lines = text.split("\n");
220
+ const result = [];
221
+ let fence = null;
222
+ for (const line of lines) {
223
+ if (fence) {
224
+ // Inside a fenced code block — don't indent
225
+ result.push(line);
226
+ if (closesFence(line, fence))
227
+ fence = null;
228
+ }
229
+ else {
230
+ const nextFence = isFenceStart(line);
231
+ if (nextFence) {
232
+ // Fence opener — don't indent so markdown recognises it
233
+ fence = nextFence;
234
+ result.push(line);
235
+ }
236
+ else {
237
+ result.push(`${prefix}${line}`);
238
+ }
239
+ }
240
+ }
241
+ return result.join("\n");
242
+ }
243
+ function isWhitespaceOnlyText(node) {
244
+ return node.kind === "text" && node.value.trim() === "";
245
+ }
246
+ function findComponentTagEnd(input, start) {
247
+ let quote = null;
248
+ let braceDepth = 0;
249
+ let i = start + 1;
250
+ while (i < input.length) {
251
+ const ch = input[i];
252
+ if (quote) {
253
+ if (ch === "\\") {
254
+ i += 2;
255
+ continue;
256
+ }
257
+ if (ch === quote)
258
+ quote = null;
259
+ i += 1;
260
+ continue;
261
+ }
262
+ if (ch === `"` || ch === `'`) {
263
+ quote = ch;
264
+ i += 1;
265
+ continue;
266
+ }
267
+ if (ch === "{") {
268
+ braceDepth += 1;
269
+ i += 1;
270
+ continue;
271
+ }
272
+ if (ch === "}" && braceDepth > 0) {
273
+ braceDepth -= 1;
274
+ i += 1;
275
+ continue;
276
+ }
277
+ if (ch === ">" && braceDepth === 0) {
278
+ return i + 1;
279
+ }
280
+ i += 1;
281
+ }
282
+ return null;
283
+ }
284
+ function tryParseComponentTag(input, start) {
285
+ if (input[start] !== "<")
286
+ return null;
287
+ const end = findComponentTagEnd(input, start);
288
+ if (end === null)
289
+ return null;
290
+ const raw = input.slice(start, end);
291
+ const inner = input.slice(start + 1, end - 1).trim();
292
+ if (!inner)
293
+ return null;
294
+ if (inner.startsWith("/")) {
295
+ const closeName = inner.slice(1).trim();
296
+ if (!SUPPORTED_COMPONENT_TAGS.has(closeName))
297
+ return null;
298
+ return {
299
+ name: closeName,
300
+ kind: "close",
301
+ attrs: {},
302
+ raw,
303
+ start,
304
+ end,
305
+ };
306
+ }
307
+ const selfClosing = inner.endsWith("/");
308
+ const content = selfClosing ? inner.slice(0, -1).trimEnd() : inner;
309
+ const nameMatch = content.match(/^([A-Za-z][A-Za-z0-9]*)/);
310
+ if (!nameMatch || !SUPPORTED_COMPONENT_TAGS.has(nameMatch[1])) {
311
+ return null;
312
+ }
313
+ const name = nameMatch[1];
314
+ const attrSource = content.slice(name.length);
315
+ return {
316
+ name,
317
+ kind: selfClosing || SELF_CLOSING_COMPONENT_TAGS.has(name) ? "self" : "open",
318
+ attrs: parseKeyValueAttrs(attrSource, { allowBraces: true }),
319
+ raw,
320
+ start,
321
+ end,
322
+ };
323
+ }
324
+ function parseComponentNodes(input, startIndex = 0, untilTag) {
325
+ const nodes = [];
326
+ let cursor = startIndex;
327
+ while (cursor < input.length) {
328
+ const nextTag = input.indexOf("<", cursor);
329
+ if (nextTag === -1) {
330
+ if (cursor < input.length) {
331
+ nodes.push({ kind: "text", value: input.slice(cursor) });
332
+ }
333
+ return { nodes, nextIndex: input.length };
334
+ }
335
+ if (nextTag > cursor) {
336
+ nodes.push({ kind: "text", value: input.slice(cursor, nextTag) });
337
+ }
338
+ const tag = tryParseComponentTag(input, nextTag);
339
+ if (!tag) {
340
+ nodes.push({ kind: "text", value: "<" });
341
+ cursor = nextTag + 1;
342
+ continue;
343
+ }
344
+ if (tag.kind === "close") {
345
+ if (untilTag && tag.name === untilTag) {
346
+ return {
347
+ nodes,
348
+ nextIndex: tag.end,
349
+ matchedClose: tag,
350
+ };
351
+ }
352
+ nodes.push({ kind: "text", value: tag.raw });
353
+ cursor = tag.end;
354
+ continue;
355
+ }
356
+ if (tag.kind === "self") {
357
+ nodes.push({
358
+ kind: "component",
359
+ name: tag.name,
360
+ attrs: tag.attrs,
361
+ children: [],
362
+ original: tag.raw,
363
+ });
364
+ cursor = tag.end;
365
+ continue;
366
+ }
367
+ const childResult = parseComponentNodes(input, tag.end, tag.name);
368
+ if (!childResult.matchedClose) {
369
+ nodes.push({
370
+ kind: "text",
371
+ value: input.slice(nextTag, childResult.nextIndex),
372
+ });
373
+ cursor = childResult.nextIndex;
374
+ continue;
375
+ }
376
+ nodes.push({
377
+ kind: "component",
378
+ name: tag.name,
379
+ attrs: tag.attrs,
380
+ children: childResult.nodes,
381
+ original: input.slice(nextTag, childResult.nextIndex),
382
+ });
383
+ cursor = childResult.nextIndex;
384
+ }
385
+ return { nodes, nextIndex: cursor };
386
+ }
387
+ function renderParsedComponentNodes(nodes) {
388
+ return nodes.map(renderParsedComponentNode).join("");
389
+ }
390
+ function collectChildComponents(children, expected) {
391
+ const matches = [];
392
+ for (const child of children) {
393
+ if (isWhitespaceOnlyText(child))
394
+ continue;
395
+ if (child.kind !== "component" || child.name !== expected) {
396
+ return null;
397
+ }
398
+ matches.push(child);
399
+ }
400
+ return matches;
401
+ }
402
+ /**
403
+ * Render the raw text content of parsed component children through the full
404
+ * markdown pipeline (component preprocessing → directive preprocessing →
405
+ * markdown rendering). Strips common leading whitespace first so that
406
+ * indented JSX content (e.g. code fences inside <Step>) is handled correctly.
407
+ */
408
+ function renderComponentChildrenToHtml(children) {
409
+ let raw = renderParsedComponentNodes(children);
410
+ raw = restoreFencedCodeBlocks(raw, activeFenceBlocks);
411
+ raw = restoreInlineCodeSpans(raw, activeInlineSpans);
412
+ return renderDirectiveMarkdown(dedent(raw));
413
+ }
414
+ /**
415
+ * Strip common leading whitespace from a block of text.
416
+ */
417
+ function dedent(text) {
418
+ const lines = text.split("\n");
419
+ const nonEmpty = lines.filter((l) => l.trim().length > 0);
420
+ if (nonEmpty.length === 0)
421
+ return text;
422
+ const indent = Math.min(...nonEmpty.map((l) => l.match(/^(\s*)/)?.[1].length ?? 0));
423
+ if (indent === 0)
424
+ return text;
425
+ return lines.map((l) => l.slice(indent)).join("\n");
426
+ }
427
+ /**
428
+ * Render an accordion from a parsed JSX component directly to HTML.
429
+ */
430
+ function renderComponentAccordion(node) {
431
+ const title = renderMarkdownInline(node.attrs.title || "").trim();
432
+ const body = renderComponentChildrenToHtml(node.children);
433
+ return `<details class="accordion-item">
434
+ <summary class="accordion-trigger">${title}</summary>
435
+ <div class="accordion-content">
436
+ ${body}
437
+ </div>
438
+ </details>`;
439
+ }
440
+ /**
441
+ * Render a parsed JSX component directly to final HTML.
442
+ * Returns null if the component structure is invalid (falls back to raw text).
443
+ */
444
+ function renderParsedComponentElement(node) {
445
+ if (node.name === "Steps") {
446
+ const steps = collectChildComponents(node.children, "Step");
447
+ if (!steps)
448
+ return null;
449
+ const items = steps
450
+ .map((step, index) => {
451
+ const title = renderMarkdownInline(step.attrs.title || "").trim();
452
+ const body = renderComponentChildrenToHtml(step.children);
453
+ return `<div role="listitem" class="step-item">
454
+ <div class="step-number">${index + 1}</div>
455
+ <div class="step-body">
456
+ <p class="step-title">${title}</p>
457
+ <div class="step-content">
458
+ ${body}
459
+ </div>
460
+ </div>
461
+ </div>`;
462
+ })
463
+ .join("\n");
464
+ return `\n\n<div role="list" class="steps not-prose">\n${items}\n</div>\n`;
465
+ }
466
+ if (node.name === "CardGroup") {
467
+ const cards = collectChildComponents(node.children, "Card");
468
+ if (!cards)
469
+ return null;
470
+ const cols = node.attrs.cols || "2";
471
+ const cardHtml = cards
472
+ .map((card) => {
473
+ const tag = card.attrs.href ? "a" : "div";
474
+ const href = card.attrs.href ? ` href="${escapeHtmlAttr(card.attrs.href)}"` : "";
475
+ const iconHtml = renderIcon(card.attrs.icon || "");
476
+ const title = renderMarkdownInline(card.attrs.title || "").trim();
477
+ const body = renderComponentChildrenToHtml(card.children);
478
+ return `<${tag}${href} class="card-item">
479
+ <div class="card-item-inner">
480
+ ${iconHtml}
481
+ <h3 class="card-item-title">${title}</h3>
482
+ <div class="card-item-content">
483
+ ${body}
484
+ </div>
485
+ </div>
486
+ </${tag}>`;
487
+ })
488
+ .join("\n");
489
+ return `\n\n<div class="card-group not-prose" data-cols="${escapeHtmlAttr(cols)}">\n${cardHtml}\n</div>\n`;
490
+ }
491
+ if (node.name === "AccordionGroup") {
492
+ const accordions = collectChildComponents(node.children, "Accordion");
493
+ if (!accordions)
494
+ return null;
495
+ return `\n\n<div class="accordion-group not-prose">\n${accordions.map(renderComponentAccordion).join("\n")}\n</div>\n`;
496
+ }
497
+ if (node.name === "Accordion" || node.name === "Expandable") {
498
+ return `\n\n${renderComponentAccordion(node)}\n`;
499
+ }
500
+ if (node.name === "Tabs") {
501
+ const tabs = collectChildComponents(node.children, "Tab");
502
+ if (!tabs)
503
+ return null;
504
+ const tabData = tabs.map((tab) => ({
505
+ title: renderMarkdownInline(tab.attrs.title || "").trim(),
506
+ body: renderComponentChildrenToHtml(tab.children),
507
+ }));
508
+ return `\n\n${buildTabbedHtml(tabData, nextId("tabs"))}\n`;
509
+ }
510
+ if (node.name === "CodeGroup") {
511
+ let cgRaw = renderParsedComponentNodes(node.children);
512
+ cgRaw = restoreFencedCodeBlocks(cgRaw, activeFenceBlocks);
513
+ cgRaw = restoreInlineCodeSpans(cgRaw, activeInlineSpans);
514
+ const codeBlocks = parseTitledCodeBlocks(dedent(cgRaw)).map((block) => ({
515
+ title: renderMarkdownInline(block.title).trim(),
516
+ body: renderCodeBlock(block.body, block.lang),
517
+ }));
518
+ if (codeBlocks.length === 0)
519
+ return null;
520
+ return `\n\n${buildTabbedHtml(codeBlocks, nextId("cg"), "directive-code-group")}\n`;
521
+ }
522
+ if (node.name === "Note" || node.name === "Warning" || node.name === "Tip" || node.name === "Info") {
523
+ const type = node.name.toLowerCase();
524
+ const label = renderMarkdownInline(node.attrs.title?.trim() || node.name.charAt(0).toUpperCase() + node.name.slice(1)).trim();
525
+ const body = renderComponentChildrenToHtml(node.children);
526
+ return `\n\n<div class="callout callout-${type} not-prose">
527
+ <div class="callout-title">${label}</div>
528
+ ${body ? `<div class="callout-content">\n${body}\n</div>` : ""}
529
+ </div>\n`;
530
+ }
531
+ if (node.name === "Video") {
532
+ const titleAttr = node.attrs.title ? `{title="${escapeDirectiveAttr(node.attrs.title)}"}` : "";
533
+ return `::video[${node.attrs.src || ""}]${titleAttr}`;
534
+ }
535
+ if (node.name === "Iframe") {
536
+ const attrString = buildDirectiveAttrList([
537
+ ["title", node.attrs.title],
538
+ ["height", node.attrs.height],
539
+ ]);
540
+ return `::iframe[${node.attrs.src || ""}]${attrString}`;
541
+ }
542
+ return null;
543
+ }
544
+ function renderParsedComponentNode(node) {
545
+ if (node.kind === "text")
546
+ return node.value;
547
+ return renderParsedComponentElement(node) ?? node.original;
548
+ }
63
549
  /**
64
550
  * Convert JSX-style components to directive syntax so they go through
65
551
  * a single rendering path in preprocessDirectives.
66
552
  */
67
553
  function preprocessComponents(body) {
68
- const { text, blocks } = protectFencedCodeBlocks(body);
69
- let html = text;
70
- // <Steps> <Step title="...">content</Step> ... </Steps> → :::steps numbered list
71
- html = html.replace(/<Steps>\s*([\s\S]*?)\s*<\/Steps>/g, (_m, inner) => {
72
- const steps = [];
73
- inner.replace(/\s*<Step\s+title="([^"]*)">\s*([\s\S]*?)\s*<\/Step>/g, (_sm, title, content) => {
74
- steps.push({ title, content: content.trim() });
75
- return "";
76
- });
77
- const list = steps
78
- .map((s, i) => `${i + 1}. ${s.title}\n ${s.content}`)
79
- .join("\n");
80
- return `:::steps\n${list}\n:::`;
81
- });
82
- // <CardGroup cols={N}> <Card ...> ... </Card> ... </CardGroup> → :::card-group
83
- html = html.replace(/<CardGroup\s+cols=\{(\d+)\}>\s*([\s\S]*?)\s*<\/CardGroup>/g, (_m, cols, inner) => {
84
- const cards = inner.replace(/\s*<Card\s+title="([^"]*)"\s+icon="([^"]*)"(?:\s+href="([^"]*)")?\s*>\s*([\s\S]*?)\s*<\/Card>/g, (_cm, title, icon, href, content) => {
85
- const hrefAttr = href ? ` href="${href}"` : "";
86
- return `\n::card{title="${title}" icon="${icon}"${hrefAttr}}\n${content.trim()}\n::`;
87
- }).trim();
88
- return `:::card-group{cols="${cols}"}\n${cards}\n:::`;
89
- });
90
- // <AccordionGroup> <Accordion ...> ... </AccordionGroup> → wrap in accordion-group
91
- html = html.replace(/<AccordionGroup>\s*([\s\S]*?)\s*<\/AccordionGroup>/g, (_m, inner) => {
92
- const items = inner.replace(/\s*<Accordion\s+title="([^"]*)">\s*([\s\S]*?)\s*<\/Accordion>/g, (_am, title, content) => {
93
- return `\n:::accordion{title="${title}"}\n${content.trim()}\n:::`;
94
- }).trim();
95
- return `<div class="accordion-group not-prose">\n${items}\n</div>`;
96
- });
97
- // Standalone <Accordion> outside of group
98
- html = html.replace(/<Accordion\s+title="([^"]*)">\s*([\s\S]*?)\s*<\/Accordion>/g, (_m, title, content) => {
99
- return `:::accordion{title="${title}"}\n${content.trim()}\n:::`;
100
- });
101
- // <Tabs> <Tab title="...">content</Tab> ... </Tabs> → :::tabs with ::tab children
102
- html = html.replace(/<Tabs>\s*([\s\S]*?)\s*<\/Tabs>/g, (_m, inner) => {
103
- const tabs = [];
104
- inner.replace(/\s*<Tab\s+title="([^"]*)">\s*([\s\S]*?)\s*<\/Tab>/g, (_tm, title, content) => {
105
- tabs.push(`::tab{title="${title}"}\n${content.trim()}\n::`);
106
- return "";
107
- });
108
- return `:::tabs\n${tabs.join("\n")}\n:::`;
109
- });
110
- // <CodeGroup> with titled fenced code blocks → :::code-group
111
- html = html.replace(/<CodeGroup>\s*([\s\S]*?)\s*<\/CodeGroup>/g, (_m, inner) => `:::code-group\n${inner.trim()}\n:::`);
112
- // <Note>, <Warning>, <Tip>, <Info> → :::callout directives
113
- for (const type of ["note", "warning", "tip", "info"]) {
114
- const tag = type.charAt(0).toUpperCase() + type.slice(1);
115
- html = html.replace(new RegExp(`<${tag}(?:\\s+title="([^"]*)")?\\s*>\\s*([\\s\\S]*?)\\s*<\\/${tag}>`, "g"), (_m, title, content) => {
116
- const titleSuffix = title ? ` ${title}` : "";
117
- return `:::${type}${titleSuffix}\n${content.trim()}\n:::`;
118
- });
119
- }
120
- // <Video src="..." title="..." /> → ::video[url]{title="..."}
121
- html = html.replace(/<Video\s+([^>]*?)\s*\/?\s*>/g, (_m, attrs) => {
122
- const src = attrs.match(/src="([^"]*)"/)?.[1] ?? "";
123
- const title = attrs.match(/title="([^"]*)"/)?.[1];
124
- const titleAttr = title ? `{title="${title}"}` : "";
125
- return `::video[${src}]${titleAttr}`;
126
- });
127
- // <Iframe src="..." title="..." height="..." /> → ::iframe[url]{attrs}
128
- html = html.replace(/<Iframe\s+([^>]*?)\s*\/?\s*>/g, (_m, attrs) => {
129
- const src = attrs.match(/src="([^"]*)"/)?.[1] ?? "";
130
- const title = attrs.match(/title="([^"]*)"/)?.[1];
131
- const height = attrs.match(/height="([^"]*)"/)?.[1];
132
- const parts = [];
133
- if (title)
134
- parts.push(`title="${title}"`);
135
- if (height)
136
- parts.push(`height="${height}"`);
137
- const attrStr = parts.length ? `{${parts.join(" ")}}` : "";
138
- return `::iframe[${src}]${attrStr}`;
139
- });
140
- return restoreFencedCodeBlocks(html, blocks);
554
+ const { text: fencedText, blocks } = protectFencedCodeBlocks(body);
555
+ activeFenceBlocks = blocks;
556
+ const { text, spans } = protectInlineCodeSpans(fencedText);
557
+ activeInlineSpans = spans;
558
+ const parsed = parseComponentNodes(text);
559
+ const restoredInline = restoreInlineCodeSpans(renderParsedComponentNodes(parsed.nodes), spans);
560
+ return restoreFencedCodeBlocks(restoredInline, blocks);
141
561
  }
142
562
  // ---------------------------------------------------------------------------
143
563
  // Directive preprocessor: transforms :::directive blocks into HTML
144
564
  // ---------------------------------------------------------------------------
145
565
  /** Parse {key="value" key2="value2"} attribute strings. */
146
566
  function parseAttrs(raw) {
147
- const attrs = {};
148
- if (!raw)
149
- return attrs;
150
- for (const m of raw.matchAll(/(\w+)="([^"]*)"/g)) {
151
- attrs[m[1]] = m[2];
152
- }
153
- return attrs;
567
+ return parseKeyValueAttrs(raw, { allowBraces: false });
154
568
  }
155
569
  /** Deterministic tab-group ID; reset per page. */
156
570
  let directiveCounter = 0;
@@ -161,6 +575,31 @@ function nextId(prefix) {
161
575
  function resetDirectiveCounter() {
162
576
  directiveCounter = 0;
163
577
  }
578
+ function escapeRegExp(value) {
579
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
580
+ }
581
+ function escapeHtmlAttr(value) {
582
+ return value
583
+ .replace(/&/g, "&amp;")
584
+ .replace(/"/g, "&quot;")
585
+ .replace(/</g, "&lt;");
586
+ }
587
+ function stripDirectiveIndent(line) {
588
+ return line.replace(/^ {0,3}/, "");
589
+ }
590
+ function isFenceStart(line) {
591
+ const match = stripDirectiveIndent(line).match(/^(`{3,}|~{3,})(.*)$/);
592
+ if (!match)
593
+ return null;
594
+ return { char: match[1][0], length: match[1].length };
595
+ }
596
+ function closesFence(line, fence) {
597
+ const match = stripDirectiveIndent(line).match(/^(`{3,}|~{3,})(.*)$/);
598
+ return !!(match &&
599
+ match[1][0] === fence.char &&
600
+ match[1].length >= fence.length &&
601
+ !match[2].trim());
602
+ }
164
603
  /** Build tabbed UI HTML (shared by :::tabs and :::code-group). */
165
604
  function buildTabbedHtml(tabs, id, extraClass) {
166
605
  const cls = extraClass
@@ -187,146 +626,316 @@ ${panels}
187
626
  */
188
627
  function splitChildren(content, marker) {
189
628
  const children = [];
190
- const parts = content.split(new RegExp(`^\\s*::${marker}`, "gm"));
191
- for (const part of parts) {
192
- if (!part.trim())
629
+ const lines = content.split("\n");
630
+ const startRe = new RegExp(`^::${escapeRegExp(marker)}(?:\\{([^}]*)\\})?\\s*$`);
631
+ let i = 0;
632
+ while (i < lines.length) {
633
+ const start = stripDirectiveIndent(lines[i]).trimEnd().match(startRe);
634
+ if (!start) {
635
+ i += 1;
193
636
  continue;
194
- const m = part.match(/^\{([^}]*)\}\s*\n?([\s\S]*)/);
195
- if (m) {
196
- // Strip trailing :: child-close marker
197
- const body = m[2].trim().replace(/\n?::$/m, "").trim();
198
- children.push({ attrs: parseAttrs(m[1]), body });
199
637
  }
638
+ let fence = null;
639
+ let depth = 1;
640
+ let j = i + 1;
641
+ while (j < lines.length) {
642
+ const line = lines[j];
643
+ if (fence) {
644
+ if (closesFence(line, fence))
645
+ fence = null;
646
+ j += 1;
647
+ continue;
648
+ }
649
+ const nextFence = isFenceStart(line);
650
+ if (nextFence) {
651
+ fence = nextFence;
652
+ j += 1;
653
+ continue;
654
+ }
655
+ const stripped = stripDirectiveIndent(line).trimEnd();
656
+ if (startRe.test(stripped)) {
657
+ depth += 1;
658
+ }
659
+ else if (/^::\s*$/.test(stripped)) {
660
+ depth -= 1;
661
+ if (depth === 0)
662
+ break;
663
+ }
664
+ j += 1;
665
+ }
666
+ if (depth !== 0) {
667
+ i += 1;
668
+ continue;
669
+ }
670
+ children.push({
671
+ attrs: parseAttrs(start[1] ?? ""),
672
+ body: lines.slice(i + 1, j).join("\n").trim(),
673
+ });
674
+ i = j + 1;
200
675
  }
201
676
  return children;
202
677
  }
203
- /**
204
- * Process :::directive blocks. Handles callouts, steps, tabs,
205
- * code-group, card-group, and accordion.
206
- *
207
- * Matching strategy: process one block type at a time. Each regex
208
- * matches a specific `:::type` opener so closing `:::` markers
209
- * from other directive types cannot collide.
210
- */
211
- function preprocessDirectives(body) {
212
- const { text, blocks } = protectFencedCodeBlocks(body);
213
- let html = text;
214
- // Callouts: :::note, :::warning, :::tip, :::info (optional title)
215
- html = html.replace(/^:::(note|warning|tip|info)(?:[^\S\n]+(.+))?\s*\n([\s\S]*?)^:::\s*$/gm, (_m, type, title, content) => {
216
- const label = title?.trim() || type.charAt(0).toUpperCase() + type.slice(1);
217
- const body = content.trim();
678
+ function parseTitledCodeBlocks(content) {
679
+ const blocks = [];
680
+ const lines = content.split("\n");
681
+ let i = 0;
682
+ while (i < lines.length) {
683
+ const line = stripDirectiveIndent(lines[i]).trimEnd();
684
+ const open = line.match(/^(`{3,}|~{3,})(\S+)?(?:\s+title="([^"]*)"|\s+(.+?))?\s*$/);
685
+ if (!open) {
686
+ i += 1;
687
+ continue;
688
+ }
689
+ const fence = { char: open[1][0], length: open[1].length };
690
+ let j = i + 1;
691
+ while (j < lines.length && !closesFence(lines[j], fence))
692
+ j += 1;
693
+ if (j >= lines.length)
694
+ break;
695
+ blocks.push({
696
+ title: open[3] ?? open[4] ?? "",
697
+ lang: open[2] ?? "",
698
+ body: lines.slice(i + 1, j).join("\n"),
699
+ });
700
+ i = j + 1;
701
+ }
702
+ return blocks;
703
+ }
704
+ const SUPPORTED_DIRECTIVES = new Set([
705
+ "note",
706
+ "warning",
707
+ "tip",
708
+ "info",
709
+ "steps",
710
+ "tabs",
711
+ "code-group",
712
+ "card-group",
713
+ "accordion",
714
+ ]);
715
+ function matchDirectiveStart(line) {
716
+ const match = stripDirectiveIndent(line).trimEnd().match(/^:::(\w[\w-]*)(.*)$/);
717
+ if (!match || !SUPPORTED_DIRECTIVES.has(match[1]))
718
+ return null;
719
+ return {
720
+ type: match[1],
721
+ meta: match[2].trim(),
722
+ };
723
+ }
724
+ function isAnyDirectiveStart(line) {
725
+ return /^:::\w[\w-]*(?:\{[^}]*\}|.*)?$/.test(stripDirectiveIndent(line).trimEnd());
726
+ }
727
+ function renderDirectiveMarkdown(content) {
728
+ const trimmed = content.trim();
729
+ if (!trimmed)
730
+ return "";
731
+ return renderMarkdown(preprocessDirectives(preprocessComponents(trimmed))).trim();
732
+ }
733
+ function collectDirectiveBlock(lines, startIndex) {
734
+ let fence = null;
735
+ let depth = 1;
736
+ let i = startIndex + 1;
737
+ while (i < lines.length) {
738
+ const line = lines[i];
739
+ if (fence) {
740
+ if (closesFence(line, fence))
741
+ fence = null;
742
+ i += 1;
743
+ continue;
744
+ }
745
+ const nextFence = isFenceStart(line);
746
+ if (nextFence) {
747
+ fence = nextFence;
748
+ i += 1;
749
+ continue;
750
+ }
751
+ const stripped = stripDirectiveIndent(line).trimEnd();
752
+ if (isAnyDirectiveStart(stripped)) {
753
+ depth += 1;
754
+ }
755
+ else if (/^:::\s*$/.test(stripped)) {
756
+ depth -= 1;
757
+ if (depth === 0) {
758
+ return {
759
+ content: lines.slice(startIndex + 1, i).join("\n"),
760
+ nextLine: i + 1,
761
+ };
762
+ }
763
+ }
764
+ i += 1;
765
+ }
766
+ return null;
767
+ }
768
+ function renderDirectiveBlock(type, meta, content) {
769
+ if (type === "note" || type === "warning" || type === "tip" || type === "info") {
770
+ const label = renderMarkdownInline(meta.trim() || type.charAt(0).toUpperCase() + type.slice(1)).trim();
771
+ const body = renderDirectiveMarkdown(content);
218
772
  return `\n\n<div class="callout callout-${type} not-prose">
219
773
  <div class="callout-title">${label}</div>
220
- ${body ? `<div class="callout-content">\n\n${body}\n\n</div>` : ""}
221
- </div>\n\n`;
222
- });
223
- // Steps: :::steps with a plain numbered list inside
224
- html = html.replace(/^:::steps\s*\n([\s\S]*?)^:::\s*$/gm, (_m, content) => {
774
+ ${body ? `<div class="callout-content">\n${body}\n</div>` : ""}
775
+ </div>\n`;
776
+ }
777
+ if (type === "steps") {
225
778
  const lines = content.trim().split("\n");
226
779
  const steps = [];
227
- let fenceMarker = "";
780
+ let fence = null;
228
781
  for (const line of lines) {
229
- // Track fenced code blocks so we don't mis-detect numbered
230
- // lines inside them as step titles. A closing fence must use
231
- // the same character, at least as many repeats, and no info string.
232
- const stripped = line.replace(/^ {1,3}/, "");
233
- const fm = stripped.match(/^(`{3,}|~{3,})(.*)/);
234
- if (fm) {
235
- if (!fenceMarker) {
236
- fenceMarker = fm[1];
237
- }
238
- else if (fm[1][0] === fenceMarker[0] &&
239
- fm[1].length >= fenceMarker.length &&
240
- !fm[2].trim()) {
241
- fenceMarker = "";
782
+ if (fence) {
783
+ if (closesFence(line, fence))
784
+ fence = null;
785
+ }
786
+ else {
787
+ const nextFence = isFenceStart(line);
788
+ if (nextFence) {
789
+ fence = nextFence;
242
790
  }
243
791
  }
244
- const sm = !fenceMarker && line.match(/^\d+\.\s+(.+)/);
245
- if (sm) {
246
- steps.push({ title: sm[1], body: [] });
792
+ const stripped = line.replace(/^ {1,3}/, "");
793
+ const stepMatch = !fence && line.match(/^\d+\.\s+(.+)/);
794
+ if (stepMatch) {
795
+ steps.push({ title: stepMatch[1], body: [] });
247
796
  }
248
797
  else if (steps.length > 0) {
249
- // Strip up to 3 leading spaces (markdown list indent)
250
798
  steps[steps.length - 1].body.push(stripped);
251
799
  }
252
800
  }
801
+ if (steps.length === 0)
802
+ return renderDirectiveMarkdown(content);
253
803
  const items = steps
254
- .map((s, i) => `<div role="listitem" class="step-item">
255
- <div class="step-number">${i + 1}</div>
804
+ .map((step, index) => {
805
+ const title = renderMarkdownInline(step.title).trim();
806
+ const body = renderDirectiveMarkdown(step.body.join("\n").trim());
807
+ return `<div role="listitem" class="step-item">
808
+ <div class="step-number">${index + 1}</div>
256
809
  <div class="step-body">
257
- <p class="step-title">${s.title}</p>
810
+ <p class="step-title">${title}</p>
258
811
  <div class="step-content">
259
-
260
- ${s.body.join("\n").trim()}
261
-
812
+ ${body}
262
813
  </div>
263
814
  </div>
264
- </div>`)
815
+ </div>`;
816
+ })
265
817
  .join("\n");
266
- return `\n\n<div role="list" class="steps not-prose">\n${items}\n</div>\n\n`;
267
- });
268
- // Tabs: :::tabs with ::tab{title="..."} children
269
- html = html.replace(/^:::tabs\s*\n([\s\S]*?)^:::\s*$/gm, (_m, content) => {
270
- const tabs = splitChildren(content, "tab").map((c) => ({
271
- title: c.attrs.title || "",
272
- body: c.body,
818
+ return `\n\n<div role="list" class="steps not-prose">\n${items}\n</div>\n`;
819
+ }
820
+ if (type === "tabs") {
821
+ const tabs = splitChildren(content, "tab").map((child) => ({
822
+ title: renderMarkdownInline(child.attrs.title || "").trim(),
823
+ body: renderDirectiveMarkdown(child.body),
824
+ }));
825
+ if (tabs.length === 0)
826
+ return renderDirectiveMarkdown(content);
827
+ return `\n\n${buildTabbedHtml(tabs, nextId("tabs"))}\n`;
828
+ }
829
+ if (type === "code-group") {
830
+ const codeBlocks = parseTitledCodeBlocks(content).map((block) => ({
831
+ title: renderMarkdownInline(block.title).trim(),
832
+ body: renderCodeBlock(block.body, block.lang),
273
833
  }));
274
- return `\n\n${buildTabbedHtml(tabs, nextId("tabs"))}\n\n`;
275
- });
276
- // Code Group: :::code-group with titled fenced code blocks
277
- html = html.replace(/^:::code-group\s*\n([\s\S]*?)^:::\s*$/gm, (_m, content) => {
278
- const restoredContent = restoreFencedCodeBlocks(content, blocks);
279
- const codeBlocks = [];
280
- const re = /```(\w+)\s+title="([^"]*)"\s*\n([\s\S]*?)```/g;
281
- let match;
282
- while ((match = re.exec(restoredContent)) !== null) {
283
- codeBlocks.push({ title: match[2], body: `\`\`\`${match[1]}\n${match[3]}\`\`\`` });
284
- }
285
834
  if (codeBlocks.length === 0)
286
- return restoredContent;
287
- return `\n\n${buildTabbedHtml(codeBlocks, nextId("cg"), "directive-code-group")}\n\n`;
288
- });
289
- // Card Group: :::card-group{cols="N"} with ::card children
290
- html = html.replace(/^:::card-group(?:\{([^}]*)\})?\s*\n([\s\S]*?)^:::\s*$/gm, (_m, attrsRaw, content) => {
291
- const cols = parseAttrs(attrsRaw || "").cols || "2";
292
- const cards = splitChildren(content, "card").map((c) => {
293
- const tag = c.attrs.href ? "a" : "div";
294
- const href = c.attrs.href ? ` href="${c.attrs.href}"` : "";
295
- const iconHtml = renderIcon(c.attrs.icon || "");
835
+ return renderDirectiveMarkdown(content);
836
+ return `\n\n${buildTabbedHtml(codeBlocks, nextId("cg"), "directive-code-group")}\n`;
837
+ }
838
+ if (type === "card-group") {
839
+ const cols = parseAttrs(meta.match(/^\{([^}]*)\}$/)?.[1] ?? "").cols || "2";
840
+ const cards = splitChildren(content, "card").map((child) => {
841
+ const tag = child.attrs.href ? "a" : "div";
842
+ const href = child.attrs.href ? ` href="${escapeHtmlAttr(child.attrs.href)}"` : "";
843
+ const iconHtml = renderIcon(child.attrs.icon || "");
844
+ const title = renderMarkdownInline(child.attrs.title || "").trim();
845
+ const body = renderDirectiveMarkdown(child.body);
296
846
  return `<${tag}${href} class="card-item">
297
847
  <div class="card-item-inner">
298
848
  ${iconHtml}
299
- <h3 class="card-item-title">${c.attrs.title || ""}</h3>
849
+ <h3 class="card-item-title">${title}</h3>
300
850
  <div class="card-item-content">
301
-
302
- ${c.body}
303
-
851
+ ${body}
304
852
  </div>
305
853
  </div>
306
854
  </${tag}>`;
307
855
  });
308
- return `\n\n<div class="card-group not-prose" data-cols="${cols}">\n${cards.join("\n")}\n</div>\n\n`;
309
- });
310
- // Accordion: :::accordion{title="..."}
311
- html = html.replace(/^:::accordion(?:\{([^}]*)\})?\s*\n([\s\S]*?)^:::\s*$/gm, (_m, attrsRaw, content) => {
312
- const title = parseAttrs(attrsRaw || "").title || "";
313
- return `\n\n<details class="accordion-item">
856
+ if (cards.length === 0)
857
+ return renderDirectiveMarkdown(content);
858
+ return `\n\n<div class="card-group not-prose" data-cols="${escapeHtmlAttr(cols)}">\n${cards.join("\n")}\n</div>\n`;
859
+ }
860
+ const title = renderMarkdownInline(parseAttrs(meta.match(/^\{([^}]*)\}$/)?.[1] ?? "").title || "").trim();
861
+ const body = renderDirectiveMarkdown(content);
862
+ return `\n\n<details class="accordion-item">
314
863
  <summary class="accordion-trigger">${title}</summary>
315
864
  <div class="accordion-content">
316
-
317
- ${content.trim()}
318
-
865
+ ${body}
319
866
  </div>
320
- </details>\n\n`;
321
- });
322
- // Auto-wrap consecutive accordion items in an accordion-group (skip already-wrapped)
323
- html = html.replace(/(<details class="accordion-item">[\s\S]*?<\/details>\s*){2,}/g, (match, _p1, offset) => {
324
- const before = html.slice(Math.max(0, offset - 40), offset);
325
- if (before.includes("accordion-group"))
326
- return match;
327
- return `<div class="accordion-group not-prose">\n${match.trim()}\n</div>`;
328
- });
329
- return restoreFencedCodeBlocks(html, blocks);
867
+ </details>\n`;
868
+ }
869
+ /**
870
+ * Process :::directive blocks. Handles callouts, steps, tabs,
871
+ * code-group, card-group, and accordion.
872
+ *
873
+ * Matching strategy: process one block type at a time. Each regex
874
+ * matches a specific `:::type` opener so closing `:::` markers
875
+ * from other directive types cannot collide.
876
+ */
877
+ function preprocessDirectives(body) {
878
+ const lines = body.split("\n");
879
+ const out = [];
880
+ let i = 0;
881
+ let fence = null;
882
+ while (i < lines.length) {
883
+ if (fence) {
884
+ out.push(lines[i]);
885
+ if (closesFence(lines[i], fence))
886
+ fence = null;
887
+ i += 1;
888
+ continue;
889
+ }
890
+ const nextFence = isFenceStart(lines[i]);
891
+ if (nextFence) {
892
+ fence = nextFence;
893
+ out.push(lines[i]);
894
+ i += 1;
895
+ continue;
896
+ }
897
+ const start = matchDirectiveStart(lines[i]);
898
+ if (!start) {
899
+ out.push(lines[i]);
900
+ i += 1;
901
+ continue;
902
+ }
903
+ if (start.type === "accordion") {
904
+ const accordions = [];
905
+ let nextLine = i;
906
+ while (true) {
907
+ const accordionStart = matchDirectiveStart(lines[nextLine]);
908
+ if (!accordionStart || accordionStart.type !== "accordion")
909
+ break;
910
+ const accordionBlock = collectDirectiveBlock(lines, nextLine);
911
+ if (!accordionBlock)
912
+ break;
913
+ accordions.push(renderDirectiveBlock("accordion", accordionStart.meta, accordionBlock.content));
914
+ nextLine = accordionBlock.nextLine;
915
+ let scan = nextLine;
916
+ while (scan < lines.length && /^\s*$/.test(lines[scan]))
917
+ scan += 1;
918
+ const nextAccordion = scan < lines.length ? matchDirectiveStart(lines[scan]) : null;
919
+ if (!nextAccordion || nextAccordion.type !== "accordion")
920
+ break;
921
+ nextLine = scan;
922
+ }
923
+ if (accordions.length > 1) {
924
+ out.push(`\n\n<div class="accordion-group not-prose">\n${accordions.join("\n")}\n</div>\n`);
925
+ i = nextLine;
926
+ continue;
927
+ }
928
+ }
929
+ const block = collectDirectiveBlock(lines, i);
930
+ if (!block) {
931
+ out.push(lines[i]);
932
+ i += 1;
933
+ continue;
934
+ }
935
+ out.push(renderDirectiveBlock(start.type, start.meta, block.content));
936
+ i = block.nextLine;
937
+ }
938
+ return out.join("\n");
330
939
  }
331
940
  // ---------------------------------------------------------------------------
332
941
  // Public API
@@ -338,14 +947,24 @@ export async function loadMarkdownPage(filePath, slug) {
338
947
  const raw = await readFile(filePath, "utf-8");
339
948
  const { meta, body } = parseFrontmatter(raw);
340
949
  resetDirectiveCounter();
341
- const preprocessed = preprocessDirectives(preprocessComponents(body));
342
- const headings = extractHeadings(preprocessed);
950
+ const componentPreprocessed = preprocessComponents(body);
951
+ const preprocessed = preprocessDirectives(componentPreprocessed);
952
+ const headings = extractHeadings(componentPreprocessed);
343
953
  const html = renderMarkdown(preprocessed);
344
- const title = meta.title ?? extractFirstHeading(preprocessed) ?? slug;
954
+ const title = meta.title ?? extractFirstHeading(componentPreprocessed) ?? slug;
345
955
  const description = meta.description ?? "";
346
956
  // Strip leading h1 from rendered HTML when it duplicates the page title
347
957
  const cleanHtml = html.replace(/^\s*<h1[^>]*>(.*?)<\/h1>\s*/i, (_m, inner) => inner.replace(/<[^>]+>/g, "").trim() === title ? "" : _m);
348
- return { title, description, slug, html: cleanHtml, headings, sourcePath: relative(process.cwd(), filePath) };
958
+ const sourcePath = relative(process.cwd(), filePath);
959
+ return {
960
+ title,
961
+ description,
962
+ slug,
963
+ html: cleanHtml,
964
+ headings,
965
+ sourcePath,
966
+ editPath: sourcePath,
967
+ };
349
968
  }
350
969
  /**
351
970
  * Extract the first # heading from markdown as a fallback title.