@kenjura/ursa 0.97.0 → 0.99.0

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.
@@ -509,6 +509,211 @@ describe("json-only", () => {
509
509
  });
510
510
  });
511
511
 
512
+ describe("named menus: menu-<name>.md rendered where a page anchors it", () => {
513
+ const MENU = "---\nid: powers\n---\n- [Absorb](./absorb.md)\n- [Blast](./blast.md)\n";
514
+
515
+ it("renders the menu inline, marks the current page, and is not itself a page", async () => {
516
+ await write("character/powers/menu-powers.md", MENU);
517
+ await write("character/powers/absorb.md", "---\nclass: Witch\n---\n\n{menu:powers}\n\n# Absorb\n\nTouch.\n");
518
+ await write("character/powers/blast.md", "# Blast\n\n{menu:powers}\n\nBoom.\n");
519
+ const built = await coldBuild();
520
+ await built.close();
521
+
522
+ const absorb = await read("character/powers/absorb.html");
523
+ expect(absorb).toContain('<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="powers"');
524
+ expect(absorb).toContain('<li class="ursa-menu-item ursa-menu-current"><a href="/character/powers/absorb.html" aria-current="page">Absorb</a>');
525
+ expect(absorb).toContain('<li class="ursa-menu-item"><a href="/character/powers/blast.html">Blast</a>');
526
+ expect(absorb).not.toContain("{menu:powers}");
527
+ // Anchored above the heading: the menu stays above the title
528
+ expect(absorb.indexOf('data-menu-id="powers"')).toBeLessThan(absorb.indexOf("<h1>Absorb</h1>"));
529
+
530
+ const blast = await read("character/powers/blast.html");
531
+ expect(blast).toContain('ursa-menu-current"><a href="/character/powers/blast.html"');
532
+ expect(blast.indexOf("<h1>Blast</h1>")).toBeLessThan(blast.indexOf('data-menu-id="powers"'));
533
+
534
+ // The menu file is navigation, not a document
535
+ expect(existsSync(join(output, "character/powers/menu-powers.html"))).toBe(false);
536
+ expect(await read("public/menu-data.json")).not.toContain("menu-powers");
537
+ expect(await read("character/powers.html")).not.toContain("menu-powers");
538
+ expect(existsSync(join(output, "public/custom-menu-character-powers.json"))).toBe(false);
539
+ });
540
+
541
+ it("editing the menu rewrites exactly the pages that anchor it", async () => {
542
+ await write("character/powers/menu-powers.md", MENU);
543
+ await write("character/powers/absorb.md", "---\nclass: Witch\n---\n\n{menu:powers}\n\n# Absorb\n\nTouch.\n");
544
+ const built = await coldBuild();
545
+
546
+ await write("character/powers/menu-powers.md", MENU + "- [Rules](../../rules/)\n");
547
+ let r = await built.pass();
548
+ expect(r.wrote).toEqual([
549
+ "character/powers/absorb.html",
550
+ "character/powers/absorb.json",
551
+ "character/powers/absorb.xml",
552
+ ]);
553
+ expect(await read("character/powers/absorb.html")).toContain(">Rules</a>");
554
+
555
+ // A vertical appearance is a menu change too
556
+ await write("character/powers/menu-powers.md", "---\nid: powers\nappearance: vertical\n---\n- [Absorb](./absorb.md)\n");
557
+ r = await built.pass();
558
+ expect(r.wrote).toContain("character/powers/absorb.html");
559
+ expect(r.wrote).not.toContain("character/powers/blast.html");
560
+ expect(await read("character/powers/absorb.html")).toContain("ursa-menu-vertical");
561
+
562
+ await built.close();
563
+ await expectConverged();
564
+ });
565
+
566
+ it("a missing menu degrades to a comment and a warning; creating it fills the anchor", async () => {
567
+ await write("character/powers/blast.md", "# Blast\n\n{menu:powers}\n\nBoom.\n");
568
+ const warnings = [];
569
+ const realWarn = console.warn;
570
+ console.warn = (m) => warnings.push(String(m));
571
+ const built = await build({ clean: true });
572
+ try {
573
+ await built.pass();
574
+ } finally {
575
+ console.warn = realWarn;
576
+ }
577
+ let blast = await read("character/powers/blast.html");
578
+ expect(blast).toContain('<!-- ursa: menu "powers" not found -->');
579
+ expect(blast).not.toContain("{menu:powers}");
580
+ expect(blast).toContain("<p>Boom.</p>");
581
+ expect(warnings.some((w) => w.includes('no menu with id "powers"'))).toBe(true);
582
+
583
+ // The menu file appears one level up: the page picks it up without being edited
584
+ await write("character/menu-powers.md", "---\nid: powers\n---\n- [Blast](./powers/blast.md)\n");
585
+ const r = await built.pass();
586
+ expect(r.wrote).toContain("character/powers/blast.html");
587
+ blast = await read("character/powers/blast.html");
588
+ expect(blast).toContain('data-menu-id="powers"');
589
+ expect(blast).toContain('ursa-menu-current"><a href="/character/powers/blast.html"');
590
+
591
+ // Deleting it puts the comment back
592
+ await unlink(join(source, "character/menu-powers.md"));
593
+ await built.pass();
594
+ expect(await read("character/powers/blast.html")).toContain('<!-- ursa: menu "powers" not found -->');
595
+ await built.close();
596
+ await expectConverged();
597
+ });
598
+
599
+ it("a menu.md with an id is a named menu, not the folder's nav", async () => {
600
+ await write("character/menu.md", "---\nid: sidebar\nappearance: vertical\n---\n- [Absorb](./powers/absorb.md)\n");
601
+ await write("character/powers/blast.md", "# Blast\n\nBoom.\n\n{menu:sidebar}\n");
602
+ const built = await coldBuild();
603
+ await built.close();
604
+ const blast = await read("character/powers/blast.html");
605
+ expect(blast).not.toContain("data-custom-menu=");
606
+ expect(blast).toContain('data-menu-id="sidebar"');
607
+ expect(existsSync(join(output, "public/custom-menu-character.json"))).toBe(false);
608
+ });
609
+
610
+ it("prose in the menu file is kept, with its links rebased to the menu's folder", async () => {
611
+ await write("character/menu-powers.md", "---\nid: powers\n---\nPowers:\n- [Absorb](./powers/absorb.md)\n\nSee also [the rules](../rules/index.md).\n");
612
+ await write("character/powers/blast.md", "# Blast\n\n{menu:powers}\n\nBoom.\n");
613
+ const built = await coldBuild();
614
+ await built.close();
615
+ const blast = await read("character/powers/blast.html");
616
+ const nav = blast.match(/<nav class="ursa-menu[\s\S]*?<\/nav>/)[0];
617
+ expect(nav).toContain('<div class="ursa-menu-text"><p>Powers:</p>');
618
+ expect(nav.indexOf("Powers:")).toBeLessThan(nav.indexOf("<ul"));
619
+ expect(nav).toContain('href="/character/powers/absorb.html"');
620
+ expect(nav).toContain('<a href="/rules/index.html">the rules</a>');
621
+ expect(nav.indexOf("See also")).toBeGreaterThan(nav.indexOf("</ul>"));
622
+ });
623
+
624
+ it("the anchor works in MDX", async () => {
625
+ await write("character/powers/menu-powers.md", MENU);
626
+ await write("character/powers/absorb.mdx", "---\nclass: Witch\n---\n\n{menu:powers}\n\n# Absorb\n\nTouch.\n");
627
+ await unlink(join(source, "character/powers/absorb.md"));
628
+ const built = await coldBuild();
629
+ await built.close();
630
+ const absorb = await read("character/powers/absorb.html");
631
+ expect(absorb).toContain('data-menu-id="powers"');
632
+ expect(absorb).toContain('ursa-menu-current"><a href="/character/powers/absorb.html"');
633
+ expect(absorb).not.toContain("data-ursa-menu");
634
+ });
635
+ });
636
+
637
+ describe("config.json inject-menu: a folder puts a named menu on every document beneath it", () => {
638
+ const MENU = "---\nid: powers\n---\n- [Absorb](./absorb.md)\n- [Blast](./blast.md)\n";
639
+
640
+ it("injects at the top or bottom of every document in the subtree, above the injected title", async () => {
641
+ await write("character/menu-powers.md", MENU.replace(/\.\/(\w+)\.md/g, "./powers/$1.md"));
642
+ await write("character/config.json", JSON.stringify({ "inject-menu": [{ id: "powers", position: "top" }, { id: "powers", position: "bottom" }] }));
643
+ await write("character/notes.md", "# Notes\n\nText.\n");
644
+ const built = await coldBuild();
645
+ await built.close();
646
+
647
+ const blast = await read("character/powers/blast.html");
648
+ const navs = blast.match(/<nav class="ursa-menu[^"]*" data-menu-id="powers"/g) ?? [];
649
+ expect(navs).toHaveLength(2);
650
+ expect(blast.indexOf('data-menu-id="powers"')).toBeLessThan(blast.indexOf("<h1>Blast</h1>"));
651
+ expect(blast.lastIndexOf('data-menu-id="powers"')).toBeGreaterThan(blast.indexOf("<p>Boom.</p>"));
652
+ expect(blast).toContain('ursa-menu-current"><a href="/character/powers/blast.html"');
653
+ // absorb has no H1 of its own: the injected title still comes after the menu
654
+ const absorb = await read("character/powers/absorb.html");
655
+ expect(absorb.indexOf('data-menu-id="powers"')).toBeLessThan(absorb.indexOf("<h1>Absorb</h1>"));
656
+ expect((absorb.match(/<h1[ >]/g) ?? []).length).toBe(1);
657
+ // a document directly in the folder gets it too; one outside does not
658
+ expect(await read("character/notes.html")).toContain('data-menu-id="powers"');
659
+ expect(await read("rules/combat.html")).not.toContain("data-menu-id");
660
+ });
661
+
662
+ it("a document that already anchors the menu is not given it twice", async () => {
663
+ await write("character/menu-powers.md", MENU.replace(/\.\/(\w+)\.md/g, "./powers/$1.md"));
664
+ await write("character/config.json", JSON.stringify({ "inject-menu": { id: "powers" } }));
665
+ await write("character/powers/blast.md", "# Blast\n\n{menu:powers}\n\nBoom.\n");
666
+ const built = await coldBuild();
667
+ await built.close();
668
+ const blast = await read("character/powers/blast.html");
669
+ expect(blast.match(/data-menu-id="powers"/g)).toHaveLength(1);
670
+ expect(blast.indexOf("<h1>Blast</h1>")).toBeLessThan(blast.indexOf('data-menu-id="powers"'));
671
+ });
672
+
673
+ it("a deeper config.json replaces the injection unless it inherits", async () => {
674
+ await write("menu-site.md", "---\nid: site\n---\n- [Home](./index.md)\n");
675
+ await write("character/menu-powers.md", MENU.replace(/\.\/(\w+)\.md/g, "./powers/$1.md"));
676
+ await write("config.json", JSON.stringify({ "inject-menu": { id: "site", position: "bottom" } }));
677
+ await write("character/config.json", JSON.stringify({ "inject-menu": [{ inherit: true }, { id: "powers" }] }));
678
+ await write("rules/config.json", JSON.stringify({ "inject-menu": { id: "powers" } }));
679
+ const built = await coldBuild();
680
+ await built.close();
681
+
682
+ const blast = await read("character/powers/blast.html");
683
+ expect(blast).toContain('data-menu-id="site"');
684
+ expect(blast).toContain('data-menu-id="powers"');
685
+ const combat = await read("rules/combat.html");
686
+ expect(combat).not.toContain('data-menu-id="site"');
687
+ // "powers" lives under character/, so rules/ cannot resolve it: comment plus warning, page intact
688
+ expect(combat).toContain('<!-- ursa: menu "powers" not found -->');
689
+ expect(combat).toContain("<h1>Combat</h1>");
690
+ expect(await read("index.html")).toContain('data-menu-id="site"');
691
+ });
692
+
693
+ it("editing the folder's config.json rewrites exactly the subtree's documents", async () => {
694
+ await write("character/menu-powers.md", MENU.replace(/\.\/(\w+)\.md/g, "./powers/$1.md"));
695
+ const built = await coldBuild();
696
+
697
+ await write("character/config.json", JSON.stringify({ "inject-menu": { id: "powers" } }));
698
+ let r = await built.pass();
699
+ // character.html is the folder's listing page, which lists the new config.json itself
700
+ expect(r.wrote.filter((w) => w.endsWith(".html") && w !== "character.html").sort()).toEqual([
701
+ "character/powers/absorb.html",
702
+ "character/powers/blast.html",
703
+ ]);
704
+ expect(r.wrote).not.toContain("rules/combat.html");
705
+ expect(r.wrote).not.toContain("index.html");
706
+ expect(await read("character/powers/absorb.html")).toContain('data-menu-id="powers"');
707
+
708
+ await unlink(join(source, "character/config.json"));
709
+ r = await built.pass();
710
+ expect(r.wrote).toContain("character/powers/absorb.html");
711
+ expect(await read("character/powers/absorb.html")).not.toContain("data-menu-id");
712
+ await built.close();
713
+ await expectConverged();
714
+ });
715
+ });
716
+
512
717
  describe("determinism", () => {
513
718
  it("two clean builds of the same tree are byte-identical modulo build metadata", async () => {
514
719
  const built = await coldBuild();
@@ -2,6 +2,7 @@
2
2
  import { readdir } from "./tracedFs.js";
3
3
  import { basename, extname, join } from "path";
4
4
  import { getFolderConfig, isFolderSelfHidden } from "../folderConfig.js";
5
+ import { isMenuFile } from "../customMenu.js";
5
6
  import {
6
7
  toDisplayName,
7
8
  getFolderLabel,
@@ -39,6 +40,7 @@ async function directoryHasDocuments(dir, extensions, sourceDir = dir) {
39
40
  const childSource = sourceDir ? join(sourceDir, child.name) : null;
40
41
  if (await directoryHasDocuments(fullPath, extensions, childSource)) return true;
41
42
  } else {
43
+ if (isMenuFile(child.name)) continue;
42
44
  const ext = extname(child.name).toLowerCase();
43
45
  if (extensions.includes(ext)) return true;
44
46
  }
@@ -106,6 +108,8 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
106
108
  if (child.name.startsWith('.')) return false;
107
109
  // Skip index files (we're generating into the index)
108
110
  if (child.name.match(/^index\.(md|mdx|txt|yml|html)$/i)) return false;
111
+ // Skip menu files (menu.md, menu-<name>.md): navigation, not content
112
+ if (isMenuFile(child.name)) return false;
109
113
  // Skip img folders (contain images, not content)
110
114
  if (child.isDirectory() && child.name === 'img') return false;
111
115
  // Skip folders config.json marks hidden — they produce no output
@@ -54,7 +54,22 @@ import {
54
54
  extractMenuFrontmatter,
55
55
  parseCustomMenu,
56
56
  combineAutoAndManualMenu,
57
+ isMenuFile,
57
58
  } from "../customMenu.js";
59
+ import {
60
+ findNamedMenu,
61
+ namedMenuOptions,
62
+ collectMenuAnchorIds,
63
+ prepareMdxMenuAnchors,
64
+ resolveMenuAnchors,
65
+ renderInlineMenuHtml,
66
+ menuNotFoundComment,
67
+ leadingMenusEnd,
68
+ parseInjectMenu,
69
+ mergeInjectMenus,
70
+ splitMenuBody,
71
+ rebaseMenuHtml,
72
+ } from "../inlineMenu.js";
58
73
  import { findAllStyleCss } from "../findStyleCss.js";
59
74
  import { findAllScriptJs } from "../findScriptJs.js";
60
75
  import {
@@ -98,6 +113,7 @@ export const DIR_KINDS = ["dirIndexJson", "dirListingHtml", "autoIndexPage", "di
98
113
  export const INTERNAL_KINDS = [
99
114
  "linkResolution", "outputOwner", "customMenuFor", "cssBundle", "jsBundle",
100
115
  "metaAsset", "metaBundle", "imageInfo", "docMeta",
116
+ "menuFileMeta", "namedMenuFor", "namedMenu", "injectMenusFor",
101
117
  ];
102
118
  /** Site-wide singletons. */
103
119
  export const SITE_KINDS = [
@@ -428,6 +444,62 @@ export function createSite(env) {
428
444
  return finishAssets(ctx, html, docUrlPath);
429
445
  }
430
446
 
447
+ /**
448
+ * One named menu rendered for one document, or an HTML comment (and a
449
+ * warning) when it is missing or cannot be rendered. `how` names the
450
+ * requester in the warning key: an anchor or a config.json injection.
451
+ */
452
+ async function renderNamedMenu(ctx, rel, id, currentUrl, how) {
453
+ const dirRel = dirRelOf(rel);
454
+ try {
455
+ const menuRel = await ctx.get(nodeId("namedMenuFor", `${dirRel}|${id}`));
456
+ const menu = menuRel ? await ctx.get(nodeId("namedMenu", menuRel)) : null;
457
+ if (!menu) {
458
+ warn(`menu-${how}:${rel}:${id}`, `⚠️ ${rel}: no menu with id "${id}" in this folder or above it`);
459
+ return menuNotFoundComment(id);
460
+ }
461
+ return renderInlineMenuHtml(menu.segments, { id, appearance: menu.appearance, currentUrl });
462
+ } catch (e) {
463
+ warn(`menu-${how}:${rel}:${id}`, `⚠️ ${rel}: menu "${id}" could not be rendered: ${e.message}`);
464
+ return menuNotFoundComment(id, "could not be rendered");
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Replace a document's `{menu:<id>}` anchors with the named menus they name.
470
+ * A menu that is missing or fails to parse becomes an HTML comment and a
471
+ * warning; the page still renders.
472
+ */
473
+ async function resolveNamedMenus(ctx, body, rel) {
474
+ const ids = collectMenuAnchorIds(body);
475
+ if (ids.length === 0) return body;
476
+ const currentUrl = "/" + outputPathFor(rel);
477
+ const rendered = new Map();
478
+ for (const id of ids) rendered.set(id, await renderNamedMenu(ctx, rel, id, currentUrl, "anchor"));
479
+ return resolveMenuAnchors(body, (id) => rendered.get(id) ?? menuNotFoundComment(id));
480
+ }
481
+
482
+ /**
483
+ * Add the menus the document's folder injects (config.json `inject-menu`)
484
+ * to the top and bottom of its body. A menu the document already anchors
485
+ * itself is not added again.
486
+ */
487
+ async function injectNamedMenus(ctx, body, rel, anchoredIds) {
488
+ const entries = await ctx.get(nodeId("injectMenusFor", dirRelOf(rel)));
489
+ if (entries.length === 0) return body;
490
+ const currentUrl = "/" + outputPathFor(rel);
491
+ const anchored = new Set(anchoredIds);
492
+ let top = "";
493
+ let bottom = "";
494
+ for (const { id, position } of entries) {
495
+ if (anchored.has(id)) continue;
496
+ const html = await renderNamedMenu(ctx, rel, id, currentUrl, "inject");
497
+ if (position === "bottom") bottom += "\n" + html;
498
+ else top += html + "\n";
499
+ }
500
+ return top + body + bottom;
501
+ }
502
+
431
503
  // -------------------------------------------------------------------------
432
504
  // Node families
433
505
  // -------------------------------------------------------------------------
@@ -474,7 +546,8 @@ export function createSite(env) {
474
546
  files.sort();
475
547
  dirs.sort();
476
548
 
477
- const articles = files.filter((f) => isArticle(f));
549
+ // Menu files (menu.md, menu-<name>.md) configure navigation; they are not pages
550
+ const articles = files.filter((f) => isArticle(f) && !isMenuFile(basename(f)));
478
551
  const html = files.filter((f) => isHandwrittenHtml(f));
479
552
  const images = files.filter((f) => IMAGE_EXTENSIONS.test(f));
480
553
  const media = files.filter((f) => isMedia(f));
@@ -752,6 +825,116 @@ export function createSite(env) {
752
825
  };
753
826
  },
754
827
 
828
+ // ----- Named menus (inlineMenu.js) --------------------------------------
829
+
830
+ /**
831
+ * A menu file's identity: `{id, appearance}` from its frontmatter, or null
832
+ * when the file is gone. A projection, so pages that only need to know
833
+ * *which* file answers an anchor do not re-render for a body edit.
834
+ */
835
+ menuFileMeta: (rel) => async (ctx) => {
836
+ let content;
837
+ try {
838
+ content = await ctx.read(abs(rel));
839
+ } catch {
840
+ return null;
841
+ }
842
+ const { frontmatter } = extractMenuFrontmatter(content);
843
+ const { id, appearance, appearanceInvalid } = namedMenuOptions(frontmatter);
844
+ if (!id && /^_?menu-/i.test(basename(rel))) {
845
+ warn(`menu-id:${rel}`, `⚠️ ${rel}: a named menu needs an \`id\` in its frontmatter; this file renders nowhere`);
846
+ }
847
+ if (appearanceInvalid) {
848
+ warn(`menu-appearance:${rel}`, `⚠️ ${rel}: appearance "${appearanceInvalid}" is not one of horizontal, vertical; using horizontal`);
849
+ }
850
+ return { id, appearance };
851
+ },
852
+
853
+ /**
854
+ * Which menu file answers `{menu:<id>}` for pages in a folder: the nearest
855
+ * one up the tree with that id, or null. Key: `<dirRel>|<id>`. Reads only
856
+ * directory listings and `menuFileMeta` projections.
857
+ */
858
+ namedMenuFor: (key) => async (ctx) => {
859
+ const sep = key.lastIndexOf("|");
860
+ const dirRel = key.slice(0, sep);
861
+ const id = key.slice(sep + 1);
862
+ let cur = dirRel;
863
+ for (;;) {
864
+ const entries = await ctx.listDir(abs(cur));
865
+ const candidates = entries
866
+ .filter((e) => e.kind === "file" && isMenuFile(e.name))
867
+ .map((e) => (cur ? `${cur}/${e.name}` : e.name))
868
+ .sort();
869
+ const matches = [];
870
+ for (const rel of candidates) {
871
+ const info = await ctx.get(nodeId("menuFileMeta", rel));
872
+ if (info?.id === id) matches.push(rel);
873
+ }
874
+ if (matches.length > 1) {
875
+ warn(`menu-dup:${cur}:${id}`, `⚠️ ${matches.join(", ")} all declare menu id "${id}"; using ${matches[0]}`);
876
+ }
877
+ if (matches.length > 0) return matches[0];
878
+ if (!cur) return null;
879
+ const parent = dirname(cur);
880
+ cur = parent === "." ? "" : parent;
881
+ }
882
+ },
883
+
884
+ /**
885
+ * The menus a folder's documents get injected (config.json `inject-menu`),
886
+ * resolved down the chain of folder configs from the docroot. Each
887
+ * config.json on the way is a recorded input, present or not.
888
+ */
889
+ injectMenusFor: (dirRel) => async () => {
890
+ const chain = [];
891
+ let cur = dirRel;
892
+ for (;;) {
893
+ chain.unshift(cur);
894
+ if (!cur) break;
895
+ const parent = dirname(cur);
896
+ cur = parent === "." ? "" : parent;
897
+ }
898
+ const levels = chain.map((d) => {
899
+ const config = getFolderConfig(abs(d));
900
+ if (!config || !("inject-menu" in config)) return null;
901
+ const parsed = parseInjectMenu(config["inject-menu"]);
902
+ for (const problem of parsed.problems) {
903
+ warn(`inject-menu:${d}:${problem}`, `⚠️ ${d ? d + "/" : ""}config.json inject-menu: ${problem}`);
904
+ }
905
+ return parsed;
906
+ });
907
+ return mergeInjectMenus(levels);
908
+ },
909
+
910
+ /** One named menu's parsed items, or null when the file is gone. */
911
+ namedMenu: (rel) => async (ctx) => {
912
+ const info = await ctx.get(nodeId("menuFileMeta", rel));
913
+ if (!info?.id) return null;
914
+ const menuDirRel = dirRelOf(rel);
915
+ const found = findNamedMenu(abs(menuDirRel), source, info.id);
916
+ if (!found || relOf(found.path) !== rel) return null;
917
+ const below = await ctx.get(nodeId("dirSet", menuDirRel));
918
+ await preloadFrontmatter(ctx, below.articles, { nonDocuments: below.files.filter((f) => !isArticle(f)) });
919
+ const { frontmatter, body } = found;
920
+ const autoGenerate = frontmatter["auto-generate-menu"] === true || frontmatter["auto-generate-menu"] === "true";
921
+ const depth = parseInt(frontmatter["menu-depth"], 10) || 10;
922
+ // With auto-generation the body is a template around the {menu} token
923
+ // and only its items count; otherwise prose between the lists is kept
924
+ let segments;
925
+ if (autoGenerate) {
926
+ segments = [{ kind: "items", items: combineAutoAndManualMenu(body, found.menuDir, source, depth) }];
927
+ } else {
928
+ const menuUrlDir = "/" + menuDirRel;
929
+ segments = splitMenuBody(body).map((seg) =>
930
+ seg.kind === "items"
931
+ ? { kind: "items", items: parseCustomMenu(seg.text, found.menuDir, source) }
932
+ : { kind: "text", html: rebaseMenuHtml(renderFile({ fileContents: seg.text, type: ".md" }) ?? "", menuUrlDir) }
933
+ );
934
+ }
935
+ return { id: info.id, appearance: info.appearance, segments };
936
+ },
937
+
755
938
  /**
756
939
  * The folder's inherited stylesheets bundled into public/<folder>.bundle.css.
757
940
  * Value: {url} with `?v=<hash>`, or null when the chain is empty. The chain
@@ -870,7 +1053,7 @@ export function createSite(env) {
870
1053
  const shouldHydrate = type === ".mdx" && meta?.hydrate === true;
871
1054
 
872
1055
  const renderResult = await renderFileAsync({
873
- fileContents: raw,
1056
+ fileContents: type === ".mdx" ? prepareMdxMenuAnchors(raw) : raw,
874
1057
  type,
875
1058
  dirname: dir,
876
1059
  basename: base,
@@ -902,10 +1085,18 @@ export function createSite(env) {
902
1085
  body = renderResult;
903
1086
  }
904
1087
 
905
- // Inject default H1 if body doesn't start with one
906
- if (!body || !body.trimStart().startsWith("<h1")) {
1088
+ // `{menu:<id>}` anchors become the named menu, rendered in place, and
1089
+ // the folder's config.json can inject menus at the top and bottom
1090
+ const anchoredIds = collectMenuAnchorIds(body || "");
1091
+ body = await resolveNamedMenus(ctx, body || "", rel);
1092
+ body = await injectNamedMenus(ctx, body, rel, anchoredIds);
1093
+
1094
+ // Inject default H1 if body doesn't start with one. A menu anchored at
1095
+ // the very top stays above the title.
1096
+ const afterMenus = leadingMenusEnd(body);
1097
+ if (!body.slice(afterMenus).trimStart().startsWith("<h1")) {
907
1098
  const h1Title = meta?.title || title;
908
- body = `<h1>${h1Title}</h1>\n` + (body || "");
1099
+ body = body.slice(0, afterMenus) + `<h1>${h1Title}</h1>\n` + body.slice(afterMenus);
909
1100
  }
910
1101
 
911
1102
  // Breadcrumbs before the H1 (folder labels come from docMeta projections)
@@ -1094,6 +1285,7 @@ export function createSite(env) {
1094
1285
  if (owner) return { ownedBy: owner };
1095
1286
  const below = await ctx.get(nodeId("dirSet", dirRel));
1096
1287
  const items = below.files
1288
+ .filter((f) => !isMenuFile(basename(f)))
1097
1289
  .map((f) => {
1098
1290
  const ext = extname(f);
1099
1291
  const href = "/" + (ext ? f.slice(0, -ext.length) : f) + ".html";
@@ -1,4 +1,5 @@
1
- // Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt
1
+ // Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt.
2
+ // Named menus (menu-<name>.md with an `id`) are handled by inlineMenu.js.
2
3
  import { existsSync, readFileSync, readdirSync, statSync } from "./build/tracedFs.js";
3
4
  import { join, dirname, relative, resolve, basename, extname } from "path";
4
5
  import { extractMetadata } from "./metadataExtractor.js";
@@ -6,6 +7,24 @@ import { extractMetadata } from "./metadataExtractor.js";
6
7
  // Menu file names to look for (in order of priority)
7
8
  const MENU_FILE_NAMES = ['menu.md', 'menu.txt', '_menu.md', '_menu.txt'];
8
9
 
10
+ /**
11
+ * Every menu file: the folder menu (`menu.md`, `_menu.md`, `.txt` variants)
12
+ * and the named menus (`menu-classes.md`, `menu-2.txt`, …) that inlineMenu.js
13
+ * renders where a document anchors them. None of these is a document.
14
+ */
15
+ export const MENU_FILE_RE = /^_?menu(-[a-z0-9][a-z0-9_.-]*)?\.(md|txt)$/i;
16
+
17
+ /** True when a basename names a menu file (folder menu or named menu). */
18
+ export function isMenuFile(name) {
19
+ return MENU_FILE_RE.test(name);
20
+ }
21
+
22
+ /** True when a menu file's frontmatter makes it a named menu (rendered only by anchors). */
23
+ export function isNamedMenuFrontmatter(frontmatter) {
24
+ const id = frontmatter?.id;
25
+ return id !== undefined && id !== null && String(id).trim() !== '';
26
+ }
27
+
9
28
  // Token to mark where auto-generated menu should be inserted
10
29
  const MENU_TOKEN = '{menu}';
11
30
 
@@ -115,6 +134,7 @@ function folderHasDocuments(dirPath) {
115
134
  if (entry.name === 'img') continue;
116
135
  if (folderHasDocuments(fullPath)) return true;
117
136
  } else {
137
+ if (isMenuFile(entry.name)) continue;
118
138
  const ext = extname(entry.name);
119
139
  if (SOURCE_EXTENSIONS.includes(ext)) return true;
120
140
  }
@@ -236,8 +256,8 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 10, i
236
256
  for (const entry of entries) {
237
257
  // Skip hidden files/folders
238
258
  if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
239
- // Skip menu files themselves
240
- if (MENU_FILE_NAMES.includes(entry.name)) continue;
259
+ // Skip menu files themselves (the folder menu and any named menus)
260
+ if (isMenuFile(entry.name)) continue;
241
261
  // Skip config files
242
262
  if (entry.name === 'config.json') continue;
243
263
  // Skip img folders
@@ -406,6 +426,9 @@ export function findCustomMenu(dirPath, sourceRoot) {
406
426
  if (existsSync(menuPath)) {
407
427
  try {
408
428
  const content = readFileSync(menuPath, 'utf8');
429
+ // A menu.md with an `id` is a named menu: it renders only where a
430
+ // document anchors it, and does not replace the folder's nav
431
+ if (isNamedMenuFrontmatter(extractMenuFrontmatter(content).frontmatter)) continue;
409
432
  return {
410
433
  path: menuPath,
411
434
  content,