@dogsbay/format-vitepress 0.2.0-beta.101

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.
@@ -0,0 +1,882 @@
1
+ /**
2
+ * VitePress Markdown → Dogsbay-MD text.
3
+ *
4
+ * VitePress markdown is CommonMark + `:::` containers + a handful of
5
+ * code-fence conventions + Vue. It is a near-superset of Dogsbay-MD, so this
6
+ * is a delimiter rewrite rather than a parser: once containers are renamed and
7
+ * fence metadata is normalized, `dogsbayMdToTree` defines correctness and we
8
+ * introduce zero new TreeNode types. Same pipeline as `format-gitbook` and
9
+ * `format-myst`.
10
+ *
11
+ * Everything this translator cannot map is recorded in a ledger. A migration
12
+ * that loses a construct silently is worse than one that says so.
13
+ */
14
+ // ── Container vocabulary ──────────────────────────────────────────────────
15
+ /** VitePress container name → Dogsbay-MD directive name. */
16
+ const CALLOUTS = {
17
+ info: "info",
18
+ note: "note",
19
+ tip: "tip",
20
+ warning: "warning",
21
+ danger: "danger",
22
+ // VitePress inherited `caution` from its markdown-it-container defaults in
23
+ // some themes; Dogsbay has no `caution`, and warning is the nearest.
24
+ caution: "warning",
25
+ };
26
+ /** Containers whose body passes through with the wrapper removed. */
27
+ const TRANSPARENT = new Set(["raw", "v-pre", "html"]);
28
+ // ── Public entry point ────────────────────────────────────────────────────
29
+ export function vitepressToDogsbayMd(source, opts = {}) {
30
+ const ledger = [];
31
+ const components = {};
32
+ const ctx = { ledger, components, opts, depth: 0 };
33
+ return translateWith(source, ctx);
34
+ }
35
+ function translateWith(source, ctx) {
36
+ const { ledger, components } = ctx;
37
+ let text = source.replace(/\r\n?/g, "\n");
38
+ text = stripVueBlocks(text, ctx);
39
+ const lines = text.split("\n");
40
+ const nodes = parseRange(lines, { start: 0, end: lines.length }, ctx);
41
+ const rendered = renderNodes(nodes);
42
+ return { markdown: rendered.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", ledger, components };
43
+ }
44
+ /**
45
+ * Containers may be INDENTED — VitePress's own deployment guide nests a
46
+ * `::: code-group` three spaces deep inside a numbered list, and
47
+ * markdown-it-container accepts up to three spaces of indentation. Anchoring
48
+ * these at column 0 left every such container as literal `::: code-group`
49
+ * text in the page, with its tabs unbuilt.
50
+ *
51
+ * The indent is CAPTURED and re-emitted on the translated directive, so the
52
+ * container stays inside the list item it belongs to instead of being hoisted
53
+ * out of it.
54
+ */
55
+ const OPEN_RE = /^( {0,3})(:{3,})\s*([A-Za-z][\w-]*)\s*(.*)$/;
56
+ const CLOSE_RE = /^ {0,3}(:{3,})\s*$/;
57
+ const FENCE_RE = /^(\s*)(`{3,}|~{3,})(.*)$/;
58
+ /**
59
+ * Walk a line range, tracking fenced code blocks, and call `onLine` for every
60
+ * line that is NOT inside a fence. Returns nothing; used by the closer scan
61
+ * and the tab splitter so both agree with the main loop about what is code.
62
+ */
63
+ function scanOutsideFences(lines, range, onLine) {
64
+ let i = range.start;
65
+ while (i < range.end) {
66
+ const fence = lines[i].match(FENCE_RE);
67
+ if (fence) {
68
+ const marker = fence[2];
69
+ const closeRe = new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`);
70
+ i++;
71
+ while (i < range.end && !closeRe.test(lines[i]))
72
+ i++;
73
+ i++;
74
+ continue;
75
+ }
76
+ if (onLine(lines[i], i) === true)
77
+ return;
78
+ i++;
79
+ }
80
+ }
81
+ /**
82
+ * Find the line that closes a container opened with `markerLen` colons.
83
+ *
84
+ * This is markdown-it-container's ACTUAL rule, and getting it wrong is the
85
+ * difference between a readable page and a page swallowed whole:
86
+ *
87
+ * - a closer is a line of AT LEAST `markerLen` colons and nothing else;
88
+ * - the scan runs from the opener to the end of the ENCLOSING range;
89
+ * - a container with no closer in that range auto-closes at the range end.
90
+ *
91
+ * Crucially the extent is fixed when the container OPENS, before its children
92
+ * are parsed. The earlier implementation recursed innermost-first, so an
93
+ * unclosed inner container consumed the closer meant for its parent and every
94
+ * outer container then ran to end of file. vitest's browser-mode page opens
95
+ * four containers (`warning` > `tabs` > `code-group` > `code-group`) and
96
+ * provides ONE `:::` for all of them — under the old rule that one closer went
97
+ * to the innermost code-group and the `warning` callout swallowed the rest of
98
+ * the document.
99
+ */
100
+ function findCloser(lines, range, markerLen) {
101
+ const closeRe = new RegExp(`^ {0,3}:{${markerLen},}\\s*$`);
102
+ let found = range.end;
103
+ scanOutsideFences(lines, range, (line, index) => {
104
+ if (closeRe.test(line)) {
105
+ found = index;
106
+ return true;
107
+ }
108
+ });
109
+ return found;
110
+ }
111
+ /**
112
+ * Split a `::: tabs` body on its `== Label` markers BEFORE any container in it
113
+ * is parsed.
114
+ *
115
+ * `vitepress-plugin-tabs` divides the raw body first, so an unclosed
116
+ * `::: code-group` inside one tab cannot leak into the next one. Parsing
117
+ * containers first instead merged every fence between the two markers into a
118
+ * single tab strip — the live vitest page shows Playwright and WebdriverIO
119
+ * with four package-manager tabs each; we produced one provider tab with
120
+ * fifteen, several of them duplicate labels (which `<Tabs>` cannot even
121
+ * address, since it matches by string equality).
122
+ */
123
+ function splitTabRanges(lines, range) {
124
+ const marks = [];
125
+ scanOutsideFences(lines, range, (line, index) => {
126
+ const m = line.match(/^\s*==\s+(.*)$/);
127
+ if (m)
128
+ marks.push({ title: m[1].trim(), at: index });
129
+ });
130
+ const pre = { start: range.start, end: marks.length ? marks[0].at : range.end };
131
+ const sections = marks.map((mark, i) => ({
132
+ title: mark.title,
133
+ range: { start: mark.at + 1, end: i + 1 < marks.length ? marks[i + 1].at : range.end },
134
+ }));
135
+ return { pre, sections };
136
+ }
137
+ /**
138
+ * Parse a line range into a region tree.
139
+ *
140
+ * Fence-aware: a `:::` inside a fenced code block is literal text. VitePress's
141
+ * own docs contain exactly that (the page documenting containers).
142
+ */
143
+ function parseRange(lines, range, ctx) {
144
+ const nodes = [];
145
+ /**
146
+ * Buffered lines, segmented by whether they are inside a fenced code block.
147
+ * Prose rewrites (component stripping, `{{ }}` neutralization, `.md` link
148
+ * normalization) must NOT run inside a fence: the Vue docs a VitePress site
149
+ * ships are full of `<Foo />` and `{{ msg }}` shown as code, and rewriting
150
+ * those corrupts the very examples the page exists to display.
151
+ */
152
+ let buf = [];
153
+ let i = range.start;
154
+ const flush = () => {
155
+ if (buf.length === 0)
156
+ return;
157
+ // Each segment reports its OWN first line. Attributing the whole coalesced
158
+ // run to the flush point sent every prose ledger entry to wherever the
159
+ // buffer happened to end — end of file on a container-free page, so a
160
+ // `{{ }}` on line 3 was reported at line 403.
161
+ const text = buf
162
+ .map((seg) => (seg.code ? seg.text : transformProse(seg.text, seg.line, ctx)))
163
+ .join("\n");
164
+ nodes.push({ kind: "text", text });
165
+ buf = [];
166
+ };
167
+ while (i < range.end) {
168
+ const line = lines[i];
169
+ // Fenced code block — consume whole, translating its info string.
170
+ const fence = line.match(FENCE_RE);
171
+ if (fence) {
172
+ const [, indent, marker, info] = fence;
173
+ const closeRe = new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`);
174
+ const body = [];
175
+ let j = i + 1;
176
+ while (j < range.end && !closeRe.test(lines[j])) {
177
+ body.push(lines[j]);
178
+ j++;
179
+ }
180
+ buf.push({
181
+ code: true,
182
+ line: i + 1,
183
+ text: [
184
+ `${indent}${marker}${translateFenceInfo(info, i + 1, ctx)}`,
185
+ ...body,
186
+ `${indent}${marker}`,
187
+ ].join("\n"),
188
+ });
189
+ i = j + 1;
190
+ continue;
191
+ }
192
+ // A marker-only line here belongs to no open container in this range —
193
+ // drop it rather than emit a `:::` dogsbay-md would read as an empty
194
+ // directive.
195
+ if (CLOSE_RE.test(line)) {
196
+ i++;
197
+ continue;
198
+ }
199
+ const open = line.match(OPEN_RE);
200
+ if (open) {
201
+ flush();
202
+ const indent = open[1];
203
+ const markerLen = open[2].length;
204
+ const name = open[3].toLowerCase();
205
+ const rest = open[4].trim();
206
+ const body = { start: i + 1, end: range.end };
207
+ const closeAt = findCloser(lines, body, markerLen);
208
+ if (closeAt === range.end) {
209
+ ctx.ledger.push({
210
+ construct: `::: ${name}`,
211
+ note: "no closing fence before its parent ended — VitePress requires the OUTER container to use more colons than the inner",
212
+ line: i + 1,
213
+ });
214
+ }
215
+ nodes.push(...mapContainer(name, rest, lines, { start: i + 1, end: closeAt }, i + 1, ctx, indent));
216
+ i = closeAt < range.end ? closeAt + 1 : range.end;
217
+ continue;
218
+ }
219
+ // Coalesce adjacent prose lines into ONE segment — `stripComponents`
220
+ // matches paired tags across lines, so per-line segmentation would miss
221
+ // every multi-line `<Foo>…</Foo>`.
222
+ const last = buf[buf.length - 1];
223
+ if (last && !last.code)
224
+ last.text += `\n${line}`;
225
+ else
226
+ buf.push({ code: false, text: line, line: i + 1 });
227
+ i++;
228
+ }
229
+ flush();
230
+ return nodes;
231
+ }
232
+ /**
233
+ * Make tab titles unique within one group.
234
+ *
235
+ * `<Tabs defaultValue>` and `<TabsTrigger value>` are matched by STRING
236
+ * EQUALITY, so two tabs sharing a title are not two tabs — the second is
237
+ * unreachable, and clicking its trigger shows the first. VitePress tolerates
238
+ * the duplicate (it keys by index) and vitest's own CLI page ships one: a
239
+ * `code-group` with two `[npm]` blocks. Suffixing keeps both reachable, and
240
+ * the change of label is recorded rather than made quietly.
241
+ */
242
+ function uniqueTitles(raw, construct, line, ctx) {
243
+ const seen = new Map();
244
+ let changed = false;
245
+ const out = raw.map((title) => {
246
+ const n = seen.get(title) ?? 0;
247
+ seen.set(title, n + 1);
248
+ if (n === 0)
249
+ return title;
250
+ changed = true;
251
+ return `${title} (${n + 1})`;
252
+ });
253
+ if (changed) {
254
+ ctx.ledger.push({
255
+ construct,
256
+ note: "duplicate tab titles were numbered so every tab stays reachable",
257
+ line,
258
+ });
259
+ }
260
+ return out;
261
+ }
262
+ /** Map one VitePress container to zero or more Dogsbay-MD directives. */
263
+ function mapContainer(name, rest, lines, body, line, ctx, indent = "") {
264
+ // A container's TITLE is not prose and never reaches `transformProse`, but
265
+ // authors put components in it — vitest writes
266
+ // `::: warning WARNING <Version>3.2.0</Version>`. Left alone, that tag ends
267
+ // up in a `title="…"` attribute and then in an .astro page, where an
268
+ // undefined component is a hard build error.
269
+ rest = sanitizeTitle(rest, ctx);
270
+ // `tabs` splits its RAW body on `== Label` BEFORE any nested container is
271
+ // parsed — see splitTabRanges. Every other container parses its body first.
272
+ if (name === "tabs") {
273
+ const { pre, sections } = splitTabRanges(lines, body);
274
+ if (sections.length === 0) {
275
+ ctx.ledger.push({ construct: "::: tabs", note: "no `==` tab markers found", line });
276
+ return parseRange(lines, body, ctx);
277
+ }
278
+ const titles = uniqueTitles(sections.map((sec) => sec.title), "::: tabs", line, ctx);
279
+ const tabs = sections.map((sec, idx) => ({
280
+ kind: "dir",
281
+ name: "tab",
282
+ attrs: attrBlock({ title: titles[idx] }),
283
+ indent,
284
+ children: parseRange(lines, sec.range, ctx),
285
+ }));
286
+ const out = [];
287
+ const lead = parseRange(lines, pre, ctx);
288
+ if (lead.length > 0) {
289
+ ctx.ledger.push({
290
+ construct: "::: tabs",
291
+ note: "content before the first tab kept as a sibling block",
292
+ line,
293
+ });
294
+ out.push(...lead);
295
+ }
296
+ out.push({ kind: "dir", name: "tabs", attrs: "", indent, children: tabs });
297
+ return out;
298
+ }
299
+ const children = parseRange(lines, body, ctx);
300
+ if (TRANSPARENT.has(name)) {
301
+ // `raw` / `v-pre` only suppress Vue compilation — no rendered wrapper.
302
+ return children;
303
+ }
304
+ if (name === "code-group") {
305
+ const { pre, sections } = codeGroupSections(children, ctx);
306
+ return sectionsToTabs(pre, sections, children, "::: code-group", line, ctx, indent);
307
+ }
308
+ if (name === "details") {
309
+ const title = rest || "Details";
310
+ return [{ kind: "dir", name: "details", attrs: attrBlock({ title }), indent, children }];
311
+ }
312
+ const callout = CALLOUTS[name];
313
+ if (callout) {
314
+ if (name === "caution") {
315
+ ctx.ledger.push({ construct: "::: caution", note: "mapped to :::warning", line });
316
+ }
317
+ return [
318
+ { kind: "dir", name: callout, attrs: rest ? attrBlock({ title: rest }) : "", indent, children },
319
+ ];
320
+ }
321
+ // An unknown container (a site's own markdown-it-container plugin). Keep the
322
+ // CONTENT and record the loss — an unmapped `:::name` would otherwise reach
323
+ // dogsbay-md, which has no container registered for it, and render as
324
+ // literal `:::name` text in the page.
325
+ ctx.ledger.push({
326
+ construct: `::: ${name}`,
327
+ note: "unknown container — wrapper dropped, content preserved",
328
+ line,
329
+ });
330
+ return children;
331
+ }
332
+ function pushText(nodes, text) {
333
+ if (!text.trim())
334
+ return;
335
+ nodes.push({ kind: "text", text });
336
+ }
337
+ /**
338
+ * `::: code-group` holds fenced blocks whose info string carries a `[label]`.
339
+ * Each fence starts a new tab; the label becomes its title.
340
+ */
341
+ function codeGroupSections(children, ctx) {
342
+ const pre = [];
343
+ const sections = [];
344
+ const target = () => (sections.length ? sections[sections.length - 1].nodes : pre);
345
+ for (const child of children) {
346
+ if (child.kind === "dir") {
347
+ target().push(child);
348
+ continue;
349
+ }
350
+ const lines = child.text.split("\n");
351
+ let buf = [];
352
+ let i = 0;
353
+ while (i < lines.length) {
354
+ const fence = lines[i].match(FENCE_RE);
355
+ if (!fence) {
356
+ buf.push(lines[i]);
357
+ i++;
358
+ continue;
359
+ }
360
+ pushText(target(), buf.join("\n"));
361
+ buf = [];
362
+ const [, fIndent, marker, info] = fence;
363
+ const closeRe = new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`);
364
+ const body = [];
365
+ let j = i + 1;
366
+ while (j < lines.length && !closeRe.test(lines[j])) {
367
+ body.push(lines[j]);
368
+ j++;
369
+ }
370
+ // The info string was ALREADY translated by parseRegions, so the label
371
+ // now lives in `title="…"` rather than `[…]`.
372
+ const title = info.match(/title="([^"]*)"/)?.[1] || info.trim().split(/\s+/)[0] || "Tab";
373
+ sections.push({
374
+ title,
375
+ nodes: [
376
+ {
377
+ kind: "text",
378
+ text: [`${fIndent}${marker}${info}`, ...body, `${fIndent}${marker}`].join("\n"),
379
+ },
380
+ ],
381
+ });
382
+ i = j + 1;
383
+ }
384
+ pushText(target(), buf.join("\n"));
385
+ }
386
+ return { pre, sections };
387
+ }
388
+ /**
389
+ * Turn sections into a `:::tabs` node, with any leading content emitted as a
390
+ * SIBLING before it rather than dropped.
391
+ *
392
+ * VitePress itself ignores content before the first tab marker, but a
393
+ * migration must not delete text just because the source renderer hid it —
394
+ * especially since the commonest cause is an unclosed container upstream,
395
+ * where the "leading content" is the rest of the page.
396
+ */
397
+ function sectionsToTabs(pre, sections, children, construct, line, ctx, indent) {
398
+ if (sections.length === 0) {
399
+ ctx.ledger.push({ construct, note: "no tab boundaries found — emitted as plain content", line });
400
+ return children;
401
+ }
402
+ const titles = uniqueTitles(sections.map((sec) => sec.title), construct, line, ctx);
403
+ const tabs = sections.map((sec, idx) => ({
404
+ kind: "dir",
405
+ name: "tab",
406
+ attrs: attrBlock({ title: titles[idx] }),
407
+ indent,
408
+ children: sec.nodes,
409
+ }));
410
+ const out = [];
411
+ if (pre.length > 0) {
412
+ ctx.ledger.push({
413
+ construct,
414
+ note: "content before the first tab kept as a sibling block",
415
+ line,
416
+ });
417
+ out.push(...pre);
418
+ }
419
+ out.push({ kind: "dir", name: "tabs", attrs: "", indent, children: tabs });
420
+ return out;
421
+ }
422
+ // ── Code fence info strings ───────────────────────────────────────────────
423
+ /**
424
+ * VitePress fence metadata → a single trailing `{…}` attrs block, which is
425
+ * what `dogsbayMdToTree` reads (markdown-it-attrs attaches it to the fence
426
+ * token; a bare `1,3-5` key becomes `props.highlights`).
427
+ *
428
+ * ts [vitest.config.ts] → ts {title="vitest.config.ts"}
429
+ * js{1,3-5} → js {1,3-5}
430
+ * ts:line-numbers=2 → ts {lineNumbers="2"}
431
+ * ts:no-line-numbers → ts
432
+ * js twoslash [a.js] → js {title="a.js"}
433
+ *
434
+ * The attrs block MUST be last — markdown-it-attrs only reads a trailing one.
435
+ */
436
+ export function translateFenceInfo(info, line, ctx) {
437
+ let rest = info.trim();
438
+ if (!rest)
439
+ return "";
440
+ const attrs = [];
441
+ let highlights = "";
442
+ // `[title]` — anywhere in the info string, but conventionally last.
443
+ rest = rest.replace(/\[([^\]]*)\]/, (_m, t) => {
444
+ attrs.push(`title="${escapeAttr(String(t))}"`);
445
+ return " ";
446
+ });
447
+ // `{1,3-5}` line ranges, and `{...}` blocks generally.
448
+ rest = rest.replace(/\{([^}]*)\}/g, (_m, body) => {
449
+ const b = String(body).trim();
450
+ if (/^[\d,\-\s]+$/.test(b))
451
+ highlights = b;
452
+ else if (b)
453
+ attrs.push(b);
454
+ return " ";
455
+ });
456
+ const tokens = rest.split(/\s+/).filter(Boolean);
457
+ const kept = [];
458
+ for (const tok of tokens) {
459
+ // Language plus colon-suffixed modifiers: `ts:line-numbers=2`.
460
+ const [head, ...mods] = tok.split(":");
461
+ for (const mod of mods) {
462
+ if (mod === "no-line-numbers")
463
+ continue;
464
+ const m = mod.match(/^line-numbers(?:=(\d+))?$/);
465
+ if (m) {
466
+ attrs.push(m[1] ? `lineNumbers="${m[1]}"` : "lineNumbers");
467
+ continue;
468
+ }
469
+ if (mod) {
470
+ ctx?.ledger.push({
471
+ construct: `\`\`\`…:${mod}`,
472
+ note: "unknown fence modifier dropped",
473
+ line,
474
+ });
475
+ }
476
+ }
477
+ if (!head)
478
+ continue;
479
+ // Shiki transformers that are build-time only.
480
+ if (head === "twoslash" || head === "vue-html-twoslash") {
481
+ ctx?.ledger.push({ construct: "```… twoslash", note: "Twoslash type hints dropped", line });
482
+ continue;
483
+ }
484
+ kept.push(head);
485
+ }
486
+ const lang = kept.shift() ?? "";
487
+ // Anything left over was an unrecognized bare word — keep it out of the
488
+ // language slot but say so.
489
+ for (const extra of kept) {
490
+ ctx?.ledger.push({ construct: `\`\`\`… ${extra}`, note: "unrecognized fence token dropped", line });
491
+ }
492
+ const attrParts = [highlights, ...attrs].filter(Boolean);
493
+ if (attrParts.length === 0)
494
+ return lang;
495
+ return `${lang} {${attrParts.join(" ")}}`;
496
+ }
497
+ // ── Prose-level rewrites ──────────────────────────────────────────────────
498
+ /**
499
+ * Rewrites applied to non-fenced text. Order matters: snippets and includes
500
+ * are resolved first (they inject fenced code and markdown of their own),
501
+ * then Vue is neutralized, then links are normalized.
502
+ */
503
+ function transformProse(text, line, ctx) {
504
+ let out = text;
505
+ out = resolveIncludes(out, line, ctx);
506
+ out = resolveSnippets(out, line, ctx);
507
+ out = stripToc(out, line, ctx);
508
+ out = rewriteBadges(out);
509
+ out = stripComponents(out, ctx);
510
+ out = neutralizeInterpolation(out, line, ctx);
511
+ out = rewriteMdLinks(out);
512
+ out = rewriteAssets(out, ctx, line);
513
+ return out;
514
+ }
515
+ /** Point image references at the exporter's asset mount. */
516
+ function rewriteAssets(text, ctx, line) {
517
+ const map = ctx.opts.assetHref;
518
+ if (!map)
519
+ return text;
520
+ let out = text.replace(/(!\[[^\]]*\]\()([^)\s]+)/g, (_m, head, src) => `${head}${map(src)}`);
521
+ // `(?<![:\w-])src` so a Vue BINDING (`:src`, `v-bind:src`) is left alone.
522
+ // `\bsrc` matches inside `:src="` — the `:`→`s` transition is a word
523
+ // boundary — so every dynamic image on vitest's IDE page was rewritten into
524
+ // a `/_assets/pages/…` path built from an unevaluated template literal. That
525
+ // is worse than not rewriting: the reference now looks resolved and 404s.
526
+ out = out.replace(/(<img\b[^>]*?(?<![:\w-])src\s*=\s*")([^"]+)(")/g, (whole, head, src, tail) => {
527
+ // A template literal or interpolation cannot be resolved statically.
528
+ if (/[`$]\{|`/.test(src)) {
529
+ ctx.ledger.push({
530
+ construct: "<img> with a computed src",
531
+ note: "Vue-computed image source left as authored — it cannot be resolved without a Vue runtime",
532
+ line,
533
+ });
534
+ return whole;
535
+ }
536
+ return `${head}${map(src)}${tail}`;
537
+ });
538
+ // Bound sources are skipped above by design; record them separately, or the
539
+ // skip is itself a silent loss.
540
+ for (const _ of out.matchAll(/<img\b[^>]*(?::|v-bind:)src\s*=/g)) {
541
+ ctx.ledger.push({
542
+ construct: "<img> with a computed src",
543
+ note: "Vue-bound image source left as authored — it cannot be resolved without a Vue runtime",
544
+ line,
545
+ });
546
+ }
547
+ return out;
548
+ }
549
+ /** `<!--@include: ./file.md[#region]-->` — splice the referenced markdown. */
550
+ function resolveIncludes(text, line, ctx) {
551
+ return text.replace(/<!--\s*@include:\s*([^\s>]+?)\s*-->/g, (whole, spec) => {
552
+ const { path, region } = splitRegion(String(spec));
553
+ const content = ctx.opts.readSnippet?.({ path, region });
554
+ if (content === undefined) {
555
+ ctx.ledger.push({
556
+ construct: `<!--@include: ${spec}-->`,
557
+ note: "include target not found — reference dropped",
558
+ line,
559
+ });
560
+ return "";
561
+ }
562
+ if (ctx.depth >= 4) {
563
+ ctx.ledger.push({
564
+ construct: `<!--@include: ${spec}-->`,
565
+ note: "include nesting exceeded 4 levels — not expanded",
566
+ line,
567
+ });
568
+ return "";
569
+ }
570
+ // The included file is VitePress markdown too — containers, code-group and
571
+ // all — so it goes through the same translation, not a verbatim splice.
572
+ // It is translated with options anchored at ITS OWN directory: a fragment's
573
+ // `./inner.md` means the fragment's sibling, not the including page's.
574
+ const nextDir = ctx.opts.resolveDir?.(path);
575
+ const nestedOpts = nextDir && ctx.opts.optionsForDir ? ctx.opts.optionsForDir(nextDir) : ctx.opts;
576
+ const nested = translateWith(content, {
577
+ ...ctx,
578
+ opts: nestedOpts,
579
+ depth: ctx.depth + 1,
580
+ });
581
+ return `\n${nested.markdown.trim()}\n`;
582
+ });
583
+ }
584
+ /** `<<< @/path[#region][{meta}]` — splice the file as a fenced code block. */
585
+ function resolveSnippets(text, line, ctx) {
586
+ // The whole rest of the line is captured and then taken apart, rather than
587
+ // matched with one shape. Requiring `{…}` to be glued to the path and end
588
+ // the line meant `<<< @/f.js [title]` and `<<< @/f.js#region {1,3}` — both
589
+ // documented VitePress syntax — matched NOTHING: the snippet was never
590
+ // inlined and the raw `<<<` line was left in the page, with no ledger entry.
591
+ // Up to three spaces of indentation, CAPTURED and re-emitted on every line
592
+ // of the inlined block. Vite's own deployment guide puts a `<<<` inside a
593
+ // numbered list item; anchored at column 0 the reference matched nothing and
594
+ // the raw `<<<` line was left in the page. Emitting the fence flush left
595
+ // would be no better — it would break out of the list item it belongs to.
596
+ return text.replace(/^( {0,3})<<<\s*(.+?)\s*$/gm, (whole, indent, rest) => {
597
+ let spec = String(rest);
598
+ let title;
599
+ let meta = "";
600
+ spec = spec.replace(/\[([^\]]*)\]\s*$/, (_m, t) => {
601
+ title = t;
602
+ return "";
603
+ });
604
+ spec = spec.replace(/\{([^}]*)\}/, (_m, m2) => {
605
+ meta = `{${m2}}`;
606
+ return "";
607
+ });
608
+ spec = spec.trim();
609
+ if (!spec)
610
+ return whole;
611
+ const { path, region } = splitRegion(spec);
612
+ const content = ctx.opts.readSnippet?.({ path, region });
613
+ if (content === undefined) {
614
+ ctx.ledger.push({
615
+ construct: `<<< ${spec}`,
616
+ note: "snippet file not found — reference dropped",
617
+ line,
618
+ });
619
+ return "";
620
+ }
621
+ const lang = langFromPath(path);
622
+ const info = translateFenceInfo(`${lang}${meta} [${title ?? basename(path)}]`, line, ctx);
623
+ const fence = pickCodeFence(content);
624
+ const body = content.replace(/\n+$/, "");
625
+ const block = [`${fence}${info}`, ...body.split("\n"), fence];
626
+ return indent ? block.map((l) => (l ? `${indent}${l}` : l)).join("\n") : block.join("\n");
627
+ });
628
+ }
629
+ function stripToc(text, line, ctx) {
630
+ if (!text.includes("[[toc]]"))
631
+ return text;
632
+ ctx.ledger.push({
633
+ construct: "[[toc]]",
634
+ note: "inline table of contents dropped — the layout renders its own TOC",
635
+ line,
636
+ });
637
+ return text.replace(/^\s*\[\[toc\]\]\s*$/gm, "");
638
+ }
639
+ /**
640
+ * `<Badge type="tip" text="1.9.0" />` → a styled span.
641
+ *
642
+ * There is no `badge` TreeNode, and inventing one for a single source format
643
+ * is the wrong trade. A span survives every serializer (Astro passes raw HTML
644
+ * through; Obsidian markdown renders inline HTML) and keeps the text visible.
645
+ */
646
+ function rewriteBadges(text) {
647
+ const span = (attrs, label) => {
648
+ const type = attrs.match(/\btype\s*=\s*["']([^"']*)["']/)?.[1] ?? "info";
649
+ const t = label.trim();
650
+ return t ? `<span class="vp-badge ${escapeAttr(type)}">${t}</span>` : "";
651
+ };
652
+ // The SLOT form `<Badge type="x">label</Badge>` must be handled first and as
653
+ // a pair. Matching the open tag alone consumed it and left an orphan
654
+ // `</Badge>` behind — which then reached the .astro page as a closer for an
655
+ // undefined component, in headings, on ten-plus real pages.
656
+ let out = text.replace(/<Badge\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/Badge>/g, (_m, attrs, inner) => {
657
+ const explicit = attrs.match(/\btext\s*=\s*["']([^"']*)["']/)?.[1];
658
+ return span(attrs, explicit ?? inner);
659
+ });
660
+ out = out.replace(/<Badge\b((?:[^>"']|"[^"]*"|'[^']*')*)\/?>/g, (_m, attrs) => span(attrs, attrs.match(/\btext\s*=\s*["']([^"']*)["']/)?.[1] ?? ""));
661
+ return out;
662
+ }
663
+ /** `<script setup>` / `<style>` blocks — Vue plumbing with no rendered output. */
664
+ function stripVueBlocks(text, ctx) {
665
+ const lines = text.split("\n");
666
+ const out = [];
667
+ let i = 0;
668
+ while (i < lines.length) {
669
+ // A fence is copied through verbatim, so a `<script>` shown INSIDE a code
670
+ // example is never treated as Vue plumbing.
671
+ const fence = lines[i].match(FENCE_RE);
672
+ if (fence) {
673
+ const marker = fence[2];
674
+ const closeRe = new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`);
675
+ out.push(lines[i]);
676
+ i++;
677
+ while (i < lines.length && !closeRe.test(lines[i])) {
678
+ out.push(lines[i]);
679
+ i++;
680
+ }
681
+ if (i < lines.length) {
682
+ out.push(lines[i]);
683
+ i++;
684
+ }
685
+ continue;
686
+ }
687
+ const open = lines[i].match(/^<(script|style)\b/);
688
+ if (open) {
689
+ const tag = open[1];
690
+ const closeRe = new RegExp(`^</${tag}>\\s*$`);
691
+ let j = i + 1;
692
+ // The closer is searched for OUTSIDE fences too, and the block is only
693
+ // dropped if one is found — an unpaired `<script>` is left alone rather
694
+ // than allowed to swallow the rest of the page.
695
+ while (j < lines.length && !closeRe.test(lines[j]) && !FENCE_RE.test(lines[j]))
696
+ j++;
697
+ if (j < lines.length && closeRe.test(lines[j])) {
698
+ ctx.ledger.push({
699
+ construct: `<${tag}>`,
700
+ note: `Vue ${tag} block dropped`,
701
+ line: i + 1,
702
+ });
703
+ i = j + 1;
704
+ continue;
705
+ }
706
+ ctx.ledger.push({
707
+ construct: `<${tag}>`,
708
+ note: `unclosed <${tag}> left as authored rather than dropped`,
709
+ line: i + 1,
710
+ });
711
+ }
712
+ out.push(lines[i]);
713
+ i++;
714
+ }
715
+ return out.join("\n");
716
+ }
717
+ /**
718
+ * Reduce a container title to plain text: a badge becomes its label, component
719
+ * tags are removed (and counted), and any remaining markup is dropped.
720
+ */
721
+ function sanitizeTitle(title, ctx) {
722
+ if (!title)
723
+ return title;
724
+ let out = rewriteBadges(title);
725
+ out = stripComponents(out, ctx);
726
+ out = out.replace(/<\/?[A-Za-z][^>]*>/g, "");
727
+ return out.replace(/\s+/g, " ").trim();
728
+ }
729
+ /**
730
+ * Strip Vue single-file components used in markdown.
731
+ *
732
+ * These MUST go: a `<Version />` reaching an `.astro` page is an undefined
733
+ * component and a hard build error, not a cosmetic problem. Paired tags keep
734
+ * their inner content; self-closing ones vanish. Every tag is counted and
735
+ * named in the import summary, because a component nested inside a mapped
736
+ * container is not "dropped" — its tag leaks into the page as text, which is
737
+ * the failure mode `format-starlight` was built to surface.
738
+ *
739
+ * Only PascalCase names qualify, and a bare `<Word>` with no attributes and no
740
+ * closing tag is left alone — prose placeholders like `<USERNAME>` and
741
+ * `<Response>` are not components.
742
+ */
743
+ function stripComponents(text, ctx) {
744
+ const count = (name) => {
745
+ ctx.components[name] = (ctx.components[name] ?? 0) + 1;
746
+ };
747
+ // Self-closing: `<Foo />`, `<Foo bar="baz" />`
748
+ let out = text.replace(
749
+ // Attribute values may contain `>` (`<Foo cond="a > b" />`). `[^>]*` ended
750
+ // the tag early, corrupting the text and leaving the component uncounted.
751
+ /<([A-Z][A-Za-z0-9]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)\/>/g, (_m, name) => {
752
+ count(name);
753
+ return "";
754
+ });
755
+ // Paired: `<Foo …>inner</Foo>` — keep the inner content.
756
+ for (let pass = 0; pass < 4; pass++) {
757
+ const before = out;
758
+ out = out.replace(/<([A-Z][A-Za-z0-9]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/\1>/g, (_m, name, _attrs, inner) => {
759
+ count(name);
760
+ return inner;
761
+ });
762
+ if (out === before)
763
+ break;
764
+ }
765
+ return out;
766
+ }
767
+ /**
768
+ * Vue interpolation has no runtime here, and — worse — `{{ x }}` is also minja
769
+ * syntax, so leaving it would let the build resolve it to nothing. Rendering
770
+ * it as inline code keeps the author's intent visible and inert.
771
+ */
772
+ function neutralizeInterpolation(text, line, ctx) {
773
+ let found = false;
774
+ const out = text.replace(/(`+)([^`\n]*)\1|\{\{([^}\n]+)\}\}/g, (whole, tick, _code, expr) => {
775
+ if (tick)
776
+ return whole; // already inside inline code
777
+ found = true;
778
+ return `\`{{${expr}}}\``;
779
+ });
780
+ if (found) {
781
+ ctx.ledger.push({
782
+ construct: "{{ expression }}",
783
+ note: "Vue interpolation rendered as inline code (no Vue runtime in the target)",
784
+ line,
785
+ });
786
+ }
787
+ return out;
788
+ }
789
+ /**
790
+ * `[x](./guide/foo.md)` → `[x](./guide/foo)`. VitePress serves extensionless
791
+ * URLs, so the `.md` in a link is a source-tree detail, not part of the href.
792
+ * External URLs and non-markdown targets are untouched.
793
+ */
794
+ function rewriteMdLinks(text) {
795
+ return text.replace(/\]\(([^)\s]+?)\.md(#[^)\s]*)?\)/g, (whole, target, hash) => {
796
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target))
797
+ return whole; // absolute URL
798
+ return `](${target}${hash ?? ""})`;
799
+ });
800
+ }
801
+ // ── Rendering ─────────────────────────────────────────────────────────────
802
+ /**
803
+ * markdown-it-container closes an outer `:::` at the first bare `:::`, so a
804
+ * directive must use MORE colons than anything nested inside it — including a
805
+ * TEXT child that already carries a `:::` (a resolved include can).
806
+ */
807
+ function longestColonFence(text) {
808
+ let max = 0;
809
+ for (const l of text.split("\n")) {
810
+ const m = l.match(/^ {0,3}(:{3,})/);
811
+ if (m)
812
+ max = Math.max(max, m[1].length);
813
+ }
814
+ return max;
815
+ }
816
+ function fenceLen(node) {
817
+ let innerMax = 0;
818
+ for (const c of node.children) {
819
+ innerMax = Math.max(innerMax, c.kind === "dir" ? fenceLen(c) : longestColonFence(c.text));
820
+ }
821
+ return Math.max(3, innerMax + 1);
822
+ }
823
+ /** Strip leading/trailing blank lines while leaving indentation intact. */
824
+ function trimBlankLines(text) {
825
+ return text.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, "");
826
+ }
827
+ function renderNodes(nodes) {
828
+ const parts = [];
829
+ for (const n of nodes) {
830
+ if (n.kind === "text") {
831
+ parts.push(n.text);
832
+ }
833
+ else {
834
+ const colons = ":".repeat(fenceLen(n));
835
+ // Trim blank lines, NOT indentation: an indented container's first
836
+ // inner line carries the list indentation that keeps it in its item.
837
+ const inner = trimBlankLines(renderNodes(n.children));
838
+ parts.push(`${n.indent}${colons}${n.name}${n.attrs}\n${inner}\n${n.indent}${colons}`);
839
+ }
840
+ }
841
+ return parts.join("\n");
842
+ }
843
+ // ── Small helpers ─────────────────────────────────────────────────────────
844
+ function attrBlock(props) {
845
+ const parts = Object.entries(props)
846
+ .filter(([, v]) => v !== "")
847
+ .map(([k, v]) => `${k}="${escapeAttr(v)}"`);
848
+ return parts.length ? `{${parts.join(" ")}}` : "";
849
+ }
850
+ function escapeAttr(v) {
851
+ return v.replace(/"/g, "&quot;").replace(/\n+/g, " ").trim();
852
+ }
853
+ export function splitRegion(spec) {
854
+ const hash = spec.indexOf("#");
855
+ if (hash < 0)
856
+ return { path: spec };
857
+ return { path: spec.slice(0, hash), region: spec.slice(hash + 1) || undefined };
858
+ }
859
+ function basename(p) {
860
+ const parts = p.split("/");
861
+ return parts[parts.length - 1] || p;
862
+ }
863
+ const EXT_LANG = {
864
+ ts: "ts", mts: "ts", cts: "ts", tsx: "tsx",
865
+ js: "js", mjs: "js", cjs: "js", jsx: "jsx",
866
+ json: "json", jsonc: "jsonc", yml: "yaml", yaml: "yaml",
867
+ vue: "vue", svelte: "svelte", md: "md", html: "html", css: "css",
868
+ sh: "sh", bash: "bash", toml: "toml", rs: "rust", py: "python",
869
+ ansi: "ansi", txt: "text",
870
+ };
871
+ function langFromPath(p) {
872
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
873
+ return EXT_LANG[ext] ?? "";
874
+ }
875
+ /** A fence long enough not to collide with backticks in the content. */
876
+ function pickCodeFence(content) {
877
+ let max = 2;
878
+ for (const m of content.matchAll(/^\s*(`{3,})/gm))
879
+ max = Math.max(max, m[1].length);
880
+ return "`".repeat(Math.max(3, max + 1));
881
+ }
882
+ //# sourceMappingURL=translate.js.map