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