@kenjura/ursa 0.79.0 → 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 CHANGED
@@ -1,3 +1,26 @@
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
+
14
+ # 0.80.1
15
+ 2026-02-16
16
+
17
+ - Fixed package.json issue
18
+
19
+ # 0.80.0
20
+ 2026-02-16
21
+
22
+ - Added remark extensions to ensure MDX has the same extended markdown features as regular markdown (e.g. footnotes, definition lists, etc.)
23
+
1
24
  # 0.79.0
2
25
  2026-02-14
3
26
 
@@ -42,7 +42,7 @@
42
42
 
43
43
  // === observer factory tied to current STICKY_TOP ===
44
44
  let observer = null;
45
- function (re)buildObserver() {
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
- (re)buildObserver();
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
- (re)buildObserver();
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.79.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": {
@@ -39,7 +39,12 @@
39
39
  "object-to-xml": "^2.0.0",
40
40
  "react": "^19.2.4",
41
41
  "react-dom": "^19.2.4",
42
+ "remark-definition-list": "^2.0.0",
43
+ "remark-directive": "^4.0.0",
44
+ "remark-gfm": "^4.0.1",
45
+ "remark-supersub": "^1.0.0",
42
46
  "sharp": "^0.33.2",
47
+ "unist-util-visit": "^5.1.0",
43
48
  "ws": "^8.19.0",
44
49
  "yaml": "^2.1.3",
45
50
  "yargs": "^17.7.2"
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
- let body = await renderFileAsync({
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
- const fileMeta = extractMetadata(rawBody);
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}": 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 (default: true)
43
- * @returns {Promise<string>} Rendered HTML
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' // Auto-indexing 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,9 +1,32 @@
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 { renderToStaticMarkup } from "react-dom/server";
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";
9
+ import remarkDirective from "remark-directive";
10
+ import { remarkDefinitionList, defListHastHandlers } from "remark-definition-list";
11
+ import remarkSupersub from "remark-supersub";
12
+ import remarkGfm from "remark-gfm";
13
+ import { visit } from "unist-util-visit";
14
+
15
+ /**
16
+ * Custom remark plugin that converts container directives (:::name ... :::)
17
+ * into <aside> HTML elements, matching the markdown-it-container behavior
18
+ * used in the .md pipeline (markdownHelper.cjs).
19
+ */
20
+ function remarkAsideContainers() {
21
+ return (tree) => {
22
+ visit(tree, (node) => {
23
+ if (node.type === "containerDirective") {
24
+ const data = node.data || (node.data = {});
25
+ data.hName = "aside";
26
+ }
27
+ });
28
+ };
29
+ }
7
30
 
8
31
  /**
9
32
  * Find _components directories by walking up from the MDX file to the source root.
@@ -34,10 +57,10 @@ function findComponentDirs(startDir, sourceRoot) {
34
57
  }
35
58
 
36
59
  /**
37
- * Render an MDX file to static HTML.
60
+ * Render an MDX file to HTML with optional client-side hydration support.
38
61
  *
39
62
  * Uses mdx-bundler to compile MDX source (with component imports resolved via esbuild),
40
- * then renders the resulting React component to static HTML using react-dom/server.
63
+ * then renders the resulting React component to HTML using react-dom/server.
41
64
  *
42
65
  * Supports a `_components/` directory convention: any `_components/` folder found in
43
66
  * the MDX file's directory or any parent directory (up to sourceRoot) will be added
@@ -48,13 +71,18 @@ function findComponentDirs(startDir, sourceRoot) {
48
71
  * @param {string} options.source - Raw MDX file contents
49
72
  * @param {string} options.filePath - Absolute path to the MDX file (used for import resolution)
50
73
  * @param {string} [options.sourceRoot] - Root directory of the source files (for absolute imports)
51
- * @returns {Promise<{ html: string, frontmatter: Record<string, any> }>}
74
+ * @param {boolean} [options.hydrate=false] - If true, includes client bundle for hydration
75
+ * @returns {Promise<{ html: string, frontmatter: Record<string, any>, clientCode?: string }>}
52
76
  */
53
- export async function renderMDX({ source, filePath, sourceRoot }) {
77
+ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false }) {
54
78
  const cwd = dirname(filePath);
55
79
  const componentDirs = findComponentDirs(cwd, sourceRoot);
56
80
 
57
- const esbuildOptions = (options) => {
81
+ /**
82
+ * Create esbuild options for the given platform
83
+ * @param {'node'|'browser'} platform - Target platform
84
+ */
85
+ const createEsbuildOptions = (platform) => (options) => {
58
86
  // Enable loaders for TypeScript/JSX component files
59
87
  options.loader = {
60
88
  ...options.loader,
@@ -63,9 +91,9 @@ export async function renderMDX({ source, filePath, sourceRoot }) {
63
91
  ".tsx": "tsx",
64
92
  ".jsx": "jsx",
65
93
  };
66
- // Set target for modern Node.js
94
+ // Set target based on platform
67
95
  options.target = "es2020";
68
- options.platform = "node";
96
+ options.platform = platform;
69
97
 
70
98
  // Add _components directories as resolve paths so imports like
71
99
  // '_components/Foo.tsx' resolve without relative path prefixes
@@ -78,26 +106,72 @@ export async function renderMDX({ source, filePath, sourceRoot }) {
78
106
  return options;
79
107
  };
80
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
+
81
138
  try {
82
- const result = await bundleMDX({
139
+ // Server-side bundle (for SSR)
140
+ const serverResult = await bundleMDX({
83
141
  source,
84
142
  cwd,
85
- esbuildOptions,
86
- // mdx-bundler uses gray-matter internally for frontmatter
87
- mdxOptions(options) {
88
- return options;
89
- },
143
+ esbuildOptions: createEsbuildOptions('node'),
144
+ mdxOptions: createMdxOptions(),
90
145
  });
91
146
 
92
- const { code, frontmatter } = result;
147
+ const { code: serverCode, frontmatter } = serverResult;
93
148
 
94
149
  // getMDXComponent evaluates the bundled code and returns a React component
95
- const Component = getMDXComponent(code);
150
+ const Component = getMDXComponent(serverCode);
96
151
 
97
- // Render to static HTML (no client-side React needed)
98
- const html = renderToStaticMarkup(React.createElement(Component));
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
+ }
159
+
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
+ });
99
169
 
100
- return { html, frontmatter: frontmatter || {} };
170
+ return {
171
+ html,
172
+ frontmatter: frontmatter || {},
173
+ clientCode: clientResult.code,
174
+ };
101
175
  } catch (error) {
102
176
  throw formatMDXError(error, filePath);
103
177
  }
@@ -173,3 +247,110 @@ function formatMDXError(error, filePath) {
173
247
  wrappedError.originalError = error;
174
248
  return wrappedError;
175
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
+ }
@@ -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
+ }
@@ -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 mtime for recent activity tracking
525
- try {
526
- const fileStat = await stat(file);
527
- recentActivity.push({
528
- title: title,
529
- url: searchUrl,
530
- mtime: fileStat.mtimeMs
531
- });
532
- } catch (e) {
533
- // ignore stat errors
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
- let body = await renderFileAsync({
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}": 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
- body = await renderFileAsync({
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}": 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 mtime
1568
+ // Update recent-activity.json with this file's new content timestamp
1500
1569
  try {
1501
- const fileStat = await stat(changedFile);
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: fileStat.mtimeMs });
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
  }