@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 +21 -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 +116 -21
- package/src/serve.js +5 -1
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 (
|
|
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.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
|
-
|
|
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";
|
|
@@ -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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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}":
|
|
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
|
-
|
|
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}":
|
|
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
|
|
1590
|
+
// Update recent-activity.json with this file's new content timestamp
|
|
1500
1591
|
try {
|
|
1501
|
-
const
|
|
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:
|
|
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));
|