@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 +22 -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 +97 -17
- package/src/serve.js +65 -4
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
450
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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...");
|