@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.
- package/CHANGELOG.md +26 -0
- package/README.md +72 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +36 -11
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +553 -0
- package/src/helper/build/autoIndex.js +2 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1270 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +1 -1
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- 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
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
17
|
-
import { existsSync } from "
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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:
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
/**
|
package/src/helper/automenu.js
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
212
|
-
|
|
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 =
|
|
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?)`
|