@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 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
+ })();
@@ -162,6 +162,7 @@
162
162
  <div id="global-nav">
163
163
  </div>
164
164
 
165
+ <script src="/public/content-hooks.js"></script>
165
166
  <script src="/public/toc.js"></script>
166
167
  <script src="/public/toc-generator.js"></script>
167
168
  <script src="/public/menu.js"></script>
@@ -2,7 +2,9 @@ document.addEventListener('DOMContentLoaded', () => {
2
2
  const article = document.querySelector('article#main-content');
3
3
  if (!article) return;
4
4
 
5
- const headings = article.querySelectorAll('h1, h2, h3');
5
+ // Re-collected on ursa:content-changed, since an island may add headings
6
+ // after load (see content-hooks.js).
7
+ let headings = article.querySelectorAll('h1, h2, h3');
6
8
 
7
9
  function updateStuckState() {
8
10
  let currentStuckHeading = null;
@@ -70,4 +72,8 @@ document.addEventListener('DOMContentLoaded', () => {
70
72
  updateStuckState();
71
73
  window.addEventListener('scroll', updateStuckState, { passive: true });
72
74
  window.addEventListener('resize', updateStuckState);
75
+ document.addEventListener('ursa:content-changed', () => {
76
+ headings = article.querySelectorAll('h1, h2, h3');
77
+ updateStuckState();
78
+ });
73
79
  });
@@ -6,47 +6,67 @@ document.addEventListener('DOMContentLoaded', () => {
6
6
 
7
7
  if (!tocTarget || !article) return;
8
8
 
9
- // Find all headings in the article
10
- const headings = article.querySelectorAll('h1, h2, h3');
11
-
12
- if (headings.length === 0) {
13
- // Hide the TOC widget button if no headings
14
- const tocButton = document.querySelector('.widget-button[data-widget="toc"]');
15
- if (tocButton) tocButton.style.display = 'none';
16
- tocTarget.style.display = 'none';
17
- return;
18
- }
19
-
20
- // Generate TOC HTML
21
- const tocList = document.createElement('ul');
22
-
23
- headings.forEach((heading, index) => {
24
- // Create unique ID for the heading if it doesn't have one
25
- if (!heading.id) {
26
- const text = heading.textContent.trim()
27
- .toLowerCase()
28
- .replace(/[^\w\s-]/g, '') // Remove special characters
29
- .replace(/\s+/g, '-'); // Replace spaces with hyphens
30
- heading.id = `heading-${index}-${text}`;
31
- }
32
-
33
- // Create TOC item
34
- const listItem = document.createElement('li');
35
- listItem.className = `toc-${heading.tagName.toLowerCase()}`;
36
-
37
- const link = document.createElement('a');
38
- link.href = `#${heading.id}`;
39
- link.textContent = heading.textContent;
40
- link.addEventListener('click', handleTocClick);
41
-
42
- listItem.appendChild(link);
43
- tocList.appendChild(listItem);
44
- });
45
-
9
+ // The headings the TOC currently reflects. Rebuilt on ursa:content-changed,
10
+ // since an island may add headings after load (see content-hooks.js).
11
+ let headings = [];
12
+ const tocButton = document.querySelector('.widget-button[data-widget="toc"]');
13
+
46
14
  // Add an id=toc wrapper for the toc.js sentinel-based highlighter
15
+ const tocList = document.createElement('ul');
47
16
  tocList.id = 'toc';
48
17
  tocTarget.appendChild(tocList);
49
-
18
+
19
+ function headingId(heading, index) {
20
+ const text = heading.textContent.trim()
21
+ .toLowerCase()
22
+ .replace(/[^\w\s-]/g, '') // Remove special characters
23
+ .replace(/\s+/g, '-'); // Replace spaces with hyphens
24
+ let id = `heading-${index}-${text}`;
25
+ // A heading added later can land on an index an earlier one already used
26
+ let n = 2;
27
+ while (document.getElementById(id)) id = `heading-${index}-${text}-${n++}`;
28
+ return id;
29
+ }
30
+
31
+ // (Re)build the list from the article's current headings. Headings keep the
32
+ // ids they already have, so existing anchors and links stay valid.
33
+ function buildToc() {
34
+ headings = article.querySelectorAll('h1, h2, h3');
35
+
36
+ if (headings.length === 0) {
37
+ // Hide the TOC widget button if no headings
38
+ if (tocButton) tocButton.style.display = 'none';
39
+ tocTarget.style.display = 'none';
40
+ return;
41
+ }
42
+ if (tocButton) tocButton.style.display = '';
43
+ tocTarget.style.display = '';
44
+
45
+ tocList.replaceChildren();
46
+ headings.forEach((heading, index) => {
47
+ // Create unique ID for the heading if it doesn't have one
48
+ if (!heading.id) heading.id = headingId(heading, index);
49
+
50
+ // Create TOC item
51
+ const listItem = document.createElement('li');
52
+ listItem.className = `toc-${heading.tagName.toLowerCase()}`;
53
+
54
+ const link = document.createElement('a');
55
+ link.href = `#${heading.id}`;
56
+ link.textContent = heading.textContent;
57
+ link.addEventListener('click', handleTocClick);
58
+
59
+ listItem.appendChild(link);
60
+ tocList.appendChild(listItem);
61
+ });
62
+ }
63
+
64
+ buildToc();
65
+ document.addEventListener('ursa:content-changed', () => {
66
+ buildToc();
67
+ updateActiveTocItem();
68
+ });
69
+
50
70
  // Handle TOC link clicks for smooth scrolling
51
71
  function handleTocClick(e) {
52
72
  e.preventDefault();
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.93.0",
5
+ "version": "0.96.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
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
+ });