@kenjura/ursa 0.80.1 → 0.81.2
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 +13 -0
- package/meta/templates/default-template/toc.js +3 -3
- package/package.json +2 -1
- package/src/dev.js +31 -7
- package/src/helper/fileRenderer.js +15 -5
- package/src/helper/frontmatterTable.js +2 -1
- package/src/helper/mdxRenderer.js +180 -39
- package/src/helper/ursaConfig.js +49 -0
- package/src/jobs/generate.js +94 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
# 0.81.2
|
|
2
|
+
2026-03-28
|
|
3
|
+
|
|
4
|
+
- MDX hydration: MDX documents now support hydration of embedded React components, allowing for interactive content within static pages.
|
|
5
|
+
|
|
6
|
+
- **Fixed Recent Activity tracking**: The Recent Activity widget now properly tracks when document content actually changed, rather than relying on file system modification times (mtime).
|
|
7
|
+
- Previously, Recent Activity used filesystem mtime, which doesn't work correctly after git clone (git doesn't preserve timestamps) or when all files are built simultaneously.
|
|
8
|
+
- Now, content change timestamps are stored in `.ursa.json` (in the source directory), which:
|
|
9
|
+
- Survives `--clean` builds (unlike the `.ursa/` cache folder)
|
|
10
|
+
- Can be committed to git to preserve wiki history across clones
|
|
11
|
+
- Falls back to file mtime for files that haven't been tracked yet (backward compatibility)
|
|
12
|
+
- Both full builds and single-file regeneration (serve/dev mode) now update content timestamps when content actually changes (detected by hash comparison).
|
|
13
|
+
|
|
1
14
|
# 0.80.1
|
|
2
15
|
2026-02-16
|
|
3
16
|
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
// === observer factory tied to current STICKY_TOP ===
|
|
44
44
|
let observer = null;
|
|
45
|
-
function (
|
|
45
|
+
function rebuildObserver() {
|
|
46
46
|
if (observer) observer.disconnect();
|
|
47
47
|
// ignore intersections near the bottom; we only care about the top line
|
|
48
48
|
const bottomRM = -(window.innerHeight - 1) + 'px';
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
|
|
73
73
|
// initial setup
|
|
74
74
|
ensureSentinels();
|
|
75
|
-
(
|
|
75
|
+
rebuildObserver();
|
|
76
76
|
updateActiveFromSentinels();
|
|
77
77
|
|
|
78
78
|
// react when sticky top changes
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
// update sentinel offsets
|
|
87
87
|
document.querySelectorAll('.toc-sentinel').forEach(s => s.style.marginTop = `-${STICKY_TOP}px`);
|
|
88
88
|
heads.forEach(h => h.style.scrollMarginTop = (STICKY_TOP + 8) + 'px');
|
|
89
|
-
(
|
|
89
|
+
rebuildObserver();
|
|
90
90
|
updateActiveFromSentinels();
|
|
91
91
|
});
|
|
92
92
|
}, { passive: true });
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@kenjura/ursa",
|
|
3
3
|
"author": "Andrew London <andrew@kenjura.com>",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.81.2",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"react-dom": "^19.2.4",
|
|
42
42
|
"remark-definition-list": "^2.0.0",
|
|
43
43
|
"remark-directive": "^4.0.0",
|
|
44
|
+
"remark-gfm": "^4.0.1",
|
|
44
45
|
"remark-supersub": "^1.0.0",
|
|
45
46
|
"sharp": "^0.33.2",
|
|
46
47
|
"unist-util-visit": "^5.1.0",
|
package/src/dev.js
CHANGED
|
@@ -19,6 +19,7 @@ const { readFile, readdir, stat, mkdir } = promises;
|
|
|
19
19
|
|
|
20
20
|
// Import helper modules
|
|
21
21
|
import { renderFileAsync } from "./helper/fileRenderer.js";
|
|
22
|
+
import { buildReactRuntime } from "./helper/mdxRenderer.js";
|
|
22
23
|
import { findStyleCss } from "./helper/findStyleCss.js";
|
|
23
24
|
import { findAllScriptJs } from "./helper/findScriptJs.js";
|
|
24
25
|
import { extractMetadata, getAutoIndexConfig, isMetadataOnly } from "./helper/metadataExtractor.js";
|
|
@@ -382,22 +383,37 @@ async function renderDocument(urlPath) {
|
|
|
382
383
|
if (autoIndexHtml) {
|
|
383
384
|
// Wrap in template and return — use parent folder name for title
|
|
384
385
|
const indexTitle = toTitleCase(basename(dirname(sourcePath)) || 'Index');
|
|
385
|
-
return await wrapInTemplate(autoIndexHtml, indexTitle, null, urlPath, sourcePath);
|
|
386
|
+
return await wrapInTemplate(autoIndexHtml, indexTitle, null, urlPath, sourcePath, '');
|
|
386
387
|
}
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
// Extract metadata first to determine if hydration is needed
|
|
391
|
+
const fileMeta = extractMetadata(rawBody);
|
|
392
|
+
|
|
389
393
|
// Render body
|
|
390
|
-
|
|
394
|
+
// For MDX files, enable hydration if frontmatter has `hydrate: true`
|
|
395
|
+
const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
|
|
396
|
+
|
|
397
|
+
let renderResult = await renderFileAsync({
|
|
391
398
|
fileContents: rawBody,
|
|
392
399
|
type,
|
|
393
400
|
dirname: dir,
|
|
394
401
|
basename: base,
|
|
395
402
|
filePath: sourcePath,
|
|
396
403
|
sourceRoot: devState.source,
|
|
397
|
-
useWorker: false // Use main thread for faster single-file processing
|
|
404
|
+
useWorker: false, // Use main thread for faster single-file processing
|
|
405
|
+
hydrate: shouldHydrate,
|
|
398
406
|
});
|
|
399
407
|
|
|
400
|
-
|
|
408
|
+
// Handle the result - can be string or { html, hydrationScript }
|
|
409
|
+
let body;
|
|
410
|
+
let hydrationScript = '';
|
|
411
|
+
if (typeof renderResult === 'object' && renderResult.html) {
|
|
412
|
+
body = renderResult.html;
|
|
413
|
+
hydrationScript = renderResult.hydrationScript || '';
|
|
414
|
+
} else {
|
|
415
|
+
body = renderResult;
|
|
416
|
+
}
|
|
401
417
|
|
|
402
418
|
// Title from filename (for index/home, use parent folder name)
|
|
403
419
|
const titleBase = (base === 'index' || base === 'home') ? basename(dirname(sourcePath)) : base;
|
|
@@ -438,7 +454,7 @@ async function renderDocument(urlPath) {
|
|
|
438
454
|
// Process images in this document
|
|
439
455
|
body = await processDocumentImages(body, sourcePath);
|
|
440
456
|
|
|
441
|
-
const html = await wrapInTemplate(body, title, fileMeta, urlPath, sourcePath);
|
|
457
|
+
const html = await wrapInTemplate(body, title, fileMeta, urlPath, sourcePath, hydrationScript);
|
|
442
458
|
|
|
443
459
|
// Cache rendered document
|
|
444
460
|
// documentCache.set(urlPath, html);
|
|
@@ -449,7 +465,7 @@ async function renderDocument(urlPath) {
|
|
|
449
465
|
/**
|
|
450
466
|
* Wrap body in template
|
|
451
467
|
*/
|
|
452
|
-
async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
|
|
468
|
+
async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath, hydrationScript = '') {
|
|
453
469
|
const { source, output, templates, menuHtml, footer, validPaths, customMenus } = devState;
|
|
454
470
|
|
|
455
471
|
// Get template
|
|
@@ -504,6 +520,11 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
|
|
|
504
520
|
// Calculate document URL path
|
|
505
521
|
const docUrlPath = urlPath.endsWith('.html') ? urlPath : urlPath + '.html';
|
|
506
522
|
|
|
523
|
+
// Append hydration script to customScript if present (for MDX with hydrate: true)
|
|
524
|
+
const finalCustomScript = hydrationScript
|
|
525
|
+
? customScript + '\n' + hydrationScript
|
|
526
|
+
: customScript;
|
|
527
|
+
|
|
507
528
|
// Build replacements
|
|
508
529
|
const replacements = {
|
|
509
530
|
"${title}": fileMeta?.title || title,
|
|
@@ -512,7 +533,7 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
|
|
|
512
533
|
"${transformedMetadata}": "",
|
|
513
534
|
"${body}": body,
|
|
514
535
|
"${styleLink}": styleLink,
|
|
515
|
-
"${customScript}":
|
|
536
|
+
"${customScript}": finalCustomScript,
|
|
516
537
|
"${searchIndex}": "[]",
|
|
517
538
|
"${footer}": footer || ""
|
|
518
539
|
};
|
|
@@ -765,6 +786,9 @@ export async function dev({
|
|
|
765
786
|
devState.templates = await bundleMetaTemplateAssets(rawTemplates, metaDir, publicDir, { minify: true, sourcemap: false });
|
|
766
787
|
console.log('📦 Meta template assets bundled');
|
|
767
788
|
|
|
789
|
+
// Build React runtime for MDX hydration (React 19 has no UMD, so we bundle locally)
|
|
790
|
+
await buildReactRuntime(publicDir);
|
|
791
|
+
|
|
768
792
|
// Start server immediately
|
|
769
793
|
const app = express();
|
|
770
794
|
const httpServer = createServer(app);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import { markdownToHtml } from "./markdownHelper.cjs";
|
|
3
3
|
import { wikiToHtml } from "./wikitextHelper.js";
|
|
4
|
-
import { renderMDX } from "./mdxRenderer.js";
|
|
4
|
+
import { renderMDX, generateHydrationScript } from "./mdxRenderer.js";
|
|
5
5
|
import { parseWithWorker, terminateParserPool } from "./parserPool.js";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_WIKITEXT_ARGS = { db: "noDB", noSection: true, noTOC: true };
|
|
@@ -39,10 +39,11 @@ export function renderFile({ fileContents, type, dirname, basename }) {
|
|
|
39
39
|
* @param {string} options.basename - Base filename
|
|
40
40
|
* @param {string} [options.filePath] - Absolute path to file (required for .mdx)
|
|
41
41
|
* @param {string} [options.sourceRoot] - Source root directory (for .mdx absolute imports)
|
|
42
|
-
* @param {boolean} options.useWorker - Whether to attempt worker thread parsing
|
|
43
|
-
* @
|
|
42
|
+
* @param {boolean} [options.useWorker=true] - Whether to attempt worker thread parsing
|
|
43
|
+
* @param {boolean} [options.hydrate=false] - Whether to enable client-side hydration (.mdx only)
|
|
44
|
+
* @returns {Promise<string|{html: string, hydrationScript?: string}>} Rendered HTML or object with HTML and hydration script
|
|
44
45
|
*/
|
|
45
|
-
export async function renderFileAsync({ fileContents, type, dirname, basename, filePath, sourceRoot, useWorker = true }) {
|
|
46
|
+
export async function renderFileAsync({ fileContents, type, dirname, basename, filePath, sourceRoot, useWorker = true, hydrate = false }) {
|
|
46
47
|
// Wikitext always runs on main thread due to complex ES module dependencies
|
|
47
48
|
if (type === ".txt") {
|
|
48
49
|
return wikiToHtml({
|
|
@@ -70,7 +71,16 @@ export async function renderFileAsync({ fileContents, type, dirname, basename, f
|
|
|
70
71
|
// Falls back to markdown rendering if MDX compilation fails
|
|
71
72
|
if (type === ".mdx") {
|
|
72
73
|
try {
|
|
73
|
-
const result = await renderMDX({ source: fileContents, filePath, sourceRoot });
|
|
74
|
+
const result = await renderMDX({ source: fileContents, filePath, sourceRoot, hydrate });
|
|
75
|
+
|
|
76
|
+
// If hydration was requested and we have client code, return object with hydration script
|
|
77
|
+
if (hydrate && result.clientCode) {
|
|
78
|
+
return {
|
|
79
|
+
html: result.html,
|
|
80
|
+
hydrationScript: generateHydrationScript(result.clientCode),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
return result.html;
|
|
75
85
|
} catch (mdxError) {
|
|
76
86
|
// Extract a concise error description for the warning banner
|
|
@@ -74,7 +74,8 @@ export function metadataToTable(metadata) {
|
|
|
74
74
|
'menu-sort-as', // Custom sort key for menu ordering
|
|
75
75
|
'generate-auto-index', // Auto-indexing control
|
|
76
76
|
'auto-index-depth', // Auto-indexing depth
|
|
77
|
-
'auto-index-position'
|
|
77
|
+
'auto-index-position', // Auto-indexing position
|
|
78
|
+
'hydrate' // MDX hydration control
|
|
78
79
|
];
|
|
79
80
|
const entries = Object.entries(metadata).filter(
|
|
80
81
|
([key]) => !excludeKeys.includes(key.toLowerCase())
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { bundleMDX } from "mdx-bundler";
|
|
2
2
|
import { getMDXComponent } from "mdx-bundler/client/index.js";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import * as esbuild from "esbuild";
|
|
5
6
|
import { dirname, join, resolve } from "path";
|
|
6
7
|
import { existsSync } from "fs";
|
|
8
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
7
9
|
import remarkDirective from "remark-directive";
|
|
8
10
|
import { remarkDefinitionList, defListHastHandlers } from "remark-definition-list";
|
|
9
11
|
import remarkSupersub from "remark-supersub";
|
|
12
|
+
import remarkGfm from "remark-gfm";
|
|
10
13
|
import { visit } from "unist-util-visit";
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -54,10 +57,10 @@ function findComponentDirs(startDir, sourceRoot) {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* Render an MDX file to
|
|
60
|
+
* Render an MDX file to HTML with optional client-side hydration support.
|
|
58
61
|
*
|
|
59
62
|
* Uses mdx-bundler to compile MDX source (with component imports resolved via esbuild),
|
|
60
|
-
* then renders the resulting React component to
|
|
63
|
+
* then renders the resulting React component to HTML using react-dom/server.
|
|
61
64
|
*
|
|
62
65
|
* Supports a `_components/` directory convention: any `_components/` folder found in
|
|
63
66
|
* the MDX file's directory or any parent directory (up to sourceRoot) will be added
|
|
@@ -68,13 +71,18 @@ function findComponentDirs(startDir, sourceRoot) {
|
|
|
68
71
|
* @param {string} options.source - Raw MDX file contents
|
|
69
72
|
* @param {string} options.filePath - Absolute path to the MDX file (used for import resolution)
|
|
70
73
|
* @param {string} [options.sourceRoot] - Root directory of the source files (for absolute imports)
|
|
71
|
-
* @
|
|
74
|
+
* @param {boolean} [options.hydrate=false] - If true, includes client bundle for hydration
|
|
75
|
+
* @returns {Promise<{ html: string, frontmatter: Record<string, any>, clientCode?: string }>}
|
|
72
76
|
*/
|
|
73
|
-
export async function renderMDX({ source, filePath, sourceRoot }) {
|
|
77
|
+
export async function renderMDX({ source, filePath, sourceRoot, hydrate = false }) {
|
|
74
78
|
const cwd = dirname(filePath);
|
|
75
79
|
const componentDirs = findComponentDirs(cwd, sourceRoot);
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Create esbuild options for the given platform
|
|
83
|
+
* @param {'node'|'browser'} platform - Target platform
|
|
84
|
+
*/
|
|
85
|
+
const createEsbuildOptions = (platform) => (options) => {
|
|
78
86
|
// Enable loaders for TypeScript/JSX component files
|
|
79
87
|
options.loader = {
|
|
80
88
|
...options.loader,
|
|
@@ -83,9 +91,9 @@ export async function renderMDX({ source, filePath, sourceRoot }) {
|
|
|
83
91
|
".tsx": "tsx",
|
|
84
92
|
".jsx": "jsx",
|
|
85
93
|
};
|
|
86
|
-
// Set target
|
|
94
|
+
// Set target based on platform
|
|
87
95
|
options.target = "es2020";
|
|
88
|
-
options.platform =
|
|
96
|
+
options.platform = platform;
|
|
89
97
|
|
|
90
98
|
// Add _components directories as resolve paths so imports like
|
|
91
99
|
// '_components/Foo.tsx' resolve without relative path prefixes
|
|
@@ -98,46 +106,72 @@ export async function renderMDX({ source, filePath, sourceRoot }) {
|
|
|
98
106
|
return options;
|
|
99
107
|
};
|
|
100
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Create MDX options with remark plugins
|
|
111
|
+
*/
|
|
112
|
+
const createMdxOptions = () => (options) => {
|
|
113
|
+
// Add remark plugins matching the markdown-it extensions in markdownHelper.cjs:
|
|
114
|
+
// - remarkGfm: adds GFM support (tables, strikethrough, autolinks, task lists)
|
|
115
|
+
// - remarkDirective: parses :::name container syntax into AST nodes
|
|
116
|
+
// - remarkAsideContainers: converts container directives to <aside> elements
|
|
117
|
+
// - remarkDefinitionList: adds PHP Markdown Extra style definition lists (Term\n: Def)
|
|
118
|
+
// - remarkSupersub: adds ^superscript^ and ~subscript~ syntax
|
|
119
|
+
options.remarkPlugins = [
|
|
120
|
+
...(options.remarkPlugins || []),
|
|
121
|
+
remarkGfm,
|
|
122
|
+
remarkDirective,
|
|
123
|
+
remarkAsideContainers,
|
|
124
|
+
remarkDefinitionList,
|
|
125
|
+
remarkSupersub,
|
|
126
|
+
];
|
|
127
|
+
// remark-definition-list needs custom handlers for remark-rehype conversion
|
|
128
|
+
options.remarkRehypeOptions = {
|
|
129
|
+
...(options.remarkRehypeOptions || {}),
|
|
130
|
+
handlers: {
|
|
131
|
+
...(options.remarkRehypeOptions?.handlers || {}),
|
|
132
|
+
...defListHastHandlers,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
return options;
|
|
136
|
+
};
|
|
137
|
+
|
|
101
138
|
try {
|
|
102
|
-
|
|
139
|
+
// Server-side bundle (for SSR)
|
|
140
|
+
const serverResult = await bundleMDX({
|
|
103
141
|
source,
|
|
104
142
|
cwd,
|
|
105
|
-
esbuildOptions,
|
|
106
|
-
|
|
107
|
-
mdxOptions(options) {
|
|
108
|
-
// Add remark plugins matching the markdown-it extensions in markdownHelper.cjs:
|
|
109
|
-
// - remarkDirective: parses :::name container syntax into AST nodes
|
|
110
|
-
// - remarkAsideContainers: converts container directives to <aside> elements
|
|
111
|
-
// - remarkDefinitionList: adds PHP Markdown Extra style definition lists (Term\n: Def)
|
|
112
|
-
// - remarkSupersub: adds ^superscript^ and ~subscript~ syntax
|
|
113
|
-
options.remarkPlugins = [
|
|
114
|
-
...(options.remarkPlugins || []),
|
|
115
|
-
remarkDirective,
|
|
116
|
-
remarkAsideContainers,
|
|
117
|
-
remarkDefinitionList,
|
|
118
|
-
remarkSupersub,
|
|
119
|
-
];
|
|
120
|
-
// remark-definition-list needs custom handlers for remark-rehype conversion
|
|
121
|
-
options.remarkRehypeOptions = {
|
|
122
|
-
...(options.remarkRehypeOptions || {}),
|
|
123
|
-
handlers: {
|
|
124
|
-
...(options.remarkRehypeOptions?.handlers || {}),
|
|
125
|
-
...defListHastHandlers,
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
return options;
|
|
129
|
-
},
|
|
143
|
+
esbuildOptions: createEsbuildOptions('node'),
|
|
144
|
+
mdxOptions: createMdxOptions(),
|
|
130
145
|
});
|
|
131
146
|
|
|
132
|
-
const { code, frontmatter } =
|
|
147
|
+
const { code: serverCode, frontmatter } = serverResult;
|
|
133
148
|
|
|
134
149
|
// getMDXComponent evaluates the bundled code and returns a React component
|
|
135
|
-
const Component = getMDXComponent(
|
|
150
|
+
const Component = getMDXComponent(serverCode);
|
|
151
|
+
|
|
152
|
+
// Render to HTML with hydration markers (renderToString vs renderToStaticMarkup)
|
|
153
|
+
const html = renderToString(React.createElement(Component));
|
|
154
|
+
|
|
155
|
+
// If hydration is not requested, return without client code
|
|
156
|
+
if (!hydrate) {
|
|
157
|
+
return { html, frontmatter: frontmatter || {} };
|
|
158
|
+
}
|
|
136
159
|
|
|
137
|
-
//
|
|
138
|
-
|
|
160
|
+
// Client-side bundle (for hydration)
|
|
161
|
+
// Same settings as server — mdx-bundler output is platform-agnostic
|
|
162
|
+
// (it references React/ReactDOM/jsx-runtime via function parameters, not imports)
|
|
163
|
+
const clientResult = await bundleMDX({
|
|
164
|
+
source,
|
|
165
|
+
cwd,
|
|
166
|
+
esbuildOptions: createEsbuildOptions('browser'),
|
|
167
|
+
mdxOptions: createMdxOptions(),
|
|
168
|
+
});
|
|
139
169
|
|
|
140
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
html,
|
|
172
|
+
frontmatter: frontmatter || {},
|
|
173
|
+
clientCode: clientResult.code,
|
|
174
|
+
};
|
|
141
175
|
} catch (error) {
|
|
142
176
|
throw formatMDXError(error, filePath);
|
|
143
177
|
}
|
|
@@ -213,3 +247,110 @@ function formatMDXError(error, filePath) {
|
|
|
213
247
|
wrappedError.originalError = error;
|
|
214
248
|
return wrappedError;
|
|
215
249
|
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build a local React runtime bundle for client-side hydration.
|
|
253
|
+
*
|
|
254
|
+
* Uses esbuild to bundle React + ReactDOM from node_modules into a single
|
|
255
|
+
* browser-ready file that sets window.React and window.ReactDOM globals.
|
|
256
|
+
* This replaces the previous CDN approach (unpkg) which broke with React 19
|
|
257
|
+
* since React 19 removed UMD builds.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} publicDir - Absolute path to the output public/ directory
|
|
260
|
+
* @returns {Promise<void>}
|
|
261
|
+
*/
|
|
262
|
+
export async function buildReactRuntime(publicDir) {
|
|
263
|
+
const outfile = join(publicDir, 'react-runtime.js');
|
|
264
|
+
|
|
265
|
+
// Skip rebuild if already exists (for incremental builds)
|
|
266
|
+
if (existsSync(outfile)) return;
|
|
267
|
+
|
|
268
|
+
await mkdir(publicDir, { recursive: true });
|
|
269
|
+
|
|
270
|
+
await esbuild.build({
|
|
271
|
+
stdin: {
|
|
272
|
+
contents: `
|
|
273
|
+
import React from 'react';
|
|
274
|
+
import * as ReactDOM from 'react-dom';
|
|
275
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
276
|
+
import * as _jsx_runtime from 'react/jsx-runtime';
|
|
277
|
+
window.React = React;
|
|
278
|
+
window.ReactDOM = { ...ReactDOM, hydrateRoot };
|
|
279
|
+
window._jsx_runtime = _jsx_runtime;
|
|
280
|
+
`,
|
|
281
|
+
resolveDir: dirname(new URL(import.meta.url).pathname),
|
|
282
|
+
loader: 'js',
|
|
283
|
+
},
|
|
284
|
+
bundle: true,
|
|
285
|
+
format: 'iife',
|
|
286
|
+
platform: 'browser',
|
|
287
|
+
target: 'es2020',
|
|
288
|
+
minify: true,
|
|
289
|
+
outfile,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Generate the hydration script tags for an MDX page.
|
|
295
|
+
* References the locally-built React runtime instead of CDN.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} clientCode - The bundled MDX client code from renderMDX
|
|
298
|
+
* @param {string} [containerId='main-content'] - The ID of the container element to hydrate
|
|
299
|
+
* @returns {string} HTML script tags to include in the page
|
|
300
|
+
*/
|
|
301
|
+
export function generateHydrationScript(clientCode, containerId = 'main-content') {
|
|
302
|
+
// Escape the code for embedding in a script tag
|
|
303
|
+
const escapedCode = clientCode
|
|
304
|
+
.replace(/\\/g, '\\\\')
|
|
305
|
+
.replace(/`/g, '\\`')
|
|
306
|
+
.replace(/\$\{/g, '\\${')
|
|
307
|
+
.replace(/<\/script>/gi, '<\\/script>');
|
|
308
|
+
|
|
309
|
+
return `
|
|
310
|
+
<!-- React runtime for MDX hydration (built from node_modules) -->
|
|
311
|
+
<script src="/public/react-runtime.js"></script>
|
|
312
|
+
|
|
313
|
+
<!-- MDX Hydration -->
|
|
314
|
+
<script>
|
|
315
|
+
(function() {
|
|
316
|
+
// getMDXComponent: matches mdx-bundler/client calling convention.
|
|
317
|
+
// The bundled code is a function body expecting (React, ReactDOM, _jsx_runtime)
|
|
318
|
+
// as parameters and returning { default: Component }.
|
|
319
|
+
function getMDXComponent(code) {
|
|
320
|
+
var React = window.React;
|
|
321
|
+
var ReactDOM = window.ReactDOM;
|
|
322
|
+
var _jsx_runtime = window._jsx_runtime;
|
|
323
|
+
var fn = new Function('React', 'ReactDOM', '_jsx_runtime', code);
|
|
324
|
+
var mdxExport = fn(React, ReactDOM, _jsx_runtime);
|
|
325
|
+
return mdxExport.default;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Hydrate when DOM is ready
|
|
329
|
+
function hydrate() {
|
|
330
|
+
try {
|
|
331
|
+
var container = document.getElementById('${containerId}');
|
|
332
|
+
if (!container) {
|
|
333
|
+
console.error('MDX hydration: container #${containerId} not found');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// MDX bundled code (compiled by mdx-bundler)
|
|
338
|
+
var mdxCode = \`${escapedCode}\`;
|
|
339
|
+
var Component = getMDXComponent(mdxCode);
|
|
340
|
+
|
|
341
|
+
// Use hydrateRoot (React 18+)
|
|
342
|
+
window.ReactDOM.hydrateRoot(container, window.React.createElement(Component));
|
|
343
|
+
console.log('MDX hydration complete');
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.error('MDX hydration error:', err);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (document.readyState === 'loading') {
|
|
350
|
+
document.addEventListener('DOMContentLoaded', hydrate);
|
|
351
|
+
} else {
|
|
352
|
+
hydrate();
|
|
353
|
+
}
|
|
354
|
+
})();
|
|
355
|
+
</script>`;
|
|
356
|
+
}
|
package/src/helper/ursaConfig.js
CHANGED
|
@@ -60,3 +60,52 @@ export function getAndIncrementBuildId(sourceDir) {
|
|
|
60
60
|
|
|
61
61
|
return newBuildId;
|
|
62
62
|
}
|
|
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
|
@@ -24,9 +24,10 @@ import {
|
|
|
24
24
|
markInactiveLinks,
|
|
25
25
|
resolveRelativeUrls,
|
|
26
26
|
} from "../helper/linkValidator.js";
|
|
27
|
-
import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
|
|
27
|
+
import { getAndIncrementBuildId, loadContentTimestamps, saveContentTimestamps, updateContentTimestamp } from "../helper/ursaConfig.js";
|
|
28
28
|
import { extractSections } from "../helper/sectionExtractor.js";
|
|
29
29
|
import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
|
|
30
|
+
import { buildReactRuntime } from "../helper/mdxRenderer.js";
|
|
30
31
|
import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
|
|
31
32
|
import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
|
|
32
33
|
import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMetaBundleCache, generateSeparateCssTags, generateSeparateJsTags } from "../helper/assetBundler.js";
|
|
@@ -291,6 +292,12 @@ export async function generate({
|
|
|
291
292
|
progress.logTimed(`Clean build: ignoring cached hashes`);
|
|
292
293
|
progress.stopTimer('Cache');
|
|
293
294
|
}
|
|
295
|
+
|
|
296
|
+
// Load content timestamps from .ursa.json (survives --clean)
|
|
297
|
+
// These track when content actually changed, not filesystem mtime
|
|
298
|
+
const contentTimestamps = loadContentTimestamps(source);
|
|
299
|
+
const buildTimestamp = Date.now();
|
|
300
|
+
progress.logTimed(`Loaded ${contentTimestamps.size} content timestamps`);
|
|
294
301
|
profiler.endPhase('Load cache');
|
|
295
302
|
|
|
296
303
|
// Phase: Copy meta/public files
|
|
@@ -322,6 +329,9 @@ export async function generate({
|
|
|
322
329
|
templates = await bundleMetaTemplateAssets(templates, meta, pub, { minify: true, sourcemap: false });
|
|
323
330
|
progress.logTimed(`Meta template assets bundled`);
|
|
324
331
|
|
|
332
|
+
// Build React runtime for MDX hydration (React 19 has no UMD, so we bundle locally)
|
|
333
|
+
await buildReactRuntime(pub);
|
|
334
|
+
|
|
325
335
|
// Process all CSS files in the entire output directory tree for cache-busting
|
|
326
336
|
const allOutputFiles = await recurse(output, [() => false]);
|
|
327
337
|
for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
|
|
@@ -521,17 +531,25 @@ export async function generate({
|
|
|
521
531
|
content: rawBody
|
|
522
532
|
});
|
|
523
533
|
|
|
524
|
-
// Collect
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
+
// Collect timestamp for recent activity tracking
|
|
535
|
+
// Use stored content timestamp if available, otherwise fall back to file mtime
|
|
536
|
+
// Content timestamps track when content actually changed, not filesystem mtime
|
|
537
|
+
const storedTimestamp = contentTimestamps.get(relativePath);
|
|
538
|
+
let activityTimestamp = storedTimestamp;
|
|
539
|
+
if (!activityTimestamp) {
|
|
540
|
+
// No stored timestamp - use file mtime as initial value
|
|
541
|
+
try {
|
|
542
|
+
const fileStat = await stat(file);
|
|
543
|
+
activityTimestamp = fileStat.mtimeMs;
|
|
544
|
+
} catch (e) {
|
|
545
|
+
activityTimestamp = 0;
|
|
546
|
+
}
|
|
534
547
|
}
|
|
548
|
+
recentActivity.push({
|
|
549
|
+
title: title,
|
|
550
|
+
url: searchUrl,
|
|
551
|
+
mtime: activityTimestamp
|
|
552
|
+
});
|
|
535
553
|
|
|
536
554
|
// Check if a corresponding .html file already exists in source directory
|
|
537
555
|
const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
|
@@ -587,16 +605,30 @@ export async function generate({
|
|
|
587
605
|
|
|
588
606
|
// Use async rendering with worker threads for parallel markdown parsing
|
|
589
607
|
// Wikitext (.txt) files will fall back to main thread
|
|
590
|
-
|
|
608
|
+
// For MDX files, enable hydration if frontmatter has `hydrate: true`
|
|
609
|
+
const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
|
|
610
|
+
|
|
611
|
+
let renderResult = await renderFileAsync({
|
|
591
612
|
fileContents: rawBody,
|
|
592
613
|
type,
|
|
593
614
|
dirname: dir,
|
|
594
615
|
basename: base,
|
|
595
616
|
filePath: file,
|
|
596
617
|
sourceRoot: source,
|
|
597
|
-
useWorker: true
|
|
618
|
+
useWorker: true,
|
|
619
|
+
hydrate: shouldHydrate,
|
|
598
620
|
});
|
|
599
621
|
|
|
622
|
+
// Handle the result - can be string or { html, hydrationScript }
|
|
623
|
+
let body;
|
|
624
|
+
let hydrationScript = '';
|
|
625
|
+
if (typeof renderResult === 'object' && renderResult.html) {
|
|
626
|
+
body = renderResult.html;
|
|
627
|
+
hydrationScript = renderResult.hydrationScript || '';
|
|
628
|
+
} else {
|
|
629
|
+
body = renderResult;
|
|
630
|
+
}
|
|
631
|
+
|
|
600
632
|
// Inject default H1 if body doesn't start with one
|
|
601
633
|
if (!body || !body.trimStart().startsWith('<h1')) {
|
|
602
634
|
const h1Title = fileMeta?.title || title;
|
|
@@ -738,6 +770,11 @@ export async function generate({
|
|
|
738
770
|
|
|
739
771
|
// Build final HTML with all replacements in a single regex pass
|
|
740
772
|
// This avoids creating 8 intermediate strings
|
|
773
|
+
// Append hydration script to customScript if present (for MDX with hydrate: true)
|
|
774
|
+
const finalCustomScript = hydrationScript
|
|
775
|
+
? customScript + '\n' + hydrationScript
|
|
776
|
+
: customScript;
|
|
777
|
+
|
|
741
778
|
const replacements = {
|
|
742
779
|
"${title}": fileMeta?.title || title,
|
|
743
780
|
"${menu}": menu,
|
|
@@ -745,7 +782,7 @@ export async function generate({
|
|
|
745
782
|
"${transformedMetadata}": lazyTransformedMeta,
|
|
746
783
|
"${body}": body,
|
|
747
784
|
"${styleLink}": styleLink,
|
|
748
|
-
"${customScript}":
|
|
785
|
+
"${customScript}": finalCustomScript,
|
|
749
786
|
"${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
|
|
750
787
|
"${footer}": footer
|
|
751
788
|
};
|
|
@@ -824,6 +861,14 @@ export async function generate({
|
|
|
824
861
|
|
|
825
862
|
// Update the content hash for this file
|
|
826
863
|
updateHash(file, rawBody, hashCache);
|
|
864
|
+
|
|
865
|
+
// Update content timestamp since this file was regenerated (content changed)
|
|
866
|
+
contentTimestamps.set(relativePath, buildTimestamp);
|
|
867
|
+
// Also update the recentActivity entry we pushed earlier with the new timestamp
|
|
868
|
+
const activityEntry = recentActivity.find(e => e.url === searchUrl);
|
|
869
|
+
if (activityEntry) {
|
|
870
|
+
activityEntry.mtime = buildTimestamp;
|
|
871
|
+
}
|
|
827
872
|
} catch (e) {
|
|
828
873
|
progress.log(`Error processing ${file}: ${e.message}`);
|
|
829
874
|
errors.push({ file, phase: 'article-generation', error: e });
|
|
@@ -1085,6 +1130,12 @@ export async function generate({
|
|
|
1085
1130
|
if (hashCache.size > 0) {
|
|
1086
1131
|
await saveHashCache(source, hashCache);
|
|
1087
1132
|
}
|
|
1133
|
+
|
|
1134
|
+
// Save content timestamps to .ursa.json (tracks when content actually changed)
|
|
1135
|
+
if (contentTimestamps.size > 0) {
|
|
1136
|
+
saveContentTimestamps(source, contentTimestamps);
|
|
1137
|
+
progress.log(`Saved ${contentTimestamps.size} content timestamps`);
|
|
1138
|
+
}
|
|
1088
1139
|
|
|
1089
1140
|
// Populate watch mode cache for fast single-file regeneration
|
|
1090
1141
|
watchModeCache.templates = templates;
|
|
@@ -1319,18 +1370,31 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1319
1370
|
// Calculate the document's URL path
|
|
1320
1371
|
const docUrlPath = '/' + dir + base + '.html';
|
|
1321
1372
|
|
|
1373
|
+
// Check if hydration should be enabled for MDX files
|
|
1374
|
+
const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
|
|
1375
|
+
|
|
1322
1376
|
// Render body (use async for .mdx, sync for .md/.txt)
|
|
1323
1377
|
let body;
|
|
1378
|
+
let hydrationScript = '';
|
|
1324
1379
|
if (type === '.mdx') {
|
|
1325
|
-
|
|
1380
|
+
const renderResult = await renderFileAsync({
|
|
1326
1381
|
fileContents: rawBody,
|
|
1327
1382
|
type,
|
|
1328
1383
|
dirname: dir,
|
|
1329
1384
|
basename: base,
|
|
1330
1385
|
filePath: changedFile,
|
|
1331
1386
|
sourceRoot: source,
|
|
1332
|
-
useWorker: false
|
|
1387
|
+
useWorker: false,
|
|
1388
|
+
hydrate: shouldHydrate,
|
|
1333
1389
|
});
|
|
1390
|
+
|
|
1391
|
+
// Handle the result - can be string or { html, hydrationScript }
|
|
1392
|
+
if (typeof renderResult === 'object' && renderResult.html) {
|
|
1393
|
+
body = renderResult.html;
|
|
1394
|
+
hydrationScript = renderResult.hydrationScript || '';
|
|
1395
|
+
} else {
|
|
1396
|
+
body = renderResult;
|
|
1397
|
+
}
|
|
1334
1398
|
} else {
|
|
1335
1399
|
body = renderFile({
|
|
1336
1400
|
fileContents: rawBody,
|
|
@@ -1425,6 +1489,11 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1425
1489
|
// Check if this file has a custom menu
|
|
1426
1490
|
const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
|
|
1427
1491
|
|
|
1492
|
+
// Append hydration script to customScript if present (for MDX with hydrate: true)
|
|
1493
|
+
const finalCustomScript = hydrationScript
|
|
1494
|
+
? customScript + '\n' + hydrationScript
|
|
1495
|
+
: customScript;
|
|
1496
|
+
|
|
1428
1497
|
// Build final HTML
|
|
1429
1498
|
let finalHtml = template;
|
|
1430
1499
|
const replacements = {
|
|
@@ -1434,7 +1503,7 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1434
1503
|
"${transformedMetadata}": transformedMetadata,
|
|
1435
1504
|
"${body}": body,
|
|
1436
1505
|
"${styleLink}": styleLink,
|
|
1437
|
-
"${customScript}":
|
|
1506
|
+
"${customScript}": finalCustomScript,
|
|
1438
1507
|
"${searchIndex}": "[]",
|
|
1439
1508
|
"${footer}": footer
|
|
1440
1509
|
};
|
|
@@ -1496,9 +1565,9 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1496
1565
|
// Update hash cache
|
|
1497
1566
|
updateHash(changedFile, rawBody, hashCache);
|
|
1498
1567
|
|
|
1499
|
-
// Update recent-activity.json with this file's new
|
|
1568
|
+
// Update recent-activity.json with this file's new content timestamp
|
|
1500
1569
|
try {
|
|
1501
|
-
const
|
|
1570
|
+
const now = Date.now();
|
|
1502
1571
|
const recentActivityPath = join(output, 'public', 'recent-activity.json');
|
|
1503
1572
|
let recentActivity = [];
|
|
1504
1573
|
try {
|
|
@@ -1507,12 +1576,16 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1507
1576
|
} catch (e) { /* no existing file, start fresh */ }
|
|
1508
1577
|
// Remove old entry for this URL if present
|
|
1509
1578
|
recentActivity = recentActivity.filter(r => r.url !== url);
|
|
1510
|
-
// Add updated entry
|
|
1511
|
-
recentActivity.push({ title, url, mtime:
|
|
1579
|
+
// Add updated entry with current timestamp (content changed now)
|
|
1580
|
+
recentActivity.push({ title, url, mtime: now });
|
|
1512
1581
|
// Sort by mtime descending, keep top 10
|
|
1513
1582
|
recentActivity.sort((a, b) => b.mtime - a.mtime);
|
|
1514
1583
|
recentActivity = recentActivity.slice(0, 10);
|
|
1515
1584
|
await outputFile(recentActivityPath, JSON.stringify(recentActivity));
|
|
1585
|
+
|
|
1586
|
+
// Also update content timestamp in .ursa.json for persistence
|
|
1587
|
+
const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
|
|
1588
|
+
updateContentTimestamp(source, relativePath, now);
|
|
1516
1589
|
} catch (e) {
|
|
1517
1590
|
// ignore recent activity update errors
|
|
1518
1591
|
}
|