@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/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 filePath = assignment
23
- ? `${assignment.folderPath}/${fileNameFromSlug(page.slug)}.md`
24
- : fallbackFolder
25
- ? `${fallbackFolder}/${fileNameFromSlug(page.slug)}.md`
26
- : `${fileNameFromSlug(page.slug)}.md`;
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 _index.md for nav sections (folders)
52
- generateSectionIndexes(nav, pathPrefix, files);
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
- const fallbackFolder = section || "";
61
- const fileName = fileNameFromSlug(page.slug);
62
- let vaultPath;
63
- if (assignment) {
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
- function assignPaths(items, parentFolder, assignments, pageBySlug) {
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
- // Create _index.md for this section
138
- files.set(`${folderPath}/_index.md`, `---\ntitle: ${item.label}\n---\n`);
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
- .replace(/^\/docs\/?/, "")
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("/");