@kenjura/ursa 0.96.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +144 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/default.css +144 -0
  5. package/meta/templates/default-template/menu.js +18 -1
  6. package/meta/templates/default-template/search.js +11 -0
  7. package/meta/templates/default-template/sectionify.js +17 -9
  8. package/meta/templates/default-template/widgets.js +4 -0
  9. package/package.json +1 -2
  10. package/src/dev.js +13 -23
  11. package/src/helper/__test__/contentHash.test.js +16 -6
  12. package/src/helper/__test__/inlineMenu.test.js +142 -0
  13. package/src/helper/assetBundler.js +93 -19
  14. package/src/helper/automenu.js +39 -13
  15. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  16. package/src/helper/build/__test__/graph.test.js +259 -3
  17. package/src/helper/build/__test__/pass.test.js +664 -0
  18. package/src/helper/build/autoIndex.js +6 -371
  19. package/src/helper/build/excludeFilter.js +1 -2
  20. package/src/helper/build/footer.js +27 -14
  21. package/src/helper/build/graph.js +575 -152
  22. package/src/helper/build/index.js +0 -2
  23. package/src/helper/build/metadata.js +19 -5
  24. package/src/helper/build/pass.js +497 -0
  25. package/src/helper/build/precedence.js +174 -0
  26. package/src/helper/build/site.js +1392 -0
  27. package/src/helper/build/templates.js +1 -2
  28. package/src/helper/build/tracedFs.js +247 -0
  29. package/src/helper/contentHash.js +0 -78
  30. package/src/helper/customMenu.js +27 -4
  31. package/src/helper/fileRenderer.js +119 -111
  32. package/src/helper/findScriptJs.js +1 -1
  33. package/src/helper/findStyleCss.js +1 -1
  34. package/src/helper/folderConfig.js +7 -18
  35. package/src/helper/fullTextIndex.js +41 -29
  36. package/src/helper/imageProcessor.js +45 -0
  37. package/src/helper/inlineMenu.js +275 -0
  38. package/src/helper/linkValidator.js +118 -127
  39. package/src/helper/mdxRenderer.js +27 -5
  40. package/src/helper/menuLabels.js +30 -5
  41. package/src/helper/whitelistFilter.js +1 -2
  42. package/src/jobs/generate.js +67 -1829
  43. package/src/serve.js +317 -697
  44. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  45. package/src/helper/build/cacheBust.js +0 -141
  46. package/src/helper/build/navCache.js +0 -145
  47. package/src/helper/build/watchCache.js +0 -33
  48. package/src/helper/dependencyTracker.js +0 -384
package/CHANGELOG.md CHANGED
@@ -1,3 +1,47 @@
1
+ # 0.98.0
2
+ 2026-09-20
3
+
4
+ Named menus: `menu-<name>.md` files rendered inline where a document anchors them.
5
+
6
+ A folder's `menu.md` replaces the site navigation for its subtree. That is one menu per folder, always in the chrome. Sites also want small menus that belong to the content — a strip of sibling pages at the top of each class page, say — and those have been written by hand into every page, and kept in sync by hand.
7
+
8
+ - **Any `menu*.md` is a menu file.** `menu.md` (and `_menu.md`, `.txt` variants) is the folder menu as before; `menu-classes.md`, `menu-2.md` and so on are additional menus in the same folder, and the same file format.
9
+ - **An `id` makes a menu named.** A menu file whose frontmatter has `id: classes` renders nowhere on its own. A document in that folder or below it places it with `{menu:classes}` on a line of its own, and the menu appears there as a static `<nav class="ursa-menu">` inside the article — not a fixed element. `menu.md` with an `id` is a named menu too, and stops being the folder's nav. The `id` is required for `menu-<name>.md`; a file without one is warned about and renders nowhere.
10
+ - **`appearance: horizontal | vertical`**, default horizontal: a strip of items with hover dropdowns for nested items, or a stacked, indented list. Both mark the current page's item (`ursa-menu-current`) and its ancestors (`ursa-menu-active`). Styles live in the default template's stylesheet, scoped like the breadcrumbs so a site's `a { … }` cannot break them and a site's more specific selector can restyle them.
11
+ - **Resolution is by nearest id.** The walk goes up from the document's folder to the docroot; the first menu file with a matching `id` wins, so a deeper folder can shadow one defined above it. `auto-generate-menu` and `menu-depth` work as in `menu.md`.
12
+ - **Anchors fail quietly.** `{menu:x}` is inert Markdown — braces mean nothing to the renderer — and it is substituted after rendering, so an anchor never changes how the Markdown around it parses. If no menu answers, or the menu file cannot be parsed, the anchor becomes `<!-- ursa: menu "x" not found -->` and the build warns, naming the document; the page renders normally. Anchors inside code spans and code blocks stay as written. In `.mdx`, where `{…}` is an expression, an anchor alone on a line is rewritten before compilation and never reaches the compiler.
13
+ - **A menu above the first heading stays above the title.** The default `<h1>` is injected after any leading menus rather than before them, and the default template's `sectionify.js` keeps leading menus with the breadcrumbs, outside the sections.
14
+ - **Menus are build-graph nodes.** Editing `menu-classes.md` rewrites exactly the pages that anchor it (their `.html`, `.json` and `.xml`), nothing else; creating a missing menu file fills the anchors in the next pass without the pages being edited; deleting it puts the comments back. A page that anchors a menu depends on a projection of each candidate menu file's identity, not its body, so adding an unrelated file to the folder does not re-render it.
15
+ - **Menu files are not documents.** They are not rendered to pages, listed in the automenu, an auto-index or a `<dir>.html` listing, indexed for search, or dated in recent activity. This applies to `menu.md` too, which used to be rendered to `menu.html`. The upgrade discards the build cache, so that stale page is not known to the new graph and is not deleted; one `--clean` removes it.
16
+
17
+ The README gains a "Menus" section documenting both kinds.
18
+
19
+ # 0.97.0
20
+ 2026-09-20
21
+
22
+ `ursa serve` catches every change; `generate` and `serve` are one build.
23
+
24
+ `serve` had a history of missed updates — a font replaced in `meta/shared`, a folder renamed, a document deleted, an image added where a page had a dead link, a `.tsx` edited, `menu-label` changed in a non-index file — each one a case that some filename heuristic in the watcher or some cache-clear call site had not been told about, and each one fixed by restarting with `--clean`. `docs/SERVE.md` (written 2026-09-03) specifies what `serve` promises instead and lists twenty-seven ways the old implementation fell short of it. This release implements that specification.
25
+
26
+ **One rule replaces all of the heuristics: every output is a node in a build graph, and a node recomputes if and only if one of its recorded inputs changed.** The engine that shipped unused in 0.87 (`src/helper/build/graph.js`) is now the build. Every file a node reads, every path it probes and every directory it lists while computing is recorded as an edge — including reads made deep inside the menu walker, the label resolver and the breadcrumb builder, which now import their `fs` calls from a traced shim rather than being rewritten to thread a context through. Nothing decides what to rebuild by looking at a filename.
27
+
28
+ - **`generate` runs the same pass.** A cold start is a pass in which every leaf is new; a warm start re-checks the persisted graph (`.ursa/graph.json`, replacing `content-hashes.json`, `nav-cache.json`, `dependency-graph.json`, `image-cache.json` and `fulltext-index.json`) and recomputes what its inputs say has changed. The output of `serve` after any sequence of edits is byte-identical to a clean `generate` of the same tree, modulo the footer's build id and timestamp — the acceptance test in `src/helper/build/__test__/pass.test.js` asserts it for every scenario in the spec's §10.
29
+ - **Minimality.** Editing an article body rewrites its `.html`/`.json`/`.xml`, the full-text index and recent activity, and nothing else. Editing `menu-label` rewrites `menu-data.json` and the listings that show the label. Saving a file without changing its bytes writes nothing. Editing a `.tsx` re-renders exactly the MDX documents that import it. `--explain` (new on both commands) prints, for every output rebuilt, the input that changed.
30
+ - **Catchment zones are exact.** Adding, editing or removing a `style.css` rewrites exactly the pages in its subtree, plus the bundle; renaming it to `_style.css` with the same content rewrites nothing. A template edit rewrites the pages using it, including generated index and listing pages, which the old tracker never registered. A shared font rewrites the meta bundle and every page whose template bundles it. A root-level add or rename rewrites every page (the root menu is inlined); a deeper one rewrites only the menu data.
31
+ - **Nothing is left behind.** Every output file is owned by exactly one node, and ownership is persisted. Deleting or renaming a document deletes its `.html`/`.json`/`.xml`; renaming a folder deletes the old subtree, its listing and its bundles; hiding a folder with `config.json` deletes its outputs. This applies to `generate` too: `output/` no longer accumulates ghosts, and `--clean` is corruption recovery rather than a routine step.
32
+ - **Several sources for one output.** `index.mdx` beside `index.md`, `foo.md` beside a hand-written `foo.html`, `home.md` beside `foo/foo.md` — the winner used to be whichever wrote last. One precedence list (`src/helper/build/precedence.js`, documented in the README's "Link logic") now decides it everywhere: hand-written `.html`, then `.mdx`, `.md`, `.txt`, `.yml`; for a folder's index, `index.*`, `_index.*`, `home.*`, `_home.*`, `<foldername>.*`, then the auto-index. The shadowed source is not rendered, indexed or listed, and a warning names both files; removing the winner promotes the next candidate in the same pass, so deleting the only `index.md` of a folder hands its URL to the auto-index without a restart. `/foo` with both `foo.md` and `foo/` present resolves to `foo.html` in links as it always did in the server; the link validator had said the opposite.
33
+ - **Dead links come alive.** A page records the images it references whether or not they exist, so adding the missing image gives the page its preview and lightbox without the page being edited, and replacing an image changes the page's `?v=` token. A page depends on one resolution node per link target, not on the whole path map, so creating a document that other pages link to rewrites only those pages.
34
+ - **Cache-busting is by content hash**, not build timestamp: `?v=<hash of the file>` on bundles, images, scripts and CSS `url()` targets. An unchanged asset keeps its URL, so pages are not rewritten for it; a changed one changes its URL, which is exactly the set of pages a browser must reload. The meta bundle's `fetch('/public/x.json')` calls carry the page's build id (`data-build` on `<body>`) at request time instead of the JSON's hash — hashing the menu data into the bundle would have rewritten every page whenever the menu changed.
35
+ - **Watching, batching, single writer.** Both trees are watched recursively without an extension allow-list (`.otf`, `.wav`, `.zip` and files with no extension were invisible before); only `.ursa/`, the output directory, `node_modules`, `.git` and editor scratch files are excluded. Directory events rescan their subtree, since macOS reports a renamed folder as one event. Events are batched with a 500 ms quiet window and a 2 s upper bound, so a process writing continuously cannot postpone regeneration forever. Exactly one pass runs at a time — the startup build included, which used to race a meta edit — and events that arrive during a pass form the next batch.
36
+ - **Viewer first, early reload.** The pages connected browsers are looking at are built before anything else, and each browser is told to reload the moment its page is written — before the rest of the affected set is processed. "Changed" means the bytes the browser would fetch changed, so a tab on a page whose owner moved (the auto-index taking over a deleted `index.md`) or that was deleted reloads to the truth, and a tab parked on a 404 loads the page when it appears. A page whose owner failed is told `update-failed` instead of being left with the indicator on. When only the menu data, the search indices or recent activity changed, open tabs get `data-updated` and refetch in place — the default template's `menu.js`, `search.js` and `widgets.js` listen for it — rather than reloading.
37
+ - **The `$&` bug.** The single-file regenerator substituted template placeholders with `String.replace(string, string)`, which interprets `$&`, `` $` `` and `$'` in the document body; the full build did not. There is one render path now, and it uses a function replacer.
38
+ - **Determinism.** Every listing, index and menu is sorted; readdir order never reaches the output. The footer's build id, timestamp and git hash are computed once per session and are not an input of anything, so a `generate` run after an unrelated edit does not rewrite every page to stamp them.
39
+ - **`transformMetadata.js` edits take effect** (the dynamic import carries the file's content hash), a hand-written `.html` is never overwritten by `serve`, a frontmatter-only `index.md` never displaces the auto-index in `serve`, the whitelist and exclude files are live inputs (editing them adds and removes documents), and `.yaml` is no longer watched as if it were an article.
40
+
41
+ Smaller changes that fall out of the one render path: the `<dir>.html` listing pages emit root-absolute links (they were relative to the wrong directory below one level) and fill `${customScript}` instead of leaving the placeholder in the page; the auto-index no longer lists a folder's listing page as a document beside the folder itself; search-index and recent-activity entries are ordered deterministically; copies of `style.css` are no longer placed in the output beside the bundles. `dev` reads the shared precedence list but is otherwise unchanged; its future is still an open question in `docs/SERVE.md`. The `directory-tree` dependency is gone.
42
+
43
+ Upgrading: the first build discards the old caches and rebuilds everything. Files the previous version left in `output/` that this version does not produce (the `style.css` copies) are not known to the graph and are not deleted; one `--clean` removes them.
44
+
1
45
  # 0.96.0
2
46
  2026-09-16
3
47
 
package/README.md CHANGED
@@ -53,10 +53,21 @@ Generate a static site once and exit.
53
53
 
54
54
  #### `ursa serve <source>`
55
55
  Start a development server that:
56
- - Generates the site initially
57
- - Starts an HTTP server to serve the output directory
58
- - Watches source and meta directories for changes
59
- - Automatically regenerates the site when files change
56
+ - Starts an HTTP server over the output directory, then builds the site
57
+ - Watches the source and meta directories for changes — every file, no
58
+ extension allow-list and keeps the output continuously equal to what a
59
+ build from the current source would produce: adds, deletes, renames, folder
60
+ renames, inherited `style.css`/`script.js`/`menu.md`/`config.json` files
61
+ appearing or disappearing, images and linked documents that come alive,
62
+ template and shared-asset edits
63
+ - Rebuilds only the outputs whose inputs actually changed (see
64
+ [Incremental builds](#incremental-builds)), the page a connected browser is
65
+ looking at first, and tells that browser to reload the moment it is written
66
+ - Tells other open tabs to refetch the menu, search indices and recent
67
+ activity in place when those change, instead of reloading them
68
+
69
+ The same incremental pass runs under `generate`, so the two never disagree
70
+ about the output; `docs/SERVE.md` is the specification.
60
71
 
61
72
  ### CLI Options
62
73
 
@@ -68,6 +79,40 @@ Start a development server that:
68
79
  - `--exclude, -e` - Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line
69
80
  - `--clean` - Delete the `.ursa` cache folder and clear output directory, forcing full regeneration
70
81
  - `--json-only, -j` - Emit only the `.json` data files (generate command only)
82
+ - `--explain` - Log, for every output that was rebuilt, the input that changed
83
+
84
+ ### Incremental builds
85
+
86
+ Every build — `generate` and each `serve` pass alike — runs over a persisted
87
+ build graph (`.ursa/graph.json`). Every output file is owned by exactly one
88
+ node of the graph, and every file, path probe and directory listing a node
89
+ consumed while producing it is recorded as an input. A pass re-checks the
90
+ inputs that changed, recomputes only the nodes that consumed them, stops
91
+ propagating where a recomputed value came out identical, writes only files
92
+ whose bytes differ, and deletes the outputs of anything whose source is gone.
93
+
94
+ Consequences:
95
+
96
+ - `generate` after an edit rewrites the edited document's page and data, the
97
+ full-text index and recent activity — nothing else. A `menu-label` edit
98
+ rewrites the menu data and the listings that show the label. A root
99
+ `style.css` edit rewrites every page (the bundle's URL, which carries its
100
+ content hash, appears in every one of them). Run with `--explain` to see the
101
+ reason for each rewrite.
102
+ - Deleting or renaming a source removes its outputs; hiding a folder with
103
+ `config.json` deletes the folder's outputs. `output/` no longer accumulates
104
+ ghosts, and `--clean` is corruption recovery, not a routine step.
105
+ - Asset URLs carry `?v=<content hash>` rather than a build timestamp, so
106
+ identical inputs give identical output. The one exception is the footer's
107
+ build id and timestamp, which are fixed once per `serve` session or
108
+ `generate` run and are not an input of anything.
109
+ - Starting `serve` (or running `generate`) against a tree that changed while
110
+ ursa was not running converges on the startup pass, without `--clean`.
111
+ - Upgrading ursa discards the graph and rebuilds everything.
112
+
113
+ Files that used to live in `.ursa/` (`content-hashes.json`, `nav-cache.json`,
114
+ `dependency-graph.json`, `image-cache.json`, `fulltext-index.json`) are
115
+ replaced by the graph.
71
116
 
72
117
  ### JSON-Only Builds
73
118
 
@@ -91,11 +136,10 @@ Everything skipped operates on the assembled *page*; the JSON's `bodyHtml` is
91
136
  the pre-template render, which none of those steps touch. That is why the output
92
137
  is identical rather than merely similar.
93
138
 
94
- Mixing modes against one source tree is safe. The two share the `.ursa` hash
95
- cache, but each asks only for the outputs its own mode emits, so a full build
96
- following a JSON-only build still writes the HTML it is missing. A JSON-only
97
- build does not write the dependency graph or seed the watch cache, so it cannot
98
- degrade a later `serve`.
139
+ Mixing modes against one source tree is safe. The build mode is an input of
140
+ the data nodes, so a full build following a JSON-only build writes the HTML and
141
+ XML it is missing, and a JSON-only build after a full one leaves the rest of
142
+ the output in place.
99
143
 
100
144
  ### Whitelist File Format
101
145
 
@@ -360,6 +404,78 @@ useEffect(() => {
360
404
 
361
405
  `contentChanged(root)` dispatches `ursa:content-changed` on `document` with the changed element in `event.detail.root` (the article, if omitted). Sticky headings and the table of contents re-read the article on it; calls within the same task are coalesced into one event. A site's own scripts can listen for the same event.
362
406
 
407
+ ## Menus
408
+
409
+ The site's navigation is generated from the folder tree. A folder can replace
410
+ it with its own menu by holding a `menu.md` (or `menu.txt`, `_menu.md`,
411
+ `_menu.txt`); that menu applies to the folder and everything below it, until a
412
+ deeper folder holds a menu of its own.
413
+
414
+ ```markdown
415
+ ---
416
+ auto-generate-menu: true # start from the folder tree…
417
+ menu-position: top # top (default) or side
418
+ menu-depth: 3
419
+ ---
420
+
421
+ - [Custom link](./somewhere.md)
422
+ {menu} # …and put the generated items here
423
+ - [Another](https://example.com)
424
+ ```
425
+
426
+ Items are Markdown list links (`- [Label](./path.md)`, nested by indentation)
427
+ or wikitext (`* [[path|Label]]`). Relative paths resolve from the menu file's
428
+ folder.
429
+
430
+ ### Named menus
431
+
432
+ A menu file whose frontmatter has an `id` is a **named menu**. It does not
433
+ replace the folder's navigation; instead any document in that folder or below
434
+ it places the menu in its body with an anchor on a line of its own:
435
+
436
+ ```markdown
437
+ ---
438
+ id: classes
439
+ appearance: horizontal # horizontal (default) or vertical
440
+ ---
441
+
442
+ - [Arcanist](./arcanist.md)
443
+ - [Fighter](./fighter.md)
444
+ - [Witch](./witch.mdx)
445
+ ```
446
+
447
+ ```markdown
448
+ # Fighter
449
+
450
+ {menu:classes}
451
+
452
+ Fighters are…
453
+ ```
454
+
455
+ - Name the file `menu.md` or `menu-<anything>.md` (`menu-classes.md`,
456
+ `menu-2.txt`); a folder can hold several. The `id` is required for
457
+ `menu-<anything>.md`; `menu.md` without one is the folder menu above.
458
+ - The anchor renders as a static `<nav class="ursa-menu ursa-menu-<appearance>">`
459
+ exactly where it stands in the document, not as a fixed element. The item
460
+ whose link is the current page gets `ursa-menu-current` (its ancestors
461
+ `ursa-menu-active`), so a menu of sibling pages works as a category switcher.
462
+ `horizontal` is a strip of items with hover dropdowns for nested items;
463
+ `vertical` is a stacked, indented list.
464
+ - The nearest file with that `id` wins, so a deeper folder can shadow a menu
465
+ defined above it. `auto-generate-menu` and `menu-depth` work as in `menu.md`.
466
+ - A menu anchored above the first heading stays above the page title.
467
+ - The anchor must be on its own line. It works in `.md`, `.txt` and `.mdx`.
468
+ An anchor inside a code span or code block is left as written.
469
+ - Menu files are navigation, not documents: they are not rendered to pages,
470
+ listed in menus or indices, or searched.
471
+
472
+ **Failure is quiet.** An anchor whose menu does not exist, or whose menu file
473
+ cannot be parsed, is replaced by `<!-- ursa: menu "id" not found -->` and
474
+ reported as a build warning naming the document. The page renders normally
475
+ with nothing where the menu would have been, and the surrounding Markdown is
476
+ untouched. Under `ursa serve`, creating the missing menu file fills the anchor
477
+ without editing the page.
478
+
363
479
  ## Auto-Index Generation
364
480
 
365
481
  Ursa automatically generates index pages for folders that don't have one. You can also explicitly control auto-index generation in your index documents using frontmatter:
@@ -439,11 +555,23 @@ SOURCE folder should have at least an index.md in it.
439
555
  ## Link logic
440
556
  Links are allowed to be extensionless. Link resolution works as follows:
441
557
  - If link has an extension, look for exact match, and 404 if not found
558
+ (`.md`/`.mdx` links are rewritten to `.html` optimistically)
442
559
  - If link has no extension:
443
- - Look for exact match with .md, .txt, .yml extensions (in that order)
444
- - If not found, assume the path is a folder, and look for:
445
- - index.md, index.txt, _index.md, _index.txt
446
- - home.md, home.txt, _home.md, _home.txt
447
- - (folder name).md, (folder name).txt
448
- - If any of these are found, link to that file's html version
449
- - If still not found, 404
560
+ - `/foo` names the document `foo.*` if there is one the file wins over a
561
+ folder of the same name and otherwise the folder's index, `foo/index.html`
562
+ - `/foo/` always names the folder's index
563
+ - Every folder that holds documents has an index page. Which source produces
564
+ it is decided by one precedence list, highest first:
565
+ 1. a hand-written `index.html`
566
+ 2. `index.mdx`, `index.md`, `index.txt`, `index.yml`
567
+ 3. `_index.*` in the same extension order
568
+ 4. `home.*`, then `_home.*`
569
+ 5. `<foldername>.*` (the file also renders to its own path)
570
+ 6. the generated auto-index listing
571
+ - The same list decides which of several sources for one output path is
572
+ rendered (`index.mdx` beside `index.md`: the `.mdx`; `foo.md` beside a
573
+ hand-written `foo.html`: the `.html`). The shadowed source is not rendered,
574
+ indexed or listed, and a warning names both files. Removing the winner
575
+ promotes the next candidate on the next build.
576
+ - A frontmatter-only `index.md` supplies the folder's label; the auto-index is
577
+ still the page.
package/bin/ursa.js CHANGED
@@ -60,6 +60,11 @@ yargs(hideBin(process.argv))
60
60
  describe: 'Emit only the .json data files — no HTML, XML, images, static assets, search indices or menu data',
61
61
  type: 'boolean',
62
62
  default: false
63
+ })
64
+ .option('explain', {
65
+ describe: 'Log, for every output that was rebuilt, the input that changed',
66
+ type: 'boolean',
67
+ default: false
63
68
  });
64
69
  },
65
70
  async (argv) => {
@@ -71,6 +76,7 @@ yargs(hideBin(process.argv))
71
76
  const clean = argv.clean;
72
77
  const promoteChangelog = argv['promote-changelog'] || null;
73
78
  const jsonOnly = argv['json-only'];
79
+ const explain = argv.explain;
74
80
 
75
81
  console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
76
82
  if (whitelist) {
@@ -96,7 +102,8 @@ yargs(hideBin(process.argv))
96
102
  _whitelist: whitelist,
97
103
  _exclude: exclude,
98
104
  _clean: clean,
99
- _jsonOnly: jsonOnly
105
+ _jsonOnly: jsonOnly,
106
+ _explain: explain
100
107
  });
101
108
  console.log('Site generation completed successfully!');
102
109
  } catch (error) {
@@ -158,6 +165,11 @@ yargs(hideBin(process.argv))
158
165
  .option('promote-changelog', {
159
166
  describe: 'Path to a markdown file to render at the output root (sibling of index.html)',
160
167
  type: 'string'
168
+ })
169
+ .option('explain', {
170
+ describe: 'Log, for every output that was rebuilt, the input that changed',
171
+ type: 'boolean',
172
+ default: false
161
173
  });
162
174
  },
163
175
  async (argv) => {
@@ -194,6 +206,7 @@ yargs(hideBin(process.argv))
194
206
  _whitelist: whitelist,
195
207
  _exclude: exclude,
196
208
  _clean: clean,
209
+ _explain: argv.explain,
197
210
  strictPort: argv['strict-port']
198
211
  });
199
212
  } catch (error) {
@@ -1465,6 +1465,150 @@
1465
1465
  }
1466
1466
  }
1467
1467
 
1468
+ /* Named menus (`{menu:<id>}` anchors) render inside the article too, as a
1469
+ static block where the author put them. Same treatment as the breadcrumbs:
1470
+ Ursa's chrome, scoped, unlayered. Horizontal is a strip of items with
1471
+ hover dropdowns for children; vertical is a stacked list, TOC-like. */
1472
+ @scope (nav.ursa-menu) {
1473
+ :scope {
1474
+ font-size: 0.9rem;
1475
+ margin: 0.5rem 0 1rem;
1476
+ color: var(--text-color);
1477
+ }
1478
+
1479
+ ul {
1480
+ list-style: none;
1481
+ margin: 0;
1482
+ padding: 0;
1483
+ }
1484
+
1485
+ li {
1486
+ margin: 0;
1487
+ padding: 0;
1488
+ }
1489
+
1490
+ a, span {
1491
+ display: block;
1492
+ text-decoration: none;
1493
+ white-space: nowrap;
1494
+ }
1495
+
1496
+ /* Same colour as the top menu, visited or not: this is navigation, not
1497
+ prose, and it may sit outside any section the site colours. */
1498
+ a, a:visited {
1499
+ color: var(--text-color);
1500
+ }
1501
+
1502
+ a:hover {
1503
+ text-decoration: none;
1504
+ }
1505
+
1506
+ /* --- horizontal: a strip, children in hover dropdowns --- */
1507
+ :scope.ursa-menu-horizontal > ul {
1508
+ display: flex;
1509
+ flex-wrap: wrap;
1510
+ align-items: center;
1511
+ gap: 0.25rem;
1512
+ }
1513
+
1514
+ :scope.ursa-menu-horizontal > ul > li {
1515
+ position: relative;
1516
+ }
1517
+
1518
+ :scope.ursa-menu-horizontal > ul > li > a,
1519
+ :scope.ursa-menu-horizontal > ul > li > span {
1520
+ padding: 0.3rem 0.75rem;
1521
+ border-radius: 4px;
1522
+ transition: background-color 0.15s ease;
1523
+ }
1524
+
1525
+ :scope.ursa-menu-horizontal > ul > li > a:hover {
1526
+ background-color: color-mix(in srgb, var(--text-color) 10%, transparent);
1527
+ }
1528
+
1529
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-current > a,
1530
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-active > a {
1531
+ font-weight: 600;
1532
+ background-color: var(--widget-bg);
1533
+ box-shadow: inset 0 0 0 1px var(--widget-border);
1534
+ }
1535
+
1536
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-has-children > a::after,
1537
+ :scope.ursa-menu-horizontal > ul > li.ursa-menu-has-children > span::after {
1538
+ content: '▼';
1539
+ font-size: 0.4em;
1540
+ margin-left: 6px;
1541
+ opacity: 0.6;
1542
+ vertical-align: middle;
1543
+ }
1544
+
1545
+ :scope.ursa-menu-horizontal li > ul {
1546
+ display: none;
1547
+ position: absolute;
1548
+ top: 100%;
1549
+ left: 0;
1550
+ min-width: 12rem;
1551
+ background-color: var(--widget-bg);
1552
+ border: 1px solid var(--widget-border);
1553
+ border-radius: 4px;
1554
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
1555
+ z-index: 1005;
1556
+ }
1557
+
1558
+ :scope.ursa-menu-horizontal li:hover > ul {
1559
+ display: block;
1560
+ }
1561
+
1562
+ :scope.ursa-menu-horizontal li li {
1563
+ position: relative;
1564
+ }
1565
+
1566
+ :scope.ursa-menu-horizontal li li > a,
1567
+ :scope.ursa-menu-horizontal li li > span {
1568
+ padding: 0.5rem 1rem;
1569
+ }
1570
+
1571
+ :scope.ursa-menu-horizontal li li > a:hover {
1572
+ background-color: color-mix(in srgb, var(--text-color) 10%, transparent);
1573
+ }
1574
+
1575
+ :scope.ursa-menu-horizontal li li.ursa-menu-current > a {
1576
+ font-weight: 600;
1577
+ }
1578
+
1579
+ /* third level and deeper fly out to the side */
1580
+ :scope.ursa-menu-horizontal li li > ul {
1581
+ top: 0;
1582
+ left: 100%;
1583
+ }
1584
+
1585
+ /* --- vertical: stacked, nested levels indented --- */
1586
+ :scope.ursa-menu-vertical a,
1587
+ :scope.ursa-menu-vertical span {
1588
+ padding: 0.25rem 0.7rem;
1589
+ border-left: 2px solid transparent;
1590
+ white-space: normal;
1591
+ }
1592
+
1593
+ :scope.ursa-menu-vertical a:hover {
1594
+ background-color: color-mix(in srgb, var(--text-color) 8%, transparent);
1595
+ }
1596
+
1597
+ :scope.ursa-menu-vertical li.ursa-menu-current > a {
1598
+ font-weight: 600;
1599
+ border-left-color: var(--text-color);
1600
+ }
1601
+
1602
+ :scope.ursa-menu-vertical li.ursa-menu-active > a {
1603
+ font-weight: 600;
1604
+ }
1605
+
1606
+ :scope.ursa-menu-vertical li > ul {
1607
+ margin-left: 0.7rem;
1608
+ border-left: 1px solid var(--widget-border);
1609
+ }
1610
+ }
1611
+
1468
1612
 
1469
1613
  /* --------------------------------------------------------------------------
1470
1614
  Document content. Layered *and* scoped: layered so site CSS always wins,
@@ -84,6 +84,15 @@ document.addEventListener('DOMContentLoaded', () => {
84
84
  // Start loading menu data immediately
85
85
  loadMenuData();
86
86
 
87
+ // `ursa serve` says the menu data changed (a document was added or
88
+ // renamed somewhere): refetch and re-render in place rather than reload.
89
+ document.addEventListener('ursa:data-updated', (e) => {
90
+ if (!(e.detail?.what || []).includes('menu')) return;
91
+ menuDataLoaded = false;
92
+ menuDataLoading = false;
93
+ loadMenuData();
94
+ });
95
+
87
96
  /**
88
97
  * Find item by path string
89
98
  */
@@ -557,7 +566,7 @@ function initTopMenu() {
557
566
  const menuUrl = customMenuPath || '/public/menu-data.json';
558
567
 
559
568
  // Load menu data and render both top menu and mobile menu
560
- fetch(menuUrl)
569
+ const loadTopMenu = () => fetch(menuUrl)
561
570
  .then(response => response.json())
562
571
  .then(data => {
563
572
  const menuData = data.menuData || data;
@@ -567,6 +576,12 @@ function initTopMenu() {
567
576
  .catch(error => {
568
577
  console.error('Failed to load top menu data:', error);
569
578
  });
579
+ loadTopMenu();
580
+
581
+ // `ursa serve` says the menu data changed: refetch and re-render in place
582
+ document.addEventListener('ursa:data-updated', (e) => {
583
+ if ((e.detail?.what || []).includes('menu')) loadTopMenu();
584
+ });
570
585
 
571
586
  // Set up home button (desktop) / hamburger (mobile)
572
587
  setupMenuButton(menuButton, navMain);
@@ -715,6 +730,8 @@ function isCurrentTopMenuPage(href) {
715
730
  * Render the top navigation menu
716
731
  */
717
732
  function renderTopMenu(container, menuData) {
733
+ // Re-rendered in place when the menu data changes under `ursa serve`
734
+ container.innerHTML = '';
718
735
  const ul = document.createElement('ul');
719
736
  ul.className = 'top-menu-level';
720
737
 
@@ -33,6 +33,17 @@ class GlobalSearch {
33
33
  // Start loading the indices immediately (but don't block)
34
34
  this.loadSearchIndex();
35
35
  this.loadFullTextIndex();
36
+
37
+ // `ursa serve` says the indices changed: refetch them in place
38
+ document.addEventListener('ursa:data-updated', (e) => {
39
+ if (!(e.detail?.what || []).includes('search')) return;
40
+ window.SEARCH_INDEX = null;
41
+ window.FULLTEXT_INDEX = null;
42
+ this.indexLoaded = false;
43
+ this.fullTextLoaded = false;
44
+ this.loadSearchIndex();
45
+ this.loadFullTextIndex();
46
+ });
36
47
  }
37
48
 
38
49
  wrapSearchInput() {
@@ -7,10 +7,23 @@ document.addEventListener('DOMContentLoaded', () => {
7
7
  let currentSection = document.createElement('section');
8
8
  currentSection.classList.add('sectionOuter');
9
9
 
10
+ // The page header stays outside any section: the breadcrumbs, and any
11
+ // named menu (`{menu:…}`) anchored above the first heading. A menu placed
12
+ // further down is part of its section like any other content.
13
+ const preamble = [];
14
+ let inPreamble = true;
15
+
10
16
  for (let i = 0; i < children.length; i++) {
11
17
  const el = children[i];
12
- // Skip breadcrumb nav — it stays outside sections
13
- if (el.classList && el.classList.contains('breadcrumbs')) continue;
18
+ if (el.classList && el.classList.contains('breadcrumbs')) {
19
+ preamble.push(el);
20
+ continue;
21
+ }
22
+ if (inPreamble && el.classList && el.classList.contains('ursa-menu')) {
23
+ preamble.push(el);
24
+ continue;
25
+ }
26
+ inPreamble = false;
14
27
  if (el.tagName === 'H1' && currentSection.childNodes.length > 0) {
15
28
  sections.push(currentSection);
16
29
  currentSection = document.createElement('section');
@@ -22,18 +35,13 @@ document.addEventListener('DOMContentLoaded', () => {
22
35
  sections.push(currentSection);
23
36
  }
24
37
 
25
- // Preserve breadcrumb nav before clearing
26
- const breadcrumbs = article.querySelector('.breadcrumbs');
27
-
28
38
  // Remove all existing children
29
39
  while (article.firstChild) {
30
40
  article.removeChild(article.firstChild);
31
41
  }
32
42
 
33
- // Re-insert breadcrumbs at the top, outside any section
34
- if (breadcrumbs) {
35
- article.appendChild(breadcrumbs);
36
- }
43
+ // Re-insert the header at the top, outside any section
44
+ preamble.forEach(el => article.appendChild(el));
37
45
 
38
46
  // Append new sections
39
47
  sections.forEach(section => article.appendChild(section));
@@ -141,6 +141,10 @@ class WidgetManager {
141
141
 
142
142
  // Initialize recent activity widget
143
143
  this.initRecentActivityWidget();
144
+ // `ursa serve` says recent activity changed: refetch in place
145
+ document.addEventListener('ursa:data-updated', (e) => {
146
+ if ((e.detail?.what || []).includes('recent-activity')) this.initRecentActivityWidget();
147
+ });
144
148
 
145
149
  // Track current page view and initialize suggested content widget
146
150
  this.trackPageView();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.96.0",
5
+ "version": "0.98.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -25,7 +25,6 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "compression": "^1.7.4",
28
- "directory-tree": "^3.3.2",
29
28
  "esbuild": "^0.27.3",
30
29
  "express": "^4.18.2",
31
30
  "fs-extra": "^10.1.0",
package/src/dev.js CHANGED
@@ -39,6 +39,7 @@ import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTra
39
39
  import { findCustomMenu, extractMenuFrontmatter, parseCustomMenu, combineAutoAndManualMenu } from "./helper/customMenu.js";
40
40
  import { getAndIncrementBuildId } from "./helper/ursaConfig.js";
41
41
  import { resolvePort } from "./helper/portUtils.js";
42
+ import { candidatesForOutput } from "./helper/build/precedence.js";
42
43
  import { findAllStyleCss } from "./helper/findStyleCss.js";
43
44
  import { bundleMetaTemplateAssets, generateSeparateCssTags, generateSeparateJsTags, clearMetaBundleCache } from "./helper/assetBundler.js";
44
45
 
@@ -213,30 +214,19 @@ async function resolveSourceFile(urlPath) {
213
214
 
214
215
  // Remove leading slash for joining
215
216
  const relativePath = cleanPath.startsWith('/') ? cleanPath.slice(1) : cleanPath;
216
-
217
- // Try different file extensions and patterns
217
+
218
+ // The one precedence list (helper/build/precedence.js): a document named
219
+ // like the URL, then the folder's index candidates — index, _index, home,
220
+ // _home, the folder's own name — .mdx before .md before .txt before .yml.
218
221
  const candidates = [
219
- { path: join(source, relativePath + '.md'), type: '.md' },
220
- { path: join(source, relativePath + '.mdx'), type: '.mdx' },
221
- { path: join(source, relativePath + '.txt'), type: '.txt' },
222
- { path: join(source, relativePath, 'index.md'), type: '.md' },
223
- { path: join(source, relativePath, 'index.mdx'), type: '.mdx' },
224
- { path: join(source, relativePath, 'index.txt'), type: '.txt' },
225
- ];
226
-
227
- // Also try folder-named file (e.g., /foo/bar -> /foo/bar/bar.md)
228
- const folderName = basename(relativePath);
229
- if (folderName) {
230
- candidates.push(
231
- { path: join(source, relativePath, folderName + '.md'), type: '.md' },
232
- { path: join(source, relativePath, folderName + '.mdx'), type: '.mdx' },
233
- { path: join(source, relativePath, folderName + '.txt'), type: '.txt' }
234
- );
235
- }
236
-
237
- for (const candidate of candidates) {
238
- if (existsSync(candidate.path)) {
239
- return { sourcePath: candidate.path, type: candidate.type };
222
+ ...candidatesForOutput(relativePath + '.html'),
223
+ ...candidatesForOutput(join(relativePath, 'index.html')),
224
+ ].filter((c) => typeof c === 'string' && !c.endsWith('.html'));
225
+
226
+ for (const rel of candidates) {
227
+ const path = join(source, rel);
228
+ if (existsSync(path)) {
229
+ return { sourcePath: path, type: extname(rel) };
240
230
  }
241
231
  }
242
232