@kenjura/ursa 0.95.0 → 0.97.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 (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +114 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/content-hooks.js +45 -0
  5. package/meta/templates/default-template/index.html +1 -0
  6. package/meta/templates/default-template/menu.js +18 -1
  7. package/meta/templates/default-template/search.js +11 -0
  8. package/meta/templates/default-template/sticky.js +7 -1
  9. package/meta/templates/default-template/toc-generator.js +58 -38
  10. package/meta/templates/default-template/widgets.js +4 -0
  11. package/package.json +1 -2
  12. package/src/dev.js +13 -23
  13. package/src/helper/__test__/contentHash.test.js +16 -6
  14. package/src/helper/__test__/mdxRenderer.test.js +159 -0
  15. package/src/helper/__test__/sourceTimestamps.test.js +0 -0
  16. package/src/helper/assetBundler.js +93 -19
  17. package/src/helper/automenu.js +36 -11
  18. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  19. package/src/helper/build/__test__/graph.test.js +259 -3
  20. package/src/helper/build/__test__/pass.test.js +553 -0
  21. package/src/helper/build/autoIndex.js +2 -371
  22. package/src/helper/build/excludeFilter.js +1 -2
  23. package/src/helper/build/footer.js +27 -14
  24. package/src/helper/build/graph.js +575 -152
  25. package/src/helper/build/index.js +0 -2
  26. package/src/helper/build/metadata.js +19 -5
  27. package/src/helper/build/pass.js +497 -0
  28. package/src/helper/build/precedence.js +174 -0
  29. package/src/helper/build/site.js +1270 -0
  30. package/src/helper/build/templates.js +1 -2
  31. package/src/helper/build/tracedFs.js +247 -0
  32. package/src/helper/contentHash.js +0 -78
  33. package/src/helper/customMenu.js +1 -1
  34. package/src/helper/fileRenderer.js +119 -111
  35. package/src/helper/findScriptJs.js +1 -1
  36. package/src/helper/findStyleCss.js +1 -1
  37. package/src/helper/folderConfig.js +7 -18
  38. package/src/helper/fullTextIndex.js +41 -29
  39. package/src/helper/imageProcessor.js +45 -0
  40. package/src/helper/linkValidator.js +118 -127
  41. package/src/helper/mdxRenderer.js +225 -26
  42. package/src/helper/menuLabels.js +30 -5
  43. package/src/helper/sourceTimestamps.js +139 -0
  44. package/src/helper/ursaConfig.js +3 -49
  45. package/src/helper/whitelistFilter.js +1 -2
  46. package/src/jobs/generate.js +67 -1859
  47. package/src/serve.js +317 -697
  48. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  49. package/src/helper/build/cacheBust.js +0 -141
  50. package/src/helper/build/navCache.js +0 -145
  51. package/src/helper/build/watchCache.js +0 -33
  52. package/src/helper/dependencyTracker.js +0 -384
package/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ # 0.97.0
2
+ 2026-09-20
3
+
4
+ `ursa serve` catches every change; `generate` and `serve` are one build.
5
+
6
+ `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.
7
+
8
+ **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.
9
+
10
+ - **`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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+ - **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.
20
+ - **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.
21
+ - **`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.
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ # 0.96.0
28
+ 2026-09-16
29
+
30
+ MDX pages hydrate their components, not the page; Recent Activity is dated from git.
31
+
32
+ `hydrate: true` on an `.mdx` page broke the page's layout, and the break got worse the more headings the page had: sticky H1s piled on top of one another, breadcrumbs vanished, and the table of contents listed the title twice. The hydration script handed React the whole of `#main-content` and the whole MDX component and expected them to match. They never did. The template puts breadcrumbs and (sometimes) a title heading inside that container, and the default template's `sectionify.js` rewrites it on `DOMContentLoaded`, wrapping each H1 section in `<section class="sectionOuter">` — and the hydration script, being the last script in the body, always ran after it. React reported the mismatch (error #418), discarded the server-rendered DOM and rendered the component from scratch, with none of the template's structure.
33
+
34
+ - **Each component imported into the `.mdx` is now an island.** An esbuild plugin wraps the default export of every `.jsx`/`.tsx` the entry imports (and `.js`/`.ts` under `_components/`), so the build renders it inside `<ursa-island data-island="N">` and the browser hydrates that element as its own React root, against exactly the markup the build produced for it. The Markdown around the islands is never handed to React; `sectionify`, breadcrumbs and the TOC can do what they like to it.
35
+ - **Function props keep working.** The client still runs the whole bundled MDX module — into a detached root, purely to execute the tree — so each island receives its real props, `filter={fn}` included, rather than a serialized replay. Island numbering is a per-render counter taken in `useState`'s lazy initializer, so it increments once per mount in tree order on both sides.
36
+ - **Components imported by components are not islands.** They render inside their parent's root, as before; nesting would put one root inside another. Named exports and non-function imports pass through untouched.
37
+ - **React 19's hoisted `<link rel="preload">` is stripped** from the MDX render. It carried the un-rewritten relative image path, so it fetched nothing useful, and its position ahead of the first `<h1>` is what defeated the "body starts with a heading" check and produced the duplicate title.
38
+ - **`react-runtime.js` now exposes `createRoot`** and carries a version marker; `buildReactRuntime` rebuilds an older runtime found in `output/public/` instead of reusing it.
39
+ - **`window.ursa.contentChanged(root)` tells the template the article changed.** The template's scripts read the article once, on `DOMContentLoaded`: `sticky.js` collects the headings it marks `.stuck`, `toc-generator.js` builds the table of contents from them. A component that fetches data and renders a list with its own headings after that point was invisible to both — its H2s never rolled up into the stuck H1 and piled on top of each other, and the TOC did not list them. The helper dispatches `ursa:content-changed` on `document` (coalesced per task, so a multi-step render can call it freely) and both scripts re-read the headings on it: sticky state is recomputed, and the TOC is rebuilt in place with existing heading ids preserved. `content-hooks.js` is a new template script, loaded first.
40
+
41
+ **Recent Activity is dated from git, not from the build.** The feed took each document's time from a `contentTimestamps` map in `.ursa.json` that was set to the build time whenever the document was regenerated. Under `--clean` every document regenerates, so every entry got the same time and the feed showed ten arbitrary pages. The map also grew to one line per document and changed on every build, which in a repo that commits `.ursa.json` meant a diff on every commit.
42
+
43
+ - **One `git log --name-only` pass over the source directory** at the start of a build gives every document's last-commit time in a single process (0.4s on 1,400 documents). A document with uncommitted changes, an untracked one, or any document when the source is not a git work tree, is dated by file mtime instead.
44
+ - **A shallow clone is detected and warned about**, since with one fetched commit every document looks edited in it. The fix is `fetch-depth: 0` on the checkout.
45
+ - **`contentTimestamps` is gone from `.ursa.json`**; the next build removes the stale key. `serve`'s single-file regeneration dates the changed document the same way instead of stamping "now".
46
+
47
+ `hydrate: true` means what it did: emit the client bundle. Without it, islands are rendered at build time and inert. React context does not cross island boundaries, which no MDX page relied on — there is no provider above the components to begin with. Design notes are in `docs/changes/island-hydration.md`, and the README gains an "MDX and Interactive Components" section.
48
+
1
49
  # 0.95.0
2
50
  2026-09-06
3
51
 
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
 
@@ -318,6 +362,48 @@ Both panels carry it: `#widget-dropdown` for the right-hand widgets (`toc`,
318
362
  Ursa uses this hook itself, to lay the TOC out along the bottom of the viewport
319
363
  on a narrow screen.
320
364
 
365
+ ## Recent Activity
366
+
367
+ The site's Recent Activity widget lists the ten most recently edited documents. A document is dated by the last git commit that touched it — one `git log` pass over the source directory at the start of a build — or by its file mtime if it has uncommitted changes, is untracked, or the source is not in a git work tree. The build's own time never enters into it, so `--clean` does not reset the feed.
368
+
369
+ This needs git history to be present. A shallow checkout (GitHub Actions' `actions/checkout` defaults to depth 1) makes every document look edited in the one fetched commit; ursa warns when it sees one. Use `fetch-depth: 0`.
370
+
371
+ ## MDX and Interactive Components
372
+
373
+ A `.mdx` document is Markdown that can import and use React components. Put components in a `_components/` folder anywhere from the docroot down to the document's own folder, and import them without a relative prefix:
374
+
375
+ ```mdx
376
+ ---
377
+ hydrate: true
378
+ ---
379
+ import PowerList from '_components/PowerList.jsx';
380
+
381
+ # Spells
382
+
383
+ <PowerList class="Witch" groupBy="school" />
384
+ ```
385
+
386
+ Every document is rendered to HTML at build time, components included, so a page reads the same with JavaScript off. `hydrate: true` in the frontmatter additionally ships the page's components to the browser so they can run there.
387
+
388
+ Hydration works per component, not per page. Each component imported directly into the `.mdx` file becomes an **island**: the build wraps its output in `<ursa-island data-island="N">`, and in the browser each island is hydrated as its own React root against exactly the markup the build produced for it. The Markdown around the islands is never handed to React, so the template is free to rearrange it — section wrappers for sticky headings, breadcrumbs, the table of contents — without any hydration mismatch. Two things follow from this:
389
+
390
+ - A component's first render must produce the same markup in the browser as it did at build time (the usual hydration contract). Fetch data in an effect and render a placeholder first.
391
+ - React context does not cross from one island to another. Components that need to share state should be one island, with the shared state inside it.
392
+
393
+ `island` wrapping applies to the default export of any `.jsx`/`.tsx` file the `.mdx` imports, and of `.js`/`.ts` files under `_components/`. Components that a component imports are not islands themselves — they render inside their parent's root. Non-function imports (JSON, data) pass through untouched.
394
+
395
+ ### Telling the template the article changed
396
+
397
+ The template's scripts read the article once, when the page loads: the sticky headings and the table of contents are both built from the headings present at that moment. A component that renders content later — a list fetched from a JSON file, say, with headings of its own — should say so once it has:
398
+
399
+ ```jsx
400
+ useEffect(() => {
401
+ if (items.length) window.ursa?.contentChanged?.(rootRef.current);
402
+ }, [items]);
403
+ ```
404
+
405
+ `contentChanged(root)` dispatches `ursa:content-changed` on `document` with the changed element in `event.detail.root` (the article, if omitted). Sticky headings and the table of contents re-read the article on it; calls within the same task are coalesced into one event. A site's own scripts can listen for the same event.
406
+
321
407
  ## Auto-Index Generation
322
408
 
323
409
  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:
@@ -397,11 +483,23 @@ SOURCE folder should have at least an index.md in it.
397
483
  ## Link logic
398
484
  Links are allowed to be extensionless. Link resolution works as follows:
399
485
  - If link has an extension, look for exact match, and 404 if not found
486
+ (`.md`/`.mdx` links are rewritten to `.html` optimistically)
400
487
  - If link has no extension:
401
- - Look for exact match with .md, .txt, .yml extensions (in that order)
402
- - If not found, assume the path is a folder, and look for:
403
- - index.md, index.txt, _index.md, _index.txt
404
- - home.md, home.txt, _home.md, _home.txt
405
- - (folder name).md, (folder name).txt
406
- - If any of these are found, link to that file's html version
407
- - If still not found, 404
488
+ - `/foo` names the document `foo.*` if there is one the file wins over a
489
+ folder of the same name and otherwise the folder's index, `foo/index.html`
490
+ - `/foo/` always names the folder's index
491
+ - Every folder that holds documents has an index page. Which source produces
492
+ it is decided by one precedence list, highest first:
493
+ 1. a hand-written `index.html`
494
+ 2. `index.mdx`, `index.md`, `index.txt`, `index.yml`
495
+ 3. `_index.*` in the same extension order
496
+ 4. `home.*`, then `_home.*`
497
+ 5. `<foldername>.*` (the file also renders to its own path)
498
+ 6. the generated auto-index listing
499
+ - The same list decides which of several sources for one output path is
500
+ rendered (`index.mdx` beside `index.md`: the `.mdx`; `foo.md` beside a
501
+ hand-written `foo.html`: the `.html`). The shadowed source is not rendered,
502
+ indexed or listed, and a warning names both files. Removing the winner
503
+ promotes the next candidate on the next build.
504
+ - A frontmatter-only `index.md` supplies the folder's label; the auto-index is
505
+ 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) {
@@ -0,0 +1,45 @@
1
+ // Content-change hook
2
+ //
3
+ // The template's scripts read the article once, on DOMContentLoaded: sticky.js
4
+ // collects the headings it will mark .stuck, toc-generator.js builds the table
5
+ // of contents from them. Anything that adds content to the article after that
6
+ // point — an island that fetches data and renders a list with its own headings —
7
+ // is invisible to them unless it says so.
8
+ //
9
+ // This is how it says so. A script that has changed the article calls
10
+ //
11
+ // window.ursa.contentChanged(root)
12
+ //
13
+ // where `root` is the element whose contents changed (optional; defaults to the
14
+ // article). That dispatches `ursa:content-changed` on `document`, with the root
15
+ // in `event.detail.root`, and each template script that keeps a view of the
16
+ // article listens for it and re-reads what it needs. Calls made in the same
17
+ // task are coalesced into one event (a macrotask, not an animation frame, so it
18
+ // also fires in a background tab), so a component that renders in several
19
+ // steps can call it freely.
20
+ //
21
+ // Dispatching the event directly works just as well; the helper exists so a
22
+ // caller does not have to know the event's name.
23
+ (() => {
24
+ const EVENT = 'ursa:content-changed';
25
+ let queued = null;
26
+
27
+ function contentChanged(root) {
28
+ if (root && !(root instanceof Element)) root = null;
29
+ // Coalesce: keep the broadest root seen so far
30
+ if (queued) {
31
+ if (queued.root && root && queued.root !== root && !queued.root.contains(root)) {
32
+ queued.root = null;
33
+ }
34
+ return;
35
+ }
36
+ queued = { root: root || null };
37
+ setTimeout(() => {
38
+ const detail = { root: queued.root || document.querySelector('article#main-content') };
39
+ queued = null;
40
+ document.dispatchEvent(new CustomEvent(EVENT, { detail }));
41
+ });
42
+ }
43
+
44
+ window.ursa = Object.assign(window.ursa || {}, { contentChanged, CONTENT_CHANGED_EVENT: EVENT });
45
+ })();
@@ -162,6 +162,7 @@
162
162
  <div id="global-nav">
163
163
  </div>
164
164
 
165
+ <script src="/public/content-hooks.js"></script>
165
166
  <script src="/public/toc.js"></script>
166
167
  <script src="/public/toc-generator.js"></script>
167
168
  <script src="/public/menu.js"></script>
@@ -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() {
@@ -2,7 +2,9 @@ document.addEventListener('DOMContentLoaded', () => {
2
2
  const article = document.querySelector('article#main-content');
3
3
  if (!article) return;
4
4
 
5
- const headings = article.querySelectorAll('h1, h2, h3');
5
+ // Re-collected on ursa:content-changed, since an island may add headings
6
+ // after load (see content-hooks.js).
7
+ let headings = article.querySelectorAll('h1, h2, h3');
6
8
 
7
9
  function updateStuckState() {
8
10
  let currentStuckHeading = null;
@@ -70,4 +72,8 @@ document.addEventListener('DOMContentLoaded', () => {
70
72
  updateStuckState();
71
73
  window.addEventListener('scroll', updateStuckState, { passive: true });
72
74
  window.addEventListener('resize', updateStuckState);
75
+ document.addEventListener('ursa:content-changed', () => {
76
+ headings = article.querySelectorAll('h1, h2, h3');
77
+ updateStuckState();
78
+ });
73
79
  });
@@ -6,47 +6,67 @@ document.addEventListener('DOMContentLoaded', () => {
6
6
 
7
7
  if (!tocTarget || !article) return;
8
8
 
9
- // Find all headings in the article
10
- const headings = article.querySelectorAll('h1, h2, h3');
11
-
12
- if (headings.length === 0) {
13
- // Hide the TOC widget button if no headings
14
- const tocButton = document.querySelector('.widget-button[data-widget="toc"]');
15
- if (tocButton) tocButton.style.display = 'none';
16
- tocTarget.style.display = 'none';
17
- return;
18
- }
19
-
20
- // Generate TOC HTML
21
- const tocList = document.createElement('ul');
22
-
23
- headings.forEach((heading, index) => {
24
- // Create unique ID for the heading if it doesn't have one
25
- if (!heading.id) {
26
- const text = heading.textContent.trim()
27
- .toLowerCase()
28
- .replace(/[^\w\s-]/g, '') // Remove special characters
29
- .replace(/\s+/g, '-'); // Replace spaces with hyphens
30
- heading.id = `heading-${index}-${text}`;
31
- }
32
-
33
- // Create TOC item
34
- const listItem = document.createElement('li');
35
- listItem.className = `toc-${heading.tagName.toLowerCase()}`;
36
-
37
- const link = document.createElement('a');
38
- link.href = `#${heading.id}`;
39
- link.textContent = heading.textContent;
40
- link.addEventListener('click', handleTocClick);
41
-
42
- listItem.appendChild(link);
43
- tocList.appendChild(listItem);
44
- });
45
-
9
+ // The headings the TOC currently reflects. Rebuilt on ursa:content-changed,
10
+ // since an island may add headings after load (see content-hooks.js).
11
+ let headings = [];
12
+ const tocButton = document.querySelector('.widget-button[data-widget="toc"]');
13
+
46
14
  // Add an id=toc wrapper for the toc.js sentinel-based highlighter
15
+ const tocList = document.createElement('ul');
47
16
  tocList.id = 'toc';
48
17
  tocTarget.appendChild(tocList);
49
-
18
+
19
+ function headingId(heading, index) {
20
+ const text = heading.textContent.trim()
21
+ .toLowerCase()
22
+ .replace(/[^\w\s-]/g, '') // Remove special characters
23
+ .replace(/\s+/g, '-'); // Replace spaces with hyphens
24
+ let id = `heading-${index}-${text}`;
25
+ // A heading added later can land on an index an earlier one already used
26
+ let n = 2;
27
+ while (document.getElementById(id)) id = `heading-${index}-${text}-${n++}`;
28
+ return id;
29
+ }
30
+
31
+ // (Re)build the list from the article's current headings. Headings keep the
32
+ // ids they already have, so existing anchors and links stay valid.
33
+ function buildToc() {
34
+ headings = article.querySelectorAll('h1, h2, h3');
35
+
36
+ if (headings.length === 0) {
37
+ // Hide the TOC widget button if no headings
38
+ if (tocButton) tocButton.style.display = 'none';
39
+ tocTarget.style.display = 'none';
40
+ return;
41
+ }
42
+ if (tocButton) tocButton.style.display = '';
43
+ tocTarget.style.display = '';
44
+
45
+ tocList.replaceChildren();
46
+ headings.forEach((heading, index) => {
47
+ // Create unique ID for the heading if it doesn't have one
48
+ if (!heading.id) heading.id = headingId(heading, index);
49
+
50
+ // Create TOC item
51
+ const listItem = document.createElement('li');
52
+ listItem.className = `toc-${heading.tagName.toLowerCase()}`;
53
+
54
+ const link = document.createElement('a');
55
+ link.href = `#${heading.id}`;
56
+ link.textContent = heading.textContent;
57
+ link.addEventListener('click', handleTocClick);
58
+
59
+ listItem.appendChild(link);
60
+ tocList.appendChild(listItem);
61
+ });
62
+ }
63
+
64
+ buildToc();
65
+ document.addEventListener('ursa:content-changed', () => {
66
+ buildToc();
67
+ updateActiveTocItem();
68
+ });
69
+
50
70
  // Handle TOC link clicks for smooth scrolling
51
71
  function handleTocClick(e) {
52
72
  e.preventDefault();
@@ -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.95.0",
5
+ "version": "0.97.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
 
@@ -2,12 +2,22 @@ import { join } from "path";
2
2
  import { mkdtemp, rm, mkdir, writeFile, readFile } from "fs/promises";
3
3
  import { existsSync } from "fs";
4
4
  import { tmpdir } from "os";
5
- import {
6
- enforceCacheVersion,
7
- getUrsaDir,
8
- loadHashCache,
9
- saveHashCache,
10
- } from "../contentHash.js";
5
+ import { enforceCacheVersion, getUrsaDir } from "../contentHash.js";
6
+
7
+ // The cache `.ursa/` holds is the build graph. These tests only need a file
8
+ // that must survive a matching stamp and vanish on a mismatch.
9
+ const CACHE_FILE = "graph.json";
10
+ async function saveHashCache(dir, map) {
11
+ await mkdir(getUrsaDir(dir), { recursive: true });
12
+ await writeFile(join(getUrsaDir(dir), CACHE_FILE), JSON.stringify([...map]));
13
+ }
14
+ async function loadHashCache(dir) {
15
+ try {
16
+ return new Map(JSON.parse(await readFile(join(getUrsaDir(dir), CACHE_FILE), "utf8")));
17
+ } catch {
18
+ return new Map();
19
+ }
20
+ }
11
21
 
12
22
  let sourceDir;
13
23
  beforeEach(async () => {