@kenjura/ursa 0.96.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 (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/menu.js +18 -1
  5. package/meta/templates/default-template/search.js +11 -0
  6. package/meta/templates/default-template/widgets.js +4 -0
  7. package/package.json +1 -2
  8. package/src/dev.js +13 -23
  9. package/src/helper/__test__/contentHash.test.js +16 -6
  10. package/src/helper/assetBundler.js +93 -19
  11. package/src/helper/automenu.js +36 -11
  12. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  13. package/src/helper/build/__test__/graph.test.js +259 -3
  14. package/src/helper/build/__test__/pass.test.js +553 -0
  15. package/src/helper/build/autoIndex.js +2 -371
  16. package/src/helper/build/excludeFilter.js +1 -2
  17. package/src/helper/build/footer.js +27 -14
  18. package/src/helper/build/graph.js +575 -152
  19. package/src/helper/build/index.js +0 -2
  20. package/src/helper/build/metadata.js +19 -5
  21. package/src/helper/build/pass.js +497 -0
  22. package/src/helper/build/precedence.js +174 -0
  23. package/src/helper/build/site.js +1270 -0
  24. package/src/helper/build/templates.js +1 -2
  25. package/src/helper/build/tracedFs.js +247 -0
  26. package/src/helper/contentHash.js +0 -78
  27. package/src/helper/customMenu.js +1 -1
  28. package/src/helper/fileRenderer.js +119 -111
  29. package/src/helper/findScriptJs.js +1 -1
  30. package/src/helper/findStyleCss.js +1 -1
  31. package/src/helper/folderConfig.js +7 -18
  32. package/src/helper/fullTextIndex.js +41 -29
  33. package/src/helper/imageProcessor.js +45 -0
  34. package/src/helper/linkValidator.js +118 -127
  35. package/src/helper/mdxRenderer.js +27 -5
  36. package/src/helper/menuLabels.js +30 -5
  37. package/src/helper/whitelistFilter.js +1 -2
  38. package/src/jobs/generate.js +67 -1829
  39. package/src/serve.js +317 -697
  40. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  41. package/src/helper/build/cacheBust.js +0 -141
  42. package/src/helper/build/navCache.js +0 -145
  43. package/src/helper/build/watchCache.js +0 -33
  44. package/src/helper/dependencyTracker.js +0 -384
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
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
+
1
27
  # 0.96.0
2
28
  2026-09-16
3
29
 
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
 
@@ -439,11 +483,23 @@ SOURCE folder should have at least an index.md in it.
439
483
  ## Link logic
440
484
  Links are allowed to be extensionless. Link resolution works as follows:
441
485
  - If link has an extension, look for exact match, and 404 if not found
486
+ (`.md`/`.mdx` links are rewritten to `.html` optimistically)
442
487
  - 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
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) {
@@ -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() {
@@ -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.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 () => {
@@ -13,8 +13,8 @@
13
13
 
14
14
  import * as esbuild from "esbuild";
15
15
  import { join, dirname, basename, relative, resolve } from "path";
16
- import { readFile, writeFile, mkdir } from "fs/promises";
17
- import { existsSync } from "fs";
16
+ import { writeFile, mkdir } from "fs/promises";
17
+ import { readFile, existsSync } from "./build/tracedFs.js";
18
18
  import { outputFile } from "fs-extra";
19
19
 
20
20
  // Cache for meta bundles so we don't rebuild them per-document
@@ -68,10 +68,13 @@ export function parseTemplateAssets(templateHtml) {
68
68
  * @param {string} templateHtml - Original template HTML
69
69
  * @param {string} templateName - Template name (used for bundle filename)
70
70
  * @param {{ cssFiles: string[], jsFiles: string[], cdnCss: string[], cdnJs: string[] }} assets - Parsed assets
71
+ * @param {{cssUrl?: string, jsUrl?: string}} [urls] - Bundle URLs to emit (default: /public/<name>.bundle.*)
71
72
  * @returns {string} Rewritten template HTML
72
73
  */
73
- export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
74
+ export function rewriteTemplateWithBundles(templateHtml, templateName, assets, urls = {}) {
74
75
  let html = templateHtml;
76
+ const cssUrl = urls.cssUrl ?? `/public/${templateName}.bundle.css`;
77
+ const jsUrl = urls.jsUrl ?? `/public/${templateName}.bundle.js`;
75
78
 
76
79
  // Replace individual CSS <link> tags with a single bundle reference
77
80
  if (assets.cssFiles.length > 0) {
@@ -86,7 +89,7 @@ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
86
89
  html = html.replace(pattern, "\n");
87
90
  }
88
91
  // Insert single bundle link where the first CSS link was (in <head>)
89
- const bundleCssTag = ` <link rel="stylesheet" href="/public/${templateName}.bundle.css" />`;
92
+ const bundleCssTag = ` <link rel="stylesheet" href="${cssUrl}" />`;
90
93
  // Insert after the last CDN CSS or at the position of the first removed tag
91
94
  // Best heuristic: insert right before ${styleLink} or before </head>
92
95
  if (html.includes("${styleLink}")) {
@@ -107,7 +110,7 @@ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
107
110
  html = html.replace(pattern, "\n");
108
111
  }
109
112
  // Insert single bundle script before ${customScript} or before </body>
110
- const bundleJsTag = ` <script src="/public/${templateName}.bundle.js"></script>`;
113
+ const bundleJsTag = ` <script src="${jsUrl}"></script>`;
111
114
  if (html.includes("${customScript}")) {
112
115
  html = html.replace("${customScript}", bundleJsTag + "\n ${customScript}");
113
116
  } else {
@@ -192,6 +195,19 @@ function rebaseCssUrls(css, cssFileDir, sourceDir) {
192
195
  */
193
196
  export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
194
197
  if (filePaths.length === 0) return;
198
+ const code = await bundleCssContent(filePaths, { minify, rebaseUrls, sourceDir });
199
+ await mkdir(dirname(outputPath), { recursive: true });
200
+ await writeFile(outputPath, code);
201
+ }
202
+
203
+ /**
204
+ * Bundle CSS files and return the result instead of writing it.
205
+ * @param {string[]} filePaths - Absolute paths to CSS files to bundle
206
+ * @param {{ minify?: boolean, rebaseUrls?: boolean, sourceDir?: string }} options
207
+ * @returns {Promise<string>} Bundled (and, when possible, minified) CSS
208
+ */
209
+ export async function bundleCssContent(filePaths, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
210
+ if (filePaths.length === 0) return "";
195
211
 
196
212
  const allImports = [];
197
213
  const allRules = [];
@@ -224,17 +240,14 @@ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUr
224
240
  loader: "css",
225
241
  minify: true,
226
242
  });
227
- await mkdir(dirname(outputPath), { recursive: true });
228
- await writeFile(outputPath, result.code);
229
- return;
243
+ return result.code;
230
244
  } catch (e) {
231
245
  console.warn(`⚠️ CSS minification failed, using unminified bundle: ${e.message}`);
232
246
  }
233
247
  }
234
248
 
235
- // Fallback: write raw concatenated CSS
236
- await mkdir(dirname(outputPath), { recursive: true });
237
- await writeFile(outputPath, combined);
249
+ // Fallback: raw concatenated CSS
250
+ return combined;
238
251
  }
239
252
 
240
253
  /**
@@ -251,6 +264,22 @@ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUr
251
264
  */
252
265
  export async function bundleJs(filePaths, outputPath, { minify = true, minifySyntax = true } = {}) {
253
266
  if (filePaths.length === 0) return { success: false };
267
+ const { success, code } = await bundleJsContent(filePaths, { minify, minifySyntax });
268
+ if (!success) return { success: false };
269
+ await mkdir(dirname(outputPath), { recursive: true });
270
+ await writeFile(outputPath, code);
271
+ return { success: true };
272
+ }
273
+
274
+ /**
275
+ * Bundle JS files and return the result instead of writing it.
276
+ * @param {string[]} filePaths - Absolute paths to JS files to bundle
277
+ * @param {{ minify?: boolean, minifySyntax?: boolean }} options
278
+ * @returns {Promise<{ success: boolean, code: string }>} success is false when the
279
+ * concatenation has a syntax error (callers keep individual tags)
280
+ */
281
+ export async function bundleJsContent(filePaths, { minify = true, minifySyntax = true } = {}) {
282
+ if (filePaths.length === 0) return { success: false, code: "" };
254
283
 
255
284
  // Concatenate all JS files with separators
256
285
  const contents = [];
@@ -268,9 +297,7 @@ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyn
268
297
  minify: true,
269
298
  minifySyntax,
270
299
  });
271
- await mkdir(dirname(outputPath), { recursive: true });
272
- await writeFile(outputPath, result.code);
273
- return { success: true };
300
+ return { success: true, code: result.code };
274
301
  } catch (e) {
275
302
  // If minification fails (e.g., non-standard syntax), fall back to raw concatenation
276
303
  console.warn(`⚠️ JS minification failed, trying unminified bundle: ${e.message}`);
@@ -284,13 +311,60 @@ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyn
284
311
  await esbuild.transform(combined, { loader: "js" });
285
312
  } catch (e) {
286
313
  console.warn(`⚠️ JS bundle has syntax errors, keeping individual script tags: ${e.message}`);
287
- return { success: false };
314
+ return { success: false, code: "" };
288
315
  }
289
316
 
290
- // Write raw concatenated code (syntax-valid but unminified)
291
- await mkdir(dirname(outputPath), { recursive: true });
292
- await writeFile(outputPath, combined);
293
- return { success: true };
317
+ // Raw concatenated code (syntax-valid but unminified)
318
+ return { success: true, code: combined };
319
+ }
320
+
321
+ /**
322
+ * Where a template's `/public/<rel>` reference lives in the meta directory:
323
+ * the template's own folder first, then `meta/shared`, then (legacy) the meta
324
+ * root. Every probe is a recorded lookup, so moving an asset between the two
325
+ * folders is observed. Returns null when nothing exists.
326
+ */
327
+ export function resolveMetaAssetPath(publicPath, templateDir, metaDir) {
328
+ const relativePath = publicPath.replace(/^\/public\//, "");
329
+ for (const candidate of [
330
+ resolve(templateDir, relativePath),
331
+ resolve(metaDir, "shared", relativePath),
332
+ resolve(metaDir, relativePath),
333
+ ]) {
334
+ if (existsSync(candidate)) return candidate;
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Append `?v=<hash>` to every `url()` in a stylesheet whose target `lookup`
341
+ * knows. URLs with a query string, data: URIs, fragments and anything the
342
+ * lookup does not recognise are left alone. Cache-busting by content hash
343
+ * keeps an unchanged asset's URL stable, so pages are not rewritten for it.
344
+ * @param {string} css
345
+ * @param {(url: string) => string|null} lookup - site-absolute URL → content hash
346
+ */
347
+ export function versionCssUrls(css, lookup) {
348
+ return css.replace(/url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi, (match, quote, url) => {
349
+ if (url.includes("?") || url.startsWith("#")) return match;
350
+ const hash = lookup(url);
351
+ if (!hash) return match;
352
+ return `url(${quote}${url}?v=${hash}${quote})`;
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Rewrite literal `fetch('/public/x.json')` calls in template JavaScript so
358
+ * the request carries the page's build id: `data-build` on <body>, set once
359
+ * per serve session and once per generate run. The bundle itself stays a pure
360
+ * function of the meta files — baking a JSON file's content hash into it
361
+ * would rewrite the bundle, and so every page, whenever the menu changed.
362
+ */
363
+ export function rewriteJsonFetches(js) {
364
+ return js.replace(
365
+ /fetch\((['"])([^'"\)]+\.json)\1(?!\s*\+)/g,
366
+ (m, q, url) => `fetch(${q}${url}?v=${q}+(document.body&&document.body.dataset.build||'')`
367
+ );
294
368
  }
295
369
 
296
370
  /**
@@ -1,9 +1,7 @@
1
- import dirTree from "directory-tree";
2
1
  import { isHiddenOrSystemPath } from "./hiddenPaths.js";
3
2
  import { extname, basename, join, dirname } from "path";
4
- import { existsSync, readFileSync } from "fs";
3
+ import { existsSync, readFileSync, readdirSync, isIgnoredDirEntry } from "./build/tracedFs.js";
5
4
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
6
- import { isMetadataOnly } from "./metadataExtractor.js";
7
5
  import {
8
6
  INDEX_EXTENSIONS,
9
7
  toDisplayName,
@@ -11,6 +9,7 @@ import {
11
9
  getMenuSortAsFromFile,
12
10
  getFolderLabel,
13
11
  getFolderSortKey,
12
+ readFrontmatterInfo,
14
13
  } from "./menuLabels.js";
15
14
 
16
15
  // Icon extensions to check for custom icons
@@ -208,13 +207,8 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
208
207
 
209
208
  // Skip metadata-only index files (they only provide folder metadata, not actual pages)
210
209
  if (!hasChildren && isIndexFile(baseName)) {
211
- try {
212
- const content = readFileSync(item.path, 'utf8');
213
- if (isMetadataOnly(content)) {
214
- continue; // Skip - this file doesn't produce a page
215
- }
216
- } catch (e) {
217
- // If we can't read it, include it in the menu
210
+ if (readFrontmatterInfo(item.path)?.isMetadataOnly) {
211
+ continue; // Skip - this file doesn't produce a page
218
212
  }
219
213
  }
220
214
 
@@ -390,6 +384,37 @@ export function pruneHiddenNodes(node, source) {
390
384
  };
391
385
  }
392
386
 
387
+ /**
388
+ * Build a directory tree in the shape `directory-tree` produced
389
+ * ({name, path, children?}), reading through the traced filesystem so the
390
+ * build graph records every listing the menu depends on. Entries are sorted
391
+ * by name; readdir order never reaches the menu.
392
+ * @param {string} dir - Absolute directory path
393
+ * @returns {object|null} Tree node, or null if `dir` cannot be listed
394
+ */
395
+ function walkTree(dir) {
396
+ let entries;
397
+ try {
398
+ entries = readdirSync(dir, { withFileTypes: true });
399
+ } catch {
400
+ return null;
401
+ }
402
+ const children = [];
403
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
404
+ // Never inputs (see tracedFs.isIgnoredDirEntry); skipping them here also
405
+ // keeps the walk out of a .git or node_modules sitting inside the docroot.
406
+ if (isIgnoredDirEntry(entry.name)) continue;
407
+ const path = join(dir, entry.name);
408
+ if (entry.isDirectory()) {
409
+ const child = walkTree(path);
410
+ if (child) children.push(child);
411
+ } else if (entry.isFile()) {
412
+ children.push({ name: entry.name, path });
413
+ }
414
+ }
415
+ return { name: basename(dir), path: dir, children };
416
+ }
417
+
393
418
  export async function getAutomenu(source, validPaths) {
394
419
  /*
395
420
  * Walk first, prune second.
@@ -406,7 +431,7 @@ export async function getAutomenu(source, validPaths) {
406
431
  * discarded; docroots do not normally contain one, and correctness on every
407
432
  * ordinary path is worth more than speed on a pathological one.
408
433
  */
409
- const fullTree = dirTree(source);
434
+ const fullTree = walkTree(source.replace(/\/$/, ''));
410
435
  if (!fullTree) {
411
436
  throw new Error(
412
437
  `Cannot read docroot for menu generation: ${source} (does it exist and is it a directory?)`