@kenjura/ursa 0.49.0 → 0.51.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 CHANGED
@@ -1,7 +1,29 @@
1
+ # 0.51.0
2
+ 2025-12-21
3
+
4
+ - Existing .html files are no longer overwritten by generated documents.
5
+
6
+ # 0.50.0
7
+ 2025-12-21
8
+
9
+ ### Performance Optimizations
10
+ - **CSS Path Caching**: Implemented caching for `findStyleCss()` lookups during generation. Reduces redundant filesystem walks for documents in the same directory tree.
11
+ - **Template Replacement Optimization**: Changed from 8 sequential `string.replace()` calls to a single regex pass, reducing intermediate string allocations.
12
+ - **Wikitext Regex Pre-compilation**: Pre-compiled ~40 regex patterns at module load time instead of compiling on every `wikiToHtml()` call.
13
+
14
+ ### New Features
15
+ - **Static File Watch**: `ursa serve` now watches for new/changed static files (images, fonts, PDFs, etc.) and automatically copies them to output without requiring a full rebuild.
16
+
17
+ ### Bug Fixes
18
+ - **Menu Folder Expansion**: Fixed issue where navigating to `/folder` wouldn't auto-expand the menu, but `/folder/index.html` would. Both now behave consistently by normalizing trailing slashes in URL comparison.
19
+
20
+
1
21
  # 0.49.0
2
22
  2025-12-20
3
23
 
4
24
  - Fixed more instances of false inactive links, this time in wikitext files (.txt)
25
+ - **Auto-Index Style Fix**: Auto-generated index pages now correctly inherit `style.css` from parent folders, just like normal documents
26
+ - **Clean Build Fix**: The `--clean` flag now properly clears the output directory before generation. Previously it only ignored the hash cache, which could leave stale files (like old auto-generated indexes) that would block new generation.
5
27
 
6
28
 
7
29
  # 0.48.0
package/README.md CHANGED
@@ -65,6 +65,8 @@ Start a development server that:
65
65
  - `--output, -o` - Output directory for generated site (default: "output")
66
66
  - `--port, -p` - Port for development server (default: 8080, serve command only)
67
67
  - `--whitelist, -w` - Path to whitelist file containing patterns for files to include
68
+ - `--exclude, -e` - Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line
69
+ - `--clean` - Clear output directory and ignore cache, forcing full regeneration
68
70
 
69
71
  ### Whitelist File Format
70
72
 
@@ -94,6 +96,34 @@ important-document
94
96
  classes/wizard
95
97
  ```
96
98
 
99
+ ### Exclude Option
100
+
101
+ The `--exclude` option allows you to skip certain folders during generation. This can be specified as:
102
+
103
+ 1. **Comma-separated paths** directly on the command line:
104
+ ```bash
105
+ ursa content --exclude=archive,drafts,old-content
106
+ ursa serve content --exclude=test,backup
107
+ ```
108
+
109
+ 2. **A file path** containing one folder per line:
110
+ ```bash
111
+ ursa content --exclude=exclude-list.txt
112
+ ```
113
+
114
+ The exclude file format is similar to the whitelist:
115
+
116
+ ```text
117
+ # Comments start with # and are ignored
118
+ # Empty lines are also ignored
119
+
120
+ # Folders to exclude (relative to source)
121
+ archive
122
+ drafts
123
+ old-content/v1
124
+ test/fixtures
125
+ ```
126
+
97
127
  ### Large Workloads
98
128
 
99
129
  For sites with many documents (hundreds or thousands), you may need to increase Node.js memory limits:
package/meta/menu.js CHANGED
@@ -206,8 +206,9 @@ document.addEventListener('DOMContentLoaded', () => {
206
206
  if (!item.href) return false;
207
207
  const currentHref = window.location.pathname;
208
208
  // Normalize paths for comparison - decode URI components to handle spaces and special chars
209
- const normalizedItemHref = decodeURIComponent(item.href).replace(/\/index\.html$/, '').replace(/\.html$/, '');
210
- const normalizedCurrentHref = decodeURIComponent(currentHref).replace(/\/index\.html$/, '').replace(/\.html$/, '');
209
+ // Also strip trailing slashes and /index.html for consistent comparison
210
+ const normalizedItemHref = decodeURIComponent(item.href).replace(/\/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
211
+ const normalizedCurrentHref = decodeURIComponent(currentHref).replace(/\/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
211
212
  return normalizedItemHref === normalizedCurrentHref;
212
213
  }
213
214
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.49.0",
5
+ "version": "0.51.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -2,6 +2,48 @@ import { getImageTag } from './WikiImage.js';
2
2
 
3
3
  let instance = {};
4
4
 
5
+ // Pre-compiled regex patterns for better performance
6
+ // These are created once at module load time instead of on every call
7
+ const REGEX = {
8
+ menuStyle: /^_(menu|style)/,
9
+ hasH1: /^=([^=\n]+)=/,
10
+ noH1: /^__NOH1__/,
11
+ noH1Replace: /__NOH1__/g,
12
+ nowiki: /<nowiki>([\d\D]*?)<\/nowiki>/g,
13
+ codeBlock: /^ ([^\n]*)$/gm,
14
+ htmlTag: /<\/?[A-Za-z][^>]*>/g,
15
+ h3: /^===([^=\n]+)===/gm,
16
+ h2: /^==([^=\n]+)==/gm,
17
+ h1: /^=([^=\n]+)=/gm,
18
+ numberedList: /(\n|^)#([\d\D]*?)(\n(?!#)|$)/g,
19
+ bulletList: /(\n|^)\*([\d\D]*?)(\n(?!\*)|$)/g,
20
+ ddDt: /^;([^:\n]*)\n?(?::(.*))?/gm,
21
+ dd: /^:(.*)/m,
22
+ hr: /---/g,
23
+ boldItalic: /'''''([^']+)'''''/g,
24
+ bold: /'''([^']+)'''/g,
25
+ italic: /''([^']+)''/g,
26
+ embiggen3: /\+\+\+([^\+]+)\+\+\+/g,
27
+ embiggen2: /\+\+([^\+]+)\+\+/g,
28
+ table: /\{\|([\d\D]*?)\|\}/g,
29
+ indent3: /^\.\.\.(.*)$/gm,
30
+ indent2: /^\.\.(.*)$/gm,
31
+ indent1: /^\.(.*)$/gm,
32
+ wikiLink1: /\[\[([^\[\]\|#]*)(?:(\|[^\]\|#]*)+)?(?:#([^\]\|#]*))?\]\]/g,
33
+ wikiLink2: /\[\[([^\[\]\|#\n]*)((\|[^\]\|#\n]*)+)?(?:#([^\]\|#\n]*))?\]\]/g,
34
+ externalLink: /\[([^\]\n ]*)(?: ([^\]\n]+))?\]/g,
35
+ paragraph: /^[^\$\n].*$/gm,
36
+ emptyP: /<p><\/p>/g,
37
+ superscript: /\^([^\^]*)\^/g,
38
+ nowikiRestore: /\$NOWIKI_(\d*)\$/g,
39
+ codeRestore: /\$CODE_(\d*)\$/g,
40
+ codeJoin: /<\/code>\s*<code>/g,
41
+ htmlRestore: /\$HTML_(\d*)\$/g,
42
+ sectionH1: /(?:<h1>)([^\|<]*)(?:\|([^<\|]*))?(?:\|([^<]*))?(?:<\/h1>)([\d\D]*?)(?=<h1|$)/g,
43
+ sectionH2: /(?:<h2>)([^\|<]*)(?:\|([^<\|]*))?(?:\|([^<]*))?(?:<\/h2>)([\d\D]*?)(?=<h2|<\!--SECTION-END|$)/g,
44
+ tocHeader: /(?:<h(\d)>)([^<]*)(?:<\/h\1>)/g,
45
+ };
46
+
5
47
  export function wikiToHtml({ wikitext, articleName, args } = {}) {
6
48
  if (!args) args = { db: "noDB", noSection: true, noTOC: true };
7
49
  if (!wikitext) return "nothing to render";
@@ -18,93 +60,77 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
18
60
  // 1 - add title if none present
19
61
  if (
20
62
  !args.noH1 &&
21
- !articleName.match(/^_(menu|style)/) &&
22
- !html.match(/^=([^=\n]+)=/) &&
23
- !html.match(/^__NOH1__/)
63
+ !articleName.match(REGEX.menuStyle) &&
64
+ !html.match(REGEX.hasH1) &&
65
+ !html.match(REGEX.noH1)
24
66
  )
25
67
  html = "=" + articleName.replace(/^_/, "") + "=\n" + html;
26
- html = html.replace(/__NOH1__/g, "");
68
+ html = html.replace(REGEX.noH1Replace, "");
27
69
 
28
70
  // basic formatting ------------------------------------------
29
71
  // nowiki
30
- html = html.replace(/<nowiki>([\d\D]*?)<\/nowiki>/g, processNoWiki);
31
- html = html.replace(/^ ([^\n]*)$/gm, processCodeBlock);
32
- html = html.replace(/<\/?[A-Za-z][^>]*>/g, processHTML);
72
+ html = html.replace(REGEX.nowiki, processNoWiki);
73
+ html = html.replace(REGEX.codeBlock, processCodeBlock);
74
+ html = html.replace(REGEX.htmlTag, processHTML);
33
75
  //html = html.replace( /{(?!\|)([^\|]+\|)?([^}]*)}/g , processJSON );
34
76
  // headers
35
- html = html.replace(/^===([^=\n]+)===/gm, "<h3>$1</h3>");
36
- html = html.replace(/^==([^=\n]+)==/gm, "<h2>$1</h2>");
37
- html = html.replace(/^=([^=\n]+)=/gm, "<h1>$1</h1>");
77
+ html = html.replace(REGEX.h3, "<h3>$1</h3>");
78
+ html = html.replace(REGEX.h2, "<h2>$1</h2>");
79
+ html = html.replace(REGEX.h1, "<h1>$1</h1>");
38
80
 
39
81
  // bullets
40
- html = html.replace(/(\n|^)#([\d\D]*?)(\n(?!#)|$)/g, processNumberedLists);
41
- html = html.replace(/(\n|^)\*([\d\D]*?)(\n(?!\*)|$)/g, processBullets);
82
+ html = html.replace(REGEX.numberedList, processNumberedLists);
83
+ html = html.replace(REGEX.bulletList, processBullets);
42
84
 
43
85
  // dd/dt
44
- html = html.replace(
45
- /^;([^:\n]*)\n?(?::(.*))?/gm,
46
- "<dl><dt>$1</dt><dd>$2</dd></dl>"
47
- );
48
- html = html.replace(/^:(.*)/m, "<dd>$1</dd>\n");
86
+ html = html.replace(REGEX.ddDt, "<dl><dt>$1</dt><dd>$2</dd></dl>");
87
+ html = html.replace(REGEX.dd, "<dd>$1</dd>\n");
49
88
  // hr
50
- html = html.replace(/---/g, "<hr>");
89
+ html = html.replace(REGEX.hr, "<hr>");
51
90
  // inline
52
- html = html.replace(/'''''([^']+)'''''/g, "<b><i>$1</i></b>");
53
- html = html.replace(/'''([^']+)'''/g, "<b>$1</b>");
54
- html = html.replace(/''([^']+)''/g, "<i>$1</i>");
91
+ html = html.replace(REGEX.boldItalic, "<b><i>$1</i></b>");
92
+ html = html.replace(REGEX.bold, "<b>$1</b>");
93
+ html = html.replace(REGEX.italic, "<i>$1</i>");
55
94
  // html = html.replace( /''(.*?)''/g , '<i>$1</i>' );
56
95
  // strikethrough
57
96
  // html = html.replace( /--(.*?)--/g , '<strike>$1</strike>' );
58
97
  // embiggen
59
- html = html.replace(
60
- /\+\+\+([^\+]+)\+\+\+/g,
61
- '<span style="font-size: 200%;">$1</span>'
62
- );
63
- html = html.replace(
64
- /\+\+([^\+]+)\+\+/g,
65
- '<span style="font-size: 150%;">$1</span>'
66
- );
98
+ html = html.replace(REGEX.embiggen3, '<span style="font-size: 200%;">$1</span>');
99
+ html = html.replace(REGEX.embiggen2, '<span style="font-size: 150%;">$1</span>');
67
100
  // tables
68
- html = html.replace(/\{\|([\d\D]*?)\|\}/g, processTable);
101
+ html = html.replace(REGEX.table, processTable);
69
102
  // div/indent
70
- html = html.replace(/^\.\.\.(.*)$/gm, '<div class="indent2">$1</div>');
71
- html = html.replace(/^\.\.(.*)$/gm, '<div class="indent1">$1</div>');
72
- html = html.replace(/^\.(.*)$/gm, "<div>$1</div>");
103
+ html = html.replace(REGEX.indent3, '<div class="indent2">$1</div>');
104
+ html = html.replace(REGEX.indent2, '<div class="indent1">$1</div>');
105
+ html = html.replace(REGEX.indent1, "<div>$1</div>");
73
106
  // links
74
- html = html.replace(
75
- /\[\[([^\[\]\|#]*)(?:(\|[^\]\|#]*)+)?(?:#([^\]\|#]*))?\]\]/g,
76
- processLink
77
- );
78
- html = html.replace(
79
- /\[\[([^\[\]\|#\n]*)((\|[^\]\|#\n]*)+)?(?:#([^\]\|#\n]*))?\]\]/g,
80
- processLink
81
- );
82
- html = html.replace(/\[([^\]\n ]*)(?: ([^\]\n]+))?\]/g, processExternalLink);
107
+ html = html.replace(REGEX.wikiLink1, processLink);
108
+ html = html.replace(REGEX.wikiLink2, processLink);
109
+ html = html.replace(REGEX.externalLink, processExternalLink);
83
110
 
84
111
  // code
85
112
  // html = html.replace( /^ (.*)$/mg , '<code>$1</code>' );
86
113
  // paragraphs
87
114
  html = html.trim();
88
115
  // html = html.replace( /^.*$/gm , processParagraphs );
89
- html = html.replace(/^[^\$\n].*$/gm, processParagraphs);
90
- html = html.replace(/<p><\/p>/g, "");
116
+ html = html.replace(REGEX.paragraph, processParagraphs);
117
+ html = html.replace(REGEX.emptyP, "");
91
118
  // beautify HTML
92
119
  //html = beautifyHTML(html);
93
120
 
94
121
  // superscript
95
- html = html.replace(/\^([^\^]*)\^/g, "<sup>$1</sup>");
122
+ html = html.replace(REGEX.superscript, "<sup>$1</sup>");
96
123
 
97
124
  // restore nowiki blocks
98
- html = html.replace(/\$NOWIKI_(\d*)\$/g, processNoWikiRestore);
99
- html = html.replace(/\$CODE_(\d*)\$/g, processCodeBlockRestore);
100
- html = html.replace(/<\/code>\s*<code>/g, "\n");
101
- html = html.replace(/\$HTML_(\d*)\$/g, processHTMLRestore);
125
+ html = html.replace(REGEX.nowikiRestore, processNoWikiRestore);
126
+ html = html.replace(REGEX.codeRestore, processCodeBlockRestore);
127
+ html = html.replace(REGEX.codeJoin, "\n");
128
+ html = html.replace(REGEX.htmlRestore, processHTMLRestore);
102
129
  //html = html.replace( /\$JSON_(\d*)\$/g , processJSONRestore );
103
130
 
104
131
  // WORKING CODE for sectioning h1 and h2
105
132
  if (!args.noSection) {
106
- var find =
107
- /(?:<h1>)([^\|<]*)(?:\|([^<\|]*))?(?:\|([^<]*))?(?:<\/h1>)([\d\D]*?)(?=<h1|$)/g;
133
+ var find = REGEX.sectionH1;
108
134
  var replace =
109
135
  '\
110
136
  <div class="sectionOuter sectionOuter1 $2" style="$3">\
@@ -132,9 +158,8 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
132
158
  return em.replace(find, replace);
133
159
  });
134
160
 
135
- var find =
136
- /(?:<h2>)([^\|<]*)(?:\|([^<\|]*))?(?:\|([^<]*))?(?:<\/h2>)([\d\D]*?)(?=<h2|<\!--SECTION-END|$)/g;
137
- var replace =
161
+ find = REGEX.sectionH2;
162
+ replace =
138
163
  '\
139
164
  <div class="sectionOuter2 $2">\
140
165
  <h2>$1</h2>\
@@ -149,7 +174,7 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
149
174
 
150
175
  // adding IDs to headers for TOC seeks
151
176
  if (!args.noTOC) {
152
- var find = /(?:<h(\d)>)([^<]*)(?:<\/h\1>)/g;
177
+ var find = REGEX.tocHeader;
153
178
  var replace = '<h$1 id="$2">$2</h$1>';
154
179
  html = html.replace(find, function (em, g1, g2) {
155
180
  var id = g2.replace(/\s/g, "_");
@@ -32,6 +32,7 @@ export function clearWatchCache() {
32
32
  watchModeCache.validPaths = null;
33
33
  watchModeCache.hashCache = null;
34
34
  watchModeCache.isInitialized = false;
35
+ cssPathCache.clear(); // Also clear CSS path cache
35
36
  console.log('Watch cache cleared');
36
37
  }
37
38
 
@@ -169,6 +170,9 @@ import { createWhitelistFilter } from "../helper/whitelistFilter.js";
169
170
  const DEFAULT_TEMPLATE_NAME =
170
171
  process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
171
172
 
173
+ // Cache for CSS path lookups to avoid repeated filesystem walks
174
+ const cssPathCache = new Map();
175
+
172
176
  /**
173
177
  * Parse exclude option - can be comma-separated paths or a file path
174
178
  * @param {string} excludeOption - The exclude option value
@@ -248,6 +252,12 @@ export async function generate({
248
252
  const output = resolve(_output) + "/";
249
253
  console.log({ source, meta, output });
250
254
 
255
+ // Clear output directory when --clean is specified
256
+ if (_clean) {
257
+ progress.log(`Clean build: clearing output directory ${output}`);
258
+ await emptyDir(output);
259
+ }
260
+
251
261
  const allSourceFilenamesUnfiltered = await recurse(source, [() => false]);
252
262
 
253
263
  // Apply include filter (existing functionality)
@@ -299,6 +309,15 @@ export async function generate({
299
309
  (filename) => isDirectory(filename)
300
310
  )).filter((filename) => !filename.match(hiddenOrSystemDirs) && !isFolderHidden(filename, source));
301
311
 
312
+ // Build set of existing HTML files in source directory (these should not be overwritten)
313
+ const htmlExtensions = /\.html$/;
314
+ const existingHtmlFiles = new Set(
315
+ allSourceFilenames
316
+ .filter(f => f.match(htmlExtensions) && !f.match(hiddenOrSystemDirs))
317
+ .map(f => f.replace(source, '')) // Store relative paths for easy lookup
318
+ );
319
+ progress.log(`Found ${existingHtmlFiles.size} existing HTML files in source`);
320
+
302
321
  // Build set of valid internal paths for link validation (must be before menu)
303
322
  // Pass directories to ensure folder links are valid (auto-index generates index.html for all folders)
304
323
  const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
@@ -381,6 +400,14 @@ export async function generate({
381
400
  content: '' // Content excerpts built lazily to save memory
382
401
  });
383
402
 
403
+ // Check if a corresponding .html file already exists in source directory
404
+ const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
405
+ if (existingHtmlFiles.has(outputHtmlRelative)) {
406
+ progress.log(`āš ļø Warning: Skipping ${shortFile} - would overwrite existing ${outputHtmlRelative} in source`);
407
+ skippedCount++;
408
+ return;
409
+ }
410
+
384
411
  // Check if file needs regeneration
385
412
  const needsRegen = _clean || needsRegeneration(file, rawBody, hashCache);
386
413
 
@@ -415,9 +442,15 @@ export async function generate({
415
442
  });
416
443
 
417
444
  // Find nearest style.css or _style.css up the tree and copy to output
445
+ // Use cache to avoid repeated filesystem walks for same directory
418
446
  let styleLink = "";
419
447
  try {
420
- const cssPath = await findStyleCss(resolve(_source, dir));
448
+ const dirKey = resolve(_source, dir);
449
+ let cssPath = cssPathCache.get(dirKey);
450
+ if (cssPath === undefined) {
451
+ cssPath = await findStyleCss(dirKey);
452
+ cssPathCache.set(dirKey, cssPath); // Cache null results too
453
+ }
421
454
  if (cssPath) {
422
455
  // Calculate output path for the CSS file (mirrors source structure)
423
456
  const cssOutputPath = cssPath.replace(source, output);
@@ -446,9 +479,8 @@ export async function generate({
446
479
  throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
447
480
  }
448
481
 
449
- // Build final HTML with all replacements in a single chain to reduce intermediate strings
450
- let finalHtml = template;
451
- // Use a map of replacements to minimize string allocations
482
+ // Build final HTML with all replacements in a single regex pass
483
+ // This avoids creating 8 intermediate strings
452
484
  const replacements = {
453
485
  "${title}": title,
454
486
  "${menu}": menu,
@@ -459,9 +491,9 @@ export async function generate({
459
491
  "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
460
492
  "${footer}": footer
461
493
  };
462
- for (const [key, value] of Object.entries(replacements)) {
463
- finalHtml = finalHtml.replace(key, value);
464
- }
494
+ // Single-pass replacement using regex alternation
495
+ const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|searchIndex|footer)\}/g;
496
+ let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
465
497
 
466
498
  // Resolve links and mark broken internal links as inactive
467
499
  finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
@@ -585,16 +617,23 @@ export async function generate({
585
617
  // Clear directory index cache to free memory before processing static files
586
618
  dirIndexCache.clear();
587
619
 
588
- // copy all static files (i.e. images) with batched concurrency
620
+ // copy all static files (images and existing HTML files) with batched concurrency
589
621
  const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/; // static asset extensions
590
622
  const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
591
623
  (filename) => filename.match(imageExtensions)
592
624
  );
593
- const totalStatic = allSourceFilenamesThatAreImages.length;
625
+
626
+ // Also copy existing HTML files from source to output (they're treated as static)
627
+ const allSourceFilenamesThatAreHtml = allSourceFilenames.filter(
628
+ (filename) => filename.match(/\.html$/) && !filename.match(hiddenOrSystemDirs)
629
+ );
630
+
631
+ const allStaticFiles = [...allSourceFilenamesThatAreImages, ...allSourceFilenamesThatAreHtml];
632
+ const totalStatic = allStaticFiles.length;
594
633
  let processedStatic = 0;
595
634
  let copiedStatic = 0;
596
- progress.log(`Processing ${totalStatic} static files...`);
597
- await processBatched(allSourceFilenamesThatAreImages, async (file) => {
635
+ progress.log(`Processing ${totalStatic} static files (${allSourceFilenamesThatAreImages.length} images, ${allSourceFilenamesThatAreHtml.length} HTML)...`);
636
+ await processBatched(allStaticFiles, async (file) => {
598
637
  try {
599
638
  processedStatic++;
600
639
  const shortFile = file.replace(source, '');
@@ -624,7 +663,7 @@ export async function generate({
624
663
 
625
664
  // Automatic index generation for folders without index.html
626
665
  progress.log(`Checking for missing index files...`);
627
- await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles);
666
+ await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles);
628
667
 
629
668
  // Save the hash cache to .ursa folder in source directory
630
669
  if (hashCache.size > 0) {
@@ -689,8 +728,11 @@ export async function generate({
689
728
  * @param {object} templates - Template map
690
729
  * @param {string} menu - Rendered menu HTML
691
730
  * @param {string} footer - Footer HTML
731
+ * @param {string[]} generatedArticles - List of source article paths that were generated
732
+ * @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
733
+ * @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
692
734
  */
693
- async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles) {
735
+ async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles) {
694
736
  // Alternate index file names to look for (in priority order)
695
737
  const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
696
738
 
@@ -719,6 +761,7 @@ async function generateAutoIndices(output, directories, source, templates, menu,
719
761
 
720
762
  let generatedCount = 0;
721
763
  let renamedCount = 0;
764
+ let skippedHtmlCount = 0;
722
765
 
723
766
  for (const dir of outputDirs) {
724
767
  const indexPath = join(dir, 'index.html');
@@ -728,7 +771,15 @@ async function generateAutoIndices(output, directories, source, templates, menu,
728
771
  continue;
729
772
  }
730
773
 
731
- // Skip if index.html already exists (e.g., created by previous run)
774
+ // Check if there's an existing index.html in the source directory (don't overwrite it)
775
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
776
+ const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
777
+ if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
778
+ skippedHtmlCount++;
779
+ continue; // Don't overwrite existing source HTML
780
+ }
781
+
782
+ // Skip if index.html already exists in output (e.g., created by previous run)
732
783
  if (existsSync(indexPath)) {
733
784
  continue;
734
785
  }
@@ -794,6 +845,31 @@ async function generateAutoIndices(output, directories, source, templates, menu,
794
845
  continue;
795
846
  }
796
847
 
848
+ // Find nearest style.css for this directory
849
+ let styleLink = "";
850
+ try {
851
+ // Map output dir back to source dir to find style.css
852
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
853
+ const cssPath = await findStyleCss(sourceDir);
854
+ if (cssPath) {
855
+ // Calculate output path for the CSS file (mirrors source structure)
856
+ const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
857
+ const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
858
+
859
+ // Copy CSS file if not already copied
860
+ if (!copiedCssFiles.has(cssPath)) {
861
+ const cssContent = await readFile(cssPath, 'utf8');
862
+ await outputFile(cssOutputPath, cssContent);
863
+ copiedCssFiles.add(cssPath);
864
+ }
865
+
866
+ // Generate link tag
867
+ styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
868
+ }
869
+ } catch (e) {
870
+ // ignore CSS lookup errors
871
+ }
872
+
797
873
  let finalHtml = template;
798
874
  const replacements = {
799
875
  "${menu}": menu,
@@ -802,7 +878,7 @@ async function generateAutoIndices(output, directories, source, templates, menu,
802
878
  "${title}": folderDisplayName,
803
879
  "${meta}": "{}",
804
880
  "${transformedMetadata}": "",
805
- "${styleLink}": "",
881
+ "${styleLink}": styleLink,
806
882
  "${footer}": footer
807
883
  };
808
884
  for (const [key, value] of Object.entries(replacements)) {
@@ -818,8 +894,12 @@ async function generateAutoIndices(output, directories, source, templates, menu,
818
894
  }
819
895
  }
820
896
 
821
- if (generatedCount > 0 || renamedCount > 0) {
822
- progress.done('Auto-index', `${generatedCount} generated, ${renamedCount} promoted`);
897
+ if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
898
+ let summary = `${generatedCount} generated, ${renamedCount} promoted`;
899
+ if (skippedHtmlCount > 0) {
900
+ summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
901
+ }
902
+ progress.done('Auto-index', summary);
823
903
  } else {
824
904
  progress.log(`Auto-index: All folders already have index.html`);
825
905
  }
package/src/serve.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import express from "express";
2
2
  import watch from "node-watch";
3
3
  import { generate, regenerateSingleFile, clearWatchCache } from "./jobs/generate.js";
4
- import { join, resolve } from "path";
4
+ import { join, resolve, dirname } from "path";
5
5
  import fs from "fs";
6
6
  import { promises } from "fs";
7
7
  import { outputFile } from "fs-extra";
8
- const { readdir, mkdir, readFile } = promises;
8
+ const { readdir, mkdir, readFile, copyFile } = promises;
9
9
 
10
10
  // Debounce timer and lock for preventing concurrent regenerations
11
11
  let debounceTimer = null;
@@ -33,6 +33,33 @@ async function copyCssFile(cssPath, sourceDir, outputDir) {
33
33
  }
34
34
  }
35
35
 
36
+ // Static file extensions that should be copied (images, fonts, etc.)
37
+ const STATIC_FILE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
38
+
39
+ /**
40
+ * Copy a single static file to the output directory
41
+ * @param {string} filePath - Absolute path to the static file
42
+ * @param {string} sourceDir - Source directory root
43
+ * @param {string} outputDir - Output directory root
44
+ */
45
+ async function copyStaticFile(filePath, sourceDir, outputDir) {
46
+ const startTime = Date.now();
47
+ const relativePath = filePath.replace(sourceDir, '');
48
+ const outputPath = join(outputDir, relativePath);
49
+
50
+ try {
51
+ // Ensure directory exists
52
+ await mkdir(dirname(outputPath), { recursive: true });
53
+
54
+ // Copy the file
55
+ await copyFile(filePath, outputPath);
56
+ const elapsed = Date.now() - startTime;
57
+ return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
58
+ } catch (e) {
59
+ return { success: false, message: `Error copying static file: ${e.message}` };
60
+ }
61
+ }
62
+
36
63
  /**
37
64
  * Configurable serve function for CLI and library use
38
65
  */
@@ -90,8 +117,8 @@ export async function serve({
90
117
  filter: (f, skip) => {
91
118
  // Skip .ursa folder (contains hash cache that gets updated during generation)
92
119
  if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
93
- // Only watch relevant file types
94
- return /\.(js|json|css|html|md|txt|yml|yaml)$/.test(f);
120
+ // Watch article files, config files, and static assets
121
+ return /\.(js|json|css|html|md|txt|yml|yaml|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i.test(f);
95
122
  }
96
123
  }, async (evt, name) => {
97
124
  // Skip if we're already regenerating
@@ -124,6 +151,40 @@ export async function serve({
124
151
  return;
125
152
  }
126
153
 
154
+ // Static files (images, fonts, etc.): just copy the file
155
+ const isStaticFile = name && STATIC_FILE_EXTENSIONS.test(name);
156
+ if (isStaticFile) {
157
+ console.log(`\nšŸ–¼ļø Static file ${evt === 'remove' ? 'removed' : 'changed'}: ${name}`);
158
+ isRegenerating = true;
159
+ try {
160
+ if (evt === 'remove') {
161
+ // Delete the file from output
162
+ const relativePath = name.replace(sourceDir, '');
163
+ const outputPath = join(outputDir, relativePath);
164
+ try {
165
+ await promises.unlink(outputPath);
166
+ console.log(`āœ… Removed ${relativePath}`);
167
+ } catch (e) {
168
+ if (e.code !== 'ENOENT') {
169
+ console.log(`āš ļø Error removing file: ${e.message}`);
170
+ }
171
+ }
172
+ } else {
173
+ const result = await copyStaticFile(name, sourceDir + '/', outputDir + '/');
174
+ if (result.success) {
175
+ console.log(`āœ… ${result.message}`);
176
+ } else {
177
+ console.log(`āš ļø ${result.message}`);
178
+ }
179
+ }
180
+ } catch (error) {
181
+ console.error("Error handling static file:", error.message);
182
+ } finally {
183
+ isRegenerating = false;
184
+ }
185
+ return;
186
+ }
187
+
127
188
  if (isMenuChange || isConfigChange) {
128
189
  console.log(`\nšŸ“¦ ${isMenuChange ? 'Menu' : 'Config'} change detected: ${name}`);
129
190
  console.log("Full rebuild required...");