@kenjura/ursa 0.76.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 (34) hide show
  1. package/CHANGELOG.md +35 -17
  2. package/meta/default.css +33 -0
  3. package/meta/templates/default-template/default.css +1268 -0
  4. package/meta/{default-template.html → templates/default-template/index.html} +15 -0
  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/{widgets.js → templates/default-template/widgets.js} +126 -0
  8. package/package.json +1 -1
  9. package/src/dev.js +73 -28
  10. package/src/helper/assetBundler.js +471 -0
  11. package/src/helper/build/autoIndex.js +24 -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/findStyleCss.js +29 -0
  19. package/src/helper/portUtils.js +132 -0
  20. package/src/jobs/generate.js +228 -60
  21. package/src/serve.js +446 -162
  22. package/meta/character-sheet.css +0 -50
  23. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  24. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  25. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  26. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  27. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  28. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  29. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  30. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  31. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  32. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  33. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  34. /package/meta/{template2.html → templates/template2/index.html} +0 -0
@@ -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";
29
+ import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
30
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]);
@@ -596,65 +629,97 @@ export async function generate({
596
629
  }
597
630
  }
598
631
 
599
- // Find nearest style.css or _style.css up the tree and copy to output
600
- // 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)
601
634
  let styleLink = "";
602
635
  try {
603
- // For root-level files, dir may be "/" which would resolve to filesystem root
604
- // Use source directory directly in that case
605
636
  const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
606
- let cssPath = cssPathCache.get(dirKey);
607
- if (cssPath === undefined) {
608
- cssPath = await findStyleCss(dirKey);
609
- cssPathCache.set(dirKey, cssPath); // Cache null results too
610
- }
611
- if (cssPath) {
612
- // Calculate output path for the CSS file (mirrors source structure)
613
- const cssOutputPath = cssPath.replace(source, output);
614
- const cssUrlPath = '/' + cssPath.replace(source, '');
615
-
616
- // Copy CSS file if not already copied
617
- if (!copiedCssFiles.has(cssPath)) {
618
- const cssContent = await readFile(cssPath, 'utf8');
619
- await outputFile(cssOutputPath, cssContent);
620
- 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);
621
667
  }
622
-
623
- // Generate link tag
624
- styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
625
668
  }
626
669
  } catch (e) {
627
670
  // ignore
628
671
  console.error(e);
629
672
  }
630
673
 
631
- // Find all script.js or _script.js files from docroot to current dir and inline their contents
632
- // 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)
633
676
  let customScript = "";
634
677
  try {
635
678
  const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
636
- let scriptPaths = scriptPathCache.get(dirKey);
637
- if (scriptPaths === undefined) {
638
- scriptPaths = await findAllScriptJs(dirKey, _source);
639
- scriptPathCache.set(dirKey, scriptPaths); // Cache empty arrays too
640
- }
641
- const scriptTags = [];
642
- for (const scriptPath of scriptPaths) {
643
- const scriptContent = await readFile(scriptPath, 'utf8');
644
- scriptTags.push(`<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
+ }
645
699
  }
646
- customScript = scriptTags.join('\n');
647
700
  } catch (e) {
648
701
  // ignore
649
702
  console.error(e);
650
703
  }
651
704
 
652
705
  const requestedTemplateName = fileMeta && fileMeta.template;
653
- const template =
654
- templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
706
+ const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
707
+ const template = templates[templateName];
655
708
 
656
709
  if (!template) {
657
- 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
+ });
658
723
  }
659
724
 
660
725
  // Check if this file has a custom menu
@@ -1026,11 +1091,15 @@ export async function generate({
1026
1091
  watchModeCache.meta = meta;
1027
1092
  watchModeCache.output = output;
1028
1093
  watchModeCache.hashCache = hashCache;
1094
+ watchModeCache.cacheBustTimestamp = cacheBustTimestamp;
1095
+ watchModeCache.cacheBustHashes = cacheBustHashes;
1096
+ watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
1029
1097
  watchModeCache.imageMap = imageMap;
1030
1098
  watchModeCache.customMenus = customMenus;
1031
1099
  watchModeCache.lastFullBuild = Date.now();
1032
1100
  watchModeCache.isInitialized = true;
1033
- 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)`);
1034
1103
 
1035
1104
  // Write error report if there were any errors
1036
1105
  if (errors.length > 0) {
@@ -1085,6 +1154,104 @@ export async function generate({
1085
1154
  };
1086
1155
  }
1087
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
+
1088
1255
  /**
1089
1256
  * Regenerate a single file without scanning the entire source directory.
1090
1257
  * This is much faster for watch mode - only regenerate what changed.
@@ -1204,23 +1371,20 @@ export async function regenerateSingleFile(changedFile, {
1204
1371
  }
1205
1372
  }
1206
1373
 
1207
- // 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)
1208
1376
  let styleLink = "";
1209
1377
  try {
1210
- // For root-level files, dir may be "/" which would resolve to filesystem root
1211
1378
  const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1212
- const cssPath = await findStyleCss(dirKey);
1213
- if (cssPath) {
1214
- // Calculate output path for the CSS file
1215
- const cssOutputPath = cssPath.replace(source, output);
1216
- const cssUrlPath = '/' + cssPath.replace(source, '');
1217
-
1218
- // Copy CSS file (always copy in single-file mode to ensure it's up to date)
1219
- const cssContent = await readFile(cssPath, 'utf8');
1220
- await outputFile(cssOutputPath, cssContent);
1221
-
1222
- // Generate link tag
1223
- 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);
1224
1388
  }
1225
1389
  } catch (e) {
1226
1390
  // ignore
@@ -1235,17 +1399,21 @@ export async function regenerateSingleFile(changedFile, {
1235
1399
  return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
1236
1400
  }
1237
1401
 
1238
- // Find all script.js or _script.js files from docroot to current dir and inline their 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)
1239
1404
  let customScript = "";
1240
1405
  try {
1241
1406
  const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1242
1407
  const scriptPaths = await findAllScriptJs(dirKey, _source);
1243
- const scriptTags = [];
1244
- for (const scriptPath of scriptPaths) {
1245
- const scriptContent = await readFile(scriptPath, 'utf8');
1246
- scriptTags.push(`<script>\n${scriptContent}\n</script>`);
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);
1247
1416
  }
1248
- customScript = scriptTags.join('\n');
1249
1417
  } catch (e) {
1250
1418
  // ignore
1251
1419
  }