@kenjura/ursa 0.97.0 → 0.98.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ # 0.98.0
2
+ 2026-09-20
3
+
4
+ Named menus: `menu-<name>.md` files rendered inline where a document anchors them.
5
+
6
+ A folder's `menu.md` replaces the site navigation for its subtree. That is one menu per folder, always in the chrome. Sites also want small menus that belong to the content — a strip of sibling pages at the top of each class page, say — and those have been written by hand into every page, and kept in sync by hand.
7
+
8
+ - **Any `menu*.md` is a menu file.** `menu.md` (and `_menu.md`, `.txt` variants) is the folder menu as before; `menu-classes.md`, `menu-2.md` and so on are additional menus in the same folder, and the same file format.
9
+ - **An `id` makes a menu named.** A menu file whose frontmatter has `id: classes` renders nowhere on its own. A document in that folder or below it places it with `{menu:classes}` on a line of its own, and the menu appears there as a static `<nav class="ursa-menu">` inside the article — not a fixed element. `menu.md` with an `id` is a named menu too, and stops being the folder's nav. The `id` is required for `menu-<name>.md`; a file without one is warned about and renders nowhere.
10
+ - **`appearance: horizontal | vertical`**, default horizontal: a strip of items with hover dropdowns for nested items, or a stacked, indented list. Both mark the current page's item (`ursa-menu-current`) and its ancestors (`ursa-menu-active`). Styles live in the default template's stylesheet, scoped like the breadcrumbs so a site's `a { … }` cannot break them and a site's more specific selector can restyle them.
11
+ - **Resolution is by nearest id.** The walk goes up from the document's folder to the docroot; the first menu file with a matching `id` wins, so a deeper folder can shadow one defined above it. `auto-generate-menu` and `menu-depth` work as in `menu.md`.
12
+ - **Anchors fail quietly.** `{menu:x}` is inert Markdown — braces mean nothing to the renderer — and it is substituted after rendering, so an anchor never changes how the Markdown around it parses. If no menu answers, or the menu file cannot be parsed, the anchor becomes `<!-- ursa: menu "x" not found -->` and the build warns, naming the document; the page renders normally. Anchors inside code spans and code blocks stay as written. In `.mdx`, where `{…}` is an expression, an anchor alone on a line is rewritten before compilation and never reaches the compiler.
13
+ - **A menu above the first heading stays above the title.** The default `<h1>` is injected after any leading menus rather than before them, and the default template's `sectionify.js` keeps leading menus with the breadcrumbs, outside the sections.
14
+ - **Menus are build-graph nodes.** Editing `menu-classes.md` rewrites exactly the pages that anchor it (their `.html`, `.json` and `.xml`), nothing else; creating a missing menu file fills the anchors in the next pass without the pages being edited; deleting it puts the comments back. A page that anchors a menu depends on a projection of each candidate menu file's identity, not its body, so adding an unrelated file to the folder does not re-render it.
15
+ - **Menu files are not documents.** They are not rendered to pages, listed in the automenu, an auto-index or a `<dir>.html` listing, indexed for search, or dated in recent activity. This applies to `menu.md` too, which used to be rendered to `menu.html`. The upgrade discards the build cache, so that stale page is not known to the new graph and is not deleted; one `--clean` removes it.
16
+
17
+ The README gains a "Menus" section documenting both kinds.
18
+
1
19
  # 0.97.0
2
20
  2026-09-20
3
21
 
package/README.md CHANGED
@@ -404,6 +404,78 @@ useEffect(() => {
404
404
 
405
405
  `contentChanged(root)` dispatches `ursa:content-changed` on `document` with the changed element in `event.detail.root` (the article, if omitted). Sticky headings and the table of contents re-read the article on it; calls within the same task are coalesced into one event. A site's own scripts can listen for the same event.
406
406
 
407
+ ## Menus
408
+
409
+ The site's navigation is generated from the folder tree. A folder can replace
410
+ it with its own menu by holding a `menu.md` (or `menu.txt`, `_menu.md`,
411
+ `_menu.txt`); that menu applies to the folder and everything below it, until a
412
+ deeper folder holds a menu of its own.
413
+
414
+ ```markdown
415
+ ---
416
+ auto-generate-menu: true # start from the folder tree…
417
+ menu-position: top # top (default) or side
418
+ menu-depth: 3
419
+ ---
420
+
421
+ - [Custom link](./somewhere.md)
422
+ {menu} # …and put the generated items here
423
+ - [Another](https://example.com)
424
+ ```
425
+
426
+ Items are Markdown list links (`- [Label](./path.md)`, nested by indentation)
427
+ or wikitext (`* [[path|Label]]`). Relative paths resolve from the menu file's
428
+ folder.
429
+
430
+ ### Named menus
431
+
432
+ A menu file whose frontmatter has an `id` is a **named menu**. It does not
433
+ replace the folder's navigation; instead any document in that folder or below
434
+ it places the menu in its body with an anchor on a line of its own:
435
+
436
+ ```markdown
437
+ ---
438
+ id: classes
439
+ appearance: horizontal # horizontal (default) or vertical
440
+ ---
441
+
442
+ - [Arcanist](./arcanist.md)
443
+ - [Fighter](./fighter.md)
444
+ - [Witch](./witch.mdx)
445
+ ```
446
+
447
+ ```markdown
448
+ # Fighter
449
+
450
+ {menu:classes}
451
+
452
+ Fighters are…
453
+ ```
454
+
455
+ - Name the file `menu.md` or `menu-<anything>.md` (`menu-classes.md`,
456
+ `menu-2.txt`); a folder can hold several. The `id` is required for
457
+ `menu-<anything>.md`; `menu.md` without one is the folder menu above.
458
+ - The anchor renders as a static `<nav class="ursa-menu ursa-menu-<appearance>">`
459
+ exactly where it stands in the document, not as a fixed element. The item
460
+ whose link is the current page gets `ursa-menu-current` (its ancestors
461
+ `ursa-menu-active`), so a menu of sibling pages works as a category switcher.
462
+ `horizontal` is a strip of items with hover dropdowns for nested items;
463
+ `vertical` is a stacked, indented list.
464
+ - The nearest file with that `id` wins, so a deeper folder can shadow a menu
465
+ defined above it. `auto-generate-menu` and `menu-depth` work as in `menu.md`.
466
+ - A menu anchored above the first heading stays above the page title.
467
+ - The anchor must be on its own line. It works in `.md`, `.txt` and `.mdx`.
468
+ An anchor inside a code span or code block is left as written.
469
+ - Menu files are navigation, not documents: they are not rendered to pages,
470
+ listed in menus or indices, or searched.
471
+
472
+ **Failure is quiet.** An anchor whose menu does not exist, or whose menu file
473
+ cannot be parsed, is replaced by `<!-- ursa: menu "id" not found -->` and
474
+ reported as a build warning naming the document. The page renders normally
475
+ with nothing where the menu would have been, and the surrounding Markdown is
476
+ untouched. Under `ursa serve`, creating the missing menu file fills the anchor
477
+ without editing the page.
478
+
407
479
  ## Auto-Index Generation
408
480
 
409
481
  Ursa automatically generates index pages for folders that don't have one. You can also explicitly control auto-index generation in your index documents using frontmatter:
@@ -1465,6 +1465,150 @@
1465
1465
  }
1466
1466
  }
1467
1467
 
1468
+ /* Named menus (`{menu:<id>}` anchors) render inside the article too, as a
1469
+ static block where the author put them. Same treatment as the breadcrumbs:
1470
+ Ursa's chrome, scoped, unlayered. Horizontal is a strip of items with
1471
+ hover dropdowns for children; vertical is a stacked list, TOC-like. */
1472
+ @scope (nav.ursa-menu) {
1473
+ :scope {
1474
+ font-size: 0.9rem;
1475
+ margin: 0.5rem 0 1rem;
1476
+ color: var(--text-color);
1477
+ }
1478
+
1479
+ ul {
1480
+ list-style: none;
1481
+ margin: 0;
1482
+ padding: 0;
1483
+ }
1484
+
1485
+ li {
1486
+ margin: 0;
1487
+ padding: 0;
1488
+ }
1489
+
1490
+ a, span {
1491
+ display: block;
1492
+ text-decoration: none;
1493
+ white-space: nowrap;
1494
+ }
1495
+
1496
+ /* Same colour as the top menu, visited or not: this is navigation, not
1497
+ prose, and it may sit outside any section the site colours. */
1498
+ a, a:visited {
1499
+ color: var(--text-color);
1500
+ }
1501
+
1502
+ a:hover {
1503
+ text-decoration: none;
1504
+ }
1505
+
1506
+ /* --- horizontal: a strip, children in hover dropdowns --- */
1507
+ :scope.ursa-menu-horizontal > ul {
1508
+ display: flex;
1509
+ flex-wrap: wrap;
1510
+ align-items: center;
1511
+ gap: 0.25rem;
1512
+ }
1513
+
1514
+ :scope.ursa-menu-horizontal > ul > li {
1515
+ position: relative;
1516
+ }
1517
+
1518
+ :scope.ursa-menu-horizontal > ul > li > a,
1519
+ :scope.ursa-menu-horizontal > ul > li > span {
1520
+ padding: 0.3rem 0.75rem;
1521
+ border-radius: 4px;
1522
+ transition: background-color 0.15s ease;
1523
+ }
1524
+
1525
+ :scope.ursa-menu-horizontal > ul > li > a:hover {
1526
+ background-color: color-mix(in srgb, var(--text-color) 10%, transparent);
1527
+ }
1528
+
1529
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-current > a,
1530
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-active > a {
1531
+ font-weight: 600;
1532
+ background-color: var(--widget-bg);
1533
+ box-shadow: inset 0 0 0 1px var(--widget-border);
1534
+ }
1535
+
1536
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-has-children > a::after,
1537
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-has-children > span::after {
1538
+ content: '▼';
1539
+ font-size: 0.4em;
1540
+ margin-left: 6px;
1541
+ opacity: 0.6;
1542
+ vertical-align: middle;
1543
+ }
1544
+
1545
+ :scope.ursa-menu-horizontal li > ul {
1546
+ display: none;
1547
+ position: absolute;
1548
+ top: 100%;
1549
+ left: 0;
1550
+ min-width: 12rem;
1551
+ background-color: var(--widget-bg);
1552
+ border: 1px solid var(--widget-border);
1553
+ border-radius: 4px;
1554
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
1555
+ z-index: 1005;
1556
+ }
1557
+
1558
+ :scope.ursa-menu-horizontal li:hover > ul {
1559
+ display: block;
1560
+ }
1561
+
1562
+ :scope.ursa-menu-horizontal li li {
1563
+ position: relative;
1564
+ }
1565
+
1566
+ :scope.ursa-menu-horizontal li li > a,
1567
+ :scope.ursa-menu-horizontal li li > span {
1568
+ padding: 0.5rem 1rem;
1569
+ }
1570
+
1571
+ :scope.ursa-menu-horizontal li li > a:hover {
1572
+ background-color: color-mix(in srgb, var(--text-color) 10%, transparent);
1573
+ }
1574
+
1575
+ :scope.ursa-menu-horizontal li li.ursa-menu-current > a {
1576
+ font-weight: 600;
1577
+ }
1578
+
1579
+ /* third level and deeper fly out to the side */
1580
+ :scope.ursa-menu-horizontal li li > ul {
1581
+ top: 0;
1582
+ left: 100%;
1583
+ }
1584
+
1585
+ /* --- vertical: stacked, nested levels indented --- */
1586
+ :scope.ursa-menu-vertical a,
1587
+ :scope.ursa-menu-vertical span {
1588
+ padding: 0.25rem 0.7rem;
1589
+ border-left: 2px solid transparent;
1590
+ white-space: normal;
1591
+ }
1592
+
1593
+ :scope.ursa-menu-vertical a:hover {
1594
+ background-color: color-mix(in srgb, var(--text-color) 8%, transparent);
1595
+ }
1596
+
1597
+ :scope.ursa-menu-vertical li.ursa-menu-current > a {
1598
+ font-weight: 600;
1599
+ border-left-color: var(--text-color);
1600
+ }
1601
+
1602
+ :scope.ursa-menu-vertical li.ursa-menu-active > a {
1603
+ font-weight: 600;
1604
+ }
1605
+
1606
+ :scope.ursa-menu-vertical li > ul {
1607
+ margin-left: 0.7rem;
1608
+ border-left: 1px solid var(--widget-border);
1609
+ }
1610
+ }
1611
+
1468
1612
 
1469
1613
  /* --------------------------------------------------------------------------
1470
1614
  Document content. Layered *and* scoped: layered so site CSS always wins,
@@ -7,10 +7,23 @@ document.addEventListener('DOMContentLoaded', () => {
7
7
  let currentSection = document.createElement('section');
8
8
  currentSection.classList.add('sectionOuter');
9
9
 
10
+ // The page header stays outside any section: the breadcrumbs, and any
11
+ // named menu (`{menu:…}`) anchored above the first heading. A menu placed
12
+ // further down is part of its section like any other content.
13
+ const preamble = [];
14
+ let inPreamble = true;
15
+
10
16
  for (let i = 0; i < children.length; i++) {
11
17
  const el = children[i];
12
- // Skip breadcrumb nav — it stays outside sections
13
- if (el.classList && el.classList.contains('breadcrumbs')) continue;
18
+ if (el.classList && el.classList.contains('breadcrumbs')) {
19
+ preamble.push(el);
20
+ continue;
21
+ }
22
+ if (inPreamble && el.classList && el.classList.contains('ursa-menu')) {
23
+ preamble.push(el);
24
+ continue;
25
+ }
26
+ inPreamble = false;
14
27
  if (el.tagName === 'H1' && currentSection.childNodes.length > 0) {
15
28
  sections.push(currentSection);
16
29
  currentSection = document.createElement('section');
@@ -22,18 +35,13 @@ document.addEventListener('DOMContentLoaded', () => {
22
35
  sections.push(currentSection);
23
36
  }
24
37
 
25
- // Preserve breadcrumb nav before clearing
26
- const breadcrumbs = article.querySelector('.breadcrumbs');
27
-
28
38
  // Remove all existing children
29
39
  while (article.firstChild) {
30
40
  article.removeChild(article.firstChild);
31
41
  }
32
42
 
33
- // Re-insert breadcrumbs at the top, outside any section
34
- if (breadcrumbs) {
35
- article.appendChild(breadcrumbs);
36
- }
43
+ // Re-insert the header at the top, outside any section
44
+ preamble.forEach(el => article.appendChild(el));
37
45
 
38
46
  // Append new sections
39
47
  sections.forEach(section => article.appendChild(section));
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.97.0",
5
+ "version": "0.98.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -0,0 +1,142 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import {
5
+ namedMenuOptions,
6
+ findNamedMenu,
7
+ collectMenuAnchorIds,
8
+ prepareMdxMenuAnchors,
9
+ resolveMenuAnchors,
10
+ renderInlineMenuHtml,
11
+ menuNotFoundComment,
12
+ leadingMenusEnd,
13
+ } from "../inlineMenu.js";
14
+ import { isMenuFile, findCustomMenu } from "../customMenu.js";
15
+
16
+ describe("isMenuFile", () => {
17
+ it("matches the folder menu and named menus, not documents", () => {
18
+ for (const name of ["menu.md", "_menu.md", "menu.txt", "menu-2.md", "menu-classes.md", "_menu-x.txt", "Menu-Classes.md"]) {
19
+ expect(isMenuFile(name)).toBe(true);
20
+ }
21
+ for (const name of ["menus.md", "menu.mdx", "my-menu.md", "menu-.md", "menu_x.md", "index.md", "menu"]) {
22
+ expect(isMenuFile(name)).toBe(false);
23
+ }
24
+ });
25
+ });
26
+
27
+ describe("namedMenuOptions", () => {
28
+ it("defaults appearance to horizontal and reports an invalid value", () => {
29
+ expect(namedMenuOptions({ id: "classes" })).toEqual({ id: "classes", appearance: "horizontal", appearanceInvalid: null });
30
+ expect(namedMenuOptions({ id: "x", appearance: "Vertical" }).appearance).toBe("vertical");
31
+ expect(namedMenuOptions({ id: "x", appearance: "sideways" })).toMatchObject({ appearance: "horizontal", appearanceInvalid: "sideways" });
32
+ expect(namedMenuOptions({}).id).toBeNull();
33
+ expect(namedMenuOptions({ id: "" }).id).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe("anchors", () => {
38
+ it("collects ids in both forms, once each", () => {
39
+ const html = '<p>{menu:a}</p><div data-ursa-menu="b"></div><p>x {menu:a} y</p>';
40
+ expect(collectMenuAnchorIds(html)).toEqual(["b", "a"]);
41
+ });
42
+
43
+ it("rewrites MDX anchors alone on a line into the element form", () => {
44
+ const src = "---\nx: 1\n---\n\n{menu:classes}\n\nText with {menu:inline} stays.\n {menu:indented} \n";
45
+ const out = prepareMdxMenuAnchors(src);
46
+ expect(out).toContain('<div data-ursa-menu="classes"></div>');
47
+ expect(out).toContain('<div data-ursa-menu="indented"></div>');
48
+ expect(out).toContain("Text with {menu:inline} stays.");
49
+ });
50
+
51
+ it("replaces a paragraph that is only the anchor", () => {
52
+ const out = resolveMenuAnchors("<h1>T</h1>\n<p>{menu:classes}</p>\n<p>Body.</p>", (id) => `<nav>${id}</nav>`);
53
+ expect(out).toBe("<h1>T</h1>\n<nav>classes</nav>\n<p>Body.</p>");
54
+ });
55
+
56
+ it("splits a paragraph with text around the anchor so the nav is not inside a <p>", () => {
57
+ const out = resolveMenuAnchors("<p>Before {menu:x} after</p>", (id) => `<nav>${id}</nav>`);
58
+ expect(out).toBe("<p>Before</p>\n<nav>x</nav><p>after</p>\n");
59
+ });
60
+
61
+ it("leaves anchors quoted in code alone", () => {
62
+ const html = "<p>Write <code>{menu:x}</code> on its own line.</p>\n<pre><code>{menu:y}</code></pre>";
63
+ expect(resolveMenuAnchors(html, () => "NO")).toBe(html);
64
+ });
65
+
66
+ it("replaces the element form anywhere", () => {
67
+ const out = resolveMenuAnchors('<div data-ursa-menu="c"></div><div data-ursa-menu="d"/>', (id) => `[${id}]`);
68
+ expect(out).toBe("[c][d]");
69
+ });
70
+
71
+ it("is a no-op for bodies without anchors", () => {
72
+ const html = "<p>Nothing here { menu } either</p>";
73
+ expect(resolveMenuAnchors(html, () => "X")).toBe(html);
74
+ });
75
+
76
+ it("finds where leading menus end", () => {
77
+ const nav = '<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="a"><ul></ul></nav>';
78
+ expect(leadingMenusEnd(`${nav}\n<p>x</p>`)).toBe(nav.length);
79
+ expect(leadingMenusEnd(`\n${nav}${menuNotFoundComment("b")}<h1>T</h1>`)).toBe(1 + nav.length + menuNotFoundComment("b").length);
80
+ expect(leadingMenusEnd("<h1>T</h1>")).toBe(0);
81
+ });
82
+ });
83
+
84
+ describe("renderInlineMenuHtml", () => {
85
+ const data = [
86
+ { label: "Arcanist", href: "/character/classes/arcanist.html", children: [] },
87
+ { label: "Fighter & Co", href: "/character/classes/fighter.html", children: [] },
88
+ {
89
+ label: "More",
90
+ href: null,
91
+ children: [{ label: "Witch", href: "/character/classes/witch.html", children: [] }],
92
+ },
93
+ ];
94
+
95
+ it("renders a horizontal nav by default, escaping labels", () => {
96
+ const html = renderInlineMenuHtml(data, { id: "classes" });
97
+ expect(html).toMatch(/^<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="classes"/);
98
+ expect(html).toContain("Fighter &amp; Co");
99
+ expect(html).toContain('<ul class="ursa-menu-level" data-depth="1">');
100
+ expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children"><span>More</span>');
101
+ });
102
+
103
+ it("marks the current page and its ancestors", () => {
104
+ const html = renderInlineMenuHtml(data, { id: "classes", appearance: "vertical", currentUrl: "/character/classes/witch.html" });
105
+ expect(html).toContain("ursa-menu-vertical");
106
+ expect(html).toContain('<li class="ursa-menu-item ursa-menu-current"><a href="/character/classes/witch.html" aria-current="page">Witch</a>');
107
+ expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children ursa-menu-active"><span>More</span>');
108
+ expect(html).not.toContain('ursa-menu-current"><a href="/character/classes/arcanist.html"');
109
+ });
110
+
111
+ it("treats index.html, a trailing slash and no extension as the same page", () => {
112
+ const items = [{ label: "Classes", href: "/character/classes/index.html", children: [] }];
113
+ for (const url of ["/character/classes/", "/character/classes", "/character/classes/index.html"]) {
114
+ expect(renderInlineMenuHtml(items, { id: "x", currentUrl: url })).toContain("ursa-menu-current");
115
+ }
116
+ });
117
+ });
118
+
119
+ describe("findNamedMenu / findCustomMenu", () => {
120
+ let root;
121
+ beforeEach(async () => {
122
+ root = await mkdtemp(join(tmpdir(), "ursa-inline-menu-"));
123
+ await mkdir(join(root, "a/b"), { recursive: true });
124
+ await writeFile(join(root, "menu.md"), "---\nauto-generate-menu: true\n---\n");
125
+ await writeFile(join(root, "menu-classes.md"), "---\nid: classes\n---\n- [Root](./index.md)\n");
126
+ await writeFile(join(root, "a/menu-classes.md"), "---\nid: classes\nappearance: vertical\n---\n- [Deep](./x.md)\n");
127
+ await writeFile(join(root, "a/b/menu.md"), "---\nid: named-main\n---\n- [Hidden](./y.md)\n");
128
+ });
129
+ afterEach(() => rm(root, { recursive: true, force: true }));
130
+
131
+ it("nearest file with the id wins", () => {
132
+ expect(findNamedMenu(join(root, "a/b"), root, "classes").menuDir).toBe(join(root, "a"));
133
+ expect(findNamedMenu(root, root, "classes").menuDir).toBe(root);
134
+ expect(findNamedMenu(join(root, "a/b"), root, "named-main").path).toBe(join(root, "a/b/menu.md"));
135
+ expect(findNamedMenu(join(root, "a/b"), root, "nope")).toBeNull();
136
+ });
137
+
138
+ it("a menu.md with an id is not the folder's nav menu", () => {
139
+ const info = findCustomMenu(join(root, "a/b"), root);
140
+ expect(info.menuDir).toBe(root);
141
+ });
142
+ });
@@ -2,6 +2,7 @@ import { isHiddenOrSystemPath } from "./hiddenPaths.js";
2
2
  import { extname, basename, join, dirname } from "path";
3
3
  import { existsSync, readFileSync, readdirSync, isIgnoredDirEntry } from "./build/tracedFs.js";
4
4
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
5
+ import { isMenuFile } from "./customMenu.js";
5
6
  import {
6
7
  INDEX_EXTENSIONS,
7
8
  toDisplayName,
@@ -200,8 +201,8 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
200
201
  const relativePath = item.path.replace(source, '');
201
202
  const folderPath = parentPath ? `${parentPath}/${baseName}` : baseName;
202
203
 
203
- // Skip hidden files (config.json, style.css, etc.)
204
- if (!hasChildren && hiddenFiles.includes(fileName)) {
204
+ // Skip hidden files (config.json, style.css, etc.) and menu files
205
+ if (!hasChildren && (hiddenFiles.includes(fileName) || isMenuFile(fileName))) {
205
206
  continue;
206
207
  }
207
208
 
@@ -509,6 +509,117 @@ 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("the anchor works in MDX", async () => {
611
+ await write("character/powers/menu-powers.md", MENU);
612
+ await write("character/powers/absorb.mdx", "---\nclass: Witch\n---\n\n{menu:powers}\n\n# Absorb\n\nTouch.\n");
613
+ await unlink(join(source, "character/powers/absorb.md"));
614
+ const built = await coldBuild();
615
+ await built.close();
616
+ const absorb = await read("character/powers/absorb.html");
617
+ expect(absorb).toContain('data-menu-id="powers"');
618
+ expect(absorb).toContain('ursa-menu-current"><a href="/character/powers/absorb.html"');
619
+ expect(absorb).not.toContain("data-ursa-menu");
620
+ });
621
+ });
622
+
512
623
  describe("determinism", () => {
513
624
  it("two clean builds of the same tree are byte-identical modulo build metadata", async () => {
514
625
  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,18 @@ 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
+ } from "../inlineMenu.js";
58
69
  import { findAllStyleCss } from "../findStyleCss.js";
59
70
  import { findAllScriptJs } from "../findScriptJs.js";
60
71
  import {
@@ -98,6 +109,7 @@ export const DIR_KINDS = ["dirIndexJson", "dirListingHtml", "autoIndexPage", "di
98
109
  export const INTERNAL_KINDS = [
99
110
  "linkResolution", "outputOwner", "customMenuFor", "cssBundle", "jsBundle",
100
111
  "metaAsset", "metaBundle", "imageInfo", "docMeta",
112
+ "menuFileMeta", "namedMenuFor", "namedMenu",
101
113
  ];
102
114
  /** Site-wide singletons. */
103
115
  export const SITE_KINDS = [
@@ -428,6 +440,35 @@ export function createSite(env) {
428
440
  return finishAssets(ctx, html, docUrlPath);
429
441
  }
430
442
 
443
+ /**
444
+ * Replace a document's `{menu:<id>}` anchors with the named menus they name.
445
+ * A menu that is missing or fails to parse becomes an HTML comment and a
446
+ * warning; the page still renders.
447
+ */
448
+ async function resolveNamedMenus(ctx, body, rel) {
449
+ const ids = collectMenuAnchorIds(body);
450
+ if (ids.length === 0) return body;
451
+ const dirRel = dirRelOf(rel);
452
+ const currentUrl = "/" + outputPathFor(rel);
453
+ const rendered = new Map();
454
+ for (const id of ids) {
455
+ try {
456
+ const menuRel = await ctx.get(nodeId("namedMenuFor", `${dirRel}|${id}`));
457
+ const menu = menuRel ? await ctx.get(nodeId("namedMenu", menuRel)) : null;
458
+ if (!menu) {
459
+ warn(`menu-anchor:${rel}:${id}`, `⚠️ ${rel}: no menu with id "${id}" in this folder or above it`);
460
+ rendered.set(id, menuNotFoundComment(id));
461
+ continue;
462
+ }
463
+ rendered.set(id, renderInlineMenuHtml(menu.menuData, { id, appearance: menu.appearance, currentUrl }));
464
+ } catch (e) {
465
+ warn(`menu-anchor:${rel}:${id}`, `⚠️ ${rel}: menu "${id}" could not be rendered: ${e.message}`);
466
+ rendered.set(id, menuNotFoundComment(id, "could not be rendered"));
467
+ }
468
+ }
469
+ return resolveMenuAnchors(body, (id) => rendered.get(id) ?? menuNotFoundComment(id));
470
+ }
471
+
431
472
  // -------------------------------------------------------------------------
432
473
  // Node families
433
474
  // -------------------------------------------------------------------------
@@ -474,7 +515,8 @@ export function createSite(env) {
474
515
  files.sort();
475
516
  dirs.sort();
476
517
 
477
- const articles = files.filter((f) => isArticle(f));
518
+ // Menu files (menu.md, menu-<name>.md) configure navigation; they are not pages
519
+ const articles = files.filter((f) => isArticle(f) && !isMenuFile(basename(f)));
478
520
  const html = files.filter((f) => isHandwrittenHtml(f));
479
521
  const images = files.filter((f) => IMAGE_EXTENSIONS.test(f));
480
522
  const media = files.filter((f) => isMedia(f));
@@ -752,6 +794,80 @@ export function createSite(env) {
752
794
  };
753
795
  },
754
796
 
797
+ // ----- Named menus (inlineMenu.js) --------------------------------------
798
+
799
+ /**
800
+ * A menu file's identity: `{id, appearance}` from its frontmatter, or null
801
+ * when the file is gone. A projection, so pages that only need to know
802
+ * *which* file answers an anchor do not re-render for a body edit.
803
+ */
804
+ menuFileMeta: (rel) => async (ctx) => {
805
+ let content;
806
+ try {
807
+ content = await ctx.read(abs(rel));
808
+ } catch {
809
+ return null;
810
+ }
811
+ const { frontmatter } = extractMenuFrontmatter(content);
812
+ const { id, appearance, appearanceInvalid } = namedMenuOptions(frontmatter);
813
+ if (!id && /^_?menu-/i.test(basename(rel))) {
814
+ warn(`menu-id:${rel}`, `⚠️ ${rel}: a named menu needs an \`id\` in its frontmatter; this file renders nowhere`);
815
+ }
816
+ if (appearanceInvalid) {
817
+ warn(`menu-appearance:${rel}`, `⚠️ ${rel}: appearance "${appearanceInvalid}" is not one of horizontal, vertical; using horizontal`);
818
+ }
819
+ return { id, appearance };
820
+ },
821
+
822
+ /**
823
+ * Which menu file answers `{menu:<id>}` for pages in a folder: the nearest
824
+ * one up the tree with that id, or null. Key: `<dirRel>|<id>`. Reads only
825
+ * directory listings and `menuFileMeta` projections.
826
+ */
827
+ namedMenuFor: (key) => async (ctx) => {
828
+ const sep = key.lastIndexOf("|");
829
+ const dirRel = key.slice(0, sep);
830
+ const id = key.slice(sep + 1);
831
+ let cur = dirRel;
832
+ for (;;) {
833
+ const entries = await ctx.listDir(abs(cur));
834
+ const candidates = entries
835
+ .filter((e) => e.kind === "file" && isMenuFile(e.name))
836
+ .map((e) => (cur ? `${cur}/${e.name}` : e.name))
837
+ .sort();
838
+ const matches = [];
839
+ for (const rel of candidates) {
840
+ const info = await ctx.get(nodeId("menuFileMeta", rel));
841
+ if (info?.id === id) matches.push(rel);
842
+ }
843
+ if (matches.length > 1) {
844
+ warn(`menu-dup:${cur}:${id}`, `⚠️ ${matches.join(", ")} all declare menu id "${id}"; using ${matches[0]}`);
845
+ }
846
+ if (matches.length > 0) return matches[0];
847
+ if (!cur) return null;
848
+ const parent = dirname(cur);
849
+ cur = parent === "." ? "" : parent;
850
+ }
851
+ },
852
+
853
+ /** One named menu's parsed items, or null when the file is gone. */
854
+ namedMenu: (rel) => async (ctx) => {
855
+ const info = await ctx.get(nodeId("menuFileMeta", rel));
856
+ if (!info?.id) return null;
857
+ const menuDirRel = dirRelOf(rel);
858
+ const found = findNamedMenu(abs(menuDirRel), source, info.id);
859
+ if (!found || relOf(found.path) !== rel) return null;
860
+ const below = await ctx.get(nodeId("dirSet", menuDirRel));
861
+ await preloadFrontmatter(ctx, below.articles, { nonDocuments: below.files.filter((f) => !isArticle(f)) });
862
+ const { frontmatter, body } = found;
863
+ const autoGenerate = frontmatter["auto-generate-menu"] === true || frontmatter["auto-generate-menu"] === "true";
864
+ const depth = parseInt(frontmatter["menu-depth"], 10) || 10;
865
+ const menuData = autoGenerate
866
+ ? combineAutoAndManualMenu(body, found.menuDir, source, depth)
867
+ : parseCustomMenu(body, found.menuDir, source);
868
+ return { id: info.id, appearance: info.appearance, menuData };
869
+ },
870
+
755
871
  /**
756
872
  * The folder's inherited stylesheets bundled into public/<folder>.bundle.css.
757
873
  * Value: {url} with `?v=<hash>`, or null when the chain is empty. The chain
@@ -870,7 +986,7 @@ export function createSite(env) {
870
986
  const shouldHydrate = type === ".mdx" && meta?.hydrate === true;
871
987
 
872
988
  const renderResult = await renderFileAsync({
873
- fileContents: raw,
989
+ fileContents: type === ".mdx" ? prepareMdxMenuAnchors(raw) : raw,
874
990
  type,
875
991
  dirname: dir,
876
992
  basename: base,
@@ -902,10 +1018,15 @@ export function createSite(env) {
902
1018
  body = renderResult;
903
1019
  }
904
1020
 
905
- // Inject default H1 if body doesn't start with one
906
- if (!body || !body.trimStart().startsWith("<h1")) {
1021
+ // `{menu:<id>}` anchors become the named menu, rendered in place
1022
+ body = await resolveNamedMenus(ctx, body || "", rel);
1023
+
1024
+ // Inject default H1 if body doesn't start with one. A menu anchored at
1025
+ // the very top stays above the title.
1026
+ const afterMenus = leadingMenusEnd(body);
1027
+ if (!body.slice(afterMenus).trimStart().startsWith("<h1")) {
907
1028
  const h1Title = meta?.title || title;
908
- body = `<h1>${h1Title}</h1>\n` + (body || "");
1029
+ body = body.slice(0, afterMenus) + `<h1>${h1Title}</h1>\n` + body.slice(afterMenus);
909
1030
  }
910
1031
 
911
1032
  // Breadcrumbs before the H1 (folder labels come from docMeta projections)
@@ -1094,6 +1215,7 @@ export function createSite(env) {
1094
1215
  if (owner) return { ownedBy: owner };
1095
1216
  const below = await ctx.get(nodeId("dirSet", dirRel));
1096
1217
  const items = below.files
1218
+ .filter((f) => !isMenuFile(basename(f)))
1097
1219
  .map((f) => {
1098
1220
  const ext = extname(f);
1099
1221
  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,
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Named menus: `menu-<name>.md` files rendered inline where a document asks
3
+ * for them.
4
+ *
5
+ * `menu.md` defines a folder's navigation menu and replaces the site's nav for
6
+ * the folder and everything below it. A menu file whose frontmatter carries an
7
+ * `id` is different: it renders nowhere on its own. Instead any document in
8
+ * the folder (or below it) places it with an anchor on a line of its own:
9
+ *
10
+ * {menu:classes}
11
+ *
12
+ * The anchor becomes a static `<nav class="ursa-menu">` at that point in the
13
+ * body — part of the document, not a fixed element — with the menu's items
14
+ * as a horizontal strip (the default) or a vertical list (`appearance:
15
+ * vertical`). The item whose href is the current page is marked.
16
+ *
17
+ * Failure is quiet by design: an anchor whose menu is not found, or whose menu
18
+ * file cannot be parsed, is replaced by an HTML comment and reported as a
19
+ * build warning. The page still renders, with nothing visible where the menu
20
+ * would have been, and the surrounding Markdown is untouched.
21
+ */
22
+
23
+ import { readdirSync, readFileSync } from "./build/tracedFs.js";
24
+ import { join, dirname, resolve, basename } from "path";
25
+ import { extractMenuFrontmatter, isMenuFile } from "./customMenu.js";
26
+
27
+ /** An anchor: `{menu:<id>}`. Ids are letters, digits, `_`, `-` and `.`. */
28
+ const ANCHOR_ID = "[A-Za-z0-9_][A-Za-z0-9_.-]*";
29
+ const ANCHOR_RE = new RegExp(`\\{menu:(${ANCHOR_ID})\\}`, "g");
30
+ /** The anchor alone on a line (leading/trailing blanks allowed). */
31
+ const ANCHOR_LINE_RE = new RegExp(`^[ \\t]*\\{menu:(${ANCHOR_ID})\\}[ \\t]*$`, "gm");
32
+ /** The element form of the anchor, which is what the MDX pipeline sees. */
33
+ const ANCHOR_ELEMENT_RE = /<div\s+data-ursa-menu="([^"]+)"\s*(?:\/>|>\s*<\/div>)/g;
34
+
35
+ export const APPEARANCES = ["horizontal", "vertical"];
36
+ const DEFAULT_APPEARANCE = "horizontal";
37
+
38
+ /**
39
+ * Normalise a menu file's frontmatter into the fields named menus use.
40
+ * `id` is required for a named menu; without it the file is a folder menu
41
+ * (menu.md) or invalid (menu-x.md), which the caller decides.
42
+ * @param {object} frontmatter
43
+ * @returns {{id: string|null, appearance: string, appearanceInvalid: string|null}}
44
+ */
45
+ export function namedMenuOptions(frontmatter) {
46
+ const rawId = frontmatter?.id;
47
+ const id = rawId === undefined || rawId === null || rawId === "" ? null : String(rawId).trim();
48
+ const rawAppearance = frontmatter?.appearance;
49
+ let appearance = DEFAULT_APPEARANCE;
50
+ let appearanceInvalid = null;
51
+ if (rawAppearance !== undefined && rawAppearance !== "") {
52
+ const a = String(rawAppearance).trim().toLowerCase();
53
+ if (APPEARANCES.includes(a)) appearance = a;
54
+ else appearanceInvalid = String(rawAppearance);
55
+ }
56
+ return { id, appearance, appearanceInvalid };
57
+ }
58
+
59
+ /**
60
+ * Menu files in one directory, sorted. Reads the listing through tracedFs so
61
+ * a file appearing later is an observed change.
62
+ * @param {string} dirPath - Absolute directory
63
+ * @returns {string[]} - Absolute paths
64
+ */
65
+ export function menuFilesIn(dirPath) {
66
+ let entries;
67
+ try {
68
+ entries = readdirSync(dirPath, { withFileTypes: true });
69
+ } catch {
70
+ return [];
71
+ }
72
+ return entries
73
+ .filter((e) => e.isFile() && isMenuFile(e.name))
74
+ .map((e) => join(dirPath, e.name))
75
+ .sort();
76
+ }
77
+
78
+ /**
79
+ * Find the nearest menu file with the given id, walking up from `dirPath` to
80
+ * the source root. A deeper file with the same id shadows a shallower one.
81
+ * @param {string} dirPath - Absolute directory to start from
82
+ * @param {string} sourceRoot - Absolute docroot; the walk stops here
83
+ * @param {string} id - The menu id an anchor named
84
+ * @returns {{path: string, menuDir: string, content: string, frontmatter: object, body: string} | null}
85
+ */
86
+ export function findNamedMenu(dirPath, sourceRoot, id) {
87
+ const root = resolve(sourceRoot);
88
+ let current = resolve(dirPath);
89
+ while (current.startsWith(root)) {
90
+ for (const menuPath of menuFilesIn(current)) {
91
+ let content;
92
+ try {
93
+ content = readFileSync(menuPath, "utf8");
94
+ } catch {
95
+ continue;
96
+ }
97
+ const { frontmatter, body } = extractMenuFrontmatter(content);
98
+ if (namedMenuOptions(frontmatter).id === id) {
99
+ return { path: menuPath, menuDir: current, content, frontmatter, body };
100
+ }
101
+ }
102
+ const parent = dirname(current);
103
+ if (parent === current) break;
104
+ current = parent;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Every menu id a rendered body anchors, in both forms, in order of first
111
+ * appearance. Used to demand the menus before substituting them.
112
+ * @param {string} html
113
+ * @returns {string[]}
114
+ */
115
+ export function collectMenuAnchorIds(html) {
116
+ const ids = [];
117
+ const seen = new Set();
118
+ const add = (id) => {
119
+ if (!seen.has(id)) {
120
+ seen.add(id);
121
+ ids.push(id);
122
+ }
123
+ };
124
+ for (const m of html.matchAll(ANCHOR_ELEMENT_RE)) add(m[1]);
125
+ for (const m of html.matchAll(ANCHOR_RE)) add(m[1]);
126
+ return ids;
127
+ }
128
+
129
+ /**
130
+ * MDX reads `{menu:x}` as a JavaScript expression and fails to compile it.
131
+ * Before compiling, an anchor alone on a line becomes the element form, which
132
+ * is JSX the compiler passes through and `resolveMenuAnchors` recognises.
133
+ * @param {string} source - Raw .mdx source
134
+ * @returns {string}
135
+ */
136
+ export function prepareMdxMenuAnchors(source) {
137
+ if (!source.includes("{menu:")) return source;
138
+ return source.replace(ANCHOR_LINE_RE, (_, id) => `\n<div data-ursa-menu="${id}"></div>\n`);
139
+ }
140
+
141
+ /**
142
+ * Replace every anchor in rendered HTML with what `render(id)` returns.
143
+ *
144
+ * Handles the element form anywhere, and the `{menu:x}` form inside a
145
+ * paragraph: a paragraph that is only the anchor is replaced whole, and a
146
+ * paragraph with text around the anchor is split so the `<nav>` never sits
147
+ * inside a `<p>`. Anchors inside `<code>` are left alone — they are being
148
+ * talked about, not used.
149
+ *
150
+ * @param {string} html - Rendered body
151
+ * @param {(id: string) => string} render - Markup for one id (never throws; see `menuNotFoundComment`)
152
+ * @returns {string}
153
+ */
154
+ export function resolveMenuAnchors(html, render) {
155
+ if (!html || (!html.includes("{menu:") && !html.includes("data-ursa-menu"))) return html;
156
+
157
+ html = html.replace(ANCHOR_ELEMENT_RE, (_, id) => render(id));
158
+
159
+ if (!html.includes("{menu:")) return html;
160
+ return html.replace(/<p>([\s\S]*?)<\/p>/g, (paragraph, inner) => {
161
+ if (!inner.includes("{menu:")) return paragraph;
162
+ // Anchors quoted in code spans stay as written
163
+ const codeSpans = [];
164
+ const masked = inner.replace(/<code[\s>][\s\S]*?<\/code>/g, (span) => {
165
+ codeSpans.push(span);
166
+ return `\u0000${codeSpans.length - 1}\u0000`;
167
+ });
168
+ const unmask = (s) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)]);
169
+ const parts = masked.split(new RegExp(`\\{menu:(${ANCHOR_ID})\\}`));
170
+ if (parts.length === 1) return paragraph;
171
+ let out = "";
172
+ for (let i = 0; i < parts.length; i++) {
173
+ if (i % 2 === 1) {
174
+ out += render(parts[i]);
175
+ } else {
176
+ const text = unmask(parts[i]).trim();
177
+ if (text) out += `<p>${text}</p>\n`;
178
+ }
179
+ }
180
+ return out;
181
+ });
182
+ }
183
+
184
+ /** What an anchor becomes when its menu cannot be rendered. */
185
+ export function menuNotFoundComment(id, reason = "not found") {
186
+ return `<!-- ursa: menu "${escapeHtml(id)}" ${escapeHtml(reason)} -->`;
187
+ }
188
+
189
+ /**
190
+ * True when a rendered body begins with inline menus (after optional
191
+ * whitespace), so the default-title injection can place its `<h1>` after them
192
+ * rather than pushing a top-of-page menu below the title.
193
+ * @param {string} html
194
+ * @returns {number} - Index just past the leading menus (0 when there are none)
195
+ */
196
+ export function leadingMenusEnd(html) {
197
+ let i = 0;
198
+ const re = /^\s*(<nav class="ursa-menu[^"]*"[^>]*>[\s\S]*?<\/nav>|<!-- ursa: menu [^>]*-->)/;
199
+ for (;;) {
200
+ const m = re.exec(html.slice(i));
201
+ if (!m) return i;
202
+ i += m[0].length;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Static markup for a named menu.
208
+ *
209
+ * @param {Array} menuData - Items as `parseCustomMenu` produces them ({label, href, children})
210
+ * @param {object} opts
211
+ * @param {string} opts.id
212
+ * @param {string} [opts.appearance="horizontal"]
213
+ * @param {string|null} [opts.currentUrl] - The page's root-absolute `.html` URL, to mark the current item
214
+ * @returns {string}
215
+ */
216
+ export function renderInlineMenuHtml(menuData, { id, appearance = DEFAULT_APPEARANCE, currentUrl = null }) {
217
+ const current = currentUrl ? normalizeUrl(currentUrl) : null;
218
+ const list = renderLevel(menuData || [], current, 0);
219
+ return `<nav class="ursa-menu ursa-menu-${appearance}" data-menu-id="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${list}</nav>`;
220
+ }
221
+
222
+ function renderLevel(items, current, depth) {
223
+ if (!items || items.length === 0) return "";
224
+ const lis = items.map((item) => {
225
+ const children = item.children || [];
226
+ const isCurrent = current !== null && item.href && normalizeUrl(item.href) === current;
227
+ const hasCurrentBelow = !isCurrent && containsCurrent(children, current);
228
+ const classes = ["ursa-menu-item"];
229
+ if (children.length > 0) classes.push("ursa-menu-has-children");
230
+ if (isCurrent) classes.push("ursa-menu-current");
231
+ if (hasCurrentBelow) classes.push("ursa-menu-active");
232
+ const label = escapeHtml(item.label ?? "");
233
+ const link = item.href
234
+ ? `<a href="${escapeHtml(item.href)}"${isCurrent ? ' aria-current="page"' : ""}>${label}</a>`
235
+ : `<span>${label}</span>`;
236
+ return `<li class="${classes.join(" ")}">${link}${renderLevel(children, current, depth + 1)}</li>`;
237
+ });
238
+ return `<ul class="ursa-menu-level" data-depth="${depth}">${lis.join("")}</ul>`;
239
+ }
240
+
241
+ function containsCurrent(items, current) {
242
+ if (current === null) return false;
243
+ for (const item of items || []) {
244
+ if (item.href && normalizeUrl(item.href) === current) return true;
245
+ if (containsCurrent(item.children, current)) return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ /** `/a/b/index.html`, `/a/b/`, `/a/b` and `/a/b.html` compare by the same key. */
251
+ function normalizeUrl(url) {
252
+ let u = String(url).split("#")[0].split("?")[0];
253
+ try {
254
+ u = decodeURIComponent(u);
255
+ } catch {
256
+ // leave as written
257
+ }
258
+ u = u.replace(/\/index\.html$/i, "/").replace(/\.html$/i, "");
259
+ if (u.length > 1) u = u.replace(/\/$/, "");
260
+ return u.toLowerCase();
261
+ }
262
+
263
+ export function escapeHtml(s) {
264
+ return String(s)
265
+ .replace(/&/g, "&amp;")
266
+ .replace(/</g, "&lt;")
267
+ .replace(/>/g, "&gt;")
268
+ .replace(/"/g, "&quot;");
269
+ }
270
+
271
+ /** The menu id a `menu-<id>.md` filename suggests; for messages only. */
272
+ export function menuFileSuffix(path) {
273
+ const m = basename(path).match(/^_?menu-(.+)\.(md|txt)$/i);
274
+ return m ? m[1] : null;
275
+ }