@dogsbay/format-rst 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,675 @@
1
+ /**
2
+ * docutils XML AST → Dogsbay-MD text.
3
+ *
4
+ * The docutils document tree (emitted as XML by the Python sidecar) is a
5
+ * stable, well-specified node set (the Docutils DTD). This transformer
6
+ * walks it and emits Dogsbay-MD — the canonical `dogsbayMdToTree` then
7
+ * produces the TreeNode, so no new node types are needed (same shape as
8
+ * format-gitbook / format-asciidoc).
9
+ *
10
+ * Standard rST + core directives are handled here (Phase 1). Sphinx-only
11
+ * directives/roles arrive as `system_message` / `problematic` nodes under
12
+ * plain docutils — those are ledgered (never silent loss) and resolve
13
+ * properly once Sphinx mode (Phase 2) feeds a resolved tree.
14
+ *
15
+ * See plans/format-rst.md for the full node→Dogsbay-MD mapping.
16
+ */
17
+ import { parseDocument } from "htmlparser2";
18
+ import { stringify as stringifyYaml } from "yaml";
19
+ // NOTE: guard on the node's `.type` string, never `instanceof`. htmlparser2
20
+ // builds the tree with its own bundled copy of domhandler, which may be a
21
+ // different module instance than the one we import — `instanceof Element`
22
+ // then silently returns false for every node and the whole tree reads as
23
+ // empty. `.type` is a plain string and cross-copy safe.
24
+ function isTag(n) {
25
+ return n.type === "tag" || n.type === "script" || n.type === "style";
26
+ }
27
+ function isTextNode(n) {
28
+ return n.type === "text";
29
+ }
30
+ function textData(n) {
31
+ return n.data ?? "";
32
+ }
33
+ /** All child nodes (text + elements) — for inline rendering, which needs
34
+ * text nodes. (`elementChildren` is tags-only, for block iteration.) */
35
+ function kids(el) {
36
+ return el.children ?? [];
37
+ }
38
+ /** docutils admonition element → Dogsbay callout directive name. */
39
+ const ADMONITION_TO_CALLOUT = {
40
+ note: "note",
41
+ tip: "tip",
42
+ hint: "tip",
43
+ important: "important",
44
+ warning: "warning",
45
+ caution: "caution",
46
+ danger: "danger",
47
+ attention: "note",
48
+ error: "danger",
49
+ };
50
+ export function docutilsXmlToDogsbayMd(xml, opts = {}) {
51
+ const doc = parseDocument(xml, { xmlMode: true });
52
+ const root = findElement(doc, "document");
53
+ const ledger = [];
54
+ if (!root)
55
+ return { markdown: "", ledger };
56
+ // Sphinx omits the `title=` attr on <document>; the H1 is the first
57
+ // section's <title>. Plain docutils sets `title=`.
58
+ const title = root.attribs?.title ?? firstSectionTitle(root);
59
+ const docPath = opts.docPath ?? "";
60
+ const docDir = docPath.includes("/") ? docPath.slice(0, docPath.lastIndexOf("/")) : "";
61
+ // Plain docutils puts the H1 in `<document title><title>…`; start at
62
+ // depth 1. Sphinx wraps the whole doc in a top `<section>` whose title
63
+ // is the H1, so start at 0 and let that section bump it to 1.
64
+ const ctx = {
65
+ ledger,
66
+ depth: root.attribs?.title ? 1 : 0,
67
+ docDir,
68
+ hrefPrefix: opts.hrefPrefix ?? "",
69
+ };
70
+ // The root `<document>`'s own `<title>` is the H1; its remaining children
71
+ // are the body. Section titles below get depth+1.
72
+ const blocks = renderBlocks(elementChildren(root), ctx);
73
+ const md = blocks
74
+ .filter((b) => b.trim() !== "")
75
+ .join("\n\n")
76
+ .replace(/\n{3,}/g, "\n\n")
77
+ .trim();
78
+ return { markdown: md + "\n", title, ledger };
79
+ }
80
+ // ── Block level ────────────────────────────────────────────────────────────
81
+ function renderBlocks(nodes, ctx) {
82
+ const out = [];
83
+ for (const n of nodes) {
84
+ if (!isTag(n))
85
+ continue; // whitespace text between blocks — ignore
86
+ const s = renderBlock(n, ctx);
87
+ if (s != null)
88
+ out.push(s);
89
+ }
90
+ return out;
91
+ }
92
+ function renderBlock(el, ctx) {
93
+ const tag = el.tagName;
94
+ switch (tag) {
95
+ case "title":
96
+ return `${"#".repeat(Math.min(ctx.depth, 6))} ${renderInline(kids(el), ctx).trim()}`;
97
+ case "section": {
98
+ const child = { ...ctx, depth: ctx.depth + 1 };
99
+ return renderBlocks(elementChildren(el), child).join("\n\n");
100
+ }
101
+ case "paragraph":
102
+ return renderInline(kids(el), ctx).trim();
103
+ case "note":
104
+ case "tip":
105
+ case "hint":
106
+ case "important":
107
+ case "warning":
108
+ case "caution":
109
+ case "danger":
110
+ case "attention":
111
+ case "error":
112
+ return renderAdmonition(ADMONITION_TO_CALLOUT[tag], el, ctx);
113
+ case "admonition":
114
+ return renderGenericAdmonition(el, ctx);
115
+ case "seealso":
116
+ return renderAdmonition("note", el, ctx, "See also");
117
+ case "literal_block":
118
+ return renderCodeBlock(el);
119
+ case "math_block": {
120
+ // `.. math::` display equation → `$$…$$` block (KaTeX). Was dropped.
121
+ const tex = textContent(el).trim();
122
+ return tex ? `$$\n${tex}\n$$` : null;
123
+ }
124
+ case "line_block":
125
+ // rST line block — significant line breaks; join with hard breaks.
126
+ return elementChildren(el)
127
+ .filter((c) => c.tagName === "line")
128
+ .map((line) => renderInline(kids(line), ctx).trim())
129
+ .join("\\\n");
130
+ case "container":
131
+ return renderContainer(el, ctx);
132
+ case "compound":
133
+ // Sphinx toctree renders as a <compound toctree-wrapper> in the body;
134
+ // it's navigation (extracted separately by loaders/sphinx.ts), not
135
+ // page content — drop it here. Other compounds render their blocks.
136
+ if ((el.attribs?.classes ?? "").includes("toctree-wrapper"))
137
+ return null;
138
+ return renderBlocks(elementChildren(el), ctx).join("\n\n");
139
+ case "bullet_list":
140
+ return renderList(el, ctx, false);
141
+ case "enumerated_list":
142
+ return renderList(el, ctx, true);
143
+ case "definition_list":
144
+ return renderDefinitionList(el, ctx);
145
+ case "field_list":
146
+ return renderFieldList(el, ctx);
147
+ case "block_quote":
148
+ return renderBlockQuote(el, ctx);
149
+ case "image":
150
+ return renderImage(el);
151
+ case "figure":
152
+ return renderFigure(el, ctx);
153
+ case "table":
154
+ return renderTable(el, ctx);
155
+ case "transition":
156
+ return "---";
157
+ case "doctest_block":
158
+ // `>>> …` interactive blocks → a python fence (preserving prompts).
159
+ return renderDoctest(el);
160
+ case "versionmodified":
161
+ // `.. versionadded::` / `.. versionchanged::` / `.. deprecated::`.
162
+ // Render INLINE (the paragraph already carries an "Added in version
163
+ // X:" prefix inline span), not as a callout box — the fidelity review
164
+ // showed 55 note-boxes on one API page is far heavier than the live
165
+ // docs, which inline these. renderInlineEl italicises the prefix.
166
+ return renderBlocks(elementChildren(el), ctx).join("\n\n").trim() || null;
167
+ case "desc":
168
+ return renderDesc(el, ctx);
169
+ case "index":
170
+ // Invisible index marker (`.. index::` / autodoc index entries) —
171
+ // no rendered content, so drop silently (not a loss to ledger).
172
+ return null;
173
+ case "target":
174
+ case "substitution_definition":
175
+ case "comment":
176
+ // Link targets are already inlined into references' refuri;
177
+ // substitutions already resolved; comments dropped.
178
+ return null;
179
+ case "system_message":
180
+ ctx.ledger.push({
181
+ construct: `system_message: ${firstParagraphText(el)}`,
182
+ note: "docutils diagnostic (often a Sphinx-only directive/role) — dropped; re-run in Sphinx mode",
183
+ });
184
+ return null;
185
+ case "literal": // stray block-level literal
186
+ return renderInline([el], ctx).trim();
187
+ default:
188
+ ctx.ledger.push({ construct: `<${tag}>`, note: "unmapped docutils node — dropped" });
189
+ return null;
190
+ }
191
+ }
192
+ function renderAdmonition(name, el, ctx, title) {
193
+ const inner = renderBlocks(elementChildren(el), ctx).join("\n\n").trim();
194
+ const attrs = title ? `{title="${title}"}` : "";
195
+ const fence = ":".repeat(3 + colonDepth(inner));
196
+ return `${fence}${name}${attrs}\n${inner}\n${fence}`;
197
+ }
198
+ function renderGenericAdmonition(el, ctx) {
199
+ // `.. admonition:: Custom title` → first <title> child is the title.
200
+ const titleEl = elementChildren(el).find((c) => c.tagName === "title");
201
+ const title = titleEl ? renderInline(kids(titleEl), ctx).trim() : undefined;
202
+ const body = elementChildren(el).filter((c) => c.tagName !== "title");
203
+ const inner = renderBlocks(body, ctx).join("\n\n").trim();
204
+ const attrs = title ? `{title="${escapeAttr(title)}"}` : "";
205
+ const fence = ":".repeat(3 + colonDepth(inner));
206
+ return `${fence}note${attrs}\n${inner}\n${fence}`;
207
+ }
208
+ function renderCodeBlock(el, title) {
209
+ // literal_block text may be split across highlight <inline> spans — take
210
+ // the full text content. Language: `language=` attr (Sphinx) or
211
+ // `classes="code <lang>"` (plain docutils).
212
+ const classes = (el.attribs?.classes ?? "").split(/\s+/);
213
+ const lang = el.attribs?.language ?? classes.find((c) => c && c !== "code") ?? "";
214
+ const code = textContent(el).replace(/\n$/, "");
215
+ const fence = pickCodeFence(code);
216
+ const info = title ? `${lang} title="${escapeAttr(title)}"` : lang;
217
+ return `${fence}${info}\n${code}\n${fence}`;
218
+ }
219
+ /**
220
+ * `<container>` — Sphinx wraps a captioned code block in a
221
+ * `literal-block-wrapper` container (`.. code-block:: … :caption:`).
222
+ * Extract the caption → code-fence title. Other containers render their
223
+ * blocks inline.
224
+ */
225
+ function renderContainer(el, ctx) {
226
+ const classes = el.attribs?.classes ?? "";
227
+ if (classes.includes("literal-block-wrapper") || el.attribs?.literal_block === "1") {
228
+ const lit = descendant(el, "literal_block");
229
+ const capEl = elementChildren(el).find((c) => c.tagName === "caption");
230
+ const caption = capEl ? renderInline(kids(capEl), ctx).trim() : undefined;
231
+ return lit ? renderCodeBlock(lit, caption) : null;
232
+ }
233
+ const inner = renderBlocks(elementChildren(el), ctx).join("\n\n").trim();
234
+ return inner || null;
235
+ }
236
+ function renderDoctest(el) {
237
+ const code = textContent(el).replace(/\n$/, "");
238
+ const fence = pickCodeFence(code);
239
+ return `${fence}python\n${code}\n${fence}`;
240
+ }
241
+ /** Sphinx desctype/objtype → (dogsbay directive, ApiSymbol `kind`). */
242
+ function apiKind(objtype) {
243
+ switch (objtype) {
244
+ case "class":
245
+ return { dir: "api-class", kind: "class" };
246
+ case "exception":
247
+ return { dir: "api-class", kind: "class" };
248
+ case "module":
249
+ return { dir: "api-class", kind: "module" };
250
+ case "method":
251
+ case "classmethod":
252
+ case "staticmethod":
253
+ return { dir: "api-member", kind: "meth" };
254
+ case "function":
255
+ return { dir: "api-member", kind: "func" };
256
+ case "attribute":
257
+ case "property":
258
+ case "data":
259
+ return { dir: "api-member", kind: "attr" };
260
+ default:
261
+ return { dir: "api-member", kind: objtype || "func" };
262
+ }
263
+ }
264
+ /**
265
+ * `<desc>` — a Sphinx domain description (autodoc: a function / class /
266
+ * method signature + docstring). Emit a `:::api-class` / `:::api-member`
267
+ * component — the SAME vocabulary autodoc-python produces for the mkdocs
268
+ * path — instead of a markdown heading.
269
+ *
270
+ * Why not a heading: the fidelity review vs the live tornado docs showed
271
+ * 100+ API signatures rendered as `<h3>/<h4>` flood the on-page TOC (141
272
+ * headings vs the site's 11). `ApiSymbol` renders the signature at an
273
+ * h2/h3 in HTML but is NOT a `heading` TreeNode, so it stays out of the
274
+ * TOC — and gets the proper API styling. An explicit `<a id>` preserves
275
+ * the cross-ref target (`:meth:` → `#fullname`) that ApiSymbol doesn't emit.
276
+ *
277
+ * See plans/format-rst.md Phase 4.
278
+ */
279
+ /**
280
+ * Serialize a `<desc_parameterlist>` to `a, b, c`, preserving OPTIONAL
281
+ * groups: `<desc_optional>` wraps optional params, rendered bracketed as
282
+ * `a[, b]` (Sphinx convention). Recurses for nested optionals
283
+ * (`a[, b[, c]]`). Reading only direct `<desc_parameter>` children — the
284
+ * old behaviour — silently dropped every optional param.
285
+ */
286
+ function serializeParams(plist) {
287
+ let s = "";
288
+ for (const c of elementChildren(plist)) {
289
+ if (c.tagName === "desc_parameter") {
290
+ const t = textContent(c).replace(/\s+/g, " ").trim();
291
+ s += s ? `, ${t}` : t;
292
+ }
293
+ else if (c.tagName === "desc_optional") {
294
+ const inner = serializeParams(c);
295
+ s += s ? `[, ${inner}]` : `[${inner}]`;
296
+ }
297
+ }
298
+ return s;
299
+ }
300
+ function renderDesc(el, ctx) {
301
+ // A `<desc>` can carry MORE THAN ONE `<desc_signature>` — overloaded
302
+ // functions (`@typing.overload`, C++ overloads) list each form. Render
303
+ // them all: every signature keeps its cross-ref anchor, and the signature
304
+ // strings are stacked in the one ApiSymbol (which renders them in a code
305
+ // block). Taking only the first would silently drop overloads.
306
+ const sigs = elementChildren(el).filter((c) => c.tagName === "desc_signature");
307
+ if (sigs.length === 0) {
308
+ // No signature — just render the content.
309
+ const c = elementChildren(el).find((x) => x.tagName === "desc_content");
310
+ return c ? renderBlocks(elementChildren(c), ctx).join("\n\n").trim() : "";
311
+ }
312
+ const signatureText = (sig) => {
313
+ const addname = textOfChild(sig, "desc_addname");
314
+ const shortName = textOfChild(sig, "desc_name");
315
+ const plist = elementChildren(sig).find((c) => c.tagName === "desc_parameterlist");
316
+ const params = plist ? serializeParams(plist) : "";
317
+ const full = `${addname}${shortName}`.trim();
318
+ return { full, sig: `${full}${plist ? `(${params})` : ""}`.trim() };
319
+ };
320
+ const parsed = sigs.map(signatureText);
321
+ const fullName = parsed.find((p) => p.full)?.full ?? "";
322
+ const shortName = textOfChild(sigs[0], "desc_name");
323
+ const signature = parsed.map((p) => p.sig).filter(Boolean).join("\n");
324
+ const { dir, kind } = apiKind(el.attribs?.objtype ?? el.attribs?.desctype ?? "");
325
+ // Body: one anchor per signature id (cross-ref targets), then the
326
+ // docstring. Nested `<desc>` (methods in a class) recurse into api-members.
327
+ const parts = [];
328
+ const seenIds = new Set();
329
+ for (const sig of sigs) {
330
+ const id = sig.attribs?.ids?.split(/\s+/)[0];
331
+ if (id && !seenIds.has(id)) {
332
+ seenIds.add(id);
333
+ parts.push(`<a id="${id}"></a>`);
334
+ }
335
+ }
336
+ const content = elementChildren(el).find((c) => c.tagName === "desc_content");
337
+ if (content) {
338
+ const inner = renderBlocks(elementChildren(content), ctx).join("\n\n").trim();
339
+ if (inner)
340
+ parts.push(inner);
341
+ }
342
+ const body = parts.join("\n\n");
343
+ const propsYaml = stringifyYaml({ name: fullName || shortName, kind, signature }).trimEnd();
344
+ // Fence longer than any ::: already in the body (nested api-members).
345
+ const fence = ":".repeat(3 + colonDepth(body));
346
+ return `${fence}${dir}\n---\n${propsYaml}\n---\n${body}\n${fence}`;
347
+ }
348
+ function textOfChild(el, tag) {
349
+ const c = elementChildren(el).find((x) => x.tagName === tag);
350
+ return c ? textContent(c) : "";
351
+ }
352
+ function firstSectionTitle(root) {
353
+ const sec = elementChildren(root).find((c) => c.tagName === "section");
354
+ const titleEl = sec ? elementChildren(sec).find((c) => c.tagName === "title") : undefined;
355
+ return titleEl ? textContent(titleEl).trim() : undefined;
356
+ }
357
+ function renderList(el, ctx, ordered) {
358
+ const items = elementChildren(el).filter((c) => c.tagName === "list_item");
359
+ // Honour docutils' `start` attr so a list beginning at 5 isn't renumbered
360
+ // to 1 (review #6). markdown renders the first number and auto-increments.
361
+ const start = ordered ? parseInt(el.attribs?.start ?? "1", 10) || 1 : 1;
362
+ const lines = [];
363
+ items.forEach((item, i) => {
364
+ const marker = ordered ? `${start + i}.` : "-";
365
+ const blocks = renderBlocks(elementChildren(item), ctx);
366
+ const body = blocks.join("\n\n").trim();
367
+ const [first, ...rest] = body.split("\n");
368
+ const pad = " ".repeat(marker.length + 1);
369
+ lines.push(`${marker} ${first}`);
370
+ for (const r of rest)
371
+ lines.push(r ? pad + r : r);
372
+ });
373
+ return lines.join("\n");
374
+ }
375
+ function renderDefinitionList(el, ctx) {
376
+ const out = [];
377
+ for (const item of elementChildren(el).filter((c) => c.tagName === "definition_list_item")) {
378
+ const termEl = elementChildren(item).find((c) => c.tagName === "term");
379
+ const defEl = elementChildren(item).find((c) => c.tagName === "definition");
380
+ const term = termEl ? renderInline(kids(termEl), ctx).trim() : "";
381
+ const defBlocks = defEl ? renderBlocks(elementChildren(defEl), ctx) : [];
382
+ const def = defBlocks.join("\n\n").trim();
383
+ const [first, ...rest] = def.split("\n");
384
+ out.push(`${term}\n: ${first}`);
385
+ for (const r of rest)
386
+ out.push(r ? " " + r : r);
387
+ }
388
+ return out.join("\n\n");
389
+ }
390
+ function renderFieldList(el, ctx) {
391
+ // Field lists (`:name: value`) → a simple bold-term list. Rare in body
392
+ // prose (mostly bibliographic); keep it lossless-ish and readable.
393
+ const out = [];
394
+ for (const field of elementChildren(el).filter((c) => c.tagName === "field")) {
395
+ const nameEl = elementChildren(field).find((c) => c.tagName === "field_name");
396
+ const bodyEl = elementChildren(field).find((c) => c.tagName === "field_body");
397
+ const name = nameEl ? renderInline(kids(nameEl), ctx).trim() : "";
398
+ const body = bodyEl ? renderBlocks(elementChildren(bodyEl), ctx).join(" ").trim() : "";
399
+ out.push(`- **${name}:** ${body}`);
400
+ }
401
+ return out.join("\n");
402
+ }
403
+ function renderBlockQuote(el, ctx) {
404
+ const inner = renderBlocks(elementChildren(el), ctx).join("\n\n").trim();
405
+ return inner
406
+ .split("\n")
407
+ .map((l) => (l ? `> ${l}` : ">"))
408
+ .join("\n");
409
+ }
410
+ function renderImage(el) {
411
+ const uri = el.attribs?.uri ?? "";
412
+ const alt = el.attribs?.alt ?? "";
413
+ return `![${escapeAlt(alt)}](${uri})`;
414
+ }
415
+ function renderFigure(el, ctx) {
416
+ const img = elementChildren(el).find((c) => c.tagName === "image");
417
+ const capEl = elementChildren(el).find((c) => c.tagName === "caption");
418
+ const imgMd = img ? renderImage(img) : "";
419
+ const cap = capEl ? renderInline(kids(capEl), ctx).trim() : "";
420
+ return cap ? `${imgMd}\n\n_${cap}_` : imgMd;
421
+ }
422
+ // ── Tables ──────────────────────────────────────────────────────────────────
423
+ function renderTable(el, ctx) {
424
+ const tgroup = descendant(el, "tgroup");
425
+ if (!tgroup)
426
+ return "";
427
+ const cols = parseInt(tgroup.attribs?.cols ?? "0", 10);
428
+ const thead = descendant(tgroup, "thead");
429
+ const tbody = descendant(tgroup, "tbody");
430
+ const headRows = thead ? rowsOf(thead) : [];
431
+ const bodyRows = tbody ? rowsOf(tbody) : [];
432
+ // A `.. list-table:: Caption` / grid-table caption is a `<title>` child of
433
+ // the `<table>` (before `<tgroup>`). It rides on the pipe table as a
434
+ // `{caption="…"}` attribute block (dogsbay-md reattaches it as a real
435
+ // `<caption>` on re-parse); an HTML-fallback table gets an inline
436
+ // `<caption>` element.
437
+ const titleEl = elementChildren(el).find((c) => c.tagName === "title");
438
+ const caption = titleEl ? renderInline(kids(titleEl), ctx).trim() : "";
439
+ const withPipeCaption = (table) => caption ? `${table}\n\n{caption="${captionAttr(caption)}"}` : table;
440
+ // Fall back to an HTML table when any cell spans columns/rows or holds
441
+ // block content a GFM pipe table can't represent.
442
+ const complex = [...headRows, ...bodyRows].some((r) => elementChildren(r)
443
+ .filter((c) => c.tagName === "entry")
444
+ .some((e) => e.attribs?.morecols || e.attribs?.morerows || isBlockCell(e)));
445
+ if (complex)
446
+ return renderHtmlTable(headRows, bodyRows, ctx, caption);
447
+ const headerCells = headRows.length
448
+ ? cellsText(headRows[0], ctx)
449
+ : new Array(cols).fill("");
450
+ const lines = [
451
+ `| ${headerCells.join(" | ")} |`,
452
+ `| ${headerCells.map(() => "---").join(" | ")} |`,
453
+ ];
454
+ for (const r of bodyRows)
455
+ lines.push(`| ${cellsText(r, ctx).join(" | ")} |`);
456
+ return withPipeCaption(lines.join("\n"));
457
+ }
458
+ /** Escape a caption for a `{caption="…"}` attribute value (quotes only). */
459
+ function captionAttr(text) {
460
+ return text.replace(/"/g, "&quot;").replace(/\n+/g, " ").trim();
461
+ }
462
+ function renderHtmlTable(headRows, bodyRows, ctx, caption = "") {
463
+ const cell = (e, tag) => {
464
+ const span = e.attribs?.morecols ? ` colspan="${parseInt(e.attribs.morecols, 10) + 1}"` : "";
465
+ const rspan = e.attribs?.morerows ? ` rowspan="${parseInt(e.attribs.morerows, 10) + 1}"` : "";
466
+ const inner = renderBlocks(elementChildren(e), ctx).join(" ").trim();
467
+ return `<${tag}${span}${rspan}>${inner}</${tag}>`;
468
+ };
469
+ const row = (r, tag) => `<tr>${entriesOf(r).map((e) => cell(e, tag)).join("")}</tr>`;
470
+ const parts = ["<table>"];
471
+ if (caption)
472
+ parts.push(`<caption>${caption}</caption>`);
473
+ if (headRows.length)
474
+ parts.push("<thead>", ...headRows.map((r) => row(r, "th")), "</thead>");
475
+ parts.push("<tbody>", ...bodyRows.map((r) => row(r, "td")), "</tbody>", "</table>");
476
+ return parts.join("");
477
+ }
478
+ // ── Inline level ─────────────────────────────────────────────────────────────
479
+ function renderInline(nodes, ctx) {
480
+ let out = "";
481
+ for (const n of nodes) {
482
+ if (isTextNode(n)) {
483
+ out += escapeText(textData(n));
484
+ continue;
485
+ }
486
+ if (!isTag(n))
487
+ continue;
488
+ out += renderInlineEl(n, ctx);
489
+ }
490
+ return out;
491
+ }
492
+ function renderInlineEl(el, ctx) {
493
+ const tag = el.tagName;
494
+ const inner = () => renderInline(kids(el), ctx);
495
+ switch (tag) {
496
+ case "emphasis":
497
+ case "title_reference":
498
+ return `*${inner()}*`;
499
+ case "strong":
500
+ return `**${inner()}**`;
501
+ case "literal":
502
+ return `\`${textContent(el)}\``;
503
+ case "reference": {
504
+ const raw = el.attribs?.refuri ?? (el.attribs?.refid ? `#${el.attribs.refid}` : "");
505
+ const uri = resolveRef(raw, el.attribs?.internal === "1", ctx);
506
+ const text = inner() || uri;
507
+ return uri ? `[${text}](${uri})` : text;
508
+ }
509
+ case "target":
510
+ return "";
511
+ case "footnote_reference":
512
+ return `[^${el.attribs?.refid ?? textContent(el)}]`;
513
+ case "substitution_reference":
514
+ return inner();
515
+ case "math":
516
+ return `$${textContent(el)}$`;
517
+ case "problematic": {
518
+ ctx.ledger.push({
519
+ construct: textContent(el),
520
+ note: "Sphinx-only role under plain docutils — emitted as text; resolves in Sphinx mode",
521
+ });
522
+ return textContent(el);
523
+ }
524
+ case "inline":
525
+ // The "Added in version X:" prefix span of a versionmodified note —
526
+ // italicise it so the inline version note still reads as a note.
527
+ if ((el.attribs?.classes ?? "").includes("versionmodified")) {
528
+ const t = inner().trim();
529
+ return t ? `*${t}* ` : "";
530
+ }
531
+ return inner();
532
+ case "generated":
533
+ return inner();
534
+ case "image":
535
+ return renderImage(el);
536
+ default:
537
+ return inner();
538
+ }
539
+ }
540
+ // ── DOM helpers ──────────────────────────────────────────────────────────────
541
+ function elementChildren(el) {
542
+ const kids = [];
543
+ for (const c of el.children)
544
+ if (isTag(c))
545
+ kids.push(c);
546
+ return kids;
547
+ }
548
+ function findElement(doc, tag) {
549
+ for (const c of doc.children)
550
+ if (isTag(c) && c.tagName === tag)
551
+ return c;
552
+ return undefined;
553
+ }
554
+ function descendant(el, tag) {
555
+ for (const c of el.children) {
556
+ if (isTag(c)) {
557
+ if (c.tagName === tag)
558
+ return c;
559
+ const d = descendant(c, tag);
560
+ if (d)
561
+ return d;
562
+ }
563
+ }
564
+ return undefined;
565
+ }
566
+ function rowsOf(el) {
567
+ return elementChildren(el).filter((c) => c.tagName === "row");
568
+ }
569
+ function entriesOf(row) {
570
+ return elementChildren(row).filter((c) => c.tagName === "entry");
571
+ }
572
+ function cellsText(row, ctx) {
573
+ return entriesOf(row).map((e) => {
574
+ // Single-paragraph cells → inline text; anything richer is flagged as
575
+ // complex upstream (renderHtmlTable), so here we join paragraphs.
576
+ const paras = elementChildren(e).filter((c) => c.tagName === "paragraph");
577
+ // `|` is already escaped by escapeText, so no extra pipe-escaping here.
578
+ return paras.map((p) => renderInline(kids(p), ctx).trim()).join(" ");
579
+ });
580
+ }
581
+ function isBlockCell(e) {
582
+ const kids = elementChildren(e);
583
+ if (kids.length > 1)
584
+ return true;
585
+ return kids.some((c) => c.tagName !== "paragraph");
586
+ }
587
+ function textContent(el) {
588
+ let s = "";
589
+ for (const c of el.children) {
590
+ if (isTextNode(c))
591
+ s += textData(c);
592
+ else if (isTag(c))
593
+ s += textContent(c);
594
+ }
595
+ return s;
596
+ }
597
+ function firstParagraphText(el) {
598
+ const p = descendant(el, "paragraph");
599
+ return p ? textContent(p).slice(0, 80) : "";
600
+ }
601
+ /**
602
+ * Resolve a docutils/Sphinx `<reference>` target to a Dogsbay href.
603
+ *
604
+ * Sphinx internal `:doc:`/`:ref:` refuris are doc-relative (`advanced/setup`,
605
+ * `intro#a-section`) — resolve them against the current doc's directory into
606
+ * a root-relative slug so links work under Dogsbay's directory-style URLs
607
+ * (same problem the AsciiDoc/Antora xref resolver solves). External URLs and
608
+ * pure `#anchor` refs pass through unchanged.
609
+ */
610
+ function resolveRef(raw, internal, ctx) {
611
+ if (!raw)
612
+ return "";
613
+ if (raw[0] === "#")
614
+ return raw; // in-page anchor
615
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("//"))
616
+ return raw; // URL
617
+ if (!internal)
618
+ return raw;
619
+ // Strip a trailing .html Sphinx sometimes emits, split off #fragment.
620
+ const hashIdx = raw.indexOf("#");
621
+ let path = (hashIdx === -1 ? raw : raw.slice(0, hashIdx)).replace(/\.html$/i, "");
622
+ const frag = hashIdx === -1 ? "" : raw.slice(hashIdx);
623
+ if (!path)
624
+ return frag; // same-page ref
625
+ // Absolute docname (leading '/') is already root-relative.
626
+ const segs = path.startsWith("/") ? [] : ctx.docDir ? ctx.docDir.split("/") : [];
627
+ for (const part of path.replace(/^\//, "").split("/")) {
628
+ if (part === "" || part === ".")
629
+ continue;
630
+ if (part === "..")
631
+ segs.pop();
632
+ else
633
+ segs.push(part);
634
+ }
635
+ // Prefix with the site basePath so in-body links match the nav's hrefs.
636
+ const prefix = ctx.hrefPrefix.replace(/\/$/, "");
637
+ return `${prefix}/${segs.join("/")}${frag}`;
638
+ }
639
+ // ── Escaping / fences ────────────────────────────────────────────────────────
640
+ function escapeText(s) {
641
+ // rST prose is reflowed — the source line breaks + continuation indents
642
+ // in a paragraph's text are NOT significant, so collapse whitespace runs
643
+ // (incl. newlines) to single spaces. Otherwise the 4+ leading spaces on
644
+ // wrapped source lines leak in and markdown reads them as indented code.
645
+ // (Code blocks use textContent, which preserves whitespace — unaffected.)
646
+ // Then neutralise markdown-active chars docutils text may contain:
647
+ // backslash, code/emphasis markers, link brackets, raw-HTML `<`, and the
648
+ // table pipe. (`<` escaping is safe — markdown renders `\<` as a literal
649
+ // `<`.) Leaving these unescaped let plain rST prose like `see <http://x>`
650
+ // or `item [1]` be reinterpreted as HTML / link syntax (review #10).
651
+ return s.replace(/\s+/g, " ").replace(/([\\`*_[\]<|])/g, "\\$1");
652
+ }
653
+ function escapeAttr(s) {
654
+ return s.replace(/"/g, "&quot;");
655
+ }
656
+ function escapeAlt(s) {
657
+ return s.replace(/([\]\\])/g, "\\$1");
658
+ }
659
+ /** Longest run of `:` in a line-start `:::…` fence within text (for nesting). */
660
+ function colonDepth(text) {
661
+ let max = 0;
662
+ for (const line of text.split("\n")) {
663
+ const m = line.match(/^(:{3,})/);
664
+ if (m)
665
+ max = Math.max(max, m[1].length - 2);
666
+ }
667
+ return max;
668
+ }
669
+ function pickCodeFence(code) {
670
+ let longest = 0;
671
+ for (const m of code.matchAll(/^(`{3,})/gm))
672
+ longest = Math.max(longest, m[1].length);
673
+ return "`".repeat(Math.max(3, longest + 1));
674
+ }
675
+ //# sourceMappingURL=transform.js.map