@dogsbay/format-obsidian 0.2.0-beta.12 → 0.2.0-beta.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serialize.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { walkInline, applyTextFlags, renderLeaf, prefixLines, pickCodeFence, stripHtml, inlineHtmlToMarkdown, escapeMarkdownText, isSafeHref, codeSpan, markdownDestination, markdownTitle, markdownLatex, footnoteLabel, mapOutsideCodeSpans, neutralizeMarkdown, } from "@dogsbay/serialize-core";
1
2
  /**
2
3
  * Transform an href for Obsidian vault paths.
3
4
  *
@@ -7,13 +8,68 @@
7
8
  * https://example.com → https://example.com (unchanged)
8
9
  * #local-anchor → #local-anchor (unchanged)
9
10
  */
11
+ /**
12
+ * `/_assets/x.png` → `_assets/x.png`.
13
+ *
14
+ * Importers rewrite media refs to the `/_assets/…` mount convention, which is
15
+ * right for a served site and wrong in a vault: Obsidian resolves a link
16
+ * against the vault, so a leading slash finds nothing — even though the
17
+ * exporter copied the file to `_assets/x.png`. External and data URIs are
18
+ * left alone.
19
+ */
20
+ /**
21
+ * Make asset references inside RAW HTML resolvable from the vault.
22
+ *
23
+ * A served site roots them (`/_assets/images/x.png`); a vault has no site
24
+ * root, so the same path resolves nowhere. `vaultAssetPath` already handles
25
+ * this for `image` inline nodes, but a complex AsciiDoc table is emitted as
26
+ * raw HTML and its cells carry their own `<img src>` — which never reaches
27
+ * that renderer. Five images in an OpenShift vault were dead for exactly this
28
+ * reason while the identical file resolved fine one paragraph earlier.
29
+ */
30
+ function vaultifyHtmlAssets(html) {
31
+ if (!html.includes("/_assets/"))
32
+ return html;
33
+ return html
34
+ .replace(/(\ssrc=")\/+(_assets\/)/g, "$1$2")
35
+ .replace(/(\shref=")\/+(_assets\/)/g, "$1$2");
36
+ }
37
+ function vaultAssetPath(src) {
38
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src))
39
+ return src;
40
+ return src.replace(/^\/+/, "");
41
+ }
42
+ /**
43
+ * Sanitize a slug into a valid Obsidian BLOCK IDENTIFIER.
44
+ *
45
+ * Obsidian block ids accept only letters, numbers and dashes. Anything else
46
+ * means the `^id` is not parsed as a block id at all — it renders as literal
47
+ * text in BOTH edit and reading view, and any `#^id` link pointing at it is
48
+ * dead.
49
+ *
50
+ * This is not a corner case: AsciiDoc ids join a module and its context with
51
+ * an underscore (`additional-resources_welcome-index`), so a real OpenShift
52
+ * vault had 21,509 unusable anchors across 82% of its notes, and 2,333 of its
53
+ * cross-references (21%) resolved to nothing. The visible tell is that a
54
+ * dash-only id like `^welcome-index` superscripts and hides correctly while
55
+ * its underscore sibling does not.
56
+ *
57
+ * MUST be applied to the anchor and to every link that targets it, or the two
58
+ * stop agreeing — which is worse than leaving both broken.
59
+ */
60
+ export function obsidianBlockId(slug) {
61
+ return slug
62
+ .replace(/[^A-Za-z0-9-]+/g, "-")
63
+ .replace(/-{2,}/g, "-")
64
+ .replace(/^-+|-+$/g, "");
65
+ }
10
66
  function obsidianHref(href) {
11
67
  // External URLs — leave as-is
12
68
  if (href.startsWith("http://") || href.startsWith("https://"))
13
69
  return href;
14
70
  // Local anchors — rewrite to block ID reference
15
71
  if (href.startsWith("#"))
16
- return `#^${href.slice(1)}`;
72
+ return `#^${obsidianBlockId(href.slice(1))}`;
17
73
  // Strip leading /
18
74
  let result = href.replace(/^\//, "");
19
75
  // Strip trailing /
@@ -21,13 +77,43 @@ function obsidianHref(href) {
21
77
  // Normalize /#anchor to #anchor (strip / before #)
22
78
  result = result.replace(/\/#/, "#");
23
79
  // Rewrite #slug anchors to #^slug block ID references
24
- result = result.replace(/#(.+)$/, "#^$1");
80
+ result = result.replace(/#(.+)$/, (_m, frag) => `#^${obsidianBlockId(frag)}`);
25
81
  return result;
26
82
  }
83
+ /**
84
+ * Block ids already emitted for the CURRENT page.
85
+ *
86
+ * Reset per page in `treeToObsidian` (project.ts calls it exactly once per
87
+ * page). Obsidian indexes a block id per note, so two headings sharing one id
88
+ * make every `#^id` link resolve to whichever comes first — silently.
89
+ *
90
+ * Measured on OpenShift 4.22: 1,855 duplicate ids were ALREADY present in the
91
+ * AsciiDoc source, and sanitizing added only 2 more. So this mostly cleans up
92
+ * a pre-existing source problem rather than one we introduced. Links keep
93
+ * targeting the un-suffixed id and therefore still land on the first heading —
94
+ * the same behaviour as before, but without Obsidian seeing a duplicate.
95
+ */
96
+ const usedBlockIds = new Set();
97
+ function uniqueBlockId(id) {
98
+ if (!id)
99
+ return id;
100
+ if (!usedBlockIds.has(id)) {
101
+ usedBlockIds.add(id);
102
+ return id;
103
+ }
104
+ for (let n = 1;; n++) {
105
+ const candidate = `${id}-${n}`;
106
+ if (!usedBlockIds.has(candidate)) {
107
+ usedBlockIds.add(candidate);
108
+ return candidate;
109
+ }
110
+ }
111
+ }
27
112
  /**
28
113
  * Convert TreeNode[] to Obsidian-flavored Markdown.
29
114
  */
30
115
  export function treeToObsidian(nodes) {
116
+ usedBlockIds.clear();
31
117
  return nodes.map(nodeToObsidian).filter(Boolean).join("\n\n");
32
118
  }
33
119
  // ── Block dispatcher ────────────────────────────────────
@@ -40,7 +126,7 @@ function nodeToObsidian(node) {
40
126
  case "heading":
41
127
  return headingToObsidian(node);
42
128
  case "paragraph":
43
- return childrenToObsidian(node);
129
+ return leafContent(node);
44
130
  case "prose":
45
131
  return proseToObsidian(node);
46
132
  case "callout":
@@ -68,9 +154,9 @@ function nodeToObsidian(node) {
68
154
  case "deflist":
69
155
  return deflistToObsidian(node);
70
156
  case "dt":
71
- return `**${childrenToObsidian(node)}**`;
157
+ return `**${leafContent(node)}**`;
72
158
  case "dd":
73
- return `: ${childrenToObsidian(node)}`;
159
+ return `: ${leafContent(node)}`;
74
160
  case "steps":
75
161
  return stepsToObsidian(node);
76
162
  case "step":
@@ -80,20 +166,21 @@ function nodeToObsidian(node) {
80
166
  case "annotation-list":
81
167
  return childrenToObsidian(node);
82
168
  case "link-button": {
83
- const href = obsidianHref(node.props?.href ?? "");
84
- const text = childrenToObsidian(node) || href;
85
- return `[${text}](${href})`;
169
+ const raw = node.props?.href ?? "";
170
+ const href = raw && isSafeHref(raw) ? obsidianHref(raw) : "";
171
+ const text = childrenToObsidian(node) || esc(href);
172
+ return href ? `[${text}](${markdownDestination(href)})` : text;
86
173
  }
87
174
  case "hr":
88
175
  return "---";
89
176
  case "html":
90
- return node.html ?? "";
177
+ return vaultifyHtmlAssets(node.html ?? "");
91
178
  case "html-container":
92
179
  return childrenToObsidian(node);
93
180
  case "figure": {
94
181
  const caption = node.props?.caption;
95
182
  const inner = childrenToObsidian(node);
96
- return caption ? `${inner}\n\n*${caption}*` : inner;
183
+ return caption ? `${inner}\n\n*${esc(oneLine(caption))}*` : inner;
97
184
  }
98
185
  case "card":
99
186
  return cardToObsidian(node);
@@ -111,81 +198,192 @@ function nodeToObsidian(node) {
111
198
  return apiParamsToObsidian(node);
112
199
  case "api-source":
113
200
  return apiSourceToObsidian(node);
201
+ case "endpoint":
202
+ return endpointToObsidian(node);
114
203
  case "footnote-list":
115
204
  return footnoteListToObsidian(node);
116
205
  case "footnote-def":
117
206
  return footnoteDefToObsidian(node);
207
+ case "related": {
208
+ // "Additional resources" blocks — the parser models the labeled
209
+ // link-list as a `related` node carrying its TITLE in props. The
210
+ // default fallthrough emitted only the children, so every such
211
+ // label vanished from the vault (issue #064: first-of-two blocks,
212
+ // the 8 per-section labels on oadp-api, nodes-nodes-jobs' heading —
213
+ // links floated with no context). A missing serializer case is a
214
+ // silent loss.
215
+ const title = (node.props?.title ?? "").trim();
216
+ const body = childrenToObsidian(node);
217
+ // No invented default label (format-astro omits it too — and an
218
+ // English fallback is wrong for localized corpora); escape emphasis
219
+ // chars so a title containing `*` can't break the bold wrapper.
220
+ if (!title)
221
+ return body;
222
+ return `**${esc(oneLine(title))}**\n\n${body}`;
223
+ }
118
224
  default:
119
225
  if (node.html)
120
- return stripHtml(node.html);
226
+ return htmlToObsidian(node.html);
121
227
  if (node.children)
122
228
  return childrenToObsidian(node);
123
229
  return "";
124
230
  }
125
231
  }
126
232
  // ── Inline rendering ────────────────────────────────────
233
+ /**
234
+ * Obsidian's inline syntax, as emitters over serialize-core's shared walk.
235
+ *
236
+ * The traversal, the nesting recursion, and the bold/italic/strike flag order
237
+ * used to live here as a fourth copy of the same 11-variant switch. Only the
238
+ * leaf syntax below is Obsidian-specific; everything structural is now shared,
239
+ * so a variant added to `InlineNode` surfaces in one place instead of four.
240
+ *
241
+ * Emitters are exhaustive on purpose: an omission routes through the core's
242
+ * `onUnsupported` plain-text degradation, which is the right default for a new
243
+ * variant but the WRONG behaviour for one Obsidian genuinely supports.
244
+ */
245
+ const OBSIDIAN_INLINE = {
246
+ text: (node) =>
247
+ // Escape tag-like `<` in prose: markdown-it decoded the engine's
248
+ // `&lt;` entities into bare `<`, and Obsidian's reading view swallows
249
+ // `<cluster-name>` as an unknown HTML tag — the placeholder text
250
+ // silently disappears for the reader (issue #046). A backslash escape
251
+ // renders as the literal character.
252
+ // Every other Markdown marker too (`*x*`, `[x](…)`): text is literal.
253
+ applyTextFlags(esc(node.text), node, {
254
+ bold: (s) => `**${s}**`,
255
+ italic: (s) => `*${s}*`,
256
+ strike: (s) => `~~${s}~~`,
257
+ }),
258
+ link: (node, ctx) => {
259
+ if (!isSafeHref(node.href)) {
260
+ ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
261
+ return ctx.recurse(node.children);
262
+ }
263
+ return `[${ctx.recurse(node.children)}](${markdownDestination(obsidianHref(node.href))}${markdownTitle(node.title)})`;
264
+ },
265
+ // A leading slash is not a vault path — see vaultAssetPath.
266
+ image: (node, ctx) => {
267
+ if (!isSafeHref(node.src, "image")) {
268
+ ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
269
+ return esc(node.alt ?? "");
270
+ }
271
+ return `![${(node.alt ?? "").replace(/[[\]\\]/g, "\\$&")}](${markdownDestination(vaultAssetPath(node.src))})`;
272
+ },
273
+ code: (node) => codeSpan(node.text),
274
+ highlight: (node, ctx) => `==${ctx.recurse(node.children)}==`,
275
+ "footnote-ref": (node) => `[^${footnoteLabel(node.label)}]`,
276
+ kbd: (node) => node.keys.map((k) => `<kbd>${escapeHtmlChars(k)}</kbd>`).join("+"),
277
+ math: (node) => `$${markdownLatex(node.latex)}$`,
278
+ icon: (node) => `:${node.name.replace(/[^\w+-]/g, "")}:`,
279
+ "html-inline": (node) => node._composedRun
280
+ ? (node.html ?? "")
281
+ : vaultifyHtmlAssets(node.html ?? ""),
282
+ break: () => " \n",
283
+ };
127
284
  function inlineToObsidian(nodes) {
128
- return nodes.map(inlineNodeToObsidian).join("");
285
+ // `escape`: inline nodes without their own emitter fall back to plain text.
286
+ return walkInline(groupFormatRuns(nodes), { emitters: OBSIDIAN_INLINE, escape: esc });
129
287
  }
130
- function inlineNodeToObsidian(node) {
131
- switch (node.type) {
132
- case "text": {
133
- let text = node.text;
134
- if (node.bold && node.italic)
135
- text = `***${text}***`;
136
- else if (node.bold)
137
- text = `**${text}**`;
138
- else if (node.italic)
139
- text = `*${text}*`;
140
- if (node.strikethrough)
141
- text = `~~${text}~~`;
142
- return text;
288
+ /**
289
+ * Wrap CONSECUTIVE nodes sharing the same bold/italic/strike flags in one
290
+ * mark pair instead of per-node wrapping. The parser inherits strong/em
291
+ * flags onto code spans (`**`code` text**`, issue #045); per-node
292
+ * emission produced `` `code`** text** `` — the code span unbolded and
293
+ * `** text**` (space inside the opener) not even parsing as bold. Runs
294
+ * are emitted as a single html-inline-free group: flags stripped from the
295
+ * clones, marks around the whole run, run-edge spaces shifted OUTSIDE the
296
+ * marks (CommonMark flanking).
297
+ */
298
+ function groupFormatRuns(nodes) {
299
+ const flagsOf = (n) => {
300
+ const f = n;
301
+ return `${f.bold ? "b" : ""}${f.italic ? "i" : ""}${f.strikethrough ? "s" : ""}`;
302
+ };
303
+ const flaggable = (n) => n.type === "text" || n.type === "code";
304
+ const out = [];
305
+ let i = 0;
306
+ while (i < nodes.length) {
307
+ const key = flagsOf(nodes[i]);
308
+ if (!key || !flaggable(nodes[i])) {
309
+ out.push(nodes[i]);
310
+ i++;
311
+ continue;
312
+ }
313
+ let j = i;
314
+ while (j < nodes.length && flaggable(nodes[j]) && flagsOf(nodes[j]) === key)
315
+ j++;
316
+ // A single flagged TEXT node keeps the proven per-node path (its
317
+ // escaping and flag order are pinned by the byte-parity suite); the
318
+ // synthetic group exists only for the broken shapes — a flagged code
319
+ // span, or a run split across several nodes.
320
+ if (j === i + 1 && nodes[i].type === "text") {
321
+ out.push(nodes[i]);
322
+ i = j;
323
+ continue;
143
324
  }
144
- case "link": {
145
- const text = inlineToObsidian(node.children);
146
- const title = node.title ? ` "${node.title}"` : "";
147
- return `[${text}](${obsidianHref(node.href)}${title})`;
325
+ const run = nodes.slice(i, j).map((n) => {
326
+ const clone = { ...n };
327
+ delete clone.bold;
328
+ delete clone.italic;
329
+ delete clone.strikethrough;
330
+ return clone;
331
+ });
332
+ let inner = walkInline(run, { emitters: OBSIDIAN_INLINE, escape: esc });
333
+ const lead = inner.match(/^\s*/)[0];
334
+ const tail = inner.match(/\s*$/)[0];
335
+ inner = inner.slice(lead.length, inner.length - tail.length || undefined);
336
+ if (inner) {
337
+ let open = "";
338
+ let close = "";
339
+ if (key.includes("s")) {
340
+ open += "~~";
341
+ close = "~~" + close;
342
+ }
343
+ if (key.includes("i")) {
344
+ open += "*";
345
+ close = "*" + close;
346
+ }
347
+ if (key.includes("b")) {
348
+ open += "**";
349
+ close = "**" + close;
350
+ }
351
+ // _composedRun: this is already-serialized MARKDOWN riding the
352
+ // html-inline pass-through, not raw HTML — the emitter must not run
353
+ // vaultifyHtmlAssets over it (it would rewrite `src="/_assets/…"`
354
+ // even inside a code span within the run).
355
+ out.push({ type: "html-inline", html: lead + open + inner + close + tail, _composedRun: true });
148
356
  }
149
- case "image": {
150
- const alt = node.alt ?? "";
151
- return `![${alt}](${node.src})`;
357
+ else if (lead) {
358
+ // Whitespace-only run: lead and tail both matched the WHOLE string,
359
+ // so pushing lead+tail doubled the spaces. lead alone IS the run.
360
+ out.push({ type: "text", text: lead });
152
361
  }
153
- case "code":
154
- return `\`${node.text}\``;
155
- case "highlight":
156
- return `==${inlineToObsidian(node.children)}==`;
157
- case "footnote-ref":
158
- return `[^${node.label}]`;
159
- case "kbd":
160
- return node.keys.map((k) => `<kbd>${k}</kbd>`).join("+");
161
- case "math":
162
- return `$${node.latex}$`;
163
- case "icon":
164
- return `:${node.name}:`;
165
- case "html-inline":
166
- return node.html;
167
- case "break":
168
- return " \n";
169
- default:
170
- return "";
362
+ i = j;
171
363
  }
364
+ return out;
172
365
  }
173
366
  // ── Prose (inline or HTML fallback) ─────────────────────
174
367
  function proseToObsidian(node) {
175
368
  if (node.inline)
176
369
  return inlineToObsidian(node.inline);
177
370
  if (node.html)
178
- return stripHtml(node.html);
371
+ return htmlToObsidian(node.html);
179
372
  return "";
180
373
  }
181
374
  // ── Callouts (lossless mapping) ─────────────────────────
182
375
  function calloutToObsidian(node) {
183
- const variant = node.props?.variant ?? "note";
376
+ // Shape-tolerant: the dogsbay-md parser emits `props.type` for `:::tip`
377
+ // directives; importers emit `props.variant`. Reading only `variant`
378
+ // demoted EVERY parsed callout to note — 2,194 of the OpenShift vault's
379
+ // 7,926 admonitions (all its tips/warnings/importants) mislabeled.
380
+ const variant = (node.props?.variant ?? node.props?.type) ?? "note";
184
381
  const title = node.props?.title;
185
382
  let opener = `> [!${variant}]`;
186
383
  const autoTitle = variant.charAt(0).toUpperCase() + variant.slice(1);
384
+ // The title is inline Markdown on the callout's first line.
187
385
  if (title && title !== autoTitle)
188
- opener += ` ${title}`;
386
+ opener += ` ${esc(oneLine(title))}`;
189
387
  if (title === null)
190
388
  opener += "";
191
389
  const inner = childrenToObsidian(node);
@@ -194,14 +392,15 @@ function calloutToObsidian(node) {
194
392
  return `${opener}\n${prefixLines(inner, "> ")}`;
195
393
  }
196
394
  function detailsToObsidian(node) {
197
- const variant = node.props?.variant ?? "note";
395
+ // Shape-tolerant variant read — see calloutToObsidian.
396
+ const variant = (node.props?.variant ?? node.props?.type) ?? "note";
198
397
  const title = node.props?.title;
199
398
  const open = node.props?.open;
200
399
  const foldMarker = open ? "+" : "-";
201
400
  let opener = `> [!${variant}]${foldMarker}`;
202
401
  const autoTitle = variant.charAt(0).toUpperCase() + variant.slice(1);
203
402
  if (title && title !== autoTitle)
204
- opener += ` ${title}`;
403
+ opener += ` ${esc(oneLine(title))}`;
205
404
  const inner = childrenToObsidian(node);
206
405
  if (!inner)
207
406
  return opener;
@@ -236,8 +435,16 @@ function tabsToObsidian(node) {
236
435
  codeBlockNestingDepth++;
237
436
  const inner = node.children
238
437
  .map((tab) => {
239
- const title = tab.props?.title ?? "Tab";
438
+ // The parser labels a tab with `label`; older/other shapes use `title` or
439
+ // `value`. Mirror format-astro's tolerant cascade — reading only `title`
440
+ // (the old obsidian behaviour) makes every tab render as the literal "Tab".
441
+ let title = tab.props?.title ||
442
+ tab.props?.label ||
443
+ tab.props?.value ||
444
+ "Tab";
240
445
  const content = tabContentToObsidian(tab);
446
+ // One line: the plugin reads the header line by line.
447
+ title = oneLine(title);
241
448
  if (!content)
242
449
  return `tab: ${title}\n---`;
243
450
  return `tab: ${title}\n---\n${content}`;
@@ -252,8 +459,12 @@ function tabContentToObsidian(node) {
252
459
  }
253
460
  // ── Code blocks ─────────────────────────────────────────
254
461
  function codeToObsidian(node) {
255
- const lang = node.props?.lang ?? "";
256
- const code = (node.props?.code ?? "").replace(/\n$/, "");
462
+ // The parser emits `props.language` with the code text in `node.html`;
463
+ // importers use `props.lang`/`props.code`. Read BOTH (as format-astro does)
464
+ // — reading only `lang`/`code` produced an EMPTY, language-less fence for
465
+ // every code block parsed from real dogsbay-md.
466
+ const lang = node.props?.lang || node.props?.language || "";
467
+ const code = (node.props?.code ?? node.html ?? "").replace(/\n$/, "");
257
468
  const title = node.props?.title;
258
469
  const highlights = node.props?.highlights;
259
470
  const annotations = node.props?.annotations;
@@ -292,7 +503,7 @@ function diagramToObsidian(node) {
292
503
  // ── Math ────────────────────────────────────────────────
293
504
  function mathBlockToObsidian(node) {
294
505
  const latex = node.props?.latex ?? "";
295
- return `$$\n${latex}\n$$`;
506
+ return `$$\n${markdownLatex(latex)}\n$$`;
296
507
  }
297
508
  // ── Tables (GFM pipe tables) ────────────────────────────
298
509
  function tableToObsidian(node) {
@@ -366,11 +577,17 @@ function orderedListToObsidian(node) {
366
577
  .map((child, i) => {
367
578
  const num = start + i;
368
579
  const text = nodeToObsidian(child);
369
- const first = `${num}. ${text.split("\n")[0]}`;
580
+ // Continuation indent must reach the item's CONTENT column, which is
581
+ // the marker's own width: `10. ` is 4 wide, `100. ` is 5. A fixed
582
+ // 3-space indent detached every table/callout attached to item 10+
583
+ // (CommonMark: one column short of the content column ends the item).
584
+ const marker = `${num}. `;
585
+ const pad = " ".repeat(marker.length);
586
+ const first = `${marker}${text.split("\n")[0]}`;
370
587
  const rest = text.split("\n").slice(1);
371
588
  if (rest.length === 0)
372
589
  return first;
373
- return `${first}\n${rest.map((l) => (l ? ` ${l}` : l)).join("\n")}`;
590
+ return `${first}\n${rest.map((l) => (l ? `${pad}${l}` : l)).join("\n")}`;
374
591
  })
375
592
  .join("\n");
376
593
  }
@@ -389,18 +606,77 @@ function unorderedListToObsidian(node) {
389
606
  .join("\n");
390
607
  }
391
608
  function listItemToObsidian(node) {
392
- if (!node.children)
393
- return "";
394
- const parts = node.children.map(nodeToObsidian).filter(Boolean);
395
- // Join with blank line between items to ensure Obsidian treats
396
- // consecutive blockquote-style elements (callouts, details) as separate
397
- return parts.join("\n\n");
609
+ // A list item's text is in `inline` (flat parser shape); nested blocks are
610
+ // in `children`. Reading only children returned "" for every plain bullet.
611
+ const parts = [];
612
+ if (node.inline && node.inline.length > 0)
613
+ parts.push(inlineToObsidian(node.inline));
614
+ if (node.children)
615
+ parts.push(...node.children.map(nodeToObsidian).filter(Boolean));
616
+ // Blank line between blocks so Obsidian treats consecutive blockquote-style
617
+ // elements (callouts, details) as separate.
618
+ return parts.filter(Boolean).join("\n\n");
398
619
  }
399
620
  // ── Definition lists (degraded) ─────────────────────────
621
+ /**
622
+ * Definition list → a `dogsbay-deflist` fence the vault plugin renders as a
623
+ * real `<dl>`.
624
+ *
625
+ * Obsidian has NO definition-list syntax. The previous emission used the
626
+ * common `**term**` / `: definition` convention, which Obsidian renders as
627
+ * literal text with a stray colon — 821 of 2,106 notes (28,946 lines) in a
628
+ * real OpenShift vault. And because the `dd` case flattened its body with
629
+ * `leafContent`, a definition carrying a TABLE was emitted as `: ` followed by
630
+ * pipe rows at column 0, so the table could not parse either.
631
+ *
632
+ * Definition bodies are serialized as full BLOCK markdown, so a table, list or
633
+ * code block inside a `dd` survives; the plugin renders each body through
634
+ * Obsidian's own markdown renderer.
635
+ */
400
636
  function deflistToObsidian(node) {
401
- if (!node.children)
637
+ if (!node.children?.length)
402
638
  return "";
403
- return node.children.map(nodeToObsidian).join("\n");
639
+ const entries = [];
640
+ let current = null;
641
+ // A definition body may itself contain a fenced code block, so the wrapper
642
+ // alternates fence characters by nesting depth (same rule as steps/tabs).
643
+ const fence = codeBlockNestingDepth % 2 === 0 ? "~~~" : "```";
644
+ codeBlockNestingDepth++;
645
+ for (const child of node.children) {
646
+ if (child.type === "dt") {
647
+ if (current)
648
+ entries.push(current);
649
+ current = { term: leafContent(child).trim(), definitions: [] };
650
+ continue;
651
+ }
652
+ if (child.type === "dd") {
653
+ // Block children first — `leafContent` is the fallback for the flat
654
+ // `inline` shape the dogsbay-md parser produces.
655
+ const body = (childrenToObsidian(child) || leafContent(child)).trim();
656
+ if (!current)
657
+ current = { term: "", definitions: [] };
658
+ if (body)
659
+ current.definitions.push(body);
660
+ continue;
661
+ }
662
+ // Anything else inside a deflist keeps its content rather than vanishing.
663
+ const other = nodeToObsidian(child).trim();
664
+ if (!other)
665
+ continue;
666
+ if (!current)
667
+ current = { term: "", definitions: [] };
668
+ current.definitions.push(other);
669
+ }
670
+ if (current)
671
+ entries.push(current);
672
+ codeBlockNestingDepth--;
673
+ const body = entries
674
+ .map((e) => {
675
+ const defs = e.definitions.join("\n\n---\n").trim();
676
+ return defs ? `term: ${e.term}\n---\n${defs}` : `term: ${e.term}\n---`;
677
+ })
678
+ .join("\n\n");
679
+ return `${fence}dogsbay-deflist\n${body}\n${fence}`;
404
680
  }
405
681
  // ── Steps (numbered list) ───────────────────────────────
406
682
  function stepsToObsidian(node) {
@@ -411,7 +687,14 @@ function stepsToObsidian(node) {
411
687
  // Use dogsbay-steps element for richer rendering
412
688
  const inner = node.children
413
689
  .map((child) => {
414
- const title = child.props?.title || "";
690
+ // A `step` node carries its TITLE as its own inline content (the parser's
691
+ // ordered-list recovery), with the body in `children` — the same shape
692
+ // format-astro renders via leafContent. Reading `props.title` (the old
693
+ // behaviour) dropped every step title. `props.title` remains a fallback
694
+ // for shapes that carry it there.
695
+ const title = (child.inline && child.inline.length ? inlineToObsidian(child.inline) : "") ||
696
+ child.props?.title ||
697
+ "";
415
698
  const content = childrenToObsidian(child);
416
699
  if (title) {
417
700
  return content ? `step: ${title}\n---\n${content}` : `step: ${title}\n---`;
@@ -428,7 +711,8 @@ function headingToObsidian(node) {
428
711
  const level = node.props?.level ?? 1;
429
712
  const slug = node.props?.slug ?? "";
430
713
  const hashes = "#".repeat(level);
431
- const blockId = slug ? ` ^${slug}` : "";
714
+ const id = uniqueBlockId(obsidianBlockId(slug));
715
+ const blockId = id ? ` ^${id}` : "";
432
716
  if (node.inline)
433
717
  return `${hashes} ${inlineToObsidian(node.inline)}${blockId}`;
434
718
  const text = childrenToObsidian(node) || (node.props?.text ?? "");
@@ -450,22 +734,66 @@ function referencesToObsidian(node) {
450
734
  .join("\n");
451
735
  }
452
736
  function youtubeToObsidian(node) {
453
- const id = node.props?.videoId ?? "";
454
- const title = node.props?.title ?? "Video";
737
+ // The parser emits `props.id`; importers may use `props.videoId`. Read BOTH
738
+ // (as format-astro reads `props.id`) — reading only `videoId` produced a
739
+ // broken embed URL (`.../vi//maxres...`, `watch?v=`) for every parsed video.
740
+ const id = String(node.props?.id || node.props?.videoId || "").replace(/[^\w-]/g, "");
741
+ const title = (node.props?.title ?? "Video").replace(/[[\]\\\r\n]/g, " ");
455
742
  return `[![${title}](https://img.youtube.com/vi/${id}/maxresdefault.jpg)](https://www.youtube.com/watch?v=${id})`;
456
743
  }
744
+ /**
745
+ * The icon value if a vault can show it, otherwise undefined.
746
+ *
747
+ * Renderable: an image path (the plugin builds an <img>), or an emoji — any
748
+ * non-ASCII glyph, which the plugin prints as text and which reads correctly.
749
+ * Not renderable: an ASCII slug naming an icon in a registry the vault does
750
+ * not have.
751
+ */
752
+ function renderableVaultIcon(icon) {
753
+ if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon))
754
+ return vaultAssetPath(icon);
755
+ // eslint-disable-next-line no-control-regex
756
+ return /^[\x00-\x7F]+$/.test(icon) ? undefined : icon;
757
+ }
758
+ /** Give an internal card target the `.md` its exact-path lookup needs. */
759
+ function cardHref(href) {
760
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#"))
761
+ return href;
762
+ return /\.[a-z0-9]+$/i.test(href) ? href : `${href}.md`;
763
+ }
457
764
  function cardToObsidian(node) {
458
- const title = node.props?.title || "";
459
- const href = node.props?.href;
765
+ const title = oneLine(node.props?.title || "");
766
+ const rawHref = node.props?.href;
767
+ const href = rawHref && isSafeHref(rawHref) ? oneLine(rawHref) : undefined;
460
768
  const icon = node.props?.icon;
461
769
  const content = childrenToObsidian(node);
462
770
  const header = [];
463
771
  if (title)
464
772
  header.push(`title: ${title}`);
773
+ // `.md` REQUIRED here, unlike an ordinary markdown link.
774
+ //
775
+ // A `[text](path)` link is resolved by Obsidian's own linkpath lookup, which
776
+ // is forgiving about the extension. This href is consumed by the dogsbay-docs
777
+ // plugin, which resolves it with `vault.getFileByPath()` — an EXACT match.
778
+ // Extensionless, that returns null and the click does nothing at all: the
779
+ // cards rendered correctly and were simply inert, with no error anywhere.
465
780
  if (href)
466
- header.push(`href: ${obsidianHref(href)}`);
467
- if (icon)
468
- header.push(`icon: ${icon}`);
781
+ header.push(`href: ${cardHref(obsidianHref(href))}`);
782
+ // Only an icon the VAULT can render.
783
+ //
784
+ // The plugin draws an image when the icon is a file path and prints the
785
+ // value as text otherwise — which is right for an emoji and wrong for a
786
+ // registry NAME. Starlight names like `open-book`, `discord` and `x.com`
787
+ // have no file behind them and no registry in a vault, so they rendered as
788
+ // the literal word "discord" stacked one letter per line down the card's
789
+ // icon column.
790
+ //
791
+ // A name we cannot resolve is dropped rather than emitted: the card simply
792
+ // has no icon, which is what it would look like anyway if the reference
793
+ // worked and pointed at nothing.
794
+ const vaultIcon = icon ? renderableVaultIcon(icon) : undefined;
795
+ if (vaultIcon)
796
+ header.push(`icon: ${vaultIcon}`);
469
797
  if (header.length === 0 && !content)
470
798
  return "";
471
799
  const meta = header.join("\n");
@@ -487,13 +815,20 @@ function apiSymbolToObsidian(node) {
487
815
  const level = node.type === "api-class" ? 3 : 4;
488
816
  const hashes = "#".repeat(level);
489
817
  const sig = node.props?.signature ?? "";
490
- const bases = node.props?.bases;
818
+ // The parser emits `bases` as a string; the RST/autodoc importer (and
819
+ // format-astro's read) may use a string[]. Tolerate both.
820
+ const basesRaw = node.props?.bases;
821
+ const bases = Array.isArray(basesRaw)
822
+ ? basesRaw.join(", ")
823
+ : basesRaw;
491
824
  const parts = [];
492
- parts.push(`${hashes} ${kind} \`${name}\``);
825
+ parts.push(`${hashes} ${esc(kind)} ${codeSpan(name)}`);
493
826
  if (bases)
494
- parts.push(`Bases: \`${bases}\``);
495
- if (sig)
496
- parts.push(`\`\`\`python\n${sig}\n\`\`\``);
827
+ parts.push(`Bases: ${codeSpan(bases)}`);
828
+ if (sig) {
829
+ const fence = chooseFence(sig);
830
+ parts.push(`${fence}python\n${sig}\n${fence}`);
831
+ }
497
832
  if (node.children)
498
833
  parts.push(childrenToObsidian(node));
499
834
  return parts.filter(Boolean).join("\n\n");
@@ -542,7 +877,7 @@ function apiDocToObsidian(node) {
542
877
  // api-doc has HTML content in props.html — convert to markdown
543
878
  const html = node.props?.html ?? "";
544
879
  if (html)
545
- return stripHtml(html);
880
+ return htmlToObsidian(html);
546
881
  return childrenToObsidian(node);
547
882
  }
548
883
  function apiSourceToObsidian(node) {
@@ -557,6 +892,166 @@ function apiSourceToObsidian(node) {
557
892
  if (code) {
558
893
  parts.push(`\`\`\`python\n${code.replace(/\n$/, "")}\n\`\`\``);
559
894
  }
895
+ else if (node.children && node.children.length > 0) {
896
+ // The dogsbay-md parser puts the fenced source into a child `code` node
897
+ // (props.language + node.html), not props.code — reading only props.code
898
+ // dropped the source block. Serialize children as a fallback; the
899
+ // props.code path stays for importers that carry it there.
900
+ const inner = childrenToObsidian(node);
901
+ if (inner)
902
+ parts.push(inner);
903
+ }
904
+ return parts.join("\n\n");
905
+ }
906
+ const objects = (v) => (Array.isArray(v) ? v.filter((x) => !!x && typeof x === "object") : []);
907
+ const text = (v) => (v == null ? "" : String(v));
908
+ /** A GFM table cell: one line, pipes escaped (GFM unescapes them inside code spans too). */
909
+ function cell(md) {
910
+ return oneLine(md).replace(/\|/g, "\\|");
911
+ }
912
+ /** A spec description (CommonMark, from someone else's file) in a table cell, inert. */
913
+ function descriptionCell(p) {
914
+ const parts = [neutralizeMarkdown(oneLine(text(p.description)))];
915
+ if (Array.isArray(p.enum) && p.enum.length)
916
+ parts.push(`One of: ${p.enum.map((v) => codeSpan(text(v))).join(", ")}`);
917
+ if (p.default !== undefined)
918
+ parts.push(`Default: ${codeSpan(typeof p.default === "string" ? p.default : JSON.stringify(p.default))}`);
919
+ if (p.example !== undefined)
920
+ parts.push(`Example: ${codeSpan(JSON.stringify(p.example))}`);
921
+ if (p.deprecated === true)
922
+ parts.push("*Deprecated.*");
923
+ return cell(parts.filter(Boolean).join(" "));
924
+ }
925
+ function typeCell(p) {
926
+ const t = text(p.type) || "any";
927
+ return cell(codeSpan(p.format ? `${t} <${text(p.format)}>` : t) + (p.nullable === true ? " (nullable)" : ""));
928
+ }
929
+ function table(head, rows) {
930
+ return [`| ${head.join(" | ")} |`, `|${head.map(() => " --- ").join("|")}|`, ...rows.map((r) => `| ${r.join(" | ")} |`)].join("\n");
931
+ }
932
+ /** Schema properties, nested ones flattened to `owner.id` / `tags[].name`, as the rows of one table. */
933
+ function propertyRows(props, prefix = "", depth = 0) {
934
+ if (depth > 10)
935
+ return [];
936
+ return props.flatMap((p) => {
937
+ const name = `${prefix}${text(p.name)}`;
938
+ const row = [cell(codeSpan(name)), typeCell(p), p.required === true ? "yes" : "", descriptionCell(p)];
939
+ const nested = objects(p.properties).length
940
+ ? propertyRows(objects(p.properties), `${name}.`, depth + 1)
941
+ : propertyRows(objects(p.items), `${name}[].`, depth + 1);
942
+ return [row, ...nested];
943
+ });
944
+ }
945
+ /** A foldable callout holding a code block (samples and examples). */
946
+ function codeCallout(title, code, lang) {
947
+ const fence = chooseFence(code);
948
+ // The language is the spec's: a backtick or newline in it would keep the fence from opening, and the code would render as Markdown.
949
+ const info = /^[\w#+.-]{1,40}$/.test(lang) ? lang : "";
950
+ return `> [!example]- ${esc(oneLine(title))}\n${prefixLines(`${fence}${info}\n${code.replace(/\n$/, "")}\n${fence}`, "> ")}`;
951
+ }
952
+ /**
953
+ * A spec description, inert; its own headings as bold lines (they would sit
954
+ * in the note's outline under the operation, as on the web, where they are
955
+ * bold paragraphs); and any fence it opens closed: an unclosed one would turn
956
+ * the rest of the note into code.
957
+ */
958
+ function specMarkdown(md) {
959
+ let open;
960
+ const lines = neutralizeMarkdown(md).split("\n").map((line) => {
961
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
962
+ if (m) {
963
+ if (!open)
964
+ open = m[1];
965
+ else if (m[1][0] === open[0] && m[1].length >= open.length && line.trim() === m[1])
966
+ open = undefined;
967
+ return line;
968
+ }
969
+ const h = open ? null : /^ {0,3}#{1,6}(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/.exec(line);
970
+ return h ? (h[1] ? `**${h[1]}**` : "") : line;
971
+ });
972
+ // Setext headings (a text line underlined with === or ---): the text bold, the underline gone.
973
+ for (let k = 1, fence = false; k < lines.length; k++) {
974
+ if (/^ {0,3}(`{3,}|~{3,})/.test(lines[k - 1]))
975
+ fence = !fence;
976
+ const prev = lines[k - 1];
977
+ if (fence || !/^ {0,3}(=+|-+)[ \t]*$/.test(lines[k]))
978
+ continue;
979
+ if (!prev.trim() || /^ {0,3}([-*+>]|\d+[.)]|#|\*\*.*\*\*$)/.test(prev))
980
+ continue;
981
+ lines[k - 1] = `**${prev.trim()}**`;
982
+ lines[k] = "";
983
+ }
984
+ const out = lines.join("\n");
985
+ return open ? `${out}\n${open}` : out;
986
+ }
987
+ /** ExampleBlock's language for a response content type. */
988
+ function exampleLang(contentType) {
989
+ const base = contentType.split(";")[0].trim();
990
+ if (!base || base.includes("json"))
991
+ return "json";
992
+ if (base.includes("xml"))
993
+ return "xml";
994
+ if (base.includes("yaml") || base.includes("yml"))
995
+ return "yaml";
996
+ if (base === "text/html")
997
+ return "html";
998
+ return /^text\/plain$|form/.test(base) ? "" : "json";
999
+ }
1000
+ const SAMPLE_LABELS = {
1001
+ curl: "cURL", python: "Python", typescript: "TypeScript", javascript: "JavaScript", go: "Go",
1002
+ ruby: "Ruby", java: "Java", csharp: "C#", php: "PHP", rust: "Rust",
1003
+ };
1004
+ /**
1005
+ * An OpenAPI operation (`endpoint`, from format-openapi or the MDX adapters)
1006
+ * as vault Markdown: the method and path as a heading, the description,
1007
+ * tables for parameters, the request body and responses, and the code
1008
+ * samples and examples as folded callouts. Every description is the spec
1009
+ * author's Markdown, made inert (neutralizeMarkdown) as format-astro renders
1010
+ * it with raw HTML off. With no case here the default emitted the node's
1011
+ * (absent) children: every operation vanished from a vault without a word.
1012
+ */
1013
+ function endpointToObsidian(node) {
1014
+ const p = (node.props ?? {});
1015
+ const method = (text(p.method) || "get").toUpperCase();
1016
+ const path = text(p.path);
1017
+ const parts = [`### ${codeSpan(method)} ${codeSpan(path)}`];
1018
+ if (p.summary)
1019
+ parts.push(esc(oneLine(text(p.summary))));
1020
+ if (p.deprecated === true)
1021
+ parts.push("> [!warning] Deprecated\n> This endpoint is deprecated.");
1022
+ if (typeof p.description === "string" && p.description.trim() && p.description !== p.summary) {
1023
+ parts.push(specMarkdown(p.description.trim()));
1024
+ }
1025
+ if (p.baseUrl)
1026
+ parts.push(`URL: ${codeSpan(text(p.baseUrl) + path)}`);
1027
+ const params = objects(p.parameters);
1028
+ if (params.length) {
1029
+ parts.push("**Parameters**", table(["Name", "In", "Type", "Required", "Description"], params.map((x) => [cell(codeSpan(text(x.name))), cell(esc(text(x.in))), typeCell(x), x.required === true ? "yes" : "", descriptionCell(x)])));
1030
+ }
1031
+ const body = objects(p.requestBody);
1032
+ if (body.length)
1033
+ parts.push("**Request body**", table(["Property", "Type", "Required", "Description"], propertyRows(body)));
1034
+ const responses = objects(p.responses);
1035
+ if (responses.length) {
1036
+ parts.push("**Responses**", table(["Status", "Description"], responses.map((r) => [cell(codeSpan(text(r.status))), cell(neutralizeMarkdown(oneLine(text(r.description))))])));
1037
+ for (const r of responses) {
1038
+ const schema = objects(r.schema);
1039
+ if (!schema.length)
1040
+ continue;
1041
+ parts.push(`> [!info]- ${esc(`Response ${text(r.status)} schema`)}\n${prefixLines(table(["Property", "Type", "Required", "Description"], propertyRows(schema)), "> ")}`);
1042
+ }
1043
+ }
1044
+ for (const s of objects(p.codeSamples)) {
1045
+ const lang = text(s.language);
1046
+ parts.push(codeCallout(text(s.label) || SAMPLE_LABELS[lang] || lang, text(s.code), lang === "curl" ? "bash" : lang));
1047
+ }
1048
+ if (p.requestBodyExample !== undefined)
1049
+ parts.push(codeCallout("Request body example", JSON.stringify(p.requestBodyExample, null, 2), "json"));
1050
+ for (const r of responses) {
1051
+ if (r.example === undefined)
1052
+ continue;
1053
+ parts.push(codeCallout(`Response ${text(r.status)} example`, JSON.stringify(r.example, null, 2), exampleLang(text(r.contentType))));
1054
+ }
560
1055
  return parts.join("\n\n");
561
1056
  }
562
1057
  // ── Footnotes ───────────────────────────────────────────
@@ -566,8 +1061,13 @@ function footnoteListToObsidian(node) {
566
1061
  return node.children.map(nodeToObsidian).join("\n");
567
1062
  }
568
1063
  function footnoteDefToObsidian(node) {
569
- const label = node.props?.label ?? "1";
570
- const content = childrenToObsidian(node);
1064
+ const label = footnoteLabel(String(node.props?.label ?? "1"));
1065
+ // The dogsbay-md parser puts the definition body on `inline` (flat
1066
+ // shape); reading only `children` dropped every footnote body — the
1067
+ // three-TreeNode-shapes lesson documented at leafContent below.
1068
+ const content = node.inline && node.inline.length > 0
1069
+ ? inlineToObsidian(node.inline)
1070
+ : childrenToObsidian(node);
571
1071
  return `[^${label}]: ${content}`;
572
1072
  }
573
1073
  // ── Utilities ───────────────────────────────────────────
@@ -576,50 +1076,66 @@ function childrenToObsidian(node) {
576
1076
  return "";
577
1077
  return node.children.map(nodeToObsidian).filter(Boolean).join("\n\n");
578
1078
  }
579
- function prefixLines(text, prefix) {
580
- return text
581
- .split("\n")
582
- .map((line) => (line ? `${prefix}${line}` : prefix.trimEnd()))
583
- .join("\n");
584
- }
585
- function indent(text, spaces) {
586
- const prefix = " ".repeat(spaces);
587
- return text
588
- .split("\n")
589
- .map((line) => (line ? `${prefix}${line}` : line))
590
- .join("\n");
591
- }
592
1079
  /**
593
- * Choose a fence string (```) that doesn't conflict with content.
594
- * Scans for the longest run of backticks in the content and uses one more.
1080
+ * Text-bearing content across all THREE TreeNode shapes.
1081
+ *
1082
+ * Was a local re-derivation handling only two of them (flat `inline` and
1083
+ * `children`). It now goes through the core's `renderLeaf`, which adds the
1084
+ * third — a node carrying only `html`, or a single wrapped `prose` child
1085
+ * carrying `html` with no `inline` (what the MDX/Starlight/MkDocs importers
1086
+ * emit). Reading only the first two rendered those nodes EMPTY; that is the
1087
+ * bug that shipped in the Docusaurus exporter's first version and dropped
1088
+ * every paragraph body on a real Mintlify conversion.
1089
+ *
1090
+ * The `join` is "\n\n" to preserve this serializer's paragraph spacing (the
1091
+ * core defaults to "\n").
595
1092
  */
596
- function chooseFence(content) {
597
- let max = 2;
598
- const matches = content.match(/`{3,}/g);
599
- if (matches) {
600
- for (const m of matches) {
601
- if (m.length > max)
602
- max = m.length;
603
- }
604
- }
605
- return "`".repeat(max + 1);
1093
+ function leafContent(node) {
1094
+ return renderLeaf(node, {
1095
+ inline: inlineToObsidian,
1096
+ children: (children) => children.map(nodeToObsidian).filter(Boolean).join("\n\n"),
1097
+ html: htmlToObsidian,
1098
+ join: "\n\n",
1099
+ });
606
1100
  }
607
1101
  /**
608
- * Strip HTML tags, preserving text content.
609
- * Handles common inline HTML from prose nodes.
1102
+ * Render pre-rendered prose HTML as Obsidian markdown.
1103
+ *
1104
+ * `stripHtml` alone DELETES tags, so every `<code>`, `<strong>`, `<em>` and
1105
+ * `<a>` an importer hands us became plain text. That is invisible for sources
1106
+ * whose prose nodes also carry `inline` (mkdocs), and catastrophic for those
1107
+ * that carry ONLY html — the MDX/Mintlify shape. Measured on a full Mintlify
1108
+ * export before this fix: 38,994 inline code spans in the source became 6,864
1109
+ * in the vault, and 14,548 bold runs became 3,465. Roughly 80% of inline
1110
+ * formatting, silently gone, on the export we point at as the back-out.
1111
+ *
1112
+ * `inlineHtmlToMarkdown` converts the unambiguous inline tags to real markdown
1113
+ * first; `stripHtml` then removes whatever markup is left and decodes entities.
610
1114
  */
611
- function stripHtml(html) {
612
- return html
613
- .replace(/<br\s*\/?>/gi, "\n")
614
- .replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
615
- .replace(/<\/?(?:p|div|span|strong|em|s|a|code|sup|sub|mark|kbd|abbr)[^>]*>/gi, "")
616
- .replace(/<img[^>]+alt="([^"]*)"[^>]*>/gi, "![$1]")
617
- .replace(/<[^>]+>/g, "")
618
- .replace(/&amp;/g, "&")
619
- .replace(/&lt;/g, "<")
620
- .replace(/&gt;/g, ">")
621
- .replace(/&quot;/g, '"')
622
- .replace(/&#39;/g, "'")
623
- .trim();
1115
+ function htmlToObsidian(html) {
1116
+ // Applied HERE because this is the shared path for prose, html-container and
1117
+ // inline HTML. Raw `<img src="/_assets/…">` most often arrives inline inside
1118
+ // a paragraph, not as a standalone `html` node — 39 vault images stayed
1119
+ // site-rooted when only the block case was covered.
1120
+ // BEFORE the html→markdown conversion: `inlineHtmlToMarkdown` turns
1121
+ // `<img src>` into `![alt](src)`, after which there is no `src=` attribute
1122
+ // left to rewrite.
1123
+ // stripHtml decodes entities: an `&lt;img onerror=…&gt;` that was TEXT
1124
+ // came back tag-shaped, and Obsidian renders inline HTML.
1125
+ // Code spans are left as written: a backslash there would show.
1126
+ return mapOutsideCodeSpans(stripHtml(inlineHtmlToMarkdown(vaultifyHtmlAssets(html), { flatten: true })), (t) => t.replace(/<(?=[A-Za-z/!?])/g, "\\<"));
1127
+ }
1128
+ /** Literal text as vault Markdown (`\<`, which reads better than `&lt;`
1129
+ * in a file people edit). */
1130
+ function esc(text) {
1131
+ return escapeMarkdownText(text, { style: "backslash" });
1132
+ }
1133
+ /** Text for a one-line header or label: newlines folded to spaces. */
1134
+ function oneLine(s) {
1135
+ return s.replace(/\s*[\r\n]+\s*/g, " ");
1136
+ }
1137
+ /** Obsidian's code fence, via the core's shared longest-run picker. */
1138
+ function chooseFence(content) {
1139
+ return pickCodeFence(content);
624
1140
  }
625
1141
  //# sourceMappingURL=serialize.js.map