@dogsbay/format-obsidian 0.2.0-beta.15 → 0.2.0-beta.151
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/capabilities.d.ts +25 -0
- package/dist/capabilities.js +171 -0
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +148 -32
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/project.d.ts +49 -3
- package/dist/project.js +525 -38
- package/dist/serialize.d.ts +19 -1
- package/dist/serialize.js +664 -130
- package/package.json +8 -5
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/serialize.d.ts.map +0 -1
- package/dist/serialize.js.map +0 -1
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, renderAroundGroup, } 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(/#(.+)$/,
|
|
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
|
|
129
|
+
return leafContent(node);
|
|
44
130
|
case "prose":
|
|
45
131
|
return proseToObsidian(node);
|
|
46
132
|
case "callout":
|
|
@@ -48,7 +134,7 @@ function nodeToObsidian(node) {
|
|
|
48
134
|
case "details":
|
|
49
135
|
return detailsToObsidian(node);
|
|
50
136
|
case "tabs":
|
|
51
|
-
return
|
|
137
|
+
return renderAroundGroup(node, "tab", tabsToObsidian, nodeToObsidian, "\n\n");
|
|
52
138
|
case "code":
|
|
53
139
|
return codeToObsidian(node);
|
|
54
140
|
case "diagram":
|
|
@@ -68,9 +154,9 @@ function nodeToObsidian(node) {
|
|
|
68
154
|
case "deflist":
|
|
69
155
|
return deflistToObsidian(node);
|
|
70
156
|
case "dt":
|
|
71
|
-
return `**${
|
|
157
|
+
return `**${leafContent(node)}**`;
|
|
72
158
|
case "dd":
|
|
73
|
-
return `: ${
|
|
159
|
+
return `: ${leafContent(node)}`;
|
|
74
160
|
case "steps":
|
|
75
161
|
return stepsToObsidian(node);
|
|
76
162
|
case "step":
|
|
@@ -80,20 +166,39 @@ function nodeToObsidian(node) {
|
|
|
80
166
|
case "annotation-list":
|
|
81
167
|
return childrenToObsidian(node);
|
|
82
168
|
case "link-button": {
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
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":
|
|
179
|
+
// Parsed from raw HTML (a one-line `<details>` body), the content is in
|
|
180
|
+
// `html` with no children — rendering only children dropped it
|
|
181
|
+
if (!node.children?.length && node.html)
|
|
182
|
+
return vaultifyHtmlAssets(node.html);
|
|
92
183
|
return childrenToObsidian(node);
|
|
184
|
+
case "link-card": {
|
|
185
|
+
// The parser folds a one-paragraph body into `props.description`, so a
|
|
186
|
+
// link card often has no children; with no case it emitted nothing
|
|
187
|
+
const description = node.props?.description?.trim();
|
|
188
|
+
const body = node.children?.length
|
|
189
|
+
? node.children
|
|
190
|
+
: description ? [{ type: "paragraph", inline: [{ type: "text", text: description }] }] : [];
|
|
191
|
+
return cardToObsidian({ ...node, type: "card", children: body });
|
|
192
|
+
}
|
|
193
|
+
case "accordion":
|
|
194
|
+
return (node.children ?? []).map(nodeToObsidian).filter(Boolean).join("\n\n");
|
|
195
|
+
case "accordion-item":
|
|
196
|
+
// Each item is a collapsed fold titled with its label
|
|
197
|
+
return detailsToObsidian({ ...node, props: { title: node.props?.label ?? node.props?.title, open: node.props?.open } });
|
|
93
198
|
case "figure": {
|
|
94
199
|
const caption = node.props?.caption;
|
|
95
200
|
const inner = childrenToObsidian(node);
|
|
96
|
-
return caption ? `${inner}\n\n*${caption}*` : inner;
|
|
201
|
+
return caption ? `${inner}\n\n*${esc(oneLine(caption))}*` : inner;
|
|
97
202
|
}
|
|
98
203
|
case "card":
|
|
99
204
|
return cardToObsidian(node);
|
|
@@ -111,81 +216,192 @@ function nodeToObsidian(node) {
|
|
|
111
216
|
return apiParamsToObsidian(node);
|
|
112
217
|
case "api-source":
|
|
113
218
|
return apiSourceToObsidian(node);
|
|
219
|
+
case "endpoint":
|
|
220
|
+
return endpointToObsidian(node);
|
|
114
221
|
case "footnote-list":
|
|
115
222
|
return footnoteListToObsidian(node);
|
|
116
223
|
case "footnote-def":
|
|
117
224
|
return footnoteDefToObsidian(node);
|
|
225
|
+
case "related": {
|
|
226
|
+
// "Additional resources" blocks — the parser models the labeled
|
|
227
|
+
// link-list as a `related` node carrying its TITLE in props. The
|
|
228
|
+
// default fallthrough emitted only the children, so every such
|
|
229
|
+
// label vanished from the vault (issue #064: first-of-two blocks,
|
|
230
|
+
// the 8 per-section labels on oadp-api, nodes-nodes-jobs' heading —
|
|
231
|
+
// links floated with no context). A missing serializer case is a
|
|
232
|
+
// silent loss.
|
|
233
|
+
const title = (node.props?.title ?? "").trim();
|
|
234
|
+
const body = childrenToObsidian(node);
|
|
235
|
+
// No invented default label (format-astro omits it too — and an
|
|
236
|
+
// English fallback is wrong for localized corpora); escape emphasis
|
|
237
|
+
// chars so a title containing `*` can't break the bold wrapper.
|
|
238
|
+
if (!title)
|
|
239
|
+
return body;
|
|
240
|
+
return `**${esc(oneLine(title))}**\n\n${body}`;
|
|
241
|
+
}
|
|
118
242
|
default:
|
|
119
243
|
if (node.html)
|
|
120
|
-
return
|
|
244
|
+
return htmlToObsidian(node.html);
|
|
121
245
|
if (node.children)
|
|
122
246
|
return childrenToObsidian(node);
|
|
123
247
|
return "";
|
|
124
248
|
}
|
|
125
249
|
}
|
|
126
250
|
// ── Inline rendering ────────────────────────────────────
|
|
251
|
+
/**
|
|
252
|
+
* Obsidian's inline syntax, as emitters over serialize-core's shared walk.
|
|
253
|
+
*
|
|
254
|
+
* The traversal, the nesting recursion, and the bold/italic/strike flag order
|
|
255
|
+
* used to live here as a fourth copy of the same 11-variant switch. Only the
|
|
256
|
+
* leaf syntax below is Obsidian-specific; everything structural is now shared,
|
|
257
|
+
* so a variant added to `InlineNode` surfaces in one place instead of four.
|
|
258
|
+
*
|
|
259
|
+
* Emitters are exhaustive on purpose: an omission routes through the core's
|
|
260
|
+
* `onUnsupported` plain-text degradation, which is the right default for a new
|
|
261
|
+
* variant but the WRONG behaviour for one Obsidian genuinely supports.
|
|
262
|
+
*/
|
|
263
|
+
const OBSIDIAN_INLINE = {
|
|
264
|
+
text: (node) =>
|
|
265
|
+
// Escape tag-like `<` in prose: markdown-it decoded the engine's
|
|
266
|
+
// `<` entities into bare `<`, and Obsidian's reading view swallows
|
|
267
|
+
// `<cluster-name>` as an unknown HTML tag — the placeholder text
|
|
268
|
+
// silently disappears for the reader (issue #046). A backslash escape
|
|
269
|
+
// renders as the literal character.
|
|
270
|
+
// Every other Markdown marker too (`*x*`, `[x](…)`): text is literal.
|
|
271
|
+
applyTextFlags(esc(node.text), node, {
|
|
272
|
+
bold: (s) => `**${s}**`,
|
|
273
|
+
italic: (s) => `*${s}*`,
|
|
274
|
+
strike: (s) => `~~${s}~~`,
|
|
275
|
+
}),
|
|
276
|
+
link: (node, ctx) => {
|
|
277
|
+
if (!isSafeHref(node.href)) {
|
|
278
|
+
ctx.report("link", `unsafe link target dropped (${node.href.slice(0, 40)})`);
|
|
279
|
+
return ctx.recurse(node.children);
|
|
280
|
+
}
|
|
281
|
+
return `[${ctx.recurse(node.children)}](${markdownDestination(obsidianHref(node.href))}${markdownTitle(node.title)})`;
|
|
282
|
+
},
|
|
283
|
+
// A leading slash is not a vault path — see vaultAssetPath.
|
|
284
|
+
image: (node, ctx) => {
|
|
285
|
+
if (!isSafeHref(node.src, "image")) {
|
|
286
|
+
ctx.report("image", `unsafe image source dropped (${node.src.slice(0, 40)})`);
|
|
287
|
+
return esc(node.alt ?? "");
|
|
288
|
+
}
|
|
289
|
+
return `![${(node.alt ?? "").replace(/[[\]\\]/g, "\\$&")}](${markdownDestination(vaultAssetPath(node.src))})`;
|
|
290
|
+
},
|
|
291
|
+
code: (node) => codeSpan(node.text),
|
|
292
|
+
highlight: (node, ctx) => `==${ctx.recurse(node.children)}==`,
|
|
293
|
+
"footnote-ref": (node) => `[^${footnoteLabel(node.label)}]`,
|
|
294
|
+
kbd: (node) => node.keys.map((k) => `<kbd>${escapeHtmlChars(k)}</kbd>`).join("+"),
|
|
295
|
+
math: (node) => `$${markdownLatex(node.latex)}$`,
|
|
296
|
+
icon: (node) => `:${node.name.replace(/[^\w+-]/g, "")}:`,
|
|
297
|
+
"html-inline": (node) => node._composedRun
|
|
298
|
+
? (node.html ?? "")
|
|
299
|
+
: vaultifyHtmlAssets(node.html ?? ""),
|
|
300
|
+
break: () => " \n",
|
|
301
|
+
};
|
|
127
302
|
function inlineToObsidian(nodes) {
|
|
128
|
-
|
|
303
|
+
// `escape`: inline nodes without their own emitter fall back to plain text.
|
|
304
|
+
return walkInline(groupFormatRuns(nodes), { emitters: OBSIDIAN_INLINE, escape: esc });
|
|
129
305
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Wrap CONSECUTIVE nodes sharing the same bold/italic/strike flags in one
|
|
308
|
+
* mark pair instead of per-node wrapping. The parser inherits strong/em
|
|
309
|
+
* flags onto code spans (`**`code` text**`, issue #045); per-node
|
|
310
|
+
* emission produced `` `code`** text** `` — the code span unbolded and
|
|
311
|
+
* `** text**` (space inside the opener) not even parsing as bold. Runs
|
|
312
|
+
* are emitted as a single html-inline-free group: flags stripped from the
|
|
313
|
+
* clones, marks around the whole run, run-edge spaces shifted OUTSIDE the
|
|
314
|
+
* marks (CommonMark flanking).
|
|
315
|
+
*/
|
|
316
|
+
function groupFormatRuns(nodes) {
|
|
317
|
+
const flagsOf = (n) => {
|
|
318
|
+
const f = n;
|
|
319
|
+
return `${f.bold ? "b" : ""}${f.italic ? "i" : ""}${f.strikethrough ? "s" : ""}`;
|
|
320
|
+
};
|
|
321
|
+
const flaggable = (n) => n.type === "text" || n.type === "code";
|
|
322
|
+
const out = [];
|
|
323
|
+
let i = 0;
|
|
324
|
+
while (i < nodes.length) {
|
|
325
|
+
const key = flagsOf(nodes[i]);
|
|
326
|
+
if (!key || !flaggable(nodes[i])) {
|
|
327
|
+
out.push(nodes[i]);
|
|
328
|
+
i++;
|
|
329
|
+
continue;
|
|
143
330
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
331
|
+
let j = i;
|
|
332
|
+
while (j < nodes.length && flaggable(nodes[j]) && flagsOf(nodes[j]) === key)
|
|
333
|
+
j++;
|
|
334
|
+
// A single flagged TEXT node keeps the proven per-node path (its
|
|
335
|
+
// escaping and flag order are pinned by the byte-parity suite); the
|
|
336
|
+
// synthetic group exists only for the broken shapes — a flagged code
|
|
337
|
+
// span, or a run split across several nodes.
|
|
338
|
+
if (j === i + 1 && nodes[i].type === "text") {
|
|
339
|
+
out.push(nodes[i]);
|
|
340
|
+
i = j;
|
|
341
|
+
continue;
|
|
148
342
|
}
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
343
|
+
const run = nodes.slice(i, j).map((n) => {
|
|
344
|
+
const clone = { ...n };
|
|
345
|
+
delete clone.bold;
|
|
346
|
+
delete clone.italic;
|
|
347
|
+
delete clone.strikethrough;
|
|
348
|
+
return clone;
|
|
349
|
+
});
|
|
350
|
+
let inner = walkInline(run, { emitters: OBSIDIAN_INLINE, escape: esc });
|
|
351
|
+
const lead = inner.match(/^\s*/)[0];
|
|
352
|
+
const tail = inner.match(/\s*$/)[0];
|
|
353
|
+
inner = inner.slice(lead.length, inner.length - tail.length || undefined);
|
|
354
|
+
if (inner) {
|
|
355
|
+
let open = "";
|
|
356
|
+
let close = "";
|
|
357
|
+
if (key.includes("s")) {
|
|
358
|
+
open += "~~";
|
|
359
|
+
close = "~~" + close;
|
|
360
|
+
}
|
|
361
|
+
if (key.includes("i")) {
|
|
362
|
+
open += "*";
|
|
363
|
+
close = "*" + close;
|
|
364
|
+
}
|
|
365
|
+
if (key.includes("b")) {
|
|
366
|
+
open += "**";
|
|
367
|
+
close = "**" + close;
|
|
368
|
+
}
|
|
369
|
+
// _composedRun: this is already-serialized MARKDOWN riding the
|
|
370
|
+
// html-inline pass-through, not raw HTML — the emitter must not run
|
|
371
|
+
// vaultifyHtmlAssets over it (it would rewrite `src="/_assets/…"`
|
|
372
|
+
// even inside a code span within the run).
|
|
373
|
+
out.push({ type: "html-inline", html: lead + open + inner + close + tail, _composedRun: true });
|
|
152
374
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 "";
|
|
375
|
+
else if (lead) {
|
|
376
|
+
// Whitespace-only run: lead and tail both matched the WHOLE string,
|
|
377
|
+
// so pushing lead+tail doubled the spaces. lead alone IS the run.
|
|
378
|
+
out.push({ type: "text", text: lead });
|
|
379
|
+
}
|
|
380
|
+
i = j;
|
|
171
381
|
}
|
|
382
|
+
return out;
|
|
172
383
|
}
|
|
173
384
|
// ── Prose (inline or HTML fallback) ─────────────────────
|
|
174
385
|
function proseToObsidian(node) {
|
|
175
386
|
if (node.inline)
|
|
176
387
|
return inlineToObsidian(node.inline);
|
|
177
388
|
if (node.html)
|
|
178
|
-
return
|
|
389
|
+
return htmlToObsidian(node.html);
|
|
179
390
|
return "";
|
|
180
391
|
}
|
|
181
392
|
// ── Callouts (lossless mapping) ─────────────────────────
|
|
182
393
|
function calloutToObsidian(node) {
|
|
183
|
-
|
|
394
|
+
// Shape-tolerant: the dogsbay-md parser emits `props.type` for `:::tip`
|
|
395
|
+
// directives; importers emit `props.variant`. Reading only `variant`
|
|
396
|
+
// demoted EVERY parsed callout to note — 2,194 of the OpenShift vault's
|
|
397
|
+
// 7,926 admonitions (all its tips/warnings/importants) mislabeled.
|
|
398
|
+
const variant = (node.props?.variant ?? node.props?.type) ?? "note";
|
|
184
399
|
const title = node.props?.title;
|
|
185
400
|
let opener = `> [!${variant}]`;
|
|
186
401
|
const autoTitle = variant.charAt(0).toUpperCase() + variant.slice(1);
|
|
402
|
+
// The title is inline Markdown on the callout's first line.
|
|
187
403
|
if (title && title !== autoTitle)
|
|
188
|
-
opener += ` ${title}`;
|
|
404
|
+
opener += ` ${esc(oneLine(title))}`;
|
|
189
405
|
if (title === null)
|
|
190
406
|
opener += "";
|
|
191
407
|
const inner = childrenToObsidian(node);
|
|
@@ -194,14 +410,16 @@ function calloutToObsidian(node) {
|
|
|
194
410
|
return `${opener}\n${prefixLines(inner, "> ")}`;
|
|
195
411
|
}
|
|
196
412
|
function detailsToObsidian(node) {
|
|
197
|
-
|
|
198
|
-
const
|
|
413
|
+
// Shape-tolerant variant read — see calloutToObsidian.
|
|
414
|
+
const variant = (node.props?.variant ?? node.props?.type) ?? "note";
|
|
415
|
+
// `summary` is dogsbay-md's alias for the title (`:::details{summary="…"}`)
|
|
416
|
+
const title = (node.props?.title ?? node.props?.summary);
|
|
199
417
|
const open = node.props?.open;
|
|
200
418
|
const foldMarker = open ? "+" : "-";
|
|
201
419
|
let opener = `> [!${variant}]${foldMarker}`;
|
|
202
420
|
const autoTitle = variant.charAt(0).toUpperCase() + variant.slice(1);
|
|
203
421
|
if (title && title !== autoTitle)
|
|
204
|
-
opener += ` ${title}`;
|
|
422
|
+
opener += ` ${esc(oneLine(title))}`;
|
|
205
423
|
const inner = childrenToObsidian(node);
|
|
206
424
|
if (!inner)
|
|
207
425
|
return opener;
|
|
@@ -236,8 +454,16 @@ function tabsToObsidian(node) {
|
|
|
236
454
|
codeBlockNestingDepth++;
|
|
237
455
|
const inner = node.children
|
|
238
456
|
.map((tab) => {
|
|
239
|
-
|
|
457
|
+
// The parser labels a tab with `label`; older/other shapes use `title` or
|
|
458
|
+
// `value`. Mirror format-astro's tolerant cascade — reading only `title`
|
|
459
|
+
// (the old obsidian behaviour) makes every tab render as the literal "Tab".
|
|
460
|
+
let title = tab.props?.title ||
|
|
461
|
+
tab.props?.label ||
|
|
462
|
+
tab.props?.value ||
|
|
463
|
+
"Tab";
|
|
240
464
|
const content = tabContentToObsidian(tab);
|
|
465
|
+
// One line: the plugin reads the header line by line.
|
|
466
|
+
title = oneLine(title);
|
|
241
467
|
if (!content)
|
|
242
468
|
return `tab: ${title}\n---`;
|
|
243
469
|
return `tab: ${title}\n---\n${content}`;
|
|
@@ -252,8 +478,12 @@ function tabContentToObsidian(node) {
|
|
|
252
478
|
}
|
|
253
479
|
// ── Code blocks ─────────────────────────────────────────
|
|
254
480
|
function codeToObsidian(node) {
|
|
255
|
-
|
|
256
|
-
|
|
481
|
+
// The parser emits `props.language` with the code text in `node.html`;
|
|
482
|
+
// importers use `props.lang`/`props.code`. Read BOTH (as format-astro does)
|
|
483
|
+
// — reading only `lang`/`code` produced an EMPTY, language-less fence for
|
|
484
|
+
// every code block parsed from real dogsbay-md.
|
|
485
|
+
const lang = node.props?.lang || node.props?.language || "";
|
|
486
|
+
const code = (node.props?.code ?? node.html ?? "").replace(/\n$/, "");
|
|
257
487
|
const title = node.props?.title;
|
|
258
488
|
const highlights = node.props?.highlights;
|
|
259
489
|
const annotations = node.props?.annotations;
|
|
@@ -292,7 +522,7 @@ function diagramToObsidian(node) {
|
|
|
292
522
|
// ── Math ────────────────────────────────────────────────
|
|
293
523
|
function mathBlockToObsidian(node) {
|
|
294
524
|
const latex = node.props?.latex ?? "";
|
|
295
|
-
return `$$\n${latex}\n$$`;
|
|
525
|
+
return `$$\n${markdownLatex(latex)}\n$$`;
|
|
296
526
|
}
|
|
297
527
|
// ── Tables (GFM pipe tables) ────────────────────────────
|
|
298
528
|
function tableToObsidian(node) {
|
|
@@ -366,11 +596,17 @@ function orderedListToObsidian(node) {
|
|
|
366
596
|
.map((child, i) => {
|
|
367
597
|
const num = start + i;
|
|
368
598
|
const text = nodeToObsidian(child);
|
|
369
|
-
|
|
599
|
+
// Continuation indent must reach the item's CONTENT column, which is
|
|
600
|
+
// the marker's own width: `10. ` is 4 wide, `100. ` is 5. A fixed
|
|
601
|
+
// 3-space indent detached every table/callout attached to item 10+
|
|
602
|
+
// (CommonMark: one column short of the content column ends the item).
|
|
603
|
+
const marker = `${num}. `;
|
|
604
|
+
const pad = " ".repeat(marker.length);
|
|
605
|
+
const first = `${marker}${text.split("\n")[0]}`;
|
|
370
606
|
const rest = text.split("\n").slice(1);
|
|
371
607
|
if (rest.length === 0)
|
|
372
608
|
return first;
|
|
373
|
-
return `${first}\n${rest.map((l) => (l ?
|
|
609
|
+
return `${first}\n${rest.map((l) => (l ? `${pad}${l}` : l)).join("\n")}`;
|
|
374
610
|
})
|
|
375
611
|
.join("\n");
|
|
376
612
|
}
|
|
@@ -389,18 +625,77 @@ function unorderedListToObsidian(node) {
|
|
|
389
625
|
.join("\n");
|
|
390
626
|
}
|
|
391
627
|
function listItemToObsidian(node) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const parts =
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
628
|
+
// A list item's text is in `inline` (flat parser shape); nested blocks are
|
|
629
|
+
// in `children`. Reading only children returned "" for every plain bullet.
|
|
630
|
+
const parts = [];
|
|
631
|
+
if (node.inline && node.inline.length > 0)
|
|
632
|
+
parts.push(inlineToObsidian(node.inline));
|
|
633
|
+
if (node.children)
|
|
634
|
+
parts.push(...node.children.map(nodeToObsidian).filter(Boolean));
|
|
635
|
+
// Blank line between blocks so Obsidian treats consecutive blockquote-style
|
|
636
|
+
// elements (callouts, details) as separate.
|
|
637
|
+
return parts.filter(Boolean).join("\n\n");
|
|
398
638
|
}
|
|
399
639
|
// ── Definition lists (degraded) ─────────────────────────
|
|
640
|
+
/**
|
|
641
|
+
* Definition list → a `dogsbay-deflist` fence the vault plugin renders as a
|
|
642
|
+
* real `<dl>`.
|
|
643
|
+
*
|
|
644
|
+
* Obsidian has NO definition-list syntax. The previous emission used the
|
|
645
|
+
* common `**term**` / `: definition` convention, which Obsidian renders as
|
|
646
|
+
* literal text with a stray colon — 821 of 2,106 notes (28,946 lines) in a
|
|
647
|
+
* real OpenShift vault. And because the `dd` case flattened its body with
|
|
648
|
+
* `leafContent`, a definition carrying a TABLE was emitted as `: ` followed by
|
|
649
|
+
* pipe rows at column 0, so the table could not parse either.
|
|
650
|
+
*
|
|
651
|
+
* Definition bodies are serialized as full BLOCK markdown, so a table, list or
|
|
652
|
+
* code block inside a `dd` survives; the plugin renders each body through
|
|
653
|
+
* Obsidian's own markdown renderer.
|
|
654
|
+
*/
|
|
400
655
|
function deflistToObsidian(node) {
|
|
401
|
-
if (!node.children)
|
|
656
|
+
if (!node.children?.length)
|
|
402
657
|
return "";
|
|
403
|
-
|
|
658
|
+
const entries = [];
|
|
659
|
+
let current = null;
|
|
660
|
+
// A definition body may itself contain a fenced code block, so the wrapper
|
|
661
|
+
// alternates fence characters by nesting depth (same rule as steps/tabs).
|
|
662
|
+
const fence = codeBlockNestingDepth % 2 === 0 ? "~~~" : "```";
|
|
663
|
+
codeBlockNestingDepth++;
|
|
664
|
+
for (const child of node.children) {
|
|
665
|
+
if (child.type === "dt") {
|
|
666
|
+
if (current)
|
|
667
|
+
entries.push(current);
|
|
668
|
+
current = { term: leafContent(child).trim(), definitions: [] };
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (child.type === "dd") {
|
|
672
|
+
// Block children first — `leafContent` is the fallback for the flat
|
|
673
|
+
// `inline` shape the dogsbay-md parser produces.
|
|
674
|
+
const body = (childrenToObsidian(child) || leafContent(child)).trim();
|
|
675
|
+
if (!current)
|
|
676
|
+
current = { term: "", definitions: [] };
|
|
677
|
+
if (body)
|
|
678
|
+
current.definitions.push(body);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
// Anything else inside a deflist keeps its content rather than vanishing.
|
|
682
|
+
const other = nodeToObsidian(child).trim();
|
|
683
|
+
if (!other)
|
|
684
|
+
continue;
|
|
685
|
+
if (!current)
|
|
686
|
+
current = { term: "", definitions: [] };
|
|
687
|
+
current.definitions.push(other);
|
|
688
|
+
}
|
|
689
|
+
if (current)
|
|
690
|
+
entries.push(current);
|
|
691
|
+
codeBlockNestingDepth--;
|
|
692
|
+
const body = entries
|
|
693
|
+
.map((e) => {
|
|
694
|
+
const defs = e.definitions.join("\n\n---\n").trim();
|
|
695
|
+
return defs ? `term: ${e.term}\n---\n${defs}` : `term: ${e.term}\n---`;
|
|
696
|
+
})
|
|
697
|
+
.join("\n\n");
|
|
698
|
+
return `${fence}dogsbay-deflist\n${body}\n${fence}`;
|
|
404
699
|
}
|
|
405
700
|
// ── Steps (numbered list) ───────────────────────────────
|
|
406
701
|
function stepsToObsidian(node) {
|
|
@@ -411,7 +706,14 @@ function stepsToObsidian(node) {
|
|
|
411
706
|
// Use dogsbay-steps element for richer rendering
|
|
412
707
|
const inner = node.children
|
|
413
708
|
.map((child) => {
|
|
414
|
-
|
|
709
|
+
// A `step` node carries its TITLE as its own inline content (the parser's
|
|
710
|
+
// ordered-list recovery), with the body in `children` — the same shape
|
|
711
|
+
// format-astro renders via leafContent. Reading `props.title` (the old
|
|
712
|
+
// behaviour) dropped every step title. `props.title` remains a fallback
|
|
713
|
+
// for shapes that carry it there.
|
|
714
|
+
const title = (child.inline && child.inline.length ? inlineToObsidian(child.inline) : "") ||
|
|
715
|
+
child.props?.title ||
|
|
716
|
+
"";
|
|
415
717
|
const content = childrenToObsidian(child);
|
|
416
718
|
if (title) {
|
|
417
719
|
return content ? `step: ${title}\n---\n${content}` : `step: ${title}\n---`;
|
|
@@ -428,7 +730,8 @@ function headingToObsidian(node) {
|
|
|
428
730
|
const level = node.props?.level ?? 1;
|
|
429
731
|
const slug = node.props?.slug ?? "";
|
|
430
732
|
const hashes = "#".repeat(level);
|
|
431
|
-
const
|
|
733
|
+
const id = uniqueBlockId(obsidianBlockId(slug));
|
|
734
|
+
const blockId = id ? ` ^${id}` : "";
|
|
432
735
|
if (node.inline)
|
|
433
736
|
return `${hashes} ${inlineToObsidian(node.inline)}${blockId}`;
|
|
434
737
|
const text = childrenToObsidian(node) || (node.props?.text ?? "");
|
|
@@ -450,22 +753,66 @@ function referencesToObsidian(node) {
|
|
|
450
753
|
.join("\n");
|
|
451
754
|
}
|
|
452
755
|
function youtubeToObsidian(node) {
|
|
453
|
-
|
|
454
|
-
|
|
756
|
+
// The parser emits `props.id`; importers may use `props.videoId`. Read BOTH
|
|
757
|
+
// (as format-astro reads `props.id`) — reading only `videoId` produced a
|
|
758
|
+
// broken embed URL (`.../vi//maxres...`, `watch?v=`) for every parsed video.
|
|
759
|
+
const id = String(node.props?.id || node.props?.videoId || "").replace(/[^\w-]/g, "");
|
|
760
|
+
const title = (node.props?.title ?? "Video").replace(/[[\]\\\r\n]/g, " ");
|
|
455
761
|
return `[](https://www.youtube.com/watch?v=${id})`;
|
|
456
762
|
}
|
|
763
|
+
/**
|
|
764
|
+
* The icon value if a vault can show it, otherwise undefined.
|
|
765
|
+
*
|
|
766
|
+
* Renderable: an image path (the plugin builds an <img>), or an emoji — any
|
|
767
|
+
* non-ASCII glyph, which the plugin prints as text and which reads correctly.
|
|
768
|
+
* Not renderable: an ASCII slug naming an icon in a registry the vault does
|
|
769
|
+
* not have.
|
|
770
|
+
*/
|
|
771
|
+
function renderableVaultIcon(icon) {
|
|
772
|
+
if (/\.(svg|png|jpe?g|webp|avif)$/i.test(icon))
|
|
773
|
+
return vaultAssetPath(icon);
|
|
774
|
+
// eslint-disable-next-line no-control-regex
|
|
775
|
+
return /^[\x00-\x7F]+$/.test(icon) ? undefined : icon;
|
|
776
|
+
}
|
|
777
|
+
/** Give an internal card target the `.md` its exact-path lookup needs. */
|
|
778
|
+
function cardHref(href) {
|
|
779
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#"))
|
|
780
|
+
return href;
|
|
781
|
+
return /\.[a-z0-9]+$/i.test(href) ? href : `${href}.md`;
|
|
782
|
+
}
|
|
457
783
|
function cardToObsidian(node) {
|
|
458
|
-
const title = node.props?.title || "";
|
|
459
|
-
const
|
|
784
|
+
const title = oneLine(node.props?.title || "");
|
|
785
|
+
const rawHref = node.props?.href;
|
|
786
|
+
const href = rawHref && isSafeHref(rawHref) ? oneLine(rawHref) : undefined;
|
|
460
787
|
const icon = node.props?.icon;
|
|
461
788
|
const content = childrenToObsidian(node);
|
|
462
789
|
const header = [];
|
|
463
790
|
if (title)
|
|
464
791
|
header.push(`title: ${title}`);
|
|
792
|
+
// `.md` REQUIRED here, unlike an ordinary markdown link.
|
|
793
|
+
//
|
|
794
|
+
// A `[text](path)` link is resolved by Obsidian's own linkpath lookup, which
|
|
795
|
+
// is forgiving about the extension. This href is consumed by the dogsbay-docs
|
|
796
|
+
// plugin, which resolves it with `vault.getFileByPath()` — an EXACT match.
|
|
797
|
+
// Extensionless, that returns null and the click does nothing at all: the
|
|
798
|
+
// cards rendered correctly and were simply inert, with no error anywhere.
|
|
465
799
|
if (href)
|
|
466
|
-
header.push(`href: ${obsidianHref(href)}`);
|
|
467
|
-
|
|
468
|
-
|
|
800
|
+
header.push(`href: ${cardHref(obsidianHref(href))}`);
|
|
801
|
+
// Only an icon the VAULT can render.
|
|
802
|
+
//
|
|
803
|
+
// The plugin draws an image when the icon is a file path and prints the
|
|
804
|
+
// value as text otherwise — which is right for an emoji and wrong for a
|
|
805
|
+
// registry NAME. Starlight names like `open-book`, `discord` and `x.com`
|
|
806
|
+
// have no file behind them and no registry in a vault, so they rendered as
|
|
807
|
+
// the literal word "discord" stacked one letter per line down the card's
|
|
808
|
+
// icon column.
|
|
809
|
+
//
|
|
810
|
+
// A name we cannot resolve is dropped rather than emitted: the card simply
|
|
811
|
+
// has no icon, which is what it would look like anyway if the reference
|
|
812
|
+
// worked and pointed at nothing.
|
|
813
|
+
const vaultIcon = icon ? renderableVaultIcon(icon) : undefined;
|
|
814
|
+
if (vaultIcon)
|
|
815
|
+
header.push(`icon: ${vaultIcon}`);
|
|
469
816
|
if (header.length === 0 && !content)
|
|
470
817
|
return "";
|
|
471
818
|
const meta = header.join("\n");
|
|
@@ -487,13 +834,20 @@ function apiSymbolToObsidian(node) {
|
|
|
487
834
|
const level = node.type === "api-class" ? 3 : 4;
|
|
488
835
|
const hashes = "#".repeat(level);
|
|
489
836
|
const sig = node.props?.signature ?? "";
|
|
490
|
-
|
|
837
|
+
// The parser emits `bases` as a string; the RST/autodoc importer (and
|
|
838
|
+
// format-astro's read) may use a string[]. Tolerate both.
|
|
839
|
+
const basesRaw = node.props?.bases;
|
|
840
|
+
const bases = Array.isArray(basesRaw)
|
|
841
|
+
? basesRaw.join(", ")
|
|
842
|
+
: basesRaw;
|
|
491
843
|
const parts = [];
|
|
492
|
-
parts.push(`${hashes} ${kind}
|
|
844
|
+
parts.push(`${hashes} ${esc(kind)} ${codeSpan(name)}`);
|
|
493
845
|
if (bases)
|
|
494
|
-
parts.push(`Bases:
|
|
495
|
-
if (sig)
|
|
496
|
-
|
|
846
|
+
parts.push(`Bases: ${codeSpan(bases)}`);
|
|
847
|
+
if (sig) {
|
|
848
|
+
const fence = chooseFence(sig);
|
|
849
|
+
parts.push(`${fence}python\n${sig}\n${fence}`);
|
|
850
|
+
}
|
|
497
851
|
if (node.children)
|
|
498
852
|
parts.push(childrenToObsidian(node));
|
|
499
853
|
return parts.filter(Boolean).join("\n\n");
|
|
@@ -542,7 +896,7 @@ function apiDocToObsidian(node) {
|
|
|
542
896
|
// api-doc has HTML content in props.html — convert to markdown
|
|
543
897
|
const html = node.props?.html ?? "";
|
|
544
898
|
if (html)
|
|
545
|
-
return
|
|
899
|
+
return htmlToObsidian(html);
|
|
546
900
|
return childrenToObsidian(node);
|
|
547
901
|
}
|
|
548
902
|
function apiSourceToObsidian(node) {
|
|
@@ -557,6 +911,166 @@ function apiSourceToObsidian(node) {
|
|
|
557
911
|
if (code) {
|
|
558
912
|
parts.push(`\`\`\`python\n${code.replace(/\n$/, "")}\n\`\`\``);
|
|
559
913
|
}
|
|
914
|
+
else if (node.children && node.children.length > 0) {
|
|
915
|
+
// The dogsbay-md parser puts the fenced source into a child `code` node
|
|
916
|
+
// (props.language + node.html), not props.code — reading only props.code
|
|
917
|
+
// dropped the source block. Serialize children as a fallback; the
|
|
918
|
+
// props.code path stays for importers that carry it there.
|
|
919
|
+
const inner = childrenToObsidian(node);
|
|
920
|
+
if (inner)
|
|
921
|
+
parts.push(inner);
|
|
922
|
+
}
|
|
923
|
+
return parts.join("\n\n");
|
|
924
|
+
}
|
|
925
|
+
const objects = (v) => (Array.isArray(v) ? v.filter((x) => !!x && typeof x === "object") : []);
|
|
926
|
+
const text = (v) => (v == null ? "" : String(v));
|
|
927
|
+
/** A GFM table cell: one line, pipes escaped (GFM unescapes them inside code spans too). */
|
|
928
|
+
function cell(md) {
|
|
929
|
+
return oneLine(md).replace(/\|/g, "\\|");
|
|
930
|
+
}
|
|
931
|
+
/** A spec description (CommonMark, from someone else's file) in a table cell, inert. */
|
|
932
|
+
function descriptionCell(p) {
|
|
933
|
+
const parts = [neutralizeMarkdown(oneLine(text(p.description)))];
|
|
934
|
+
if (Array.isArray(p.enum) && p.enum.length)
|
|
935
|
+
parts.push(`One of: ${p.enum.map((v) => codeSpan(text(v))).join(", ")}`);
|
|
936
|
+
if (p.default !== undefined)
|
|
937
|
+
parts.push(`Default: ${codeSpan(typeof p.default === "string" ? p.default : JSON.stringify(p.default))}`);
|
|
938
|
+
if (p.example !== undefined)
|
|
939
|
+
parts.push(`Example: ${codeSpan(JSON.stringify(p.example))}`);
|
|
940
|
+
if (p.deprecated === true)
|
|
941
|
+
parts.push("*Deprecated.*");
|
|
942
|
+
return cell(parts.filter(Boolean).join(" "));
|
|
943
|
+
}
|
|
944
|
+
function typeCell(p) {
|
|
945
|
+
const t = text(p.type) || "any";
|
|
946
|
+
return cell(codeSpan(p.format ? `${t} <${text(p.format)}>` : t) + (p.nullable === true ? " (nullable)" : ""));
|
|
947
|
+
}
|
|
948
|
+
function table(head, rows) {
|
|
949
|
+
return [`| ${head.join(" | ")} |`, `|${head.map(() => " --- ").join("|")}|`, ...rows.map((r) => `| ${r.join(" | ")} |`)].join("\n");
|
|
950
|
+
}
|
|
951
|
+
/** Schema properties, nested ones flattened to `owner.id` / `tags[].name`, as the rows of one table. */
|
|
952
|
+
function propertyRows(props, prefix = "", depth = 0) {
|
|
953
|
+
if (depth > 10)
|
|
954
|
+
return [];
|
|
955
|
+
return props.flatMap((p) => {
|
|
956
|
+
const name = `${prefix}${text(p.name)}`;
|
|
957
|
+
const row = [cell(codeSpan(name)), typeCell(p), p.required === true ? "yes" : "", descriptionCell(p)];
|
|
958
|
+
const nested = objects(p.properties).length
|
|
959
|
+
? propertyRows(objects(p.properties), `${name}.`, depth + 1)
|
|
960
|
+
: propertyRows(objects(p.items), `${name}[].`, depth + 1);
|
|
961
|
+
return [row, ...nested];
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
/** A foldable callout holding a code block (samples and examples). */
|
|
965
|
+
function codeCallout(title, code, lang) {
|
|
966
|
+
const fence = chooseFence(code);
|
|
967
|
+
// 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.
|
|
968
|
+
const info = /^[\w#+.-]{1,40}$/.test(lang) ? lang : "";
|
|
969
|
+
return `> [!example]- ${esc(oneLine(title))}\n${prefixLines(`${fence}${info}\n${code.replace(/\n$/, "")}\n${fence}`, "> ")}`;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* A spec description, inert; its own headings as bold lines (they would sit
|
|
973
|
+
* in the note's outline under the operation, as on the web, where they are
|
|
974
|
+
* bold paragraphs); and any fence it opens closed: an unclosed one would turn
|
|
975
|
+
* the rest of the note into code.
|
|
976
|
+
*/
|
|
977
|
+
function specMarkdown(md) {
|
|
978
|
+
let open;
|
|
979
|
+
const lines = neutralizeMarkdown(md).split("\n").map((line) => {
|
|
980
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
981
|
+
if (m) {
|
|
982
|
+
if (!open)
|
|
983
|
+
open = m[1];
|
|
984
|
+
else if (m[1][0] === open[0] && m[1].length >= open.length && line.trim() === m[1])
|
|
985
|
+
open = undefined;
|
|
986
|
+
return line;
|
|
987
|
+
}
|
|
988
|
+
const h = open ? null : /^ {0,3}#{1,6}(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/.exec(line);
|
|
989
|
+
return h ? (h[1] ? `**${h[1]}**` : "") : line;
|
|
990
|
+
});
|
|
991
|
+
// Setext headings (a text line underlined with === or ---): the text bold, the underline gone.
|
|
992
|
+
for (let k = 1, fence = false; k < lines.length; k++) {
|
|
993
|
+
if (/^ {0,3}(`{3,}|~{3,})/.test(lines[k - 1]))
|
|
994
|
+
fence = !fence;
|
|
995
|
+
const prev = lines[k - 1];
|
|
996
|
+
if (fence || !/^ {0,3}(=+|-+)[ \t]*$/.test(lines[k]))
|
|
997
|
+
continue;
|
|
998
|
+
if (!prev.trim() || /^ {0,3}([-*+>]|\d+[.)]|#|\*\*.*\*\*$)/.test(prev))
|
|
999
|
+
continue;
|
|
1000
|
+
lines[k - 1] = `**${prev.trim()}**`;
|
|
1001
|
+
lines[k] = "";
|
|
1002
|
+
}
|
|
1003
|
+
const out = lines.join("\n");
|
|
1004
|
+
return open ? `${out}\n${open}` : out;
|
|
1005
|
+
}
|
|
1006
|
+
/** ExampleBlock's language for a response content type. */
|
|
1007
|
+
function exampleLang(contentType) {
|
|
1008
|
+
const base = contentType.split(";")[0].trim();
|
|
1009
|
+
if (!base || base.includes("json"))
|
|
1010
|
+
return "json";
|
|
1011
|
+
if (base.includes("xml"))
|
|
1012
|
+
return "xml";
|
|
1013
|
+
if (base.includes("yaml") || base.includes("yml"))
|
|
1014
|
+
return "yaml";
|
|
1015
|
+
if (base === "text/html")
|
|
1016
|
+
return "html";
|
|
1017
|
+
return /^text\/plain$|form/.test(base) ? "" : "json";
|
|
1018
|
+
}
|
|
1019
|
+
const SAMPLE_LABELS = {
|
|
1020
|
+
curl: "cURL", python: "Python", typescript: "TypeScript", javascript: "JavaScript", go: "Go",
|
|
1021
|
+
ruby: "Ruby", java: "Java", csharp: "C#", php: "PHP", rust: "Rust",
|
|
1022
|
+
};
|
|
1023
|
+
/**
|
|
1024
|
+
* An OpenAPI operation (`endpoint`, from format-openapi or the MDX adapters)
|
|
1025
|
+
* as vault Markdown: the method and path as a heading, the description,
|
|
1026
|
+
* tables for parameters, the request body and responses, and the code
|
|
1027
|
+
* samples and examples as folded callouts. Every description is the spec
|
|
1028
|
+
* author's Markdown, made inert (neutralizeMarkdown) as format-astro renders
|
|
1029
|
+
* it with raw HTML off. With no case here the default emitted the node's
|
|
1030
|
+
* (absent) children: every operation vanished from a vault without a word.
|
|
1031
|
+
*/
|
|
1032
|
+
function endpointToObsidian(node) {
|
|
1033
|
+
const p = (node.props ?? {});
|
|
1034
|
+
const method = (text(p.method) || "get").toUpperCase();
|
|
1035
|
+
const path = text(p.path);
|
|
1036
|
+
const parts = [`### ${codeSpan(method)} ${codeSpan(path)}`];
|
|
1037
|
+
if (p.summary)
|
|
1038
|
+
parts.push(esc(oneLine(text(p.summary))));
|
|
1039
|
+
if (p.deprecated === true)
|
|
1040
|
+
parts.push("> [!warning] Deprecated\n> This endpoint is deprecated.");
|
|
1041
|
+
if (typeof p.description === "string" && p.description.trim() && p.description !== p.summary) {
|
|
1042
|
+
parts.push(specMarkdown(p.description.trim()));
|
|
1043
|
+
}
|
|
1044
|
+
if (p.baseUrl)
|
|
1045
|
+
parts.push(`URL: ${codeSpan(text(p.baseUrl) + path)}`);
|
|
1046
|
+
const params = objects(p.parameters);
|
|
1047
|
+
if (params.length) {
|
|
1048
|
+
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)])));
|
|
1049
|
+
}
|
|
1050
|
+
const body = objects(p.requestBody);
|
|
1051
|
+
if (body.length)
|
|
1052
|
+
parts.push("**Request body**", table(["Property", "Type", "Required", "Description"], propertyRows(body)));
|
|
1053
|
+
const responses = objects(p.responses);
|
|
1054
|
+
if (responses.length) {
|
|
1055
|
+
parts.push("**Responses**", table(["Status", "Description"], responses.map((r) => [cell(codeSpan(text(r.status))), cell(neutralizeMarkdown(oneLine(text(r.description))))])));
|
|
1056
|
+
for (const r of responses) {
|
|
1057
|
+
const schema = objects(r.schema);
|
|
1058
|
+
if (!schema.length)
|
|
1059
|
+
continue;
|
|
1060
|
+
parts.push(`> [!info]- ${esc(`Response ${text(r.status)} schema`)}\n${prefixLines(table(["Property", "Type", "Required", "Description"], propertyRows(schema)), "> ")}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
for (const s of objects(p.codeSamples)) {
|
|
1064
|
+
const lang = text(s.language);
|
|
1065
|
+
parts.push(codeCallout(text(s.label) || SAMPLE_LABELS[lang] || lang, text(s.code), lang === "curl" ? "bash" : lang));
|
|
1066
|
+
}
|
|
1067
|
+
if (p.requestBodyExample !== undefined)
|
|
1068
|
+
parts.push(codeCallout("Request body example", JSON.stringify(p.requestBodyExample, null, 2), "json"));
|
|
1069
|
+
for (const r of responses) {
|
|
1070
|
+
if (r.example === undefined)
|
|
1071
|
+
continue;
|
|
1072
|
+
parts.push(codeCallout(`Response ${text(r.status)} example`, JSON.stringify(r.example, null, 2), exampleLang(text(r.contentType))));
|
|
1073
|
+
}
|
|
560
1074
|
return parts.join("\n\n");
|
|
561
1075
|
}
|
|
562
1076
|
// ── Footnotes ───────────────────────────────────────────
|
|
@@ -566,8 +1080,13 @@ function footnoteListToObsidian(node) {
|
|
|
566
1080
|
return node.children.map(nodeToObsidian).join("\n");
|
|
567
1081
|
}
|
|
568
1082
|
function footnoteDefToObsidian(node) {
|
|
569
|
-
const label = node.props?.label ?? "1";
|
|
570
|
-
|
|
1083
|
+
const label = footnoteLabel(String(node.props?.label ?? "1"));
|
|
1084
|
+
// The dogsbay-md parser puts the definition body on `inline` (flat
|
|
1085
|
+
// shape); reading only `children` dropped every footnote body — the
|
|
1086
|
+
// three-TreeNode-shapes lesson documented at leafContent below.
|
|
1087
|
+
const content = node.inline && node.inline.length > 0
|
|
1088
|
+
? inlineToObsidian(node.inline)
|
|
1089
|
+
: childrenToObsidian(node);
|
|
571
1090
|
return `[^${label}]: ${content}`;
|
|
572
1091
|
}
|
|
573
1092
|
// ── Utilities ───────────────────────────────────────────
|
|
@@ -576,50 +1095,65 @@ function childrenToObsidian(node) {
|
|
|
576
1095
|
return "";
|
|
577
1096
|
return node.children.map(nodeToObsidian).filter(Boolean).join("\n\n");
|
|
578
1097
|
}
|
|
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
1098
|
/**
|
|
593
|
-
*
|
|
594
|
-
*
|
|
1099
|
+
* Text-bearing content across all THREE TreeNode shapes.
|
|
1100
|
+
*
|
|
1101
|
+
* Was a local re-derivation handling only two of them (flat `inline` and
|
|
1102
|
+
* `children`). It now goes through the core's `renderLeaf`, which adds the
|
|
1103
|
+
* third — a node carrying only `html`, or a single wrapped `prose` child
|
|
1104
|
+
* carrying `html` with no `inline` (what the MDX/Starlight/MkDocs importers
|
|
1105
|
+
* emit). Reading only the first two rendered those nodes EMPTY; that is the
|
|
1106
|
+
* bug that shipped in the Docusaurus exporter's first version and dropped
|
|
1107
|
+
* every paragraph body on a real Mintlify conversion.
|
|
1108
|
+
*
|
|
1109
|
+
* The `join` is "\n\n" to preserve this serializer's paragraph spacing (the
|
|
1110
|
+
* core defaults to "\n").
|
|
595
1111
|
*/
|
|
596
|
-
function
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return "`".repeat(max + 1);
|
|
1112
|
+
function leafContent(node) {
|
|
1113
|
+
return renderLeaf(node, {
|
|
1114
|
+
inline: inlineToObsidian,
|
|
1115
|
+
children: (children) => children.map(nodeToObsidian).filter(Boolean).join("\n\n"),
|
|
1116
|
+
html: htmlToObsidian,
|
|
1117
|
+
join: "\n\n",
|
|
1118
|
+
});
|
|
606
1119
|
}
|
|
607
1120
|
/**
|
|
608
|
-
*
|
|
609
|
-
*
|
|
1121
|
+
* Render pre-rendered prose HTML as Obsidian markdown.
|
|
1122
|
+
*
|
|
1123
|
+
* `stripHtml` alone DELETES tags, so every `<code>`, `<strong>`, `<em>` and
|
|
1124
|
+
* `<a>` an importer hands us became plain text. That is invisible for sources
|
|
1125
|
+
* whose prose nodes also carry `inline` (mkdocs), and catastrophic for those
|
|
1126
|
+
* that carry ONLY html — the MDX/Mintlify shape. Measured on a full Mintlify
|
|
1127
|
+
* export before this fix: 38,994 inline code spans in the source became 6,864
|
|
1128
|
+
* in the vault, and 14,548 bold runs became 3,465. Roughly 80% of inline
|
|
1129
|
+
* formatting, silently gone, on the export we point at as the back-out.
|
|
1130
|
+
*
|
|
1131
|
+
* `inlineHtmlToMarkdown` converts the unambiguous inline tags to real markdown
|
|
1132
|
+
* first; `stripHtml` then removes whatever markup is left and decodes entities.
|
|
610
1133
|
*/
|
|
611
|
-
function
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1134
|
+
function htmlToObsidian(html) {
|
|
1135
|
+
// Applied HERE because this is the shared path for prose, html-container and
|
|
1136
|
+
// inline HTML. Raw `<img src="/_assets/…">` most often arrives inline inside
|
|
1137
|
+
// a paragraph, not as a standalone `html` node — 39 vault images stayed
|
|
1138
|
+
// site-rooted when only the block case was covered.
|
|
1139
|
+
// BEFORE the html→markdown conversion: `inlineHtmlToMarkdown` turns
|
|
1140
|
+
// `<img src>` into ``, after which there is no `src=` attribute
|
|
1141
|
+
// left to rewrite.
|
|
1142
|
+
// stripHtml decodes entities: an `<img onerror=…>` that was TEXT
|
|
1143
|
+
// came back tag-shaped, and Obsidian renders inline HTML.
|
|
1144
|
+
// Code spans are left as written: a backslash there would show.
|
|
1145
|
+
return mapOutsideCodeSpans(stripHtml(inlineHtmlToMarkdown(vaultifyHtmlAssets(html), { flatten: true })), (t) => t.replace(/<(?=[A-Za-z/!?])/g, "\\<"));
|
|
1146
|
+
}
|
|
1147
|
+
/** Literal text as vault Markdown (`\<`, which reads better than `<`
|
|
1148
|
+
* in a file people edit). */
|
|
1149
|
+
function esc(text) {
|
|
1150
|
+
return escapeMarkdownText(text, { style: "backslash" });
|
|
1151
|
+
}
|
|
1152
|
+
/** Text for a one-line header or label: newlines folded to spaces. */
|
|
1153
|
+
function oneLine(s) {
|
|
1154
|
+
return s.replace(/\s*[\r\n]+\s*/g, " ");
|
|
1155
|
+
}
|
|
1156
|
+
/** Obsidian's code fence, via the core's shared longest-run picker. */
|
|
1157
|
+
function chooseFence(content) {
|
|
1158
|
+
return pickCodeFence(content);
|
|
1159
|
+
}
|