@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.
@@ -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
- const DEFAULT_TEMPLATE_NAME =
171
- process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
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
- * Parse exclude option - can be comma-separated paths or a file path
178
- * @param {string} excludeOption - The exclude option value
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
- * Create a filter function that excludes files in specified folders
215
- * @param {Set<string>} excludedPaths - Set of excluded folder paths
216
- * @param {string} source - Source directory path
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
- return await copyFile(file, outputFilename);
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
  }