@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 +17 -0
- package/README.md +30 -0
- package/meta/menu.js +3 -2
- package/package.json +1 -1
- package/src/helper/wikitextHelper.js +80 -55
- package/src/jobs/generate.js +52 -10
- package/src/serve.js +65 -4
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
|
-
|
|
210
|
-
const
|
|
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,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(
|
|
22
|
-
!html.match(
|
|
23
|
-
!html.match(
|
|
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(
|
|
68
|
+
html = html.replace(REGEX.noH1Replace, "");
|
|
27
69
|
|
|
28
70
|
// basic formatting ------------------------------------------
|
|
29
71
|
// nowiki
|
|
30
|
-
html = html.replace(
|
|
31
|
-
html = html.replace(
|
|
32
|
-
html = html.replace(
|
|
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(
|
|
36
|
-
html = html.replace(
|
|
37
|
-
html = html.replace(
|
|
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(
|
|
41
|
-
html = html.replace(
|
|
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
|
-
|
|
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(
|
|
89
|
+
html = html.replace(REGEX.hr, "<hr>");
|
|
51
90
|
// inline
|
|
52
|
-
html = html.replace(
|
|
53
|
-
html = html.replace(
|
|
54
|
-
html = html.replace(
|
|
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
|
-
|
|
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(
|
|
101
|
+
html = html.replace(REGEX.table, processTable);
|
|
69
102
|
// div/indent
|
|
70
|
-
html = html.replace(
|
|
71
|
-
html = html.replace(
|
|
72
|
-
html = html.replace(
|
|
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
|
-
|
|
76
|
-
|
|
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(
|
|
90
|
-
html = html.replace(
|
|
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(
|
|
122
|
+
html = html.replace(REGEX.superscript, "<sup>$1</sup>");
|
|
96
123
|
|
|
97
124
|
// restore nowiki blocks
|
|
98
|
-
html = html.replace(
|
|
99
|
-
html = html.replace(
|
|
100
|
-
html = html.replace(
|
|
101
|
-
html = html.replace(
|
|
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
|
-
|
|
136
|
-
|
|
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 =
|
|
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, "_");
|
package/src/jobs/generate.js
CHANGED
|
@@ -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
|
|
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
|
|
450
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
//
|
|
94
|
-
return /\.(js|json|css|html|md|txt|yml|yaml)
|
|
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...");
|