@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/meta/default.css +149 -3
  3. package/meta/templates/default-template/default.css +1268 -0
  4. package/meta/{default-template.html → templates/default-template/index.html} +49 -2
  5. package/meta/{menu.js → templates/default-template/menu.js} +1 -1
  6. package/meta/templates/default-template/sectionify.js +46 -0
  7. package/meta/templates/default-template/widgets.js +701 -0
  8. package/package.json +1 -1
  9. package/src/dev.js +125 -34
  10. package/src/helper/assetBundler.js +471 -0
  11. package/src/helper/build/autoIndex.js +26 -23
  12. package/src/helper/build/cacheBust.js +79 -0
  13. package/src/helper/build/navCache.js +4 -0
  14. package/src/helper/build/templates.js +176 -19
  15. package/src/helper/build/watchCache.js +7 -0
  16. package/src/helper/customMenu.js +4 -2
  17. package/src/helper/dependencyTracker.js +269 -0
  18. package/src/helper/findScriptJs.js +29 -0
  19. package/src/helper/findStyleCss.js +29 -0
  20. package/src/helper/portUtils.js +132 -0
  21. package/src/jobs/generate.js +276 -59
  22. package/src/serve.js +446 -162
  23. package/meta/character-sheet.css +0 -50
  24. package/meta/widgets.js +0 -376
  25. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  26. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  27. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  28. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  29. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  30. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  31. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  32. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  33. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  34. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  35. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  36. /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
+ }
@@ -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
- await copyDir(meta, pub);
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 nearest style.css or _style.css up the tree and copy to output
586
- // Use cache to avoid repeated filesystem walks for same directory
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
- let cssPath = cssPathCache.get(dirKey);
593
- if (cssPath === undefined) {
594
- cssPath = await findStyleCss(dirKey);
595
- cssPathCache.set(dirKey, cssPath); // Cache null results too
596
- }
597
- if (cssPath) {
598
- // Calculate output path for the CSS file (mirrors source structure)
599
- const cssOutputPath = cssPath.replace(source, output);
600
- const cssUrlPath = '/' + cssPath.replace(source, '');
601
-
602
- // Copy CSS file if not already copied
603
- if (!copiedCssFiles.has(cssPath)) {
604
- const cssContent = await readFile(cssPath, 'utf8');
605
- await outputFile(cssOutputPath, cssContent);
606
- copiedCssFiles.add(cssPath);
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 nearest script.js or _script.js up the tree and inline its contents
618
- // Use cache to avoid repeated filesystem walks for same directory
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
- let scriptPath = scriptPathCache.get(dirKey);
623
- if (scriptPath === undefined) {
624
- scriptPath = await findScriptJs(dirKey);
625
- scriptPathCache.set(dirKey, scriptPath); // Cache null results too
626
- }
627
- if (scriptPath) {
628
- const scriptContent = await readFile(scriptPath, 'utf8');
629
- // Inline the script content in a script tag
630
- customScript = `<script>\n${scriptContent}\n</script>`;
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 template =
639
- templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
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: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
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
- progress.log(`Watch cache initialized for fast single-file regeneration`);
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 copy to output
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 cssPath = await findStyleCss(dirKey);
1187
- if (cssPath) {
1188
- // Calculate output path for the CSS file
1189
- const cssOutputPath = cssPath.replace(source, output);
1190
- const cssUrlPath = '/' + cssPath.replace(source, '');
1191
-
1192
- // Copy CSS file (always copy in single-file mode to ensure it's up to date)
1193
- const cssContent = await readFile(cssPath, 'utf8');
1194
- await outputFile(cssOutputPath, cssContent);
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 nearest script.js or _script.js and inline its contents
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 scriptPath = await findScriptJs(dirKey);
1217
- if (scriptPath) {
1218
- const scriptContent = await readFile(scriptPath, 'utf8');
1219
- customScript = `<script>\n${scriptContent}\n</script>`;
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` };