@kenjura/ursa 0.93.0 → 0.96.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 +54 -0
- package/README.md +109 -0
- package/bin/ursa.js +14 -3
- package/meta/templates/default-template/content-hooks.js +45 -0
- package/meta/templates/default-template/index.html +1 -0
- package/meta/templates/default-template/sticky.js +7 -1
- package/meta/templates/default-template/toc-generator.js +58 -38
- package/package.json +1 -1
- package/src/dev.js +29 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/__test__/mdxRenderer.test.js +159 -0
- package/src/helper/__test__/sourceTimestamps.test.js +0 -0
- package/src/helper/automenu.js +4 -2
- package/src/helper/build/__test__/autoIndex.test.js +67 -0
- package/src/helper/build/autoIndex.js +22 -5
- package/src/helper/folderConfig.js +34 -4
- package/src/helper/mdxRenderer.js +199 -22
- package/src/helper/sourceTimestamps.js +139 -0
- package/src/helper/ursaConfig.js +3 -49
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +297 -220
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
+
# 0.96.0
|
|
2
|
+
2026-09-16
|
|
3
|
+
|
|
4
|
+
MDX pages hydrate their components, not the page; Recent Activity is dated from git.
|
|
5
|
+
|
|
6
|
+
`hydrate: true` on an `.mdx` page broke the page's layout, and the break got worse the more headings the page had: sticky H1s piled on top of one another, breadcrumbs vanished, and the table of contents listed the title twice. The hydration script handed React the whole of `#main-content` and the whole MDX component and expected them to match. They never did. The template puts breadcrumbs and (sometimes) a title heading inside that container, and the default template's `sectionify.js` rewrites it on `DOMContentLoaded`, wrapping each H1 section in `<section class="sectionOuter">` — and the hydration script, being the last script in the body, always ran after it. React reported the mismatch (error #418), discarded the server-rendered DOM and rendered the component from scratch, with none of the template's structure.
|
|
7
|
+
|
|
8
|
+
- **Each component imported into the `.mdx` is now an island.** An esbuild plugin wraps the default export of every `.jsx`/`.tsx` the entry imports (and `.js`/`.ts` under `_components/`), so the build renders it inside `<ursa-island data-island="N">` and the browser hydrates that element as its own React root, against exactly the markup the build produced for it. The Markdown around the islands is never handed to React; `sectionify`, breadcrumbs and the TOC can do what they like to it.
|
|
9
|
+
- **Function props keep working.** The client still runs the whole bundled MDX module — into a detached root, purely to execute the tree — so each island receives its real props, `filter={fn}` included, rather than a serialized replay. Island numbering is a per-render counter taken in `useState`'s lazy initializer, so it increments once per mount in tree order on both sides.
|
|
10
|
+
- **Components imported by components are not islands.** They render inside their parent's root, as before; nesting would put one root inside another. Named exports and non-function imports pass through untouched.
|
|
11
|
+
- **React 19's hoisted `<link rel="preload">` is stripped** from the MDX render. It carried the un-rewritten relative image path, so it fetched nothing useful, and its position ahead of the first `<h1>` is what defeated the "body starts with a heading" check and produced the duplicate title.
|
|
12
|
+
- **`react-runtime.js` now exposes `createRoot`** and carries a version marker; `buildReactRuntime` rebuilds an older runtime found in `output/public/` instead of reusing it.
|
|
13
|
+
- **`window.ursa.contentChanged(root)` tells the template the article changed.** The template's scripts read the article once, on `DOMContentLoaded`: `sticky.js` collects the headings it marks `.stuck`, `toc-generator.js` builds the table of contents from them. A component that fetches data and renders a list with its own headings after that point was invisible to both — its H2s never rolled up into the stuck H1 and piled on top of each other, and the TOC did not list them. The helper dispatches `ursa:content-changed` on `document` (coalesced per task, so a multi-step render can call it freely) and both scripts re-read the headings on it: sticky state is recomputed, and the TOC is rebuilt in place with existing heading ids preserved. `content-hooks.js` is a new template script, loaded first.
|
|
14
|
+
|
|
15
|
+
**Recent Activity is dated from git, not from the build.** The feed took each document's time from a `contentTimestamps` map in `.ursa.json` that was set to the build time whenever the document was regenerated. Under `--clean` every document regenerates, so every entry got the same time and the feed showed ten arbitrary pages. The map also grew to one line per document and changed on every build, which in a repo that commits `.ursa.json` meant a diff on every commit.
|
|
16
|
+
|
|
17
|
+
- **One `git log --name-only` pass over the source directory** at the start of a build gives every document's last-commit time in a single process (0.4s on 1,400 documents). A document with uncommitted changes, an untracked one, or any document when the source is not a git work tree, is dated by file mtime instead.
|
|
18
|
+
- **A shallow clone is detected and warned about**, since with one fetched commit every document looks edited in it. The fix is `fetch-depth: 0` on the checkout.
|
|
19
|
+
- **`contentTimestamps` is gone from `.ursa.json`**; the next build removes the stale key. `serve`'s single-file regeneration dates the changed document the same way instead of stamping "now".
|
|
20
|
+
|
|
21
|
+
`hydrate: true` means what it did: emit the client bundle. Without it, islands are rendered at build time and inert. React context does not cross island boundaries, which no MDX page relied on — there is no provider above the components to begin with. Design notes are in `docs/changes/island-hydration.md`, and the README gains an "MDX and Interactive Components" section.
|
|
22
|
+
|
|
23
|
+
# 0.95.0
|
|
24
|
+
2026-09-06
|
|
25
|
+
|
|
26
|
+
`generate --json-only` builds the data and skips the site.
|
|
27
|
+
|
|
28
|
+
ursa emits a `.json` beside every document and a `<dir>.json` record list beside every directory, and those files are useful on their own — an application can ingest a docs repo at build time and never serve the HTML. Getting them, though, meant paying for the whole site: rendering every page, resizing every image, bundling the React runtime, and writing a full-text index nobody would query. On the system8 docs that is 2.85s and 88 MB to obtain 8.8 MB of JSON.
|
|
29
|
+
|
|
30
|
+
- **`--json-only` (`-j`) emits only the data files.** Skipped: HTML, XML, images and their previews, meta/template assets, the React runtime, per-folder CSS/JS bundles, static file copying (fonts, audio, video, PDFs), the search and full-text indices, `menu-data.json`, `recent-activity.json`, and auto-generated index pages. On the system8 docs: **1.12s and 8.8 MB**, down from 2.85s and 88 MB.
|
|
31
|
+
- **The JSON is byte-identical to a full build's**, not merely similar — verified across all 1,475 content files of the system8 docs, and asserted in the test suite. Every step the mode skips operates on the assembled *page*: `bodyHtml` in the JSON is the pre-template render, and `transformImageTags`, `markInactiveLinks` and `resolveRelativeUrls` only ever rewrote the finished HTML. So there is nothing for image processing or template bundling to contribute to it.
|
|
32
|
+
- **The directory record lists are kept.** They are the one thing a data consumer most wants — `character/powers.json` is the list of every power with its frontmatter — so only the directory's *listing page* is skipped, never its `.json`.
|
|
33
|
+
|
|
34
|
+
Mixing modes against one source tree is safe. The `.ursa` hash cache lives in the source and is shared, so the per-document output check now asks only for the outputs the current mode emits: a JSON-only run after a full build skips work (the `.json` is present and identical either way), and a full build after a JSON-only run regenerates, because its `.html` and `.xml` are missing. That check already existed for a related reason — one hash cache serving several output directories — and it extends to modes for free.
|
|
35
|
+
|
|
36
|
+
Two pieces of build state are deliberately not written by a JSON-only run. The dependency graph is skipped because registration lives inside the page assembly the mode skips, so saving would replace a full build's graph with an empty one. The watch-mode cache is skipped because it would be seeded with unbundled templates and an empty image map, which would make a later single-file regeneration emit an unstyled page.
|
|
37
|
+
|
|
38
|
+
# 0.94.0
|
|
39
|
+
2026-09-04
|
|
40
|
+
|
|
41
|
+
`config.json { hidden: true }` now actually ignores a folder.
|
|
42
|
+
|
|
43
|
+
It was documented as "hide from menu and don't generate files", and it did neither reliably. `generate` filtered articles and directories, but every other category was derived from an unfiltered list, so a hidden folder still had its images, fonts, audio and video copied into the output and its hand-written HTML carried across. Auto-index listings never consulted the setting at all, so a hidden folder was listed — with working-looking links — in its parent's index. `serve` rendered its pages on request, which is the worst version of the bug: the page works all through development and 404s in production, exactly the failure the setting exists to prevent.
|
|
44
|
+
|
|
45
|
+
- **One filter, applied once.** `generate` now drops hidden paths from the whole source file list, ahead of classification, instead of re-checking in each category. Articles, directories, images, media and hand-written HTML all inherit it, so no category can be missed — which is how images and media came to be copied out of hidden folders in the first place.
|
|
46
|
+
- **Auto-indices skip hidden folders**, in all three listing paths: from source, from output, and the fallback index generated for a folder without one. The output-scanning path checks the *source* tree, so a folder hidden after it was generated does not reappear from stale files left in `output/`.
|
|
47
|
+
- **A folder holding nothing but a hidden subfolder is no longer treated as having content**, and so is not linked as if it had pages.
|
|
48
|
+
- **`serve` returns 404 for anything under a hidden folder** — documents, images, `config.json` itself — from a single gate ahead of the static-file fallbacks. `serve` and `generate` now agree.
|
|
49
|
+
- **The menu check no longer depends on the folder having children**, so an empty hidden folder is skipped too.
|
|
50
|
+
|
|
51
|
+
`isFolderHidden()` accepts file paths as well as directories: the walk begins at the path itself, and a file has no `config.json` of its own, so its ancestors decide. That is what lets one predicate filter a mixed list of files and directories. Its shallow companion `isFolderSelfHidden()` tests one folder without a docroot, for the auto-index builders, which know only the folder they are listing.
|
|
52
|
+
|
|
53
|
+
Files already written to `output/` before a folder was hidden are still not deleted — nothing links to them any more, but removing them needs `--clean`. That is the general "generate never deletes" behaviour, unchanged here.
|
|
54
|
+
|
|
1
55
|
# 0.93.0
|
|
2
56
|
2026-09-03
|
|
3
57
|
|
package/README.md
CHANGED
|
@@ -67,6 +67,35 @@ Start a development server that:
|
|
|
67
67
|
- `--whitelist, -w` - Path to whitelist file containing patterns for files to include
|
|
68
68
|
- `--exclude, -e` - Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line
|
|
69
69
|
- `--clean` - Delete the `.ursa` cache folder and clear output directory, forcing full regeneration
|
|
70
|
+
- `--json-only, -j` - Emit only the `.json` data files (generate command only)
|
|
71
|
+
|
|
72
|
+
### JSON-Only Builds
|
|
73
|
+
|
|
74
|
+
`--json-only` builds the data and skips the site:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
ursa content --json-only --output data
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
What it emits: every document's `<name>.json` and every directory's `<dir>.json`
|
|
81
|
+
record list — the same files a normal build writes, byte for byte.
|
|
82
|
+
|
|
83
|
+
What it skips: HTML, XML, images and their previews, meta/template assets, the
|
|
84
|
+
React runtime, per-folder CSS/JS bundles, static file copying (fonts, audio,
|
|
85
|
+
video, PDFs), the search and full-text indices, `menu-data.json`,
|
|
86
|
+
`recent-activity.json`, and auto-generated index pages.
|
|
87
|
+
|
|
88
|
+
This is for pipelines that consume ursa's JSON as data rather than publishing a
|
|
89
|
+
site — ingesting a docs repo into an application at build time, for example.
|
|
90
|
+
Everything skipped operates on the assembled *page*; the JSON's `bodyHtml` is
|
|
91
|
+
the pre-template render, which none of those steps touch. That is why the output
|
|
92
|
+
is identical rather than merely similar.
|
|
93
|
+
|
|
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`.
|
|
70
99
|
|
|
71
100
|
### Whitelist File Format
|
|
72
101
|
|
|
@@ -124,6 +153,44 @@ old-content/v1
|
|
|
124
153
|
test/fixtures
|
|
125
154
|
```
|
|
126
155
|
|
|
156
|
+
### Ignoring a Folder
|
|
157
|
+
|
|
158
|
+
To keep a folder out of the build permanently — working notes, prompt
|
|
159
|
+
scratchpads, raw source material — put a `config.json` in it:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"hidden": true
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The folder and everything beneath it then take no part in the build:
|
|
168
|
+
|
|
169
|
+
- no HTML is rendered from its documents
|
|
170
|
+
- its images, fonts and other static assets are not copied to the output
|
|
171
|
+
- it does not appear in the sidebar menu, in any auto-index, or in breadcrumbs
|
|
172
|
+
- its text is not added to the search index
|
|
173
|
+
- `ursa serve` returns 404 for anything under it, matching what `generate`
|
|
174
|
+
produces
|
|
175
|
+
|
|
176
|
+
The files stay where they are in the source tree; the site simply behaves as
|
|
177
|
+
though they were not there.
|
|
178
|
+
|
|
179
|
+
This differs from `--exclude` in scope and in lifetime: `--exclude` is a flag on
|
|
180
|
+
one invocation, useful for a one-off or a per-environment build, while
|
|
181
|
+
`config.json` travels with the content and applies to every build and every
|
|
182
|
+
person who checks the repo out.
|
|
183
|
+
|
|
184
|
+
`config.json` accepts a few other keys, all of which apply to the folder it
|
|
185
|
+
sits in:
|
|
186
|
+
|
|
187
|
+
| Key | Type | Meaning |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| `hidden` | boolean | Ignore this folder and its subtree entirely (above) |
|
|
190
|
+
| `label` | string | Name to show for this folder in menus and indices |
|
|
191
|
+
| `icon` | string | URL of an icon to show beside it in the menu |
|
|
192
|
+
| `openMenuItems` | string[] | Root `config.json` only: folders to expand by default |
|
|
193
|
+
|
|
127
194
|
### Large Workloads
|
|
128
195
|
|
|
129
196
|
For sites with many documents (hundreds or thousands), you may need to increase Node.js memory limits:
|
|
@@ -251,6 +318,48 @@ Both panels carry it: `#widget-dropdown` for the right-hand widgets (`toc`,
|
|
|
251
318
|
Ursa uses this hook itself, to lay the TOC out along the bottom of the viewport
|
|
252
319
|
on a narrow screen.
|
|
253
320
|
|
|
321
|
+
## Recent Activity
|
|
322
|
+
|
|
323
|
+
The site's Recent Activity widget lists the ten most recently edited documents. A document is dated by the last git commit that touched it — one `git log` pass over the source directory at the start of a build — or by its file mtime if it has uncommitted changes, is untracked, or the source is not in a git work tree. The build's own time never enters into it, so `--clean` does not reset the feed.
|
|
324
|
+
|
|
325
|
+
This needs git history to be present. A shallow checkout (GitHub Actions' `actions/checkout` defaults to depth 1) makes every document look edited in the one fetched commit; ursa warns when it sees one. Use `fetch-depth: 0`.
|
|
326
|
+
|
|
327
|
+
## MDX and Interactive Components
|
|
328
|
+
|
|
329
|
+
A `.mdx` document is Markdown that can import and use React components. Put components in a `_components/` folder anywhere from the docroot down to the document's own folder, and import them without a relative prefix:
|
|
330
|
+
|
|
331
|
+
```mdx
|
|
332
|
+
---
|
|
333
|
+
hydrate: true
|
|
334
|
+
---
|
|
335
|
+
import PowerList from '_components/PowerList.jsx';
|
|
336
|
+
|
|
337
|
+
# Spells
|
|
338
|
+
|
|
339
|
+
<PowerList class="Witch" groupBy="school" />
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Every document is rendered to HTML at build time, components included, so a page reads the same with JavaScript off. `hydrate: true` in the frontmatter additionally ships the page's components to the browser so they can run there.
|
|
343
|
+
|
|
344
|
+
Hydration works per component, not per page. Each component imported directly into the `.mdx` file becomes an **island**: the build wraps its output in `<ursa-island data-island="N">`, and in the browser each island is hydrated as its own React root against exactly the markup the build produced for it. The Markdown around the islands is never handed to React, so the template is free to rearrange it — section wrappers for sticky headings, breadcrumbs, the table of contents — without any hydration mismatch. Two things follow from this:
|
|
345
|
+
|
|
346
|
+
- A component's first render must produce the same markup in the browser as it did at build time (the usual hydration contract). Fetch data in an effect and render a placeholder first.
|
|
347
|
+
- React context does not cross from one island to another. Components that need to share state should be one island, with the shared state inside it.
|
|
348
|
+
|
|
349
|
+
`island` wrapping applies to the default export of any `.jsx`/`.tsx` file the `.mdx` imports, and of `.js`/`.ts` files under `_components/`. Components that a component imports are not islands themselves — they render inside their parent's root. Non-function imports (JSON, data) pass through untouched.
|
|
350
|
+
|
|
351
|
+
### Telling the template the article changed
|
|
352
|
+
|
|
353
|
+
The template's scripts read the article once, when the page loads: the sticky headings and the table of contents are both built from the headings present at that moment. A component that renders content later — a list fetched from a JSON file, say, with headings of its own — should say so once it has:
|
|
354
|
+
|
|
355
|
+
```jsx
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (items.length) window.ursa?.contentChanged?.(rootRef.current);
|
|
358
|
+
}, [items]);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`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
|
+
|
|
254
363
|
## Auto-Index Generation
|
|
255
364
|
|
|
256
365
|
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:
|
package/bin/ursa.js
CHANGED
|
@@ -54,6 +54,12 @@ yargs(hideBin(process.argv))
|
|
|
54
54
|
.option('promote-changelog', {
|
|
55
55
|
describe: 'Path to a markdown file to render at the output root (sibling of index.html)',
|
|
56
56
|
type: 'string'
|
|
57
|
+
})
|
|
58
|
+
.option('json-only', {
|
|
59
|
+
alias: 'j',
|
|
60
|
+
describe: 'Emit only the .json data files — no HTML, XML, images, static assets, search indices or menu data',
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
default: false
|
|
57
63
|
});
|
|
58
64
|
},
|
|
59
65
|
async (argv) => {
|
|
@@ -64,7 +70,8 @@ yargs(hideBin(process.argv))
|
|
|
64
70
|
const exclude = argv.exclude || null;
|
|
65
71
|
const clean = argv.clean;
|
|
66
72
|
const promoteChangelog = argv['promote-changelog'] || null;
|
|
67
|
-
|
|
73
|
+
const jsonOnly = argv['json-only'];
|
|
74
|
+
|
|
68
75
|
console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
|
|
69
76
|
if (whitelist) {
|
|
70
77
|
console.log(`Using whitelist: ${whitelist}`);
|
|
@@ -75,7 +82,10 @@ yargs(hideBin(process.argv))
|
|
|
75
82
|
if (clean) {
|
|
76
83
|
console.log(`Clean build: ignoring cached hashes`);
|
|
77
84
|
}
|
|
78
|
-
|
|
85
|
+
if (jsonOnly) {
|
|
86
|
+
console.log(`JSON-only build: emitting .json data files only`);
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
let promoted = { stagedFile: null, cleanup: async () => {} };
|
|
80
90
|
try {
|
|
81
91
|
promoted = await stagePromotedChangelog({ changelogPath: promoteChangelog, sourceDir: source });
|
|
@@ -85,7 +95,8 @@ yargs(hideBin(process.argv))
|
|
|
85
95
|
_output: output,
|
|
86
96
|
_whitelist: whitelist,
|
|
87
97
|
_exclude: exclude,
|
|
88
|
-
_clean: clean
|
|
98
|
+
_clean: clean,
|
|
99
|
+
_jsonOnly: jsonOnly
|
|
89
100
|
});
|
|
90
101
|
console.log('Site generation completed successfully!');
|
|
91
102
|
} catch (error) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Content-change hook
|
|
2
|
+
//
|
|
3
|
+
// The template's scripts read the article once, on DOMContentLoaded: sticky.js
|
|
4
|
+
// collects the headings it will mark .stuck, toc-generator.js builds the table
|
|
5
|
+
// of contents from them. Anything that adds content to the article after that
|
|
6
|
+
// point — an island that fetches data and renders a list with its own headings —
|
|
7
|
+
// is invisible to them unless it says so.
|
|
8
|
+
//
|
|
9
|
+
// This is how it says so. A script that has changed the article calls
|
|
10
|
+
//
|
|
11
|
+
// window.ursa.contentChanged(root)
|
|
12
|
+
//
|
|
13
|
+
// where `root` is the element whose contents changed (optional; defaults to the
|
|
14
|
+
// article). That dispatches `ursa:content-changed` on `document`, with the root
|
|
15
|
+
// in `event.detail.root`, and each template script that keeps a view of the
|
|
16
|
+
// article listens for it and re-reads what it needs. Calls made in the same
|
|
17
|
+
// task are coalesced into one event (a macrotask, not an animation frame, so it
|
|
18
|
+
// also fires in a background tab), so a component that renders in several
|
|
19
|
+
// steps can call it freely.
|
|
20
|
+
//
|
|
21
|
+
// Dispatching the event directly works just as well; the helper exists so a
|
|
22
|
+
// caller does not have to know the event's name.
|
|
23
|
+
(() => {
|
|
24
|
+
const EVENT = 'ursa:content-changed';
|
|
25
|
+
let queued = null;
|
|
26
|
+
|
|
27
|
+
function contentChanged(root) {
|
|
28
|
+
if (root && !(root instanceof Element)) root = null;
|
|
29
|
+
// Coalesce: keep the broadest root seen so far
|
|
30
|
+
if (queued) {
|
|
31
|
+
if (queued.root && root && queued.root !== root && !queued.root.contains(root)) {
|
|
32
|
+
queued.root = null;
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
queued = { root: root || null };
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
const detail = { root: queued.root || document.querySelector('article#main-content') };
|
|
39
|
+
queued = null;
|
|
40
|
+
document.dispatchEvent(new CustomEvent(EVENT, { detail }));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
window.ursa = Object.assign(window.ursa || {}, { contentChanged, CONTENT_CHANGED_EVENT: EVENT });
|
|
45
|
+
})();
|
|
@@ -2,7 +2,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2
2
|
const article = document.querySelector('article#main-content');
|
|
3
3
|
if (!article) return;
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Re-collected on ursa:content-changed, since an island may add headings
|
|
6
|
+
// after load (see content-hooks.js).
|
|
7
|
+
let headings = article.querySelectorAll('h1, h2, h3');
|
|
6
8
|
|
|
7
9
|
function updateStuckState() {
|
|
8
10
|
let currentStuckHeading = null;
|
|
@@ -70,4 +72,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
70
72
|
updateStuckState();
|
|
71
73
|
window.addEventListener('scroll', updateStuckState, { passive: true });
|
|
72
74
|
window.addEventListener('resize', updateStuckState);
|
|
75
|
+
document.addEventListener('ursa:content-changed', () => {
|
|
76
|
+
headings = article.querySelectorAll('h1, h2, h3');
|
|
77
|
+
updateStuckState();
|
|
78
|
+
});
|
|
73
79
|
});
|
|
@@ -6,47 +6,67 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
6
6
|
|
|
7
7
|
if (!tocTarget || !article) return;
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const tocButton = document.querySelector('.widget-button[data-widget="toc"]');
|
|
15
|
-
if (tocButton) tocButton.style.display = 'none';
|
|
16
|
-
tocTarget.style.display = 'none';
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Generate TOC HTML
|
|
21
|
-
const tocList = document.createElement('ul');
|
|
22
|
-
|
|
23
|
-
headings.forEach((heading, index) => {
|
|
24
|
-
// Create unique ID for the heading if it doesn't have one
|
|
25
|
-
if (!heading.id) {
|
|
26
|
-
const text = heading.textContent.trim()
|
|
27
|
-
.toLowerCase()
|
|
28
|
-
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
29
|
-
.replace(/\s+/g, '-'); // Replace spaces with hyphens
|
|
30
|
-
heading.id = `heading-${index}-${text}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Create TOC item
|
|
34
|
-
const listItem = document.createElement('li');
|
|
35
|
-
listItem.className = `toc-${heading.tagName.toLowerCase()}`;
|
|
36
|
-
|
|
37
|
-
const link = document.createElement('a');
|
|
38
|
-
link.href = `#${heading.id}`;
|
|
39
|
-
link.textContent = heading.textContent;
|
|
40
|
-
link.addEventListener('click', handleTocClick);
|
|
41
|
-
|
|
42
|
-
listItem.appendChild(link);
|
|
43
|
-
tocList.appendChild(listItem);
|
|
44
|
-
});
|
|
45
|
-
|
|
9
|
+
// The headings the TOC currently reflects. Rebuilt on ursa:content-changed,
|
|
10
|
+
// since an island may add headings after load (see content-hooks.js).
|
|
11
|
+
let headings = [];
|
|
12
|
+
const tocButton = document.querySelector('.widget-button[data-widget="toc"]');
|
|
13
|
+
|
|
46
14
|
// Add an id=toc wrapper for the toc.js sentinel-based highlighter
|
|
15
|
+
const tocList = document.createElement('ul');
|
|
47
16
|
tocList.id = 'toc';
|
|
48
17
|
tocTarget.appendChild(tocList);
|
|
49
|
-
|
|
18
|
+
|
|
19
|
+
function headingId(heading, index) {
|
|
20
|
+
const text = heading.textContent.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
23
|
+
.replace(/\s+/g, '-'); // Replace spaces with hyphens
|
|
24
|
+
let id = `heading-${index}-${text}`;
|
|
25
|
+
// A heading added later can land on an index an earlier one already used
|
|
26
|
+
let n = 2;
|
|
27
|
+
while (document.getElementById(id)) id = `heading-${index}-${text}-${n++}`;
|
|
28
|
+
return id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// (Re)build the list from the article's current headings. Headings keep the
|
|
32
|
+
// ids they already have, so existing anchors and links stay valid.
|
|
33
|
+
function buildToc() {
|
|
34
|
+
headings = article.querySelectorAll('h1, h2, h3');
|
|
35
|
+
|
|
36
|
+
if (headings.length === 0) {
|
|
37
|
+
// Hide the TOC widget button if no headings
|
|
38
|
+
if (tocButton) tocButton.style.display = 'none';
|
|
39
|
+
tocTarget.style.display = 'none';
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (tocButton) tocButton.style.display = '';
|
|
43
|
+
tocTarget.style.display = '';
|
|
44
|
+
|
|
45
|
+
tocList.replaceChildren();
|
|
46
|
+
headings.forEach((heading, index) => {
|
|
47
|
+
// Create unique ID for the heading if it doesn't have one
|
|
48
|
+
if (!heading.id) heading.id = headingId(heading, index);
|
|
49
|
+
|
|
50
|
+
// Create TOC item
|
|
51
|
+
const listItem = document.createElement('li');
|
|
52
|
+
listItem.className = `toc-${heading.tagName.toLowerCase()}`;
|
|
53
|
+
|
|
54
|
+
const link = document.createElement('a');
|
|
55
|
+
link.href = `#${heading.id}`;
|
|
56
|
+
link.textContent = heading.textContent;
|
|
57
|
+
link.addEventListener('click', handleTocClick);
|
|
58
|
+
|
|
59
|
+
listItem.appendChild(link);
|
|
60
|
+
tocList.appendChild(listItem);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
buildToc();
|
|
65
|
+
document.addEventListener('ursa:content-changed', () => {
|
|
66
|
+
buildToc();
|
|
67
|
+
updateActiveTocItem();
|
|
68
|
+
});
|
|
69
|
+
|
|
50
70
|
// Handle TOC link clicks for smooth scrolling
|
|
51
71
|
function handleTocClick(e) {
|
|
52
72
|
e.preventDefault();
|
package/package.json
CHANGED
package/src/dev.js
CHANGED
|
@@ -372,6 +372,15 @@ async function renderDocument(urlPath) {
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
const { sourcePath, type } = resolved;
|
|
375
|
+
|
|
376
|
+
// `hidden: true` means the folder takes no part in the build. `generate`
|
|
377
|
+
// writes nothing for it, so serving it on demand would make `serve` and
|
|
378
|
+
// `generate` disagree — the page works all through development and 404s in
|
|
379
|
+
// production, the exact failure mode this is meant to prevent.
|
|
380
|
+
if (isFolderHidden(dirname(sourcePath), source)) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
375
384
|
const ext = type;
|
|
376
385
|
const base = basename(sourcePath, ext);
|
|
377
386
|
const dir = addTrailingSlash(dirname(sourcePath)).replace(source, "");
|
|
@@ -834,6 +843,26 @@ export async function dev({
|
|
|
834
843
|
app.use(async (req, res, next) => {
|
|
835
844
|
const url = req.url;
|
|
836
845
|
|
|
846
|
+
// Nothing under a folder that config.json marks `hidden: true` is served.
|
|
847
|
+
//
|
|
848
|
+
// This gate sits ahead of everything, including the `express.static`
|
|
849
|
+
// fallbacks mounted below — falling through with `next()` would just hand
|
|
850
|
+
// the file to them. `generate` writes no output for a hidden folder, so
|
|
851
|
+
// anything served here would work all through development and 404 in
|
|
852
|
+
// production, which is precisely what the setting exists to avoid.
|
|
853
|
+
let requestedSourcePath = null;
|
|
854
|
+
try {
|
|
855
|
+
requestedSourcePath = join(sourceDir, decodeURIComponent(url.split('?')[0]));
|
|
856
|
+
} catch (e) {
|
|
857
|
+
// Malformed percent-encoding — not a path we can classify; let it fall through
|
|
858
|
+
}
|
|
859
|
+
// Tested against the path itself, not its parent: `/foo/_art/` and
|
|
860
|
+
// `/foo/_art/index.html` must both be refused, and a file simply has no
|
|
861
|
+
// config.json of its own, so the ancestors decide either way.
|
|
862
|
+
if (requestedSourcePath && isFolderHidden(requestedSourcePath, sourceDir)) {
|
|
863
|
+
return res.status(404).send('<h1>404 Not Found</h1>');
|
|
864
|
+
}
|
|
865
|
+
|
|
837
866
|
// Handle search index requests
|
|
838
867
|
if (url === '/public/search-index.json' || url === '/public/fulltext-index.json') {
|
|
839
868
|
if (!devState.searchReady) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import {
|
|
5
|
+
clearConfigCache,
|
|
6
|
+
isFolderHidden,
|
|
7
|
+
isFolderSelfHidden,
|
|
8
|
+
} from "../folderConfig.js";
|
|
9
|
+
|
|
10
|
+
let source;
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
source = await mkdtemp(join(tmpdir(), "ursa-folderconfig-"));
|
|
13
|
+
clearConfigCache();
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(source, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function hide(...segments) {
|
|
20
|
+
const dir = join(source, ...segments);
|
|
21
|
+
await mkdir(dir, { recursive: true });
|
|
22
|
+
await writeFile(join(dir, "config.json"), JSON.stringify({ hidden: true }));
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("isFolderHidden", () => {
|
|
27
|
+
it("matches the hidden folder itself", async () => {
|
|
28
|
+
const art = await hide("everdew", "_art");
|
|
29
|
+
expect(isFolderHidden(art, source)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("matches a descendant folder of a hidden folder", async () => {
|
|
33
|
+
await hide("everdew", "_art");
|
|
34
|
+
const nested = join(source, "everdew", "_art", "prompts", "people");
|
|
35
|
+
await mkdir(nested, { recursive: true });
|
|
36
|
+
expect(isFolderHidden(nested, source)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("matches file paths, not just directories", async () => {
|
|
40
|
+
// The build filters one list holding both files and directories through
|
|
41
|
+
// this predicate, so a file must resolve via its ancestors.
|
|
42
|
+
await hide("everdew", "_art");
|
|
43
|
+
expect(
|
|
44
|
+
isFolderHidden(join(source, "everdew", "_art", "prompts.md"), source)
|
|
45
|
+
).toBe(true);
|
|
46
|
+
expect(
|
|
47
|
+
isFolderHidden(join(source, "everdew", "_art", "img", "map.png"), source)
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves siblings and ancestors of a hidden folder visible", async () => {
|
|
52
|
+
await hide("everdew", "_art");
|
|
53
|
+
await mkdir(join(source, "everdew", "people"), { recursive: true });
|
|
54
|
+
expect(isFolderHidden(join(source, "everdew", "people"), source)).toBe(false);
|
|
55
|
+
expect(isFolderHidden(join(source, "everdew"), source)).toBe(false);
|
|
56
|
+
expect(
|
|
57
|
+
isFolderHidden(join(source, "everdew", "people", "alice.md"), source)
|
|
58
|
+
).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("ignores a config.json that does not set hidden", async () => {
|
|
62
|
+
const dir = join(source, "everdew");
|
|
63
|
+
await mkdir(dir, { recursive: true });
|
|
64
|
+
await writeFile(join(dir, "config.json"), JSON.stringify({ label: "Everdew" }));
|
|
65
|
+
expect(isFolderHidden(dir, source)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("tolerates a trailing slash on the docroot", async () => {
|
|
69
|
+
const art = await hide("everdew", "_art");
|
|
70
|
+
expect(isFolderHidden(art, source + "/")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("isFolderSelfHidden", () => {
|
|
75
|
+
it("is true only for the folder carrying the config, not its descendants", async () => {
|
|
76
|
+
const art = await hide("everdew", "_art");
|
|
77
|
+
const nested = join(art, "prompts");
|
|
78
|
+
await mkdir(nested, { recursive: true });
|
|
79
|
+
|
|
80
|
+
expect(isFolderSelfHidden(art)).toBe(true);
|
|
81
|
+
expect(isFolderSelfHidden(nested)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is false for a folder with no config.json", async () => {
|
|
85
|
+
const dir = join(source, "people");
|
|
86
|
+
await mkdir(dir, { recursive: true });
|
|
87
|
+
expect(isFolderSelfHidden(dir)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|