@kenjura/ursa 0.80.1 → 0.81.3

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,24 @@
1
+ # 0.81.3
2
+ 2026-04-14
3
+
4
+ - bug fix: When script.js changes in serve mode, the bundle cache wasn't being cleared, so documents kept using stale bundles.
5
+ - Added clearScriptCache() and clearStyleCache() functions in generate.js
6
+ - Updated serve.js to call these functions when CSS/JS files change
7
+
8
+
9
+ # 0.81.2
10
+ 2026-03-28
11
+
12
+ - MDX hydration: MDX documents now support hydration of embedded React components, allowing for interactive content within static pages.
13
+
14
+ - **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).
15
+ - 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.
16
+ - Now, content change timestamps are stored in `.ursa.json` (in the source directory), which:
17
+ - Survives `--clean` builds (unlike the `.ursa/` cache folder)
18
+ - Can be committed to git to preserve wiki history across clones
19
+ - Falls back to file mtime for files that haven't been tracked yet (backward compatibility)
20
+ - Both full builds and single-file regeneration (serve/dev mode) now update content timestamps when content actually changes (detected by hash comparison).
21
+
1
22
  # 0.80.1
2
23
  2026-02-16
3
24
 
@@ -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.80.1",
5
+ "version": "0.81.3",
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
- 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,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 { 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";
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 static HTML.
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 static HTML using react-dom/server.
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
- * @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 }>}
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
- 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) => {
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 for modern Node.js
94
+ // Set target based on platform
87
95
  options.target = "es2020";
88
- options.platform = "node";
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
- const result = await bundleMDX({
139
+ // Server-side bundle (for SSR)
140
+ const serverResult = await bundleMDX({
103
141
  source,
104
142
  cwd,
105
- esbuildOptions,
106
- // mdx-bundler uses gray-matter internally for frontmatter
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 } = result;
147
+ const { code: serverCode, frontmatter } = serverResult;
133
148
 
134
149
  // getMDXComponent evaluates the bundled code and returns a React component
135
- const Component = getMDXComponent(code);
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
- // Render to static HTML (no client-side React needed)
138
- const html = renderToStaticMarkup(React.createElement(Component));
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 { html, frontmatter: frontmatter || {} };
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
+ }
@@ -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";
@@ -99,6 +100,28 @@ export function clearWatchCache() {
99
100
  clearMetaBundleCache();
100
101
  }
101
102
 
103
+ // Clear just the script-related caches (for when script.js changes)
104
+ export function clearScriptCache() {
105
+ scriptPathCache.clear();
106
+ // Clear all JS bundle cache entries
107
+ for (const key of docBundleCache.keys()) {
108
+ if (key.startsWith('js:')) {
109
+ docBundleCache.delete(key);
110
+ }
111
+ }
112
+ }
113
+
114
+ // Clear just the CSS-related caches (for when style.css changes)
115
+ export function clearStyleCache() {
116
+ cssPathCache.clear();
117
+ // Clear all CSS bundle cache entries
118
+ for (const key of docBundleCache.keys()) {
119
+ if (key.startsWith('css:')) {
120
+ docBundleCache.delete(key);
121
+ }
122
+ }
123
+ }
124
+
102
125
  const progress = new ProgressReporter();
103
126
 
104
127
  const DEFAULT_TEMPLATE_NAME =
@@ -291,6 +314,12 @@ export async function generate({
291
314
  progress.logTimed(`Clean build: ignoring cached hashes`);
292
315
  progress.stopTimer('Cache');
293
316
  }
317
+
318
+ // Load content timestamps from .ursa.json (survives --clean)
319
+ // These track when content actually changed, not filesystem mtime
320
+ const contentTimestamps = loadContentTimestamps(source);
321
+ const buildTimestamp = Date.now();
322
+ progress.logTimed(`Loaded ${contentTimestamps.size} content timestamps`);
294
323
  profiler.endPhase('Load cache');
295
324
 
296
325
  // Phase: Copy meta/public files
@@ -322,6 +351,9 @@ export async function generate({
322
351
  templates = await bundleMetaTemplateAssets(templates, meta, pub, { minify: true, sourcemap: false });
323
352
  progress.logTimed(`Meta template assets bundled`);
324
353
 
354
+ // Build React runtime for MDX hydration (React 19 has no UMD, so we bundle locally)
355
+ await buildReactRuntime(pub);
356
+
325
357
  // Process all CSS files in the entire output directory tree for cache-busting
326
358
  const allOutputFiles = await recurse(output, [() => false]);
327
359
  for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
@@ -521,17 +553,25 @@ export async function generate({
521
553
  content: rawBody
522
554
  });
523
555
 
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
556
+ // Collect timestamp for recent activity tracking
557
+ // Use stored content timestamp if available, otherwise fall back to file mtime
558
+ // Content timestamps track when content actually changed, not filesystem mtime
559
+ const storedTimestamp = contentTimestamps.get(relativePath);
560
+ let activityTimestamp = storedTimestamp;
561
+ if (!activityTimestamp) {
562
+ // No stored timestamp - use file mtime as initial value
563
+ try {
564
+ const fileStat = await stat(file);
565
+ activityTimestamp = fileStat.mtimeMs;
566
+ } catch (e) {
567
+ activityTimestamp = 0;
568
+ }
534
569
  }
570
+ recentActivity.push({
571
+ title: title,
572
+ url: searchUrl,
573
+ mtime: activityTimestamp
574
+ });
535
575
 
536
576
  // Check if a corresponding .html file already exists in source directory
537
577
  const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
@@ -587,16 +627,30 @@ export async function generate({
587
627
 
588
628
  // Use async rendering with worker threads for parallel markdown parsing
589
629
  // Wikitext (.txt) files will fall back to main thread
590
- let body = await renderFileAsync({
630
+ // For MDX files, enable hydration if frontmatter has `hydrate: true`
631
+ const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
632
+
633
+ let renderResult = await renderFileAsync({
591
634
  fileContents: rawBody,
592
635
  type,
593
636
  dirname: dir,
594
637
  basename: base,
595
638
  filePath: file,
596
639
  sourceRoot: source,
597
- useWorker: true
640
+ useWorker: true,
641
+ hydrate: shouldHydrate,
598
642
  });
599
643
 
644
+ // Handle the result - can be string or { html, hydrationScript }
645
+ let body;
646
+ let hydrationScript = '';
647
+ if (typeof renderResult === 'object' && renderResult.html) {
648
+ body = renderResult.html;
649
+ hydrationScript = renderResult.hydrationScript || '';
650
+ } else {
651
+ body = renderResult;
652
+ }
653
+
600
654
  // Inject default H1 if body doesn't start with one
601
655
  if (!body || !body.trimStart().startsWith('<h1')) {
602
656
  const h1Title = fileMeta?.title || title;
@@ -738,6 +792,11 @@ export async function generate({
738
792
 
739
793
  // Build final HTML with all replacements in a single regex pass
740
794
  // This avoids creating 8 intermediate strings
795
+ // Append hydration script to customScript if present (for MDX with hydrate: true)
796
+ const finalCustomScript = hydrationScript
797
+ ? customScript + '\n' + hydrationScript
798
+ : customScript;
799
+
741
800
  const replacements = {
742
801
  "${title}": fileMeta?.title || title,
743
802
  "${menu}": menu,
@@ -745,7 +804,7 @@ export async function generate({
745
804
  "${transformedMetadata}": lazyTransformedMeta,
746
805
  "${body}": body,
747
806
  "${styleLink}": styleLink,
748
- "${customScript}": customScript,
807
+ "${customScript}": finalCustomScript,
749
808
  "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
750
809
  "${footer}": footer
751
810
  };
@@ -824,6 +883,14 @@ export async function generate({
824
883
 
825
884
  // Update the content hash for this file
826
885
  updateHash(file, rawBody, hashCache);
886
+
887
+ // Update content timestamp since this file was regenerated (content changed)
888
+ contentTimestamps.set(relativePath, buildTimestamp);
889
+ // Also update the recentActivity entry we pushed earlier with the new timestamp
890
+ const activityEntry = recentActivity.find(e => e.url === searchUrl);
891
+ if (activityEntry) {
892
+ activityEntry.mtime = buildTimestamp;
893
+ }
827
894
  } catch (e) {
828
895
  progress.log(`Error processing ${file}: ${e.message}`);
829
896
  errors.push({ file, phase: 'article-generation', error: e });
@@ -1085,6 +1152,12 @@ export async function generate({
1085
1152
  if (hashCache.size > 0) {
1086
1153
  await saveHashCache(source, hashCache);
1087
1154
  }
1155
+
1156
+ // Save content timestamps to .ursa.json (tracks when content actually changed)
1157
+ if (contentTimestamps.size > 0) {
1158
+ saveContentTimestamps(source, contentTimestamps);
1159
+ progress.log(`Saved ${contentTimestamps.size} content timestamps`);
1160
+ }
1088
1161
 
1089
1162
  // Populate watch mode cache for fast single-file regeneration
1090
1163
  watchModeCache.templates = templates;
@@ -1319,18 +1392,31 @@ export async function regenerateSingleFile(changedFile, {
1319
1392
  // Calculate the document's URL path
1320
1393
  const docUrlPath = '/' + dir + base + '.html';
1321
1394
 
1395
+ // Check if hydration should be enabled for MDX files
1396
+ const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
1397
+
1322
1398
  // Render body (use async for .mdx, sync for .md/.txt)
1323
1399
  let body;
1400
+ let hydrationScript = '';
1324
1401
  if (type === '.mdx') {
1325
- body = await renderFileAsync({
1402
+ const renderResult = await renderFileAsync({
1326
1403
  fileContents: rawBody,
1327
1404
  type,
1328
1405
  dirname: dir,
1329
1406
  basename: base,
1330
1407
  filePath: changedFile,
1331
1408
  sourceRoot: source,
1332
- useWorker: false
1409
+ useWorker: false,
1410
+ hydrate: shouldHydrate,
1333
1411
  });
1412
+
1413
+ // Handle the result - can be string or { html, hydrationScript }
1414
+ if (typeof renderResult === 'object' && renderResult.html) {
1415
+ body = renderResult.html;
1416
+ hydrationScript = renderResult.hydrationScript || '';
1417
+ } else {
1418
+ body = renderResult;
1419
+ }
1334
1420
  } else {
1335
1421
  body = renderFile({
1336
1422
  fileContents: rawBody,
@@ -1425,6 +1511,11 @@ export async function regenerateSingleFile(changedFile, {
1425
1511
  // Check if this file has a custom menu
1426
1512
  const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
1427
1513
 
1514
+ // Append hydration script to customScript if present (for MDX with hydrate: true)
1515
+ const finalCustomScript = hydrationScript
1516
+ ? customScript + '\n' + hydrationScript
1517
+ : customScript;
1518
+
1428
1519
  // Build final HTML
1429
1520
  let finalHtml = template;
1430
1521
  const replacements = {
@@ -1434,7 +1525,7 @@ export async function regenerateSingleFile(changedFile, {
1434
1525
  "${transformedMetadata}": transformedMetadata,
1435
1526
  "${body}": body,
1436
1527
  "${styleLink}": styleLink,
1437
- "${customScript}": customScript,
1528
+ "${customScript}": finalCustomScript,
1438
1529
  "${searchIndex}": "[]",
1439
1530
  "${footer}": footer
1440
1531
  };
@@ -1496,9 +1587,9 @@ export async function regenerateSingleFile(changedFile, {
1496
1587
  // Update hash cache
1497
1588
  updateHash(changedFile, rawBody, hashCache);
1498
1589
 
1499
- // Update recent-activity.json with this file's new mtime
1590
+ // Update recent-activity.json with this file's new content timestamp
1500
1591
  try {
1501
- const fileStat = await stat(changedFile);
1592
+ const now = Date.now();
1502
1593
  const recentActivityPath = join(output, 'public', 'recent-activity.json');
1503
1594
  let recentActivity = [];
1504
1595
  try {
@@ -1507,12 +1598,16 @@ export async function regenerateSingleFile(changedFile, {
1507
1598
  } catch (e) { /* no existing file, start fresh */ }
1508
1599
  // Remove old entry for this URL if present
1509
1600
  recentActivity = recentActivity.filter(r => r.url !== url);
1510
- // Add updated entry
1511
- recentActivity.push({ title, url, mtime: fileStat.mtimeMs });
1601
+ // Add updated entry with current timestamp (content changed now)
1602
+ recentActivity.push({ title, url, mtime: now });
1512
1603
  // Sort by mtime descending, keep top 10
1513
1604
  recentActivity.sort((a, b) => b.mtime - a.mtime);
1514
1605
  recentActivity = recentActivity.slice(0, 10);
1515
1606
  await outputFile(recentActivityPath, JSON.stringify(recentActivity));
1607
+
1608
+ // Also update content timestamp in .ursa.json for persistence
1609
+ const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
1610
+ updateContentTimestamp(source, relativePath, now);
1516
1611
  } catch (e) {
1517
1612
  // ignore recent activity update errors
1518
1613
  }
package/src/serve.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import express from "express";
2
2
  import compression from "compression";
3
3
  import watch from "node-watch";
4
- import { generate, regenerateAffectedDocuments, clearWatchCache } from "./jobs/generate.js";
4
+ import { generate, regenerateAffectedDocuments, clearWatchCache, clearScriptCache, clearStyleCache } from "./jobs/generate.js";
5
5
  import { join, resolve, dirname, basename } from "path";
6
6
  import fs from "fs";
7
7
  import { promises } from "fs";
@@ -463,6 +463,8 @@ export async function serve({
463
463
  for (const change of cssChanges) {
464
464
  const result = await copyCssFile(change.name, sourceDir + '/', outputDir + '/');
465
465
  if (result.success) console.log(`✅ ${result.message}`);
466
+ // Clear CSS bundle cache so affected documents will regenerate bundles
467
+ clearStyleCache();
466
468
  if (watchModeCache.isInitialized) {
467
469
  const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
468
470
  if (plan.requiresFullRebuild) {
@@ -481,6 +483,8 @@ export async function serve({
481
483
  const content = await readFile(change.name, 'utf8');
482
484
  await outputFile(outputPath, content);
483
485
  console.log(`✅ Copied ${relativePath}`);
486
+ // Clear script bundle cache so affected documents will regenerate bundles
487
+ clearScriptCache();
484
488
  if (watchModeCache.isInitialized) {
485
489
  const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
486
490
  plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));