@dogsbay/format-myst 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,678 @@
1
+ /**
2
+ * MyST Markdown → Dogsbay-MD text translator.
3
+ *
4
+ * MyST (Markedly Structured Text) is CommonMark + Sphinx-style
5
+ * **directives**, **roles**, and **targets**. Dogsbay-MD is CommonMark +
6
+ * `:::` block directives + `{attrs}`. So — exactly like the GitBook and
7
+ * AsciiDoc pipelines — translation is largely a delimiter rewrite: prose,
8
+ * lists, tables, and normal fenced code pass through untouched; only the
9
+ * MyST-specific constructs are rewritten, after which the canonical
10
+ * `dogsbayMdToTree` parser is the definition of correctness (zero new
11
+ * TreeNode types).
12
+ *
13
+ * Three MyST construct classes:
14
+ * - **Directives** — a fenced block, either ```` ```{name} ```` (backtick)
15
+ * or `:::{name}` (colon fence). An optional argument follows the name on
16
+ * the opener line; `:key: value` option lines may follow; then a blank
17
+ * line and the body. These map to `:::` block directives (callouts,
18
+ * tabs, grid, details, figure, math, code) and can NEST — emitted with
19
+ * DEPTH-AWARE fence lengths (outer gets more colons), mirroring
20
+ * format-dogsbay-md's serializer and format-gitbook's translator.
21
+ * - **Roles** — inline ``{name}`content` `` → a link / math / `<sub>` /
22
+ * code span / emphasis, per role.
23
+ * - **Targets** — `(label)=` on its own line before a block → a `{#label}`
24
+ * anchor on the following heading (or a bare `<a id>` otherwise), so
25
+ * `{ref}`label`` cross-references resolve.
26
+ *
27
+ * Fence-aware throughout: MyST syntax inside a plain fenced code block is
28
+ * literal and never rewritten. Anything unmapped is recorded in the
29
+ * returned `ledger` (never silent loss — same discipline as format-dita /
30
+ * format-gitbook) and passed through as best-effort text.
31
+ *
32
+ * See plans/format-myst.md.
33
+ */
34
+ /**
35
+ * MyST admonition directive → Dogsbay callout name. Sphinx/docutils ship
36
+ * ten admonition types; MyST/sphinx-design add a couple. Dogsbay has
37
+ * note/tip/warning/danger/info/success/caution/important — the rest fold
38
+ * onto the nearest match.
39
+ */
40
+ const ADMONITION_TO_CALLOUT = {
41
+ note: "note",
42
+ tip: "tip",
43
+ hint: "tip",
44
+ important: "important",
45
+ warning: "warning",
46
+ attention: "warning",
47
+ caution: "caution",
48
+ danger: "danger",
49
+ error: "danger",
50
+ seealso: "note",
51
+ };
52
+ /** Directives dropped from the body entirely (structural / auto-generated). */
53
+ const DROPPED_DIRECTIVES = new Set(["contents", "tableofcontents", "toctree"]);
54
+ // ── Fence / opener recognition ─────────────────────────────────────────────
55
+ const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})(.*)$/;
56
+ /**
57
+ * Match a directive opener: 3+ backticks OR 3+ colons, immediately
58
+ * followed by `{name}` and an optional argument. Returns null for a plain
59
+ * code fence (no `{…}`) or non-fence lines.
60
+ */
61
+ function matchDirectiveOpener(line) {
62
+ const m = line.match(/^\s*(`{3,}|:{3,})\{([a-zA-Z0-9_+-]+)\}\s*(.*?)\s*$/);
63
+ if (!m)
64
+ return null;
65
+ return {
66
+ char: m[1][0],
67
+ len: m[1].length,
68
+ name: m[2].toLowerCase(),
69
+ arg: m[3] ?? "",
70
+ };
71
+ }
72
+ /** Is `line` a closing fence for a block opened with `char` × `len`? */
73
+ function isCloseFence(line, char, len) {
74
+ const re = char === "`"
75
+ ? new RegExp(`^\\s*\`{${len},}\\s*$`)
76
+ : new RegExp(`^\\s*:{${len},}\\s*$`);
77
+ return re.test(line);
78
+ }
79
+ // ── Block capture (nesting-aware) ─────────────────────────────────────────
80
+ /**
81
+ * Given a directive opened at `openIdx`, return its body lines and the
82
+ * index of the line AFTER its close. Nested fenced blocks (directives or
83
+ * plain code) are skipped so their own closes don't terminate the outer
84
+ * block — correct regardless of the relative fence lengths MyST authors
85
+ * choose. An unterminated directive runs to EOF.
86
+ */
87
+ function captureBlock(lines, openIdx, char, len) {
88
+ const body = [];
89
+ let j = openIdx + 1;
90
+ while (j < lines.length) {
91
+ const line = lines[j];
92
+ if (isCloseFence(line, char, len)) {
93
+ return { body, nextIdx: j + 1 };
94
+ }
95
+ // Skip a nested directive block wholesale.
96
+ const nested = matchDirectiveOpener(line);
97
+ if (nested) {
98
+ const inner = captureBlock(lines, j, nested.char, nested.len);
99
+ for (let k = j; k < inner.nextIdx; k++)
100
+ body.push(lines[k]);
101
+ j = inner.nextIdx;
102
+ continue;
103
+ }
104
+ // Skip a nested PLAIN code fence (```lang … ```), so a stray ::: or
105
+ // ``` inside example code can't close the directive.
106
+ const cf = line.match(CODE_FENCE_RE);
107
+ if (cf) {
108
+ const marker = cf[2];
109
+ body.push(line);
110
+ j++;
111
+ while (j < lines.length) {
112
+ body.push(lines[j]);
113
+ const cm = lines[j].match(CODE_FENCE_RE);
114
+ if (cm && cm[2][0] === marker[0] && cm[2].length >= marker.length && !cm[3].trim()) {
115
+ j++;
116
+ break;
117
+ }
118
+ j++;
119
+ }
120
+ continue;
121
+ }
122
+ body.push(line);
123
+ j++;
124
+ }
125
+ return { body, nextIdx: j };
126
+ }
127
+ /** Split a directive body into `:key: value` options + the remaining body. */
128
+ function splitOptions(body) {
129
+ const options = {};
130
+ let i = 0;
131
+ // Skip a leading blank line (some authors put one before options).
132
+ while (i < body.length && !body[i].trim())
133
+ i++;
134
+ for (; i < body.length; i++) {
135
+ const m = body[i].match(/^\s*:([a-zA-Z][\w-]*):\s?(.*)$/);
136
+ if (!m)
137
+ break;
138
+ options[m[1].toLowerCase()] = m[2].trim();
139
+ }
140
+ // One blank line customarily separates options from content — drop it.
141
+ if (i < body.length && !body[i].trim())
142
+ i++;
143
+ return { options, rest: body.slice(i) };
144
+ }
145
+ // ── Main entry ─────────────────────────────────────────────────────────────
146
+ export function mystToDogsbayMd(src) {
147
+ const ledger = [];
148
+ const toctree = [];
149
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
150
+ const out = translateLines(lines, 0, ledger, toctree);
151
+ return { markdown: out.trimEnd() + "\n", ledger, toctree };
152
+ }
153
+ /**
154
+ * Translate a run of lines (a whole doc, or a directive body). `baseLine`
155
+ * is the 1-based source line of `lines[0]` for ledger accuracy.
156
+ */
157
+ function translateLines(lines, baseLine, ledger, toctree) {
158
+ const parts = [];
159
+ let i = 0;
160
+ let pendingTarget = null;
161
+ const flushTargetAsAnchor = () => {
162
+ if (pendingTarget) {
163
+ parts.push(`<a id="${pendingTarget}"></a>`);
164
+ pendingTarget = null;
165
+ }
166
+ };
167
+ while (i < lines.length) {
168
+ const line = lines[i];
169
+ const lineNo = baseLine + i + 1;
170
+ // Plain fenced code block (no `{…}`): copy verbatim.
171
+ const cf = line.match(CODE_FENCE_RE);
172
+ if (cf && !matchDirectiveOpener(line)) {
173
+ const marker = cf[2];
174
+ const block = [line];
175
+ i++;
176
+ while (i < lines.length) {
177
+ block.push(lines[i]);
178
+ const cm = lines[i].match(CODE_FENCE_RE);
179
+ i++;
180
+ if (cm && cm[2][0] === marker[0] && cm[2].length >= marker.length && !cm[3].trim())
181
+ break;
182
+ }
183
+ flushTargetAsAnchor();
184
+ parts.push(block.join("\n"));
185
+ continue;
186
+ }
187
+ // Directive opener.
188
+ const opener = matchDirectiveOpener(line);
189
+ if (opener) {
190
+ const { body, nextIdx } = captureBlock(lines, i, opener.char, opener.len);
191
+ const emitted = emitDirective(opener, body, baseLine + i, ledger, toctree);
192
+ i = nextIdx;
193
+ if (emitted === null) {
194
+ // Dropped (toctree / contents). A target that pointed AT this
195
+ // directive must not leak onto the next block — emit it here as a
196
+ // bare anchor so the label resolves to this position (or is a
197
+ // harmless orphan) rather than mis-attaching downstream.
198
+ flushTargetAsAnchor();
199
+ continue;
200
+ }
201
+ flushTargetAsAnchor();
202
+ parts.push(emitted);
203
+ continue;
204
+ }
205
+ // Target `(label)=` — attach to the following heading, else emit an anchor.
206
+ const target = line.match(/^\(([A-Za-z0-9_.:+-]+)\)=\s*$/);
207
+ if (target) {
208
+ pendingTarget = target[1];
209
+ i++;
210
+ continue;
211
+ }
212
+ // Line comment `% …` — MyST comment, dropped.
213
+ if (/^\s*%(\s|$)/.test(line)) {
214
+ i++;
215
+ continue;
216
+ }
217
+ // Block break `+++` (MyST cell/part break) — dropped.
218
+ if (/^\s*\+\+\+/.test(line)) {
219
+ i++;
220
+ continue;
221
+ }
222
+ // A pending target attaches to the FOLLOWING block. Blank lines between
223
+ // the `(label)=` and that block are common in MyST — skip them without
224
+ // discarding the target.
225
+ if (pendingTarget) {
226
+ if (!line.trim()) {
227
+ parts.push(line);
228
+ i++;
229
+ continue;
230
+ }
231
+ const h = line.match(/^(#{1,6})\s+(.*?)\s*(\{#([^}]+)\})?\s*$/);
232
+ if (h) {
233
+ const authorId = h[4];
234
+ if (authorId && authorId !== pendingTarget) {
235
+ // Heading already has an explicit id — keep it, and emit the
236
+ // target as a separate anchor so BOTH cross-refs resolve.
237
+ parts.push(`<a id="${pendingTarget}"></a>`);
238
+ parts.push(`${h[1]} ${transformInline(h[2], ledger, lineNo)} {#${authorId}}`);
239
+ }
240
+ else {
241
+ parts.push(`${h[1]} ${transformInline(h[2], ledger, lineNo)} {#${pendingTarget}}`);
242
+ }
243
+ pendingTarget = null;
244
+ i++;
245
+ continue;
246
+ }
247
+ // Non-heading block: emit a bare anchor, then render the line.
248
+ flushTargetAsAnchor();
249
+ }
250
+ parts.push(transformInline(line, ledger, lineNo));
251
+ i++;
252
+ }
253
+ return parts.join("\n").replace(/\n{3,}/g, "\n\n");
254
+ }
255
+ // ── Directive emission ─────────────────────────────────────────────────────
256
+ /**
257
+ * Render one directive to Dogsbay-MD, or null when it should be dropped
258
+ * from the body (toctree / contents). Recurses into the body for nested
259
+ * directives + inline roles.
260
+ */
261
+ function emitDirective(opener, rawBody, openLineIdx, ledger, toctree) {
262
+ const { name, arg } = opener;
263
+ const line = openLineIdx + 1;
264
+ const { options, rest } = splitOptions(rawBody);
265
+ // Structural directives collected/dropped, not rendered inline.
266
+ if (name === "toctree") {
267
+ for (const raw of rest) {
268
+ const t = raw.trim();
269
+ if (t && !t.startsWith(":"))
270
+ toctree.push(t);
271
+ }
272
+ return null;
273
+ }
274
+ if (DROPPED_DIRECTIVES.has(name))
275
+ return null;
276
+ const bodyText = () => translateLines(rest, openLineIdx + 1 + (rawBody.length - rest.length), ledger, toctree).trim();
277
+ // Admonitions → callouts.
278
+ if (name in ADMONITION_TO_CALLOUT) {
279
+ const title = arg.trim();
280
+ const attrs = title ? attrBlock({ title }) : "";
281
+ return fenced(ADMONITION_TO_CALLOUT[name], attrs, bodyText());
282
+ }
283
+ if (name === "admonition") {
284
+ // Generic admonition: `:class:` picks the style, arg is the title.
285
+ const cls = (options.class ?? "").toLowerCase().split(/\s+/).find((c) => c in ADMONITION_TO_CALLOUT);
286
+ const dir = cls ? ADMONITION_TO_CALLOUT[cls] : "note";
287
+ const title = arg.trim();
288
+ return fenced(dir, title ? attrBlock({ title }) : "", bodyText());
289
+ }
290
+ switch (name) {
291
+ // sphinx-design tabs.
292
+ case "tab-set":
293
+ return fenced("tabs", "", bodyText());
294
+ case "tab-item":
295
+ return fenced("tab", arg.trim() ? attrBlock({ title: arg.trim() }) : "", bodyText());
296
+ // sphinx-design grid + cards.
297
+ case "grid":
298
+ return fenced("grid", "", bodyText());
299
+ case "grid-item":
300
+ return fenced("grid-item", "", bodyText());
301
+ case "grid-item-card":
302
+ case "card":
303
+ return fenced("card", arg.trim() ? attrBlock({ title: arg.trim() }) : "", bodyText());
304
+ // Collapsible.
305
+ case "dropdown":
306
+ return fenced("details", arg.trim() ? attrBlock({ title: arg.trim() }) : "", bodyText());
307
+ // Figures / images.
308
+ case "figure":
309
+ return emitFigure(arg.trim(), options, rest, ledger, toctree, openLineIdx);
310
+ case "image":
311
+ return emitImage(arg.trim(), options);
312
+ // Math.
313
+ case "math":
314
+ return emitMath(options, rest);
315
+ // Code.
316
+ case "code-block":
317
+ case "code":
318
+ case "sourcecode":
319
+ return emitCode(arg.trim(), options, rest, ledger, line);
320
+ // Version notes → inline emphasis (mirrors format-rst Phase 4: a badge,
321
+ // not a heavy callout box).
322
+ case "versionadded":
323
+ case "versionchanged":
324
+ case "deprecated": {
325
+ const label = name === "versionadded" ? "Added in version" : name === "deprecated" ? "Deprecated since version" : "Changed in version";
326
+ const prefix = arg.trim() ? `*${label} ${arg.trim()}.*` : `*${label}.*`;
327
+ const body = bodyText();
328
+ return body ? `${prefix} ${body}` : prefix;
329
+ }
330
+ // Rubric — an unnumbered heading-like label.
331
+ case "rubric":
332
+ return arg.trim() ? `**${transformInline(arg.trim(), ledger, line)}**` : bodyText();
333
+ // Block quotes with attribution.
334
+ case "epigraph":
335
+ case "pull-quote":
336
+ case "highlights": {
337
+ const body = bodyText();
338
+ return body
339
+ .split("\n")
340
+ .map((l) => (l ? `> ${l}` : ">"))
341
+ .join("\n");
342
+ }
343
+ // Table directives → a real Markdown table. Cells are run through the
344
+ // inline transformer so roles inside them resolve. If parsing yields no
345
+ // rows, fall back to keeping the raw body + a ledger entry (never worse).
346
+ case "list-table":
347
+ case "csv-table": {
348
+ const rows = name === "list-table" ? parseListTable(rest) : parseCsvTable(rest, options);
349
+ const headerRows = name === "list-table"
350
+ ? Math.max(0, parseInt(options["header-rows"] ?? "0", 10) || 0)
351
+ : options.header
352
+ ? 1
353
+ : 0;
354
+ const table = renderMarkdownTable(rows, headerRows, (c) => transformInline(c, ledger, line));
355
+ if (table) {
356
+ // The caption rides as a `{caption="…"}` attribute block after the
357
+ // table — dogsbay-md reattaches it as a real `<caption>` on re-parse.
358
+ const cap = arg.trim();
359
+ return cap ? `${table}\n\n{caption="${captionAttr(cap)}"}` : table;
360
+ }
361
+ ledger.push({
362
+ construct: `{${name}}`,
363
+ note: `table body could not be parsed — kept as raw content`,
364
+ line,
365
+ });
366
+ const title = arg.trim() ? `**${arg.trim()}**\n\n` : "";
367
+ return title + bodyText();
368
+ }
369
+ // Includes — Dogsbay resolves includes at build; ledger the reference.
370
+ case "include":
371
+ case "literalinclude": {
372
+ ledger.push({
373
+ construct: `{${name}} ${arg.trim()}`,
374
+ note: `file include not inlined at migrate time; wire up a build-time include or paste the content`,
375
+ line,
376
+ });
377
+ return `<!-- ${name}: ${arg.trim()} -->`;
378
+ }
379
+ default: {
380
+ // Unknown directive: keep the body (translated), ledger the wrapper.
381
+ ledger.push({
382
+ construct: `{${name}}`,
383
+ note: `unknown directive — body kept, wrapper dropped`,
384
+ line,
385
+ });
386
+ return bodyText();
387
+ }
388
+ }
389
+ }
390
+ /**
391
+ * Emit a `:::name{attrs}\nbody\n:::` with a colon fence strictly longer
392
+ * than any `:::` fence already in the body — markdown-it-container closes
393
+ * an outer `:::` at the first bare `:::`, so a nested directive needs the
394
+ * outer to use MORE colons. Minimal (`inner + 1`), mirroring
395
+ * format-gitbook's fence-scaling.
396
+ */
397
+ function fenced(name, attrs, body) {
398
+ const colons = ":".repeat(Math.max(3, longestColonFence(body) + 1));
399
+ return `${colons}${name}${attrs}\n${body}\n${colons}`;
400
+ }
401
+ // ── Tables ─────────────────────────────────────────────────────────────────
402
+ /** Escape a caption for a `{caption="…"}` attribute value (quotes only). */
403
+ function captionAttr(text) {
404
+ return text.replace(/"/g, "&quot;").replace(/\n+/g, " ").trim();
405
+ }
406
+ /** Strip the common leading indentation from a block of lines. */
407
+ function dedent(lines) {
408
+ const indents = lines
409
+ .filter((l) => l.trim())
410
+ .map((l) => l.match(/^\s*/)[0].length);
411
+ const min = indents.length ? Math.min(...indents) : 0;
412
+ return lines.map((l) => l.slice(min));
413
+ }
414
+ /**
415
+ * Parse a `{list-table}` body — a bulleted list where each top-level bullet
416
+ * is a ROW and its nested bullets are the CELLS:
417
+ *
418
+ * * - Name → row 1: ["Name", "Age"]
419
+ * - Age
420
+ * * - Alice
421
+ * - 30
422
+ *
423
+ * Both the inline `* - cell` form and a lone `*` row marker (cells on
424
+ * following `- cell` lines) are handled. Continuation lines append to the
425
+ * current cell. A cell that itself contains a nested bullet list is a known
426
+ * limitation (rare in list-tables).
427
+ */
428
+ function parseListTable(rawLines) {
429
+ const lines = dedent(rawLines);
430
+ const rows = [];
431
+ let cur = null;
432
+ for (const line of lines) {
433
+ // Row start with an inline first cell: `* - content`
434
+ const rowInline = line.match(/^[*+-][ \t]+[*+-][ \t]?(.*)$/);
435
+ if (rowInline) {
436
+ cur = [rowInline[1]];
437
+ rows.push(cur);
438
+ continue;
439
+ }
440
+ // A lone row bullet: `*` (cells follow on `- cell` lines)
441
+ if (/^[*+-][ \t]*$/.test(line)) {
442
+ cur = [];
443
+ rows.push(cur);
444
+ continue;
445
+ }
446
+ // Another cell in the current row: `- content` (indented under the row)
447
+ const cellM = line.match(/^\s*[*+-][ \t]?(.*)$/);
448
+ if (cellM && cur) {
449
+ cur.push(cellM[1]);
450
+ continue;
451
+ }
452
+ // Continuation of the current cell.
453
+ if (cur && cur.length && line.trim()) {
454
+ cur[cur.length - 1] += ` ${line.trim()}`;
455
+ }
456
+ }
457
+ return rows.map((r) => r.map((c) => c.trim()));
458
+ }
459
+ /** Parse one CSV record (comma-delimited, `"`-quoted, `""` escape). */
460
+ function parseCsvLine(line) {
461
+ const out = [];
462
+ let cur = "";
463
+ let inQuote = false;
464
+ for (let i = 0; i < line.length; i++) {
465
+ const ch = line[i];
466
+ if (inQuote) {
467
+ if (ch === '"') {
468
+ if (line[i + 1] === '"') {
469
+ cur += '"';
470
+ i++;
471
+ }
472
+ else
473
+ inQuote = false;
474
+ }
475
+ else
476
+ cur += ch;
477
+ }
478
+ else if (ch === '"') {
479
+ inQuote = true;
480
+ }
481
+ else if (ch === ",") {
482
+ out.push(cur.trim());
483
+ cur = "";
484
+ }
485
+ else
486
+ cur += ch;
487
+ }
488
+ out.push(cur.trim());
489
+ return out;
490
+ }
491
+ /** Parse a `{csv-table}` body (+ its `:header:` option) into rows. */
492
+ function parseCsvTable(rawLines, options) {
493
+ const rows = [];
494
+ if (options.header)
495
+ rows.push(parseCsvLine(options.header));
496
+ for (const line of dedent(rawLines)) {
497
+ if (!line.trim())
498
+ continue;
499
+ rows.push(parseCsvLine(line));
500
+ }
501
+ return rows;
502
+ }
503
+ /**
504
+ * Emit a GitHub-flavoured Markdown table. `headerRows` says how many
505
+ * leading rows are header: 0 → an empty header row (Markdown requires one);
506
+ * ≥1 → the FIRST row is the header (extra header rows fall through as body,
507
+ * since Markdown supports a single header row). Cells are passed through
508
+ * `cell()` (inline transform) and pipe-escaped.
509
+ */
510
+ function renderMarkdownTable(rows, headerRows, cell) {
511
+ if (rows.length === 0)
512
+ return null;
513
+ const ncol = Math.max(...rows.map((r) => r.length));
514
+ if (ncol === 0)
515
+ return null;
516
+ const fmt = (s) => cell(s).replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
517
+ const pad = (r) => {
518
+ const c = r.map(fmt);
519
+ while (c.length < ncol)
520
+ c.push("");
521
+ return c;
522
+ };
523
+ const out = [];
524
+ const header = headerRows > 0 ? pad(rows[0]) : new Array(ncol).fill("");
525
+ out.push(`| ${header.join(" | ")} |`);
526
+ out.push(`| ${new Array(ncol).fill("---").join(" | ")} |`);
527
+ for (let i = headerRows > 0 ? 1 : 0; i < rows.length; i++) {
528
+ out.push(`| ${pad(rows[i]).join(" | ")} |`);
529
+ }
530
+ return out.join("\n");
531
+ }
532
+ function emitFigure(src, options, rest, ledger, toctree, openLineIdx) {
533
+ const alt = options.alt ?? "";
534
+ const img = `![${alt}](${src})`;
535
+ // The figure body (after options) is the caption + optional legend.
536
+ const caption = translateLines(rest, openLineIdx + 2, ledger, toctree).trim();
537
+ return caption ? `${img}\n\n*${caption.replace(/\n+/g, " ")}*` : img;
538
+ }
539
+ function emitImage(src, options) {
540
+ const alt = options.alt ?? "";
541
+ return `![${alt}](${src})`;
542
+ }
543
+ function emitMath(options, rest) {
544
+ const expr = rest.join("\n").trim();
545
+ const label = options.label;
546
+ const anchor = label ? `<a id="${label}"></a>\n` : "";
547
+ return `${anchor}$$\n${expr}\n$$`;
548
+ }
549
+ function emitCode(lang, options, rest, ledger, line) {
550
+ const code = rest.join("\n").replace(/\n+$/, "");
551
+ const attrs = [];
552
+ if (options.caption)
553
+ attrs.push(`title="${options.caption.replace(/"/g, "&quot;")}"`);
554
+ if ("linenos" in options || "lineno-start" in options)
555
+ attrs.push(`lineNumbers="true"`);
556
+ if (options["emphasize-lines"]) {
557
+ ledger.push({
558
+ construct: `{code-block} :emphasize-lines:`,
559
+ note: `line emphasis dropped — Dogsbay CodeRich uses a different highlight syntax`,
560
+ line,
561
+ });
562
+ }
563
+ const info = [lang, ...attrs].filter(Boolean).join(" ");
564
+ return "```" + info + "\n" + code + "\n```";
565
+ }
566
+ // ── Inline roles ───────────────────────────────────────────────────────────
567
+ /**
568
+ * Rewrite inline MyST roles ``{name}`content` `` on a content line. Prose,
569
+ * links, emphasis, and — crucially — inline **code spans** pass through
570
+ * untouched.
571
+ *
572
+ * A role and a code span are both delimited by backticks, so a plain regex
573
+ * can't tell ``{ref}`x` `` (role) from `` `{ref}` `` (a code span that
574
+ * merely CONTAINS `{ref}`). We therefore scan left-to-right: a backtick is
575
+ * the start of a role only when the text just emitted ends with `{name}`
576
+ * (the MyST role form — no space between `}` and the backtick); otherwise
577
+ * it opens an ordinary inline code span, which is copied verbatim so a role
578
+ * name mentioned inside example code is never rewritten.
579
+ */
580
+ function transformInline(line, ledger, lineNo) {
581
+ let out = "";
582
+ let i = 0;
583
+ const n = line.length;
584
+ while (i < n) {
585
+ const ch = line[i];
586
+ if (ch === "`") {
587
+ const roleMatch = out.match(/\{([a-zA-Z0-9_+-]+)\}$/);
588
+ const close = line.indexOf("`", i + 1);
589
+ if (roleMatch && close !== -1) {
590
+ // {name}`content` — a role. Drop the already-emitted `{name}`.
591
+ out = out.slice(0, out.length - roleMatch[0].length);
592
+ out += renderRole(roleMatch[1].toLowerCase(), line.slice(i + 1, close), ledger, lineNo);
593
+ i = close + 1;
594
+ continue;
595
+ }
596
+ // Ordinary inline code span (or an unterminated backtick) — verbatim.
597
+ if (close !== -1) {
598
+ out += line.slice(i, close + 1);
599
+ i = close + 1;
600
+ continue;
601
+ }
602
+ }
603
+ out += ch;
604
+ i++;
605
+ }
606
+ return out;
607
+ }
608
+ /** Render one MyST role `{name}` with body `content` to Dogsbay-MD inline. */
609
+ function renderRole(role, content, ledger, lineNo) {
610
+ switch (role) {
611
+ case "doc":
612
+ case "ref":
613
+ case "numref":
614
+ case "eq":
615
+ case "download": {
616
+ const { text, target } = splitRoleTarget(content);
617
+ const href = role === "doc" || role === "download" ? target : `#${target}`;
618
+ return `[${text || target}](${href})`;
619
+ }
620
+ case "math":
621
+ return `$${content}$`;
622
+ case "sub":
623
+ return `<sub>${content}</sub>`;
624
+ case "sup":
625
+ return `<sup>${content}</sup>`;
626
+ case "kbd":
627
+ return `<kbd>${content}</kbd>`;
628
+ case "abbr": {
629
+ // {abbr}`HTML (HyperText Markup Language)`
630
+ const m = content.match(/^(.*?)\s*\(([^)]*)\)\s*$/);
631
+ if (m)
632
+ return `<abbr title="${m[2].replace(/"/g, "&quot;")}">${m[1]}</abbr>`;
633
+ return content;
634
+ }
635
+ case "file":
636
+ case "command":
637
+ case "samp":
638
+ case "program":
639
+ case "code":
640
+ return `\`${content}\``;
641
+ case "guilabel":
642
+ case "menuselection":
643
+ return `**${content}**`;
644
+ case "term":
645
+ // Glossary term — no glossary target model yet; keep the text.
646
+ ledger.push({ construct: "{term}", note: "glossary term rendered as plain text", line: lineNo });
647
+ return content;
648
+ default:
649
+ ledger.push({ construct: `{${role}}`, note: "unknown role — rendered as plain text", line: lineNo });
650
+ return content;
651
+ }
652
+ }
653
+ /** Split a role body of the form `Explicit Text <target>` → {text, target}. */
654
+ function splitRoleTarget(content) {
655
+ const m = content.match(/^(.*?)\s*<([^>]+)>\s*$/);
656
+ if (m)
657
+ return { text: m[1].trim(), target: m[2].trim() };
658
+ return { text: "", target: content.trim() };
659
+ }
660
+ // ── Attributes / fences ────────────────────────────────────────────────────
661
+ function attrBlock(attrs) {
662
+ const keys = Object.keys(attrs).filter((k) => attrs[k] !== "");
663
+ if (keys.length === 0)
664
+ return "";
665
+ const inner = keys.map((k) => `${k}="${attrs[k].replace(/"/g, "&quot;")}"`).join(" ");
666
+ return `{${inner}}`;
667
+ }
668
+ /** Longest run of `:` in a line-start `:::…` directive fence within text. */
669
+ function longestColonFence(text) {
670
+ let max = 0;
671
+ for (const l of text.split("\n")) {
672
+ const m = l.match(/^(:{3,})/);
673
+ if (m)
674
+ max = Math.max(max, m[1].length);
675
+ }
676
+ return max;
677
+ }
678
+ //# sourceMappingURL=translate.js.map