@kenjura/ursa 0.53.0 → 0.55.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 +13 -1
- package/meta/default.css +44 -9
- package/meta/menu.js +237 -2
- package/meta/search.js +41 -0
- package/package.json +1 -1
- 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 +103 -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/helper/customMenu.js +303 -0
- package/src/jobs/generate.js +97 -561
package/src/jobs/generate.js
CHANGED
|
@@ -1,108 +1,5 @@
|
|
|
1
1
|
import { recurse } from "../helper/recursive-readdir.js";
|
|
2
|
-
|
|
3
2
|
import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
|
|
4
|
-
|
|
5
|
-
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
6
|
-
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Cache for watch mode - stores expensive data that doesn't change often
|
|
10
|
-
* This allows single-file regeneration to skip re-building menu, templates, etc.
|
|
11
|
-
*/
|
|
12
|
-
const watchModeCache = {
|
|
13
|
-
templates: null,
|
|
14
|
-
menu: null,
|
|
15
|
-
footer: null,
|
|
16
|
-
validPaths: null,
|
|
17
|
-
source: null,
|
|
18
|
-
meta: null,
|
|
19
|
-
output: null,
|
|
20
|
-
hashCache: null,
|
|
21
|
-
lastFullBuild: 0,
|
|
22
|
-
isInitialized: false,
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Clear the watch mode cache (call when templates/meta/config change)
|
|
27
|
-
*/
|
|
28
|
-
export function clearWatchCache() {
|
|
29
|
-
watchModeCache.templates = null;
|
|
30
|
-
watchModeCache.menu = null;
|
|
31
|
-
watchModeCache.footer = null;
|
|
32
|
-
watchModeCache.validPaths = null;
|
|
33
|
-
watchModeCache.hashCache = null;
|
|
34
|
-
watchModeCache.isInitialized = false;
|
|
35
|
-
cssPathCache.clear(); // Also clear CSS path cache
|
|
36
|
-
console.log('Watch cache cleared');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Progress reporter that updates lines in place (like pnpm)
|
|
41
|
-
*/
|
|
42
|
-
class ProgressReporter {
|
|
43
|
-
constructor() {
|
|
44
|
-
this.lines = {};
|
|
45
|
-
this.isTTY = process.stdout.isTTY;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Update a named status line in place
|
|
49
|
-
status(name, message) {
|
|
50
|
-
if (this.isTTY) {
|
|
51
|
-
// Save cursor, move to line, clear it, write, restore cursor
|
|
52
|
-
const line = `${name}: ${message}`;
|
|
53
|
-
this.lines[name] = line;
|
|
54
|
-
// Clear line and write
|
|
55
|
-
process.stdout.write(`\r\x1b[K${line}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Complete a status line (print final state and newline)
|
|
60
|
-
done(name, message) {
|
|
61
|
-
if (this.isTTY) {
|
|
62
|
-
process.stdout.write(`\r\x1b[K${name}: ${message}\n`);
|
|
63
|
-
} else {
|
|
64
|
-
console.log(`${name}: ${message}`);
|
|
65
|
-
}
|
|
66
|
-
delete this.lines[name];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Regular log that doesn't get overwritten
|
|
70
|
-
log(message) {
|
|
71
|
-
if (this.isTTY) {
|
|
72
|
-
// Clear current line first, print message, then newline
|
|
73
|
-
process.stdout.write(`\r\x1b[K${message}\n`);
|
|
74
|
-
} else {
|
|
75
|
-
console.log(message);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Clear all status lines
|
|
80
|
-
clear() {
|
|
81
|
-
if (this.isTTY) {
|
|
82
|
-
process.stdout.write(`\r\x1b[K`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const progress = new ProgressReporter();
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Process items in batches to limit memory usage
|
|
91
|
-
* @param {Array} items - Items to process
|
|
92
|
-
* @param {Function} processor - Async function to process each item
|
|
93
|
-
* @param {number} batchSize - Max concurrent operations
|
|
94
|
-
*/
|
|
95
|
-
async function processBatched(items, processor, batchSize = BATCH_SIZE) {
|
|
96
|
-
const results = [];
|
|
97
|
-
for (let i = 0; i < items.length; i += batchSize) {
|
|
98
|
-
const batch = items.slice(i, i + batchSize);
|
|
99
|
-
const batchResults = await Promise.all(batch.map(processor));
|
|
100
|
-
results.push(...batchResults);
|
|
101
|
-
// Allow GC to run between batches
|
|
102
|
-
if (global.gc) global.gc();
|
|
103
|
-
}
|
|
104
|
-
return results;
|
|
105
|
-
}
|
|
106
3
|
import { getAutomenu } from "../helper/automenu.js";
|
|
107
4
|
import { filterAsync } from "../helper/filterAsync.js";
|
|
108
5
|
import { isDirectory } from "../helper/isDirectory.js";
|
|
@@ -124,38 +21,6 @@ import {
|
|
|
124
21
|
} from "../helper/linkValidator.js";
|
|
125
22
|
import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
|
|
126
23
|
import { extractSections } from "../helper/sectionExtractor.js";
|
|
127
|
-
|
|
128
|
-
// Helper function to build search index from processed files
|
|
129
|
-
function buildSearchIndex(jsonCache, source, output) {
|
|
130
|
-
const searchIndex = [];
|
|
131
|
-
|
|
132
|
-
for (const [filePath, jsonObject] of jsonCache.entries()) {
|
|
133
|
-
// Generate URL path relative to output
|
|
134
|
-
const relativePath = filePath.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
|
|
135
|
-
const url = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
136
|
-
|
|
137
|
-
// Extract text content from body (strip HTML tags for search)
|
|
138
|
-
const textContent = jsonObject.bodyHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
139
|
-
const excerpt = textContent.substring(0, 200); // First 200 chars for preview
|
|
140
|
-
|
|
141
|
-
searchIndex.push({
|
|
142
|
-
title: toTitleCase(jsonObject.name),
|
|
143
|
-
path: relativePath,
|
|
144
|
-
url: url,
|
|
145
|
-
content: excerpt
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return searchIndex;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Helper function to convert filename to title case
|
|
153
|
-
function toTitleCase(filename) {
|
|
154
|
-
return filename
|
|
155
|
-
.split(/[-_\s]+/) // Split on hyphens, underscores, and spaces
|
|
156
|
-
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
157
|
-
.join(' ');
|
|
158
|
-
}
|
|
159
24
|
import { renderFile } from "../helper/fileRenderer.js";
|
|
160
25
|
import { findStyleCss } from "../helper/findStyleCss.js";
|
|
161
26
|
import { copy as copyDir, emptyDir, outputFile } from "fs-extra";
|
|
@@ -164,78 +29,45 @@ import { URL } from "url";
|
|
|
164
29
|
import o2x from "object-to-xml";
|
|
165
30
|
import { existsSync } from "fs";
|
|
166
31
|
import { fileExists } from "../helper/fileExists.js";
|
|
167
|
-
|
|
168
32
|
import { createWhitelistFilter } from "../helper/whitelistFilter.js";
|
|
169
33
|
|
|
170
|
-
|
|
171
|
-
|
|
34
|
+
// Import build helpers from organized modules
|
|
35
|
+
import {
|
|
36
|
+
generateCacheBustTimestamp,
|
|
37
|
+
addTimestampToCssUrls,
|
|
38
|
+
addTimestampToHtmlStaticRefs,
|
|
39
|
+
processBatched,
|
|
40
|
+
ProgressReporter,
|
|
41
|
+
watchModeCache,
|
|
42
|
+
clearWatchCache as clearWatchCacheBase,
|
|
43
|
+
toTitleCase,
|
|
44
|
+
parseExcludeOption,
|
|
45
|
+
createExcludeFilter,
|
|
46
|
+
addTrailingSlash,
|
|
47
|
+
getTemplates,
|
|
48
|
+
getMenu,
|
|
49
|
+
findAllCustomMenus,
|
|
50
|
+
getCustomMenuForFile,
|
|
51
|
+
getTransformedMetadata,
|
|
52
|
+
getFooter,
|
|
53
|
+
generateAutoIndices,
|
|
54
|
+
} from "../helper/build/index.js";
|
|
55
|
+
|
|
56
|
+
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
57
|
+
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
172
58
|
|
|
173
59
|
// Cache for CSS path lookups to avoid repeated filesystem walks
|
|
174
60
|
const cssPathCache = new Map();
|
|
175
61
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
* @param {string} source - Source directory path
|
|
180
|
-
* @returns {Promise<Set<string>>} Set of excluded folder paths (normalized)
|
|
181
|
-
*/
|
|
182
|
-
async function parseExcludeOption(excludeOption, source) {
|
|
183
|
-
const excludedPaths = new Set();
|
|
184
|
-
|
|
185
|
-
if (!excludeOption) return excludedPaths;
|
|
186
|
-
|
|
187
|
-
// Check if it's a file path (exists as a file)
|
|
188
|
-
const isFile = existsSync(excludeOption) && (await stat(excludeOption)).isFile();
|
|
189
|
-
|
|
190
|
-
let patterns;
|
|
191
|
-
if (isFile) {
|
|
192
|
-
// Read patterns from file (one per line)
|
|
193
|
-
const content = await readFile(excludeOption, 'utf8');
|
|
194
|
-
patterns = content.split('\n')
|
|
195
|
-
.map(line => line.trim())
|
|
196
|
-
.filter(line => line && !line.startsWith('#')); // Skip empty lines and comments
|
|
197
|
-
} else {
|
|
198
|
-
// Treat as comma-separated list
|
|
199
|
-
patterns = excludeOption.split(',').map(p => p.trim()).filter(Boolean);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Normalize patterns to absolute paths
|
|
203
|
-
for (const pattern of patterns) {
|
|
204
|
-
// Remove leading/trailing slashes and normalize
|
|
205
|
-
const normalized = pattern.replace(/^\/+|\/+$/g, '');
|
|
206
|
-
// Store as relative path for easier matching
|
|
207
|
-
excludedPaths.add(normalized);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return excludedPaths;
|
|
62
|
+
// Wrapper for clearWatchCache that passes cssPathCache
|
|
63
|
+
export function clearWatchCache() {
|
|
64
|
+
clearWatchCacheBase(cssPathCache);
|
|
211
65
|
}
|
|
212
66
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
* @returns {Function} Filter function
|
|
218
|
-
*/
|
|
219
|
-
function createExcludeFilter(excludedPaths, source) {
|
|
220
|
-
if (excludedPaths.size === 0) {
|
|
221
|
-
return () => true; // No exclusions, allow all
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return (filePath) => {
|
|
225
|
-
// Get path relative to source
|
|
226
|
-
const relativePath = filePath.replace(source, '').replace(/^\/+/, '');
|
|
227
|
-
|
|
228
|
-
// Check if file is in any excluded folder
|
|
229
|
-
for (const excluded of excludedPaths) {
|
|
230
|
-
if (relativePath === excluded ||
|
|
231
|
-
relativePath.startsWith(excluded + '/') ||
|
|
232
|
-
relativePath.startsWith(excluded + '\\')) {
|
|
233
|
-
return false; // Exclude this file
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return true; // Include this file
|
|
237
|
-
};
|
|
238
|
-
}
|
|
67
|
+
const progress = new ProgressReporter();
|
|
68
|
+
|
|
69
|
+
const DEFAULT_TEMPLATE_NAME =
|
|
70
|
+
process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
|
|
239
71
|
|
|
240
72
|
export async function generate({
|
|
241
73
|
_source = join(process.cwd(), "."),
|
|
@@ -252,6 +84,10 @@ export async function generate({
|
|
|
252
84
|
const output = resolve(_output) + "/";
|
|
253
85
|
console.log({ source, meta, output });
|
|
254
86
|
|
|
87
|
+
// Generate cache-busting timestamp for this build
|
|
88
|
+
const cacheBustTimestamp = generateCacheBustTimestamp();
|
|
89
|
+
progress.log(`Cache-bust timestamp: ${cacheBustTimestamp}`);
|
|
90
|
+
|
|
255
91
|
// Clear output directory when --clean is specified
|
|
256
92
|
if (_clean) {
|
|
257
93
|
progress.log(`Clean build: clearing output directory ${output}`);
|
|
@@ -327,6 +163,10 @@ export async function generate({
|
|
|
327
163
|
const menu = menuResult.html;
|
|
328
164
|
const menuData = menuResult.menuData;
|
|
329
165
|
|
|
166
|
+
// Find all custom menus in the source tree
|
|
167
|
+
const customMenus = findAllCustomMenus(allSourceFilenames, source);
|
|
168
|
+
progress.log(`Found ${customMenus.size} custom menu(s)`);
|
|
169
|
+
|
|
330
170
|
// Get and increment build ID from .ursa.json
|
|
331
171
|
const buildId = getAndIncrementBuildId(resolve(_source));
|
|
332
172
|
progress.log(`Build #${buildId}`);
|
|
@@ -343,11 +183,30 @@ export async function generate({
|
|
|
343
183
|
progress.log(`Clean build: ignoring cached hashes`);
|
|
344
184
|
}
|
|
345
185
|
|
|
186
|
+
|
|
346
187
|
// create public folder
|
|
347
188
|
const pub = join(output, "public");
|
|
348
189
|
await mkdir(pub, { recursive: true });
|
|
349
190
|
await copyDir(meta, pub);
|
|
350
191
|
|
|
192
|
+
// Process all CSS files in the entire output directory tree for cache-busting
|
|
193
|
+
const allOutputFiles = await recurse(output, [() => false]);
|
|
194
|
+
for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
|
|
195
|
+
const cssContent = await readFile(cssFile, 'utf8');
|
|
196
|
+
const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
|
|
197
|
+
await outputFile(cssFile, processedCss);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Process JS files in output for cache-busting fetch URLs
|
|
201
|
+
for (const jsFile of allOutputFiles.filter(f => f.endsWith('.js'))) {
|
|
202
|
+
let jsContent = await readFile(jsFile, 'utf8');
|
|
203
|
+
jsContent = jsContent.replace(
|
|
204
|
+
/fetch\(['"]([^'"\)]+\.(json))['"](?!\s*\+)/g,
|
|
205
|
+
`fetch('$1?v=${cacheBustTimestamp}'`
|
|
206
|
+
);
|
|
207
|
+
await outputFile(jsFile, jsContent);
|
|
208
|
+
}
|
|
209
|
+
|
|
351
210
|
// Track errors for error report
|
|
352
211
|
const errors = [];
|
|
353
212
|
|
|
@@ -481,6 +340,9 @@ export async function generate({
|
|
|
481
340
|
throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
482
341
|
}
|
|
483
342
|
|
|
343
|
+
// Check if this file has a custom menu
|
|
344
|
+
const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
|
|
345
|
+
|
|
484
346
|
// Build final HTML with all replacements in a single regex pass
|
|
485
347
|
// This avoids creating 8 intermediate strings
|
|
486
348
|
const replacements = {
|
|
@@ -497,9 +359,20 @@ export async function generate({
|
|
|
497
359
|
const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|searchIndex|footer)\}/g;
|
|
498
360
|
let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
|
|
499
361
|
|
|
362
|
+
// If this page has a custom menu, add data attribute to body
|
|
363
|
+
if (customMenuInfo) {
|
|
364
|
+
finalHtml = finalHtml.replace(
|
|
365
|
+
/<body([^>]*)>/,
|
|
366
|
+
`<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}">`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
500
370
|
// Resolve links and mark broken internal links as inactive
|
|
501
371
|
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
502
372
|
|
|
373
|
+
// Add cache-busting timestamps to static file references
|
|
374
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
375
|
+
|
|
503
376
|
await outputFile(outputFilename, finalHtml);
|
|
504
377
|
|
|
505
378
|
// Clear finalHtml reference to allow GC
|
|
@@ -558,6 +431,14 @@ export async function generate({
|
|
|
558
431
|
progress.log(`Writing menu data (${(menuDataJson.length / 1024).toFixed(1)} KB)`);
|
|
559
432
|
await outputFile(menuDataPath, menuDataJson);
|
|
560
433
|
|
|
434
|
+
// Write custom menu JSON files
|
|
435
|
+
for (const [menuDir, menuInfo] of customMenus) {
|
|
436
|
+
const customMenuPath = join(output, menuInfo.menuJsonPath);
|
|
437
|
+
const customMenuJson = JSON.stringify(menuInfo.menuData);
|
|
438
|
+
progress.log(`Writing custom menu: ${menuInfo.menuJsonPath}`);
|
|
439
|
+
await outputFile(customMenuPath, customMenuJson);
|
|
440
|
+
}
|
|
441
|
+
|
|
561
442
|
// Process directory indices with batched concurrency
|
|
562
443
|
const totalDirs = allSourceFilenamesThatAreDirectories.length;
|
|
563
444
|
let processedDirs = 0;
|
|
@@ -613,6 +494,8 @@ export async function generate({
|
|
|
613
494
|
for (const [key, value] of Object.entries(replacements)) {
|
|
614
495
|
finalHtml = finalHtml.replace(key, value);
|
|
615
496
|
}
|
|
497
|
+
// Add cache-busting timestamps to static file references
|
|
498
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
616
499
|
await outputFile(htmlOutputFilename, finalHtml);
|
|
617
500
|
}
|
|
618
501
|
} catch (e) {
|
|
@@ -647,7 +530,7 @@ export async function generate({
|
|
|
647
530
|
processedStatic++;
|
|
648
531
|
const shortFile = file.replace(source, '');
|
|
649
532
|
progress.status('Static files', `${processedStatic}/${totalStatic} ${shortFile}`);
|
|
650
|
-
|
|
533
|
+
|
|
651
534
|
// Check if file has changed using file stat as a quick check
|
|
652
535
|
const fileStat = await stat(file);
|
|
653
536
|
const statKey = `${file}:stat`;
|
|
@@ -659,9 +542,16 @@ export async function generate({
|
|
|
659
542
|
copiedStatic++;
|
|
660
543
|
|
|
661
544
|
const outputFilename = file.replace(source, output);
|
|
662
|
-
|
|
663
545
|
await mkdir(dirname(outputFilename), { recursive: true });
|
|
664
|
-
|
|
546
|
+
|
|
547
|
+
if (file.endsWith('.css')) {
|
|
548
|
+
// Process CSS for cache busting
|
|
549
|
+
const cssContent = await readFile(file, 'utf8');
|
|
550
|
+
const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
|
|
551
|
+
await outputFile(outputFilename, processedCss);
|
|
552
|
+
} else {
|
|
553
|
+
await copyFile(file, outputFilename);
|
|
554
|
+
}
|
|
665
555
|
} catch (e) {
|
|
666
556
|
progress.log(`Error processing static file ${file}: ${e.message}`);
|
|
667
557
|
errors.push({ file, phase: 'static-file', error: e });
|
|
@@ -672,7 +562,7 @@ export async function generate({
|
|
|
672
562
|
|
|
673
563
|
// Automatic index generation for folders without index.html
|
|
674
564
|
progress.log(`Checking for missing index files...`);
|
|
675
|
-
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles);
|
|
565
|
+
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress);
|
|
676
566
|
|
|
677
567
|
// Save the hash cache to .ursa folder in source directory
|
|
678
568
|
if (hashCache.size > 0) {
|
|
@@ -729,191 +619,6 @@ export async function generate({
|
|
|
729
619
|
}
|
|
730
620
|
}
|
|
731
621
|
|
|
732
|
-
/**
|
|
733
|
-
* Generate automatic index.html files for folders that don't have one
|
|
734
|
-
* @param {string} output - Output directory path
|
|
735
|
-
* @param {string[]} directories - List of source directories
|
|
736
|
-
* @param {string} source - Source directory path
|
|
737
|
-
* @param {object} templates - Template map
|
|
738
|
-
* @param {string} menu - Rendered menu HTML
|
|
739
|
-
* @param {string} footer - Footer HTML
|
|
740
|
-
* @param {string[]} generatedArticles - List of source article paths that were generated
|
|
741
|
-
* @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
|
|
742
|
-
* @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
|
|
743
|
-
*/
|
|
744
|
-
async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles) {
|
|
745
|
-
// Alternate index file names to look for (in priority order)
|
|
746
|
-
const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
|
|
747
|
-
|
|
748
|
-
// Normalize paths (remove trailing slashes for consistent replacement)
|
|
749
|
-
const sourceNorm = source.replace(/\/+$/, '');
|
|
750
|
-
const outputNorm = output.replace(/\/+$/, '');
|
|
751
|
-
|
|
752
|
-
// Build set of directories that already have an index.html from a source index.md/txt/yml
|
|
753
|
-
const dirsWithSourceIndex = new Set();
|
|
754
|
-
for (const articlePath of generatedArticles) {
|
|
755
|
-
const base = basename(articlePath, extname(articlePath));
|
|
756
|
-
if (base === 'index') {
|
|
757
|
-
const dir = dirname(articlePath);
|
|
758
|
-
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
759
|
-
dirsWithSourceIndex.add(outputDir);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Get all output directories (including root)
|
|
764
|
-
const outputDirs = new Set([outputNorm]);
|
|
765
|
-
for (const dir of directories) {
|
|
766
|
-
// Handle both with and without trailing slash in source
|
|
767
|
-
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
768
|
-
outputDirs.add(outputDir);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
let generatedCount = 0;
|
|
772
|
-
let renamedCount = 0;
|
|
773
|
-
let skippedHtmlCount = 0;
|
|
774
|
-
|
|
775
|
-
for (const dir of outputDirs) {
|
|
776
|
-
const indexPath = join(dir, 'index.html');
|
|
777
|
-
|
|
778
|
-
// Skip if this directory had a source index.md/txt/yml that was already processed
|
|
779
|
-
if (dirsWithSourceIndex.has(dir)) {
|
|
780
|
-
continue;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// Check if there's an existing index.html in the source directory (don't overwrite it)
|
|
784
|
-
const sourceDir = dir.replace(outputNorm, sourceNorm);
|
|
785
|
-
const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
|
|
786
|
-
if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
|
|
787
|
-
skippedHtmlCount++;
|
|
788
|
-
continue; // Don't overwrite existing source HTML
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// Skip if index.html already exists in output (e.g., created by previous run)
|
|
792
|
-
if (existsSync(indexPath)) {
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// Get folder name for (foldername).html check
|
|
797
|
-
const folderName = basename(dir);
|
|
798
|
-
const folderNameAlternate = `${folderName}.html`;
|
|
799
|
-
|
|
800
|
-
// Check for alternate index files
|
|
801
|
-
let foundAlternate = null;
|
|
802
|
-
for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
|
|
803
|
-
const altPath = join(dir, alt);
|
|
804
|
-
if (existsSync(altPath)) {
|
|
805
|
-
foundAlternate = altPath;
|
|
806
|
-
break;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
if (foundAlternate) {
|
|
811
|
-
// Rename/copy alternate to index.html
|
|
812
|
-
try {
|
|
813
|
-
const content = await readFile(foundAlternate, 'utf8');
|
|
814
|
-
await outputFile(indexPath, content);
|
|
815
|
-
renamedCount++;
|
|
816
|
-
progress.status('Auto-index', `Promoted ${basename(foundAlternate)} → index.html in ${dir.replace(outputNorm, '') || '/'}`);
|
|
817
|
-
} catch (e) {
|
|
818
|
-
progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
|
|
819
|
-
}
|
|
820
|
-
} else {
|
|
821
|
-
// Generate a simple index listing direct children
|
|
822
|
-
try {
|
|
823
|
-
const children = await readdir(dir, { withFileTypes: true });
|
|
824
|
-
|
|
825
|
-
// Filter to only include relevant files and folders
|
|
826
|
-
const items = children
|
|
827
|
-
.filter(child => {
|
|
828
|
-
// Skip hidden files and index alternates we just checked
|
|
829
|
-
if (child.name.startsWith('.')) return false;
|
|
830
|
-
if (child.name === 'index.html') return false;
|
|
831
|
-
// Include directories and html files
|
|
832
|
-
return child.isDirectory() || child.name.endsWith('.html');
|
|
833
|
-
})
|
|
834
|
-
.map(child => {
|
|
835
|
-
const isDir = child.isDirectory();
|
|
836
|
-
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
837
|
-
const href = isDir ? `${child.name}/` : child.name;
|
|
838
|
-
const displayName = toTitleCase(name);
|
|
839
|
-
const icon = isDir ? '📁' : '📄';
|
|
840
|
-
return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
if (items.length === 0) {
|
|
844
|
-
// Empty folder, skip generating index
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
|
|
849
|
-
const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
850
|
-
|
|
851
|
-
const template = templates["default-template"];
|
|
852
|
-
if (!template) {
|
|
853
|
-
progress.log(`Warning: No default template for auto-index in ${dir}`);
|
|
854
|
-
continue;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// Find nearest style.css for this directory
|
|
858
|
-
let styleLink = "";
|
|
859
|
-
try {
|
|
860
|
-
// Map output dir back to source dir to find style.css
|
|
861
|
-
const sourceDir = dir.replace(outputNorm, sourceNorm);
|
|
862
|
-
const cssPath = await findStyleCss(sourceDir);
|
|
863
|
-
if (cssPath) {
|
|
864
|
-
// Calculate output path for the CSS file (mirrors source structure)
|
|
865
|
-
const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
|
|
866
|
-
const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
|
|
867
|
-
|
|
868
|
-
// Copy CSS file if not already copied
|
|
869
|
-
if (!copiedCssFiles.has(cssPath)) {
|
|
870
|
-
const cssContent = await readFile(cssPath, 'utf8');
|
|
871
|
-
await outputFile(cssOutputPath, cssContent);
|
|
872
|
-
copiedCssFiles.add(cssPath);
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Generate link tag
|
|
876
|
-
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
877
|
-
}
|
|
878
|
-
} catch (e) {
|
|
879
|
-
// ignore CSS lookup errors
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
let finalHtml = template;
|
|
883
|
-
const replacements = {
|
|
884
|
-
"${menu}": menu,
|
|
885
|
-
"${body}": indexHtml,
|
|
886
|
-
"${searchIndex}": "[]",
|
|
887
|
-
"${title}": folderDisplayName,
|
|
888
|
-
"${meta}": "{}",
|
|
889
|
-
"${transformedMetadata}": "",
|
|
890
|
-
"${styleLink}": styleLink,
|
|
891
|
-
"${footer}": footer
|
|
892
|
-
};
|
|
893
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
894
|
-
finalHtml = finalHtml.replace(key, value);
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
await outputFile(indexPath, finalHtml);
|
|
898
|
-
generatedCount++;
|
|
899
|
-
progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
|
|
900
|
-
} catch (e) {
|
|
901
|
-
progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
|
|
907
|
-
let summary = `${generatedCount} generated, ${renamedCount} promoted`;
|
|
908
|
-
if (skippedHtmlCount > 0) {
|
|
909
|
-
summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
|
|
910
|
-
}
|
|
911
|
-
progress.done('Auto-index', summary);
|
|
912
|
-
} else {
|
|
913
|
-
progress.log(`Auto-index: All folders already have index.html`);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
622
|
/**
|
|
918
623
|
* Regenerate a single file without scanning the entire source directory.
|
|
919
624
|
* This is much faster for watch mode - only regenerate what changed.
|
|
@@ -949,7 +654,7 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
949
654
|
}
|
|
950
655
|
|
|
951
656
|
try {
|
|
952
|
-
const { templates, menu, footer, validPaths, hashCache } = watchModeCache;
|
|
657
|
+
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp } = watchModeCache;
|
|
953
658
|
|
|
954
659
|
const rawBody = await readFile(changedFile, "utf8");
|
|
955
660
|
const type = parse(changedFile).ext;
|
|
@@ -1032,6 +737,9 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1032
737
|
// Mark broken links
|
|
1033
738
|
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
1034
739
|
|
|
740
|
+
// Add cache-busting timestamps to static file references
|
|
741
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
742
|
+
|
|
1035
743
|
await outputFile(outputFilename, finalHtml);
|
|
1036
744
|
|
|
1037
745
|
// JSON output
|
|
@@ -1063,176 +771,4 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1063
771
|
} catch (e) {
|
|
1064
772
|
return { success: false, message: `Error: ${e.message}` };
|
|
1065
773
|
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
/**
|
|
1069
|
-
* gets { [templateName:String]:[templateBody:String] }
|
|
1070
|
-
* meta: full path to meta files (default-template.html, etc)
|
|
1071
|
-
*/
|
|
1072
|
-
async function getTemplates(meta) {
|
|
1073
|
-
const allMetaFilenames = await recurse(meta);
|
|
1074
|
-
const allHtmlFilenames = allMetaFilenames.filter((filename) =>
|
|
1075
|
-
filename.match(/\.html/)
|
|
1076
|
-
);
|
|
1077
|
-
|
|
1078
|
-
let templates = {};
|
|
1079
|
-
const templatesArray = await Promise.all(
|
|
1080
|
-
allHtmlFilenames.map(async (filename) => {
|
|
1081
|
-
const { name } = parse(filename);
|
|
1082
|
-
const fileContent = await readFile(filename, "utf8");
|
|
1083
|
-
return [name, fileContent];
|
|
1084
|
-
})
|
|
1085
|
-
);
|
|
1086
|
-
templatesArray.forEach(
|
|
1087
|
-
([templateName, templateText]) => (templates[templateName] = templateText)
|
|
1088
|
-
);
|
|
1089
|
-
|
|
1090
|
-
return templates;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
async function getMenu(allSourceFilenames, source, validPaths) {
|
|
1094
|
-
// todo: handle various incarnations of menu filename
|
|
1095
|
-
|
|
1096
|
-
const menuResult = await getAutomenu(source, validPaths);
|
|
1097
|
-
const menuBody = renderFile({ fileContents: menuResult.html, type: ".md" });
|
|
1098
|
-
return {
|
|
1099
|
-
html: menuBody,
|
|
1100
|
-
menuData: menuResult.menuData
|
|
1101
|
-
};
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
async function getTransformedMetadata(dirname, metadata) {
|
|
1105
|
-
// console.log("getTransformedMetadata > ", { dirname });
|
|
1106
|
-
// custom transform? else, use default
|
|
1107
|
-
const customTransformFnFilename = join(dirname, "transformMetadata.js");
|
|
1108
|
-
let transformFn = defaultTransformFn;
|
|
1109
|
-
try {
|
|
1110
|
-
const customTransformFn = (await import(customTransformFnFilename)).default;
|
|
1111
|
-
if (typeof customTransformFn === "function")
|
|
1112
|
-
transformFn = customTransformFn;
|
|
1113
|
-
} catch (e) {
|
|
1114
|
-
// console.error(e);
|
|
1115
|
-
}
|
|
1116
|
-
try {
|
|
1117
|
-
return transformFn(metadata);
|
|
1118
|
-
} catch (e) {
|
|
1119
|
-
return "error transforming metadata";
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function defaultTransformFn(metadata) {
|
|
1123
|
-
return "default transform";
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
function addTrailingSlash(somePath) {
|
|
1128
|
-
if (typeof somePath !== "string") return somePath;
|
|
1129
|
-
if (somePath.length < 1) return somePath;
|
|
1130
|
-
if (somePath[somePath.length - 1] == "/") return somePath;
|
|
1131
|
-
return `${somePath}/`;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* Generate footer HTML from footer.md and package.json
|
|
1136
|
-
* @param {string} source - resolved source path with trailing slash
|
|
1137
|
-
* @param {string} _source - original source path
|
|
1138
|
-
* @param {number} buildId - the current build ID
|
|
1139
|
-
*/
|
|
1140
|
-
async function getFooter(source, _source, buildId) {
|
|
1141
|
-
const footerParts = [];
|
|
1142
|
-
|
|
1143
|
-
// Try to read footer.md from source root
|
|
1144
|
-
const footerPath = join(source, 'footer.md');
|
|
1145
|
-
try {
|
|
1146
|
-
if (existsSync(footerPath)) {
|
|
1147
|
-
const footerMd = await readFile(footerPath, 'utf8');
|
|
1148
|
-
const footerHtml = renderFile({ fileContents: footerMd, type: '.md' });
|
|
1149
|
-
footerParts.push(`<div class="footer-content">${footerHtml}</div>`);
|
|
1150
|
-
}
|
|
1151
|
-
} catch (e) {
|
|
1152
|
-
console.error(`Error reading footer.md: ${e.message}`);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
// Try to read package.json from doc repo (check both source dir and parent)
|
|
1156
|
-
let docPackage = null;
|
|
1157
|
-
const sourceDir = resolve(_source);
|
|
1158
|
-
const packagePaths = [
|
|
1159
|
-
join(sourceDir, 'package.json'), // In source dir itself
|
|
1160
|
-
join(sourceDir, '..', 'package.json'), // One level up (if docs is a subfolder)
|
|
1161
|
-
];
|
|
1162
|
-
|
|
1163
|
-
for (const packagePath of packagePaths) {
|
|
1164
|
-
try {
|
|
1165
|
-
if (existsSync(packagePath)) {
|
|
1166
|
-
const packageJson = await readFile(packagePath, 'utf8');
|
|
1167
|
-
docPackage = JSON.parse(packageJson);
|
|
1168
|
-
console.log(`Found doc package.json at ${packagePath}`);
|
|
1169
|
-
break;
|
|
1170
|
-
}
|
|
1171
|
-
} catch (e) {
|
|
1172
|
-
// Continue to next path
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// Get ursa version from ursa's own package.json
|
|
1177
|
-
// Use import.meta.url to find the package.json relative to this file
|
|
1178
|
-
let ursaVersion = 'unknown';
|
|
1179
|
-
try {
|
|
1180
|
-
// From src/jobs/generate.js, go up to package root
|
|
1181
|
-
const currentFileUrl = new URL(import.meta.url);
|
|
1182
|
-
const currentDir = dirname(currentFileUrl.pathname);
|
|
1183
|
-
const ursaPackagePath = resolve(currentDir, '..', '..', 'package.json');
|
|
1184
|
-
|
|
1185
|
-
if (existsSync(ursaPackagePath)) {
|
|
1186
|
-
const ursaPackageJson = await readFile(ursaPackagePath, 'utf8');
|
|
1187
|
-
const ursaPackage = JSON.parse(ursaPackageJson);
|
|
1188
|
-
ursaVersion = ursaPackage.version;
|
|
1189
|
-
console.log(`Found ursa package.json at ${ursaPackagePath}, version: ${ursaVersion}`);
|
|
1190
|
-
}
|
|
1191
|
-
} catch (e) {
|
|
1192
|
-
console.error(`Error reading ursa package.json: ${e.message}`);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// Build meta line: version, build id, timestamp, "generated by ursa"
|
|
1196
|
-
const metaParts = [];
|
|
1197
|
-
if (docPackage?.version) {
|
|
1198
|
-
metaParts.push(`v${docPackage.version}`);
|
|
1199
|
-
}
|
|
1200
|
-
metaParts.push(`build ${buildId}`);
|
|
1201
|
-
|
|
1202
|
-
// Full date/time in a readable format
|
|
1203
|
-
const now = new Date();
|
|
1204
|
-
const timestamp = now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
|
|
1205
|
-
metaParts.push(timestamp);
|
|
1206
|
-
|
|
1207
|
-
metaParts.push(`Generated by <a href="https://www.npmjs.com/package/@kenjura/ursa">ursa</a> v${ursaVersion}`);
|
|
1208
|
-
|
|
1209
|
-
footerParts.push(`<div class="footer-meta">${metaParts.join(' • ')}</div>`);
|
|
1210
|
-
|
|
1211
|
-
// Copyright line from doc package.json
|
|
1212
|
-
if (docPackage?.copyright) {
|
|
1213
|
-
footerParts.push(`<div class="footer-copyright">${docPackage.copyright}</div>`);
|
|
1214
|
-
} else if (docPackage?.author) {
|
|
1215
|
-
const year = new Date().getFullYear();
|
|
1216
|
-
const author = typeof docPackage.author === 'string' ? docPackage.author : docPackage.author.name;
|
|
1217
|
-
if (author) {
|
|
1218
|
-
footerParts.push(`<div class="footer-copyright">© ${year} ${author}</div>`);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// Try to get git short hash of doc repo (as HTML comment)
|
|
1223
|
-
try {
|
|
1224
|
-
const { execSync } = await import('child_process');
|
|
1225
|
-
const gitHash = execSync('git rev-parse --short HEAD', {
|
|
1226
|
-
cwd: resolve(_source),
|
|
1227
|
-
encoding: 'utf8',
|
|
1228
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
1229
|
-
}).trim();
|
|
1230
|
-
if (gitHash) {
|
|
1231
|
-
footerParts.push(`<!-- git: ${gitHash} -->`);
|
|
1232
|
-
}
|
|
1233
|
-
} catch (e) {
|
|
1234
|
-
// Not a git repo or git not available - silently skip
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
return footerParts.join('\n');
|
|
1238
774
|
}
|