@kenjura/ursa 0.52.0 → 0.54.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 +16 -0
- package/meta/menu.js +44 -13
- package/package.json +2 -1
- package/src/helper/automenu.js +23 -12
- package/src/helper/build/autoIndex.js +197 -0
- package/src/helper/build/batch.js +19 -0
- package/src/helper/build/cacheBust.js +62 -0
- package/src/helper/build/excludeFilter.js +67 -0
- package/src/helper/build/footer.js +113 -0
- package/src/helper/build/index.js +13 -0
- package/src/helper/build/menu.js +19 -0
- package/src/helper/build/metadata.js +30 -0
- package/src/helper/build/pathUtils.js +13 -0
- package/src/helper/build/progress.js +35 -0
- package/src/helper/build/templates.js +30 -0
- package/src/helper/build/titleCase.js +7 -0
- package/src/helper/build/watchCache.js +26 -0
- package/src/jobs/generate.js +82 -573
- package/src/serve.js +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
# 0.54.0
|
|
2
|
+
2025-12-21
|
|
3
|
+
|
|
4
|
+
- added cache-busting timestamps to static files
|
|
5
|
+
- cleaned up generate.js by moving helper functions to separate files
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# 0.53.0
|
|
9
|
+
2025-12-21
|
|
10
|
+
|
|
11
|
+
### Menu Size Optimization
|
|
12
|
+
- **External Menu JSON**: Menu data is now stored in `/public/menu-data.json` instead of being embedded in every HTML file. This dramatically reduces HTML file sizes for sites with large folder structures (e.g., from 2-3MB per file down to ~50KB).
|
|
13
|
+
- **Async Menu Loading**: Menu data is fetched asynchronously after page render, showing a "Loading menu..." indicator until ready.
|
|
14
|
+
- **Debug Fields Removed**: Menu JSON no longer includes debug/inactive fields, reducing JSON size further.
|
|
15
|
+
- **Gzip Compression**: Development server now uses gzip compression for all responses, significantly reducing transfer size for JSON and HTML files.
|
|
16
|
+
|
|
1
17
|
# 0.52.0
|
|
2
18
|
2025-12-21
|
|
3
19
|
|
package/meta/menu.js
CHANGED
|
@@ -2,19 +2,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2
2
|
const navMain = document.querySelector('nav#nav-main');
|
|
3
3
|
if (!navMain) return;
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// State - menu data will be loaded asynchronously
|
|
6
|
+
let menuData = null;
|
|
7
|
+
let menuDataLoaded = false;
|
|
8
|
+
let menuDataLoading = false;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
menuData = JSON.parse(menuDataScript.textContent);
|
|
12
|
-
} catch (e) {
|
|
13
|
-
console.error('Failed to parse menu data:', e);
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Load menu config from embedded JSON (contains openMenuItems)
|
|
10
|
+
// Load menu config from embedded JSON (contains openMenuItems) - this is small, so it's embedded
|
|
18
11
|
const menuConfigScript = document.getElementById('menu-config');
|
|
19
12
|
let menuConfig = { openMenuItems: [] };
|
|
20
13
|
if (menuConfigScript) {
|
|
@@ -39,9 +32,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
39
32
|
|
|
40
33
|
// Helper to check if we're on mobile
|
|
41
34
|
const isMobile = () => window.matchMedia('(max-width: 800px)').matches;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load menu data from external JSON file
|
|
38
|
+
* This is done asynchronously to avoid blocking page render
|
|
39
|
+
*/
|
|
40
|
+
async function loadMenuData() {
|
|
41
|
+
if (menuDataLoaded || menuDataLoading) return;
|
|
42
|
+
menuDataLoading = true;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch('/public/menu-data.json');
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`HTTP ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
menuData = await response.json();
|
|
50
|
+
menuDataLoaded = true;
|
|
51
|
+
|
|
52
|
+
// Re-render menu now that we have full data
|
|
53
|
+
initializeFromCurrentPage();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('Failed to load menu data:', error);
|
|
56
|
+
menuDataLoaded = true; // Mark as loaded to prevent retries
|
|
57
|
+
} finally {
|
|
58
|
+
menuDataLoading = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Start loading menu data immediately
|
|
63
|
+
loadMenuData();
|
|
42
64
|
|
|
43
65
|
// Get items at a specific path
|
|
44
66
|
function getItemsAtPath(path) {
|
|
67
|
+
if (!menuData) return [];
|
|
45
68
|
let items = menuData;
|
|
46
69
|
for (const segment of path) {
|
|
47
70
|
const folder = items.find(item => item.path === (path.slice(0, path.indexOf(segment) + 1).join('/') || segment));
|
|
@@ -56,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
56
79
|
|
|
57
80
|
// Find item by path
|
|
58
81
|
function findItemByPath(pathString) {
|
|
82
|
+
if (!menuData) return null;
|
|
59
83
|
const segments = pathString.split('/').filter(Boolean);
|
|
60
84
|
let items = menuData;
|
|
61
85
|
let item = null;
|
|
@@ -98,6 +122,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
98
122
|
|
|
99
123
|
// Render menu at current path
|
|
100
124
|
function renderMenu() {
|
|
125
|
+
// Wait for menu data to load
|
|
126
|
+
if (!menuData) {
|
|
127
|
+
menuContainer.innerHTML = '<li class="menu-loading">Loading menu...</li>';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
// Get items for current level (level 1)
|
|
102
132
|
let level1Items;
|
|
103
133
|
if (currentPath.length === 0) {
|
|
@@ -421,5 +451,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
421
451
|
renderMenu();
|
|
422
452
|
}
|
|
423
453
|
|
|
424
|
-
initializeFromCurrentPage()
|
|
454
|
+
// Initial render shows loading state, then loadMenuData() will call initializeFromCurrentPage() when ready
|
|
455
|
+
renderMenu();
|
|
425
456
|
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@kenjura/ursa",
|
|
3
3
|
"author": "Andrew London <andrew@kenjura.com>",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.54.0",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"url": "git+https://github.com/kenjura/ursa.git"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"compression": "^1.7.4",
|
|
27
28
|
"directory-tree": "^3.3.2",
|
|
28
29
|
"express": "^4.18.2",
|
|
29
30
|
"fs-extra": "^10.1.0",
|
package/src/helper/automenu.js
CHANGED
|
@@ -132,7 +132,8 @@ function resolveHref(rawHref, validPaths) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Build a flat tree structure with path info for JS navigation
|
|
135
|
-
|
|
135
|
+
// Set includeDebug=false to exclude debug fields and reduce JSON size
|
|
136
|
+
function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug = true) {
|
|
136
137
|
const items = [];
|
|
137
138
|
|
|
138
139
|
// Files to hide from menu by default
|
|
@@ -192,14 +193,21 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
192
193
|
label,
|
|
193
194
|
path: folderPath,
|
|
194
195
|
href,
|
|
195
|
-
inactive,
|
|
196
|
-
debug,
|
|
197
196
|
hasChildren,
|
|
198
197
|
icon,
|
|
199
198
|
};
|
|
200
199
|
|
|
200
|
+
// Only include debug and inactive fields if requested (for smaller JSON)
|
|
201
|
+
if (includeDebug) {
|
|
202
|
+
menuItem.inactive = inactive;
|
|
203
|
+
menuItem.debug = debug;
|
|
204
|
+
} else if (inactive) {
|
|
205
|
+
// Only include inactive if true (to save space)
|
|
206
|
+
menuItem.inactive = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
201
209
|
if (hasChildren) {
|
|
202
|
-
menuItem.children = buildMenuData(item, source, validPaths, folderPath);
|
|
210
|
+
menuItem.children = buildMenuData(item, source, validPaths, folderPath, includeDebug);
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
items.push(menuItem);
|
|
@@ -218,7 +226,9 @@ export async function getAutomenu(source, validPaths) {
|
|
|
218
226
|
const tree = dirTree(source, {
|
|
219
227
|
exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
|
|
220
228
|
});
|
|
221
|
-
|
|
229
|
+
|
|
230
|
+
// Build menu data WITHOUT debug fields for smaller JSON
|
|
231
|
+
const menuData = buildMenuData(tree, source, validPaths, '', false);
|
|
222
232
|
|
|
223
233
|
// Get root config for openMenuItems setting
|
|
224
234
|
const rootConfig = getRootConfig(source);
|
|
@@ -227,14 +237,11 @@ export async function getAutomenu(source, validPaths) {
|
|
|
227
237
|
// Add home item with resolved href
|
|
228
238
|
const homeResolved = resolveHref('/', validPaths);
|
|
229
239
|
const fullMenuData = [
|
|
230
|
-
{ label: 'Home', path: '', href: homeResolved.href,
|
|
240
|
+
{ label: 'Home', path: '', href: homeResolved.href, hasChildren: false, icon: `<span class="menu-icon">${HOME_ICON}</span>` },
|
|
231
241
|
...menuData
|
|
232
242
|
];
|
|
233
243
|
|
|
234
|
-
// Embed the
|
|
235
|
-
const menuDataScript = `<script type="application/json" id="menu-data">${JSON.stringify(fullMenuData)}</script>`;
|
|
236
|
-
|
|
237
|
-
// Embed the openMenuItems config as separate JSON
|
|
244
|
+
// Embed the openMenuItems config as JSON (small, safe to embed)
|
|
238
245
|
const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems })}</script>`;
|
|
239
246
|
|
|
240
247
|
// Render the breadcrumb header (hidden by default, shown when navigating)
|
|
@@ -245,10 +252,14 @@ export async function getAutomenu(source, validPaths) {
|
|
|
245
252
|
<span class="menu-current-path"></span>
|
|
246
253
|
</div>`;
|
|
247
254
|
|
|
248
|
-
// Render the initial menu (root level)
|
|
255
|
+
// Render the initial menu (root level only - children loaded from external JSON)
|
|
249
256
|
const menuHtml = renderMenuLevel(fullMenuData, 0);
|
|
250
257
|
|
|
251
|
-
|
|
258
|
+
// Return both the HTML for embedding and the full menu data for the static JSON file
|
|
259
|
+
return {
|
|
260
|
+
html: `${menuConfigScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`,
|
|
261
|
+
menuData: fullMenuData
|
|
262
|
+
};
|
|
252
263
|
}
|
|
253
264
|
|
|
254
265
|
function renderMenuLevel(items, level) {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Auto-index generation helpers for build
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { readdir, readFile } from "fs/promises";
|
|
4
|
+
import { basename, dirname, extname, join } from "path";
|
|
5
|
+
import { outputFile } from "fs-extra";
|
|
6
|
+
import { findStyleCss } from "../findStyleCss.js";
|
|
7
|
+
import { toTitleCase } from "./titleCase.js";
|
|
8
|
+
import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate automatic index.html files for folders that don't have one
|
|
12
|
+
* @param {string} output - Output directory path
|
|
13
|
+
* @param {string[]} directories - List of source directories
|
|
14
|
+
* @param {string} source - Source directory path
|
|
15
|
+
* @param {object} templates - Template map
|
|
16
|
+
* @param {string} menu - Rendered menu HTML
|
|
17
|
+
* @param {string} footer - Footer HTML
|
|
18
|
+
* @param {string[]} generatedArticles - List of source article paths that were generated
|
|
19
|
+
* @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
|
|
20
|
+
* @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
|
|
21
|
+
* @param {string} cacheBustTimestamp - Cache-busting timestamp
|
|
22
|
+
* @param {object} progress - Progress reporter instance
|
|
23
|
+
*/
|
|
24
|
+
export async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress) {
|
|
25
|
+
// Alternate index file names to look for (in priority order)
|
|
26
|
+
const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
|
|
27
|
+
|
|
28
|
+
// Normalize paths (remove trailing slashes for consistent replacement)
|
|
29
|
+
const sourceNorm = source.replace(/\/+$/, '');
|
|
30
|
+
const outputNorm = output.replace(/\/+$/, '');
|
|
31
|
+
|
|
32
|
+
// Build set of directories that already have an index.html from a source index.md/txt/yml
|
|
33
|
+
const dirsWithSourceIndex = new Set();
|
|
34
|
+
for (const articlePath of generatedArticles) {
|
|
35
|
+
const base = basename(articlePath, extname(articlePath));
|
|
36
|
+
if (base === 'index') {
|
|
37
|
+
const dir = dirname(articlePath);
|
|
38
|
+
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
39
|
+
dirsWithSourceIndex.add(outputDir);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get all output directories (including root)
|
|
44
|
+
const outputDirs = new Set([outputNorm]);
|
|
45
|
+
for (const dir of directories) {
|
|
46
|
+
// Handle both with and without trailing slash in source
|
|
47
|
+
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
48
|
+
outputDirs.add(outputDir);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let generatedCount = 0;
|
|
52
|
+
let renamedCount = 0;
|
|
53
|
+
let skippedHtmlCount = 0;
|
|
54
|
+
|
|
55
|
+
for (const dir of outputDirs) {
|
|
56
|
+
const indexPath = join(dir, 'index.html');
|
|
57
|
+
|
|
58
|
+
// Skip if this directory had a source index.md/txt/yml that was already processed
|
|
59
|
+
if (dirsWithSourceIndex.has(dir)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if there's an existing index.html in the source directory (don't overwrite it)
|
|
64
|
+
const sourceDir = dir.replace(outputNorm, sourceNorm);
|
|
65
|
+
const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
|
|
66
|
+
if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
|
|
67
|
+
skippedHtmlCount++;
|
|
68
|
+
continue; // Don't overwrite existing source HTML
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Skip if index.html already exists in output (e.g., created by previous run)
|
|
72
|
+
if (existsSync(indexPath)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Get folder name for (foldername).html check
|
|
77
|
+
const folderName = basename(dir);
|
|
78
|
+
const folderNameAlternate = `${folderName}.html`;
|
|
79
|
+
|
|
80
|
+
// Check for alternate index files
|
|
81
|
+
let foundAlternate = null;
|
|
82
|
+
for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
|
|
83
|
+
const altPath = join(dir, alt);
|
|
84
|
+
if (existsSync(altPath)) {
|
|
85
|
+
foundAlternate = altPath;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (foundAlternate) {
|
|
91
|
+
// Rename/copy alternate to index.html
|
|
92
|
+
try {
|
|
93
|
+
const content = await readFile(foundAlternate, 'utf8');
|
|
94
|
+
await outputFile(indexPath, content);
|
|
95
|
+
renamedCount++;
|
|
96
|
+
progress.status('Auto-index', `Promoted ${basename(foundAlternate)} → index.html in ${dir.replace(outputNorm, '') || '/'}`);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Generate a simple index listing direct children
|
|
102
|
+
try {
|
|
103
|
+
const children = await readdir(dir, { withFileTypes: true });
|
|
104
|
+
|
|
105
|
+
// Filter to only include relevant files and folders
|
|
106
|
+
const items = children
|
|
107
|
+
.filter(child => {
|
|
108
|
+
// Skip hidden files and index alternates we just checked
|
|
109
|
+
if (child.name.startsWith('.')) return false;
|
|
110
|
+
if (child.name === 'index.html') return false;
|
|
111
|
+
// Include directories and html files
|
|
112
|
+
return child.isDirectory() || child.name.endsWith('.html');
|
|
113
|
+
})
|
|
114
|
+
.map(child => {
|
|
115
|
+
const isDir = child.isDirectory();
|
|
116
|
+
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
117
|
+
const href = isDir ? `${child.name}/` : child.name;
|
|
118
|
+
const displayName = toTitleCase(name);
|
|
119
|
+
const icon = isDir ? '📁' : '📄';
|
|
120
|
+
return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (items.length === 0) {
|
|
124
|
+
// Empty folder, skip generating index
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
|
|
129
|
+
const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
130
|
+
|
|
131
|
+
const template = templates["default-template"];
|
|
132
|
+
if (!template) {
|
|
133
|
+
progress.log(`Warning: No default template for auto-index in ${dir}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find nearest style.css for this directory
|
|
138
|
+
let styleLink = "";
|
|
139
|
+
try {
|
|
140
|
+
// Map output dir back to source dir to find style.css
|
|
141
|
+
const sourceDir = dir.replace(outputNorm, sourceNorm);
|
|
142
|
+
const cssPath = await findStyleCss(sourceDir);
|
|
143
|
+
if (cssPath) {
|
|
144
|
+
// Calculate output path for the CSS file (mirrors source structure)
|
|
145
|
+
const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
|
|
146
|
+
const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
|
|
147
|
+
|
|
148
|
+
// Copy CSS file if not already copied
|
|
149
|
+
if (!copiedCssFiles.has(cssPath)) {
|
|
150
|
+
const cssContent = await readFile(cssPath, 'utf8');
|
|
151
|
+
await outputFile(cssOutputPath, cssContent);
|
|
152
|
+
copiedCssFiles.add(cssPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Generate link tag
|
|
156
|
+
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
157
|
+
}
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// ignore CSS lookup errors
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let finalHtml = template;
|
|
163
|
+
const replacements = {
|
|
164
|
+
"${menu}": menu,
|
|
165
|
+
"${body}": indexHtml,
|
|
166
|
+
"${searchIndex}": "[]",
|
|
167
|
+
"${title}": folderDisplayName,
|
|
168
|
+
"${meta}": "{}",
|
|
169
|
+
"${transformedMetadata}": "",
|
|
170
|
+
"${styleLink}": styleLink,
|
|
171
|
+
"${footer}": footer
|
|
172
|
+
};
|
|
173
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
174
|
+
finalHtml = finalHtml.replace(key, value);
|
|
175
|
+
}
|
|
176
|
+
// Add cache-busting timestamps to static file references
|
|
177
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
178
|
+
|
|
179
|
+
await outputFile(indexPath, finalHtml);
|
|
180
|
+
generatedCount++;
|
|
181
|
+
progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
|
|
189
|
+
let summary = `${generatedCount} generated, ${renamedCount} promoted`;
|
|
190
|
+
if (skippedHtmlCount > 0) {
|
|
191
|
+
summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
|
|
192
|
+
}
|
|
193
|
+
progress.done('Auto-index', summary);
|
|
194
|
+
} else {
|
|
195
|
+
progress.log(`Auto-index: All folders already have index.html`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Batch processing helpers for build
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process items in batches to limit memory usage
|
|
5
|
+
* @param {Array} items - Items to process
|
|
6
|
+
* @param {Function} processor - Async function to process each item
|
|
7
|
+
* @param {number} batchSize - Max concurrent operations
|
|
8
|
+
*/
|
|
9
|
+
export async function processBatched(items, processor, batchSize = 50) {
|
|
10
|
+
const results = [];
|
|
11
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
12
|
+
const batch = items.slice(i, i + batchSize);
|
|
13
|
+
const batchResults = await Promise.all(batch.map(processor));
|
|
14
|
+
results.push(...batchResults);
|
|
15
|
+
// Allow GC to run between batches
|
|
16
|
+
if (global.gc) global.gc();
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Cache busting helpers for build
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a cache-busting timestamp in ISO format (e.g., 20251221T221700Z)
|
|
5
|
+
* @returns {string} Timestamp string suitable for query params
|
|
6
|
+
*/
|
|
7
|
+
export function generateCacheBustTimestamp() {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const year = now.getUTCFullYear();
|
|
10
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
11
|
+
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
12
|
+
const hours = String(now.getUTCHours()).padStart(2, '0');
|
|
13
|
+
const minutes = String(now.getUTCMinutes()).padStart(2, '0');
|
|
14
|
+
const seconds = String(now.getUTCSeconds()).padStart(2, '0');
|
|
15
|
+
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Add cache-busting timestamp to url() references in CSS content
|
|
20
|
+
* @param {string} cssContent - The CSS file content
|
|
21
|
+
* @param {string} timestamp - The cache-busting timestamp
|
|
22
|
+
* @returns {string} CSS with timestamped URLs
|
|
23
|
+
*/
|
|
24
|
+
export function addTimestampToCssUrls(cssContent, timestamp) {
|
|
25
|
+
// Match url(...) in any context, including CSS variables, with optional whitespace and quotes
|
|
26
|
+
// Exclude data: URLs and already-timestamped URLs
|
|
27
|
+
return cssContent.replace(
|
|
28
|
+
/url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi,
|
|
29
|
+
(match, quote, url) => {
|
|
30
|
+
// Don't add timestamp if already has query string
|
|
31
|
+
if (url.includes('?')) {
|
|
32
|
+
return match;
|
|
33
|
+
}
|
|
34
|
+
return `url(${quote}${url}?v=${timestamp}${quote})`;
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add cache-busting timestamp to static file references in HTML
|
|
41
|
+
* @param {string} html - The HTML content
|
|
42
|
+
* @param {string} timestamp - The cache-busting timestamp
|
|
43
|
+
* @returns {string} HTML with timestamped static file references
|
|
44
|
+
*/
|
|
45
|
+
export function addTimestampToHtmlStaticRefs(html, timestamp) {
|
|
46
|
+
// Add timestamp to CSS links
|
|
47
|
+
html = html.replace(
|
|
48
|
+
/(<link[^>]+href=["'])([^"']+\.css)(["'][^>]*>)/gi,
|
|
49
|
+
`$1$2?v=${timestamp}$3`
|
|
50
|
+
);
|
|
51
|
+
// Add timestamp to JS scripts
|
|
52
|
+
html = html.replace(
|
|
53
|
+
/(<script[^>]+src=["'])([^"']+\.js)(["'][^>]*>)/gi,
|
|
54
|
+
`$1$2?v=${timestamp}$3`
|
|
55
|
+
);
|
|
56
|
+
// Add timestamp to images in img tags
|
|
57
|
+
html = html.replace(
|
|
58
|
+
/(<img[^>]+src=["'])([^"']+\.(jpg|jpeg|png|gif|webp|svg|ico))(["'][^>]*>)/gi,
|
|
59
|
+
`$1$2?v=${timestamp}$4`
|
|
60
|
+
);
|
|
61
|
+
return html;
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Exclude/filter helpers for build
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { readFile, stat } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse exclude option - can be comma-separated paths or a file path
|
|
7
|
+
* @param {string} excludeOption - The exclude option value
|
|
8
|
+
* @param {string} source - Source directory path
|
|
9
|
+
* @returns {Promise<Set<string>>} Set of excluded folder paths (normalized)
|
|
10
|
+
*/
|
|
11
|
+
export async function parseExcludeOption(excludeOption, source) {
|
|
12
|
+
const excludedPaths = new Set();
|
|
13
|
+
|
|
14
|
+
if (!excludeOption) return excludedPaths;
|
|
15
|
+
|
|
16
|
+
// Check if it's a file path (exists as a file)
|
|
17
|
+
const isFile = existsSync(excludeOption) && (await stat(excludeOption)).isFile();
|
|
18
|
+
|
|
19
|
+
let patterns;
|
|
20
|
+
if (isFile) {
|
|
21
|
+
// Read patterns from file (one per line)
|
|
22
|
+
const content = await readFile(excludeOption, 'utf8');
|
|
23
|
+
patterns = content.split('\n')
|
|
24
|
+
.map(line => line.trim())
|
|
25
|
+
.filter(line => line && !line.startsWith('#')); // Skip empty lines and comments
|
|
26
|
+
} else {
|
|
27
|
+
// Treat as comma-separated list
|
|
28
|
+
patterns = excludeOption.split(',').map(p => p.trim()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Normalize patterns to absolute paths
|
|
32
|
+
for (const pattern of patterns) {
|
|
33
|
+
// Remove leading/trailing slashes and normalize
|
|
34
|
+
const normalized = pattern.replace(/^\/+|\/+$/g, '');
|
|
35
|
+
// Store as relative path for easier matching
|
|
36
|
+
excludedPaths.add(normalized);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return excludedPaths;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a filter function that excludes files in specified folders
|
|
44
|
+
* @param {Set<string>} excludedPaths - Set of excluded folder paths
|
|
45
|
+
* @param {string} source - Source directory path
|
|
46
|
+
* @returns {Function} Filter function
|
|
47
|
+
*/
|
|
48
|
+
export function createExcludeFilter(excludedPaths, source) {
|
|
49
|
+
if (excludedPaths.size === 0) {
|
|
50
|
+
return () => true; // No exclusions, allow all
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (filePath) => {
|
|
54
|
+
// Get path relative to source
|
|
55
|
+
const relativePath = filePath.replace(source, '').replace(/^\/+/, '');
|
|
56
|
+
|
|
57
|
+
// Check if file is in any excluded folder
|
|
58
|
+
for (const excluded of excludedPaths) {
|
|
59
|
+
if (relativePath === excluded ||
|
|
60
|
+
relativePath.startsWith(excluded + '/') ||
|
|
61
|
+
relativePath.startsWith(excluded + '\\')) {
|
|
62
|
+
return false; // Exclude this file
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return true; // Include this file
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Footer generation helpers for build
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "path";
|
|
5
|
+
import { URL } from "url";
|
|
6
|
+
import { renderFile } from "../fileRenderer.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate footer HTML from footer.md and package.json
|
|
10
|
+
* @param {string} source - resolved source path with trailing slash
|
|
11
|
+
* @param {string} _source - original source path
|
|
12
|
+
* @param {number} buildId - the current build ID
|
|
13
|
+
* @returns {Promise<string>} Footer HTML
|
|
14
|
+
*/
|
|
15
|
+
export async function getFooter(source, _source, buildId) {
|
|
16
|
+
const footerParts = [];
|
|
17
|
+
|
|
18
|
+
// Try to read footer.md from source root
|
|
19
|
+
const footerPath = join(source, 'footer.md');
|
|
20
|
+
try {
|
|
21
|
+
if (existsSync(footerPath)) {
|
|
22
|
+
const footerMd = await readFile(footerPath, 'utf8');
|
|
23
|
+
const footerHtml = renderFile({ fileContents: footerMd, type: '.md' });
|
|
24
|
+
footerParts.push(`<div class="footer-content">${footerHtml}</div>`);
|
|
25
|
+
}
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(`Error reading footer.md: ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Try to read package.json from doc repo (check both source dir and parent)
|
|
31
|
+
let docPackage = null;
|
|
32
|
+
const sourceDir = resolve(_source);
|
|
33
|
+
const packagePaths = [
|
|
34
|
+
join(sourceDir, 'package.json'), // In source dir itself
|
|
35
|
+
join(sourceDir, '..', 'package.json'), // One level up (if docs is a subfolder)
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
for (const packagePath of packagePaths) {
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(packagePath)) {
|
|
41
|
+
const packageJson = await readFile(packagePath, 'utf8');
|
|
42
|
+
docPackage = JSON.parse(packageJson);
|
|
43
|
+
console.log(`Found doc package.json at ${packagePath}`);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Continue to next path
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Get ursa version from ursa's own package.json
|
|
52
|
+
// Use import.meta.url to find the package.json relative to this file
|
|
53
|
+
let ursaVersion = 'unknown';
|
|
54
|
+
try {
|
|
55
|
+
// From src/helper/build/footer.js, go up to package root
|
|
56
|
+
const currentFileUrl = new URL(import.meta.url);
|
|
57
|
+
const currentDir = dirname(currentFileUrl.pathname);
|
|
58
|
+
const ursaPackagePath = resolve(currentDir, '..', '..', '..', 'package.json');
|
|
59
|
+
|
|
60
|
+
if (existsSync(ursaPackagePath)) {
|
|
61
|
+
const ursaPackageJson = await readFile(ursaPackagePath, 'utf8');
|
|
62
|
+
const ursaPackage = JSON.parse(ursaPackageJson);
|
|
63
|
+
ursaVersion = ursaPackage.version;
|
|
64
|
+
console.log(`Found ursa package.json at ${ursaPackagePath}, version: ${ursaVersion}`);
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(`Error reading ursa package.json: ${e.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build meta line: version, build id, timestamp, "generated by ursa"
|
|
71
|
+
const metaParts = [];
|
|
72
|
+
if (docPackage?.version) {
|
|
73
|
+
metaParts.push(`v${docPackage.version}`);
|
|
74
|
+
}
|
|
75
|
+
metaParts.push(`build ${buildId}`);
|
|
76
|
+
|
|
77
|
+
// Full date/time in a readable format
|
|
78
|
+
const now = new Date();
|
|
79
|
+
const timestamp = now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
|
|
80
|
+
metaParts.push(timestamp);
|
|
81
|
+
|
|
82
|
+
metaParts.push(`Generated by <a href="https://www.npmjs.com/package/@kenjura/ursa">ursa</a> v${ursaVersion}`);
|
|
83
|
+
|
|
84
|
+
footerParts.push(`<div class="footer-meta">${metaParts.join(' • ')}</div>`);
|
|
85
|
+
|
|
86
|
+
// Copyright line from doc package.json
|
|
87
|
+
if (docPackage?.copyright) {
|
|
88
|
+
footerParts.push(`<div class="footer-copyright">${docPackage.copyright}</div>`);
|
|
89
|
+
} else if (docPackage?.author) {
|
|
90
|
+
const year = new Date().getFullYear();
|
|
91
|
+
const author = typeof docPackage.author === 'string' ? docPackage.author : docPackage.author.name;
|
|
92
|
+
if (author) {
|
|
93
|
+
footerParts.push(`<div class="footer-copyright">© ${year} ${author}</div>`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Try to get git short hash of doc repo (as HTML comment)
|
|
98
|
+
try {
|
|
99
|
+
const { execSync } = await import('child_process');
|
|
100
|
+
const gitHash = execSync('git rev-parse --short HEAD', {
|
|
101
|
+
cwd: resolve(_source),
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
104
|
+
}).trim();
|
|
105
|
+
if (gitHash) {
|
|
106
|
+
footerParts.push(`<!-- git: ${gitHash} -->`);
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// Not a git repo or git not available - silently skip
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return footerParts.join('\n');
|
|
113
|
+
}
|