sourcey 3.4.4 → 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
  // ---------------------------------------------------------------------------
@@ -17,6 +17,7 @@ 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_";
20
21
  function protectFencedCodeBlocks(input) {
21
22
  const blocks = [];
22
23
  const output = [];
@@ -57,100 +58,417 @@ function protectFencedCodeBlocks(input) {
57
58
  function restoreFencedCodeBlocks(input, blocks) {
58
59
  return blocks.reduce((text, block, index) => text.replaceAll(`${FENCED_BLOCK_TOKEN}${index}@@`, block), input);
59
60
  }
60
- // ---------------------------------------------------------------------------
61
- // Component preprocessor: transforms MDX-style JSX components into HTML
62
- // ---------------------------------------------------------------------------
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
+ }
63
455
  /**
64
456
  * Convert JSX-style components to directive syntax so they go through
65
457
  * a single rendering path in preprocessDirectives.
66
458
  */
67
459
  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);
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);
141
465
  }
142
466
  // ---------------------------------------------------------------------------
143
467
  // Directive preprocessor: transforms :::directive blocks into HTML
144
468
  // ---------------------------------------------------------------------------
145
469
  /** Parse {key="value" key2="value2"} attribute strings. */
146
470
  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;
471
+ return parseKeyValueAttrs(raw, { allowBraces: false });
154
472
  }
155
473
  /** Deterministic tab-group ID; reset per page. */
156
474
  let directiveCounter = 0;
@@ -161,6 +479,31 @@ function nextId(prefix) {
161
479
  function resetDirectiveCounter() {
162
480
  directiveCounter = 0;
163
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
+ }
164
507
  /** Build tabbed UI HTML (shared by :::tabs and :::code-group). */
165
508
  function buildTabbedHtml(tabs, id, extraClass) {
166
509
  const cls = extraClass
@@ -187,146 +530,316 @@ ${panels}
187
530
  */
188
531
  function splitChildren(content, marker) {
189
532
  const children = [];
190
- const parts = content.split(new RegExp(`^\\s*::${marker}`, "gm"));
191
- for (const part of parts) {
192
- 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;
193
540
  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
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;
572
+ continue;
573
+ }
574
+ children.push({
575
+ attrs: parseAttrs(start[1] ?? ""),
576
+ body: lines.slice(i + 1, j).join("\n").trim(),
577
+ });
578
+ i = j + 1;
200
579
  }
201
580
  return children;
202
581
  }
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();
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);
218
676
  return `\n\n<div class="callout callout-${type} not-prose">
219
677
  <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) => {
678
+ ${body ? `<div class="callout-content">\n${body}\n</div>` : ""}
679
+ </div>\n`;
680
+ }
681
+ if (type === "steps") {
225
682
  const lines = content.trim().split("\n");
226
683
  const steps = [];
227
- let fenceMarker = "";
684
+ let fence = null;
228
685
  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 = "";
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;
242
694
  }
243
695
  }
244
- const sm = !fenceMarker && line.match(/^\d+\.\s+(.+)/);
245
- if (sm) {
246
- steps.push({ title: sm[1], body: [] });
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: [] });
247
700
  }
248
701
  else if (steps.length > 0) {
249
- // Strip up to 3 leading spaces (markdown list indent)
250
702
  steps[steps.length - 1].body.push(stripped);
251
703
  }
252
704
  }
705
+ if (steps.length === 0)
706
+ return renderDirectiveMarkdown(content);
253
707
  const items = steps
254
- .map((s, i) => `<div role="listitem" class="step-item">
255
- <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>
256
713
  <div class="step-body">
257
- <p class="step-title">${s.title}</p>
714
+ <p class="step-title">${title}</p>
258
715
  <div class="step-content">
259
-
260
- ${s.body.join("\n").trim()}
261
-
716
+ ${body}
262
717
  </div>
263
718
  </div>
264
- </div>`)
719
+ </div>`;
720
+ })
265
721
  .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,
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),
273
737
  }));
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
738
  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 || "");
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);
296
750
  return `<${tag}${href} class="card-item">
297
751
  <div class="card-item-inner">
298
752
  ${iconHtml}
299
- <h3 class="card-item-title">${c.attrs.title || ""}</h3>
753
+ <h3 class="card-item-title">${title}</h3>
300
754
  <div class="card-item-content">
301
-
302
- ${c.body}
303
-
755
+ ${body}
304
756
  </div>
305
757
  </div>
306
758
  </${tag}>`;
307
759
  });
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">
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">
314
767
  <summary class="accordion-trigger">${title}</summary>
315
768
  <div class="accordion-content">
316
-
317
- ${content.trim()}
318
-
769
+ ${body}
319
770
  </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);
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");
330
843
  }
331
844
  // ---------------------------------------------------------------------------
332
845
  // Public API
@@ -338,14 +851,24 @@ export async function loadMarkdownPage(filePath, slug) {
338
851
  const raw = await readFile(filePath, "utf-8");
339
852
  const { meta, body } = parseFrontmatter(raw);
340
853
  resetDirectiveCounter();
341
- const preprocessed = preprocessDirectives(preprocessComponents(body));
342
- const headings = extractHeadings(preprocessed);
854
+ const componentPreprocessed = preprocessComponents(body);
855
+ const preprocessed = preprocessDirectives(componentPreprocessed);
856
+ const headings = extractHeadings(componentPreprocessed);
343
857
  const html = renderMarkdown(preprocessed);
344
- const title = meta.title ?? extractFirstHeading(preprocessed) ?? slug;
858
+ const title = meta.title ?? extractFirstHeading(componentPreprocessed) ?? slug;
345
859
  const description = meta.description ?? "";
346
860
  // Strip leading h1 from rendered HTML when it duplicates the page title
347
861
  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) };
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
+ };
349
872
  }
350
873
  /**
351
874
  * Extract the first # heading from markdown as a fallback title.