@kenjura/ursa 0.98.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.
- package/CHANGELOG.md +14 -0
- package/README.md +32 -0
- package/meta/templates/default-template/default.css +22 -1
- package/package.json +1 -1
- package/src/helper/__test__/inlineMenu.test.js +83 -0
- package/src/helper/build/__test__/pass.test.js +94 -0
- package/src/helper/build/site.js +92 -22
- package/src/helper/inlineMenu.js +144 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# 0.99.0
|
|
2
|
+
2026-09-21
|
|
3
|
+
|
|
4
|
+
`inject-menu` in config.json puts a named menu on every document in a folder.
|
|
5
|
+
|
|
6
|
+
0.98.0's named menus had to be anchored by hand in every page that wanted one — ten class pages, each carrying its own `{menu:classes}`. Now the folder can do it once.
|
|
7
|
+
|
|
8
|
+
- **`"inject-menu": {"id": "classes", "position": "top"}`** in a folder's `config.json` renders that menu into every document in the folder and its subfolders, at the top (above the page title, like a first-line anchor) or the bottom. The value can be one object or an array of them. The `id` resolves from each document's folder exactly as an anchor does; a menu that cannot be resolved becomes the same comment-plus-warning.
|
|
9
|
+
- **Deeper configs replace, or inherit.** A subfolder's own `inject-menu` replaces the injection for its subtree, unless one of its entries is `{"inherit": true}`, in which case the ancestors' menus come first and the subfolder's are added after them. A folder without the key is transparent. The same id at the same position is injected once.
|
|
10
|
+
- **Anchors still win.** A document that anchors an id itself is not also given it by injection, so a page can place the folder's menu somewhere particular.
|
|
11
|
+
- **Graph-native.** `injectMenusFor:<dir>` reads the chain of `config.json` files as recorded inputs, so adding, editing or removing one re-renders exactly the documents beneath it and nothing else. Injection happens only for documents; generated index and listing pages are not touched.
|
|
12
|
+
|
|
13
|
+
**Named menus keep their prose.** The menu parser reads list-item links and nothing else, which is all a fixed nav can show — so a label such as "Feat Categories:" written above the list in `menu-feats.md` silently vanished. A named menu's body is now split into its item lists and the text between them; the text is rendered as Markdown inside the `<nav>` in document order (`.ursa-menu-text`), and in a horizontal menu it sits in the same row as the strip, so a label reads as a label. Relative links and images in that text are rebased to the menu file's folder, since the menu is inlined into pages elsewhere. `auto-generate-menu` menus are unchanged: their body is a template around `{menu}`.
|
|
14
|
+
|
|
1
15
|
# 0.98.0
|
|
2
16
|
2026-09-20
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -234,6 +234,7 @@ sits in:
|
|
|
234
234
|
| `label` | string | Name to show for this folder in menus and indices |
|
|
235
235
|
| `icon` | string | URL of an icon to show beside it in the menu |
|
|
236
236
|
| `openMenuItems` | string[] | Root `config.json` only: folders to expand by default |
|
|
237
|
+
| `inject-menu` | object or object[] | Put a named menu on every document in this folder and below; see [Injected menus](#injected-menus) |
|
|
237
238
|
|
|
238
239
|
### Large Workloads
|
|
239
240
|
|
|
@@ -466,6 +467,12 @@ Fighters are…
|
|
|
466
467
|
- A menu anchored above the first heading stays above the page title.
|
|
467
468
|
- The anchor must be on its own line. It works in `.md`, `.txt` and `.mdx`.
|
|
468
469
|
An anchor inside a code span or code block is left as written.
|
|
470
|
+
- Anything in the menu file that is not a list item — a label before the
|
|
471
|
+
list, a note after it — is rendered as Markdown inside the menu, in order.
|
|
472
|
+
Its relative links and images are resolved against the menu file's own
|
|
473
|
+
folder, since the menu is shown on pages elsewhere. (With
|
|
474
|
+
`auto-generate-menu` the body is a template around `{menu}` and only its
|
|
475
|
+
items count, as in `menu.md`.)
|
|
469
476
|
- Menu files are navigation, not documents: they are not rendered to pages,
|
|
470
477
|
listed in menus or indices, or searched.
|
|
471
478
|
|
|
@@ -476,6 +483,31 @@ with nothing where the menu would have been, and the surrounding Markdown is
|
|
|
476
483
|
untouched. Under `ursa serve`, creating the missing menu file fills the anchor
|
|
477
484
|
without editing the page.
|
|
478
485
|
|
|
486
|
+
### Injected menus
|
|
487
|
+
|
|
488
|
+
To put a named menu on every document in a folder and its subfolders without
|
|
489
|
+
anchoring it in each one, name it in the folder's `config.json`:
|
|
490
|
+
|
|
491
|
+
```json
|
|
492
|
+
{ "inject-menu": { "id": "classes", "position": "top" } }
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
- `id` is the menu's frontmatter `id`, resolved from each document's folder
|
|
496
|
+
exactly as an anchor is (nearest file up the tree with that id). `position`
|
|
497
|
+
is `top` (default) or `bottom`. An array injects several:
|
|
498
|
+
`[{ "id": "classes" }, { "id": "footer-links", "position": "bottom" }]`.
|
|
499
|
+
- A menu injected at the top goes above the page title, like an anchor on the
|
|
500
|
+
first line; one at the bottom goes after the last content. A document that
|
|
501
|
+
already anchors the same id is left alone — it is not given the menu twice.
|
|
502
|
+
- A deeper folder's `config.json` with its own `inject-menu` **replaces** the
|
|
503
|
+
injection for its subtree. To add to it instead, include `{ "inherit": true }`
|
|
504
|
+
among the entries: `[{ "inherit": true }, { "id": "subsection" }]` keeps
|
|
505
|
+
the ancestors' menus and adds this one after them. A folder without the
|
|
506
|
+
key changes nothing.
|
|
507
|
+
- A menu that cannot be resolved from a document's folder gets the same quiet
|
|
508
|
+
treatment as an anchor: an HTML comment and a warning naming the document.
|
|
509
|
+
- Editing a `config.json` re-renders exactly the documents beneath it.
|
|
510
|
+
|
|
479
511
|
## Auto-Index Generation
|
|
480
512
|
|
|
481
513
|
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:
|
|
@@ -1503,7 +1503,24 @@
|
|
|
1503
1503
|
text-decoration: none;
|
|
1504
1504
|
}
|
|
1505
1505
|
|
|
1506
|
-
/*
|
|
1506
|
+
/* Prose from the menu file (a label before the list, a note after it) */
|
|
1507
|
+
.ursa-menu-text {
|
|
1508
|
+
margin: 0;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.ursa-menu-text p {
|
|
1512
|
+
margin: 0;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/* --- horizontal: a strip, children in hover dropdowns; any prose sits
|
|
1516
|
+
in the same row, so "Categories:" reads as a label for the strip --- */
|
|
1517
|
+
:scope.ursa-menu-horizontal {
|
|
1518
|
+
display: flex;
|
|
1519
|
+
flex-wrap: wrap;
|
|
1520
|
+
align-items: center;
|
|
1521
|
+
gap: 0.25rem 0.75rem;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1507
1524
|
:scope.ursa-menu-horizontal > ul {
|
|
1508
1525
|
display: flex;
|
|
1509
1526
|
flex-wrap: wrap;
|
|
@@ -1511,6 +1528,10 @@
|
|
|
1511
1528
|
gap: 0.25rem;
|
|
1512
1529
|
}
|
|
1513
1530
|
|
|
1531
|
+
:scope.ursa-menu-vertical .ursa-menu-text {
|
|
1532
|
+
padding: 0.25rem 0.7rem;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1514
1535
|
:scope.ursa-menu-horizontal > ul > li {
|
|
1515
1536
|
position: relative;
|
|
1516
1537
|
}
|
package/package.json
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
renderInlineMenuHtml,
|
|
11
11
|
menuNotFoundComment,
|
|
12
12
|
leadingMenusEnd,
|
|
13
|
+
parseInjectMenu,
|
|
14
|
+
mergeInjectMenus,
|
|
15
|
+
splitMenuBody,
|
|
16
|
+
rebaseMenuHtml,
|
|
13
17
|
} from "../inlineMenu.js";
|
|
14
18
|
import { isMenuFile, findCustomMenu } from "../customMenu.js";
|
|
15
19
|
|
|
@@ -140,3 +144,82 @@ describe("findNamedMenu / findCustomMenu", () => {
|
|
|
140
144
|
expect(info.menuDir).toBe(root);
|
|
141
145
|
});
|
|
142
146
|
});
|
|
147
|
+
|
|
148
|
+
describe("config.json inject-menu", () => {
|
|
149
|
+
it("accepts one object or an array, defaults position to top, reports bad entries", () => {
|
|
150
|
+
expect(parseInjectMenu({ id: "classes" })).toEqual({ entries: [{ id: "classes", position: "top" }], inherit: false, problems: [] });
|
|
151
|
+
expect(parseInjectMenu([{ id: "a", position: "bottom" }, { id: "b", position: "TOP" }]).entries).toEqual([
|
|
152
|
+
{ id: "a", position: "bottom" },
|
|
153
|
+
{ id: "b", position: "top" },
|
|
154
|
+
]);
|
|
155
|
+
const bad = parseInjectMenu([{ id: "a", position: "left" }, { position: "top" }, "x"]);
|
|
156
|
+
expect(bad.entries).toEqual([{ id: "a", position: "top" }]);
|
|
157
|
+
expect(bad.problems).toHaveLength(3);
|
|
158
|
+
expect(parseInjectMenu(undefined).entries).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("recognises the inherit marker, alone or beside entries", () => {
|
|
162
|
+
expect(parseInjectMenu({ inherit: true })).toEqual({ entries: [], inherit: true, problems: [] });
|
|
163
|
+
const p = parseInjectMenu([{ inherit: true }, { id: "sub", position: "bottom" }]);
|
|
164
|
+
expect(p.inherit).toBe(true);
|
|
165
|
+
expect(p.entries).toEqual([{ id: "sub", position: "bottom" }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("merges down the folder chain: replace by default, extend with inherit, pass through when unset", () => {
|
|
169
|
+
const root = parseInjectMenu({ id: "site", position: "top" });
|
|
170
|
+
const mid = parseInjectMenu([{ inherit: true }, { id: "section", position: "bottom" }]);
|
|
171
|
+
const leaf = parseInjectMenu({ id: "leaf" });
|
|
172
|
+
expect(mergeInjectMenus([root])).toEqual([{ id: "site", position: "top" }]);
|
|
173
|
+
expect(mergeInjectMenus([root, null])).toEqual([{ id: "site", position: "top" }]);
|
|
174
|
+
expect(mergeInjectMenus([root, mid])).toEqual([{ id: "site", position: "top" }, { id: "section", position: "bottom" }]);
|
|
175
|
+
expect(mergeInjectMenus([root, mid, leaf])).toEqual([{ id: "leaf", position: "top" }]);
|
|
176
|
+
expect(mergeInjectMenus([root, mid, null, parseInjectMenu({ inherit: true })])).toEqual([
|
|
177
|
+
{ id: "site", position: "top" },
|
|
178
|
+
{ id: "section", position: "bottom" },
|
|
179
|
+
]);
|
|
180
|
+
// same id and position inherited and restated: once
|
|
181
|
+
expect(mergeInjectMenus([root, parseInjectMenu([{ inherit: true }, { id: "site" }])])).toEqual([{ id: "site", position: "top" }]);
|
|
182
|
+
expect(mergeInjectMenus([])).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("menu bodies with prose", () => {
|
|
187
|
+
it("splits item lists from the text around them, in order", () => {
|
|
188
|
+
const body = "Feat Categories:\n* [Ancestry](./ancestry/index.md)\n* [Class](./class/index.md)\n\nBlargo\n\n[foo](#)\n\n*bar* _baz_\n- [Last](./last.md)\n";
|
|
189
|
+
expect(splitMenuBody(body)).toEqual([
|
|
190
|
+
{ kind: "text", text: "Feat Categories:" },
|
|
191
|
+
{ kind: "items", text: "* [Ancestry](./ancestry/index.md)\n* [Class](./class/index.md)" },
|
|
192
|
+
{ kind: "text", text: "Blargo\n\n[foo](#)\n\n*bar* _baz_" },
|
|
193
|
+
{ kind: "items", text: "- [Last](./last.md)" },
|
|
194
|
+
]);
|
|
195
|
+
// nested items and blank lines inside a list stay with the list
|
|
196
|
+
expect(splitMenuBody("- [A](a.md)\n - [B](b.md)\n\n- [C](c.md)\n")).toEqual([
|
|
197
|
+
{ kind: "items", text: "- [A](a.md)\n - [B](b.md)\n\n- [C](c.md)" },
|
|
198
|
+
]);
|
|
199
|
+
expect(splitMenuBody("\n\n")).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("rebases a menu's relative links and images to the menu file's folder", () => {
|
|
203
|
+
const html = '<p><a href="./x.md">x</a> <a href="../y/index.md#top">y</a> <a href="/abs.html">a</a> <a href="https://e.com">e</a> <a href="#h">h</a> <img src="img/pic.png?v=1"></p>';
|
|
204
|
+
expect(rebaseMenuHtml(html, "/character/feats")).toBe(
|
|
205
|
+
'<p><a href="/character/feats/x.html">x</a> <a href="/character/y/index.html#top">y</a> <a href="/abs.html">a</a> <a href="https://e.com">e</a> <a href="#h">h</a> <img src="/character/feats/img/pic.png?v=1"></p>'
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("renders text segments inside the nav, in order with the lists", () => {
|
|
210
|
+
const html = renderInlineMenuHtml(
|
|
211
|
+
[
|
|
212
|
+
{ kind: "text", html: "<p>Feat Categories:</p>" },
|
|
213
|
+
{ kind: "items", items: [{ label: "Class", href: "/character/feats/class/index.html", children: [] }] },
|
|
214
|
+
{ kind: "text", html: "<p>Blargo</p>" },
|
|
215
|
+
],
|
|
216
|
+
{ id: "feats", currentUrl: "/character/feats/class/" }
|
|
217
|
+
);
|
|
218
|
+
expect(html).toBe(
|
|
219
|
+
'<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="feats" aria-label="feats">' +
|
|
220
|
+
'<div class="ursa-menu-text"><p>Feat Categories:</p></div>' +
|
|
221
|
+
'<ul class="ursa-menu-level" data-depth="0"><li class="ursa-menu-item ursa-menu-current"><a href="/character/feats/class/index.html" aria-current="page">Class</a></li></ul>' +
|
|
222
|
+
'<div class="ursa-menu-text"><p>Blargo</p></div></nav>'
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -607,6 +607,20 @@ describe("named menus: menu-<name>.md rendered where a page anchors it", () => {
|
|
|
607
607
|
expect(existsSync(join(output, "public/custom-menu-character.json"))).toBe(false);
|
|
608
608
|
});
|
|
609
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
|
+
|
|
610
624
|
it("the anchor works in MDX", async () => {
|
|
611
625
|
await write("character/powers/menu-powers.md", MENU);
|
|
612
626
|
await write("character/powers/absorb.mdx", "---\nclass: Witch\n---\n\n{menu:powers}\n\n# Absorb\n\nTouch.\n");
|
|
@@ -620,6 +634,86 @@ describe("named menus: menu-<name>.md rendered where a page anchors it", () => {
|
|
|
620
634
|
});
|
|
621
635
|
});
|
|
622
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
|
+
|
|
623
717
|
describe("determinism", () => {
|
|
624
718
|
it("two clean builds of the same tree are byte-identical modulo build metadata", async () => {
|
|
625
719
|
const built = await coldBuild();
|
package/src/helper/build/site.js
CHANGED
|
@@ -65,6 +65,10 @@ import {
|
|
|
65
65
|
renderInlineMenuHtml,
|
|
66
66
|
menuNotFoundComment,
|
|
67
67
|
leadingMenusEnd,
|
|
68
|
+
parseInjectMenu,
|
|
69
|
+
mergeInjectMenus,
|
|
70
|
+
splitMenuBody,
|
|
71
|
+
rebaseMenuHtml,
|
|
68
72
|
} from "../inlineMenu.js";
|
|
69
73
|
import { findAllStyleCss } from "../findStyleCss.js";
|
|
70
74
|
import { findAllScriptJs } from "../findScriptJs.js";
|
|
@@ -109,7 +113,7 @@ export const DIR_KINDS = ["dirIndexJson", "dirListingHtml", "autoIndexPage", "di
|
|
|
109
113
|
export const INTERNAL_KINDS = [
|
|
110
114
|
"linkResolution", "outputOwner", "customMenuFor", "cssBundle", "jsBundle",
|
|
111
115
|
"metaAsset", "metaBundle", "imageInfo", "docMeta",
|
|
112
|
-
"menuFileMeta", "namedMenuFor", "namedMenu",
|
|
116
|
+
"menuFileMeta", "namedMenuFor", "namedMenu", "injectMenusFor",
|
|
113
117
|
];
|
|
114
118
|
/** Site-wide singletons. */
|
|
115
119
|
export const SITE_KINDS = [
|
|
@@ -440,6 +444,27 @@ export function createSite(env) {
|
|
|
440
444
|
return finishAssets(ctx, html, docUrlPath);
|
|
441
445
|
}
|
|
442
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
|
+
|
|
443
468
|
/**
|
|
444
469
|
* Replace a document's `{menu:<id>}` anchors with the named menus they name.
|
|
445
470
|
* A menu that is missing or fails to parse becomes an HTML comment and a
|
|
@@ -448,27 +473,33 @@ export function createSite(env) {
|
|
|
448
473
|
async function resolveNamedMenus(ctx, body, rel) {
|
|
449
474
|
const ids = collectMenuAnchorIds(body);
|
|
450
475
|
if (ids.length === 0) return body;
|
|
451
|
-
const dirRel = dirRelOf(rel);
|
|
452
476
|
const currentUrl = "/" + outputPathFor(rel);
|
|
453
477
|
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
|
-
}
|
|
478
|
+
for (const id of ids) rendered.set(id, await renderNamedMenu(ctx, rel, id, currentUrl, "anchor"));
|
|
469
479
|
return resolveMenuAnchors(body, (id) => rendered.get(id) ?? menuNotFoundComment(id));
|
|
470
480
|
}
|
|
471
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
|
+
|
|
472
503
|
// -------------------------------------------------------------------------
|
|
473
504
|
// Node families
|
|
474
505
|
// -------------------------------------------------------------------------
|
|
@@ -850,6 +881,32 @@ export function createSite(env) {
|
|
|
850
881
|
}
|
|
851
882
|
},
|
|
852
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
|
+
|
|
853
910
|
/** One named menu's parsed items, or null when the file is gone. */
|
|
854
911
|
namedMenu: (rel) => async (ctx) => {
|
|
855
912
|
const info = await ctx.get(nodeId("menuFileMeta", rel));
|
|
@@ -862,10 +919,20 @@ export function createSite(env) {
|
|
|
862
919
|
const { frontmatter, body } = found;
|
|
863
920
|
const autoGenerate = frontmatter["auto-generate-menu"] === true || frontmatter["auto-generate-menu"] === "true";
|
|
864
921
|
const depth = parseInt(frontmatter["menu-depth"], 10) || 10;
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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 };
|
|
869
936
|
},
|
|
870
937
|
|
|
871
938
|
/**
|
|
@@ -1018,8 +1085,11 @@ export function createSite(env) {
|
|
|
1018
1085
|
body = renderResult;
|
|
1019
1086
|
}
|
|
1020
1087
|
|
|
1021
|
-
// `{menu:<id>}` anchors become the named menu, rendered in place
|
|
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 || "");
|
|
1022
1091
|
body = await resolveNamedMenus(ctx, body || "", rel);
|
|
1092
|
+
body = await injectNamedMenus(ctx, body, rel, anchoredIds);
|
|
1023
1093
|
|
|
1024
1094
|
// Inject default H1 if body doesn't start with one. A menu anchored at
|
|
1025
1095
|
// the very top stays above the title.
|
package/src/helper/inlineMenu.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Named menus: `menu-<name>.md` files rendered inline where a document asks
|
|
3
|
-
* for them.
|
|
3
|
+
* for them, or where a folder's config.json injects them.
|
|
4
4
|
*
|
|
5
5
|
* `menu.md` defines a folder's navigation menu and replaces the site's nav for
|
|
6
6
|
* the folder and everything below it. A menu file whose frontmatter carries an
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* as a horizontal strip (the default) or a vertical list (`appearance:
|
|
15
15
|
* vertical`). The item whose href is the current page is marked.
|
|
16
16
|
*
|
|
17
|
+
* A folder can also ask for a menu on every document beneath it without an
|
|
18
|
+
* anchor in each one: `"inject-menu": {"id": "classes", "position": "top"}`
|
|
19
|
+
* in its config.json (see parseInjectMenu / mergeInjectMenus below). The
|
|
20
|
+
* menu resolves by id from the document's folder exactly as an anchor does.
|
|
21
|
+
*
|
|
17
22
|
* Failure is quiet by design: an anchor whose menu is not found, or whose menu
|
|
18
23
|
* file cannot be parsed, is replaced by an HTML comment and reported as a
|
|
19
24
|
* build warning. The page still renders, with nothing visible where the menu
|
|
@@ -21,7 +26,7 @@
|
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
28
|
import { readdirSync, readFileSync } from "./build/tracedFs.js";
|
|
24
|
-
import { join, dirname, resolve, basename } from "path";
|
|
29
|
+
import { join, dirname, resolve, basename, posix } from "path";
|
|
25
30
|
import { extractMenuFrontmatter, isMenuFile } from "./customMenu.js";
|
|
26
31
|
|
|
27
32
|
/** An anchor: `{menu:<id>}`. Ids are letters, digits, `_`, `-` and `.`. */
|
|
@@ -181,6 +186,130 @@ export function resolveMenuAnchors(html, render) {
|
|
|
181
186
|
});
|
|
182
187
|
}
|
|
183
188
|
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Injected menus: config.json `inject-menu`
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export const INJECT_POSITIONS = ["top", "bottom"];
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalise a folder's `inject-menu` value: one object or an array of them,
|
|
197
|
+
* each `{id, position}` or the `{inherit: true}` marker.
|
|
198
|
+
*
|
|
199
|
+
* @param {unknown} value - The raw `inject-menu` value from config.json
|
|
200
|
+
* @returns {{entries: {id: string, position: string}[], inherit: boolean, problems: string[]}}
|
|
201
|
+
*/
|
|
202
|
+
export function parseInjectMenu(value) {
|
|
203
|
+
const out = { entries: [], inherit: false, problems: [] };
|
|
204
|
+
if (value === undefined || value === null) return out;
|
|
205
|
+
const list = Array.isArray(value) ? value : [value];
|
|
206
|
+
for (const item of list) {
|
|
207
|
+
if (!item || typeof item !== "object") {
|
|
208
|
+
out.problems.push(`entry ${JSON.stringify(item)} is not an object`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (item.inherit === true) {
|
|
212
|
+
out.inherit = true;
|
|
213
|
+
if (item.id === undefined) continue;
|
|
214
|
+
}
|
|
215
|
+
const id = item.id === undefined || item.id === null ? "" : String(item.id).trim();
|
|
216
|
+
if (!id) {
|
|
217
|
+
out.problems.push(`entry ${JSON.stringify(item)} has no id`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let position = item.position === undefined ? "top" : String(item.position).trim().toLowerCase();
|
|
221
|
+
if (!INJECT_POSITIONS.includes(position)) {
|
|
222
|
+
out.problems.push(`"${id}": position "${item.position}" is not top or bottom; using top`);
|
|
223
|
+
position = "top";
|
|
224
|
+
}
|
|
225
|
+
out.entries.push({ id, position });
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The menus a folder injects, from the chain of parsed `inject-menu` values
|
|
232
|
+
* on the way down from the docroot (`levels[0]` is the root, the last is the
|
|
233
|
+
* folder itself; a level with no `inject-menu` is null).
|
|
234
|
+
*
|
|
235
|
+
* A level that sets `inject-menu` replaces what was inherited unless one of
|
|
236
|
+
* its entries is `{inherit: true}`, in which case the ancestors' menus come
|
|
237
|
+
* first and the level's own are added after them. A level without the key
|
|
238
|
+
* changes nothing. The same id at the same position is injected once.
|
|
239
|
+
*
|
|
240
|
+
* @param {(ReturnType<typeof parseInjectMenu>|null)[]} levels
|
|
241
|
+
* @returns {{id: string, position: string}[]}
|
|
242
|
+
*/
|
|
243
|
+
export function mergeInjectMenus(levels) {
|
|
244
|
+
let effective = [];
|
|
245
|
+
for (const level of levels) {
|
|
246
|
+
if (!level) continue;
|
|
247
|
+
const base = level.inherit ? effective : [];
|
|
248
|
+
const merged = [...base];
|
|
249
|
+
for (const entry of level.entries) {
|
|
250
|
+
if (!merged.some((e) => e.id === entry.id && e.position === entry.position)) merged.push(entry);
|
|
251
|
+
}
|
|
252
|
+
effective = merged;
|
|
253
|
+
}
|
|
254
|
+
return effective;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Menu file bodies: item lists and the prose between them
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/** A line the menu parser treats as an item: `- [..](..)`, `* [..](..)`, `* [[..]]`. */
|
|
262
|
+
const ITEM_LINE_RE = /^\s*(?:-\s*\[[^\]]*\]\(|\*+\s*\[)/;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Split a menu file's body into item lists and the text around them, in
|
|
266
|
+
* order. `menu.md` only ever needed the items — a fixed nav shows nothing
|
|
267
|
+
* else — but a menu rendered into the page can carry a label before its list
|
|
268
|
+
* or a note after it. Text segments are Markdown; item segments are what
|
|
269
|
+
* `parseCustomMenu` reads.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} body
|
|
272
|
+
* @returns {{kind: 'items'|'text', text: string}[]}
|
|
273
|
+
*/
|
|
274
|
+
export function splitMenuBody(body) {
|
|
275
|
+
const segments = [];
|
|
276
|
+
let current = null;
|
|
277
|
+
for (const line of body.split("\n")) {
|
|
278
|
+
if (!line.trim()) {
|
|
279
|
+
if (current) current.lines.push(line);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const kind = ITEM_LINE_RE.test(line) ? "items" : "text";
|
|
283
|
+
if (!current || current.kind !== kind) {
|
|
284
|
+
current = { kind, lines: [] };
|
|
285
|
+
segments.push(current);
|
|
286
|
+
}
|
|
287
|
+
current.lines.push(line);
|
|
288
|
+
}
|
|
289
|
+
return segments
|
|
290
|
+
.map(({ kind, lines }) => ({ kind, text: lines.join("\n").trim() }))
|
|
291
|
+
.filter((seg) => seg.text);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A menu's prose is rendered once and inlined into pages in other folders,
|
|
296
|
+
* so its relative links and images must be made root-absolute against the
|
|
297
|
+
* menu file's own folder before that happens.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} html - Rendered text segment
|
|
300
|
+
* @param {string} menuUrlDir - The menu file's folder as a URL path, e.g. "/character/feats"
|
|
301
|
+
*/
|
|
302
|
+
export function rebaseMenuHtml(html, menuUrlDir) {
|
|
303
|
+
const base = menuUrlDir.endsWith("/") ? menuUrlDir : menuUrlDir + "/";
|
|
304
|
+
return html.replace(/(<(?:a|img|source|video|audio)\b[^>]*?\s(?:href|src)=["'])([^"']+)(["'])/gi, (m, before, url, quote) => {
|
|
305
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#|\?)/i.test(url)) return m;
|
|
306
|
+
const [pathPart, rest = ""] = url.split(/(?=[?#])/, 2);
|
|
307
|
+
let resolved = posix.normalize(base + pathPart);
|
|
308
|
+
resolved = resolved.replace(/\.(md|mdx|txt)$/i, ".html");
|
|
309
|
+
return `${before}${resolved}${rest}${quote}`;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
184
313
|
/** What an anchor becomes when its menu cannot be rendered. */
|
|
185
314
|
export function menuNotFoundComment(id, reason = "not found") {
|
|
186
315
|
return `<!-- ursa: menu "${escapeHtml(id)}" ${escapeHtml(reason)} -->`;
|
|
@@ -206,17 +335,26 @@ export function leadingMenusEnd(html) {
|
|
|
206
335
|
/**
|
|
207
336
|
* Static markup for a named menu.
|
|
208
337
|
*
|
|
209
|
-
* @param {Array}
|
|
338
|
+
* @param {Array} content - Either items as `parseCustomMenu` produces them
|
|
339
|
+
* ({label, href, children}), or segments: `{kind: 'items', items}` and
|
|
340
|
+
* `{kind: 'text', html}` in document order
|
|
210
341
|
* @param {object} opts
|
|
211
342
|
* @param {string} opts.id
|
|
212
343
|
* @param {string} [opts.appearance="horizontal"]
|
|
213
344
|
* @param {string|null} [opts.currentUrl] - The page's root-absolute `.html` URL, to mark the current item
|
|
214
345
|
* @returns {string}
|
|
215
346
|
*/
|
|
216
|
-
export function renderInlineMenuHtml(
|
|
347
|
+
export function renderInlineMenuHtml(content, { id, appearance = DEFAULT_APPEARANCE, currentUrl = null }) {
|
|
217
348
|
const current = currentUrl ? normalizeUrl(currentUrl) : null;
|
|
218
|
-
const
|
|
219
|
-
|
|
349
|
+
const segments = Array.isArray(content) && content.length > 0 && content[0]?.kind
|
|
350
|
+
? content
|
|
351
|
+
: [{ kind: "items", items: content || [] }];
|
|
352
|
+
const inner = segments.map((seg) =>
|
|
353
|
+
seg.kind === "text"
|
|
354
|
+
? `<div class="ursa-menu-text">${seg.html}</div>`
|
|
355
|
+
: renderLevel(seg.items || [], current, 0)
|
|
356
|
+
).join("");
|
|
357
|
+
return `<nav class="ursa-menu ursa-menu-${appearance}" data-menu-id="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${inner}</nav>`;
|
|
220
358
|
}
|
|
221
359
|
|
|
222
360
|
function renderLevel(items, current, depth) {
|