@dogsbay/format-mkdocs 0.2.0-beta.11 → 0.2.0-beta.111

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.
@@ -1,15 +1,39 @@
1
+ import { walkInline, applyTextFlags, renderLeaf, indent, prefixLines, pickCodeFence, codeSpan, stripHtml, inlineHtmlToMarkdown, renderUnknownNode, listStart, calloutVariant, leafHtml, } from "@dogsbay/serialize-core";
2
+ // Module-level, saved and restored around each treeToMkdocs call. Correct
3
+ // for synchronous nesting only: nothing in this module may `await`, or one
4
+ // page's context bleeds into the next.
5
+ let ctx = {
6
+ pageSlug: "",
7
+ };
1
8
  /**
2
- * Convert a TreeNode[] back to MkDocs-compatible Markdown.
9
+ * Convert a TreeNode[] to MkDocs-compatible Markdown.
3
10
  */
4
- export function treeToMkdocs(nodes) {
5
- return nodes.map(nodeToMkdocs).join("\n\n");
11
+ export function treeToMkdocs(nodes, options = {}) {
12
+ const prev = ctx;
13
+ ctx = { pageSlug: "", ...options };
14
+ try {
15
+ return joinBlocks(nodes);
16
+ }
17
+ finally {
18
+ ctx = prev;
19
+ }
20
+ }
21
+ function report(feature, detail, action = "degraded") {
22
+ ctx.ledger?.record({ feature, detail, action });
6
23
  }
24
+ // ── Block dispatcher ────────────────────────────────────
7
25
  function nodeToMkdocs(node) {
8
26
  switch (node.type) {
9
27
  case "frontmatter":
10
28
  return `---\n${node.props?.content ?? ""}\n---`;
11
29
  case "references":
12
30
  return referencesToMkdocs(node);
31
+ case "heading":
32
+ return headingToMkdocs(node);
33
+ case "paragraph":
34
+ return escapeBlockStart(leafContent(node));
35
+ case "prose":
36
+ return proseToMkdocs(node);
13
37
  case "callout":
14
38
  return calloutToMkdocs(node);
15
39
  case "details":
@@ -22,128 +46,781 @@ function nodeToMkdocs(node) {
22
46
  return codeToMkdocs(node);
23
47
  case "diagram":
24
48
  return diagramToMkdocs(node);
25
- case "annotation-list":
26
- return annotationListToMkdocs(node);
27
- case "heading":
28
- return headingToMkdocs(node);
29
- case "paragraph":
30
- return childrenToMkdocs(node);
31
- case "prose":
32
- return node.html ?? "";
49
+ case "math-block":
50
+ case "math":
51
+ return mathBlockToMkdocs(node);
52
+ case "table":
53
+ return tableToMkdocs(node);
33
54
  case "blockquote":
34
- return blockquoteToMkdocs(node);
55
+ return prefixLines(childrenToMkdocs(node), "> ");
35
56
  case "ordered-list":
36
57
  return orderedListToMkdocs(node);
37
58
  case "unordered-list":
38
59
  return unorderedListToMkdocs(node);
39
60
  case "list-item":
61
+ return listItemToMkdocs(node);
62
+ case "deflist":
63
+ return deflistToMkdocs(node);
64
+ case "dt":
65
+ return escapeBlockStart(leafContent(node));
66
+ case "dd":
67
+ return escapeBlockStart(leafContent(node));
68
+ case "steps":
69
+ case "substeps":
70
+ return stepsToMkdocs(node);
71
+ case "step":
72
+ // A step reached outside stepsToMkdocs: its title is its inline text.
73
+ return [escapeBlockStart(leafContent(node)), childrenToMkdocs(node)].filter(Boolean).join("\n\n");
74
+ case "annotation-list":
40
75
  return childrenToMkdocs(node);
41
- case "html-container":
42
- return htmlContainerToMkdocs(node);
76
+ case "link-button": {
77
+ const href = mkdocsHref(node.props?.href ?? "");
78
+ const text = leafContent(node) || href;
79
+ return `[${text}](${href}){ .md-button .md-button--primary }`;
80
+ }
43
81
  case "hr":
44
82
  return "---";
45
83
  case "html":
46
- return node.html ?? "";
84
+ return relativizeHtmlAssets(node.html ?? "");
85
+ case "html-container":
86
+ return htmlContainerToMkdocs(node);
87
+ case "figure": {
88
+ const caption = node.props?.caption;
89
+ const inner = childrenToMkdocs(node);
90
+ if (!caption)
91
+ return inner;
92
+ return `<figure markdown>\n\n${inner}\n\n<figcaption>${caption}</figcaption>\n</figure>`;
93
+ }
94
+ case "card":
95
+ return cardToMkdocs(node);
96
+ case "cards":
97
+ case "grid":
98
+ return cardsToMkdocs(node);
99
+ case "link-card":
100
+ return cardToMkdocs(node);
101
+ case "youtube":
102
+ return youtubeToMkdocs(node);
103
+ case "api-class":
104
+ case "api-member":
105
+ return apiSymbolToMkdocs(node);
106
+ case "api-doc":
107
+ return apiDocToMkdocs(node);
108
+ case "api-params":
109
+ return apiParamsToMkdocs(node);
110
+ case "api-source":
111
+ return apiSourceToMkdocs(node);
112
+ case "footnote-list":
113
+ return (node.children ?? []).map(nodeToMkdocs).filter(Boolean).join("\n");
114
+ case "footnote-def":
115
+ return footnoteDefToMkdocs(node);
116
+ case "related": {
117
+ // "Additional resources" — the title lives in props; children are the
118
+ // link list. Dropping the label floats the links with no context.
119
+ const title = (node.props?.title ?? "").trim();
120
+ const body = childrenToMkdocs(node);
121
+ if (!title)
122
+ return body;
123
+ return `**${title.replace(/([*\\])/g, "\\$1")}**\n\n${body}`;
124
+ }
125
+ case "include": {
126
+ // Includes are realized upstream (convert / site build). One that
127
+ // reaches the serializer is unresolved: emit the snippet form so the
128
+ // reference is visible in the output rather than vanishing, and say so.
129
+ const src = node.props?.src ?? node.props?.path ?? "";
130
+ report("include", `unrealized include ${src} emitted as a --8<-- snippet`);
131
+ return `--8<-- "${src}"`;
132
+ }
47
133
  default:
48
- return node.html ?? childrenToMkdocs(node);
134
+ // `renderChildren` is what keeps an unknown CONTAINER's content: without
135
+ // it the html policy returns a comment for a node that has children and
136
+ // no html, and the ledger says "no content to preserve" — false.
137
+ return renderUnknownNode(node, {
138
+ policy: "html",
139
+ renderChildren: (kids) => joinBlocks(kids),
140
+ report: (type, detail) => report(`node:${type}`, detail ?? ""),
141
+ join: "\n\n",
142
+ });
49
143
  }
50
144
  }
51
- function calloutToMkdocs(node) {
52
- const variant = node.props?.variant ?? "note";
145
+ // ── Inline rendering ────────────────────────────────────
146
+ /**
147
+ * MkDocs inline syntax as emitters over the core's shared walk.
148
+ *
149
+ * Exhaustive on purpose: an omitted key routes through the core's
150
+ * plain-text degradation, which is the right default for a NEW variant and
151
+ * the wrong behaviour for one PyMdown genuinely supports.
152
+ */
153
+ const MKDOCS_INLINE = {
154
+ text: (node) => applyTextFlags(escapeProse(node.text), node, {
155
+ bold: (s) => `**${s}**`,
156
+ italic: (s) => `*${s}*`,
157
+ strike: (s) => `~~${s}~~`,
158
+ }),
159
+ link: (node, c) => {
160
+ const title = node.title ? ` "${node.title.replace(/"/g, "&quot;")}"` : "";
161
+ return `[${c.recurse(node.children)}](${mkdocsHref(node.href)}${title})${attrSuffix(node.attrs)}`;
162
+ },
163
+ image: (node) => {
164
+ const alt = (node.alt ?? "").replace(/\]/g, "\\]");
165
+ const title = node.title ? ` "${node.title.replace(/"/g, "&quot;")}"` : "";
166
+ const attrs = { ...(node.attrs ?? {}) };
167
+ if (node.width)
168
+ attrs.width = String(node.width);
169
+ if (node.height)
170
+ attrs.height = String(node.height);
171
+ return `![${alt}](${mkdocsAssetPath(node.src)}${title})${attrSuffix(attrs)}`;
172
+ },
173
+ code: (node) => codeSpan(node.text),
174
+ highlight: (node, c) => `==${c.recurse(node.children)}==`,
175
+ "footnote-ref": (node) => `[^${node.label}]`,
176
+ // pymdownx.keys: `++ctrl+alt+del++`
177
+ kbd: (node) => `++${node.keys.map((k) => k.toLowerCase().replace(/\s+/g, "-")).join("+")}++`,
178
+ math: (node) => `$${node.latex}$`,
179
+ icon: (node) => `:${node.name.replace(/[^A-Za-z0-9_-]/g, "-")}:`,
180
+ "html-inline": (node) => relativizeHtmlAssets(node.html ?? ""),
181
+ // A two-space hard break inside an indented block is fragile; `<br>` is not.
182
+ break: () => "<br>",
183
+ };
184
+ /**
185
+ * Escape prose for Python-Markdown.
186
+ *
187
+ * `<cluster-name>` in running text is an HTML tag to Python-Markdown and the
188
+ * placeholder disappears from the rendered page. `\<` is NOT an escape in
189
+ * this dialect (its ESCAPED_CHARS has no `<`), so the entity is the only
190
+ * form that survives. Emphasis and code markers are backslash-escaped so a
191
+ * literal `*` or `_` in prose cannot open a span.
192
+ */
193
+ function escapeProse(text) {
194
+ // Emoji shortcodes (`:man_raising_hand:`) must keep their underscores.
195
+ const parts = text.split(/(:[a-z0-9_+-]+:)/i);
196
+ return parts
197
+ .map((part, i) => {
198
+ if (i % 2 === 1)
199
+ return part;
200
+ return part
201
+ .replace(/&(?![a-zA-Z#][a-zA-Z0-9]*;)/g, "&amp;")
202
+ .replace(/<(?=[A-Za-z/!?])/g, "&lt;")
203
+ .replace(/([*`[\]])/g, "\\$1")
204
+ // Intraword `_` is literal to Python-Markdown (snake_case is safe);
205
+ // only an underscore at a word edge can open or close emphasis.
206
+ .replace(/(^|[^A-Za-z0-9])_|_(?=[^A-Za-z0-9]|$)/g, (m) => m.replace("_", "\\_"))
207
+ .replace(/~~/g, "\\~\\~");
208
+ })
209
+ .join("");
210
+ }
211
+ /**
212
+ * A block of prose that BEGINS with something Python-Markdown reads as
213
+ * structure — `# `, `> `, `- `, `1. `, `!!! `, `=== `, a def_list `: ` —
214
+ * becomes that structure. Neutralise the first character of the first line.
215
+ */
216
+ function escapeBlockStart(block) {
217
+ if (!block)
218
+ return block;
219
+ const first = block.split("\n", 1)[0];
220
+ if (/^(#{1,6}\s|>|[-+]\s|\d+[.)]\s|---|\+\+\+)/.test(first))
221
+ return `\\${block}`;
222
+ if (/^!!!/.test(first))
223
+ return `\\${block}`;
224
+ if (/^\?\?\?/.test(first))
225
+ return `&#63;${block.slice(1)}`;
226
+ if (/^===/.test(first))
227
+ return `&#61;${block.slice(1)}`;
228
+ if (/^:\s{3}/.test(first))
229
+ return `&#58;${block.slice(1)}`;
230
+ return block;
231
+ }
232
+ /**
233
+ * Two adjacent lists — or two adjacent tab groups — are ONE list / one tab
234
+ * set to Python-Markdown (`1. a` then `- b` merge into a single `<ol>`, and
235
+ * `===!` is not a separator in pymdownx 10). An empty HTML comment between
236
+ * them is invisible and keeps them apart.
237
+ */
238
+ const LIST_TYPES = new Set(["ordered-list", "unordered-list"]);
239
+ function joinBlocks(nodes) {
240
+ const out = [];
241
+ let prev;
242
+ for (const node of nodes) {
243
+ const text = nodeToMkdocs(node);
244
+ if (!text)
245
+ continue;
246
+ if (prev && ((LIST_TYPES.has(prev.type) && LIST_TYPES.has(node.type)) || (prev.type === "tabs" && node.type === "tabs"))) {
247
+ out.push("<!-- -->");
248
+ }
249
+ out.push(text);
250
+ prev = node;
251
+ }
252
+ return out.join("\n\n");
253
+ }
254
+ function inlineToMkdocs(nodes) {
255
+ return walkInline(groupFormatRuns(nodes), {
256
+ emitters: MKDOCS_INLINE,
257
+ report: (feature, detail) => report(feature, detail ?? ""),
258
+ });
259
+ }
260
+ /**
261
+ * Wrap CONSECUTIVE nodes sharing the same bold/italic/strike flags in one
262
+ * mark pair instead of per-node wrapping. The parser splits a bold run at a
263
+ * code span (`**formatted `code` text**` arrives as three flagged nodes);
264
+ * per-node emission produced `**formatted**** ****text**` — an empty
265
+ * `** **` pair Python-Markdown renders as literal asterisks. Same fix as
266
+ * format-obsidian's groupFormatRuns.
267
+ */
268
+ function groupFormatRuns(nodes) {
269
+ const flagsOf = (n) => {
270
+ const f = n;
271
+ return `${f.bold ? "b" : ""}${f.italic ? "i" : ""}${f.strikethrough ? "s" : ""}`;
272
+ };
273
+ const flaggable = (n) => n.type === "text" || n.type === "code";
274
+ const out = [];
275
+ let i = 0;
276
+ while (i < nodes.length) {
277
+ const key = flagsOf(nodes[i]);
278
+ if (!key || !flaggable(nodes[i])) {
279
+ out.push(nodes[i]);
280
+ i++;
281
+ continue;
282
+ }
283
+ let j = i;
284
+ while (j < nodes.length && flaggable(nodes[j]) && flagsOf(nodes[j]) === key)
285
+ j++;
286
+ if (j === i + 1 && nodes[i].type === "text") {
287
+ out.push(nodes[i]);
288
+ i = j;
289
+ continue;
290
+ }
291
+ const run = nodes.slice(i, j).map((n) => {
292
+ const clone = { ...n };
293
+ delete clone.bold;
294
+ delete clone.italic;
295
+ delete clone.strikethrough;
296
+ return clone;
297
+ });
298
+ let inner = walkInline(run, { emitters: MKDOCS_INLINE });
299
+ const lead = inner.match(/^\s*/)[0];
300
+ const tail = inner.match(/\s*$/)[0];
301
+ inner = inner.slice(lead.length, inner.length - tail.length || undefined);
302
+ if (inner) {
303
+ let open = "";
304
+ let close = "";
305
+ if (key.includes("s")) {
306
+ open += "~~";
307
+ close = "~~" + close;
308
+ }
309
+ if (key.includes("i")) {
310
+ open += "*";
311
+ close = "*" + close;
312
+ }
313
+ if (key.includes("b")) {
314
+ open += "**";
315
+ close = "**" + close;
316
+ }
317
+ // Already-serialized markdown riding the html-inline pass-through.
318
+ out.push({ type: "html-inline", html: lead + open + inner + close + tail });
319
+ }
320
+ else if (lead) {
321
+ out.push({ type: "text", text: lead });
322
+ }
323
+ i = j;
324
+ }
325
+ return out;
326
+ }
327
+ /** `{ .class #id key="value" }` for attr_list, or "" when there is nothing. */
328
+ function attrSuffix(attrs) {
329
+ if (!attrs)
330
+ return "";
331
+ const parts = [];
332
+ if (attrs.id)
333
+ parts.push(`#${attrs.id}`);
334
+ if (attrs.class)
335
+ parts.push(...attrs.class.split(/\s+/).filter(Boolean).map((cls) => `.${cls}`));
336
+ for (const [k, v] of Object.entries(attrs)) {
337
+ if (k === "id" || k === "class" || v === undefined)
338
+ continue;
339
+ parts.push(`${k}="${v.replace(/"/g, "&quot;")}"`);
340
+ }
341
+ return parts.length ? `{ ${parts.join(" ")} }` : "";
342
+ }
343
+ // ── Links and assets ────────────────────────────────────
344
+ /** Directory of the current page's emitted file, as path segments. */
345
+ function pageDirSegments() {
346
+ const parts = ctx.pageSlug.split("/").filter(Boolean);
347
+ parts.pop();
348
+ return parts;
349
+ }
350
+ /** `target` (site-rooted, no leading slash) relative to the current page's directory. */
351
+ function relativeToPage(target) {
352
+ const from = pageDirSegments();
353
+ const to = target.split("/").filter(Boolean);
354
+ let i = 0;
355
+ while (i < from.length && i < to.length && from[i] === to[i])
356
+ i++;
357
+ const up = from.slice(i).map(() => "..");
358
+ const rel = [...up, ...to.slice(i)].join("/");
359
+ return rel || ".";
360
+ }
361
+ /**
362
+ * Site-rooted page links → relative `.md` links, which MkDocs resolves,
363
+ * validates, and rewrites for `use_directory_urls`. Only rewritten when the
364
+ * target is a page in this export; everything else passes through so an
365
+ * asset or external route is never turned into a dead `.md`.
366
+ */
367
+ export function mkdocsHref(href) {
368
+ if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("//")) {
369
+ return href;
370
+ }
371
+ if (!href.startsWith("/"))
372
+ return href;
373
+ const hashAt = href.indexOf("#");
374
+ const path = (hashAt === -1 ? href : href.slice(0, hashAt)).replace(/^\/+|\/+$/g, "");
375
+ const hash = hashAt === -1 ? "" : href.slice(hashAt);
376
+ if (!ctx.slugs || !ctx.pageSlug)
377
+ return href;
378
+ // `/rest_api/objects/index#frag` names the page emitted as `rest_api/objects.md`
379
+ // — 76 of 81 links MkDocs reported "left as is" on the OpenShift subset were
380
+ // this shape. Try the stripped form too.
381
+ const candidates = [
382
+ path,
383
+ path ? `${path}/index` : "index",
384
+ path.replace(/\.md$/, ""),
385
+ path.replace(/\/index$/, ""),
386
+ ];
387
+ const slug = candidates.find((c) => ctx.slugs.has(c));
388
+ if (!slug)
389
+ return href;
390
+ return `${relativeToPage(`${slug}.md`)}${hash}`;
391
+ }
392
+ /** `/images/x.png` → `../images/x.png` relative to the emitted file. */
393
+ function mkdocsAssetPath(src) {
394
+ if (!src || /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//") || !src.startsWith("/")) {
395
+ return src;
396
+ }
397
+ if (!ctx.pageSlug)
398
+ return src;
399
+ return relativeToPage(src.replace(/^\/+/, ""));
400
+ }
401
+ /**
402
+ * Adapt raw HTML for MkDocs: relative asset paths, and the AsciiDoc
403
+ * converter's admonition-in-a-table-cell convention rewritten to Material's
404
+ * own admonition markup.
405
+ *
406
+ * `adoc2md` emits an admonition inside a table cell as
407
+ * `<dl class="db-admonition db-admonition-important"><dt>Important</dt><dd>…</dd></dl>`,
408
+ * because a `!!!` block cannot live inside a raw HTML cell. The Astro
409
+ * scaffold styles that class; MkDocs has never heard of it, so the 354 such
410
+ * blocks in the OpenShift corpus rendered as plain definition lists — the
411
+ * word "Important" as a term, the warning as its definition, no box. Content
412
+ * intact, signal gone. Material's admonition HTML is stable and documented,
413
+ * so emit that instead.
414
+ */
415
+ function relativizeHtmlAssets(html) {
416
+ let out = html;
417
+ if (out.includes('db-admonition')) {
418
+ out = out.replace(/<dl class="db-admonition(?:\s+db-admonition-([a-z]+))?"[^>]*>\s*<dt>([^<]*)<\/dt>\s*<dd>([\s\S]*?)<\/dd>\s*<\/dl>/g, (_m, kind, title, body) => {
419
+ const raw = (kind || title || "note").toLowerCase();
420
+ const type = ADMONITION_ALIASES[raw] ?? raw;
421
+ const inner = /^\s*<(p|ul|ol|pre|div|table)\b/.test(body) ? body : `<p>${body}</p>`;
422
+ return `<div class="admonition ${type}"><p class="admonition-title">${title.trim() || type}</p>${inner}</div>`;
423
+ });
424
+ }
425
+ if (!ctx.pageSlug)
426
+ return out;
427
+ // src / poster / href-to-a-file, either quote style; srcset entries too.
428
+ out = out.replace(/(\s(?:src|poster|href)=)(["'])(\/[^"']+)\2/g, (m, a, q, path) => {
429
+ if (a.startsWith(" href=") && !/\.[a-z0-9]{2,5}$/i.test(path.split(/[?#]/)[0]))
430
+ return m;
431
+ return `${a}${q}${mkdocsAssetPath(path)}${q}`;
432
+ });
433
+ out = out.replace(/(\ssrcset=)(["'])([^"']+)\2/g, (_m, a, q, list) => `${a}${q}${list.split(",").map((e) => e.trim().replace(/^\/\S+/, (u) => mkdocsAssetPath(u))).join(", ")}${q}`);
434
+ return out;
435
+ }
436
+ // ── Prose ───────────────────────────────────────────────
437
+ function proseToMkdocs(node) {
438
+ if (node.inline)
439
+ return inlineToMkdocs(node.inline);
440
+ if (node.html)
441
+ return htmlToMkdocs(node.html);
442
+ return "";
443
+ }
444
+ /**
445
+ * Text-bearing content across all three TreeNode shapes (flat `inline`,
446
+ * wrapped `prose` child, html-only), via the core's `renderLeaf`.
447
+ */
448
+ function leafContent(node) {
449
+ return renderLeaf(node, {
450
+ inline: inlineToMkdocs,
451
+ children: (children) => children.map(nodeToMkdocs).filter(Boolean).join("\n\n"),
452
+ html: htmlToMkdocs,
453
+ join: "\n\n",
454
+ });
455
+ }
456
+ function htmlToMkdocs(html) {
457
+ // stripHtml decodes `&lt;` back to `<`, which would reintroduce the exact
458
+ // tag-swallow escapeProse exists to prevent. Re-escape after stripping.
459
+ return stripHtml(inlineHtmlToMarkdown(relativizeHtmlAssets(html))).replace(/<(?=[A-Za-z/!?])/g, "&lt;");
460
+ }
461
+ function childrenToMkdocs(node) {
462
+ if (!node.children || node.children.length === 0)
463
+ return "";
464
+ return joinBlocks(node.children);
465
+ }
466
+ // ── Headings ────────────────────────────────────────────
467
+ function headingToMkdocs(node) {
468
+ const level = node.props?.level ?? 1;
469
+ const hashes = "#".repeat(Math.min(Math.max(level, 1), 6));
470
+ const text = leafContent(node) || (node.props?.text ?? "");
471
+ // The authored id first (`{id="…"}`), then the parser's slug. Emitted via
472
+ // attr_list so cross-references written against the source ids resolve.
473
+ const id = (node.props?.id || node.props?.slug || "").trim();
474
+ const suffix = id ? ` { #${id} }` : "";
475
+ return `${hashes} ${text.replace(/\n+/g, " ")}${suffix}`;
476
+ }
477
+ // ── Admonitions ─────────────────────────────────────────
478
+ /** Material's admonition vocabulary; anything else keeps its name. */
479
+ const ADMONITION_ALIASES = {
480
+ caution: "warning",
481
+ important: "warning",
482
+ hint: "tip",
483
+ attention: "warning",
484
+ error: "danger",
485
+ };
486
+ function rawVariant(node) {
487
+ return (calloutVariant(node) || "note").toLowerCase();
488
+ }
489
+ function admonitionType(node) {
490
+ const raw = rawVariant(node);
491
+ return ADMONITION_ALIASES[raw] ?? raw;
492
+ }
493
+ function admonitionTitle(node, type) {
53
494
  const title = node.props?.title;
54
- const inline = node.props?.inline;
55
- let opener = "!!!";
56
- let line = `${opener} ${variant}`;
57
- if (inline)
58
- line += ` ${inline}`;
59
- line += titleSuffix(variant, title);
60
- const inner = childrenToMkdocs(node);
61
- if (!inner)
62
- return line;
63
- return `${line}\n\n${indent(inner, 4)}`;
495
+ if (title === null)
496
+ return ' ""';
497
+ if (title === undefined)
498
+ return "";
499
+ const raw = rawVariant(node);
500
+ const autoTitle = raw.charAt(0).toUpperCase() + raw.slice(1);
501
+ // An aliased type would otherwise show the alias's auto-title ("Warning"
502
+ // for an `important`), so keep the original word as an explicit title.
503
+ if (title === autoTitle && raw === type)
504
+ return "";
505
+ return ` "${title.replace(/"/g, "&quot;")}"`;
506
+ }
507
+ function calloutToMkdocs(node) {
508
+ const type = admonitionType(node);
509
+ const inlineMod = node.props?.inline;
510
+ let line = `!!! ${type}`;
511
+ if (inlineMod)
512
+ line += ` ${inlineMod}`;
513
+ line += admonitionTitle(node, type);
514
+ const inner = bodyContent(node);
515
+ return inner ? `${line}\n\n${indent(inner, 4)}` : line;
516
+ }
517
+ /** Block children when present; an html-only body otherwise, so it is not lost. */
518
+ function bodyContent(node) {
519
+ return childrenToMkdocs(node) || (node.children?.length ? "" : leafContent(node));
64
520
  }
65
521
  function detailsToMkdocs(node) {
66
- const variant = node.props?.variant ?? "note";
67
- const title = node.props?.title;
522
+ const type = admonitionType(node);
68
523
  const open = node.props?.open;
69
- const opener = open ? "???+" : "???";
70
- let line = `${opener} ${variant}`;
71
- line += titleSuffix(variant, title);
72
- const inner = childrenToMkdocs(node);
73
- if (!inner)
74
- return line;
75
- return `${line}\n\n${indent(inner, 4)}`;
524
+ let line = `${open ? "???+" : "???"} ${type}`;
525
+ line += admonitionTitle(node, type);
526
+ const inner = bodyContent(node);
527
+ return inner ? `${line}\n\n${indent(inner, 4)}` : line;
76
528
  }
529
+ // ── Tabs ────────────────────────────────────────────────
77
530
  function tabsToMkdocs(node) {
78
531
  if (!node.children)
79
532
  return "";
80
- return node.children.map(nodeToMkdocs).join("\n\n");
533
+ return node.children.map(tabToMkdocs).filter(Boolean).join("\n\n");
81
534
  }
82
535
  function tabToMkdocs(node) {
83
- const title = node.props?.title ?? "Tab";
84
- const inner = childrenToMkdocs(node);
85
- if (!inner)
86
- return `=== "${title}"`;
87
- return `=== "${title}"\n\n${indent(inner, 4)}`;
536
+ const title = node.props?.title ||
537
+ node.props?.label ||
538
+ node.props?.value ||
539
+ "Tab";
540
+ const inner = bodyContent(node);
541
+ const head = `=== "${title.replace(/"/g, "&quot;")}"`;
542
+ return inner ? `${head}\n\n${indent(inner, 4)}` : head;
88
543
  }
544
+ // ── Code ────────────────────────────────────────────────
89
545
  function codeToMkdocs(node) {
90
- const lang = node.props?.lang ?? "";
91
- const code = (node.props?.code ?? "").replace(/\n$/, "");
92
- const fence = node.props?.fence ?? "```";
93
- return `${fence}${lang}\n${code}\n${fence}`;
546
+ // Parser: `props.language` + `node.html`; importers: `props.lang` + `props.code`.
547
+ const lang = node.props?.lang || node.props?.language || "";
548
+ const code = (node.props?.code ?? node.html ?? "").replace(/\n$/, "");
549
+ const title = node.props?.title;
550
+ const highlights = node.props?.highlights;
551
+ const fence = pickCodeFence(code);
552
+ const meta = [];
553
+ if (title)
554
+ meta.push(`title="${title.replace(/"/g, "&quot;")}"`);
555
+ if (highlights)
556
+ meta.push(`hl_lines="${highlights.replace(/[^0-9 ,-]/g, "").replace(/,/g, " ")}"`);
557
+ const info = [lang || (meta.length ? "text" : ""), ...meta].filter(Boolean).join(" ");
558
+ return `${fence}${info}\n${code}\n${fence}`;
94
559
  }
95
560
  function diagramToMkdocs(node) {
96
561
  const lang = node.props?.lang ?? "mermaid";
97
- const code = (node.props?.code ?? "").replace(/\n$/, "");
562
+ const code = (node.props?.code ?? node.html ?? "").replace(/\n$/, "");
98
563
  return `\`\`\`${lang}\n${code}\n\`\`\``;
99
564
  }
100
- function referencesToMkdocs(node) {
101
- const entries = (node.props?.entries ?? []);
102
- return entries
103
- .map((e) => {
104
- let line = `[${e.label}]: ${e.href}`;
105
- if (e.title)
106
- line += ` "${e.title}"`;
107
- return line;
108
- })
109
- .join("\n");
565
+ function mathBlockToMkdocs(node) {
566
+ const latex = (node.props?.latex ?? node.props?.content ?? node.html ?? "").trim();
567
+ return `$$\n${latex}\n$$`;
110
568
  }
111
- function annotationListToMkdocs(node) {
112
- return childrenToMkdocs(node);
569
+ function collectRows(node) {
570
+ const out = { header: [], body: [] };
571
+ for (const child of node.children ?? []) {
572
+ if (child.type === "thead") {
573
+ for (const tr of child.children ?? [])
574
+ if (tr.type === "tr")
575
+ out.header.push(tr.children ?? []);
576
+ }
577
+ else if (child.type === "tbody") {
578
+ for (const tr of child.children ?? [])
579
+ if (tr.type === "tr")
580
+ out.body.push(tr.children ?? []);
581
+ }
582
+ else if (child.type === "tr") {
583
+ const cells = child.children ?? [];
584
+ if (out.body.length === 0 && out.header.length === 0 && cells.some((c) => c.type === "th")) {
585
+ out.header.push(cells);
586
+ }
587
+ else {
588
+ out.body.push(cells);
589
+ }
590
+ }
591
+ }
592
+ return out;
113
593
  }
114
- function headingToMkdocs(node) {
115
- const level = node.props?.level ?? 1;
116
- const hashes = "#".repeat(level);
117
- const text = childrenToMkdocs(node);
118
- return `${hashes} ${text}`;
594
+ const BLOCK_HTML = /<(ul|ol|p|pre|table|dl|div|blockquote|h[1-6])\b/i;
595
+ /** Does this cell hold something a single pipe-table line cannot? */
596
+ function isBlockCell(cell) {
597
+ const kids = cell.children ?? [];
598
+ if (kids.length > 1)
599
+ return true;
600
+ if (kids.some((k) => !["paragraph", "prose"].includes(k.type)))
601
+ return true;
602
+ if (cell.props?.colspan > 1 || cell.props?.rowspan > 1)
603
+ return true;
604
+ // An html-only cell carrying block markup: stripHtml would glue its words.
605
+ const html = leafHtml(cell);
606
+ return !!html && BLOCK_HTML.test(html);
119
607
  }
120
- function blockquoteToMkdocs(node) {
121
- const inner = childrenToMkdocs(node);
122
- return inner
123
- .split("\n")
124
- .map((line) => (line ? `> ${line}` : ">"))
125
- .join("\n");
608
+ function hasBlockCells(rows) {
609
+ return rows.some((row) => row.some(isBlockCell));
610
+ }
611
+ /**
612
+ * GFM pipe table when every cell is a single line of inline content;
613
+ * otherwise an HTML table whose cells are processed as Markdown
614
+ * (`md_in_html`), so a list or code block inside a cell survives instead of
615
+ * being flattened onto one line.
616
+ */
617
+ function tableToMkdocs(node) {
618
+ const rows = collectRows(node);
619
+ const all = [...rows.header, ...rows.body];
620
+ if (all.length === 0)
621
+ return "";
622
+ if (hasBlockCells(all))
623
+ return htmlTableToMkdocs(rows);
624
+ const text = (cells) => cells.map((c) => (leafContent(c) || "").replace(/\s*\n\s*/g, " ").replace(/\|/g, "\\|").trim());
625
+ const header = rows.header.length ? text(rows.header[0]) : text(all[0]);
626
+ const body = (rows.header.length ? rows.body : all.slice(1)).map(text);
627
+ const cols = Math.max(header.length, ...body.map((r) => r.length));
628
+ const widths = Array.from({ length: cols }, (_, i) => Math.max(3, header[i]?.length ?? 0, ...body.map((r) => r[i]?.length ?? 0)));
629
+ const line = (cells) => `| ${Array.from({ length: cols }, (_, i) => (cells[i] ?? "").padEnd(widths[i])).join(" | ")} |`;
630
+ return [
631
+ line(header),
632
+ `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
633
+ ...body.map(line),
634
+ ].join("\n");
635
+ }
636
+ /**
637
+ * md_in_html descends only into elements that THEMSELVES carry a `markdown`
638
+ * attribute, so `<table markdown>` with bare `<tbody>`/`<tr>` never reaches
639
+ * the cell: `<td markdown="block">` was emitted and its list rendered as the
640
+ * literal text `- a - b`. Every wrapper on the way down carries the
641
+ * attribute. Cells with block content are `markdown="block"`; one-line cells
642
+ * are `markdown="span"` so a header is not wrapped in `<p>`. A cell whose
643
+ * content is block HTML from the importer is passed through raw.
644
+ */
645
+ function htmlTableToMkdocs(rows) {
646
+ const cell = (c, tag) => {
647
+ const attrs = [];
648
+ const colspan = c.props?.colspan;
649
+ const rowspan = c.props?.rowspan;
650
+ if (colspan && colspan > 1)
651
+ attrs.push(` colspan="${colspan}"`);
652
+ if (rowspan && rowspan > 1)
653
+ attrs.push(` rowspan="${rowspan}"`);
654
+ const html = leafHtml(c);
655
+ if (!c.children?.length && !c.inline?.length && html && BLOCK_HTML.test(html)) {
656
+ return `<${tag}${attrs.join("")}>${relativizeHtmlAssets(html)}</${tag}>`;
657
+ }
658
+ const block = isBlockCell(c);
659
+ const body = block ? (childrenToMkdocs(c) || leafContent(c)) : (leafContent(c) || childrenToMkdocs(c)).replace(/\s*\n\s*/g, " ");
660
+ return block
661
+ ? `<${tag} markdown="block"${attrs.join("")}>\n${body}\n</${tag}>`
662
+ : `<${tag} markdown="span"${attrs.join("")}>${body}</${tag}>`;
663
+ };
664
+ const tr = (cells, tag) => `<tr markdown>\n${cells.map((c) => cell(c, c.type === "th" ? "th" : tag)).join("\n")}\n</tr>`;
665
+ const parts = ["<table markdown>"];
666
+ if (rows.header.length)
667
+ parts.push(`<thead markdown>\n${rows.header.map((r) => tr(r, "th")).join("\n")}\n</thead>`);
668
+ if (rows.body.length)
669
+ parts.push(`<tbody markdown>\n${rows.body.map((r) => tr(r, "td")).join("\n")}\n</tbody>`);
670
+ parts.push("</table>");
671
+ return parts.join("\n");
672
+ }
673
+ // ── Lists ───────────────────────────────────────────────
674
+ /** Python-Markdown needs FOUR-space continuation for nested block content. */
675
+ function listItemBlock(marker, text) {
676
+ const lines = text.split("\n");
677
+ const pad = " ".repeat(Math.max(4, marker.length));
678
+ // A fence on the marker line is not a fence to the superfences
679
+ // preprocessor — the whole block collapsed to an inline code span. Start a
680
+ // fence-first (or table-first) item on the line AFTER the marker.
681
+ if (/^(`{3,}|~{3,}|<table\b|<div\b|\|)/.test(lines[0] ?? "")) {
682
+ // A bare marker renders as a paragraph containing "-"; an empty HTML
683
+ // comment is invisible and gives the item a first line to hang on.
684
+ return [`${marker}<!-- -->`, "", ...lines.map((l) => (l ? `${pad}${l}` : l))].join("\n");
685
+ }
686
+ const first = `${marker}${lines[0] ?? ""}`;
687
+ const rest = lines.slice(1).map((l) => (l ? `${pad}${l}` : l));
688
+ return [first, ...rest].join("\n");
126
689
  }
127
690
  function orderedListToMkdocs(node) {
128
691
  if (!node.children)
129
692
  return "";
130
- return node.children
131
- .map((child, i) => {
132
- const text = nodeToMkdocs(child);
133
- return `${i + 1}. ${text}`;
134
- })
135
- .join("\n");
693
+ const start = listStart(node) ?? 1;
694
+ const items = node.children.map((child, i) => listItemBlock(`${start + i}. `, nodeToMkdocs(child)));
695
+ return items.join(itemSeparator(node));
136
696
  }
137
697
  function unorderedListToMkdocs(node) {
138
698
  if (!node.children)
139
699
  return "";
140
- return node.children
141
- .map((child) => {
142
- const text = nodeToMkdocs(child);
143
- return `- ${text}`;
144
- })
700
+ const items = node.children.map((child) => listItemBlock("- ", nodeToMkdocs(child)));
701
+ return items.join(itemSeparator(node));
702
+ }
703
+ /**
704
+ * Loose lists need a blank line between items. Loose means an item carries a
705
+ * block beyond its own text — NOT merely "has children": the wrapped shape
706
+ * (every HTML-derived importer, the mkdocs walker) puts the item text in a
707
+ * single paragraph child, and treating that as loose put a `<p>` inside
708
+ * every `<li>` on a mkdocs → mkdocs round trip.
709
+ */
710
+ function itemSeparator(list) {
711
+ const loose = (list.children ?? []).some((li) => {
712
+ const kids = li.children ?? [];
713
+ if (kids.length === 0)
714
+ return false;
715
+ if (li.inline?.length)
716
+ return true;
717
+ return kids.length > 1 || !["paragraph", "prose"].includes(kids[0].type);
718
+ });
719
+ return loose ? "\n\n" : "\n";
720
+ }
721
+ function listItemToMkdocs(node) {
722
+ const parts = [];
723
+ const checked = node.props?.checked;
724
+ const box = checked === undefined ? "" : checked ? "[x] " : "[ ] ";
725
+ if (node.inline && node.inline.length > 0) {
726
+ parts.push(box + escapeBlockStart(inlineToMkdocs(node.inline)));
727
+ }
728
+ if (node.children) {
729
+ const kids = node.children.map(nodeToMkdocs).filter(Boolean);
730
+ // A leading paragraph child IS the item text: it goes on the marker line,
731
+ // and the checkbox goes with it — `- [ ]` alone on the marker line renders
732
+ // as the literal characters.
733
+ if (parts.length === 0 && kids.length && node.children[0].type === "paragraph") {
734
+ parts.push(box + kids[0]);
735
+ parts.push(...joinBlocksRendered(node.children.slice(1), kids.slice(1)));
736
+ }
737
+ else {
738
+ parts.push(...joinBlocksRendered(node.children, kids));
739
+ }
740
+ }
741
+ else if (box && parts.length === 0) {
742
+ parts.push(box.trimEnd());
743
+ }
744
+ return parts.filter(Boolean).join("\n\n");
745
+ }
746
+ /** Already-rendered siblings, with the adjacent-list separator applied. */
747
+ function joinBlocksRendered(nodes, rendered) {
748
+ const out = [];
749
+ let prev;
750
+ nodes.forEach((n, i) => {
751
+ const text = rendered[i];
752
+ if (!text)
753
+ return;
754
+ if (prev && ((LIST_TYPES.has(prev.type) && LIST_TYPES.has(n.type)) || (prev.type === "tabs" && n.type === "tabs")))
755
+ out.push("<!-- -->");
756
+ out.push(text);
757
+ prev = n;
758
+ });
759
+ return out;
760
+ }
761
+ // ── Definition lists (native: def_list) ─────────────────
762
+ function deflistToMkdocs(node) {
763
+ if (!node.children?.length)
764
+ return "";
765
+ const out = [];
766
+ for (const child of node.children) {
767
+ if (child.type === "dt") {
768
+ out.push(escapeBlockStart(leafContent(child).replace(/\n+/g, " ").trim()));
769
+ continue;
770
+ }
771
+ if (child.type === "dd") {
772
+ const body = (childrenToMkdocs(child) || leafContent(child)).trim();
773
+ const lines = body.split("\n");
774
+ const first = `: ${lines[0] ?? ""}`;
775
+ const rest = lines.slice(1).map((l) => (l ? ` ${l}` : l));
776
+ out.push([first, ...rest].join("\n"));
777
+ continue;
778
+ }
779
+ const other = nodeToMkdocs(child).trim();
780
+ if (other)
781
+ out.push(other);
782
+ }
783
+ // Term / definition pairs are adjacent; a blank line separates entries.
784
+ return out
785
+ .map((block, i) => (block.startsWith(": ") || i === 0 ? block : `\n${block}`))
145
786
  .join("\n");
146
787
  }
788
+ // ── Steps → ordered list ────────────────────────────────
789
+ function stepsToMkdocs(node) {
790
+ if (!node.children)
791
+ return "";
792
+ report("steps", "steps rendered as an ordered list (MkDocs has no steps component)");
793
+ const items = node.children.map((child, i) => {
794
+ const title = (child.inline && child.inline.length ? inlineToMkdocs(child.inline) : "") ||
795
+ child.props?.title ||
796
+ "";
797
+ const body = childrenToMkdocs(child);
798
+ const text = title ? (body ? `**${title}**\n\n${body}` : `**${title}**`) : body;
799
+ return listItemBlock(`${i + 1}. `, text);
800
+ });
801
+ return items.join("\n\n");
802
+ }
803
+ // ── Cards (Material grid cards) ─────────────────────────
804
+ function cardToMkdocs(node) {
805
+ const title = node.props?.title || "";
806
+ const href = node.props?.href;
807
+ const body = childrenToMkdocs(node) || node.props?.description || "";
808
+ const parts = [];
809
+ if (title)
810
+ parts.push(`**${title}**`);
811
+ if (body)
812
+ parts.push(body);
813
+ if (href)
814
+ parts.push(`[:octicons-arrow-right-24: ${title || "Open"}](${mkdocsHref(href)})`);
815
+ return parts.join("\n\n");
816
+ }
817
+ function cardsToMkdocs(node) {
818
+ if (!node.children?.length)
819
+ return "";
820
+ const items = node.children.map((c) => listItemBlock("- ", nodeToMkdocs(c)));
821
+ return `<div class="grid cards" markdown>\n\n${items.join("\n\n")}\n\n</div>`;
822
+ }
823
+ // ── Misc blocks ─────────────────────────────────────────
147
824
  function htmlContainerToMkdocs(node) {
148
825
  const tag = node.props?.tag ?? "div";
149
826
  const attrs = node.props?.attrs ?? {};
@@ -153,26 +830,91 @@ function htmlContainerToMkdocs(node) {
153
830
  const inner = childrenToMkdocs(node);
154
831
  return `<${tag}${attrStr} markdown>\n\n${inner}\n\n</${tag}>`;
155
832
  }
156
- function childrenToMkdocs(node) {
157
- if (!node.children || node.children.length === 0)
158
- return "";
159
- return node.children.map(nodeToMkdocs).join("\n\n");
833
+ function referencesToMkdocs(node) {
834
+ const entries = (node.props?.entries ?? []);
835
+ return entries
836
+ .map((e) => `[${e.label}]: ${mkdocsHref(e.href)}${e.title ? ` "${e.title}"` : ""}`)
837
+ .join("\n");
160
838
  }
161
- function titleSuffix(variant, title) {
162
- if (title === null)
163
- return ' ""';
164
- if (title === undefined)
165
- return "";
166
- const autoTitle = variant.charAt(0).toUpperCase() + variant.slice(1);
167
- if (title === autoTitle)
839
+ function youtubeToMkdocs(node) {
840
+ const id = node.props?.id || node.props?.videoId || "";
841
+ const title = (node.props?.title ?? "Video").replace(/"/g, "&quot;");
842
+ return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${id}" title="${title}" frameborder="0" allowfullscreen></iframe>`;
843
+ }
844
+ function footnoteDefToMkdocs(node) {
845
+ const label = node.props?.label ?? "1";
846
+ const content = node.inline && node.inline.length > 0 ? inlineToMkdocs(node.inline) : childrenToMkdocs(node);
847
+ const lines = content.split("\n");
848
+ return [`[^${label}]: ${lines[0] ?? ""}`, ...lines.slice(1).map((l) => (l ? ` ${l}` : l))].join("\n");
849
+ }
850
+ // ── API nodes ───────────────────────────────────────────
851
+ function apiSymbolToMkdocs(node) {
852
+ const name = node.props?.name ?? "";
853
+ const kind = node.props?.kind ?? "class";
854
+ const hashes = "#".repeat(node.type === "api-class" ? 3 : 4);
855
+ const sig = node.props?.signature ?? "";
856
+ const basesRaw = node.props?.bases;
857
+ const bases = Array.isArray(basesRaw) ? basesRaw.join(", ") : basesRaw;
858
+ const parts = [`${hashes} ${kind} ${codeSpan(name)}`];
859
+ if (bases)
860
+ parts.push(`Bases: ${codeSpan(bases)}`);
861
+ if (sig)
862
+ parts.push(`\`\`\`python\n${sig}\n\`\`\``);
863
+ const inner = childrenToMkdocs(node);
864
+ if (inner)
865
+ parts.push(inner);
866
+ return parts.join("\n\n");
867
+ }
868
+ function apiParamsToMkdocs(node) {
869
+ const params = (node.props?.params ?? []);
870
+ if (params.length === 0)
168
871
  return "";
169
- return ` "${title}"`;
872
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
873
+ const rows = params.map((p) => {
874
+ let desc = p.docHtml || "";
875
+ if (!desc && p.doc)
876
+ desc = `<p>${esc(p.doc)}</p>`;
877
+ if (!desc && p.description)
878
+ desc = `<p>${esc(p.description)}</p>`;
879
+ const meta = [];
880
+ if (p.type)
881
+ meta.push(`<strong>TYPE:</strong> <code>${esc(p.type)}</code>`);
882
+ if (p.default != null)
883
+ meta.push(`<strong>DEFAULT:</strong> <code>${esc(String(p.default))}</code>`);
884
+ if (p.required)
885
+ meta.push("<strong>REQUIRED</strong>");
886
+ const metaHtml = meta.length ? `<p>${meta.join(" &nbsp;·&nbsp; ")}</p>` : "";
887
+ return `<tr><td><code>${esc(p.name)}</code></td><td>${desc}${metaHtml}</td></tr>`;
888
+ });
889
+ return ["<table>", "<thead><tr><th>PARAMETER</th><th>DESCRIPTION</th></tr></thead>", "<tbody>", ...rows, "</tbody>", "</table>"].join("\n");
170
890
  }
171
- function indent(text, spaces) {
172
- const prefix = " ".repeat(spaces);
173
- return text
174
- .split("\n")
175
- .map((line) => (line ? `${prefix}${line}` : line))
176
- .join("\n");
891
+ function apiDocToMkdocs(node) {
892
+ const html = node.props?.html ?? "";
893
+ return html ? htmlToMkdocs(html) : childrenToMkdocs(node);
894
+ }
895
+ function apiSourceToMkdocs(node) {
896
+ const code = node.props?.code ?? "";
897
+ const file = node.props?.file ?? "";
898
+ const line = node.props?.line;
899
+ const parts = [];
900
+ if (file)
901
+ parts.push(`*Source: ${codeSpan(line ? `${file}:${line}` : file)}*`);
902
+ if (code)
903
+ parts.push(`\`\`\`python\n${code.replace(/\n$/, "")}\n\`\`\``);
904
+ else {
905
+ const inner = childrenToMkdocs(node);
906
+ if (inner)
907
+ parts.push(inner);
908
+ }
909
+ return parts.join("\n\n");
910
+ }
911
+ // ── Nav helpers shared with the project emitter ─────────
912
+ /** Normalise an href or slug to the slug form pages are keyed by. */
913
+ export function hrefToSlugKey(href) {
914
+ return href
915
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, "")
916
+ .replace(/[?#].*$/, "")
917
+ .replace(/^\/+|\/+$/g, "")
918
+ .replace(/\.md$/, "");
177
919
  }
178
920
  //# sourceMappingURL=to-mkdocs.js.map