@dogsbay/format-obsidian 0.2.0-beta.156 → 0.2.0-beta.157

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.
@@ -99,8 +99,8 @@ export const OBSIDIAN_CAPABILITIES = [
99
99
  {
100
100
  feature: "link-button",
101
101
  support: "partial",
102
- rendersAs: "an ordinary Markdown link",
103
- note: "Button styling and variant are lost; an unsafe href leaves the label as plain text.",
102
+ rendersAs: "`dogsbay-link-button` block (plugin renders the button with its variant)",
103
+ note: "A button is a block in the vault: one written inline in a sentence stands on its own line. An unsafe href leaves the label as plain text.",
104
104
  },
105
105
  { feature: "related", support: "full", rendersAs: "bold label + the link list" },
106
106
  {
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
- import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync, readdirSync, statSync } from "node:fs";
2
- import { join, dirname, resolve, relative } from "node:path";
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync, statSync, copyFileSync } from "node:fs";
2
+ import { join, dirname, resolve, relative, isAbsolute } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { exportObsidianVault } from "./project.js";
5
+ import { mountCopies, assetsReferenced, assetCopies } from "@dogsbay/corpus";
6
+ import { readFolder } from "@dogsbay/corpus/folder";
7
+ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
5
8
  import { OBSIDIAN_CAPABILITIES } from "./capabilities.js";
6
9
  const PLUGIN_ID = "dogsbay-docs";
7
10
  export const plugin = {
@@ -12,7 +15,7 @@ export const plugin = {
12
15
  detectSource: (path) => existsSync(join(path, ".obsidian")),
13
16
  importOptions: [],
14
17
  exportOptions: [
15
- { flags: "--tabs <mode>", description: "Tab handling: dogsbay-tabs (default) or flatten", default: "dogsbay-tabs" },
18
+ { flags: "--assets <mode>", description: "Asset files: used (only what the notes refer to, default) or all (every file the importer mounted)", default: "used" },
16
19
  { flags: "--plugin <mode>", description: "Plugin setup: bundled (copy into vault), registry (list only), none", default: "bundled" },
17
20
  { flags: "--graph-groups <path>", description: "JSON file of graph-view colour groups: [{ \"query\": \"path:guides\", \"color\": \"#F2B36F\" }]. Replaces the default hub group. Ignored if the vault already has a graph.json" },
18
21
  ],
@@ -29,39 +32,72 @@ export const plugin = {
29
32
  writeFileSync(fullPath, content);
30
33
  }
31
34
  console.log(`Generated ${result.files.size} vault files`);
32
- // Mounted asset dirs (a Starlight `src/assets`, a Docusaurus `static/`),
33
- // copied to `_assets/` to match the `/_assets/…` refs importers rewrite
34
- // image paths to. Without this the vault carried the reference and not the
35
- // file — the note renders with a broken image and nothing reports it.
36
- const mounts = opts.assetMounts;
37
- for (const mount of mounts ?? []) {
35
+ // Mounted asset dirs (a Starlight `src/assets`, a Docusaurus `static/`), copied to where the
36
+ // `/_assets/…` refs importers rewrite image paths to point. Without this the vault carried the
37
+ // reference and not the file. Each mount is read through @dogsbay/corpus's contained reader
38
+ // (a symlink out of it, `node_modules` and `.git` are not copied — cpSync copied links to
39
+ // anywhere) and listed by the same rules as the Dogsbay MD export (`mountCopies`). By default
40
+ // only what a note refers to is copied: a whole Docusaurus `static/` brought other products'
41
+ // images and the site's `_headers`/`_redirects` (Tigera: 149 MB copied, 38 MB used).
42
+ const mounts = opts.assetMounts ?? [];
43
+ const assetsMode = opts.assets ?? "used";
44
+ if (assetsMode !== "used" && assetsMode !== "all")
45
+ throw new Error(`--assets must be used or all (got ${JSON.stringify(assetsMode)})`);
46
+ const copies = [];
47
+ for (const mount of mounts) {
38
48
  if (!existsSync(mount.dir))
39
49
  continue;
40
- // Folders the mount leaves out (VitePress: `public/`, a mount of its own)
41
- const skipped = new Set((mount.skip ?? []).map((f) => join(mount.dir, f)));
42
- // The mount carries the root its refs were rewritten to — `_assets`
43
- // for an imported corpus, `images`/`assets` for hand-authored content.
44
- // Hardcoding `_assets` here would copy the file somewhere the
45
- // reference does not point, which is the broken-image-and-no-report
46
- // failure this copy exists to prevent.
47
- const root = mount.root ?? "_assets";
48
- const dest = mount.into ? join(output, root, mount.into) : join(output, root);
49
- mkdirSync(dest, { recursive: true });
50
- // `mediaOnly` mounts are whole docs trees (the Docusaurus and VitePress
51
- // importers mount their entire docs dir this way) — copying them wholesale
52
- // buries hundreds of duplicate .md/.mdx notes under _assets/. Take only
53
- // the media, matching format-dogsbay-md's copyMediaTree.
54
- if (mount.mediaOnly) {
55
- copyMediaTree(mount.dir, dest, skipped);
50
+ const folder = readFolder(mount.dir);
51
+ for (const c of mountCopies(folder.paths, [{ ...mount, dir: "" }]))
52
+ copies.push({ from: folder.absolute(c.from), to: c.to });
53
+ }
54
+ const roots = [...new Set(mounts.map((m) => m.root ?? "_assets"))];
55
+ const notes = [...result.files.values()].filter((v) => typeof v === "string");
56
+ // The same rules as Docusaurus's browser import (@dogsbay/corpus): references in every
57
+ // spelling, the last mount wins a path, an HTML file brings its folder, a note wins its path
58
+ const used = assetsMode === "all" ? undefined : assetsReferenced(notes, new Set(copies.map((c) => c.to)), roots);
59
+ const chosen = assetCopies(copies, new Set(result.files.keys()), used, roots.flatMap((r) => [`${r}/`, `${r}/pages/`]));
60
+ const written = new Set();
61
+ const outside = [];
62
+ let bytes = 0;
63
+ for (const { from, to } of chosen) {
64
+ // never outside the vault (a mount's `into`/`root` from an importer)
65
+ const dest = resolve(output, to);
66
+ const rel = relative(resolve(output), dest);
67
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
68
+ outside.push(to);
56
69
  continue;
57
70
  }
58
- cpSync(mount.dir, dest, { recursive: true, filter: (src) => !skipped.has(src) });
71
+ mkdirSync(dirname(dest), { recursive: true });
72
+ copyFileSync(from, dest);
73
+ written.add(to);
74
+ bytes += statSync(dest).size;
75
+ }
76
+ if (outside.length)
77
+ console.warn(` skipped ${outside.length} asset path(s) outside the vault, e.g. ${JSON.stringify(outside[0])}`);
78
+ const seen = written;
79
+ if (copies.length) {
80
+ console.log(assetsMode === "all"
81
+ ? `Copied ${seen.size} asset file(s) (${(bytes / 1e6).toFixed(1)} MB)`
82
+ : `Copied ${seen.size} of ${copies.length} asset file(s) the notes refer to (${(bytes / 1e6).toFixed(1)} MB); --assets all copies every file`);
59
83
  }
60
- // Co-located images from the source docs directory (the mkdocs shape,
61
- // where media sits next to the markdown rather than in its own tree).
84
+ // Co-located images from the source docs directory (the mkdocs shape, where media sits next
85
+ // to the markdown rather than in its own tree): read contained too, at the same place.
62
86
  const sourceDir = opts.sourceDir;
63
- if (sourceDir) {
64
- copyImages(sourceDir, output);
87
+ if (sourceDir && existsSync(sourceDir)) {
88
+ const folder = readFolder(sourceDir);
89
+ let count = 0;
90
+ for (const path of folder.paths) {
91
+ const dot = path.lastIndexOf(".");
92
+ if (dot === -1 || !IMAGE_EXTS.has(path.slice(dot).toLowerCase()))
93
+ continue;
94
+ const dest = join(output, path);
95
+ mkdirSync(dirname(dest), { recursive: true });
96
+ copyFileSync(folder.absolute(path), dest);
97
+ count++;
98
+ }
99
+ if (count > 0)
100
+ console.log(`Copied ${count} images into vault`);
65
101
  }
66
102
  // Set up plugin
67
103
  if (pluginMode === "bundled") {
@@ -201,34 +237,6 @@ function installGraphConfig(vaultDir, groupsPath) {
201
237
  }, null, 2) + "\n");
202
238
  console.log("Pre-configured graph view (.obsidian/graph.json)");
203
239
  }
204
- function copyImages(sourceDir, vaultDir) {
205
- const imageExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
206
- let count = 0;
207
- function walk(dir) {
208
- for (const entry of readdirSync(dir)) {
209
- const full = join(dir, entry);
210
- if (statSync(full).isDirectory()) {
211
- walk(full);
212
- }
213
- else {
214
- const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
215
- if (imageExts.has(ext)) {
216
- const rel = relative(sourceDir, full);
217
- const dest = join(vaultDir, rel);
218
- mkdirSync(dirname(dest), { recursive: true });
219
- cpSync(full, dest);
220
- count++;
221
- }
222
- }
223
- }
224
- }
225
- try {
226
- walk(sourceDir);
227
- }
228
- catch { /* source may not exist */ }
229
- if (count > 0)
230
- console.log(`Copied ${count} images into vault`);
231
- }
232
240
  /**
233
241
  * Find the built plugin dist directory.
234
242
  * Searches relative to this package (sibling repo) and common locations.
@@ -251,31 +259,3 @@ function resolvePluginDist() {
251
259
  }
252
260
  return null;
253
261
  }
254
- /** Media extensions carried across for a `mediaOnly` mount — mirrors format-dogsbay-md. */
255
- const MEDIA_EXTS = new Set([
256
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
257
- ".svg", ".ico", ".pdf", ".mp4", ".webm",
258
- ]);
259
- /**
260
- * Copy only media files out of a mount, preserving its subtree shape.
261
- *
262
- * A `mediaOnly` mount is a whole docs directory (Docusaurus and VitePress both
263
- * mount theirs that way), so a recursive copy would bury every `.md`/`.mdx` in
264
- * the source under `_assets/` as duplicate notes.
265
- */
266
- function copyMediaTree(srcDir, destDir, skip) {
267
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
268
- const full = join(srcDir, entry.name);
269
- if (skip?.has(full))
270
- continue;
271
- if (entry.isDirectory()) {
272
- copyMediaTree(full, join(destDir, entry.name), skip);
273
- continue;
274
- }
275
- const dot = entry.name.lastIndexOf(".");
276
- if (dot === -1 || !MEDIA_EXTS.has(entry.name.slice(dot).toLowerCase()))
277
- continue;
278
- mkdirSync(destDir, { recursive: true });
279
- cpSync(full, join(destDir, entry.name));
280
- }
281
- }
package/dist/project.js CHANGED
@@ -157,30 +157,6 @@ export function exportObsidianVault(pages, nav, options = {}) {
157
157
  }
158
158
  }
159
159
  }
160
- // Minimal fence tracker for the two link passes below (format-obsidian
161
- // may not depend on format-dogsbay-md at runtime — its tracker lives
162
- // there). Open on a ```/~~~ run, close only on a run of the SAME char
163
- // at >= the opener's length with nothing after it (a shorter run inside
164
- // a longer fence is content).
165
- const makeFenceTracker = () => {
166
- let open = null;
167
- return (line) => {
168
- const m = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
169
- if (m) {
170
- const [, run, rest] = m;
171
- if (!open) {
172
- open = { char: run[0], len: run.length };
173
- return "fence";
174
- }
175
- if (run[0] === open.char && run.length >= open.len && rest.trim() === "") {
176
- open = null;
177
- return "fence";
178
- }
179
- return "code";
180
- }
181
- return open ? "code" : "prose";
182
- };
183
- };
184
160
  // Rescue links that spell the ORIGINAL repo path (#051). An xref can
185
161
  // name a DIFFERENT assembly of the same module than the one nav placed
186
162
  // (`edge_computing/…/update-completing-the-control-plane-only-update`
@@ -188,8 +164,10 @@ export function exportObsidianVault(pages, nav, options = {}) {
188
164
  // rewrite map above only knows nav-placed slugs. When the dangling
189
165
  // path's BASENAME names exactly one vault page, that page is the
190
166
  // target; ambiguous basenames stay dangling rather than guessing.
167
+ // Vault pages by BASENAME, for the two rescue passes below (links, then
168
+ // card/button hrefs).
169
+ const byBasename = new Map();
191
170
  {
192
- const byBasename = new Map();
193
171
  for (const f of files.keys()) {
194
172
  if (!f.endsWith(".md") || f === sectionNotePath(f.slice(0, f.lastIndexOf("/"))))
195
173
  continue;
@@ -380,6 +358,14 @@ export function exportObsidianVault(pages, nav, options = {}) {
380
358
  if (files.has(candidate))
381
359
  return `href: ${candidate}`;
382
360
  }
361
+ // Same rescue as a markdown link: a Starlight `<LinkButton
362
+ // href="/getting-started/">` names the page by its route while the
363
+ // vault filed it under its nav section (`start-here/`). A unique
364
+ // basename is the page; an ambiguous one stays as written.
365
+ const base = bare.slice(bare.lastIndexOf("/") + 1);
366
+ const candidates = byBasename.get(base);
367
+ if (candidates?.length === 1)
368
+ return `href: ${candidates[0]}.md`;
383
369
  return line;
384
370
  });
385
371
  if (updated !== content)
@@ -536,20 +522,37 @@ function sectionIndexTarget(child, folderPath, files, slugToVaultPath) {
536
522
  function navToYaml(items, indent = 0, slugToVaultPath) {
537
523
  const prefix = " ".repeat(indent);
538
524
  const lines = [];
525
+ // A destination that is not a vault file (a URL, an in-page anchor) is written under `href:`,
526
+ // not `path:`: the plugin opens every `path:` with `vault.getFileByPath()`, so such a row was a
527
+ // dead click in the sidebar and a dead stop in prev/next. The plugin (dogsbay-obsidian
528
+ // feat/plugin-round-2) opens a URL in the browser, an anchor in the open note, and keeps both
529
+ // out of prev/next.
530
+ const target = (href) => {
531
+ if (isExternalHref(href))
532
+ return `href: ${yamlStr(href)}`;
533
+ // An anchor in any spelling (`#opts`, `/#opts`, `/docs#opts`) as the plugin reads it, `#opts`;
534
+ // one with no fragment (`/?tab=b`) has nowhere to go (review)
535
+ if (isAnchorOnly(href)) {
536
+ const fragment = href.includes("#") ? href.slice(href.indexOf("#") + 1) : "";
537
+ return fragment ? `href: ${yamlStr(`#${fragment}`)}` : undefined;
538
+ }
539
+ return `path: ${yamlStr(resolveNavPath(href, slugToVaultPath))}`;
540
+ };
539
541
  for (const item of items) {
540
542
  if (item.children && item.children.length > 0) {
541
543
  lines.push(`${prefix}- label: ${yamlStr(item.label)}`);
542
- if (item.href) {
543
- const path = resolveNavPath(item.href, slugToVaultPath);
544
- lines.push(`${prefix} path: ${yamlStr(path)}`);
545
- }
544
+ const to = item.href ? target(item.href) : undefined;
545
+ if (to)
546
+ lines.push(`${prefix} ${to}`);
546
547
  lines.push(`${prefix} children:`);
547
548
  lines.push(navToYaml(item.children, indent + 2, slugToVaultPath));
548
549
  }
549
550
  else if (item.href) {
550
- const path = resolveNavPath(item.href, slugToVaultPath);
551
+ const to = target(item.href);
552
+ if (!to)
553
+ continue;
551
554
  lines.push(`${prefix}- label: ${yamlStr(item.label)}`);
552
- lines.push(`${prefix} path: ${yamlStr(path)}`);
555
+ lines.push(`${prefix} ${to}`);
553
556
  }
554
557
  }
555
558
  return lines.join("\n");
@@ -560,7 +563,7 @@ function resolveNavPath(href, slugToVaultPath) {
560
563
  // so a nav entry pointing at https://learn.mintlify.com became
561
564
  // "https://learn.mintlify.com.md" — a dangling path in every generated
562
565
  // _nav.yml that has an external destination.
563
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:"))
566
+ if (isExternalHref(href))
564
567
  return href;
565
568
  // An in-page anchor is not a vault path either — appending `.md` produced a
566
569
  // `#options.md` nav row pointing at a file that is never written, and if the
@@ -699,3 +702,30 @@ function yamlStr(s) {
699
702
  }
700
703
  return s;
701
704
  }
705
+ function isExternalHref(href) {
706
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href) || /^mailto:/i.test(href);
707
+ }
708
+ // Minimal fence tracker for the two link passes below (format-obsidian
709
+ // may not depend on format-dogsbay-md at runtime — its tracker lives
710
+ // there). Open on a ```/~~~ run, close only on a run of the SAME char
711
+ // at >= the opener's length with nothing after it (a shorter run inside
712
+ // a longer fence is content).
713
+ function makeFenceTracker() {
714
+ let open = null;
715
+ return (line) => {
716
+ const m = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
717
+ if (m) {
718
+ const [, run, rest] = m;
719
+ if (!open) {
720
+ open = { char: run[0], len: run.length };
721
+ return "fence";
722
+ }
723
+ if (run[0] === open.char && run.length >= open.len && rest.trim() === "") {
724
+ open = null;
725
+ return "fence";
726
+ }
727
+ return "code";
728
+ }
729
+ return open ? "code" : "prose";
730
+ };
731
+ }
package/dist/serialize.js CHANGED
@@ -165,15 +165,23 @@ function nodeToObsidian(node) {
165
165
  return childrenToObsidian(node);
166
166
  case "annotation-list":
167
167
  return childrenToObsidian(node);
168
- case "link-button": {
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;
173
- }
168
+ case "link-button":
169
+ return linkButtonToObsidian(node);
174
170
  case "hr":
171
+ // The MDX importers (Starlight, Docusaurus) emit `thematic-break` for a
172
+ // `---`; format-dogsbay-md emits `hr`. With no case, the default path
173
+ // returned "" for the MDX shape and every rule vanished from the vault.
174
+ case "thematic-break":
175
175
  return "---";
176
176
  case "html":
177
+ // A lone closing tag of a COMPONENT (`</Preview>`, `</FileTree>`): the
178
+ // MDX importer consumed the opener as JSX, but a closer that follows a
179
+ // blank line is an html_block to markdown-it and arrives here as raw
180
+ // HTML. Obsidian shows it as literal text — hundreds of lines on
181
+ // Starlight's docs. An HTML element's closer (`</div>`) is kept: it may
182
+ // pair with an opener passed through earlier.
183
+ if (isOrphanComponentCloser(node.html ?? ""))
184
+ return "";
177
185
  return vaultifyHtmlAssets(node.html ?? "");
178
186
  case "html-container":
179
187
  // Parsed from raw HTML (a one-line `<details>` body), the content is in
@@ -181,15 +189,10 @@ function nodeToObsidian(node) {
181
189
  if (!node.children?.length && node.html)
182
190
  return vaultifyHtmlAssets(node.html);
183
191
  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
- }
192
+ case "link-card":
193
+ // Same shape as a card with its one-paragraph body in
194
+ // `props.description`; `cardToObsidian` reads that itself.
195
+ return cardToObsidian(node);
193
196
  case "accordion":
194
197
  return (node.children ?? []).map(nodeToObsidian).filter(Boolean).join("\n\n");
195
198
  case "accordion-item":
@@ -720,7 +723,8 @@ function stepsToObsidian(node) {
720
723
  "";
721
724
  const content = childrenToObsidian(child);
722
725
  if (title) {
723
- return content ? `step: ${title}\n---\n${content}` : `step: ${title}\n---`;
726
+ const line = stepTitleLine(title);
727
+ return content ? `step: ${line}\n---\n${content}` : `step: ${line}\n---`;
724
728
  }
725
729
  // No title — just content (Starlight steps are titleless list items)
726
730
  return content ? `step:\n---\n${content}` : `step:\n---`;
@@ -789,7 +793,23 @@ function cardToObsidian(node) {
789
793
  const rawHref = node.props?.href;
790
794
  const href = rawHref && isSafeHref(rawHref) ? oneLine(rawHref) : undefined;
791
795
  const icon = node.props?.icon;
792
- const content = childrenToObsidian(node);
796
+ // The card is a fenced block, so its fence must differ from any fence its
797
+ // BODY writes — the same alternation tabs, steps and deflists use. A fixed
798
+ // triple-backtick fence (the old behaviour) was closed early by a code block
799
+ // in the body, and a card inside a backtick-fenced tabs/steps block closed
800
+ // that block instead; Starlight CardGrid pages hit both.
801
+ const fence = codeBlockNestingDepth % 2 === 0 ? "~~~" : "```";
802
+ codeBlockNestingDepth++;
803
+ let content = childrenToObsidian(node);
804
+ codeBlockNestingDepth--;
805
+ // The parser folds a one-paragraph body into `props.description` (a
806
+ // link-card, or Starlight's `<LinkCard description>` which maps to `card`),
807
+ // so the node has no children — read it, or the description is lost.
808
+ if (!content) {
809
+ const description = node.props?.description?.trim();
810
+ if (description)
811
+ content = esc(description);
812
+ }
793
813
  const header = [];
794
814
  if (title)
795
815
  header.push(`title: ${title}`);
@@ -821,9 +841,39 @@ function cardToObsidian(node) {
821
841
  return "";
822
842
  const meta = header.join("\n");
823
843
  if (content) {
824
- return `\`\`\`dogsbay-card\n${meta}\n---\n${content}\n\`\`\``;
844
+ return `${fence}dogsbay-card\n${meta}\n---\n${content}\n${fence}`;
825
845
  }
826
- return `\`\`\`dogsbay-card\n${meta}\n\`\`\``;
846
+ return `${fence}dogsbay-card\n${meta}\n${fence}`;
847
+ }
848
+ /**
849
+ * A `link-button` as the `dogsbay-link-button` block the dogsbay-docs plugin
850
+ * renders (`href:` / `variant:` / `---` / label). It used to be written as an
851
+ * ordinary Markdown link, which lost the call-to-action styling the plugin
852
+ * already had a processor for. With no safe href the label stays as text.
853
+ */
854
+ function linkButtonToObsidian(node) {
855
+ const raw = node.props?.href ?? "";
856
+ const href = raw && isSafeHref(raw) ? obsidianHref(raw) : "";
857
+ const text = childrenToObsidian(node) || esc(href);
858
+ if (!href)
859
+ return text;
860
+ const fence = codeBlockNestingDepth % 2 === 0 ? "~~~" : "```";
861
+ const variant = node.props?.variant;
862
+ // Resolved by the plugin the way a card's is (exact vault path), so an
863
+ // internal target carries `.md`; project.ts then points it at the file
864
+ // that exists.
865
+ const header = [`href: ${cardHref(oneLine(href))}`];
866
+ if (variant)
867
+ header.push(`variant: ${oneLine(variant)}`);
868
+ return `${fence}dogsbay-link-button\n${header.join("\n")}\n---\n${oneLine(text)}\n${fence}`;
869
+ }
870
+ /**
871
+ * Raw HTML that is nothing but closing tags of PascalCase names — a JSX
872
+ * component's closer the MDX importer could not pair with its opener.
873
+ */
874
+ function isOrphanComponentCloser(html) {
875
+ const t = html.trim();
876
+ return t.length > 0 && /^(<\/[A-Z][A-Za-z0-9]*\s*>\s*)+$/.test(t);
827
877
  }
828
878
  function cardsToObsidian(node) {
829
879
  // Flatten cards/grid to a simple unordered list
@@ -1161,3 +1211,15 @@ function oneLine(s) {
1161
1211
  function chooseFence(content) {
1162
1212
  return pickCodeFence(content);
1163
1213
  }
1214
+ /**
1215
+ * A step title on its `step:` line, which the plugin renders as Markdown: one line, and a start
1216
+ * that would make it a block (`1. Install` a list, `# x` a heading, `> x` a quote, `---` a rule)
1217
+ * escaped, so it stays the title's text (review, 2026-11-02).
1218
+ */
1219
+ function stepTitleLine(title) {
1220
+ return title
1221
+ .replace(/\s*\n\s*/g, " ")
1222
+ .replace(/^(\d{1,9})([.)])(?=\s|$)/, "$1\\$2")
1223
+ .replace(/^([>+]|[-*](?=\s)|#{1,6}(?=\s|$))/, "\\$1")
1224
+ .replace(/^([-*_])(?=(?:\s*\1){2,}\s*$)/, "\\$1");
1225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-obsidian",
3
- "version": "0.2.0-beta.156",
3
+ "version": "0.2.0-beta.157",
4
4
  "description": "Obsidian format plugin for Dogsbay — import/export Obsidian vaults via TreeNode[]",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,13 +23,14 @@
23
23
  "@types/node": "^25.9.5",
24
24
  "typescript": "^5.9.3",
25
25
  "vitest": "^4.1.10",
26
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.156",
27
- "@dogsbay/format-openapi": "0.2.0-beta.156"
26
+ "@dogsbay/format-openapi": "0.2.0-beta.157",
27
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.157"
28
28
  },
29
29
  "license": "MIT",
30
30
  "dependencies": {
31
- "@dogsbay/serialize-core": "0.2.0-beta.156",
32
- "@dogsbay/types": "0.2.0-beta.156"
31
+ "@dogsbay/corpus": "0.2.0-beta.157",
32
+ "@dogsbay/serialize-core": "0.2.0-beta.157",
33
+ "@dogsbay/types": "0.2.0-beta.157"
33
34
  },
34
35
  "repository": {
35
36
  "type": "git",