@kenjura/ursa 0.53.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 +8 -1
- 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 +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 +72 -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,43 @@ 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
|
+
getTransformedMetadata,
|
|
50
|
+
getFooter,
|
|
51
|
+
generateAutoIndices,
|
|
52
|
+
} from "../helper/build/index.js";
|
|
53
|
+
|
|
54
|
+
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
55
|
+
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
172
56
|
|
|
173
57
|
// Cache for CSS path lookups to avoid repeated filesystem walks
|
|
174
58
|
const cssPathCache = new Map();
|
|
175
59
|
|
|
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;
|
|
60
|
+
// Wrapper for clearWatchCache that passes cssPathCache
|
|
61
|
+
export function clearWatchCache() {
|
|
62
|
+
clearWatchCacheBase(cssPathCache);
|
|
211
63
|
}
|
|
212
64
|
|
|
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
|
-
}
|
|
65
|
+
const progress = new ProgressReporter();
|
|
66
|
+
|
|
67
|
+
const DEFAULT_TEMPLATE_NAME =
|
|
68
|
+
process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
|
|
239
69
|
|
|
240
70
|
export async function generate({
|
|
241
71
|
_source = join(process.cwd(), "."),
|
|
@@ -252,6 +82,10 @@ export async function generate({
|
|
|
252
82
|
const output = resolve(_output) + "/";
|
|
253
83
|
console.log({ source, meta, output });
|
|
254
84
|
|
|
85
|
+
// Generate cache-busting timestamp for this build
|
|
86
|
+
const cacheBustTimestamp = generateCacheBustTimestamp();
|
|
87
|
+
progress.log(`Cache-bust timestamp: ${cacheBustTimestamp}`);
|
|
88
|
+
|
|
255
89
|
// Clear output directory when --clean is specified
|
|
256
90
|
if (_clean) {
|
|
257
91
|
progress.log(`Clean build: clearing output directory ${output}`);
|
|
@@ -343,11 +177,30 @@ export async function generate({
|
|
|
343
177
|
progress.log(`Clean build: ignoring cached hashes`);
|
|
344
178
|
}
|
|
345
179
|
|
|
180
|
+
|
|
346
181
|
// create public folder
|
|
347
182
|
const pub = join(output, "public");
|
|
348
183
|
await mkdir(pub, { recursive: true });
|
|
349
184
|
await copyDir(meta, pub);
|
|
350
185
|
|
|
186
|
+
// Process all CSS files in the entire output directory tree for cache-busting
|
|
187
|
+
const allOutputFiles = await recurse(output, [() => false]);
|
|
188
|
+
for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
|
|
189
|
+
const cssContent = await readFile(cssFile, 'utf8');
|
|
190
|
+
const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
|
|
191
|
+
await outputFile(cssFile, processedCss);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Process JS files in output for cache-busting fetch URLs
|
|
195
|
+
for (const jsFile of allOutputFiles.filter(f => f.endsWith('.js'))) {
|
|
196
|
+
let jsContent = await readFile(jsFile, 'utf8');
|
|
197
|
+
jsContent = jsContent.replace(
|
|
198
|
+
/fetch\(['"]([^'"\)]+\.(json))['"](?!\s*\+)/g,
|
|
199
|
+
`fetch('$1?v=${cacheBustTimestamp}'`
|
|
200
|
+
);
|
|
201
|
+
await outputFile(jsFile, jsContent);
|
|
202
|
+
}
|
|
203
|
+
|
|
351
204
|
// Track errors for error report
|
|
352
205
|
const errors = [];
|
|
353
206
|
|
|
@@ -500,6 +353,9 @@ export async function generate({
|
|
|
500
353
|
// Resolve links and mark broken internal links as inactive
|
|
501
354
|
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
502
355
|
|
|
356
|
+
// Add cache-busting timestamps to static file references
|
|
357
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
358
|
+
|
|
503
359
|
await outputFile(outputFilename, finalHtml);
|
|
504
360
|
|
|
505
361
|
// Clear finalHtml reference to allow GC
|
|
@@ -613,6 +469,8 @@ export async function generate({
|
|
|
613
469
|
for (const [key, value] of Object.entries(replacements)) {
|
|
614
470
|
finalHtml = finalHtml.replace(key, value);
|
|
615
471
|
}
|
|
472
|
+
// Add cache-busting timestamps to static file references
|
|
473
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
616
474
|
await outputFile(htmlOutputFilename, finalHtml);
|
|
617
475
|
}
|
|
618
476
|
} catch (e) {
|
|
@@ -647,7 +505,7 @@ export async function generate({
|
|
|
647
505
|
processedStatic++;
|
|
648
506
|
const shortFile = file.replace(source, '');
|
|
649
507
|
progress.status('Static files', `${processedStatic}/${totalStatic} ${shortFile}`);
|
|
650
|
-
|
|
508
|
+
|
|
651
509
|
// Check if file has changed using file stat as a quick check
|
|
652
510
|
const fileStat = await stat(file);
|
|
653
511
|
const statKey = `${file}:stat`;
|
|
@@ -659,9 +517,16 @@ export async function generate({
|
|
|
659
517
|
copiedStatic++;
|
|
660
518
|
|
|
661
519
|
const outputFilename = file.replace(source, output);
|
|
662
|
-
|
|
663
520
|
await mkdir(dirname(outputFilename), { recursive: true });
|
|
664
|
-
|
|
521
|
+
|
|
522
|
+
if (file.endsWith('.css')) {
|
|
523
|
+
// Process CSS for cache busting
|
|
524
|
+
const cssContent = await readFile(file, 'utf8');
|
|
525
|
+
const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
|
|
526
|
+
await outputFile(outputFilename, processedCss);
|
|
527
|
+
} else {
|
|
528
|
+
await copyFile(file, outputFilename);
|
|
529
|
+
}
|
|
665
530
|
} catch (e) {
|
|
666
531
|
progress.log(`Error processing static file ${file}: ${e.message}`);
|
|
667
532
|
errors.push({ file, phase: 'static-file', error: e });
|
|
@@ -672,7 +537,7 @@ export async function generate({
|
|
|
672
537
|
|
|
673
538
|
// Automatic index generation for folders without index.html
|
|
674
539
|
progress.log(`Checking for missing index files...`);
|
|
675
|
-
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles);
|
|
540
|
+
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress);
|
|
676
541
|
|
|
677
542
|
// Save the hash cache to .ursa folder in source directory
|
|
678
543
|
if (hashCache.size > 0) {
|
|
@@ -729,191 +594,6 @@ export async function generate({
|
|
|
729
594
|
}
|
|
730
595
|
}
|
|
731
596
|
|
|
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
597
|
/**
|
|
918
598
|
* Regenerate a single file without scanning the entire source directory.
|
|
919
599
|
* This is much faster for watch mode - only regenerate what changed.
|
|
@@ -949,7 +629,7 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
949
629
|
}
|
|
950
630
|
|
|
951
631
|
try {
|
|
952
|
-
const { templates, menu, footer, validPaths, hashCache } = watchModeCache;
|
|
632
|
+
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp } = watchModeCache;
|
|
953
633
|
|
|
954
634
|
const rawBody = await readFile(changedFile, "utf8");
|
|
955
635
|
const type = parse(changedFile).ext;
|
|
@@ -1032,6 +712,9 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1032
712
|
// Mark broken links
|
|
1033
713
|
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
1034
714
|
|
|
715
|
+
// Add cache-busting timestamps to static file references
|
|
716
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
717
|
+
|
|
1035
718
|
await outputFile(outputFilename, finalHtml);
|
|
1036
719
|
|
|
1037
720
|
// JSON output
|
|
@@ -1063,176 +746,4 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1063
746
|
} catch (e) {
|
|
1064
747
|
return { success: false, message: `Error: ${e.message}` };
|
|
1065
748
|
}
|
|
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
749
|
}
|