@kenjura/ursa 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/meta/default.css +149 -3
- package/meta/templates/default-template/default.css +1268 -0
- package/meta/{default-template.html → templates/default-template/index.html} +49 -2
- package/meta/{menu.js → templates/default-template/menu.js} +1 -1
- package/meta/templates/default-template/sectionify.js +46 -0
- package/meta/templates/default-template/widgets.js +701 -0
- package/package.json +1 -1
- package/src/dev.js +125 -34
- package/src/helper/assetBundler.js +471 -0
- package/src/helper/build/autoIndex.js +26 -23
- package/src/helper/build/cacheBust.js +79 -0
- package/src/helper/build/navCache.js +4 -0
- package/src/helper/build/templates.js +176 -19
- package/src/helper/build/watchCache.js +7 -0
- package/src/helper/customMenu.js +4 -2
- package/src/helper/dependencyTracker.js +269 -0
- package/src/helper/findScriptJs.js +29 -0
- package/src/helper/findStyleCss.js +29 -0
- package/src/helper/portUtils.js +132 -0
- package/src/jobs/generate.js +276 -59
- package/src/serve.js +446 -162
- package/meta/character-sheet.css +0 -50
- package/meta/widgets.js +0 -376
- /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
- /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
- /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
- /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
- /package/meta/{search.js → templates/default-template/search.js} +0 -0
- /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
- /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
- /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
- /package/meta/{template2.html → templates/template2/index.html} +0 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import readline from 'readline';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a specific port is available.
|
|
6
|
+
* @param {number} port
|
|
7
|
+
* @returns {Promise<boolean>}
|
|
8
|
+
*/
|
|
9
|
+
export function isPortAvailable(port) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const server = net.createServer();
|
|
12
|
+
server.once('error', () => resolve(false));
|
|
13
|
+
server.once('listening', () => {
|
|
14
|
+
server.close(() => resolve(true));
|
|
15
|
+
});
|
|
16
|
+
server.listen(port);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find the closest available port to the preferred port.
|
|
22
|
+
* Searches both upward and downward from the preferred port, returning
|
|
23
|
+
* the closest available one.
|
|
24
|
+
* @param {number} preferred - The preferred port number
|
|
25
|
+
* @param {number} [maxDistance=100] - Maximum distance to search from preferred port
|
|
26
|
+
* @returns {Promise<number|null>} The closest available port, or null if none found
|
|
27
|
+
*/
|
|
28
|
+
export async function findClosestAvailablePort(preferred, maxDistance = 100) {
|
|
29
|
+
for (let offset = 1; offset <= maxDistance; offset++) {
|
|
30
|
+
const candidates = [];
|
|
31
|
+
if (preferred + offset <= 65535) candidates.push(preferred + offset);
|
|
32
|
+
if (preferred - offset >= 1024) candidates.push(preferred - offset);
|
|
33
|
+
|
|
34
|
+
// Check both candidates (up and down) in parallel
|
|
35
|
+
const results = await Promise.all(
|
|
36
|
+
candidates.map(async (port) => ({
|
|
37
|
+
port,
|
|
38
|
+
available: await isPortAvailable(port),
|
|
39
|
+
}))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Return the first available candidate (lower offset = closer)
|
|
43
|
+
// Since we push +offset first, it's preferred over -offset at the same distance
|
|
44
|
+
const found = results.find((r) => r.available);
|
|
45
|
+
if (found) return found.port;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prompt the user via stdin to confirm using an alternative port.
|
|
52
|
+
* @param {number} originalPort
|
|
53
|
+
* @param {number} alternativePort
|
|
54
|
+
* @returns {Promise<boolean>} True if user accepts the alternative port
|
|
55
|
+
*/
|
|
56
|
+
function promptUser(originalPort, alternativePort) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const rl = readline.createInterface({
|
|
59
|
+
input: process.stdin,
|
|
60
|
+
output: process.stdout,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
rl.question(
|
|
64
|
+
`⚠️ Port ${originalPort} is already in use. Use port ${alternativePort} instead? (Y/n) `,
|
|
65
|
+
(answer) => {
|
|
66
|
+
rl.close();
|
|
67
|
+
const normalized = answer.trim().toLowerCase();
|
|
68
|
+
resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve an available port for the server. If the requested port is occupied,
|
|
76
|
+
* find the closest available port and prompt the user to accept it.
|
|
77
|
+
*
|
|
78
|
+
* Also checks wsPort (port + 1) availability since the WebSocket server needs it.
|
|
79
|
+
*
|
|
80
|
+
* @param {number} port - The desired port
|
|
81
|
+
* @returns {Promise<number>} The port to use (original or user-accepted alternative)
|
|
82
|
+
* @throws {Error} If no available port is found or user declines the alternative
|
|
83
|
+
*/
|
|
84
|
+
export async function resolvePort(port) {
|
|
85
|
+
const httpAvailable = await isPortAvailable(port);
|
|
86
|
+
const wsAvailable = await isPortAvailable(port + 1);
|
|
87
|
+
|
|
88
|
+
if (httpAvailable && wsAvailable) {
|
|
89
|
+
return port;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reason = !httpAvailable
|
|
93
|
+
? `Port ${port} is already in use`
|
|
94
|
+
: `WebSocket port ${port + 1} is already in use`;
|
|
95
|
+
|
|
96
|
+
console.log(`\n⚠️ ${reason}.`);
|
|
97
|
+
console.log(`🔍 Searching for an available port...`);
|
|
98
|
+
|
|
99
|
+
const alternative = await findClosestAvailablePort(port);
|
|
100
|
+
|
|
101
|
+
if (!alternative) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Could not find an available port near ${port}. Please free up a port and try again.`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Also verify the ws port for the alternative
|
|
108
|
+
const altWsAvailable = await isPortAvailable(alternative + 1);
|
|
109
|
+
if (!altWsAvailable) {
|
|
110
|
+
// Try again, skipping this one
|
|
111
|
+
const secondTry = await findClosestAvailablePort(alternative + 1);
|
|
112
|
+
if (!secondTry) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Could not find an available port pair (HTTP + WebSocket) near ${port}.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const accepted = await promptUser(port, secondTry);
|
|
118
|
+
if (!accepted) {
|
|
119
|
+
console.log('👋 Server startup cancelled.');
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
return secondTry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const accepted = await promptUser(port, alternative);
|
|
126
|
+
if (!accepted) {
|
|
127
|
+
console.log('👋 Server startup cancelled.');
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return alternative;
|
|
132
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -26,9 +26,12 @@ import {
|
|
|
26
26
|
import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
|
|
27
27
|
import { extractSections } from "../helper/sectionExtractor.js";
|
|
28
28
|
import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
|
|
29
|
-
import { findStyleCss } from "../helper/findStyleCss.js";
|
|
30
|
-
import { findScriptJs } from "../helper/findScriptJs.js";
|
|
29
|
+
import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
|
|
30
|
+
import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
|
|
31
|
+
import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMetaBundleCache, generateSeparateCssTags, generateSeparateJsTags } from "../helper/assetBundler.js";
|
|
31
32
|
import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
|
|
33
|
+
import { dependencyTracker } from "../helper/dependencyTracker.js";
|
|
34
|
+
import { CacheBustHashMap } from "../helper/build/cacheBust.js";
|
|
32
35
|
import { copy as copyDir, emptyDir, outputFile } from "fs-extra";
|
|
33
36
|
import { basename, dirname, extname, join, parse, resolve } from "path";
|
|
34
37
|
import { URL } from "url";
|
|
@@ -71,6 +74,7 @@ import {
|
|
|
71
74
|
getFooter,
|
|
72
75
|
generateAutoIndices,
|
|
73
76
|
generateAutoIndexHtmlFromSource,
|
|
77
|
+
copyMetaAssets,
|
|
74
78
|
} from "../helper/build/index.js";
|
|
75
79
|
import { getProfiler } from "../helper/build/profiler.js";
|
|
76
80
|
|
|
@@ -83,10 +87,15 @@ const cssPathCache = new Map();
|
|
|
83
87
|
// Cache for script path lookups to avoid repeated filesystem walks
|
|
84
88
|
const scriptPathCache = new Map();
|
|
85
89
|
|
|
90
|
+
// Cache for document-level CSS/JS bundle paths to avoid re-bundling identical sets
|
|
91
|
+
const docBundleCache = new Map();
|
|
92
|
+
|
|
86
93
|
// Wrapper for clearWatchCache that passes cssPathCache and scriptPathCache
|
|
87
94
|
export function clearWatchCache() {
|
|
88
95
|
clearWatchCacheBase(cssPathCache);
|
|
89
96
|
scriptPathCache.clear();
|
|
97
|
+
docBundleCache.clear();
|
|
98
|
+
clearMetaBundleCache();
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
const progress = new ProgressReporter();
|
|
@@ -116,8 +125,12 @@ export async function generate({
|
|
|
116
125
|
|
|
117
126
|
// Generate cache-busting timestamp for this build
|
|
118
127
|
const cacheBustTimestamp = generateCacheBustTimestamp();
|
|
128
|
+
const cacheBustHashes = new CacheBustHashMap();
|
|
119
129
|
progress.logTimed(`Cache-bust timestamp: ${cacheBustTimestamp}`);
|
|
120
130
|
|
|
131
|
+
// Initialize dependency tracker for this build
|
|
132
|
+
dependencyTracker.init(source);
|
|
133
|
+
|
|
121
134
|
// Clear output directory when --clean is specified
|
|
122
135
|
if (_clean) {
|
|
123
136
|
progress.startTimer('Clean');
|
|
@@ -283,7 +296,27 @@ export async function generate({
|
|
|
283
296
|
// create public folder
|
|
284
297
|
const pub = join(output, "public");
|
|
285
298
|
await mkdir(pub, { recursive: true });
|
|
286
|
-
|
|
299
|
+
|
|
300
|
+
// Copy meta assets with new template folder structure
|
|
301
|
+
const { copiedFiles, orphanedFiles } = await copyMetaAssets(meta, pub);
|
|
302
|
+
|
|
303
|
+
// Warn about orphaned files in meta that aren't part of any template
|
|
304
|
+
if (orphanedFiles.length > 0) {
|
|
305
|
+
console.warn(`\n⚠️ Warning: Found ${orphanedFiles.length} orphaned file(s) in meta directory:`);
|
|
306
|
+
console.warn(` These files are not in meta/templates/ or meta/shared/ and won't be included:`);
|
|
307
|
+
for (const file of orphanedFiles.slice(0, 10)) {
|
|
308
|
+
console.warn(` - ${file}`);
|
|
309
|
+
}
|
|
310
|
+
if (orphanedFiles.length > 10) {
|
|
311
|
+
console.warn(` ... and ${orphanedFiles.length - 10} more`);
|
|
312
|
+
}
|
|
313
|
+
console.warn(` Move them to meta/templates/{templateName}/ or meta/shared/ to include them.\n`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Bundle meta template assets (CSS + JS) into single files per template
|
|
317
|
+
// This must happen after copying meta to public but before cache-busting
|
|
318
|
+
templates = await bundleMetaTemplateAssets(templates, meta, pub, { minify: true, sourcemap: false });
|
|
319
|
+
progress.logTimed(`Meta template assets bundled`);
|
|
287
320
|
|
|
288
321
|
// Process all CSS files in the entire output directory tree for cache-busting
|
|
289
322
|
const allOutputFiles = await recurse(output, [() => false]);
|
|
@@ -313,6 +346,8 @@ export async function generate({
|
|
|
313
346
|
const searchIndex = [];
|
|
314
347
|
// Full-text index: collect documents for word-to-document mapping
|
|
315
348
|
const fullTextDocs = [];
|
|
349
|
+
// Recent activity: collect {title, url, mtime} for all articles, keep top 10 by mtime
|
|
350
|
+
const recentActivity = [];
|
|
316
351
|
// Track paths of documents that were regenerated (for incremental index updates)
|
|
317
352
|
const changedPaths = new Set();
|
|
318
353
|
// Directory index cache: only stores minimal data needed for directory indices
|
|
@@ -481,6 +516,18 @@ export async function generate({
|
|
|
481
516
|
title: title,
|
|
482
517
|
content: rawBody
|
|
483
518
|
});
|
|
519
|
+
|
|
520
|
+
// Collect mtime for recent activity tracking
|
|
521
|
+
try {
|
|
522
|
+
const fileStat = await stat(file);
|
|
523
|
+
recentActivity.push({
|
|
524
|
+
title: title,
|
|
525
|
+
url: searchUrl,
|
|
526
|
+
mtime: fileStat.mtimeMs
|
|
527
|
+
});
|
|
528
|
+
} catch (e) {
|
|
529
|
+
// ignore stat errors
|
|
530
|
+
}
|
|
484
531
|
|
|
485
532
|
// Check if a corresponding .html file already exists in source directory
|
|
486
533
|
const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
|
@@ -582,52 +629,73 @@ export async function generate({
|
|
|
582
629
|
}
|
|
583
630
|
}
|
|
584
631
|
|
|
585
|
-
// Find
|
|
586
|
-
//
|
|
632
|
+
// Find all style.css files up the tree and bundle them into a single CSS file per folder path
|
|
633
|
+
// (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
|
|
587
634
|
let styleLink = "";
|
|
588
635
|
try {
|
|
589
|
-
// For root-level files, dir may be "/" which would resolve to filesystem root
|
|
590
|
-
// Use source directory directly in that case
|
|
591
636
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
637
|
+
const folderRelative = (dir === "/" || dir === "") ? "" : dir;
|
|
638
|
+
|
|
639
|
+
// Check bundle cache first (dirs with same CSS ancestry share the same bundle)
|
|
640
|
+
let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
|
|
641
|
+
if (cachedBundleUrl !== undefined) {
|
|
642
|
+
if (cachedBundleUrl) {
|
|
643
|
+
styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
let cssPaths = cssPathCache.get(dirKey);
|
|
647
|
+
if (cssPaths === undefined) {
|
|
648
|
+
cssPaths = await findAllStyleCss(dirKey, _source);
|
|
649
|
+
cssPathCache.set(dirKey, cssPaths);
|
|
650
|
+
}
|
|
651
|
+
if (cssPaths.length > 0) {
|
|
652
|
+
// Copy all source CSS files to output (still needed for serve mode fallback)
|
|
653
|
+
for (const cssPath of cssPaths) {
|
|
654
|
+
if (!copiedCssFiles.has(cssPath)) {
|
|
655
|
+
const cssOutputPath = cssPath.replace(source, output);
|
|
656
|
+
const cssContent = await readFile(cssPath, 'utf8');
|
|
657
|
+
await outputFile(cssOutputPath, cssContent);
|
|
658
|
+
copiedCssFiles.add(cssPath);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// Bundle into a single file
|
|
662
|
+
const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
|
|
663
|
+
docBundleCache.set(`css:${dirKey}`, bundleUrl);
|
|
664
|
+
styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
|
|
665
|
+
} else {
|
|
666
|
+
docBundleCache.set(`css:${dirKey}`, null);
|
|
607
667
|
}
|
|
608
|
-
|
|
609
|
-
// Generate link tag
|
|
610
|
-
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
611
668
|
}
|
|
612
669
|
} catch (e) {
|
|
613
670
|
// ignore
|
|
614
671
|
console.error(e);
|
|
615
672
|
}
|
|
616
673
|
|
|
617
|
-
// Find
|
|
618
|
-
//
|
|
674
|
+
// Find all script.js files from docroot to current dir and bundle them
|
|
675
|
+
// (Generate mode: one JS bundle per unique folder, external not inlined)
|
|
619
676
|
let customScript = "";
|
|
620
677
|
try {
|
|
621
678
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
679
|
+
const folderRelative = (dir === "/" || dir === "") ? "" : dir;
|
|
680
|
+
|
|
681
|
+
let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
|
|
682
|
+
if (cachedBundleUrl !== undefined) {
|
|
683
|
+
if (cachedBundleUrl) {
|
|
684
|
+
customScript = `<script src="${cachedBundleUrl}"></script>`;
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
let scriptPaths = scriptPathCache.get(dirKey);
|
|
688
|
+
if (scriptPaths === undefined) {
|
|
689
|
+
scriptPaths = await findAllScriptJs(dirKey, _source);
|
|
690
|
+
scriptPathCache.set(dirKey, scriptPaths);
|
|
691
|
+
}
|
|
692
|
+
if (scriptPaths.length > 0) {
|
|
693
|
+
const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
|
|
694
|
+
docBundleCache.set(`js:${dirKey}`, bundleUrl);
|
|
695
|
+
customScript = `<script src="${bundleUrl}"></script>`;
|
|
696
|
+
} else {
|
|
697
|
+
docBundleCache.set(`js:${dirKey}`, null);
|
|
698
|
+
}
|
|
631
699
|
}
|
|
632
700
|
} catch (e) {
|
|
633
701
|
// ignore
|
|
@@ -635,11 +703,23 @@ export async function generate({
|
|
|
635
703
|
}
|
|
636
704
|
|
|
637
705
|
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
638
|
-
const
|
|
639
|
-
|
|
706
|
+
const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
|
|
707
|
+
const template = templates[templateName];
|
|
640
708
|
|
|
641
709
|
if (!template) {
|
|
642
|
-
throw new Error(`Template not found. Requested: "${
|
|
710
|
+
throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Register this document's dependencies for invalidation tracking
|
|
714
|
+
{
|
|
715
|
+
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
716
|
+
const cssDeps = cssPathCache.get(dirKey) || [];
|
|
717
|
+
const jsDeps = scriptPathCache.get(dirKey) || [];
|
|
718
|
+
dependencyTracker.registerDocument(file, {
|
|
719
|
+
templateName,
|
|
720
|
+
cssPaths: cssDeps,
|
|
721
|
+
scriptPaths: jsDeps,
|
|
722
|
+
});
|
|
643
723
|
}
|
|
644
724
|
|
|
645
725
|
// Check if this file has a custom menu
|
|
@@ -807,6 +887,17 @@ export async function generate({
|
|
|
807
887
|
profiler.endPhase('Write search index');
|
|
808
888
|
}
|
|
809
889
|
|
|
890
|
+
// Phase: Write recent activity data
|
|
891
|
+
profiler.startPhase('Write recent activity');
|
|
892
|
+
progress.startTimer('Recent activity');
|
|
893
|
+
// Sort by mtime descending, keep top 10
|
|
894
|
+
recentActivity.sort((a, b) => b.mtime - a.mtime);
|
|
895
|
+
const top10 = recentActivity.slice(0, 10);
|
|
896
|
+
const recentActivityPath = join(output, 'public', 'recent-activity.json');
|
|
897
|
+
await outputFile(recentActivityPath, JSON.stringify(top10));
|
|
898
|
+
progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
|
|
899
|
+
profiler.endPhase('Write recent activity');
|
|
900
|
+
|
|
810
901
|
// Phase: Write menu data
|
|
811
902
|
profiler.startPhase('Write menu data');
|
|
812
903
|
progress.startTimer('Menu data');
|
|
@@ -1000,11 +1091,15 @@ export async function generate({
|
|
|
1000
1091
|
watchModeCache.meta = meta;
|
|
1001
1092
|
watchModeCache.output = output;
|
|
1002
1093
|
watchModeCache.hashCache = hashCache;
|
|
1094
|
+
watchModeCache.cacheBustTimestamp = cacheBustTimestamp;
|
|
1095
|
+
watchModeCache.cacheBustHashes = cacheBustHashes;
|
|
1096
|
+
watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
|
|
1003
1097
|
watchModeCache.imageMap = imageMap;
|
|
1004
1098
|
watchModeCache.customMenus = customMenus;
|
|
1005
1099
|
watchModeCache.lastFullBuild = Date.now();
|
|
1006
1100
|
watchModeCache.isInitialized = true;
|
|
1007
|
-
|
|
1101
|
+
const depStats = dependencyTracker.getStats();
|
|
1102
|
+
progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
|
|
1008
1103
|
|
|
1009
1104
|
// Write error report if there were any errors
|
|
1010
1105
|
if (errors.length > 0) {
|
|
@@ -1059,6 +1154,104 @@ export async function generate({
|
|
|
1059
1154
|
};
|
|
1060
1155
|
}
|
|
1061
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* Regenerate multiple documents affected by a dependency change (e.g., style.css, script.js, template).
|
|
1159
|
+
* Uses the watchModeCache and dependency tracker to efficiently re-render affected documents
|
|
1160
|
+
* with updated cache-bust timestamps.
|
|
1161
|
+
*
|
|
1162
|
+
* @param {string[]} documentPaths - Absolute paths to documents to regenerate
|
|
1163
|
+
* @param {Object} options
|
|
1164
|
+
* @param {string} options._source - Source directory
|
|
1165
|
+
* @param {string} options._meta - Meta directory
|
|
1166
|
+
* @param {string} options._output - Output directory
|
|
1167
|
+
* @param {string} [options.reason] - Reason for regeneration (for logging)
|
|
1168
|
+
* @param {string[]} [options.priorityPaths] - Document paths to regenerate first (e.g. client-viewed docs)
|
|
1169
|
+
* @param {function} [options.onPriorityComplete] - Callback after priority paths are done (receives { regenerated, failed })
|
|
1170
|
+
* @returns {Promise<{success: boolean, message: string, regenerated: number, failed: number}>}
|
|
1171
|
+
*/
|
|
1172
|
+
export async function regenerateAffectedDocuments(documentPaths, {
|
|
1173
|
+
_source,
|
|
1174
|
+
_meta,
|
|
1175
|
+
_output,
|
|
1176
|
+
reason = "dependency change",
|
|
1177
|
+
priorityPaths = [],
|
|
1178
|
+
onPriorityComplete = null,
|
|
1179
|
+
} = {}) {
|
|
1180
|
+
const startTime = Date.now();
|
|
1181
|
+
|
|
1182
|
+
if (!watchModeCache.isInitialized) {
|
|
1183
|
+
return { success: false, message: "Cache not initialized - need full build first", regenerated: 0, failed: 0 };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (documentPaths.length === 0) {
|
|
1187
|
+
return { success: true, message: "No documents to regenerate", regenerated: 0, failed: 0 };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Generate a fresh cache-bust timestamp for this invalidation pass
|
|
1191
|
+
const newTimestamp = generateCacheBustTimestamp();
|
|
1192
|
+
watchModeCache.cacheBustTimestamp = newTimestamp;
|
|
1193
|
+
|
|
1194
|
+
let regenerated = 0;
|
|
1195
|
+
let failed = 0;
|
|
1196
|
+
|
|
1197
|
+
// Separate priority paths from the rest
|
|
1198
|
+
const prioritySet = new Set(priorityPaths.map(p => resolve(p)));
|
|
1199
|
+
const priorityDocs = documentPaths.filter(p => prioritySet.has(resolve(p)));
|
|
1200
|
+
const remainingDocs = documentPaths.filter(p => !prioritySet.has(resolve(p)));
|
|
1201
|
+
|
|
1202
|
+
if (priorityDocs.length > 0) {
|
|
1203
|
+
console.log(`🔄 Regenerating ${priorityDocs.length} priority documents first, then ${remainingDocs.length} remaining (${reason})`);
|
|
1204
|
+
} else {
|
|
1205
|
+
console.log(`🔄 Regenerating ${documentPaths.length} documents (${reason})`);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Process priority documents first
|
|
1209
|
+
for (const docPath of priorityDocs) {
|
|
1210
|
+
try {
|
|
1211
|
+
const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
|
|
1212
|
+
if (result.success) {
|
|
1213
|
+
regenerated++;
|
|
1214
|
+
} else {
|
|
1215
|
+
console.warn(` ⚠️ ${docPath}: ${result.message}`);
|
|
1216
|
+
failed++;
|
|
1217
|
+
}
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
console.error(` ❌ ${docPath}: ${e.message}`);
|
|
1220
|
+
failed++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Notify caller that priority docs are done (so server can reload those clients immediately)
|
|
1225
|
+
if (priorityDocs.length > 0 && onPriorityComplete) {
|
|
1226
|
+
try {
|
|
1227
|
+
onPriorityComplete({ regenerated, failed, priorityDocs });
|
|
1228
|
+
} catch (e) {
|
|
1229
|
+
console.error(` ⚠️ onPriorityComplete callback error: ${e.message}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Process remaining documents
|
|
1234
|
+
for (const docPath of remainingDocs) {
|
|
1235
|
+
try {
|
|
1236
|
+
const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
|
|
1237
|
+
if (result.success) {
|
|
1238
|
+
regenerated++;
|
|
1239
|
+
} else {
|
|
1240
|
+
console.warn(` ⚠️ ${docPath}: ${result.message}`);
|
|
1241
|
+
failed++;
|
|
1242
|
+
}
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
console.error(` ❌ ${docPath}: ${e.message}`);
|
|
1245
|
+
failed++;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const elapsed = Date.now() - startTime;
|
|
1250
|
+
const msg = `Regenerated ${regenerated}/${documentPaths.length} documents in ${elapsed}ms (${reason})${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
1251
|
+
console.log(`✅ ${msg}`);
|
|
1252
|
+
return { success: failed === 0, message: msg, regenerated, failed };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1062
1255
|
/**
|
|
1063
1256
|
* Regenerate a single file without scanning the entire source directory.
|
|
1064
1257
|
* This is much faster for watch mode - only regenerate what changed.
|
|
@@ -1178,23 +1371,20 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1178
1371
|
}
|
|
1179
1372
|
}
|
|
1180
1373
|
|
|
1181
|
-
// Find CSS and
|
|
1374
|
+
// Find all CSS files up the tree and create separate link tags
|
|
1375
|
+
// (regenerateSingleFile is used in serve mode, so separate tags per level for invalidation)
|
|
1182
1376
|
let styleLink = "";
|
|
1183
1377
|
try {
|
|
1184
|
-
// For root-level files, dir may be "/" which would resolve to filesystem root
|
|
1185
1378
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1186
|
-
const
|
|
1187
|
-
if (
|
|
1188
|
-
//
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
// Generate link tag
|
|
1197
|
-
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
1379
|
+
const cssPaths = await findAllStyleCss(dirKey, _source);
|
|
1380
|
+
if (cssPaths.length > 0) {
|
|
1381
|
+
// Copy all CSS files to output (always copy in single-file mode to ensure up to date)
|
|
1382
|
+
for (const cssPath of cssPaths) {
|
|
1383
|
+
const cssOutputPath = cssPath.replace(source, output);
|
|
1384
|
+
const cssContent = await readFile(cssPath, 'utf8');
|
|
1385
|
+
await outputFile(cssOutputPath, cssContent);
|
|
1386
|
+
}
|
|
1387
|
+
styleLink = generateSeparateCssTags(cssPaths, source);
|
|
1198
1388
|
}
|
|
1199
1389
|
} catch (e) {
|
|
1200
1390
|
// ignore
|
|
@@ -1209,14 +1399,20 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1209
1399
|
return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
|
|
1210
1400
|
}
|
|
1211
1401
|
|
|
1212
|
-
// Find
|
|
1402
|
+
// Find all script.js files from docroot to current dir and serve as separate external tags
|
|
1403
|
+
// (Serve mode: separate tags per level for individual invalidation)
|
|
1213
1404
|
let customScript = "";
|
|
1214
1405
|
try {
|
|
1215
1406
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1216
|
-
const
|
|
1217
|
-
if (
|
|
1218
|
-
|
|
1219
|
-
|
|
1407
|
+
const scriptPaths = await findAllScriptJs(dirKey, _source);
|
|
1408
|
+
if (scriptPaths.length > 0) {
|
|
1409
|
+
// Copy all script files to output so they can be served
|
|
1410
|
+
for (const scriptPath of scriptPaths) {
|
|
1411
|
+
const scriptOutputPath = scriptPath.replace(source, output);
|
|
1412
|
+
const scriptContent = await readFile(scriptPath, 'utf8');
|
|
1413
|
+
await outputFile(scriptOutputPath, scriptContent);
|
|
1414
|
+
}
|
|
1415
|
+
customScript = generateSeparateJsTags(scriptPaths, source);
|
|
1220
1416
|
}
|
|
1221
1417
|
} catch (e) {
|
|
1222
1418
|
// ignore
|
|
@@ -1296,6 +1492,27 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1296
1492
|
// Update hash cache
|
|
1297
1493
|
updateHash(changedFile, rawBody, hashCache);
|
|
1298
1494
|
|
|
1495
|
+
// Update recent-activity.json with this file's new mtime
|
|
1496
|
+
try {
|
|
1497
|
+
const fileStat = await stat(changedFile);
|
|
1498
|
+
const recentActivityPath = join(output, 'public', 'recent-activity.json');
|
|
1499
|
+
let recentActivity = [];
|
|
1500
|
+
try {
|
|
1501
|
+
const existing = await readFile(recentActivityPath, 'utf8');
|
|
1502
|
+
recentActivity = JSON.parse(existing);
|
|
1503
|
+
} catch (e) { /* no existing file, start fresh */ }
|
|
1504
|
+
// Remove old entry for this URL if present
|
|
1505
|
+
recentActivity = recentActivity.filter(r => r.url !== url);
|
|
1506
|
+
// Add updated entry
|
|
1507
|
+
recentActivity.push({ title, url, mtime: fileStat.mtimeMs });
|
|
1508
|
+
// Sort by mtime descending, keep top 10
|
|
1509
|
+
recentActivity.sort((a, b) => b.mtime - a.mtime);
|
|
1510
|
+
recentActivity = recentActivity.slice(0, 10);
|
|
1511
|
+
await outputFile(recentActivityPath, JSON.stringify(recentActivity));
|
|
1512
|
+
} catch (e) {
|
|
1513
|
+
// ignore recent activity update errors
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1299
1516
|
const elapsed = Date.now() - startTime;
|
|
1300
1517
|
const shortFile = changedFile.replace(source, '');
|
|
1301
1518
|
return { success: true, message: `Regenerated ${shortFile} in ${elapsed}ms` };
|