sourcey 3.4.3 → 3.4.6

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