@kenjura/ursa 0.49.0 → 0.50.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,24 @@
1
+ # 0.50.0
2
+ 2025-12-21
3
+
4
+ ### Performance Optimizations
5
+ - **CSS Path Caching**: Implemented caching for `findStyleCss()` lookups during generation. Reduces redundant filesystem walks for documents in the same directory tree.
6
+ - **Template Replacement Optimization**: Changed from 8 sequential `string.replace()` calls to a single regex pass, reducing intermediate string allocations.
7
+ - **Wikitext Regex Pre-compilation**: Pre-compiled ~40 regex patterns at module load time instead of compiling on every `wikiToHtml()` call.
8
+
9
+ ### New Features
10
+ - **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.
11
+
12
+ ### Bug Fixes
13
+ - **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.
14
+
15
+
1
16
  # 0.49.0
2
17
  2025-12-20
3
18
 
4
19
  - Fixed more instances of false inactive links, this time in wikitext files (.txt)
20
+ - **Auto-Index Style Fix**: Auto-generated index pages now correctly inherit `style.css` from parent folders, just like normal documents
21
+ - **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
22
 
6
23
 
7
24
  # 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.50.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)
@@ -415,9 +425,15 @@ export async function generate({
415
425
  });
416
426
 
417
427
  // Find nearest style.css or _style.css up the tree and copy to output
428
+ // Use cache to avoid repeated filesystem walks for same directory
418
429
  let styleLink = "";
419
430
  try {
420
- const cssPath = await findStyleCss(resolve(_source, dir));
431
+ const dirKey = resolve(_source, dir);
432
+ let cssPath = cssPathCache.get(dirKey);
433
+ if (cssPath === undefined) {
434
+ cssPath = await findStyleCss(dirKey);
435
+ cssPathCache.set(dirKey, cssPath); // Cache null results too
436
+ }
421
437
  if (cssPath) {
422
438
  // Calculate output path for the CSS file (mirrors source structure)
423
439
  const cssOutputPath = cssPath.replace(source, output);
@@ -446,9 +462,8 @@ export async function generate({
446
462
  throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
447
463
  }
448
464
 
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
465
+ // Build final HTML with all replacements in a single regex pass
466
+ // This avoids creating 8 intermediate strings
452
467
  const replacements = {
453
468
  "${title}": title,
454
469
  "${menu}": menu,
@@ -459,9 +474,9 @@ export async function generate({
459
474
  "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
460
475
  "${footer}": footer
461
476
  };
462
- for (const [key, value] of Object.entries(replacements)) {
463
- finalHtml = finalHtml.replace(key, value);
464
- }
477
+ // Single-pass replacement using regex alternation
478
+ const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|searchIndex|footer)\}/g;
479
+ let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
465
480
 
466
481
  // Resolve links and mark broken internal links as inactive
467
482
  finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
@@ -624,7 +639,7 @@ export async function generate({
624
639
 
625
640
  // Automatic index generation for folders without index.html
626
641
  progress.log(`Checking for missing index files...`);
627
- await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles);
642
+ await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles);
628
643
 
629
644
  // Save the hash cache to .ursa folder in source directory
630
645
  if (hashCache.size > 0) {
@@ -689,8 +704,10 @@ export async function generate({
689
704
  * @param {object} templates - Template map
690
705
  * @param {string} menu - Rendered menu HTML
691
706
  * @param {string} footer - Footer HTML
707
+ * @param {string[]} generatedArticles - List of source article paths that were generated
708
+ * @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
692
709
  */
693
- async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles) {
710
+ async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles) {
694
711
  // Alternate index file names to look for (in priority order)
695
712
  const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
696
713
 
@@ -794,6 +811,31 @@ async function generateAutoIndices(output, directories, source, templates, menu,
794
811
  continue;
795
812
  }
796
813
 
814
+ // Find nearest style.css for this directory
815
+ let styleLink = "";
816
+ try {
817
+ // Map output dir back to source dir to find style.css
818
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
819
+ const cssPath = await findStyleCss(sourceDir);
820
+ if (cssPath) {
821
+ // Calculate output path for the CSS file (mirrors source structure)
822
+ const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
823
+ const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
824
+
825
+ // Copy CSS file if not already copied
826
+ if (!copiedCssFiles.has(cssPath)) {
827
+ const cssContent = await readFile(cssPath, 'utf8');
828
+ await outputFile(cssOutputPath, cssContent);
829
+ copiedCssFiles.add(cssPath);
830
+ }
831
+
832
+ // Generate link tag
833
+ styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
834
+ }
835
+ } catch (e) {
836
+ // ignore CSS lookup errors
837
+ }
838
+
797
839
  let finalHtml = template;
798
840
  const replacements = {
799
841
  "${menu}": menu,
@@ -802,7 +844,7 @@ async function generateAutoIndices(output, directories, source, templates, menu,
802
844
  "${title}": folderDisplayName,
803
845
  "${meta}": "{}",
804
846
  "${transformedMetadata}": "",
805
- "${styleLink}": "",
847
+ "${styleLink}": styleLink,
806
848
  "${footer}": footer
807
849
  };
808
850
  for (const [key, value] of Object.entries(replacements)) {
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...");