@kenjura/ursa 0.95.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 +22 -0
- package/README.md +42 -0
- 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/helper/__test__/mdxRenderer.test.js +159 -0
- package/src/helper/__test__/sourceTimestamps.test.js +0 -0
- package/src/helper/mdxRenderer.js +199 -22
- package/src/helper/sourceTimestamps.js +139 -0
- package/src/helper/ursaConfig.js +3 -49
- package/src/jobs/generate.js +12 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
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
|
+
|
|
1
23
|
# 0.95.0
|
|
2
24
|
2026-09-06
|
|
3
25
|
|
package/README.md
CHANGED
|
@@ -318,6 +318,48 @@ Both panels carry it: `#widget-dropdown` for the right-hand widgets (`toc`,
|
|
|
318
318
|
Ursa uses this hook itself, to lay the TOC out along the bottom of the viewport
|
|
319
319
|
on a narrow screen.
|
|
320
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
|
+
|
|
321
363
|
## Auto-Index Generation
|
|
322
364
|
|
|
323
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:
|
|
@@ -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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, rm } from 'fs/promises';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { renderMDX, generateHydrationScript } from '../mdxRenderer.js';
|
|
5
|
+
|
|
6
|
+
// Helper: a temp source tree with a _components/ directory
|
|
7
|
+
let tempDir;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
tempDir = await mkdtemp(join(tmpdir(), 'ursa-mdx-'));
|
|
10
|
+
await mkdir(join(tempDir, '_components'), { recursive: true });
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function writeComponent(name, body) {
|
|
17
|
+
const path = join(tempDir, '_components', `${name}.jsx`);
|
|
18
|
+
await writeFile(path, body);
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function render(mdx, { hydrate = true } = {}) {
|
|
23
|
+
const filePath = join(tempDir, 'page.mdx');
|
|
24
|
+
await writeFile(filePath, mdx);
|
|
25
|
+
return renderMDX({ source: mdx, filePath, sourceRoot: tempDir, hydrate });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const COUNTER = `
|
|
29
|
+
import React from 'react';
|
|
30
|
+
export default function Counter({ label }) {
|
|
31
|
+
const [n, setN] = React.useState(0);
|
|
32
|
+
return <button onClick={() => setN(n + 1)}>{label}: {n}</button>;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Island wrapping
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
describe('renderMDX islands', () => {
|
|
40
|
+
test('wraps a component imported from the MDX in <ursa-island>', async () => {
|
|
41
|
+
await writeComponent('Counter', COUNTER);
|
|
42
|
+
const { html } = await render(`
|
|
43
|
+
import Counter from '_components/Counter.jsx';
|
|
44
|
+
|
|
45
|
+
# Page
|
|
46
|
+
|
|
47
|
+
<Counter label="Clicks" />
|
|
48
|
+
`);
|
|
49
|
+
expect(html).toMatch(/<ursa-island data-island="0" data-component="Counter"[^>]*>/);
|
|
50
|
+
expect(html).toContain('<button>Clicks<!-- -->: <!-- -->0</button>');
|
|
51
|
+
expect(html).toContain('</ursa-island>');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('numbers islands in document order', async () => {
|
|
55
|
+
await writeComponent('Counter', COUNTER);
|
|
56
|
+
const { html } = await render(`
|
|
57
|
+
import Counter from '_components/Counter.jsx';
|
|
58
|
+
|
|
59
|
+
<Counter label="A" />
|
|
60
|
+
|
|
61
|
+
Some prose.
|
|
62
|
+
|
|
63
|
+
<Counter label="B" />
|
|
64
|
+
`);
|
|
65
|
+
const ids = [...html.matchAll(/<ursa-island data-island="(\d+)"/g)].map((m) => m[1]);
|
|
66
|
+
expect(ids).toEqual(['0', '1']);
|
|
67
|
+
expect(html.indexOf('>A<')).toBeLessThan(html.indexOf('>B<'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('does not nest islands: a component imported by a component is rendered plainly', async () => {
|
|
71
|
+
await writeComponent('Inner', `
|
|
72
|
+
import React from 'react';
|
|
73
|
+
export default function Inner() { return <em>inner</em>; }
|
|
74
|
+
`);
|
|
75
|
+
await writeComponent('Outer', `
|
|
76
|
+
import React from 'react';
|
|
77
|
+
import Inner from './Inner.jsx';
|
|
78
|
+
export default function Outer() { return <div>outer <Inner /></div>; }
|
|
79
|
+
`);
|
|
80
|
+
const { html } = await render(`
|
|
81
|
+
import Outer from '_components/Outer.jsx';
|
|
82
|
+
|
|
83
|
+
<Outer />
|
|
84
|
+
`);
|
|
85
|
+
expect(html.match(/<ursa-island/g)).toHaveLength(1);
|
|
86
|
+
expect(html).toContain('data-component="Outer"');
|
|
87
|
+
expect(html).toContain('<em>inner</em>');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('leaves the surrounding markdown outside any island', async () => {
|
|
91
|
+
await writeComponent('Counter', COUNTER);
|
|
92
|
+
const { html } = await render(`
|
|
93
|
+
import Counter from '_components/Counter.jsx';
|
|
94
|
+
|
|
95
|
+
# Heading
|
|
96
|
+
|
|
97
|
+
<Counter label="x" />
|
|
98
|
+
|
|
99
|
+
## Sub
|
|
100
|
+
`);
|
|
101
|
+
// Headings are siblings of the island, not children of it
|
|
102
|
+
expect(html).toMatch(/<h1>Heading<\/h1>\s*<ursa-island/);
|
|
103
|
+
expect(html).toMatch(/<\/ursa-island>\s*<h2>Sub<\/h2>/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('client bundle contains the island runtime', async () => {
|
|
107
|
+
await writeComponent('Counter', COUNTER);
|
|
108
|
+
const { clientCode } = await render(`
|
|
109
|
+
import Counter from '_components/Counter.jsx';
|
|
110
|
+
|
|
111
|
+
<Counter label="x" />
|
|
112
|
+
`);
|
|
113
|
+
expect(clientCode).toContain('hydrateRoot');
|
|
114
|
+
expect(clientCode).toContain('data-island');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('hydrate: false still renders islands but emits no client code', async () => {
|
|
118
|
+
await writeComponent('Counter', COUNTER);
|
|
119
|
+
const result = await render(`
|
|
120
|
+
import Counter from '_components/Counter.jsx';
|
|
121
|
+
|
|
122
|
+
<Counter label="x" />
|
|
123
|
+
`, { hydrate: false });
|
|
124
|
+
expect(result.html).toContain('<ursa-island');
|
|
125
|
+
expect(result.clientCode).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Preload stripping
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
describe('renderMDX preload stripping', () => {
|
|
133
|
+
test('removes React 19 hoisted <link rel="preload"> so the body starts with its heading', async () => {
|
|
134
|
+
const { html } = await render(`
|
|
135
|
+
# Title
|
|
136
|
+

|
|
137
|
+
`);
|
|
138
|
+
expect(html).not.toContain('rel="preload"');
|
|
139
|
+
expect(html.trimStart().startsWith('<h1>')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Hydration script
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
describe('generateHydrationScript', () => {
|
|
147
|
+
test('renders into a detached root and looks up islands rather than a page container', () => {
|
|
148
|
+
const script = generateHydrationScript('return { default: function () { return null; } };');
|
|
149
|
+
expect(script).toContain('ursa-island[data-island]');
|
|
150
|
+
expect(script).toContain('createRoot(detached)');
|
|
151
|
+
expect(script).not.toContain("getElementById('main-content')");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('escapes the bundle for embedding in a template literal', () => {
|
|
155
|
+
const script = generateHydrationScript('var s = `a${b}`; // </script>');
|
|
156
|
+
expect(script).toContain('\\`a\\${b}\\`');
|
|
157
|
+
expect(script).toContain('<\\/script>');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
Binary file
|
|
@@ -3,9 +3,9 @@ import { getMDXComponent } from "mdx-bundler/client/index.js";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import * as esbuild from "esbuild";
|
|
6
|
-
import { dirname, join, resolve } from "path";
|
|
6
|
+
import { dirname, extname, join, resolve, sep } from "path";
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
|
-
import { writeFile, mkdir } from "fs/promises";
|
|
8
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
9
9
|
import remarkDirective from "remark-directive";
|
|
10
10
|
import { remarkDefinitionList, defListHastHandlers } from "remark-definition-list";
|
|
11
11
|
import remarkSupersub from "remark-supersub";
|
|
@@ -47,6 +47,160 @@ function remarkAsideContainers() {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Island hydration
|
|
52
|
+
//
|
|
53
|
+
// Components imported directly into an .mdx file are wrapped in an island: the
|
|
54
|
+
// server renders each inside <ursa-island data-island="N">, and the client
|
|
55
|
+
// hydrates each of those elements as its own React root. The markdown around
|
|
56
|
+
// them is never handed to React, so the template is free to restructure it
|
|
57
|
+
// (sectionify.js wraps H1 sections, breadcrumbs are injected, etc.) without
|
|
58
|
+
// causing a hydration mismatch. See docs/changes/island-hydration.md.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const ISLAND_MODULE = "ursa:island";
|
|
62
|
+
const ISLAND_NAMESPACE = "ursa-island";
|
|
63
|
+
const ISLAND_RUNTIME_NAMESPACE = "ursa-island-runtime";
|
|
64
|
+
const ISLAND_TAG = "ursa-island";
|
|
65
|
+
|
|
66
|
+
// Extensions that are wrapped when imported from the .mdx entry. .js/.ts are
|
|
67
|
+
// only wrapped when they live in a _components/ directory, since a plain .js
|
|
68
|
+
// import from MDX is as likely to be data or a helper as a component.
|
|
69
|
+
const ISLAND_COMPONENT_EXTS = new Set([".jsx", ".tsx"]);
|
|
70
|
+
const ISLAND_COMPONENT_DIR_EXTS = new Set([".js", ".ts"]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Source of the `ursa:island` virtual module. Both variants assign island ids
|
|
74
|
+
* with a lazy useState initializer so each mount takes exactly one id, in tree
|
|
75
|
+
* order — which is what lets the client match the server's numbering.
|
|
76
|
+
*/
|
|
77
|
+
function islandRuntimeSource(platform) {
|
|
78
|
+
const shared = `
|
|
79
|
+
import React from 'react';
|
|
80
|
+
let counter = 0;
|
|
81
|
+
function useIslandId() {
|
|
82
|
+
const [id] = React.useState(() => counter++);
|
|
83
|
+
return id;
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
if (platform === "node") {
|
|
87
|
+
return `${shared}
|
|
88
|
+
export function island(Component, name) {
|
|
89
|
+
if (typeof Component !== 'function') return Component;
|
|
90
|
+
function Island(props) {
|
|
91
|
+
const id = useIslandId();
|
|
92
|
+
return React.createElement(
|
|
93
|
+
${JSON.stringify(ISLAND_TAG)},
|
|
94
|
+
{ 'data-island': String(id), 'data-component': name, style: { display: 'contents' } },
|
|
95
|
+
React.createElement(Component, props)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
Island.displayName = 'Island(' + name + ')';
|
|
99
|
+
return Island;
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
return `${shared}
|
|
104
|
+
import * as ReactDOM from 'react-dom';
|
|
105
|
+
export function island(Component, name) {
|
|
106
|
+
if (typeof Component !== 'function') return Component;
|
|
107
|
+
function Island(props) {
|
|
108
|
+
const id = useIslandId();
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
const target = document.querySelector(${JSON.stringify(ISLAND_TAG)} + '[data-island="' + id + '"]');
|
|
111
|
+
if (!target) {
|
|
112
|
+
console.warn('[ursa] island #' + id + ' (' + name + ') not found in document; skipping hydration');
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const root = ReactDOM.hydrateRoot(target, React.createElement(Component, props));
|
|
116
|
+
return () => root.unmount();
|
|
117
|
+
}, []);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
Island.displayName = 'Island(' + name + ')';
|
|
121
|
+
return Island;
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isIslandCandidate(resolvedPath) {
|
|
127
|
+
const ext = extname(resolvedPath);
|
|
128
|
+
if (ISLAND_COMPONENT_EXTS.has(ext)) return true;
|
|
129
|
+
if (ISLAND_COMPONENT_DIR_EXTS.has(ext)) {
|
|
130
|
+
return resolvedPath.split(sep).includes("_components");
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* esbuild plugin that turns direct component imports of the .mdx entry into
|
|
137
|
+
* islands. Must be registered ahead of mdx-bundler's own resolvers so it sees
|
|
138
|
+
* the import first; it defers to them (via build.resolve) for the actual lookup.
|
|
139
|
+
*/
|
|
140
|
+
function islandPlugin(platform) {
|
|
141
|
+
return {
|
|
142
|
+
name: "ursa-island",
|
|
143
|
+
setup(build) {
|
|
144
|
+
// The runtime module itself
|
|
145
|
+
build.onResolve({ filter: /^ursa:island$/ }, () => ({
|
|
146
|
+
path: ISLAND_MODULE,
|
|
147
|
+
namespace: ISLAND_RUNTIME_NAMESPACE,
|
|
148
|
+
}));
|
|
149
|
+
build.onLoad({ filter: /.*/, namespace: ISLAND_RUNTIME_NAMESPACE }, () => ({
|
|
150
|
+
contents: islandRuntimeSource(platform),
|
|
151
|
+
loader: "js",
|
|
152
|
+
resolveDir: process.cwd(),
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// Imports from the .mdx entry
|
|
156
|
+
build.onResolve({ filter: /.*/ }, async (args) => {
|
|
157
|
+
if (args.pluginData?.ursaIsland) return undefined; // our own build.resolve
|
|
158
|
+
if (args.namespace !== "file" || !args.importer.endsWith(".mdx")) return undefined;
|
|
159
|
+
if (args.path === ISLAND_MODULE || /^react(\/|$)|^react-dom(\/|$)/.test(args.path)) return undefined;
|
|
160
|
+
|
|
161
|
+
const resolved = await build.resolve(args.path, {
|
|
162
|
+
importer: args.importer,
|
|
163
|
+
resolveDir: args.resolveDir,
|
|
164
|
+
kind: args.kind,
|
|
165
|
+
pluginData: { ursaIsland: true },
|
|
166
|
+
});
|
|
167
|
+
if (resolved.errors.length > 0 || resolved.external || resolved.namespace !== "file") return undefined;
|
|
168
|
+
if (!isIslandCandidate(resolved.path)) return undefined;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
path: resolved.path,
|
|
172
|
+
namespace: ISLAND_NAMESPACE,
|
|
173
|
+
pluginData: { name: args.path.split("/").pop().replace(/\.[jt]sx?$/, "") },
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
build.onLoad({ filter: /.*/, namespace: ISLAND_NAMESPACE }, (args) => {
|
|
177
|
+
const real = JSON.stringify(args.path);
|
|
178
|
+
const name = JSON.stringify(args.pluginData?.name || "Component");
|
|
179
|
+
return {
|
|
180
|
+
contents: `
|
|
181
|
+
import __ursaInner from ${real};
|
|
182
|
+
export * from ${real};
|
|
183
|
+
import { island } from ${JSON.stringify(ISLAND_MODULE)};
|
|
184
|
+
export default island(__ursaInner, ${name});
|
|
185
|
+
`,
|
|
186
|
+
loader: "js",
|
|
187
|
+
resolveDir: dirname(args.path),
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* React 19's renderToString hoists a <link rel="preload"> for images ahead of
|
|
196
|
+
* the content. It carries the un-rewritten relative href (so it preloads
|
|
197
|
+
* nothing useful) and, sitting before the first <h1>, defeats the template's
|
|
198
|
+
* "body starts with a heading" check, which then injects a duplicate title.
|
|
199
|
+
*/
|
|
200
|
+
function stripHoistedPreloads(html) {
|
|
201
|
+
return html.replace(/<link rel="preload"[^>]*>/g, "");
|
|
202
|
+
}
|
|
203
|
+
|
|
50
204
|
/**
|
|
51
205
|
* Find _components directories by walking up from the MDX file to the source root.
|
|
52
206
|
* Returns paths from most specific (nearest) to most general (root).
|
|
@@ -121,7 +275,10 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
121
275
|
const parentDirs = componentDirs.map(d => dirname(d));
|
|
122
276
|
options.nodePaths = [...(options.nodePaths || []), ...parentDirs];
|
|
123
277
|
}
|
|
124
|
-
|
|
278
|
+
|
|
279
|
+
// Island plugin goes first so it sees component imports before mdx-bundler's resolvers
|
|
280
|
+
options.plugins = [islandPlugin(platform), ...(options.plugins || [])];
|
|
281
|
+
|
|
125
282
|
return options;
|
|
126
283
|
};
|
|
127
284
|
|
|
@@ -170,7 +327,7 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
170
327
|
const Component = getMDXComponent(serverCode);
|
|
171
328
|
|
|
172
329
|
// Render to HTML with hydration markers (renderToString vs renderToStaticMarkup)
|
|
173
|
-
const html = renderToString(React.createElement(Component));
|
|
330
|
+
const html = stripHoistedPreloads(renderToString(React.createElement(Component)));
|
|
174
331
|
|
|
175
332
|
// If hydration is not requested, return without client code
|
|
176
333
|
if (!hydrate) {
|
|
@@ -279,11 +436,18 @@ function formatMDXError(error, filePath) {
|
|
|
279
436
|
* @param {string} publicDir - Absolute path to the output public/ directory
|
|
280
437
|
* @returns {Promise<void>}
|
|
281
438
|
*/
|
|
439
|
+
// Bump when the runtime's contents change; an older runtime left in
|
|
440
|
+
// output/public/ is then rebuilt instead of reused.
|
|
441
|
+
const REACT_RUNTIME_MARKER = 'ursa-react-runtime/2';
|
|
442
|
+
|
|
282
443
|
export async function buildReactRuntime(publicDir) {
|
|
283
444
|
const outfile = join(publicDir, 'react-runtime.js');
|
|
284
445
|
|
|
285
|
-
// Skip rebuild if already exists (for incremental builds)
|
|
286
|
-
if (existsSync(outfile))
|
|
446
|
+
// Skip rebuild if an up-to-date runtime already exists (for incremental builds)
|
|
447
|
+
if (existsSync(outfile)) {
|
|
448
|
+
const existing = await readFile(outfile, 'utf8');
|
|
449
|
+
if (existing.includes(REACT_RUNTIME_MARKER)) return;
|
|
450
|
+
}
|
|
287
451
|
|
|
288
452
|
await mkdir(publicDir, { recursive: true });
|
|
289
453
|
|
|
@@ -292,11 +456,12 @@ export async function buildReactRuntime(publicDir) {
|
|
|
292
456
|
contents: `
|
|
293
457
|
import React from 'react';
|
|
294
458
|
import * as ReactDOM from 'react-dom';
|
|
295
|
-
import { hydrateRoot } from 'react-dom/client';
|
|
459
|
+
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
296
460
|
import * as _jsx_runtime from 'react/jsx-runtime';
|
|
297
461
|
window.React = React;
|
|
298
|
-
window.ReactDOM = { ...ReactDOM, hydrateRoot };
|
|
462
|
+
window.ReactDOM = { ...ReactDOM, hydrateRoot, createRoot };
|
|
299
463
|
window._jsx_runtime = _jsx_runtime;
|
|
464
|
+
window.__ursaReactRuntime = ${JSON.stringify(REACT_RUNTIME_MARKER)};
|
|
300
465
|
`,
|
|
301
466
|
resolveDir: dirname(new URL(import.meta.url).pathname),
|
|
302
467
|
loader: 'js',
|
|
@@ -313,12 +478,17 @@ export async function buildReactRuntime(publicDir) {
|
|
|
313
478
|
/**
|
|
314
479
|
* Generate the hydration script tags for an MDX page.
|
|
315
480
|
* References the locally-built React runtime instead of CDN.
|
|
316
|
-
*
|
|
481
|
+
*
|
|
482
|
+
* The bundled MDX module is rendered into a detached root purely to run the
|
|
483
|
+
* component tree: every island in it hydrates its own <ursa-island> element in
|
|
484
|
+
* the live document (see islandRuntimeSource). Nothing outside those elements
|
|
485
|
+
* is handed to React, so the template's DOM post-processing (sectionify,
|
|
486
|
+
* breadcrumbs, TOC) cannot cause a hydration mismatch.
|
|
487
|
+
*
|
|
317
488
|
* @param {string} clientCode - The bundled MDX client code from renderMDX
|
|
318
|
-
* @param {string} [containerId='main-content'] - The ID of the container element to hydrate
|
|
319
489
|
* @returns {string} HTML script tags to include in the page
|
|
320
490
|
*/
|
|
321
|
-
export function generateHydrationScript(clientCode
|
|
491
|
+
export function generateHydrationScript(clientCode) {
|
|
322
492
|
// Escape the code for embedding in a script tag
|
|
323
493
|
const escapedCode = clientCode
|
|
324
494
|
.replace(/\\/g, '\\\\')
|
|
@@ -330,7 +500,7 @@ export function generateHydrationScript(clientCode, containerId = 'main-content'
|
|
|
330
500
|
<!-- React runtime for MDX hydration (built from node_modules) -->
|
|
331
501
|
<script src="/public/react-runtime.js"></script>
|
|
332
502
|
|
|
333
|
-
<!-- MDX
|
|
503
|
+
<!-- MDX island hydration -->
|
|
334
504
|
<script>
|
|
335
505
|
(function() {
|
|
336
506
|
// getMDXComponent: matches mdx-bundler/client calling convention.
|
|
@@ -344,23 +514,30 @@ export function generateHydrationScript(clientCode, containerId = 'main-content'
|
|
|
344
514
|
var mdxExport = fn(React, ReactDOM, _jsx_runtime);
|
|
345
515
|
return mdxExport.default;
|
|
346
516
|
}
|
|
347
|
-
|
|
348
|
-
// Hydrate when DOM is ready
|
|
517
|
+
|
|
518
|
+
// Hydrate when DOM is ready. Running after the template's own
|
|
519
|
+
// DOMContentLoaded handlers is fine: islands are found by id, so it
|
|
520
|
+
// does not matter where sectionify has moved them.
|
|
349
521
|
function hydrate() {
|
|
350
522
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
523
|
+
if (!window.ReactDOM || !window.ReactDOM.createRoot) {
|
|
524
|
+
console.error('MDX hydration: React runtime not loaded');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
var islands = document.querySelectorAll('${ISLAND_TAG}[data-island]');
|
|
528
|
+
if (islands.length === 0) {
|
|
529
|
+
console.log('MDX hydration: no islands on this page');
|
|
354
530
|
return;
|
|
355
531
|
}
|
|
356
|
-
|
|
532
|
+
|
|
357
533
|
// MDX bundled code (compiled by mdx-bundler)
|
|
358
534
|
var mdxCode = \`${escapedCode}\`;
|
|
359
535
|
var Component = getMDXComponent(mdxCode);
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
536
|
+
|
|
537
|
+
// Render into a detached root; each island hydrates itself in place.
|
|
538
|
+
var detached = document.createElement('div');
|
|
539
|
+
window.ReactDOM.createRoot(detached).render(window.React.createElement(Component));
|
|
540
|
+
console.log('MDX hydration: ' + islands.length + ' island(s)');
|
|
364
541
|
} catch (err) {
|
|
365
542
|
console.error('MDX hydration error:', err);
|
|
366
543
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { realpath, stat } from "fs/promises";
|
|
4
|
+
import { relative, resolve, sep } from "path";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// When was a source document last edited?
|
|
10
|
+
//
|
|
11
|
+
// The recent-activity feed needs a per-document "last edited" time that does
|
|
12
|
+
// not depend on when ursa happened to run. Two sources, in order:
|
|
13
|
+
//
|
|
14
|
+
// 1. git: the commit date of the last commit that touched the file. One
|
|
15
|
+
// `git log --name-only` pass over the source directory yields every file's
|
|
16
|
+
// latest commit in a single process, which is far cheaper than a `git log`
|
|
17
|
+
// per file. Files with uncommitted changes (per `git status`) are taken
|
|
18
|
+
// from the working tree instead, since the commit time predates the edit.
|
|
19
|
+
// 2. filesystem mtime, for sources that are not in a git work tree, files git
|
|
20
|
+
// does not know about, and the uncommitted files above.
|
|
21
|
+
//
|
|
22
|
+
// A shallow clone (CI checkouts default to depth 1) truncates history, so
|
|
23
|
+
// every file appears to have been edited in the one fetched commit. That is
|
|
24
|
+
// detected and warned about; the fix is on the checkout side (fetch-depth: 0).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
async function git(cwd, args) {
|
|
28
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
29
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
return stdout;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse `git log --format=%x00%ct --name-only` output into a map of
|
|
36
|
+
* repo-relative path → last-commit time in ms. The log is newest-first, so the
|
|
37
|
+
* first commit a path appears under is its latest.
|
|
38
|
+
* @param {string} log
|
|
39
|
+
* @returns {Map<string, number>}
|
|
40
|
+
*/
|
|
41
|
+
export function parseGitLog(log) {
|
|
42
|
+
const times = new Map();
|
|
43
|
+
let current = 0;
|
|
44
|
+
for (const line of log.split("\n")) {
|
|
45
|
+
if (line.startsWith("\0")) {
|
|
46
|
+
current = Number(line.slice(1)) * 1000;
|
|
47
|
+
} else if (line && !times.has(line)) {
|
|
48
|
+
times.set(line, current);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return times;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse `git status --porcelain -z` output into the set of repo-relative paths
|
|
56
|
+
* with uncommitted changes (modified, added, untracked, renamed — any status).
|
|
57
|
+
* @param {string} status
|
|
58
|
+
* @returns {Set<string>}
|
|
59
|
+
*/
|
|
60
|
+
export function parseGitStatus(status) {
|
|
61
|
+
const dirty = new Set();
|
|
62
|
+
const entries = status.split("\0");
|
|
63
|
+
for (let i = 0; i < entries.length; i++) {
|
|
64
|
+
const entry = entries[i];
|
|
65
|
+
if (entry.length < 4) continue;
|
|
66
|
+
const code = entry.slice(0, 2);
|
|
67
|
+
dirty.add(entry.slice(3));
|
|
68
|
+
// A rename entry ("R new\0old") is followed by the original path
|
|
69
|
+
if (code[0] === "R" || code[0] === "C") i++;
|
|
70
|
+
}
|
|
71
|
+
return dirty;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a lookup of last-edited times for files under `sourceDir`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} sourceDir - Absolute path to the source directory
|
|
78
|
+
* @param {object} [options]
|
|
79
|
+
* @param {(msg: string) => void} [options.log] - Receives one-line status/warnings
|
|
80
|
+
* @returns {Promise<{ get: (file: string) => Promise<number>, source: 'git'|'mtime' }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function buildSourceTimestampIndex(sourceDir, { log = () => {} } = {}) {
|
|
83
|
+
const dir = resolve(sourceDir);
|
|
84
|
+
|
|
85
|
+
async function mtime(file) {
|
|
86
|
+
try {
|
|
87
|
+
return (await stat(file)).mtimeMs;
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// git reports the real path of the work tree; resolve symlinks (macOS
|
|
94
|
+
// /var → /private/var, for one) so paths line up with what git prints.
|
|
95
|
+
let realDir = dir;
|
|
96
|
+
let toplevel = null;
|
|
97
|
+
try {
|
|
98
|
+
realDir = await realpath(dir);
|
|
99
|
+
toplevel = (await git(realDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
100
|
+
} catch {
|
|
101
|
+
// Not a git work tree, or git is not installed
|
|
102
|
+
}
|
|
103
|
+
if (!toplevel) {
|
|
104
|
+
return { get: mtime, source: "mtime" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let committed;
|
|
108
|
+
let dirty;
|
|
109
|
+
const relSource = relative(toplevel, realDir) || ".";
|
|
110
|
+
try {
|
|
111
|
+
const [logOut, statusOut, shallow] = await Promise.all([
|
|
112
|
+
git(toplevel, ["log", "--format=%x00%ct", "--name-only", "--", relSource]),
|
|
113
|
+
git(toplevel, ["status", "--porcelain", "-z", "--untracked-files=all", "--", relSource]),
|
|
114
|
+
git(toplevel, ["rev-parse", "--is-shallow-repository"]),
|
|
115
|
+
]);
|
|
116
|
+
committed = parseGitLog(logOut);
|
|
117
|
+
dirty = parseGitStatus(statusOut);
|
|
118
|
+
if (shallow.trim() === "true") {
|
|
119
|
+
log(
|
|
120
|
+
"⚠️ Source is a shallow git clone: every document appears last edited in the one fetched commit, " +
|
|
121
|
+
"so recent activity will be inaccurate. Fetch full history (e.g. actions/checkout fetch-depth: 0)."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
log(`⚠️ git history unavailable (${e.message.split("\n")[0]}); using file mtimes for recent activity`);
|
|
126
|
+
return { get: mtime, source: "mtime" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
source: "git",
|
|
131
|
+
async get(file) {
|
|
132
|
+
// Repo-relative path as git prints it: forward slashes on every platform
|
|
133
|
+
const fromSource = relative(dir, resolve(file));
|
|
134
|
+
const rel = (relSource === "." ? fromSource : `${relSource}/${fromSource}`).split(sep).join("/");
|
|
135
|
+
if (dirty.has(rel) || !committed.has(rel)) return mtime(file);
|
|
136
|
+
return committed.get(rel);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/helper/ursaConfig.js
CHANGED
|
@@ -56,56 +56,10 @@ export function getAndIncrementBuildId(sourceDir) {
|
|
|
56
56
|
const newBuildId = currentBuildId + 1;
|
|
57
57
|
|
|
58
58
|
config.buildId = newBuildId;
|
|
59
|
+
// Dropped in 0.96.0: last-edited times now come from git/mtime, not the
|
|
60
|
+
// build. Remove the stale map so it stops taking up the file.
|
|
61
|
+
delete config.contentTimestamps;
|
|
59
62
|
saveUrsaConfig(sourceDir, config);
|
|
60
63
|
|
|
61
64
|
return newBuildId;
|
|
62
65
|
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Load content timestamps from .ursa.json
|
|
66
|
-
* These track when each file's content actually changed (not filesystem mtime)
|
|
67
|
-
* @param {string} sourceDir - The source directory path
|
|
68
|
-
* @returns {Map<string, number>} Map of relative file paths to timestamps
|
|
69
|
-
*/
|
|
70
|
-
export function loadContentTimestamps(sourceDir) {
|
|
71
|
-
const config = loadUrsaConfig(sourceDir);
|
|
72
|
-
const timestamps = config.contentTimestamps || {};
|
|
73
|
-
return new Map(Object.entries(timestamps));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Save content timestamps to .ursa.json
|
|
78
|
-
* @param {string} sourceDir - The source directory path
|
|
79
|
-
* @param {Map<string, number>} timestampMap - Map of relative file paths to timestamps
|
|
80
|
-
*/
|
|
81
|
-
export function saveContentTimestamps(sourceDir, timestampMap) {
|
|
82
|
-
const config = loadUrsaConfig(sourceDir);
|
|
83
|
-
config.contentTimestamps = Object.fromEntries(timestampMap);
|
|
84
|
-
saveUrsaConfig(sourceDir, config);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Update the content timestamp for a single file
|
|
89
|
-
* @param {string} sourceDir - The source directory path
|
|
90
|
-
* @param {string} relativePath - The relative file path
|
|
91
|
-
* @param {number} timestamp - The timestamp when content changed
|
|
92
|
-
*/
|
|
93
|
-
export function updateContentTimestamp(sourceDir, relativePath, timestamp) {
|
|
94
|
-
const config = loadUrsaConfig(sourceDir);
|
|
95
|
-
if (!config.contentTimestamps) {
|
|
96
|
-
config.contentTimestamps = {};
|
|
97
|
-
}
|
|
98
|
-
config.contentTimestamps[relativePath] = timestamp;
|
|
99
|
-
saveUrsaConfig(sourceDir, config);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Get the content timestamp for a file, or null if not tracked
|
|
104
|
-
* @param {string} sourceDir - The source directory path
|
|
105
|
-
* @param {string} relativePath - The relative file path
|
|
106
|
-
* @returns {number|null} The timestamp or null
|
|
107
|
-
*/
|
|
108
|
-
export function getContentTimestamp(sourceDir, relativePath) {
|
|
109
|
-
const config = loadUrsaConfig(sourceDir);
|
|
110
|
-
return config.contentTimestamps?.[relativePath] || null;
|
|
111
|
-
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
markInactiveLinks,
|
|
29
29
|
resolveRelativeUrls,
|
|
30
30
|
} from "../helper/linkValidator.js";
|
|
31
|
-
import { getAndIncrementBuildId
|
|
31
|
+
import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
|
|
32
|
+
import { buildSourceTimestampIndex } from "../helper/sourceTimestamps.js";
|
|
32
33
|
import { extractSections } from "../helper/sectionExtractor.js";
|
|
33
34
|
import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
|
|
34
35
|
import { buildReactRuntime } from "../helper/mdxRenderer.js";
|
|
@@ -418,11 +419,11 @@ export async function generate({
|
|
|
418
419
|
progress.stopTimer('Cache');
|
|
419
420
|
}
|
|
420
421
|
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
progress.logTimed(`
|
|
422
|
+
// Last-edited times for the recent-activity feed come from git history (or
|
|
423
|
+
// file mtimes outside git), never from when ursa ran, so --clean cannot
|
|
424
|
+
// stamp every document with the build time.
|
|
425
|
+
const sourceTimestamps = await buildSourceTimestampIndex(source, { log: (m) => progress.log(m) });
|
|
426
|
+
progress.logTimed(`Source timestamps: ${sourceTimestamps.source}`);
|
|
426
427
|
profiler.endPhase('Load cache');
|
|
427
428
|
|
|
428
429
|
// Phase: Copy meta/public files
|
|
@@ -673,24 +674,11 @@ export async function generate({
|
|
|
673
674
|
content: rawBody
|
|
674
675
|
});
|
|
675
676
|
|
|
676
|
-
// Collect
|
|
677
|
-
// Use stored content timestamp if available, otherwise fall back to file mtime
|
|
678
|
-
// Content timestamps track when content actually changed, not filesystem mtime
|
|
679
|
-
const storedTimestamp = contentTimestamps.get(relativePath);
|
|
680
|
-
let activityTimestamp = storedTimestamp;
|
|
681
|
-
if (!activityTimestamp) {
|
|
682
|
-
// No stored timestamp - use file mtime as initial value
|
|
683
|
-
try {
|
|
684
|
-
const fileStat = await stat(file);
|
|
685
|
-
activityTimestamp = fileStat.mtimeMs;
|
|
686
|
-
} catch (e) {
|
|
687
|
-
activityTimestamp = 0;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
677
|
+
// Collect last-edited time for recent activity tracking
|
|
690
678
|
recentActivity.push({
|
|
691
679
|
title: title,
|
|
692
680
|
url: searchUrl,
|
|
693
|
-
mtime:
|
|
681
|
+
mtime: await sourceTimestamps.get(file)
|
|
694
682
|
});
|
|
695
683
|
|
|
696
684
|
// Check if a corresponding .html file already exists in source directory
|
|
@@ -1040,14 +1028,6 @@ export async function generate({
|
|
|
1040
1028
|
|
|
1041
1029
|
// Update the content hash for this file
|
|
1042
1030
|
updateHash(file, rawBody, hashCache);
|
|
1043
|
-
|
|
1044
|
-
// Update content timestamp since this file was regenerated (content changed)
|
|
1045
|
-
contentTimestamps.set(relativePath, buildTimestamp);
|
|
1046
|
-
// Also update the recentActivity entry we pushed earlier with the new timestamp
|
|
1047
|
-
const activityEntry = recentActivity.find(e => e.url === searchUrl);
|
|
1048
|
-
if (activityEntry) {
|
|
1049
|
-
activityEntry.mtime = buildTimestamp;
|
|
1050
|
-
}
|
|
1051
1031
|
} catch (e) {
|
|
1052
1032
|
progress.log(`Error processing ${file}: ${e.message}`);
|
|
1053
1033
|
errors.push({ file, phase: 'article-generation', error: e });
|
|
@@ -1371,12 +1351,6 @@ export async function generate({
|
|
|
1371
1351
|
await saveHashCache(source, hashCache);
|
|
1372
1352
|
}
|
|
1373
1353
|
|
|
1374
|
-
// Save content timestamps to .ursa.json (tracks when content actually changed)
|
|
1375
|
-
if (contentTimestamps.size > 0) {
|
|
1376
|
-
saveContentTimestamps(source, contentTimestamps);
|
|
1377
|
-
progress.log(`Saved ${contentTimestamps.size} content timestamps`);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
1354
|
// Persist the dependency tracker so hash-skipped documents keep their
|
|
1381
1355
|
// edges on the next warm start (invalidation plans stay accurate).
|
|
1382
1356
|
//
|
|
@@ -1859,9 +1833,10 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1859
1833
|
// Update hash cache
|
|
1860
1834
|
updateHash(changedFile, rawBody, hashCache);
|
|
1861
1835
|
|
|
1862
|
-
// Update recent-activity.json with this file's
|
|
1836
|
+
// Update recent-activity.json with this file's last-edited time
|
|
1863
1837
|
try {
|
|
1864
|
-
const
|
|
1838
|
+
const sourceTimestamps = await buildSourceTimestampIndex(source);
|
|
1839
|
+
const now = await sourceTimestamps.get(changedFile);
|
|
1865
1840
|
const recentActivityPath = join(output, 'public', 'recent-activity.json');
|
|
1866
1841
|
let recentActivity = [];
|
|
1867
1842
|
try {
|
|
@@ -1870,16 +1845,11 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1870
1845
|
} catch (e) { /* no existing file, start fresh */ }
|
|
1871
1846
|
// Remove old entry for this URL if present
|
|
1872
1847
|
recentActivity = recentActivity.filter(r => r.url !== url);
|
|
1873
|
-
// Add updated entry with current timestamp (content changed now)
|
|
1874
1848
|
recentActivity.push({ title, url, mtime: now });
|
|
1875
1849
|
// Sort by mtime descending, keep top 10
|
|
1876
1850
|
recentActivity.sort((a, b) => b.mtime - a.mtime);
|
|
1877
1851
|
recentActivity = recentActivity.slice(0, 10);
|
|
1878
1852
|
await outputFile(recentActivityPath, JSON.stringify(recentActivity));
|
|
1879
|
-
|
|
1880
|
-
// Also update content timestamp in .ursa.json for persistence
|
|
1881
|
-
const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
|
|
1882
|
-
updateContentTimestamp(source, relativePath, now);
|
|
1883
1853
|
} catch (e) {
|
|
1884
1854
|
// ignore recent activity update errors
|
|
1885
1855
|
}
|