@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.
@@ -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
- 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
+ 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
- * 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;
60
+ // Wrapper for clearWatchCache that passes cssPathCache
61
+ export function clearWatchCache() {
62
+ clearWatchCacheBase(cssPathCache);
211
63
  }
212
64
 
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
- }
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
- return await copyFile(file, outputFilename);
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
  }