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