@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
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Asset bundler for Ursa static site generator.
3
+ *
4
+ * Handles two categories of bundling:
5
+ *
6
+ * 1. **Meta template assets**: Bundles all CSS and JS referenced by a template
7
+ * into a single CSS file and a single JS file per template.
8
+ *
9
+ * 2. **Document assets**: Bundles inherited style.css and script.js files.
10
+ * - In generate mode: produces a single bundle per folder path.
11
+ * - In serve mode: returns separate tags for each level (for easy invalidation).
12
+ */
13
+
14
+ import * as esbuild from "esbuild";
15
+ import { join, dirname, basename, relative, resolve } from "path";
16
+ import { readFile, writeFile, mkdir } from "fs/promises";
17
+ import { existsSync } from "fs";
18
+ import { outputFile } from "fs-extra";
19
+
20
+ // Cache for meta bundles so we don't rebuild them per-document
21
+ const metaBundleCache = new Map();
22
+
23
+ /**
24
+ * Parse a template HTML string to extract local CSS and JS asset references.
25
+ * Only extracts assets served from /public/ (meta assets). Ignores CDN URLs,
26
+ * inline scripts, and placeholder variables like ${styleLink}.
27
+ *
28
+ * @param {string} templateHtml - Raw template HTML content
29
+ * @returns {{ cssFiles: string[], jsFiles: string[], cdnCss: string[], cdnJs: string[] }}
30
+ */
31
+ export function parseTemplateAssets(templateHtml) {
32
+ const cssFiles = [];
33
+ const jsFiles = [];
34
+ const cdnCss = [];
35
+ const cdnJs = [];
36
+
37
+ // Match <link rel="stylesheet" href="..."> tags
38
+ const cssPattern = /<link[^>]+rel=["']stylesheet["'][^>]+href=["']([^"']+)["'][^>]*\/?>/gi;
39
+ let match;
40
+ while ((match = cssPattern.exec(templateHtml)) !== null) {
41
+ const href = match[1];
42
+ if (href.startsWith("http://") || href.startsWith("https://")) {
43
+ cdnCss.push(match[0]); // Keep full tag for CDN resources
44
+ } else if (href.startsWith("/public/")) {
45
+ cssFiles.push(href);
46
+ }
47
+ // Skip template variables like ${styleLink}
48
+ }
49
+
50
+ // Match <script src="..."> tags
51
+ const jsPattern = /<script\s+src=["']([^"']+)["'][^>]*>\s*<\/script>/gi;
52
+ while ((match = jsPattern.exec(templateHtml)) !== null) {
53
+ const src = match[1];
54
+ if (src.startsWith("http://") || src.startsWith("https://")) {
55
+ cdnJs.push(match[0]); // Keep full tag for CDN resources
56
+ } else if (src.startsWith("/public/")) {
57
+ jsFiles.push(src);
58
+ }
59
+ }
60
+
61
+ return { cssFiles, jsFiles, cdnCss, cdnJs };
62
+ }
63
+
64
+ /**
65
+ * Rewrite a template to replace individual CSS/JS asset tags with bundled references.
66
+ * Preserves CDN links, inline scripts, and template placeholders.
67
+ *
68
+ * @param {string} templateHtml - Original template HTML
69
+ * @param {string} templateName - Template name (used for bundle filename)
70
+ * @param {{ cssFiles: string[], jsFiles: string[], cdnCss: string[], cdnJs: string[] }} assets - Parsed assets
71
+ * @returns {string} Rewritten template HTML
72
+ */
73
+ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
74
+ let html = templateHtml;
75
+
76
+ // Replace individual CSS <link> tags with a single bundle reference
77
+ if (assets.cssFiles.length > 0) {
78
+ // Remove all individual public CSS <link> tags
79
+ for (const href of assets.cssFiles) {
80
+ // Escape special regex characters in href
81
+ const escaped = href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ const pattern = new RegExp(
83
+ `\\s*<link[^>]+href=["']${escaped}["'][^>]*\\/?>\\s*`,
84
+ "gi"
85
+ );
86
+ html = html.replace(pattern, "\n");
87
+ }
88
+ // Insert single bundle link where the first CSS link was (in <head>)
89
+ const bundleCssTag = ` <link rel="stylesheet" href="/public/${templateName}.bundle.css" />`;
90
+ // Insert after the last CDN CSS or at the position of the first removed tag
91
+ // Best heuristic: insert right before ${styleLink} or before </head>
92
+ if (html.includes("${styleLink}")) {
93
+ html = html.replace("${styleLink}", bundleCssTag + "\n ${styleLink}");
94
+ } else {
95
+ html = html.replace("</head>", bundleCssTag + "\n</head>");
96
+ }
97
+ }
98
+
99
+ // Replace individual JS <script src="/public/..."> tags with a single bundle reference
100
+ if (assets.jsFiles.length > 0) {
101
+ for (const src of assets.jsFiles) {
102
+ const escaped = src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ const pattern = new RegExp(
104
+ `\\s*<script\\s+src=["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`,
105
+ "gi"
106
+ );
107
+ html = html.replace(pattern, "\n");
108
+ }
109
+ // Insert single bundle script before ${customScript} or before </body>
110
+ const bundleJsTag = ` <script src="/public/${templateName}.bundle.js"></script>`;
111
+ if (html.includes("${customScript}")) {
112
+ html = html.replace("${customScript}", bundleJsTag + "\n ${customScript}");
113
+ } else {
114
+ html = html.replace("</body>", bundleJsTag + "\n</body>");
115
+ }
116
+ }
117
+
118
+ // Clean up any runs of multiple blank lines
119
+ html = html.replace(/\n{3,}/g, "\n\n");
120
+
121
+ return html;
122
+ }
123
+
124
+ /**
125
+ * Separate @import rules from other CSS content.
126
+ * CSS spec requires @import rules to appear before any other rules.
127
+ *
128
+ * @param {string} css - CSS content
129
+ * @returns {{ importRules: string[], otherContent: string }}
130
+ */
131
+ function separateImports(css) {
132
+ const importRules = [];
133
+ // Match @import url(...) and @import "..." rules (possibly multiline with media queries)
134
+ const importPattern = /^@import\s+(?:url\([^)]*\)|["'][^"']*["'])[^;]*;/gm;
135
+ const otherContent = css.replace(importPattern, (match) => {
136
+ importRules.push(match.trim());
137
+ return "";
138
+ });
139
+ return { importRules, otherContent: otherContent.trim() };
140
+ }
141
+
142
+ /**
143
+ * Rewrite relative url() references in CSS to be root-relative (site-absolute).
144
+ * Preserves absolute URLs, data URIs, protocol-relative URLs, and already-root-relative URLs.
145
+ *
146
+ * @param {string} css - CSS content
147
+ * @param {string} cssFileDir - Absolute directory of the CSS file
148
+ * @param {string} sourceDir - Absolute source/docroot directory
149
+ * @returns {string} CSS with rebased url() references
150
+ */
151
+ function rebaseCssUrls(css, cssFileDir, sourceDir) {
152
+ // Normalize sourceDir (strip trailing slash for consistent path math)
153
+ const normalizedSource = resolve(sourceDir);
154
+ // Compute the CSS file's site-relative directory, e.g. "campaigns/abs"
155
+ const relDir = relative(normalizedSource, resolve(cssFileDir));
156
+ // Site-absolute prefix, e.g. "/campaigns/abs/"
157
+ const sitePrefix = relDir ? "/" + relDir.replace(/\\/g, "/") + "/" : "/";
158
+
159
+ // Replace url() values that are relative paths
160
+ return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (match, quote, url) => {
161
+ // Skip absolute URLs, data URIs, protocol-relative, and already root-relative
162
+ if (
163
+ url.startsWith("http://") ||
164
+ url.startsWith("https://") ||
165
+ url.startsWith("data:") ||
166
+ url.startsWith("//") ||
167
+ url.startsWith("/") ||
168
+ url.startsWith("#")
169
+ ) {
170
+ return match;
171
+ }
172
+ // Resolve the relative URL against the CSS file's site directory
173
+ // Use URL API for proper relative path resolution (handles ../ etc.)
174
+ try {
175
+ const resolved = new URL(url, "http://x" + sitePrefix).pathname;
176
+ return `url(${quote}${resolved}${quote})`;
177
+ } catch {
178
+ return match; // If URL parsing fails, leave it unchanged
179
+ }
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Bundle CSS files together. Reads and concatenates all files, then minifies
185
+ * with esbuild's transform API. Falls back to unminified concatenation on failure.
186
+ *
187
+ * @param {string[]} filePaths - Absolute paths to CSS files to bundle
188
+ * @param {string} outputPath - Where to write the bundle
189
+ * @param {{ minify: boolean, rebaseUrls: boolean, sourceDir: string }} options
190
+ * - rebaseUrls: if true, rewrite relative url() refs to root-relative paths
191
+ * - sourceDir: required when rebaseUrls is true; the source/docroot directory
192
+ */
193
+ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
194
+ if (filePaths.length === 0) return;
195
+
196
+ const allImports = [];
197
+ const allRules = [];
198
+
199
+ for (const f of filePaths) {
200
+ let css = await readFile(f, "utf8");
201
+
202
+ // Rebase relative url() paths so they work from the bundle's output location
203
+ if (rebaseUrls && sourceDir) {
204
+ css = rebaseCssUrls(css, dirname(f), sourceDir);
205
+ }
206
+
207
+ // Extract @import rules (must be at top of final bundle per CSS spec)
208
+ const { importRules, otherContent } = separateImports(css);
209
+ allImports.push(...importRules);
210
+ if (otherContent) {
211
+ allRules.push(otherContent);
212
+ }
213
+ }
214
+
215
+ // Deduplicate @import rules (same import from multiple files)
216
+ const uniqueImports = [...new Set(allImports)];
217
+ const combined =
218
+ (uniqueImports.length > 0 ? uniqueImports.join("\n") + "\n\n" : "") +
219
+ allRules.join("\n\n");
220
+
221
+ if (minify) {
222
+ try {
223
+ const result = await esbuild.transform(combined, {
224
+ loader: "css",
225
+ minify: true,
226
+ });
227
+ await mkdir(dirname(outputPath), { recursive: true });
228
+ await writeFile(outputPath, result.code);
229
+ return;
230
+ } catch (e) {
231
+ console.warn(`⚠️ CSS minification failed, using unminified bundle: ${e.message}`);
232
+ }
233
+ }
234
+
235
+ // Fallback: write raw concatenated CSS
236
+ await mkdir(dirname(outputPath), { recursive: true });
237
+ await writeFile(outputPath, combined);
238
+ }
239
+
240
+ /**
241
+ * Bundle JS files together. Concatenates all files and optionally minifies.
242
+ * Uses esbuild's transform API for minification (doesn't require valid module syntax).
243
+ * Falls back to raw concatenation if minification fails (e.g., non-standard syntax).
244
+ * If the concatenated code has syntax errors, returns { success: false } so callers
245
+ * can fall back to individual <script> tags.
246
+ *
247
+ * @param {string[]} filePaths - Absolute paths to JS files to bundle
248
+ * @param {string} outputPath - Where to write the bundle
249
+ * @param {{ minify: boolean, minifySyntax: boolean }} options
250
+ * @returns {Promise<{ success: boolean }>}
251
+ */
252
+ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyntax = true } = {}) {
253
+ if (filePaths.length === 0) return { success: false };
254
+
255
+ // Concatenate all JS files with separators
256
+ const contents = [];
257
+ for (const f of filePaths) {
258
+ const code = await readFile(f, "utf8");
259
+ contents.push(`// --- ${basename(f)} ---\n${code}`);
260
+ }
261
+ const combined = contents.join("\n\n");
262
+
263
+ if (minify) {
264
+ try {
265
+ // Use transform API (not build) — it minifies the code without trying to resolve imports
266
+ const result = await esbuild.transform(combined, {
267
+ loader: "js",
268
+ minify: true,
269
+ minifySyntax,
270
+ });
271
+ await mkdir(dirname(outputPath), { recursive: true });
272
+ await writeFile(outputPath, result.code);
273
+ return { success: true };
274
+ } catch (e) {
275
+ // If minification fails (e.g., non-standard syntax), fall back to raw concatenation
276
+ console.warn(`⚠️ JS minification failed, trying unminified bundle: ${e.message}`);
277
+ }
278
+ }
279
+
280
+ // Validate that the concatenated code has no syntax errors before writing.
281
+ // A syntax error in any one file (e.g., toc.js) would prevent ALL scripts from
282
+ // executing when loaded as a single bundle, so it's better to keep individual tags.
283
+ try {
284
+ await esbuild.transform(combined, { loader: "js" });
285
+ } catch (e) {
286
+ console.warn(`⚠️ JS bundle has syntax errors, keeping individual script tags: ${e.message}`);
287
+ return { success: false };
288
+ }
289
+
290
+ // Write raw concatenated code (syntax-valid but unminified)
291
+ await mkdir(dirname(outputPath), { recursive: true });
292
+ await writeFile(outputPath, combined);
293
+ return { success: true };
294
+ }
295
+
296
+ /**
297
+ * Bundle all meta template assets. For each template, parse its CSS/JS references,
298
+ * bundle them into single files, and rewrite the template to use the bundles.
299
+ *
300
+ * @param {Object} templates - Map of templateName → { html, dir } or templateHtml (legacy)
301
+ * @param {string} metaDir - Absolute path to the meta directory
302
+ * @param {string} outputPublicDir - Absolute path to output/public
303
+ * @param {{ minify: boolean, sourcemap: boolean }} options
304
+ * @returns {Promise<Object>} Rewritten templates map (same format as input)
305
+ */
306
+ export async function bundleMetaTemplateAssets(templates, metaDir, outputPublicDir, { minify = true, sourcemap = false } = {}) {
307
+ const rewrittenTemplates = {};
308
+
309
+ for (const [templateName, templateData] of Object.entries(templates)) {
310
+ // Support both new { html, dir } format and legacy string format
311
+ const templateHtml = typeof templateData === 'string' ? templateData : templateData.html;
312
+ const templateDir = typeof templateData === 'string' ? metaDir : (templateData.dir || metaDir);
313
+
314
+ const assets = parseTemplateAssets(templateHtml);
315
+
316
+ // If the template has no bundleable assets, keep it as-is
317
+ if (assets.cssFiles.length === 0 && assets.jsFiles.length === 0) {
318
+ rewrittenTemplates[templateName] = templateHtml;
319
+ continue;
320
+ }
321
+
322
+ // Check cache
323
+ const cacheKey = `${templateName}:${assets.cssFiles.join(",")}:${assets.jsFiles.join(",")}`;
324
+ if (metaBundleCache.has(cacheKey)) {
325
+ rewrittenTemplates[templateName] = metaBundleCache.get(cacheKey);
326
+ continue;
327
+ }
328
+
329
+ // Resolve /public/foo.js → templateDir/foo.js (assets are in template folder)
330
+ // Falls back to metaDir for shared assets
331
+ const resolvePublicPath = (publicPath) => {
332
+ // /public/foo.js → foo.js
333
+ const relativePath = publicPath.replace(/^\/public\//, "");
334
+ // First try template-specific path
335
+ const templatePath = resolve(templateDir, relativePath);
336
+ if (existsSync(templatePath)) {
337
+ return templatePath;
338
+ }
339
+ // Fall back to shared folder
340
+ const sharedPath = resolve(metaDir, 'shared', relativePath);
341
+ if (existsSync(sharedPath)) {
342
+ return sharedPath;
343
+ }
344
+ // Legacy fallback: direct meta path
345
+ return resolve(metaDir, relativePath);
346
+ };
347
+
348
+ // Bundle CSS
349
+ if (assets.cssFiles.length > 0) {
350
+ const cssPaths = assets.cssFiles.map(resolvePublicPath).filter(existsSync);
351
+ if (cssPaths.length > 0) {
352
+ const cssOutputPath = join(outputPublicDir, `${templateName}.bundle.css`);
353
+ await bundleCss(cssPaths, cssOutputPath, { minify });
354
+ }
355
+ }
356
+
357
+ // Bundle JS
358
+ let jsBundleSuccess = false;
359
+ if (assets.jsFiles.length > 0) {
360
+ const jsPaths = assets.jsFiles.map(resolvePublicPath).filter(existsSync);
361
+ if (jsPaths.length > 0) {
362
+ const jsOutputPath = join(outputPublicDir, `${templateName}.bundle.js`);
363
+ const result = await bundleJs(jsPaths, jsOutputPath, { minify, minifySyntax: minify });
364
+ jsBundleSuccess = result.success;
365
+ }
366
+ }
367
+
368
+ // Rewrite template to reference bundles (only rewrite JS if bundling succeeded;
369
+ // if it failed due to syntax errors, keep individual script tags so each script
370
+ // loads independently and a syntax error in one doesn't break all of them)
371
+ const assetsToRewrite = jsBundleSuccess
372
+ ? assets
373
+ : { ...assets, jsFiles: [] }; // empty jsFiles = don't rewrite JS tags
374
+ const rewritten = rewriteTemplateWithBundles(templateHtml, templateName, assetsToRewrite);
375
+ rewrittenTemplates[templateName] = rewritten;
376
+ metaBundleCache.set(cacheKey, rewritten);
377
+ }
378
+
379
+ return rewrittenTemplates;
380
+ }
381
+
382
+ /**
383
+ * Bundle document-level CSS files for a given folder path into a single bundle.
384
+ * Used in generate mode.
385
+ *
386
+ * @param {string[]} cssPaths - Absolute paths to CSS files (ordered shallowest to deepest)
387
+ * @param {string} outputDir - Output directory root
388
+ * @param {string} sourceDir - Source directory root
389
+ * @param {string} folderKey - Folder identifier for naming the bundle (e.g. "campaigns/abs")
390
+ * @param {{ minify: boolean }} options
391
+ * @returns {Promise<string>} URL path to the bundled CSS file
392
+ */
393
+ export async function bundleDocumentCss(cssPaths, outputDir, sourceDir, folderKey, { minify = true } = {}) {
394
+ if (cssPaths.length === 0) return "";
395
+
396
+ // Create a deterministic bundle filename from the folder path
397
+ const safeName = folderKey.replace(/^\/+|\/+$/g, "").replace(/\//g, "-") || "root";
398
+ const bundleFilename = `${safeName}.bundle.css`;
399
+ const bundleOutputPath = join(outputDir, "public", bundleFilename);
400
+
401
+ // Rebase relative url() paths because the bundle lives in /public/
402
+ // while the original CSS files may be in nested directories
403
+ await bundleCss(cssPaths, bundleOutputPath, { minify, rebaseUrls: true, sourceDir });
404
+
405
+ return `/public/${bundleFilename}`;
406
+ }
407
+
408
+ /**
409
+ * Bundle document-level JS files for a given folder path into a single bundle.
410
+ * Used in generate mode.
411
+ *
412
+ * @param {string[]} jsPaths - Absolute paths to JS files (ordered shallowest to deepest)
413
+ * @param {string} outputDir - Output directory root
414
+ * @param {string} sourceDir - Source directory root
415
+ * @param {string} folderKey - Folder identifier for naming the bundle
416
+ * @param {{ minify: boolean }} options
417
+ * @returns {Promise<string>} URL path to the bundled JS file
418
+ */
419
+ export async function bundleDocumentJs(jsPaths, outputDir, sourceDir, folderKey, { minify = true } = {}) {
420
+ if (jsPaths.length === 0) return "";
421
+
422
+ const safeName = folderKey.replace(/^\/+|\/+$/g, "").replace(/\//g, "-") || "root";
423
+ const bundleFilename = `${safeName}.bundle.js`;
424
+ const bundleOutputPath = join(outputDir, "public", bundleFilename);
425
+
426
+ const result = await bundleJs(jsPaths, bundleOutputPath, { minify, minifySyntax: minify });
427
+ if (!result.success) return ""; // Caller should fall back to individual script tags
428
+
429
+ return `/public/${bundleFilename}`;
430
+ }
431
+
432
+ /**
433
+ * Generate separate <link> tags for each document-level CSS file.
434
+ * Used in serve mode where individual file invalidation is important.
435
+ *
436
+ * @param {string[]} cssPaths - Absolute paths to CSS files (ordered shallowest to deepest)
437
+ * @param {string} sourceDir - Source directory root (with trailing slash)
438
+ * @returns {string} HTML string with one <link> tag per CSS file
439
+ */
440
+ export function generateSeparateCssTags(cssPaths, sourceDir) {
441
+ return cssPaths
442
+ .map((cssPath) => {
443
+ const cssUrlPath = "/" + cssPath.replace(sourceDir, "");
444
+ return `<link rel="stylesheet" href="${cssUrlPath}" />`;
445
+ })
446
+ .join("\n ");
447
+ }
448
+
449
+ /**
450
+ * Generate separate <script> tags for each document-level JS file (external, not inlined).
451
+ * Used in serve mode where individual file invalidation is important.
452
+ *
453
+ * @param {string[]} jsPaths - Absolute paths to JS files (ordered shallowest to deepest)
454
+ * @param {string} sourceDir - Source directory root (with trailing slash)
455
+ * @returns {string} HTML string with one <script> tag per JS file
456
+ */
457
+ export function generateSeparateJsTags(jsPaths, sourceDir) {
458
+ return jsPaths
459
+ .map((jsPath) => {
460
+ const jsUrlPath = "/" + jsPath.replace(sourceDir, "");
461
+ return `<script src="${jsUrlPath}"></script>`;
462
+ })
463
+ .join("\n ");
464
+ }
465
+
466
+ /**
467
+ * Clear the meta bundle cache. Should be called when meta files change.
468
+ */
469
+ export function clearMetaBundleCache() {
470
+ metaBundleCache.clear();
471
+ }
@@ -3,13 +3,14 @@ import { existsSync, readFileSync } from "fs";
3
3
  import { readdir, readFile } from "fs/promises";
4
4
  import { basename, dirname, extname, join } from "path";
5
5
  import { outputFile } from "fs-extra";
6
- import { findStyleCss } from "../findStyleCss.js";
6
+ import { findStyleCss, findAllStyleCss } from "../findStyleCss.js";
7
7
  import { findAllScriptJs } from "../findScriptJs.js";
8
8
  import { toTitleCase } from "./titleCase.js";
9
9
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
10
10
  import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
11
11
  import { getCustomMenuForFile } from "./menu.js";
12
12
  import { generateBreadcrumbs } from "../breadcrumbs.js";
13
+ import { bundleDocumentCss, bundleDocumentJs } from "../assetBundler.js";
13
14
 
14
15
  // Document extensions for checking if a folder has content
15
16
  const SOURCE_DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.html'];
@@ -345,42 +346,42 @@ export async function generateAutoIndices(output, directories, source, templates
345
346
  continue;
346
347
  }
347
348
 
348
- // Find nearest style.css for this directory
349
+ // Find all style.css files up the tree and bundle them into a single CSS file
350
+ // (Generate mode: one CSS bundle per unique folder path)
349
351
  let styleLink = "";
350
352
  try {
351
- // Map output dir back to source dir to find style.css
352
353
  const sourceDir = dir.replace(outputNorm, sourceNorm);
353
- const cssPath = await findStyleCss(sourceDir);
354
- if (cssPath) {
355
- // Calculate output path for the CSS file (mirrors source structure)
356
- const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
357
- const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
358
-
359
- // Copy CSS file if not already copied
360
- if (!copiedCssFiles.has(cssPath)) {
361
- const cssContent = await readFile(cssPath, 'utf8');
362
- await outputFile(cssOutputPath, cssContent);
363
- copiedCssFiles.add(cssPath);
354
+ const cssPaths = await findAllStyleCss(sourceDir, sourceNorm);
355
+ if (cssPaths.length > 0) {
356
+ // Copy all CSS files to output
357
+ for (const cssPath of cssPaths) {
358
+ if (!copiedCssFiles.has(cssPath)) {
359
+ const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
360
+ const cssContent = await readFile(cssPath, 'utf8');
361
+ await outputFile(cssOutputPath, cssContent);
362
+ copiedCssFiles.add(cssPath);
363
+ }
364
364
  }
365
-
366
- // Generate link tag
367
- styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
365
+ // Bundle into a single file
366
+ const folderRelative = sourceDir.replace(sourceNorm, '').replace(/^\//, '');
367
+ const bundleUrl = await bundleDocumentCss(cssPaths, outputNorm, sourceNorm, folderRelative, { minify: true });
368
+ styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
368
369
  }
369
370
  } catch (e) {
370
371
  // ignore CSS lookup errors
371
372
  }
372
373
 
373
- // Find all script.js files from docroot to this directory
374
+ // Find all script.js files from docroot to this directory and bundle them
375
+ // (Generate mode: one JS bundle per unique folder path)
374
376
  let customScript = "";
375
377
  try {
376
378
  const sourceDir = dir.replace(outputNorm, sourceNorm);
377
379
  const scriptPaths = await findAllScriptJs(sourceDir, sourceNorm);
378
- const scriptTags = [];
379
- for (const scriptPath of scriptPaths) {
380
- const scriptContent = await readFile(scriptPath, 'utf8');
381
- scriptTags.push(`<script>\n${scriptContent}\n</script>`);
380
+ if (scriptPaths.length > 0) {
381
+ const folderRelative = sourceDir.replace(sourceNorm, '').replace(/^\//, '');
382
+ const bundleUrl = await bundleDocumentJs(scriptPaths, outputNorm, sourceNorm, folderRelative, { minify: true });
383
+ customScript = `<script src="${bundleUrl}"></script>`;
382
384
  }
383
- customScript = scriptTags.join('\n');
384
385
  } catch (e) {
385
386
  // ignore script lookup errors
386
387
  }
@@ -60,3 +60,82 @@ export function addTimestampToHtmlStaticRefs(html, timestamp) {
60
60
  );
61
61
  return html;
62
62
  }
63
+
64
+ /**
65
+ * Generate a short content-based hash for a file's contents.
66
+ * Used for per-file cache-busting so that only changed files invalidate caches.
67
+ * @param {string} content - File content to hash
68
+ * @returns {string} Short hex hash (8 chars)
69
+ */
70
+ export function generateFileHash(content) {
71
+ let hash = 0;
72
+ for (let i = 0; i < content.length; i++) {
73
+ const char = content.charCodeAt(i);
74
+ hash = ((hash << 5) - hash) + char;
75
+ hash = hash & hash; // Convert to 32-bit integer
76
+ }
77
+ return Math.abs(hash).toString(16).padStart(8, "0").substring(0, 8);
78
+ }
79
+
80
+ /**
81
+ * A cache-bust hash map that stores per-file content hashes.
82
+ * When a static file changes, its hash changes, which invalidates
83
+ * any document referencing it via ?v= query parameters.
84
+ */
85
+ export class CacheBustHashMap {
86
+ constructor() {
87
+ /** @type {Map<string, string>} relative file path → content hash */
88
+ this.hashes = new Map();
89
+ /** @type {string} fallback timestamp for files not individually tracked */
90
+ this.fallbackTimestamp = generateCacheBustTimestamp();
91
+ }
92
+
93
+ /**
94
+ * Update the hash for a file.
95
+ * @param {string} relativePath - File path relative to output dir (e.g., "campaigns/abs/style.css")
96
+ * @param {string} content - File content
97
+ * @returns {string} The new hash
98
+ */
99
+ update(relativePath, content) {
100
+ const hash = generateFileHash(content);
101
+ this.hashes.set(relativePath, hash);
102
+ return hash;
103
+ }
104
+
105
+ /**
106
+ * Get the cache-bust version string for a file.
107
+ * Returns the per-file hash if available, otherwise the fallback timestamp.
108
+ * @param {string} relativePath - File path relative to output dir
109
+ * @returns {string} Version string
110
+ */
111
+ getVersion(relativePath) {
112
+ return this.hashes.get(relativePath) || this.fallbackTimestamp;
113
+ }
114
+
115
+ /**
116
+ * Check if a file's hash has changed.
117
+ * @param {string} relativePath
118
+ * @param {string} content
119
+ * @returns {boolean} true if the hash differs from the stored value
120
+ */
121
+ hasChanged(relativePath, content) {
122
+ const newHash = generateFileHash(content);
123
+ const oldHash = this.hashes.get(relativePath);
124
+ return newHash !== oldHash;
125
+ }
126
+
127
+ /**
128
+ * Refresh the fallback timestamp (e.g., at the start of a new build).
129
+ */
130
+ refreshTimestamp() {
131
+ this.fallbackTimestamp = generateCacheBustTimestamp();
132
+ }
133
+
134
+ /**
135
+ * Get the fallback timestamp (for backward compatibility).
136
+ * @returns {string}
137
+ */
138
+ get timestamp() {
139
+ return this.fallbackTimestamp;
140
+ }
141
+ }
@@ -33,6 +33,10 @@ export async function hashFileStats(files) {
33
33
  base === 'index.txt' ||
34
34
  base === 'index.yml' ||
35
35
  base === 'config.json' ||
36
+ base === 'menu.md' ||
37
+ base === 'menu.txt' ||
38
+ base === '_menu.md' ||
39
+ base === '_menu.txt' ||
36
40
  base.endsWith('-icon.png') ||
37
41
  base.endsWith('-icon.svg') ||
38
42
  base === 'icon.png' ||