@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
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
|
+
|
|
15
|
+
# 0.98.0
|
|
16
|
+
2026-09-20
|
|
17
|
+
|
|
18
|
+
Named menus: `menu-<name>.md` files rendered inline where a document anchors them.
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **`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.
|
|
25
|
+
- **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`.
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
31
|
+
The README gains a "Menus" section documenting both kinds.
|
|
32
|
+
|
|
1
33
|
# 0.97.0
|
|
2
34
|
2026-09-20
|
|
3
35
|
|
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
|
|
|
@@ -404,6 +405,109 @@ useEffect(() => {
|
|
|
404
405
|
|
|
405
406
|
`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
407
|
|
|
408
|
+
## Menus
|
|
409
|
+
|
|
410
|
+
The site's navigation is generated from the folder tree. A folder can replace
|
|
411
|
+
it with its own menu by holding a `menu.md` (or `menu.txt`, `_menu.md`,
|
|
412
|
+
`_menu.txt`); that menu applies to the folder and everything below it, until a
|
|
413
|
+
deeper folder holds a menu of its own.
|
|
414
|
+
|
|
415
|
+
```markdown
|
|
416
|
+
---
|
|
417
|
+
auto-generate-menu: true # start from the folder tree…
|
|
418
|
+
menu-position: top # top (default) or side
|
|
419
|
+
menu-depth: 3
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
- [Custom link](./somewhere.md)
|
|
423
|
+
{menu} # …and put the generated items here
|
|
424
|
+
- [Another](https://example.com)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Items are Markdown list links (`- [Label](./path.md)`, nested by indentation)
|
|
428
|
+
or wikitext (`* [[path|Label]]`). Relative paths resolve from the menu file's
|
|
429
|
+
folder.
|
|
430
|
+
|
|
431
|
+
### Named menus
|
|
432
|
+
|
|
433
|
+
A menu file whose frontmatter has an `id` is a **named menu**. It does not
|
|
434
|
+
replace the folder's navigation; instead any document in that folder or below
|
|
435
|
+
it places the menu in its body with an anchor on a line of its own:
|
|
436
|
+
|
|
437
|
+
```markdown
|
|
438
|
+
---
|
|
439
|
+
id: classes
|
|
440
|
+
appearance: horizontal # horizontal (default) or vertical
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
- [Arcanist](./arcanist.md)
|
|
444
|
+
- [Fighter](./fighter.md)
|
|
445
|
+
- [Witch](./witch.mdx)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```markdown
|
|
449
|
+
# Fighter
|
|
450
|
+
|
|
451
|
+
{menu:classes}
|
|
452
|
+
|
|
453
|
+
Fighters are…
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- Name the file `menu.md` or `menu-<anything>.md` (`menu-classes.md`,
|
|
457
|
+
`menu-2.txt`); a folder can hold several. The `id` is required for
|
|
458
|
+
`menu-<anything>.md`; `menu.md` without one is the folder menu above.
|
|
459
|
+
- The anchor renders as a static `<nav class="ursa-menu ursa-menu-<appearance>">`
|
|
460
|
+
exactly where it stands in the document, not as a fixed element. The item
|
|
461
|
+
whose link is the current page gets `ursa-menu-current` (its ancestors
|
|
462
|
+
`ursa-menu-active`), so a menu of sibling pages works as a category switcher.
|
|
463
|
+
`horizontal` is a strip of items with hover dropdowns for nested items;
|
|
464
|
+
`vertical` is a stacked, indented list.
|
|
465
|
+
- The nearest file with that `id` wins, so a deeper folder can shadow a menu
|
|
466
|
+
defined above it. `auto-generate-menu` and `menu-depth` work as in `menu.md`.
|
|
467
|
+
- A menu anchored above the first heading stays above the page title.
|
|
468
|
+
- The anchor must be on its own line. It works in `.md`, `.txt` and `.mdx`.
|
|
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`.)
|
|
476
|
+
- Menu files are navigation, not documents: they are not rendered to pages,
|
|
477
|
+
listed in menus or indices, or searched.
|
|
478
|
+
|
|
479
|
+
**Failure is quiet.** An anchor whose menu does not exist, or whose menu file
|
|
480
|
+
cannot be parsed, is replaced by `<!-- ursa: menu "id" not found -->` and
|
|
481
|
+
reported as a build warning naming the document. The page renders normally
|
|
482
|
+
with nothing where the menu would have been, and the surrounding Markdown is
|
|
483
|
+
untouched. Under `ursa serve`, creating the missing menu file fills the anchor
|
|
484
|
+
without editing the page.
|
|
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
|
+
|
|
407
511
|
## Auto-Index Generation
|
|
408
512
|
|
|
409
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:
|
|
@@ -1465,6 +1465,171 @@
|
|
|
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
|
+
/* 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
|
+
|
|
1524
|
+
:scope.ursa-menu-horizontal > ul {
|
|
1525
|
+
display: flex;
|
|
1526
|
+
flex-wrap: wrap;
|
|
1527
|
+
align-items: center;
|
|
1528
|
+
gap: 0.25rem;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
:scope.ursa-menu-vertical .ursa-menu-text {
|
|
1532
|
+
padding: 0.25rem 0.7rem;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
:scope.ursa-menu-horizontal > ul > li {
|
|
1536
|
+
position: relative;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
:scope.ursa-menu-horizontal > ul > li > a,
|
|
1540
|
+
:scope.ursa-menu-horizontal > ul > li > span {
|
|
1541
|
+
padding: 0.3rem 0.75rem;
|
|
1542
|
+
border-radius: 4px;
|
|
1543
|
+
transition: background-color 0.15s ease;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
:scope.ursa-menu-horizontal > ul > li > a:hover {
|
|
1547
|
+
background-color: color-mix(in srgb, var(--text-color) 10%, transparent);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
:scope.ursa-menu-horizontal > ul > li.ursa-menu-current > a,
|
|
1551
|
+
:scope.ursa-menu-horizontal > ul > li.ursa-menu-active > a {
|
|
1552
|
+
font-weight: 600;
|
|
1553
|
+
background-color: var(--widget-bg);
|
|
1554
|
+
box-shadow: inset 0 0 0 1px var(--widget-border);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
:scope.ursa-menu-horizontal > ul > li.ursa-menu-has-children > a::after,
|
|
1558
|
+
:scope.ursa-menu-horizontal > ul > li.ursa-menu-has-children > span::after {
|
|
1559
|
+
content: '▼';
|
|
1560
|
+
font-size: 0.4em;
|
|
1561
|
+
margin-left: 6px;
|
|
1562
|
+
opacity: 0.6;
|
|
1563
|
+
vertical-align: middle;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
:scope.ursa-menu-horizontal li > ul {
|
|
1567
|
+
display: none;
|
|
1568
|
+
position: absolute;
|
|
1569
|
+
top: 100%;
|
|
1570
|
+
left: 0;
|
|
1571
|
+
min-width: 12rem;
|
|
1572
|
+
background-color: var(--widget-bg);
|
|
1573
|
+
border: 1px solid var(--widget-border);
|
|
1574
|
+
border-radius: 4px;
|
|
1575
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
1576
|
+
z-index: 1005;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
:scope.ursa-menu-horizontal li:hover > ul {
|
|
1580
|
+
display: block;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
:scope.ursa-menu-horizontal li li {
|
|
1584
|
+
position: relative;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
:scope.ursa-menu-horizontal li li > a,
|
|
1588
|
+
:scope.ursa-menu-horizontal li li > span {
|
|
1589
|
+
padding: 0.5rem 1rem;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
:scope.ursa-menu-horizontal li li > a:hover {
|
|
1593
|
+
background-color: color-mix(in srgb, var(--text-color) 10%, transparent);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
:scope.ursa-menu-horizontal li li.ursa-menu-current > a {
|
|
1597
|
+
font-weight: 600;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/* third level and deeper fly out to the side */
|
|
1601
|
+
:scope.ursa-menu-horizontal li li > ul {
|
|
1602
|
+
top: 0;
|
|
1603
|
+
left: 100%;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/* --- vertical: stacked, nested levels indented --- */
|
|
1607
|
+
:scope.ursa-menu-vertical a,
|
|
1608
|
+
:scope.ursa-menu-vertical span {
|
|
1609
|
+
padding: 0.25rem 0.7rem;
|
|
1610
|
+
border-left: 2px solid transparent;
|
|
1611
|
+
white-space: normal;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
:scope.ursa-menu-vertical a:hover {
|
|
1615
|
+
background-color: color-mix(in srgb, var(--text-color) 8%, transparent);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
:scope.ursa-menu-vertical li.ursa-menu-current > a {
|
|
1619
|
+
font-weight: 600;
|
|
1620
|
+
border-left-color: var(--text-color);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
:scope.ursa-menu-vertical li.ursa-menu-active > a {
|
|
1624
|
+
font-weight: 600;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
:scope.ursa-menu-vertical li > ul {
|
|
1628
|
+
margin-left: 0.7rem;
|
|
1629
|
+
border-left: 1px solid var(--widget-border);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1468
1633
|
|
|
1469
1634
|
/* --------------------------------------------------------------------------
|
|
1470
1635
|
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
|
-
|
|
13
|
-
|
|
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
|
|
34
|
-
|
|
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
|
@@ -0,0 +1,225 @@
|
|
|
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
|
+
parseInjectMenu,
|
|
14
|
+
mergeInjectMenus,
|
|
15
|
+
splitMenuBody,
|
|
16
|
+
rebaseMenuHtml,
|
|
17
|
+
} from "../inlineMenu.js";
|
|
18
|
+
import { isMenuFile, findCustomMenu } from "../customMenu.js";
|
|
19
|
+
|
|
20
|
+
describe("isMenuFile", () => {
|
|
21
|
+
it("matches the folder menu and named menus, not documents", () => {
|
|
22
|
+
for (const name of ["menu.md", "_menu.md", "menu.txt", "menu-2.md", "menu-classes.md", "_menu-x.txt", "Menu-Classes.md"]) {
|
|
23
|
+
expect(isMenuFile(name)).toBe(true);
|
|
24
|
+
}
|
|
25
|
+
for (const name of ["menus.md", "menu.mdx", "my-menu.md", "menu-.md", "menu_x.md", "index.md", "menu"]) {
|
|
26
|
+
expect(isMenuFile(name)).toBe(false);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("namedMenuOptions", () => {
|
|
32
|
+
it("defaults appearance to horizontal and reports an invalid value", () => {
|
|
33
|
+
expect(namedMenuOptions({ id: "classes" })).toEqual({ id: "classes", appearance: "horizontal", appearanceInvalid: null });
|
|
34
|
+
expect(namedMenuOptions({ id: "x", appearance: "Vertical" }).appearance).toBe("vertical");
|
|
35
|
+
expect(namedMenuOptions({ id: "x", appearance: "sideways" })).toMatchObject({ appearance: "horizontal", appearanceInvalid: "sideways" });
|
|
36
|
+
expect(namedMenuOptions({}).id).toBeNull();
|
|
37
|
+
expect(namedMenuOptions({ id: "" }).id).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("anchors", () => {
|
|
42
|
+
it("collects ids in both forms, once each", () => {
|
|
43
|
+
const html = '<p>{menu:a}</p><div data-ursa-menu="b"></div><p>x {menu:a} y</p>';
|
|
44
|
+
expect(collectMenuAnchorIds(html)).toEqual(["b", "a"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rewrites MDX anchors alone on a line into the element form", () => {
|
|
48
|
+
const src = "---\nx: 1\n---\n\n{menu:classes}\n\nText with {menu:inline} stays.\n {menu:indented} \n";
|
|
49
|
+
const out = prepareMdxMenuAnchors(src);
|
|
50
|
+
expect(out).toContain('<div data-ursa-menu="classes"></div>');
|
|
51
|
+
expect(out).toContain('<div data-ursa-menu="indented"></div>');
|
|
52
|
+
expect(out).toContain("Text with {menu:inline} stays.");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("replaces a paragraph that is only the anchor", () => {
|
|
56
|
+
const out = resolveMenuAnchors("<h1>T</h1>\n<p>{menu:classes}</p>\n<p>Body.</p>", (id) => `<nav>${id}</nav>`);
|
|
57
|
+
expect(out).toBe("<h1>T</h1>\n<nav>classes</nav>\n<p>Body.</p>");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("splits a paragraph with text around the anchor so the nav is not inside a <p>", () => {
|
|
61
|
+
const out = resolveMenuAnchors("<p>Before {menu:x} after</p>", (id) => `<nav>${id}</nav>`);
|
|
62
|
+
expect(out).toBe("<p>Before</p>\n<nav>x</nav><p>after</p>\n");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves anchors quoted in code alone", () => {
|
|
66
|
+
const html = "<p>Write <code>{menu:x}</code> on its own line.</p>\n<pre><code>{menu:y}</code></pre>";
|
|
67
|
+
expect(resolveMenuAnchors(html, () => "NO")).toBe(html);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("replaces the element form anywhere", () => {
|
|
71
|
+
const out = resolveMenuAnchors('<div data-ursa-menu="c"></div><div data-ursa-menu="d"/>', (id) => `[${id}]`);
|
|
72
|
+
expect(out).toBe("[c][d]");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is a no-op for bodies without anchors", () => {
|
|
76
|
+
const html = "<p>Nothing here { menu } either</p>";
|
|
77
|
+
expect(resolveMenuAnchors(html, () => "X")).toBe(html);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("finds where leading menus end", () => {
|
|
81
|
+
const nav = '<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="a"><ul></ul></nav>';
|
|
82
|
+
expect(leadingMenusEnd(`${nav}\n<p>x</p>`)).toBe(nav.length);
|
|
83
|
+
expect(leadingMenusEnd(`\n${nav}${menuNotFoundComment("b")}<h1>T</h1>`)).toBe(1 + nav.length + menuNotFoundComment("b").length);
|
|
84
|
+
expect(leadingMenusEnd("<h1>T</h1>")).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("renderInlineMenuHtml", () => {
|
|
89
|
+
const data = [
|
|
90
|
+
{ label: "Arcanist", href: "/character/classes/arcanist.html", children: [] },
|
|
91
|
+
{ label: "Fighter & Co", href: "/character/classes/fighter.html", children: [] },
|
|
92
|
+
{
|
|
93
|
+
label: "More",
|
|
94
|
+
href: null,
|
|
95
|
+
children: [{ label: "Witch", href: "/character/classes/witch.html", children: [] }],
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
it("renders a horizontal nav by default, escaping labels", () => {
|
|
100
|
+
const html = renderInlineMenuHtml(data, { id: "classes" });
|
|
101
|
+
expect(html).toMatch(/^<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="classes"/);
|
|
102
|
+
expect(html).toContain("Fighter & Co");
|
|
103
|
+
expect(html).toContain('<ul class="ursa-menu-level" data-depth="1">');
|
|
104
|
+
expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children"><span>More</span>');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("marks the current page and its ancestors", () => {
|
|
108
|
+
const html = renderInlineMenuHtml(data, { id: "classes", appearance: "vertical", currentUrl: "/character/classes/witch.html" });
|
|
109
|
+
expect(html).toContain("ursa-menu-vertical");
|
|
110
|
+
expect(html).toContain('<li class="ursa-menu-item ursa-menu-current"><a href="/character/classes/witch.html" aria-current="page">Witch</a>');
|
|
111
|
+
expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children ursa-menu-active"><span>More</span>');
|
|
112
|
+
expect(html).not.toContain('ursa-menu-current"><a href="/character/classes/arcanist.html"');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("treats index.html, a trailing slash and no extension as the same page", () => {
|
|
116
|
+
const items = [{ label: "Classes", href: "/character/classes/index.html", children: [] }];
|
|
117
|
+
for (const url of ["/character/classes/", "/character/classes", "/character/classes/index.html"]) {
|
|
118
|
+
expect(renderInlineMenuHtml(items, { id: "x", currentUrl: url })).toContain("ursa-menu-current");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("findNamedMenu / findCustomMenu", () => {
|
|
124
|
+
let root;
|
|
125
|
+
beforeEach(async () => {
|
|
126
|
+
root = await mkdtemp(join(tmpdir(), "ursa-inline-menu-"));
|
|
127
|
+
await mkdir(join(root, "a/b"), { recursive: true });
|
|
128
|
+
await writeFile(join(root, "menu.md"), "---\nauto-generate-menu: true\n---\n");
|
|
129
|
+
await writeFile(join(root, "menu-classes.md"), "---\nid: classes\n---\n- [Root](./index.md)\n");
|
|
130
|
+
await writeFile(join(root, "a/menu-classes.md"), "---\nid: classes\nappearance: vertical\n---\n- [Deep](./x.md)\n");
|
|
131
|
+
await writeFile(join(root, "a/b/menu.md"), "---\nid: named-main\n---\n- [Hidden](./y.md)\n");
|
|
132
|
+
});
|
|
133
|
+
afterEach(() => rm(root, { recursive: true, force: true }));
|
|
134
|
+
|
|
135
|
+
it("nearest file with the id wins", () => {
|
|
136
|
+
expect(findNamedMenu(join(root, "a/b"), root, "classes").menuDir).toBe(join(root, "a"));
|
|
137
|
+
expect(findNamedMenu(root, root, "classes").menuDir).toBe(root);
|
|
138
|
+
expect(findNamedMenu(join(root, "a/b"), root, "named-main").path).toBe(join(root, "a/b/menu.md"));
|
|
139
|
+
expect(findNamedMenu(join(root, "a/b"), root, "nope")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("a menu.md with an id is not the folder's nav menu", () => {
|
|
143
|
+
const info = findCustomMenu(join(root, "a/b"), root);
|
|
144
|
+
expect(info.menuDir).toBe(root);
|
|
145
|
+
});
|
|
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
|
+
});
|
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
|