@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.
- package/README.md +46 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +114 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/literal.d.ts +55 -0
- package/dist/literal.d.ts.map +1 -0
- package/dist/literal.js +845 -0
- package/dist/literal.js.map +1 -0
- package/dist/loaders/load.d.ts +74 -0
- package/dist/loaders/load.d.ts.map +1 -0
- package/dist/loaders/load.js +448 -0
- package/dist/loaders/load.js.map +1 -0
- package/dist/nav.d.ts +51 -0
- package/dist/nav.d.ts.map +1 -0
- package/dist/nav.js +182 -0
- package/dist/nav.js.map +1 -0
- package/dist/parse.d.ts +14 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +35 -0
- package/dist/parse.js.map +1 -0
- package/dist/translate.d.ts +80 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +882 -0
- package/dist/translate.js.map +1 -0
- package/package.json +37 -0
|
@@ -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, """).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
|