@dogsbay/format-obsidian 0.2.0-beta.1 → 0.2.0-beta.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +140 -31
- package/dist/cli.js.map +1 -1
- package/dist/project.d.ts +49 -2
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +501 -33
- package/dist/project.js.map +1 -1
- package/dist/serialize.d.ts +19 -0
- package/dist/serialize.d.ts.map +1 -1
- package/dist/serialize.js +444 -111
- package/dist/serialize.js.map +1 -1
- package/package.json +7 -5
package/dist/project.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { treeToObsidian } from "./serialize.js";
|
|
2
|
+
/**
|
|
3
|
+
* Path of a section's folder note.
|
|
4
|
+
*
|
|
5
|
+
* Named `_<folder>.md`, not `_index.md`: Obsidian labels graph nodes and tabs
|
|
6
|
+
* by FILENAME, so a vault of 309 sections showed 309 identical `_index` nodes.
|
|
7
|
+
* The leading underscore keeps the note sorted to the top of its folder and
|
|
8
|
+
* distinguishes it from a content page that shares the folder's name — 20
|
|
9
|
+
* sections in the OpenShift corpus contain `<folder>/<folder>.md`, so the bare
|
|
10
|
+
* folder name would collide. No page in that corpus starts with `_`.
|
|
11
|
+
*/
|
|
12
|
+
export function sectionNotePath(folderPath) {
|
|
13
|
+
const leaf = folderPath.slice(folderPath.lastIndexOf("/") + 1);
|
|
14
|
+
return `${folderPath}/_${leaf}.md`;
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* Export pages + nav to an Obsidian vault file map.
|
|
4
18
|
*/
|
|
@@ -16,23 +30,67 @@ export function exportObsidianVault(pages, nav, options = {}) {
|
|
|
16
30
|
const pathPrefix = section || "";
|
|
17
31
|
assignPaths(nav, pathPrefix, assignments, pageBySlug);
|
|
18
32
|
// Generate pages
|
|
33
|
+
//
|
|
34
|
+
// A page the nav does not place used to be flattened to its BASENAME at the
|
|
35
|
+
// vault root, so any two pages sharing one — `agent/customize` and
|
|
36
|
+
// `assistant/customize`, or the es/fr/zh translations of the same page —
|
|
37
|
+
// wrote to the same path and the later one silently overwrote the earlier.
|
|
38
|
+
// Measured on a real Mintlify export: 988 pages produced 881 files, i.e. 107
|
|
39
|
+
// pages vanished with no error. An unplaced page now keeps its SLUG PATH,
|
|
40
|
+
// which mirrors the source tree and cannot collide unless the source did.
|
|
41
|
+
const taken = new Map();
|
|
42
|
+
const actualVaultPath = new Map();
|
|
43
|
+
const collisions = [];
|
|
19
44
|
for (const page of pages) {
|
|
45
|
+
// A fragment is an include TARGET, not a note. `convert` realizes includes
|
|
46
|
+
// for this exporter, so the fragment's content is already spliced into
|
|
47
|
+
// every note that used it — emitting it again produced a duplicate,
|
|
48
|
+
// usually-untitled note sitting beside the real ones. (format-dogsbay-md
|
|
49
|
+
// does emit them, correctly: there the `{% include %}` is preserved, so the
|
|
50
|
+
// target file has to exist.)
|
|
51
|
+
if (page.fragment || page.frontmatter?._fragment === true)
|
|
52
|
+
continue;
|
|
20
53
|
const assignment = assignments.get(page.slug);
|
|
21
54
|
const fallbackFolder = section || "";
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
const unplaced = slugToVaultRelPath(page.slug);
|
|
56
|
+
// `folderPath` is "" for a root-level nav entry, so joining unconditionally
|
|
57
|
+
// produced a LEADING SLASH (`/index.md`). The write path tolerated it; the
|
|
58
|
+
// nav/link map re-derived its own path and silently did not. Now that both
|
|
59
|
+
// read one value, the join has to be correct.
|
|
60
|
+
let filePath = assignment
|
|
61
|
+
? joinVaultPath(assignment.folderPath, `${fileNameFromSlug(page.slug)}.md`)
|
|
62
|
+
: joinVaultPath(fallbackFolder, `${unplaced}.md`);
|
|
63
|
+
// Even with slug paths, a nav placement can still collide (two nav entries
|
|
64
|
+
// pointing at the same folder + basename). Never overwrite silently:
|
|
65
|
+
// disambiguate with the slug and record it for the caller to report.
|
|
66
|
+
// Keyed CASE-INSENSITIVELY. macOS and Windows filesystems are, so
|
|
67
|
+
// `API/Overview` and `api/overview` are two keys in a case-sensitive Map,
|
|
68
|
+
// no collision is recorded, both are written — and the second silently
|
|
69
|
+
// overwrites the first. The vault is a page short, `_nav.yml` points both
|
|
70
|
+
// entries at the survivor, and the warning that exists to surface exactly
|
|
71
|
+
// this never fires.
|
|
72
|
+
const key = filePath.toLowerCase();
|
|
73
|
+
if (taken.has(key)) {
|
|
74
|
+
const alt = `${filePath.slice(0, -3)}--${page.slug.replace(/\//g, "-")}.md`;
|
|
75
|
+
collisions.push({ path: filePath, kept: taken.get(key), renamed: page.slug });
|
|
76
|
+
filePath = alt;
|
|
77
|
+
}
|
|
78
|
+
taken.set(filePath.toLowerCase(), page.slug);
|
|
79
|
+
// The single source of truth for "where did this page end up". The nav and
|
|
80
|
+
// link-rewrite maps below MUST read this rather than re-deriving the path:
|
|
81
|
+
// when they re-derived it they used the basename, so an unplaced nested
|
|
82
|
+
// page's links pointed at a file that no longer exists, and a collision
|
|
83
|
+
// rename left the renamed file unreachable from nav.
|
|
84
|
+
actualVaultPath.set(page.slug, filePath.replace(/\.md$/, ""));
|
|
27
85
|
let content = treeToObsidian(page.tree);
|
|
28
86
|
// Prepend frontmatter — always include title + alias, add sidebar_position if from nav
|
|
29
87
|
if (assignment) {
|
|
30
|
-
const fm = buildFrontmatter(page.title, assignment.position);
|
|
88
|
+
const fm = buildFrontmatter(page.title, assignment.position, assignment.categories);
|
|
31
89
|
if (content.startsWith("---\n")) {
|
|
32
90
|
const endIdx = content.indexOf("\n---\n", 4);
|
|
33
91
|
if (endIdx !== -1) {
|
|
34
92
|
const existingFm = content.substring(4, endIdx);
|
|
35
|
-
content = `---\n${existingFm}\naliases:\n - ${yamlStr(page.title)}\nsidebar_position: ${assignment.position}\n---\n${content.substring(endIdx + 5)}`;
|
|
93
|
+
content = `---\n${existingFm}\naliases:\n - ${yamlStr(page.title)}\nsidebar_position: ${assignment.position}${categoryBlock(assignment.categories)}\n---\n${content.substring(endIdx + 5)}`;
|
|
36
94
|
}
|
|
37
95
|
}
|
|
38
96
|
else {
|
|
@@ -48,8 +106,10 @@ export function exportObsidianVault(pages, nav, options = {}) {
|
|
|
48
106
|
}
|
|
49
107
|
files.set(filePath, content);
|
|
50
108
|
}
|
|
51
|
-
// Generate
|
|
52
|
-
|
|
109
|
+
// Generate a folder note per nav section
|
|
110
|
+
if (options.sectionIndexes !== false) {
|
|
111
|
+
generateSectionIndexes(nav, pathPrefix, files, pageBySlug);
|
|
112
|
+
}
|
|
53
113
|
// Build slug → vault path lookup (without .md extension) for nav yaml and link rewriting
|
|
54
114
|
const slugToVaultPath = new Map();
|
|
55
115
|
// Also build link rewrite map: keys are paths as they appear in content links
|
|
@@ -57,19 +117,10 @@ export function exportObsidianVault(pages, nav, options = {}) {
|
|
|
57
117
|
const linkRewrites = new Map();
|
|
58
118
|
for (const page of pages) {
|
|
59
119
|
const assignment = assignments.get(page.slug);
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
vaultPath = assignment.folderPath
|
|
65
|
-
? `${assignment.folderPath}/${fileName}`
|
|
66
|
-
: fileName;
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
vaultPath = fallbackFolder
|
|
70
|
-
? `${fallbackFolder}/${fileName}`
|
|
71
|
-
: fileName;
|
|
72
|
-
}
|
|
120
|
+
// Where the page ACTUALLY went, recorded when it was written.
|
|
121
|
+
const vaultPath = actualVaultPath.get(page.slug);
|
|
122
|
+
if (!vaultPath)
|
|
123
|
+
continue;
|
|
73
124
|
slugToVaultPath.set(page.slug, vaultPath);
|
|
74
125
|
// Links in content use section-prefixed paths (e.g. "ddos-protection/managed-rulesets")
|
|
75
126
|
// The vault file path may differ (e.g. "ddos-protection/managed-rulesets/managed-rulesets")
|
|
@@ -77,6 +128,18 @@ export function exportObsidianVault(pages, nav, options = {}) {
|
|
|
77
128
|
if (linkPath !== vaultPath) {
|
|
78
129
|
linkRewrites.set(linkPath, vaultPath);
|
|
79
130
|
}
|
|
131
|
+
// The importer strips `/index` from a directory-landing slug
|
|
132
|
+
// (`rest_api/objects/index.md` → slug `rest_api/objects`), but
|
|
133
|
+
// engine-emitted content links spell the FULL source path
|
|
134
|
+
// (`rest_api/objects/index#anchor`). Register the index spelling too —
|
|
135
|
+
// without it every schema cross-reference on every API page dangled
|
|
136
|
+
// (issue #041: 2,320 stale links in 267 files). NOT unconditional:
|
|
137
|
+
// format-vitepress keeps `/index` in slugs, so `foo` and `foo/index`
|
|
138
|
+
// can be DISTINCT pages — the alias must never capture links meant
|
|
139
|
+
// for the real `/index` page.
|
|
140
|
+
if (!pageBySlug.has(`${page.slug}/index`)) {
|
|
141
|
+
linkRewrites.set(`${linkPath}/index`, vaultPath);
|
|
142
|
+
}
|
|
80
143
|
}
|
|
81
144
|
// Rewrite internal links: source paths → vault file paths
|
|
82
145
|
if (linkRewrites.size > 0) {
|
|
@@ -94,11 +157,277 @@ export function exportObsidianVault(pages, nav, options = {}) {
|
|
|
94
157
|
}
|
|
95
158
|
}
|
|
96
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
|
+
// Rescue links that spell the ORIGINAL repo path (#051). An xref can
|
|
185
|
+
// name a DIFFERENT assembly of the same module than the one nav placed
|
|
186
|
+
// (`edge_computing/…/update-completing-the-control-plane-only-update`
|
|
187
|
+
// when the page lives under postinstallation-configuration/…) — the
|
|
188
|
+
// rewrite map above only knows nav-placed slugs. When the dangling
|
|
189
|
+
// path's BASENAME names exactly one vault page, that page is the
|
|
190
|
+
// target; ambiguous basenames stay dangling rather than guessing.
|
|
191
|
+
{
|
|
192
|
+
const byBasename = new Map();
|
|
193
|
+
for (const f of files.keys()) {
|
|
194
|
+
if (!f.endsWith(".md") || f === sectionNotePath(f.slice(0, f.lastIndexOf("/"))))
|
|
195
|
+
continue;
|
|
196
|
+
const base = f.slice(f.lastIndexOf("/") + 1, -3);
|
|
197
|
+
const list = byBasename.get(base) ?? [];
|
|
198
|
+
list.push(f.slice(0, -3));
|
|
199
|
+
byBasename.set(base, list);
|
|
200
|
+
}
|
|
201
|
+
let rescued = 0;
|
|
202
|
+
// Anchored to LINK POSITION (`](…)`) — without the `](` prefix any
|
|
203
|
+
// parenthesized slash-token in prose was a candidate: the OpenShift
|
|
204
|
+
// corpus carries 1,533 shapes like `(POST/PUT/PATCH)`, `(read/write)`
|
|
205
|
+
// and `(CI/CD)` that pass every other guard, and one vault page named
|
|
206
|
+
// `write` or `patch` would have corrupted prose at scale (review,
|
|
207
|
+
// HIGH). Fence-masked for the same reason — code samples are full of
|
|
208
|
+
// `call(a/b)` shapes.
|
|
209
|
+
const linkRx = /\]\(([^()\s#]+)(#[^)\s]*)?\)/g;
|
|
210
|
+
for (const [filePath, content] of files) {
|
|
211
|
+
if (!filePath.endsWith(".md"))
|
|
212
|
+
continue;
|
|
213
|
+
const step = makeFenceTracker();
|
|
214
|
+
const updated = content
|
|
215
|
+
.split("\n")
|
|
216
|
+
.map((line) => {
|
|
217
|
+
if (step(line) !== "prose")
|
|
218
|
+
return line;
|
|
219
|
+
return line.replace(linkRx, (whole, dest, frag = "") => {
|
|
220
|
+
// Only vault-internal, multi-segment, non-resolving paths.
|
|
221
|
+
if (!dest.includes("/") || /^[a-z][a-z0-9+.-]*:/i.test(dest))
|
|
222
|
+
return whole;
|
|
223
|
+
if (dest.startsWith("_assets/") || /\.[a-z0-9]{2,4}$/i.test(dest))
|
|
224
|
+
return whole;
|
|
225
|
+
if (files.has(`${dest}.md`) || files.has(sectionNotePath(dest)))
|
|
226
|
+
return whole;
|
|
227
|
+
const base = dest.slice(dest.lastIndexOf("/") + 1);
|
|
228
|
+
const candidates = byBasename.get(base);
|
|
229
|
+
if (!candidates || candidates.length !== 1)
|
|
230
|
+
return whole;
|
|
231
|
+
rescued += 1;
|
|
232
|
+
return `](${candidates[0]}${frag})`;
|
|
233
|
+
});
|
|
234
|
+
})
|
|
235
|
+
.join("\n");
|
|
236
|
+
if (updated !== content)
|
|
237
|
+
files.set(filePath, updated);
|
|
238
|
+
}
|
|
239
|
+
if (rescued > 0) {
|
|
240
|
+
console.warn(`obsidian export: rescued ${rescued} link(s) spelling original repo paths (unique-basename match)`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Resolve `#^block` fragments against the TARGET file's actual block
|
|
244
|
+
// ids (#049). Cross-page xrefs name the SOURCE id, but the vault stamps
|
|
245
|
+
// anchors with the AsciiDoc `id_{context}` expansion — three mismatch
|
|
246
|
+
// shapes, all mechanical:
|
|
247
|
+
// 1. link bare, anchor suffixed → unique prefix match rewrites
|
|
248
|
+
// 2. link over-suffixed, anchor bare → unique longest-prefix strip
|
|
249
|
+
// 3. page-top id the vault never stamped → land on the H1's block id
|
|
250
|
+
// (the source intent was "this page"), or drop the fragment when
|
|
251
|
+
// the H1 carries none.
|
|
252
|
+
// The file half of these links already resolves — only the scroll
|
|
253
|
+
// position was lost. Obsidian resolves fragments literally, so a
|
|
254
|
+
// fragment the target cannot resolve is worthless to the reader.
|
|
255
|
+
{
|
|
256
|
+
const anchorIndex = new Map();
|
|
257
|
+
const indexFor = (file) => {
|
|
258
|
+
let entry = anchorIndex.get(file);
|
|
259
|
+
if (!entry) {
|
|
260
|
+
const content = files.get(file) ?? "";
|
|
261
|
+
const ids = new Set();
|
|
262
|
+
let h1 = null;
|
|
263
|
+
// Fence-aware: Obsidian does not create block ids inside code
|
|
264
|
+
// fences, so a code line ending ` ^word` (terminal transcripts,
|
|
265
|
+
// regex samples) must not become a phantom anchor that steals a
|
|
266
|
+
// prefix match from the real one (review).
|
|
267
|
+
const step = makeFenceTracker();
|
|
268
|
+
for (const line of content.split("\n")) {
|
|
269
|
+
if (step(line) !== "prose")
|
|
270
|
+
continue;
|
|
271
|
+
const m = line.match(/\s\^([A-Za-z0-9-]+)\s*$/);
|
|
272
|
+
if (!m)
|
|
273
|
+
continue;
|
|
274
|
+
ids.add(m[1]);
|
|
275
|
+
if (h1 === null && /^#\s/.test(line))
|
|
276
|
+
h1 = m[1];
|
|
277
|
+
}
|
|
278
|
+
entry = { ids, h1 };
|
|
279
|
+
anchorIndex.set(file, entry);
|
|
280
|
+
}
|
|
281
|
+
return entry;
|
|
282
|
+
};
|
|
283
|
+
let anchorRewrites = 0;
|
|
284
|
+
let anchorDrops = 0;
|
|
285
|
+
const blockLinkRx = /\(([^()\s#]*)#\^([A-Za-z0-9-]+)\)/g;
|
|
286
|
+
for (const [filePath, content] of files) {
|
|
287
|
+
if (!filePath.endsWith(".md"))
|
|
288
|
+
continue;
|
|
289
|
+
const updated = content.replace(blockLinkRx, (whole, dest, frag) => {
|
|
290
|
+
// An empty dest is a SAME-PAGE link — resolve against this file
|
|
291
|
+
// (cnf-talm's troubleshooting list carried 5 in-page fragments
|
|
292
|
+
// no anchor matched).
|
|
293
|
+
const targetFile = dest === ""
|
|
294
|
+
? filePath
|
|
295
|
+
: files.has(`${dest}.md`)
|
|
296
|
+
? `${dest}.md`
|
|
297
|
+
: files.has(sectionNotePath(dest))
|
|
298
|
+
? sectionNotePath(dest)
|
|
299
|
+
: null;
|
|
300
|
+
// Unresolvable FILE half is a different defect (#051) — leave it
|
|
301
|
+
// visible rather than masking it here.
|
|
302
|
+
if (!targetFile)
|
|
303
|
+
return whole;
|
|
304
|
+
const { ids, h1 } = indexFor(targetFile);
|
|
305
|
+
if (ids.has(frag))
|
|
306
|
+
return whole;
|
|
307
|
+
// 1. Bare source id vs suffixed anchor: unambiguous when the
|
|
308
|
+
// bare id prefixes exactly one anchor. TWO OR MORE candidate
|
|
309
|
+
// section anchors are positive evidence the link targeted a
|
|
310
|
+
// SECTION — declining beats the H1 fallback, which would
|
|
311
|
+
// destroy that evidence and make the link look "valid" to any
|
|
312
|
+
// later checker (review; same doctrine as #051's
|
|
313
|
+
// ambiguous-stays-dangling).
|
|
314
|
+
const prefixed = [...ids].filter((a) => a.startsWith(`${frag}-`));
|
|
315
|
+
if (prefixed.length === 1) {
|
|
316
|
+
anchorRewrites += 1;
|
|
317
|
+
return `(${dest}#^${prefixed[0]})`;
|
|
318
|
+
}
|
|
319
|
+
if (prefixed.length > 1)
|
|
320
|
+
return whole;
|
|
321
|
+
// 2. Over-suffixed link vs bare anchor: longest anchor the
|
|
322
|
+
// fragment extends, unique at that length; a tie declines for
|
|
323
|
+
// the same reason.
|
|
324
|
+
const stripped = [...ids]
|
|
325
|
+
.filter((a) => frag.startsWith(`${a}-`))
|
|
326
|
+
.sort((a, b) => b.length - a.length);
|
|
327
|
+
if (stripped.length > 0) {
|
|
328
|
+
if (stripped.filter((a) => a.length === stripped[0].length).length > 1) {
|
|
329
|
+
return whole;
|
|
330
|
+
}
|
|
331
|
+
anchorRewrites += 1;
|
|
332
|
+
return `(${dest}#^${stripped[0]})`;
|
|
333
|
+
}
|
|
334
|
+
// A target with NO block ids gives no evidence about its anchor
|
|
335
|
+
// scheme — leave the link alone rather than guessing (unit
|
|
336
|
+
// fixtures and hand-authored pages hit this).
|
|
337
|
+
if (ids.size === 0)
|
|
338
|
+
return whole;
|
|
339
|
+
// 3. Page-top reference the vault cannot name: the H1's block id
|
|
340
|
+
// preserves "scroll to the top of that page"; no H1 id → plain
|
|
341
|
+
// page link.
|
|
342
|
+
if (h1) {
|
|
343
|
+
anchorRewrites += 1;
|
|
344
|
+
return `(${dest}#^${h1})`;
|
|
345
|
+
}
|
|
346
|
+
anchorDrops += 1;
|
|
347
|
+
return `(${dest})`;
|
|
348
|
+
});
|
|
349
|
+
if (updated !== content)
|
|
350
|
+
files.set(filePath, updated);
|
|
351
|
+
}
|
|
352
|
+
if (anchorRewrites + anchorDrops > 0) {
|
|
353
|
+
console.warn(`obsidian export: resolved ${anchorRewrites} cross-page anchor fragment(s) to real block ids` +
|
|
354
|
+
(anchorDrops > 0 ? `, dropped ${anchorDrops} unresolvable` : ""));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Point every card at a file that EXISTS.
|
|
358
|
+
//
|
|
359
|
+
// A `dogsbay-card` href is resolved by the plugin with
|
|
360
|
+
// `vault.getFileByPath()` — an exact match — so unlike a markdown link it
|
|
361
|
+
// cannot be extensionless, and unlike a markdown link nothing about it
|
|
362
|
+
// degrades visibly when it is wrong: the card renders perfectly and the
|
|
363
|
+
// click silently does nothing.
|
|
364
|
+
//
|
|
365
|
+
// A card that targets a SECTION is the case the serializer cannot get right
|
|
366
|
+
// on its own: `ai-search/get-started` is a folder here, and the note is at
|
|
367
|
+
// `ai-search/get-started/_index.md`. Only this layer knows that, because
|
|
368
|
+
// only this layer decided it.
|
|
369
|
+
for (const [filePath, content] of files) {
|
|
370
|
+
if (!filePath.endsWith(".md"))
|
|
371
|
+
continue;
|
|
372
|
+
const updated = content.replace(/^href: (.+)$/gm, (line, raw) => {
|
|
373
|
+
const href = raw.trim();
|
|
374
|
+
if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#"))
|
|
375
|
+
return line;
|
|
376
|
+
if (files.has(href))
|
|
377
|
+
return line;
|
|
378
|
+
const bare = href.replace(/\.md$/, "");
|
|
379
|
+
for (const candidate of [`${bare}.md`, sectionNotePath(bare)]) {
|
|
380
|
+
if (files.has(candidate))
|
|
381
|
+
return `href: ${candidate}`;
|
|
382
|
+
}
|
|
383
|
+
return line;
|
|
384
|
+
});
|
|
385
|
+
if (updated !== content)
|
|
386
|
+
files.set(filePath, updated);
|
|
387
|
+
}
|
|
388
|
+
// A collision is content that would have been LOST — say so rather than
|
|
389
|
+
// leaving the operator to notice the file count is short.
|
|
390
|
+
if (collisions.length > 0) {
|
|
391
|
+
console.warn(` warning: ${collisions.length} page(s) collided on a vault path and were ` +
|
|
392
|
+
`written under a disambiguated name (previously they overwrote each other ` +
|
|
393
|
+
`silently). First: "${collisions[0].renamed}" vs "${collisions[0].kept}" ` +
|
|
394
|
+
`at ${collisions[0].path}.`);
|
|
395
|
+
}
|
|
396
|
+
// Fill each folder note with a listing of its children. Deferred to here
|
|
397
|
+
// because it needs `slugToVaultPath`, which is only complete once every page
|
|
398
|
+
// has been written — and the empty stubs must already exist above, since the
|
|
399
|
+
// link-rewriting pass keys off the folder note existing.
|
|
400
|
+
if (options.sectionIndexes !== false && options.sectionIndexLinks !== false) {
|
|
401
|
+
fillSectionIndexes(nav, pathPrefix, files, slugToVaultPath);
|
|
402
|
+
}
|
|
97
403
|
// Generate _nav.yml with actual vault paths
|
|
98
404
|
files.set("_nav.yml", navToYaml(nav, 0, slugToVaultPath));
|
|
99
405
|
return { files };
|
|
100
406
|
}
|
|
101
|
-
|
|
407
|
+
// ── Nav → folder structure ──────────────────────────────
|
|
408
|
+
/**
|
|
409
|
+
* The category label for a nav section.
|
|
410
|
+
*
|
|
411
|
+
* Prefers the nav label — it is the authored wording. Falls back to the title
|
|
412
|
+
* of the section's own page when a nav entry carries no label, and finally to
|
|
413
|
+
* the folder slug, so a category is never blank. (Every one of the 2,105 nav
|
|
414
|
+
* entries in the OpenShift corpus has a label, so the fallbacks are for
|
|
415
|
+
* corpora whose nav is generated rather than authored.)
|
|
416
|
+
*/
|
|
417
|
+
function sectionCategoryLabel(item, pageBySlug) {
|
|
418
|
+
if (item.label)
|
|
419
|
+
return item.label;
|
|
420
|
+
if (item.href) {
|
|
421
|
+
const slug = hrefToSlug(item.href);
|
|
422
|
+
const page = slug ? pageBySlug.get(slug) : undefined;
|
|
423
|
+
if (page?.title)
|
|
424
|
+
return page.title;
|
|
425
|
+
if (slug)
|
|
426
|
+
return slug.slice(slug.lastIndexOf("/") + 1);
|
|
427
|
+
}
|
|
428
|
+
return "Untitled";
|
|
429
|
+
}
|
|
430
|
+
function assignPaths(items, parentFolder, assignments, pageBySlug, ancestors = []) {
|
|
102
431
|
let position = 1;
|
|
103
432
|
for (const item of items) {
|
|
104
433
|
if (item.children && item.children.length > 0) {
|
|
@@ -108,39 +437,101 @@ function assignPaths(items, parentFolder, assignments, pageBySlug) {
|
|
|
108
437
|
const folderName = item.href ? folderNameFromHref(item.href) : slugify(item.label);
|
|
109
438
|
const folderPath = parentFolder ? `${parentFolder}/${folderName}` : folderName;
|
|
110
439
|
// If section has its own page, assign it
|
|
440
|
+
// A section's label names the section, so a page that IS the section
|
|
441
|
+
// does not repeat it — its categories are the ancestors above it.
|
|
442
|
+
const sectionLabel = sectionCategoryLabel(item, pageBySlug);
|
|
111
443
|
if (item.href) {
|
|
112
444
|
const slug = hrefToSlug(item.href);
|
|
113
445
|
if (slug && pageBySlug.has(slug)) {
|
|
114
|
-
assignments.set(slug, { folderPath, position });
|
|
446
|
+
assignments.set(slug, { folderPath, position, categories: ancestors });
|
|
115
447
|
position++;
|
|
116
448
|
}
|
|
117
449
|
}
|
|
118
450
|
// Recurse into children
|
|
119
|
-
assignPaths(item.children, folderPath, assignments, pageBySlug
|
|
451
|
+
assignPaths(item.children, folderPath, assignments, pageBySlug, [
|
|
452
|
+
...ancestors,
|
|
453
|
+
sectionLabel,
|
|
454
|
+
]);
|
|
120
455
|
position++;
|
|
121
456
|
}
|
|
122
457
|
else if (item.href) {
|
|
123
458
|
// Leaf page
|
|
124
459
|
const slug = hrefToSlug(item.href);
|
|
125
460
|
if (slug && pageBySlug.has(slug)) {
|
|
126
|
-
assignments.set(slug, { folderPath: parentFolder, position });
|
|
461
|
+
assignments.set(slug, { folderPath: parentFolder, position, categories: ancestors });
|
|
127
462
|
}
|
|
128
463
|
position++;
|
|
129
464
|
}
|
|
130
465
|
}
|
|
131
466
|
}
|
|
132
|
-
function generateSectionIndexes(items, parentFolder, files) {
|
|
467
|
+
function generateSectionIndexes(items, parentFolder, files, pageBySlug, ancestors = []) {
|
|
133
468
|
for (const item of items) {
|
|
134
469
|
if (item.children && item.children.length > 0) {
|
|
135
470
|
const folderName = item.href ? folderNameFromHref(item.href) : slugify(item.label);
|
|
136
471
|
const folderPath = parentFolder ? `${parentFolder}/${folderName}` : folderName;
|
|
137
|
-
|
|
138
|
-
|
|
472
|
+
const sectionLabel = sectionCategoryLabel(item, pageBySlug);
|
|
473
|
+
// Create the folder note for this section. Its categories are the
|
|
474
|
+
// sections ABOVE it — its own label is already the title.
|
|
475
|
+
files.set(sectionNotePath(folderPath), `---\ntitle: ${yamlStr(sectionLabel)}${categoryBlock(ancestors)}\n---\n`);
|
|
139
476
|
// Recurse
|
|
140
|
-
generateSectionIndexes(item.children, folderPath, files
|
|
477
|
+
generateSectionIndexes(item.children, folderPath, files, pageBySlug, [
|
|
478
|
+
...ancestors,
|
|
479
|
+
sectionLabel,
|
|
480
|
+
]);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Second pass over the nav: give each folder note a body listing its children.
|
|
486
|
+
*
|
|
487
|
+
* Links are root-relative and extension-less — the same shape `obsidianHref`
|
|
488
|
+
* produces for body links, so a section index resolves exactly like any other
|
|
489
|
+
* cross-reference in the vault.
|
|
490
|
+
*/
|
|
491
|
+
function fillSectionIndexes(items, parentFolder, files, slugToVaultPath) {
|
|
492
|
+
for (const item of items) {
|
|
493
|
+
if (!item.children || item.children.length === 0)
|
|
494
|
+
continue;
|
|
495
|
+
const folderName = item.href ? folderNameFromHref(item.href) : slugify(item.label);
|
|
496
|
+
const folderPath = parentFolder ? `${parentFolder}/${folderName}` : folderName;
|
|
497
|
+
const indexPath = sectionNotePath(folderPath);
|
|
498
|
+
const lines = [];
|
|
499
|
+
for (const child of item.children) {
|
|
500
|
+
const target = sectionIndexTarget(child, folderPath, files, slugToVaultPath);
|
|
501
|
+
lines.push(target ? `- [${child.label}](${target})` : `- ${child.label}`);
|
|
502
|
+
}
|
|
503
|
+
const existing = files.get(indexPath);
|
|
504
|
+
if (existing !== undefined && lines.length > 0) {
|
|
505
|
+
files.set(indexPath, `${existing}\n${lines.join("\n")}\n`);
|
|
141
506
|
}
|
|
507
|
+
fillSectionIndexes(item.children, folderPath, files, slugToVaultPath);
|
|
142
508
|
}
|
|
143
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Where a nav child actually landed in the vault, as a root-relative
|
|
512
|
+
* extension-less path. Returns undefined when the child is an external link,
|
|
513
|
+
* a bare anchor, or a page that was never written — a label with no link beats
|
|
514
|
+
* a link that dangles.
|
|
515
|
+
*/
|
|
516
|
+
function sectionIndexTarget(child, folderPath, files, slugToVaultPath) {
|
|
517
|
+
// A child that is itself a section resolves to that section's own index.
|
|
518
|
+
if (child.children && child.children.length > 0) {
|
|
519
|
+
const name = child.href ? folderNameFromHref(child.href) : slugify(child.label);
|
|
520
|
+
const nested = sectionNotePath(`${folderPath}/${name}`);
|
|
521
|
+
if (files.has(nested))
|
|
522
|
+
return nested.slice(0, -3);
|
|
523
|
+
}
|
|
524
|
+
if (!child.href)
|
|
525
|
+
return undefined;
|
|
526
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(child.href) || child.href.startsWith("mailto:"))
|
|
527
|
+
return undefined;
|
|
528
|
+
if (isAnchorOnly(child.href))
|
|
529
|
+
return undefined;
|
|
530
|
+
const vaultPath = slugToVaultPath.get(hrefToSlug(child.href));
|
|
531
|
+
if (vaultPath && files.has(`${vaultPath}.md`))
|
|
532
|
+
return vaultPath;
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
144
535
|
// ── _nav.yml generation ─────────────────────────────────
|
|
145
536
|
function navToYaml(items, indent = 0, slugToVaultPath) {
|
|
146
537
|
const prefix = " ".repeat(indent);
|
|
@@ -165,6 +556,18 @@ function navToYaml(items, indent = 0, slugToVaultPath) {
|
|
|
165
556
|
}
|
|
166
557
|
/** Resolve an href to the actual vault path, falling back to hrefToPath. */
|
|
167
558
|
function resolveNavPath(href, slugToVaultPath) {
|
|
559
|
+
// An external link is not a vault path. `hrefToPath` appended `.md` to it,
|
|
560
|
+
// so a nav entry pointing at https://learn.mintlify.com became
|
|
561
|
+
// "https://learn.mintlify.com.md" — a dangling path in every generated
|
|
562
|
+
// _nav.yml that has an external destination.
|
|
563
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:"))
|
|
564
|
+
return href;
|
|
565
|
+
// An in-page anchor is not a vault path either — appending `.md` produced a
|
|
566
|
+
// `#options.md` nav row pointing at a file that is never written, and if the
|
|
567
|
+
// row had children it created a folder named `#options/`, which Obsidian
|
|
568
|
+
// cannot address at all (`#` is reserved in note names).
|
|
569
|
+
if (isAnchorOnly(href))
|
|
570
|
+
return href;
|
|
168
571
|
if (slugToVaultPath) {
|
|
169
572
|
const slug = hrefToSlug(href);
|
|
170
573
|
const vaultPath = slugToVaultPath.get(slug);
|
|
@@ -174,13 +577,65 @@ function resolveNavPath(href, slugToVaultPath) {
|
|
|
174
577
|
return hrefToPath(href);
|
|
175
578
|
}
|
|
176
579
|
// ── Frontmatter ─────────────────────────────────────────
|
|
177
|
-
function buildFrontmatter(title, position) {
|
|
178
|
-
return `---\ntitle: ${yamlStr(title)}\naliases:\n - ${yamlStr(title)}\nsidebar_position: ${position}\n---`;
|
|
580
|
+
function buildFrontmatter(title, position, categories = []) {
|
|
581
|
+
return `---\ntitle: ${yamlStr(title)}\naliases:\n - ${yamlStr(title)}\nsidebar_position: ${position}${categoryBlock(categories)}\n---`;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* `categories` + `tags` lines for a frontmatter block, or "" when there are none.
|
|
585
|
+
*
|
|
586
|
+
* Two renderings of the same hierarchy, doing different jobs:
|
|
587
|
+
* - `categories` keeps the authored labels, for reading and for Bases
|
|
588
|
+
* filters (`categories.contains("Virtualization")`). A LIST, not a
|
|
589
|
+
* "/"-joined string, so filtering is set membership rather than the
|
|
590
|
+
* substring matching `@dogsbay/chunk`'s facets.ts SC2 rejects.
|
|
591
|
+
* - `tags` is the same chain slugified into one nested tag. Obsidian tags
|
|
592
|
+
* cannot contain spaces, but nested tags give a collapsible tree in the
|
|
593
|
+
* tag pane and make `tag:#virtualization` a valid graph colour-group
|
|
594
|
+
* query — which is what lets graph groups be real facets instead of
|
|
595
|
+
* text-matching page bodies.
|
|
596
|
+
*/
|
|
597
|
+
function categoryBlock(categories) {
|
|
598
|
+
if (categories.length === 0)
|
|
599
|
+
return "";
|
|
600
|
+
const list = categories.map((c) => `\n - ${yamlStr(c)}`).join("");
|
|
601
|
+
const tag = categories.map((c) => slugify(c)).filter(Boolean).join("/");
|
|
602
|
+
const tagLine = tag ? `\ntags:\n - ${tag}` : "";
|
|
603
|
+
return `\ncategories:${list}${tagLine}`;
|
|
179
604
|
}
|
|
180
605
|
// ── Utilities ───────────────────────────────────────────
|
|
606
|
+
/**
|
|
607
|
+
* Is this href an in-page anchor rather than a reference to a page?
|
|
608
|
+
*
|
|
609
|
+
* `#options`, `/#options`, `/docs#options` — every spelling whose PATH part is
|
|
610
|
+
* empty but which carries a fragment or query. They must not resolve to a
|
|
611
|
+
* slug: stripping the fragment left `""`, and the `|| "index"` fallback below
|
|
612
|
+
* turned that into the HOME note, so a decorative "on this page" nav row
|
|
613
|
+
* hijacked the homepage and dragged it into that row's folder. Checking only
|
|
614
|
+
* for a LEADING `#` missed the slash-prefixed spellings, which is how the
|
|
615
|
+
* first version of this guard let the same failure through.
|
|
616
|
+
*/
|
|
617
|
+
function isAnchorOnly(href) {
|
|
618
|
+
if (!/[#?]/.test(href))
|
|
619
|
+
return false;
|
|
620
|
+
const path = href
|
|
621
|
+
.split(/[#?]/)[0]
|
|
622
|
+
.replace(/^\/docs\/?/, "")
|
|
623
|
+
.replace(/^\//, "")
|
|
624
|
+
.replace(/\/$/, "");
|
|
625
|
+
return path === "";
|
|
626
|
+
}
|
|
181
627
|
function hrefToSlug(href) {
|
|
628
|
+
if (isAnchorOnly(href))
|
|
629
|
+
return href;
|
|
182
630
|
return href
|
|
183
|
-
.
|
|
631
|
+
// Otherwise an anchor or query string is not part of the slug. Left on,
|
|
632
|
+
// `hrefToPath` appended `.md` to the whole thing and produced
|
|
633
|
+
// `guide/install#configure.md` — a path matching no file, so that nav
|
|
634
|
+
// entry was dead.
|
|
635
|
+
.replace(/[#?].*$/, "")
|
|
636
|
+
.replace(/^\/docs\/?/, "") // legacy nav form
|
|
637
|
+
.replace(/^\//, "") // leading slash — a nav href is `/overview`, a page slug is `overview`
|
|
638
|
+
.replace(/\.md$/, "") // some navs carry the file extension
|
|
184
639
|
.replace(/\/$/, "")
|
|
185
640
|
|| "index";
|
|
186
641
|
}
|
|
@@ -188,6 +643,19 @@ function hrefToPath(href) {
|
|
|
188
643
|
const slug = hrefToSlug(href);
|
|
189
644
|
return slug === "index" ? "index.md" : `${slug}.md`;
|
|
190
645
|
}
|
|
646
|
+
/** Join a vault folder and file name, tolerating an empty folder. */
|
|
647
|
+
function joinVaultPath(folder, name) {
|
|
648
|
+
const clean = folder.replace(/^\/+|\/+$/g, "");
|
|
649
|
+
return clean ? `${clean}/${name}` : name;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* A page's vault-relative path when the nav does not place it — the slug,
|
|
653
|
+
* mirroring the source tree, rather than just its last segment.
|
|
654
|
+
*/
|
|
655
|
+
function slugToVaultRelPath(slug) {
|
|
656
|
+
const parts = slug.split("/").filter(Boolean);
|
|
657
|
+
return parts.length > 0 ? parts.join("/") : "index";
|
|
658
|
+
}
|
|
191
659
|
function fileNameFromSlug(slug) {
|
|
192
660
|
// "tutorial/first-steps" → "first-steps" (folder handles parent)
|
|
193
661
|
const parts = slug.split("/");
|