@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.
- package/CHANGELOG.md +35 -17
- package/meta/default.css +33 -0
- package/meta/templates/default-template/default.css +1268 -0
- package/meta/{default-template.html → templates/default-template/index.html} +15 -0
- package/meta/{menu.js → templates/default-template/menu.js} +1 -1
- package/meta/templates/default-template/sectionify.js +46 -0
- package/meta/{widgets.js → templates/default-template/widgets.js} +126 -0
- package/package.json +1 -1
- package/src/dev.js +73 -28
- package/src/helper/assetBundler.js +471 -0
- package/src/helper/build/autoIndex.js +24 -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/findStyleCss.js +29 -0
- package/src/helper/portUtils.js +132 -0
- package/src/jobs/generate.js +228 -60
- package/src/serve.js +446 -162
- package/meta/character-sheet.css +0 -50
- /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,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
|
|
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
|
|
354
|
-
if (
|
|
355
|
-
//
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
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' ||
|