@kenjura/ursa 0.48.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,3 +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
+
16
+ # 0.49.0
17
+ 2025-12-20
18
+
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.
1
22
 
2
23
 
3
24
  # 0.48.0
@@ -10,6 +31,8 @@
10
31
  - **Menu Collapse Fix**: Fixed issue where clicking the caret on a folder containing the current page wouldn't collapse it
11
32
  - **URL Encoding Fix**: Fixed menu not highlighting current page when URLs contain spaces or special characters
12
33
  - **Link Validation Fix**: Links to folders are no longer incorrectly marked as inactive (folders now included in valid paths since auto-index generates index.html for all)
34
+ - **WikiText Link Fix**: Fixed wikitext links (in .txt files) being incorrectly marked as inactive. Link validation is now handled centrally by the link validator after HTML generation.
35
+ - **Folder/Index Link Fix**: Links to folders containing a `(foldername).md` file (instead of `index.md`) are now correctly recognized as valid
13
36
 
14
37
  # 0.47.0
15
38
  2025-12-20
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.48.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": {
@@ -1,4 +1,4 @@
1
- import { extname, dirname, join, normalize, posix } from "path";
1
+ import { extname, dirname, join, normalize, posix, basename } from "path";
2
2
 
3
3
  /**
4
4
  * Build a set of valid internal paths from the list of source files and directories
@@ -38,6 +38,19 @@ export function buildValidPaths(sourceFiles, source, directories = []) {
38
38
  validPaths.add((dirPath + "/").toLowerCase());
39
39
  validPaths.add((dirPath + "/index.html").toLowerCase());
40
40
  }
41
+
42
+ // Handle (foldername).md files - they get promoted to index.html by auto-index
43
+ // e.g., /foo/bar/bar.md becomes /foo/bar/index.html (bar.html promoted to index.html)
44
+ const fileName = basename(relativePath); // e.g., "bar" from "/foo/bar/bar"
45
+ const parentDir = dirname(relativePath); // e.g., "/foo/bar" from "/foo/bar/bar"
46
+ const parentDirName = basename(parentDir); // e.g., "bar" from "/foo/bar"
47
+
48
+ if (fileName === parentDirName) {
49
+ // This file has same name as its parent folder - it will be promoted to index.html
50
+ validPaths.add(parentDir.toLowerCase());
51
+ validPaths.add((parentDir + "/").toLowerCase());
52
+ validPaths.add((parentDir + "/index.html").toLowerCase());
53
+ }
41
54
  }
42
55
 
43
56
  // Add all directories as valid paths (they get auto-generated index.html)
@@ -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";
@@ -10,8 +52,6 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
10
52
  const linkbase = ("/" + db + "/").replace(/\/\//g, "/");
11
53
  const imageroot = ("/" + db + "/img/").replace(/\/\//g, "/");
12
54
 
13
- const allArticles = args.allArticles || [];
14
-
15
55
  // console.log('wikitext=',wikitext);
16
56
  var html = String(wikitext);
17
57
  // instance.article = article;
@@ -20,93 +60,77 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
20
60
  // 1 - add title if none present
21
61
  if (
22
62
  !args.noH1 &&
23
- !articleName.match(/^_(menu|style)/) &&
24
- !html.match(/^=([^=\n]+)=/) &&
25
- !html.match(/^__NOH1__/)
63
+ !articleName.match(REGEX.menuStyle) &&
64
+ !html.match(REGEX.hasH1) &&
65
+ !html.match(REGEX.noH1)
26
66
  )
27
67
  html = "=" + articleName.replace(/^_/, "") + "=\n" + html;
28
- html = html.replace(/__NOH1__/g, "");
68
+ html = html.replace(REGEX.noH1Replace, "");
29
69
 
30
70
  // basic formatting ------------------------------------------
31
71
  // nowiki
32
- html = html.replace(/<nowiki>([\d\D]*?)<\/nowiki>/g, processNoWiki);
33
- html = html.replace(/^ ([^\n]*)$/gm, processCodeBlock);
34
- 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);
35
75
  //html = html.replace( /{(?!\|)([^\|]+\|)?([^}]*)}/g , processJSON );
36
76
  // headers
37
- html = html.replace(/^===([^=\n]+)===/gm, "<h3>$1</h3>");
38
- html = html.replace(/^==([^=\n]+)==/gm, "<h2>$1</h2>");
39
- 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>");
40
80
 
41
81
  // bullets
42
- html = html.replace(/(\n|^)#([\d\D]*?)(\n(?!#)|$)/g, processNumberedLists);
43
- html = html.replace(/(\n|^)\*([\d\D]*?)(\n(?!\*)|$)/g, processBullets);
82
+ html = html.replace(REGEX.numberedList, processNumberedLists);
83
+ html = html.replace(REGEX.bulletList, processBullets);
44
84
 
45
85
  // dd/dt
46
- html = html.replace(
47
- /^;([^:\n]*)\n?(?::(.*))?/gm,
48
- "<dl><dt>$1</dt><dd>$2</dd></dl>"
49
- );
50
- 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");
51
88
  // hr
52
- html = html.replace(/---/g, "<hr>");
89
+ html = html.replace(REGEX.hr, "<hr>");
53
90
  // inline
54
- html = html.replace(/'''''([^']+)'''''/g, "<b><i>$1</i></b>");
55
- html = html.replace(/'''([^']+)'''/g, "<b>$1</b>");
56
- 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>");
57
94
  // html = html.replace( /''(.*?)''/g , '<i>$1</i>' );
58
95
  // strikethrough
59
96
  // html = html.replace( /--(.*?)--/g , '<strike>$1</strike>' );
60
97
  // embiggen
61
- html = html.replace(
62
- /\+\+\+([^\+]+)\+\+\+/g,
63
- '<span style="font-size: 200%;">$1</span>'
64
- );
65
- html = html.replace(
66
- /\+\+([^\+]+)\+\+/g,
67
- '<span style="font-size: 150%;">$1</span>'
68
- );
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>');
69
100
  // tables
70
- html = html.replace(/\{\|([\d\D]*?)\|\}/g, processTable);
101
+ html = html.replace(REGEX.table, processTable);
71
102
  // div/indent
72
- html = html.replace(/^\.\.\.(.*)$/gm, '<div class="indent2">$1</div>');
73
- html = html.replace(/^\.\.(.*)$/gm, '<div class="indent1">$1</div>');
74
- 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>");
75
106
  // links
76
- html = html.replace(
77
- /\[\[([^\[\]\|#]*)(?:(\|[^\]\|#]*)+)?(?:#([^\]\|#]*))?\]\]/g,
78
- processLink
79
- );
80
- html = html.replace(
81
- /\[\[([^\[\]\|#\n]*)((\|[^\]\|#\n]*)+)?(?:#([^\]\|#\n]*))?\]\]/g,
82
- processLink
83
- );
84
- 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);
85
110
 
86
111
  // code
87
112
  // html = html.replace( /^ (.*)$/mg , '<code>$1</code>' );
88
113
  // paragraphs
89
114
  html = html.trim();
90
115
  // html = html.replace( /^.*$/gm , processParagraphs );
91
- html = html.replace(/^[^\$\n].*$/gm, processParagraphs);
92
- html = html.replace(/<p><\/p>/g, "");
116
+ html = html.replace(REGEX.paragraph, processParagraphs);
117
+ html = html.replace(REGEX.emptyP, "");
93
118
  // beautify HTML
94
119
  //html = beautifyHTML(html);
95
120
 
96
121
  // superscript
97
- html = html.replace(/\^([^\^]*)\^/g, "<sup>$1</sup>");
122
+ html = html.replace(REGEX.superscript, "<sup>$1</sup>");
98
123
 
99
124
  // restore nowiki blocks
100
- html = html.replace(/\$NOWIKI_(\d*)\$/g, processNoWikiRestore);
101
- html = html.replace(/\$CODE_(\d*)\$/g, processCodeBlockRestore);
102
- html = html.replace(/<\/code>\s*<code>/g, "\n");
103
- 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);
104
129
  //html = html.replace( /\$JSON_(\d*)\$/g , processJSONRestore );
105
130
 
106
131
  // WORKING CODE for sectioning h1 and h2
107
132
  if (!args.noSection) {
108
- var find =
109
- /(?:<h1>)([^\|<]*)(?:\|([^<\|]*))?(?:\|([^<]*))?(?:<\/h1>)([\d\D]*?)(?=<h1|$)/g;
133
+ var find = REGEX.sectionH1;
110
134
  var replace =
111
135
  '\
112
136
  <div class="sectionOuter sectionOuter1 $2" style="$3">\
@@ -134,9 +158,8 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
134
158
  return em.replace(find, replace);
135
159
  });
136
160
 
137
- var find =
138
- /(?:<h2>)([^\|<]*)(?:\|([^<\|]*))?(?:\|([^<]*))?(?:<\/h2>)([\d\D]*?)(?=<h2|<\!--SECTION-END|$)/g;
139
- var replace =
161
+ find = REGEX.sectionH2;
162
+ replace =
140
163
  '\
141
164
  <div class="sectionOuter2 $2">\
142
165
  <h2>$1</h2>\
@@ -151,7 +174,7 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
151
174
 
152
175
  // adding IDs to headers for TOC seeks
153
176
  if (!args.noTOC) {
154
- var find = /(?:<h(\d)>)([^<]*)(?:<\/h\1>)/g;
177
+ var find = REGEX.tocHeader;
155
178
  var replace = '<h$1 id="$2">$2</h$1>';
156
179
  html = html.replace(find, function (em, g1, g2) {
157
180
  var id = g2.replace(/\s/g, "_");
@@ -226,31 +249,19 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
226
249
  if (!anchor) anchor = "";
227
250
  else anchor = "#" + anchor;
228
251
 
229
- var active = true;
230
-
231
- active = !!allArticles.find((article) => article.match(articleName));
252
+ // Note: Link validation (active/inactive status) is now handled by linkValidator.js
253
+ // after HTML generation, so we don't set active/inactive class here.
232
254
 
233
255
  if (articleName.indexOf("/") >= 0) {
234
256
  // assume the link is fully formed
235
- return `<a class="wikiLink${
236
- active ? " active" : ""
237
- } data-articleName="${articleName}" href="${articleName}">${
257
+ return `<a class="wikiLink" data-articleName="${articleName}" href="${articleName}">${
238
258
  displayName || articleName
239
259
  }</a>`;
240
260
  } else {
241
261
  var link = linkbase + articleName + anchor;
242
262
 
243
- // not sure what this did, but I need a new handler for this case
244
- // if (articleName.indexOf('/')>-1) {
245
- // link = '/'+articleName+anchor;
246
- // displayName = articleName.substr(articleName.indexOf('/')+1);
247
- // console.log('link=',link);
248
- // }
249
-
250
263
  return (
251
- '<a class="wikiLink ' +
252
- (active ? "active" : "inactive") +
253
- '" data-articleName="' +
264
+ '<a class="wikiLink" data-articleName="' +
254
265
  articleName +
255
266
  '" href="' +
256
267
  link +
@@ -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...");