@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.
- package/dist/capabilities.js +2 -2
- package/dist/cli.js +66 -86
- package/dist/project.js +62 -32
- package/dist/serialize.js +81 -19
- package/package.json +6 -5
package/dist/capabilities.js
CHANGED
|
@@ -99,8 +99,8 @@ export const OBSIDIAN_CAPABILITIES = [
|
|
|
99
99
|
{
|
|
100
100
|
feature: "link-button",
|
|
101
101
|
support: "partial",
|
|
102
|
-
rendersAs: "
|
|
103
|
-
note: "
|
|
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,
|
|
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: "--
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
lines.push(`${prefix}
|
|
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
|
|
551
|
+
const to = target(item.href);
|
|
552
|
+
if (!to)
|
|
553
|
+
continue;
|
|
551
554
|
lines.push(`${prefix}- label: ${yamlStr(item.label)}`);
|
|
552
|
-
lines.push(`${prefix}
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
844
|
+
return `${fence}dogsbay-card\n${meta}\n---\n${content}\n${fence}`;
|
|
825
845
|
}
|
|
826
|
-
return
|
|
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.
|
|
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-
|
|
27
|
-
"@dogsbay/format-
|
|
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/
|
|
32
|
-
"@dogsbay/
|
|
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",
|