@dogsbay/format-openknowledge 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,738 @@
1
+ import { walkInline, applyTextFlags, renderLeaf, indent, prefixLines, pickCodeFence, stripHtml, inlineHtmlToMarkdown, headingLevel, headingText, slugifyHeadingText, inlinePlainText, codeSpan, ExportLedger, } from "@dogsbay/serialize-core";
2
+ /** GFM alert types OpenKnowledge renders natively (remark-github-alerts). */
3
+ const VARIANT_TO_ALERT = {
4
+ note: "note",
5
+ info: "note",
6
+ question: "note",
7
+ abstract: "note",
8
+ tip: "tip",
9
+ hint: "tip",
10
+ success: "tip",
11
+ example: "tip",
12
+ important: "important",
13
+ warning: "warning",
14
+ attention: "warning",
15
+ danger: "caution",
16
+ error: "caution",
17
+ failure: "caution",
18
+ bug: "caution",
19
+ caution: "caution",
20
+ };
21
+ export function treeToOpenKnowledge(nodes, options = {}) {
22
+ const ctx = {
23
+ href: options.href ?? defaultHref,
24
+ ledger: options.ledger ?? new ExportLedger(),
25
+ page: options.page ?? "",
26
+ depth: 0,
27
+ };
28
+ return nodes.map((n) => nodeToMdx(n, ctx)).filter(Boolean).join("\n\n");
29
+ }
30
+ /** Root-absolute `.md` shape without page knowledge: `/a/b/` → `/a/b.md`, `#x` kept, external kept. */
31
+ export function defaultHref(href) {
32
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("//"))
33
+ return href;
34
+ const [pathPart, frag] = splitFragment(href);
35
+ let p = pathPart.replace(/\/+$/, "");
36
+ if (!p)
37
+ return frag ? `#${frag}` : "/";
38
+ if (!p.startsWith("/"))
39
+ p = `/${p}`;
40
+ if (!/\.[a-z0-9]+$/i.test(p))
41
+ p = `${p}.md`;
42
+ return frag ? `${p}#${frag}` : p;
43
+ }
44
+ export function splitFragment(href) {
45
+ const i = href.indexOf("#");
46
+ return i < 0 ? [href, ""] : [href.slice(0, i), href.slice(i + 1)];
47
+ }
48
+ /* ── MDX text escaping ─────────────────────────────────────────────────── */
49
+ /**
50
+ * MDX treats `{`/`}` as expressions and `<` followed by anything but
51
+ * whitespace as a tag start (`<3`, `<=`, `<-` are all errors); a backslash
52
+ * escape renders literally in both the strict MDX stack and OpenKnowledge's
53
+ * (which restores backslash escapes). Backslashes are escaped FIRST so a
54
+ * literal `\{` in prose does not become `\\{` (= escaped backslash + a LIVE
55
+ * expression). Text starting a paragraph with `import`/`export` is ESM in
56
+ * strict MDX — see `noEsmStart`.
57
+ */
58
+ export function escapeMdxText(text) {
59
+ return text
60
+ .replace(/\\/g, "\\\\")
61
+ .replace(/[{}]/g, "\\$&")
62
+ // a `<` at the END of a text node is followed by the next inline node (a code span, a link…): escape it too
63
+ .replace(/<(?=\S|$)/g, "\\<");
64
+ }
65
+ /** A paragraph may not begin with `import`/`export` in strict MDX (ESM); a char-ref renders the same letter. */
66
+ function noEsmStart(text) {
67
+ return text.replace(/^(import|export)(?=\s|$)/, (m) => (m[0] === "i" ? "&#105;" + m.slice(1) : "&#101;" + m.slice(1)));
68
+ }
69
+ /**
70
+ * JSX attribute value. OpenKnowledge does NOT decode HTML entities inside
71
+ * JSX attributes (its `destructureAttrs` copies the raw string; only
72
+ * text/url/title/alt are restored), and JSX string attributes have no
73
+ * backslash escapes. So: a plain `"…"` when the value is simple, otherwise
74
+ * an EXPRESSION holding a JSON string with `{ } < > & "` written as
75
+ * `\uXXXX` — acorn accepts it and OpenKnowledge `JSON.parse`s it back to
76
+ * the exact value (verified against both parsers).
77
+ */
78
+ function attr(value) {
79
+ const v = value.replace(/\r?\n/g, " ");
80
+ if (!/["\\{}<>&]/.test(v))
81
+ return `"${v}"`;
82
+ const json = JSON.stringify(v).replace(/[{}<>&"]/g, (ch) => (ch === '"' ? '"' : `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`));
83
+ // JSON.stringify keeps the outer quotes; inner quotes are already \"-escaped
84
+ return `{${json}}`;
85
+ }
86
+ /** Fence meta `title="…"` is never entity-decoded: drop what it cannot carry. */
87
+ function metaTitle(value) {
88
+ const clean = value.replace(/[{}"]/g, "").replace(/\r?\n/g, " ").trim();
89
+ return `title="${clean}"`;
90
+ }
91
+ /* ── Block dispatcher ──────────────────────────────────────────────────── */
92
+ function nodeToMdx(node, ctx) {
93
+ switch (node.type) {
94
+ case "frontmatter":
95
+ // the project layer writes page frontmatter from ExportPage; an inline
96
+ // frontmatter node inside a tree is source noise, not content
97
+ return "";
98
+ case "references":
99
+ return referencesToMdx(node, ctx);
100
+ case "heading":
101
+ return headingToMdx(node, ctx);
102
+ case "paragraph":
103
+ return noEsmStart(leaf(node, ctx));
104
+ case "prose":
105
+ return noEsmStart(proseToMdx(node, ctx));
106
+ case "callout":
107
+ return calloutToMdx(node, ctx);
108
+ case "details":
109
+ return detailsToMdx(node, ctx);
110
+ case "tabs":
111
+ return tabsToMdx(node, ctx);
112
+ case "code":
113
+ return codeToMdx(node, ctx);
114
+ case "diagram":
115
+ return diagramToMdx(node);
116
+ case "math-block":
117
+ return `$$\n${String(node.props?.latex ?? "")}\n$$`;
118
+ case "table":
119
+ return tableToMdx(node, ctx);
120
+ case "blockquote":
121
+ return prefixLines(children(node, ctx), "> ");
122
+ case "ordered-list":
123
+ return orderedListToMdx(node, ctx);
124
+ case "unordered-list":
125
+ return unorderedListToMdx(node, ctx);
126
+ case "list-item":
127
+ return listItemToMdx(node, ctx);
128
+ case "deflist":
129
+ return deflistToMdx(node, ctx);
130
+ case "dt":
131
+ return `**${leaf(node, ctx)}**`;
132
+ case "dd":
133
+ return leaf(node, ctx);
134
+ case "steps":
135
+ return stepsToMdx(node, ctx);
136
+ case "step":
137
+ return children(node, ctx);
138
+ case "substeps":
139
+ return children(node, ctx);
140
+ case "annotation-list":
141
+ return children(node, ctx);
142
+ case "link-button": {
143
+ // navigational: a plain link feeds their graph/backlinks (§3.3)
144
+ const href = ctx.href(String(node.props?.href ?? ""));
145
+ const text = children(node, ctx) || leaf(node, ctx) || href;
146
+ ctx.ledger.record({ feature: "link-button", page: ctx.page, action: "degraded", detail: "rendered as a plain markdown link" });
147
+ return `[${text}](${href})`;
148
+ }
149
+ case "hr":
150
+ return "---";
151
+ case "html":
152
+ ctx.ledger.record({ feature: "html", page: ctx.page, action: "degraded", detail: "raw HTML converted to markdown (MDX cannot take unbalanced HTML)" });
153
+ return htmlToMdx(node.html ?? "");
154
+ case "html-container":
155
+ return children(node, ctx);
156
+ case "figure":
157
+ return figureToMdx(node, ctx);
158
+ case "card":
159
+ return cardToMdx(node, ctx);
160
+ case "cards":
161
+ case "grid":
162
+ return cardsToMdx(node, ctx);
163
+ case "youtube":
164
+ return youtubeToMdx(node);
165
+ case "api-class":
166
+ case "api-member":
167
+ return apiSymbolToMdx(node, ctx);
168
+ case "api-doc":
169
+ return apiDocToMdx(node, ctx);
170
+ case "api-params":
171
+ return apiParamsToMdx(node, ctx);
172
+ case "api-source":
173
+ return apiSourceToMdx(node, ctx);
174
+ case "footnote-list":
175
+ return (node.children ?? []).map((c) => nodeToMdx(c, ctx)).join("\n");
176
+ case "footnote-def": {
177
+ const label = String(node.props?.label ?? "1");
178
+ const body = node.inline?.length ? inlineToMdx(node.inline, ctx) : children(node, ctx);
179
+ const [first, ...rest] = body.split("\n");
180
+ return rest.length ? `[^${label}]: ${first}\n${rest.map((l) => (l ? ` ${l}` : l)).join("\n")}` : `[^${label}]: ${first}`;
181
+ }
182
+ case "mdx-raw":
183
+ // an importer's never-drop raw JSX/MDX: OpenKnowledge's wildcard renders it as an editable raw-MDX block
184
+ ctx.ledger.record({ feature: "mdx-raw", page: ctx.page, action: "degraded", detail: `raw MDX <${String(node.props?.component ?? "…")}> passed through to the wildcard` });
185
+ return String(node.props?.source ?? "");
186
+ case "accordion-group":
187
+ return children(node, ctx);
188
+ case "related": {
189
+ const title = String(node.props?.title ?? "").trim();
190
+ const body = children(node, ctx);
191
+ if (!title)
192
+ return body;
193
+ return `**${escapeMdxText(title.replace(/([*\\])/g, "\\$1"))}**\n\n${body}`;
194
+ }
195
+ default:
196
+ ctx.ledger.record({ feature: node.type, page: ctx.page, action: node.children || node.html || node.inline ? "degraded" : "dropped", detail: `no OpenKnowledge mapping for "${node.type}"; children emitted` });
197
+ if (node.html)
198
+ return htmlToMdx(node.html);
199
+ if (node.children)
200
+ return children(node, ctx);
201
+ if (node.inline)
202
+ return inlineToMdx(node.inline, ctx);
203
+ return "";
204
+ }
205
+ }
206
+ /* ── Inline ─────────────────────────────────────────────────────────────── */
207
+ const inlineEmitters = (ctx) => ({
208
+ text: (node) => applyTextFlags(escapeMdxText(node.text), node, {
209
+ bold: (s) => `**${s}**`,
210
+ italic: (s) => `*${s}*`,
211
+ strike: (s) => `~~${s}~~`,
212
+ }),
213
+ link: (node, c) => {
214
+ const title = node.title ? ` "${node.title.replace(/"/g, '\\"')}"` : "";
215
+ return `[${c.recurse(node.children)}](${urlSafe(ctx.href(node.href))}${title})`;
216
+ },
217
+ image: (node) => `![${(node.alt ?? "").replace(/[[\]]/g, "\\$&")}](${urlSafe(node.src)})`,
218
+ code: (node) => codeSpan(node.text),
219
+ highlight: (node, c) => `==${c.recurse(node.children)}==`,
220
+ "footnote-ref": (node) => `[^${node.label}]`,
221
+ // OpenKnowledge shows a literal `<kbd>` (lowercase tags other than img/video/audio/mark are sentinelled): keys as code spans
222
+ kbd: (node) => {
223
+ ctx.ledger.record({ feature: "kbd", page: ctx.page, action: "degraded", detail: "keys rendered as code spans (no <kbd> in OpenKnowledge)" });
224
+ return node.keys.map((k) => codeSpan(k)).join("+");
225
+ },
226
+ math: (node) => `$${node.latex}$`,
227
+ icon: (node) => {
228
+ ctx.ledger.record({ feature: "icon", page: ctx.page, action: "degraded", detail: `icon ":${node.name}:" rendered as text` });
229
+ return escapeMdxText(`:${node.name}:`);
230
+ },
231
+ "html-inline": (node) => inlineHtmlToMdx(node.html ?? ""),
232
+ break: () => " \n",
233
+ });
234
+ function inlineToMdx(nodes, ctx) {
235
+ return walkInline(pairInlineHtml(nodes, ctx), { emitters: inlineEmitters(ctx) });
236
+ }
237
+ /**
238
+ * markdown-it hands an inline `<abbr title="…">ORM</abbr>` over as THREE
239
+ * nodes — html-inline open, text, html-inline close — and stripping each
240
+ * tag alone loses the semantics (43 <abbr> + 50 <dfn> on FastAPI). Pair
241
+ * open/close across the array and rewrite the span: <a href> → link,
242
+ * <b>/<strong> → bold, <i>/<em> → italic, <code> → code span, <abbr>/<dfn
243
+ * title> → "text (title)", <s>/<del> → strike, other known tags → unwrapped.
244
+ * OpenKnowledge shows lowercase tags other than img/video/audio/mark as
245
+ * literal text, so JSX passthrough is not an option here.
246
+ */
247
+ function pairInlineHtml(nodes, ctx) {
248
+ const out = [];
249
+ for (let i = 0; i < nodes.length; i++) {
250
+ const n = nodes[i];
251
+ if (n.type !== "html-inline") {
252
+ out.push(n);
253
+ continue;
254
+ }
255
+ const open = /^<([a-z][a-z0-9]*)(\s[^>]*)?>$/i.exec((n.html ?? "").trim());
256
+ if (!open) {
257
+ out.push(n);
258
+ continue;
259
+ }
260
+ const tag = open[1].toLowerCase();
261
+ if (!PAIRABLE.has(tag)) {
262
+ out.push(n);
263
+ continue;
264
+ }
265
+ // find the matching close at the same depth
266
+ let depth = 0;
267
+ let close = -1;
268
+ for (let j = i + 1; j < nodes.length; j++) {
269
+ const m = nodes[j];
270
+ if (m.type !== "html-inline")
271
+ continue;
272
+ const h = (m.html ?? "").trim();
273
+ if (new RegExp(`^<${tag}(\\s[^>]*)?>$`, "i").test(h))
274
+ depth++;
275
+ else if (new RegExp(`^</${tag}\\s*>$`, "i").test(h)) {
276
+ if (depth === 0) {
277
+ close = j;
278
+ break;
279
+ }
280
+ depth--;
281
+ }
282
+ }
283
+ if (close < 0) {
284
+ out.push(n);
285
+ continue;
286
+ }
287
+ const inner = pairInlineHtml(nodes.slice(i + 1, close), ctx);
288
+ const attrs = open[2] ?? "";
289
+ const attrOf = (name) => new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(attrs)?.slice(1).find((v) => v !== undefined);
290
+ const flag = (f) => inner.map((x) => (x.type === "text" || x.type === "code" ? { ...x, [f]: true } : x));
291
+ switch (tag) {
292
+ case "a": {
293
+ const href = attrOf("href") ?? "";
294
+ out.push({ type: "link", href, children: inner });
295
+ break;
296
+ }
297
+ case "b":
298
+ case "strong":
299
+ out.push(...flag("bold"));
300
+ break;
301
+ case "i":
302
+ case "em":
303
+ out.push(...flag("italic"));
304
+ break;
305
+ case "s":
306
+ case "del":
307
+ case "strike":
308
+ out.push(...flag("strikethrough"));
309
+ break;
310
+ case "code":
311
+ case "kbd":
312
+ case "samp":
313
+ case "tt":
314
+ out.push({ type: "code", text: inner.map((x) => inlinePlainText(x, (t) => t)).join("") });
315
+ break;
316
+ case "abbr":
317
+ case "dfn": {
318
+ const title = attrOf("title");
319
+ out.push(...inner);
320
+ if (title) {
321
+ ctx.ledger.record({ feature: "html-inline", page: ctx.page, action: "degraded", detail: `<${tag} title> expanded inline as "text (title)"` });
322
+ out.push({ type: "text", text: ` (${title})` });
323
+ }
324
+ break;
325
+ }
326
+ case "mark":
327
+ out.push({ type: "highlight", children: inner });
328
+ break;
329
+ default:
330
+ ctx.ledger.record({ feature: "html-inline", page: ctx.page, action: "degraded", detail: `<${tag}> unwrapped (no OpenKnowledge inline equivalent)` });
331
+ out.push(...inner);
332
+ }
333
+ i = close;
334
+ }
335
+ return out;
336
+ }
337
+ const PAIRABLE = new Set(["a", "b", "strong", "i", "em", "s", "del", "strike", "code", "kbd", "samp", "tt", "abbr", "dfn", "mark", "sup", "sub", "span", "u", "small", "cite", "var", "q"]);
338
+ /** A URL in `[](…)` may not contain spaces or unbalanced parens: percent-encode them. */
339
+ function urlSafe(url) {
340
+ return url.replace(/ /g, "%20").replace(/\(/g, "%28").replace(/\)/g, "%29");
341
+ }
342
+ /**
343
+ * Inline HTML: convert the unambiguous tags to markdown, strip the rest
344
+ * (MDX would choke on `<br>`). A lone tag-shaped placeholder that is not
345
+ * a known HTML element (`<cluster-name>`, `<your-token>`) is PROSE the
346
+ * writer meant literally — keep it, escaped, instead of stripping it.
347
+ */
348
+ function inlineHtmlToMdx(html) {
349
+ const placeholder = /^<([a-z][a-z0-9-]*)>$/i.exec(html.trim());
350
+ if (placeholder && !KNOWN_HTML.has(placeholder[1].toLowerCase()))
351
+ return escapeMdxText(html.trim());
352
+ return escapeMdxText(stripHtml(inlineHtmlToMarkdown(html)));
353
+ }
354
+ const KNOWN_HTML = new Set(["a", "abbr", "b", "bdi", "bdo", "br", "cite", "code", "data", "del", "dfn", "em", "i", "img", "ins", "kbd", "mark", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "p", "div", "pre", "ul", "ol", "li", "table", "tr", "td", "th", "thead", "tbody", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "details", "summary", "figure", "figcaption", "video", "audio", "source", "iframe", "button", "input", "label", "select", "option", "textarea", "form", "nav", "section", "article", "aside", "header", "footer", "main", "dl", "dt", "dd"]);
355
+ function htmlToMdx(html) {
356
+ const cleaned = html
357
+ .replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, "")
358
+ // block boundaries become whitespace so `</td><td>` cannot glue words
359
+ .replace(/<\/(td|th)\s*>\s*<(td|th)\b/gi, "</$1> | <$2")
360
+ .replace(/<\/(tr|p|div|li|h[1-6]|blockquote|section|article|dd|dt)\s*>/gi, "$&\n")
361
+ .replace(/<br\s*\/?>/gi, "\n");
362
+ const text = escapeMdxText(stripHtml(inlineHtmlToMarkdown(cleaned)).replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim());
363
+ // converted HTML yields several paragraphs; each may not start with import/export (ESM in strict MDX)
364
+ return text.split(/\n\n/).map(noEsmStart).join("\n\n");
365
+ }
366
+ function proseToMdx(node, ctx) {
367
+ if (node.inline)
368
+ return inlineToMdx(node.inline, ctx);
369
+ if (node.html)
370
+ return htmlToMdx(node.html);
371
+ return "";
372
+ }
373
+ /* ── Callouts / details / tabs (§3.1) ──────────────────────────────────── */
374
+ function calloutToMdx(node, ctx) {
375
+ const variant = String(node.props?.variant ?? node.props?.type ?? "note").toLowerCase();
376
+ const alert = VARIANT_TO_ALERT[variant] ?? "note";
377
+ if (alert !== variant)
378
+ ctx.ledger.record({ feature: "callout", page: ctx.page, action: "degraded", detail: `variant "${variant}" mapped to "${alert}" (GFM alert enum)` });
379
+ const title = node.props?.title;
380
+ const autoTitle = variant.charAt(0).toUpperCase() + variant.slice(1);
381
+ const inner = children(node, ctx);
382
+ if (title && title !== autoTitle) {
383
+ // a custom title needs the component (GFM alerts have no title slot); a JSX string is not markdown
384
+ return `<Callout type=${attr(alert)} title=${attr(plainTitle(title))}>\n\n${inner}\n\n</Callout>`;
385
+ }
386
+ const opener = `> [!${alert.toUpperCase()}]`;
387
+ if (!inner)
388
+ return opener;
389
+ return `${opener}\n${prefixLines(inner, "> ")}`;
390
+ }
391
+ /** Markdown emphasis/code markers have no meaning inside a JSX string attribute. */
392
+ function plainTitle(title) {
393
+ return title.replace(/\*\*|__|`|~~/g, "").replace(/(^|\s)[*_](\S)/g, "$1$2").replace(/(\S)[*_](\s|$)/g, "$1$2").trim();
394
+ }
395
+ function detailsToMdx(node, ctx) {
396
+ const title = plainTitle(String(node.props?.title ?? "Details"));
397
+ const open = node.props?.open === true || node.props?.open === "" || node.props?.open === "true";
398
+ const variant = node.props?.variant ?? node.props?.type;
399
+ if (variant && variant !== "note")
400
+ ctx.ledger.record({ feature: "details", page: ctx.page, action: "degraded", detail: `fold variant "${String(variant)}" not representable (Accordion has no variant)` });
401
+ const inner = children(node, ctx);
402
+ return `<Accordion title=${attr(title)}${open ? " defaultOpen" : ""}>\n\n${inner}\n\n</Accordion>`;
403
+ }
404
+ function tabsToMdx(node, ctx) {
405
+ const tabs = node.children ?? [];
406
+ const inner = ctx.depth++;
407
+ const body = tabs
408
+ .map((tab) => {
409
+ const label = String(tab.props?.title || tab.props?.label || tab.props?.value || "Tab");
410
+ const content = children(tab, ctx);
411
+ return `<Tab label=${attr(label)}>\n\n${content}\n\n</Tab>`;
412
+ })
413
+ .join("\n\n");
414
+ ctx.depth = inner;
415
+ return `<Tabs>\n\n${body}\n\n</Tabs>`;
416
+ }
417
+ /* ── Code (§2.3: plain fences, never an envelope) ───────────────────────── */
418
+ function codeToMdx(node, ctx) {
419
+ const lang = String(node.props?.lang || node.props?.language || "");
420
+ const code = String(node.props?.code ?? node.html ?? "").replace(/\n$/, "");
421
+ const title = node.props?.title;
422
+ const highlights = node.props?.highlights;
423
+ const annotations = node.props?.annotations;
424
+ const fence = fenceFor(code, ctx);
425
+ const meta = [];
426
+ if (title)
427
+ meta.push(metaTitle(title));
428
+ if (highlights) {
429
+ ctx.ledger.record({ feature: "code", page: ctx.page, action: "degraded", detail: `line highlights "${highlights}" have no OpenKnowledge affordance (block kept)` });
430
+ }
431
+ if (annotations && Object.keys(annotations).length) {
432
+ ctx.ledger.record({ feature: "code", page: ctx.page, action: "degraded", detail: `${Object.keys(annotations).length} code annotation(s) have no OpenKnowledge affordance (block kept)` });
433
+ }
434
+ const info = [lang, ...meta].filter(Boolean).join(" ");
435
+ return `${fence}${info}\n${code}\n${fence}`;
436
+ }
437
+ /** Fence for a code body: longest-run-safe. JSX containers are not fences, so no alternation is needed inside `<Tabs>`. */
438
+ function fenceFor(code, _ctx) {
439
+ return pickCodeFence(code);
440
+ }
441
+ function diagramToMdx(node) {
442
+ const lang = String(node.props?.lang ?? "mermaid");
443
+ const code = String(node.props?.code ?? "").replace(/\n$/, "");
444
+ const f = pickCodeFence(code);
445
+ return `${f}${lang}\n${code}\n${f}`;
446
+ }
447
+ /* ── Tables (GFM) ───────────────────────────────────────────────────────── */
448
+ function tableToMdx(node, ctx) {
449
+ const rows = [];
450
+ let headerCount = 0;
451
+ const cell = (c) => (children(c, ctx) || proseToMdx(c, ctx)).replace(/\n+/g, " ").replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
452
+ for (const child of node.children ?? []) {
453
+ if ((child.type === "thead" || child.type === "tbody") && child.children) {
454
+ for (const tr of child.children) {
455
+ if (tr.type !== "tr" || !tr.children)
456
+ continue;
457
+ const cells = tr.children.map(cell);
458
+ rows.push(cells);
459
+ if (child.type === "thead")
460
+ headerCount = cells.length;
461
+ }
462
+ }
463
+ else if (child.type === "tr" && child.children) {
464
+ const cells = child.children.map(cell);
465
+ rows.push(cells);
466
+ if (headerCount === 0 && child.children.some((c) => c.type === "th"))
467
+ headerCount = cells.length;
468
+ }
469
+ }
470
+ if (!rows.length)
471
+ return "";
472
+ const colCount = Math.max(headerCount, ...rows.map((r) => r.length));
473
+ const widths = Array(colCount).fill(3);
474
+ for (const row of rows)
475
+ row.forEach((c, i) => (widths[i] = Math.max(widths[i], c.length)));
476
+ const lines = [];
477
+ rows.forEach((row, r) => {
478
+ const padded = row.map((c, i) => c.padEnd(widths[i]));
479
+ while (padded.length < colCount)
480
+ padded.push(" ".repeat(widths[padded.length]));
481
+ lines.push(`| ${padded.join(" | ")} |`);
482
+ if (r === 0)
483
+ lines.push(`| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`);
484
+ });
485
+ const caption = node.props?.caption;
486
+ if (caption) {
487
+ ctx.ledger.record({ feature: "table", page: ctx.page, action: "degraded", detail: "caption rendered as italic text below the table" });
488
+ lines.push("", `*${escapeMdxText(caption)}*`);
489
+ }
490
+ return lines.join("\n");
491
+ }
492
+ /* ── Lists ──────────────────────────────────────────────────────────────── */
493
+ function orderedListToMdx(node, ctx) {
494
+ const start = node.props?.start || 1;
495
+ return (node.children ?? [])
496
+ .map((child, i) => {
497
+ const marker = `${start + i}. `;
498
+ const text = nodeToMdx(child, ctx);
499
+ const [first, ...rest] = text.split("\n");
500
+ const pad = " ".repeat(marker.length);
501
+ return rest.length ? `${marker}${first}\n${rest.map((l) => (l ? pad + l : l)).join("\n")}` : `${marker}${first}`;
502
+ })
503
+ .join("\n");
504
+ }
505
+ function unorderedListToMdx(node, ctx) {
506
+ return (node.children ?? [])
507
+ .map((child) => {
508
+ const text = nodeToMdx(child, ctx);
509
+ const [first, ...rest] = text.split("\n");
510
+ return rest.length ? `- ${first}\n${rest.map((l) => (l ? ` ${l}` : l)).join("\n")}` : `- ${first}`;
511
+ })
512
+ .join("\n");
513
+ }
514
+ function listItemToMdx(node, ctx) {
515
+ const parts = [];
516
+ if (node.inline?.length)
517
+ parts.push(inlineToMdx(node.inline, ctx));
518
+ if (node.children)
519
+ parts.push(...node.children.map((c) => nodeToMdx(c, ctx)).filter(Boolean));
520
+ return parts.filter(Boolean).join("\n\n");
521
+ }
522
+ /* ── Definition lists: readable markdown, never a code block ────────────── */
523
+ /**
524
+ * `- **term**` with the definition body nested under it. OpenKnowledge has no
525
+ * `<dl>` component and no `: definition` syntax; a JSX wildcard block would
526
+ * turn PROSE into an opaque raw-MDX box for the corpus's most common
527
+ * construct. A term list keeps it readable, editable and re-importable
528
+ * (as a list — declared `partial`).
529
+ */
530
+ function deflistToMdx(node, ctx) {
531
+ ctx.ledger.record({ feature: "deflist", page: ctx.page, action: "degraded", detail: "definition list rendered as a bulleted term list (no <dl> in OpenKnowledge)" });
532
+ const items = [];
533
+ let current = null;
534
+ for (const child of node.children ?? []) {
535
+ if (child.type === "dt") {
536
+ if (current)
537
+ items.push(renderTerm(current));
538
+ current = { term: leaf(child, ctx).trim(), defs: [] };
539
+ }
540
+ else if (child.type === "dd") {
541
+ const body = (children(child, ctx) || leaf(child, ctx)).trim();
542
+ if (!current)
543
+ current = { term: "", defs: [] };
544
+ if (body)
545
+ current.defs.push(body);
546
+ }
547
+ else {
548
+ const other = nodeToMdx(child, ctx).trim();
549
+ if (!other)
550
+ continue;
551
+ if (!current)
552
+ current = { term: "", defs: [] };
553
+ current.defs.push(other);
554
+ }
555
+ }
556
+ if (current)
557
+ items.push(renderTerm(current));
558
+ return items.join("\n");
559
+ }
560
+ function renderTerm(t) {
561
+ const head = t.term ? `- **${t.term}**` : "-";
562
+ if (!t.defs.length)
563
+ return head;
564
+ return `${head}\n\n${indent(t.defs.join("\n\n"), 2)}`;
565
+ }
566
+ /* ── Steps / cards (wildcard JSX, Fumadocs vocabulary) ─────────────────── */
567
+ function stepsToMdx(node, ctx) {
568
+ ctx.ledger.record({ feature: "steps", page: ctx.page, action: "degraded", detail: "<Steps> lands on OpenKnowledge's wildcard (editable raw MDX, not rendered)" });
569
+ const inner = ctx.depth++;
570
+ const body = (node.children ?? [])
571
+ .map((step) => {
572
+ const title = (step.inline?.length ? step.inline.map((n) => inlinePlainText(n, (t) => t)).join("") : "") || String(step.props?.title ?? "");
573
+ const kids = step.children ?? [];
574
+ const firstText = kids[0] ? headingText(kids[0]).trim() : "";
575
+ const body = title && firstText && firstText === title.trim() ? kids.slice(1) : kids;
576
+ const content = body.map((c) => nodeToMdx(c, ctx)).filter(Boolean).join("\n\n");
577
+ const open = title ? `<Step title=${attr(title)}>` : "<Step>";
578
+ return content ? `${open}\n\n${content}\n\n</Step>` : `${open}</Step>`;
579
+ })
580
+ .join("\n\n");
581
+ ctx.depth = inner;
582
+ return `<Steps>\n\n${body}\n\n</Steps>`;
583
+ }
584
+ function cardToMdx(node, ctx) {
585
+ const title = String(node.props?.title ?? "");
586
+ const href = node.props?.href;
587
+ const icon = node.props?.icon;
588
+ const content = children(node, ctx);
589
+ const props = [title ? `title=${attr(title)}` : "", href ? `href=${attr(ctx.href(href))}` : "", icon ? `icon=${attr(icon)}` : ""].filter(Boolean).join(" ");
590
+ const open = props ? `<Card ${props}>` : "<Card>";
591
+ return content ? `${open}\n\n${content}\n\n</Card>` : `${open}</Card>`;
592
+ }
593
+ function cardsToMdx(node, ctx) {
594
+ ctx.ledger.record({ feature: node.type, page: ctx.page, action: "degraded", detail: "<Cards> lands on OpenKnowledge's wildcard (editable raw MDX, not rendered)" });
595
+ const inner = ctx.depth++;
596
+ const body = (node.children ?? []).map((c) => nodeToMdx(c, ctx)).filter(Boolean).join("\n\n");
597
+ ctx.depth = inner;
598
+ return `<Cards>\n\n${body}\n\n</Cards>`;
599
+ }
600
+ /* ── Media ──────────────────────────────────────────────────────────────── */
601
+ function figureToMdx(node, ctx) {
602
+ const caption = node.props?.caption;
603
+ const findImage = (n) => {
604
+ const hit = (n.inline ?? []).find((i) => i.type === "image");
605
+ if (hit)
606
+ return hit;
607
+ for (const c of n.children ?? []) {
608
+ const h = findImage(c);
609
+ if (h)
610
+ return h;
611
+ }
612
+ if (n.html) {
613
+ const m = /<img\b[^>]*\bsrc="([^"]+)"[^>]*(?:\balt="([^"]*)")?/i.exec(n.html);
614
+ if (m)
615
+ return { src: m[1], alt: m[2] ?? "" };
616
+ }
617
+ return undefined;
618
+ };
619
+ const img = findImage(node);
620
+ if (img?.src && caption) {
621
+ return `<Image src=${attr(img.src)} alt=${attr(img.alt ?? "")} caption=${attr(caption)} />`;
622
+ }
623
+ const inner = children(node, ctx);
624
+ return caption ? `${inner}\n\n*${escapeMdxText(caption)}*` : inner;
625
+ }
626
+ function youtubeToMdx(node) {
627
+ const id = String(node.props?.id || node.props?.videoId || "");
628
+ return `<Video src=${attr(`https://www.youtube.com/watch?v=${id}`)} />`;
629
+ }
630
+ /* ── API nodes (same shape as the Obsidian target: markdown + an HTML-free table) ── */
631
+ function apiSymbolToMdx(node, ctx) {
632
+ const name = String(node.props?.name ?? "");
633
+ const kind = String(node.props?.kind ?? "class");
634
+ const level = node.type === "api-class" ? 3 : 4;
635
+ const sig = String(node.props?.signature ?? "");
636
+ const basesRaw = node.props?.bases;
637
+ const bases = Array.isArray(basesRaw) ? basesRaw.join(", ") : basesRaw;
638
+ const parts = [`${"#".repeat(level)} ${kind} ${codeSpan(name)}`];
639
+ if (bases)
640
+ parts.push(`Bases: ${codeSpan(bases)}`);
641
+ if (sig) {
642
+ const f = pickCodeFence(sig);
643
+ parts.push(`${f}python\n${sig}\n${f}`);
644
+ }
645
+ if (node.children)
646
+ parts.push(children(node, ctx));
647
+ return parts.filter(Boolean).join("\n\n");
648
+ }
649
+ function apiParamsToMdx(node, ctx) {
650
+ const params = (node.props?.params ?? []);
651
+ if (!params.length)
652
+ return "";
653
+ ctx.ledger.record({ feature: "api-params", page: ctx.page, action: "degraded", detail: "parameter table flattened to a GFM table (one line per cell)" });
654
+ const rows = params.map((p) => {
655
+ const meta = [p.type ? `type: \`${p.type}\`` : "", p.default != null ? `default: \`${String(p.default)}\`` : "", p.required ? "required" : ""].filter(Boolean).join(" · ");
656
+ const desc = [escapeMdxText(String(p.doc ?? p.description ?? "")).replace(/\s+/g, " ").trim(), meta].filter(Boolean).join(" — ");
657
+ return `| \`${p.name}\` | ${desc.replace(/\|/g, "\\|")} |`;
658
+ });
659
+ return ["| Parameter | Description |", "| --- | --- |", ...rows].join("\n");
660
+ }
661
+ function apiDocToMdx(node, ctx) {
662
+ const html = String(node.props?.html ?? "");
663
+ return html ? htmlToMdx(html) : children(node, ctx);
664
+ }
665
+ function apiSourceToMdx(node, ctx) {
666
+ const code = String(node.props?.code ?? "");
667
+ const file = String(node.props?.file ?? "");
668
+ const line = node.props?.line;
669
+ const parts = [];
670
+ if (file)
671
+ parts.push(`*Source: \`${line ? `${file}:${line}` : file}\`*`);
672
+ if (code) {
673
+ const body = code.replace(/\n$/, "");
674
+ const f = pickCodeFence(body);
675
+ parts.push(`${f}python\n${body}\n${f}`);
676
+ }
677
+ else if (node.children?.length)
678
+ parts.push(children(node, ctx));
679
+ return parts.join("\n\n");
680
+ }
681
+ /* ── Headings / references ──────────────────────────────────────────────── */
682
+ /**
683
+ * The headings a page WILL emit, in emit order, as OpenKnowledge will see
684
+ * their text — headings nested in callouts/tabs/lists included, and the
685
+ * `### class \`name\`` lines api-class/api-member emit (whose textContent
686
+ * is "class name"). Anchors must be computed from THIS, not from an
687
+ * importer's `page.headings` (top-level only, importer-defined text).
688
+ */
689
+ export function collectEmittedHeadings(nodes) {
690
+ const out = [];
691
+ const walk = (n) => {
692
+ if (n.type === "heading") {
693
+ out.push({ slug: String(n.props?.slug ?? ""), text: headingText(n) });
694
+ }
695
+ else if (n.type === "api-class" || n.type === "api-member") {
696
+ const name = String(n.props?.name ?? "");
697
+ const kind = String(n.props?.kind ?? "class");
698
+ // the Dogsbay id an importer's TOC gives an api symbol is the slug of its bare NAME
699
+ // (mkdocs `Heading.text = name`); the emitted heading text is `kind name`
700
+ out.push({ slug: String(n.props?.slug || slugifyHeadingText(name)), text: `${kind} ${name}` });
701
+ }
702
+ // a `frontmatter` node has no headings; a `code` node's text is not a heading
703
+ if (n.children && n.type !== "code" && n.type !== "html")
704
+ n.children.forEach(walk);
705
+ };
706
+ nodes.forEach(walk);
707
+ return out;
708
+ }
709
+ function headingToMdx(node, ctx) {
710
+ const hashes = "#".repeat(headingLevel(node));
711
+ // three shapes: flat `inline` (dogsbay-md), `children:[prose{inline}]` (mkdocs/html importers), `props.text`
712
+ const text = node.inline?.length
713
+ ? inlineToMdx(node.inline, ctx)
714
+ : node.children?.length
715
+ ? node.children.map((c) => (c.inline?.length ? inlineToMdx(c.inline, ctx) : c.html ? htmlToMdx(c.html) : "")).join("").replace(/\n+/g, " ").trim() || escapeMdxText(headingText(node))
716
+ : escapeMdxText(headingText(node));
717
+ // NO `^anchor`, no `{#id}`: OpenKnowledge computes anchors from the text
718
+ return `${hashes} ${text}`;
719
+ }
720
+ function referencesToMdx(node, ctx) {
721
+ const entries = (node.props?.entries ?? []);
722
+ return entries.map((e) => `[${e.label}]: ${ctx.href(e.href)}${e.title ? ` "${e.title}"` : ""}`).join("\n");
723
+ }
724
+ /* ── Utilities ──────────────────────────────────────────────────────────── */
725
+ function children(node, ctx) {
726
+ if (!node.children?.length)
727
+ return "";
728
+ return node.children.map((c) => nodeToMdx(c, ctx)).filter(Boolean).join("\n\n");
729
+ }
730
+ function leaf(node, ctx) {
731
+ return renderLeaf(node, {
732
+ inline: (nodes) => inlineToMdx(nodes, ctx),
733
+ children: (nodes) => nodes.map((c) => nodeToMdx(c, ctx)).filter(Boolean).join("\n\n"),
734
+ html: htmlToMdx,
735
+ join: "\n\n",
736
+ });
737
+ }
738
+ //# sourceMappingURL=serialize.js.map