@kenjura/ursa 0.44.0 ā 0.46.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 +12 -0
- package/README.md +20 -0
- package/bin/ursa.js +20 -0
- package/meta/default-template.html +8 -1
- package/package.json +3 -1
- package/src/helper/recursive-readdir.js +29 -8
- package/src/jobs/generate.js +518 -253
- package/src/serve.js +21 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# 0.46.0
|
|
2
|
+
2025-12-20
|
|
3
|
+
|
|
4
|
+
- Normalized handling of trailing slashes in URLs
|
|
5
|
+
|
|
6
|
+
# 0.45.0
|
|
7
|
+
2025-12-20
|
|
8
|
+
|
|
9
|
+
- Added --exclude flag to ignore specified files or directories during generation
|
|
10
|
+
- Improved performance of the serve command with optimized file watching
|
|
11
|
+
- Automatically generating index.html for directories without an index file
|
|
12
|
+
|
|
1
13
|
# 0.44.0
|
|
2
14
|
2025-12-16
|
|
3
15
|
|
package/README.md
CHANGED
|
@@ -94,6 +94,26 @@ important-document
|
|
|
94
94
|
classes/wizard
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
### Large Workloads
|
|
98
|
+
|
|
99
|
+
For sites with many documents (hundreds or thousands), you may need to increase Node.js memory limits:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Increase heap size to 8GB for large sites
|
|
103
|
+
node --max-old-space-size=8192 $(which ursa) serve content
|
|
104
|
+
|
|
105
|
+
# Or use the npm scripts
|
|
106
|
+
npm run serve:large content
|
|
107
|
+
npm run generate:large content
|
|
108
|
+
|
|
109
|
+
# You can also set environment variables to tune batch processing
|
|
110
|
+
URSA_BATCH_SIZE=25 ursa serve content # Process fewer files at once (default: 50)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Environment Variables for Performance Tuning:**
|
|
114
|
+
- `URSA_BATCH_SIZE` - Number of files to process concurrently (default: 50). Lower values use less memory but are slower.
|
|
115
|
+
- `NODE_OPTIONS="--max-old-space-size=8192"` - Increase Node.js heap size for very large sites.
|
|
116
|
+
|
|
97
117
|
## Library Usage
|
|
98
118
|
|
|
99
119
|
### ES Modules (recommended)
|
package/bin/ursa.js
CHANGED
|
@@ -38,6 +38,11 @@ yargs(hideBin(process.argv))
|
|
|
38
38
|
describe: 'Path to whitelist file containing patterns for files to include',
|
|
39
39
|
type: 'string'
|
|
40
40
|
})
|
|
41
|
+
.option('exclude', {
|
|
42
|
+
alias: 'x',
|
|
43
|
+
describe: 'Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line',
|
|
44
|
+
type: 'string'
|
|
45
|
+
})
|
|
41
46
|
.option('clean', {
|
|
42
47
|
alias: 'c',
|
|
43
48
|
describe: 'Ignore cached hashes and regenerate all files',
|
|
@@ -50,12 +55,16 @@ yargs(hideBin(process.argv))
|
|
|
50
55
|
const meta = argv.meta ? resolve(argv.meta) : PACKAGE_META;
|
|
51
56
|
const output = resolve(argv.output);
|
|
52
57
|
const whitelist = argv.whitelist ? resolve(argv.whitelist) : null;
|
|
58
|
+
const exclude = argv.exclude || null;
|
|
53
59
|
const clean = argv.clean;
|
|
54
60
|
|
|
55
61
|
console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
|
|
56
62
|
if (whitelist) {
|
|
57
63
|
console.log(`Using whitelist: ${whitelist}`);
|
|
58
64
|
}
|
|
65
|
+
if (exclude) {
|
|
66
|
+
console.log(`Excluding: ${exclude}`);
|
|
67
|
+
}
|
|
59
68
|
if (clean) {
|
|
60
69
|
console.log(`Clean build: ignoring cached hashes`);
|
|
61
70
|
}
|
|
@@ -66,6 +75,7 @@ yargs(hideBin(process.argv))
|
|
|
66
75
|
_meta: meta,
|
|
67
76
|
_output: output,
|
|
68
77
|
_whitelist: whitelist,
|
|
78
|
+
_exclude: exclude,
|
|
69
79
|
_clean: clean
|
|
70
80
|
});
|
|
71
81
|
console.log('Site generation completed successfully!');
|
|
@@ -107,6 +117,11 @@ yargs(hideBin(process.argv))
|
|
|
107
117
|
describe: 'Path to whitelist file containing patterns for files to include',
|
|
108
118
|
type: 'string'
|
|
109
119
|
})
|
|
120
|
+
.option('exclude', {
|
|
121
|
+
alias: 'x',
|
|
122
|
+
describe: 'Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line',
|
|
123
|
+
type: 'string'
|
|
124
|
+
})
|
|
110
125
|
.option('clean', {
|
|
111
126
|
alias: 'c',
|
|
112
127
|
describe: 'Ignore cached hashes and regenerate all files',
|
|
@@ -120,6 +135,7 @@ yargs(hideBin(process.argv))
|
|
|
120
135
|
const output = resolve(argv.output);
|
|
121
136
|
const port = argv.port;
|
|
122
137
|
const whitelist = argv.whitelist ? resolve(argv.whitelist) : null;
|
|
138
|
+
const exclude = argv.exclude || null;
|
|
123
139
|
const clean = argv.clean;
|
|
124
140
|
|
|
125
141
|
console.log(`Starting development server...`);
|
|
@@ -130,6 +146,9 @@ yargs(hideBin(process.argv))
|
|
|
130
146
|
if (whitelist) {
|
|
131
147
|
console.log(`Using whitelist: ${whitelist}`);
|
|
132
148
|
}
|
|
149
|
+
if (exclude) {
|
|
150
|
+
console.log(`Excluding: ${exclude}`);
|
|
151
|
+
}
|
|
133
152
|
|
|
134
153
|
try {
|
|
135
154
|
const { serve } = await import('../src/serve.js');
|
|
@@ -139,6 +158,7 @@ yargs(hideBin(process.argv))
|
|
|
139
158
|
_output: output,
|
|
140
159
|
port: port,
|
|
141
160
|
_whitelist: whitelist,
|
|
161
|
+
_exclude: exclude,
|
|
142
162
|
_clean: clean
|
|
143
163
|
});
|
|
144
164
|
} catch (error) {
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
${embeddedStyle}
|
|
10
10
|
</style>
|
|
11
11
|
<script>
|
|
12
|
-
//
|
|
12
|
+
// Search index loaded asynchronously from separate file to reduce page size
|
|
13
13
|
window.SEARCH_INDEX = ${searchIndex};
|
|
14
|
+
// Lazy load full search index if placeholder is empty
|
|
15
|
+
if (!window.SEARCH_INDEX || window.SEARCH_INDEX.length === 0) {
|
|
16
|
+
fetch('/public/search-index.json')
|
|
17
|
+
.then(r => r.json())
|
|
18
|
+
.then(data => { window.SEARCH_INDEX = data; })
|
|
19
|
+
.catch(() => { window.SEARCH_INDEX = []; });
|
|
20
|
+
}
|
|
14
21
|
</script>
|
|
15
22
|
<script src="/public/search.js"></script>
|
|
16
23
|
|
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.
|
|
5
|
+
"version": "0.46.0",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"serve": "nodemon --config nodemon.json src/serve.js",
|
|
13
13
|
"serve:debug": "nodemon --config nodemon.json --inspect-brk src/serve.js",
|
|
14
|
+
"serve:large": "node --max-old-space-size=8192 bin/ursa.js serve",
|
|
15
|
+
"generate:large": "node --max-old-space-size=8192 bin/ursa.js generate",
|
|
14
16
|
"cli:debug": "node --inspect bin/ursa.js",
|
|
15
17
|
"cli:debug-brk": "node --inspect-brk bin/ursa.js",
|
|
16
18
|
"start": "node src/index.js",
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { resolve } from "path";
|
|
2
2
|
import { readdir } from "fs/promises";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Recursively read directory contents.
|
|
6
|
+
* Optimized to be more memory-efficient by using iteration instead of deep recursion.
|
|
7
|
+
* @param {string} dir - Directory to read
|
|
8
|
+
* @returns {Promise<string[]>} Array of file paths
|
|
9
|
+
*/
|
|
4
10
|
export async function recurse(dir) {
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const results = [];
|
|
12
|
+
const stack = [dir];
|
|
13
|
+
|
|
14
|
+
while (stack.length > 0) {
|
|
15
|
+
const currentDir = stack.pop();
|
|
16
|
+
try {
|
|
17
|
+
const dirents = await readdir(currentDir, { withFileTypes: true });
|
|
18
|
+
for (const dirent of dirents) {
|
|
19
|
+
const res = resolve(currentDir, dirent.name);
|
|
20
|
+
if (dirent.isDirectory()) {
|
|
21
|
+
results.push(res);
|
|
22
|
+
stack.push(res);
|
|
23
|
+
} else {
|
|
24
|
+
results.push(res);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Skip directories we can't read (permission errors, etc.)
|
|
29
|
+
console.warn(`Warning: Could not read directory ${currentDir}: ${e.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return results;
|
|
13
34
|
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
import { recurse } from "../helper/recursive-readdir.js";
|
|
2
2
|
|
|
3
3
|
import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
6
|
+
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Progress reporter that updates lines in place (like pnpm)
|
|
10
|
+
*/
|
|
11
|
+
class ProgressReporter {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.lines = {};
|
|
14
|
+
this.isTTY = process.stdout.isTTY;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Update a named status line in place
|
|
18
|
+
status(name, message) {
|
|
19
|
+
if (this.isTTY) {
|
|
20
|
+
// Save cursor, move to line, clear it, write, restore cursor
|
|
21
|
+
const line = `${name}: ${message}`;
|
|
22
|
+
this.lines[name] = line;
|
|
23
|
+
// Clear line and write
|
|
24
|
+
process.stdout.write(`\r\x1b[K${line}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Complete a status line (print final state and newline)
|
|
29
|
+
done(name, message) {
|
|
30
|
+
if (this.isTTY) {
|
|
31
|
+
process.stdout.write(`\r\x1b[K${name}: ${message}\n`);
|
|
32
|
+
} else {
|
|
33
|
+
console.log(`${name}: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
delete this.lines[name];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Regular log that doesn't get overwritten
|
|
39
|
+
log(message) {
|
|
40
|
+
if (this.isTTY) {
|
|
41
|
+
// Clear current line first, print message, then newline
|
|
42
|
+
process.stdout.write(`\r\x1b[K${message}\n`);
|
|
43
|
+
} else {
|
|
44
|
+
console.log(message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clear all status lines
|
|
49
|
+
clear() {
|
|
50
|
+
if (this.isTTY) {
|
|
51
|
+
process.stdout.write(`\r\x1b[K`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const progress = new ProgressReporter();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Process items in batches to limit memory usage
|
|
60
|
+
* @param {Array} items - Items to process
|
|
61
|
+
* @param {Function} processor - Async function to process each item
|
|
62
|
+
* @param {number} batchSize - Max concurrent operations
|
|
63
|
+
*/
|
|
64
|
+
async function processBatched(items, processor, batchSize = BATCH_SIZE) {
|
|
65
|
+
const results = [];
|
|
66
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
67
|
+
const batch = items.slice(i, i + batchSize);
|
|
68
|
+
const batchResults = await Promise.all(batch.map(processor));
|
|
69
|
+
results.push(...batchResults);
|
|
70
|
+
// Allow GC to run between batches
|
|
71
|
+
if (global.gc) global.gc();
|
|
72
|
+
}
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
4
75
|
import { getAutomenu } from "../helper/automenu.js";
|
|
5
76
|
import { filterAsync } from "../helper/filterAsync.js";
|
|
6
77
|
import { isDirectory } from "../helper/isDirectory.js";
|
|
@@ -68,15 +139,80 @@ import { createWhitelistFilter } from "../helper/whitelistFilter.js";
|
|
|
68
139
|
const DEFAULT_TEMPLATE_NAME =
|
|
69
140
|
process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
|
|
70
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Parse exclude option - can be comma-separated paths or a file path
|
|
144
|
+
* @param {string} excludeOption - The exclude option value
|
|
145
|
+
* @param {string} source - Source directory path
|
|
146
|
+
* @returns {Promise<Set<string>>} Set of excluded folder paths (normalized)
|
|
147
|
+
*/
|
|
148
|
+
async function parseExcludeOption(excludeOption, source) {
|
|
149
|
+
const excludedPaths = new Set();
|
|
150
|
+
|
|
151
|
+
if (!excludeOption) return excludedPaths;
|
|
152
|
+
|
|
153
|
+
// Check if it's a file path (exists as a file)
|
|
154
|
+
const isFile = existsSync(excludeOption) && (await stat(excludeOption)).isFile();
|
|
155
|
+
|
|
156
|
+
let patterns;
|
|
157
|
+
if (isFile) {
|
|
158
|
+
// Read patterns from file (one per line)
|
|
159
|
+
const content = await readFile(excludeOption, 'utf8');
|
|
160
|
+
patterns = content.split('\n')
|
|
161
|
+
.map(line => line.trim())
|
|
162
|
+
.filter(line => line && !line.startsWith('#')); // Skip empty lines and comments
|
|
163
|
+
} else {
|
|
164
|
+
// Treat as comma-separated list
|
|
165
|
+
patterns = excludeOption.split(',').map(p => p.trim()).filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Normalize patterns to absolute paths
|
|
169
|
+
for (const pattern of patterns) {
|
|
170
|
+
// Remove leading/trailing slashes and normalize
|
|
171
|
+
const normalized = pattern.replace(/^\/+|\/+$/g, '');
|
|
172
|
+
// Store as relative path for easier matching
|
|
173
|
+
excludedPaths.add(normalized);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return excludedPaths;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a filter function that excludes files in specified folders
|
|
181
|
+
* @param {Set<string>} excludedPaths - Set of excluded folder paths
|
|
182
|
+
* @param {string} source - Source directory path
|
|
183
|
+
* @returns {Function} Filter function
|
|
184
|
+
*/
|
|
185
|
+
function createExcludeFilter(excludedPaths, source) {
|
|
186
|
+
if (excludedPaths.size === 0) {
|
|
187
|
+
return () => true; // No exclusions, allow all
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (filePath) => {
|
|
191
|
+
// Get path relative to source
|
|
192
|
+
const relativePath = filePath.replace(source, '').replace(/^\/+/, '');
|
|
193
|
+
|
|
194
|
+
// Check if file is in any excluded folder
|
|
195
|
+
for (const excluded of excludedPaths) {
|
|
196
|
+
if (relativePath === excluded ||
|
|
197
|
+
relativePath.startsWith(excluded + '/') ||
|
|
198
|
+
relativePath.startsWith(excluded + '\\')) {
|
|
199
|
+
return false; // Exclude this file
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return true; // Include this file
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
71
206
|
export async function generate({
|
|
72
207
|
_source = join(process.cwd(), "."),
|
|
73
208
|
_meta = join(process.cwd(), "meta"),
|
|
74
209
|
_output = join(process.cwd(), "build"),
|
|
75
210
|
_whitelist = null,
|
|
211
|
+
_exclude = null,
|
|
76
212
|
_incremental = false, // Legacy flag, now ignored (always incremental)
|
|
77
213
|
_clean = false, // When true, ignore cache and regenerate all files
|
|
78
214
|
} = {}) {
|
|
79
|
-
console.log({ _source, _meta, _output, _whitelist, _clean });
|
|
215
|
+
console.log({ _source, _meta, _output, _whitelist, _exclude, _clean });
|
|
80
216
|
const source = resolve(_source) + "/";
|
|
81
217
|
const meta = resolve(_meta);
|
|
82
218
|
const output = resolve(_output) + "/";
|
|
@@ -90,6 +226,15 @@ export async function generate({
|
|
|
90
226
|
: Boolean;
|
|
91
227
|
let allSourceFilenames = allSourceFilenamesUnfiltered.filter(includeFilter);
|
|
92
228
|
|
|
229
|
+
// Apply exclude filter if specified
|
|
230
|
+
if (_exclude) {
|
|
231
|
+
const excludedPaths = await parseExcludeOption(_exclude, source);
|
|
232
|
+
const excludeFilter = createExcludeFilter(excludedPaths, source);
|
|
233
|
+
const beforeCount = allSourceFilenames.length;
|
|
234
|
+
allSourceFilenames = allSourceFilenames.filter(excludeFilter);
|
|
235
|
+
progress.log(`Exclude filter applied: ${beforeCount - allSourceFilenames.length} files excluded`);
|
|
236
|
+
}
|
|
237
|
+
|
|
93
238
|
// Apply whitelist filter if specified
|
|
94
239
|
if (_whitelist) {
|
|
95
240
|
const whitelistFilter = await createWhitelistFilter(_whitelist, source);
|
|
@@ -126,13 +271,13 @@ export async function generate({
|
|
|
126
271
|
|
|
127
272
|
// Build set of valid internal paths for link validation (must be before menu)
|
|
128
273
|
const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source);
|
|
129
|
-
|
|
274
|
+
progress.log(`Built ${validPaths.size} valid paths for link validation`);
|
|
130
275
|
|
|
131
276
|
const menu = await getMenu(allSourceFilenames, source, validPaths);
|
|
132
277
|
|
|
133
278
|
// Get and increment build ID from .ursa.json
|
|
134
279
|
const buildId = getAndIncrementBuildId(resolve(_source));
|
|
135
|
-
|
|
280
|
+
progress.log(`Build #${buildId}`);
|
|
136
281
|
|
|
137
282
|
// Generate footer content
|
|
138
283
|
const footer = await getFooter(source, _source, buildId);
|
|
@@ -141,9 +286,9 @@ export async function generate({
|
|
|
141
286
|
let hashCache = new Map();
|
|
142
287
|
if (!_clean) {
|
|
143
288
|
hashCache = await loadHashCache(source);
|
|
144
|
-
|
|
289
|
+
progress.log(`Loaded ${hashCache.size} cached content hashes from .ursa folder`);
|
|
145
290
|
} else {
|
|
146
|
-
|
|
291
|
+
progress.log(`Clean build: ignoring cached hashes`);
|
|
147
292
|
}
|
|
148
293
|
|
|
149
294
|
// create public folder
|
|
@@ -154,294 +299,286 @@ export async function generate({
|
|
|
154
299
|
// Track errors for error report
|
|
155
300
|
const errors = [];
|
|
156
301
|
|
|
157
|
-
//
|
|
302
|
+
// Search index: built incrementally during article processing (lighter memory footprint)
|
|
158
303
|
const searchIndex = [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
304
|
+
// Directory index cache: only stores minimal data needed for directory indices
|
|
305
|
+
// Uses WeakRef-style approach - store only what's needed, clear as we go
|
|
306
|
+
const dirIndexCache = new Map();
|
|
307
|
+
|
|
308
|
+
// Track files that were regenerated (for incremental mode stats)
|
|
309
|
+
let regeneratedCount = 0;
|
|
310
|
+
let skippedCount = 0;
|
|
311
|
+
let processedCount = 0;
|
|
312
|
+
const totalArticles = allSourceFilenamesThatAreArticles.length;
|
|
313
|
+
|
|
314
|
+
progress.log(`Processing ${totalArticles} articles in batches of ${BATCH_SIZE}...`);
|
|
315
|
+
|
|
316
|
+
// Single pass: process all articles with batched concurrency to limit memory usage
|
|
317
|
+
await processBatched(allSourceFilenamesThatAreArticles, async (file) => {
|
|
163
318
|
try {
|
|
319
|
+
processedCount++;
|
|
320
|
+
const shortFile = file.replace(source, '');
|
|
321
|
+
progress.status('Articles', `${processedCount}/${totalArticles} ${shortFile}`);
|
|
322
|
+
|
|
164
323
|
const rawBody = await readFile(file, "utf8");
|
|
165
324
|
const type = parse(file).ext;
|
|
166
325
|
const ext = extname(file);
|
|
167
326
|
const base = basename(file, ext);
|
|
168
327
|
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
169
328
|
|
|
329
|
+
// Calculate output paths for this file
|
|
330
|
+
const outputFilename = file
|
|
331
|
+
.replace(source, output)
|
|
332
|
+
.replace(parse(file).ext, ".html");
|
|
333
|
+
const url = '/' + outputFilename.replace(output, '');
|
|
334
|
+
|
|
335
|
+
// Generate URL path relative to output (for search index)
|
|
336
|
+
const relativePath = file.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
|
|
337
|
+
const searchUrl = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
338
|
+
|
|
170
339
|
// Generate title from filename (in title case)
|
|
171
340
|
const title = toTitleCase(base);
|
|
172
341
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
342
|
+
// Always add to search index (lightweight: title + path only, content added lazily)
|
|
343
|
+
searchIndex.push({
|
|
344
|
+
title: title,
|
|
345
|
+
path: relativePath,
|
|
346
|
+
url: searchUrl,
|
|
347
|
+
content: '' // Content excerpts built lazily to save memory
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Check if file needs regeneration
|
|
351
|
+
const needsRegen = _clean || needsRegeneration(file, rawBody, hashCache);
|
|
352
|
+
|
|
353
|
+
if (!needsRegen) {
|
|
354
|
+
skippedCount++;
|
|
355
|
+
// For directory indices, store minimal data (not full bodyHtml)
|
|
356
|
+
dirIndexCache.set(file, {
|
|
357
|
+
name: base,
|
|
358
|
+
url,
|
|
359
|
+
// Don't store contents or bodyHtml - saves significant memory
|
|
360
|
+
});
|
|
361
|
+
return; // Skip regenerating this file
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
regeneratedCount++;
|
|
365
|
+
|
|
366
|
+
const fileMeta = extractMetadata(rawBody);
|
|
367
|
+
const rawMeta = extractRawMetadata(rawBody);
|
|
368
|
+
const transformedMetadata = await getTransformedMetadata(
|
|
369
|
+
dirname(file),
|
|
370
|
+
fileMeta
|
|
371
|
+
);
|
|
176
372
|
|
|
177
|
-
//
|
|
373
|
+
// Calculate the document's URL path (e.g., "/character/index.html")
|
|
374
|
+
const docUrlPath = '/' + dir + base + '.html';
|
|
375
|
+
|
|
178
376
|
const body = renderFile({
|
|
179
377
|
fileContents: rawBody,
|
|
180
378
|
type,
|
|
181
379
|
dirname: dir,
|
|
182
380
|
basename: base,
|
|
183
381
|
});
|
|
184
|
-
|
|
185
|
-
// Extract text content from body (strip HTML tags for search)
|
|
186
|
-
const textContent = body && body.replace && body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() || 'body is undefined for some reason'
|
|
187
|
-
const excerpt = textContent.substring(0, 200); // First 200 chars for preview
|
|
188
|
-
|
|
189
|
-
searchIndex.push({
|
|
190
|
-
title: title,
|
|
191
|
-
path: relativePath,
|
|
192
|
-
url: url,
|
|
193
|
-
content: excerpt
|
|
194
|
-
});
|
|
195
|
-
} catch (e) {
|
|
196
|
-
console.error(`Error processing ${file} (first pass): ${e.message}`);
|
|
197
|
-
errors.push({ file, phase: 'search-index', error: e });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
console.log(`Built search index with ${searchIndex.length} entries`);
|
|
202
382
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let skippedCount = 0;
|
|
206
|
-
|
|
207
|
-
// Second pass: process individual articles with search data available
|
|
208
|
-
await Promise.all(
|
|
209
|
-
allSourceFilenamesThatAreArticles.map(async (file) => {
|
|
383
|
+
// Find nearest style.css or _style.css up the tree
|
|
384
|
+
let embeddedStyle = "";
|
|
210
385
|
try {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const base = basename(file, ext);
|
|
215
|
-
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
216
|
-
|
|
217
|
-
// Calculate output paths for this file
|
|
218
|
-
const outputFilename = file
|
|
219
|
-
.replace(source, output)
|
|
220
|
-
.replace(parse(file).ext, ".html");
|
|
221
|
-
const url = '/' + outputFilename.replace(output, '');
|
|
222
|
-
|
|
223
|
-
// Skip files that haven't changed (unless --clean flag is set)
|
|
224
|
-
if (!_clean && !needsRegeneration(file, rawBody, hashCache)) {
|
|
225
|
-
skippedCount++;
|
|
226
|
-
// Still need to populate jsonCache for directory indices
|
|
227
|
-
const meta = extractMetadata(rawBody);
|
|
228
|
-
const body = renderFile({
|
|
229
|
-
fileContents: rawBody,
|
|
230
|
-
type,
|
|
231
|
-
dirname: dir,
|
|
232
|
-
basename: base,
|
|
233
|
-
});
|
|
234
|
-
// Extract sections for markdown files
|
|
235
|
-
const sections = type === '.md' ? extractSections(rawBody) : [];
|
|
236
|
-
|
|
237
|
-
jsonCache.set(file, {
|
|
238
|
-
name: base,
|
|
239
|
-
url,
|
|
240
|
-
contents: rawBody,
|
|
241
|
-
bodyHtml: body,
|
|
242
|
-
metadata: meta,
|
|
243
|
-
sections,
|
|
244
|
-
transformedMetadata: '',
|
|
245
|
-
});
|
|
246
|
-
return; // Skip regenerating this file
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
console.log(`processing article ${file}`);
|
|
250
|
-
regeneratedCount++;
|
|
251
|
-
|
|
252
|
-
const meta = extractMetadata(rawBody);
|
|
253
|
-
const rawMeta = extractRawMetadata(rawBody);
|
|
254
|
-
const bodyLessMeta = rawMeta ? rawBody.replace(rawMeta, "") : rawBody;
|
|
255
|
-
const transformedMetadata = await getTransformedMetadata(
|
|
256
|
-
dirname(file),
|
|
257
|
-
meta
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
// Calculate the document's URL path (e.g., "/character/index.html")
|
|
261
|
-
const docUrlPath = '/' + dir + base + '.html';
|
|
262
|
-
|
|
263
|
-
// Generate title from filename (in title case)
|
|
264
|
-
const title = toTitleCase(base);
|
|
265
|
-
|
|
266
|
-
const body = renderFile({
|
|
267
|
-
fileContents: rawBody,
|
|
268
|
-
type,
|
|
269
|
-
dirname: dir,
|
|
270
|
-
basename: base,
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// Find nearest style.css or _style.css up the tree
|
|
274
|
-
let embeddedStyle = "";
|
|
275
|
-
try {
|
|
276
|
-
const css = await findStyleCss(resolve(_source, dir));
|
|
277
|
-
if (css) {
|
|
278
|
-
embeddedStyle = css;
|
|
279
|
-
}
|
|
280
|
-
} catch (e) {
|
|
281
|
-
// ignore
|
|
282
|
-
console.error(e);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const requestedTemplateName = meta && meta.template;
|
|
286
|
-
const template =
|
|
287
|
-
templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
|
|
288
|
-
|
|
289
|
-
if (!template) {
|
|
290
|
-
throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
386
|
+
const css = await findStyleCss(resolve(_source, dir));
|
|
387
|
+
if (css) {
|
|
388
|
+
embeddedStyle = css;
|
|
291
389
|
}
|
|
390
|
+
} catch (e) {
|
|
391
|
+
// ignore
|
|
392
|
+
console.error(e);
|
|
393
|
+
}
|
|
292
394
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.replace("${menu}", menu)
|
|
297
|
-
.replace("${meta}", JSON.stringify(meta))
|
|
298
|
-
.replace("${transformedMetadata}", transformedMetadata)
|
|
299
|
-
.replace("${body}", body)
|
|
300
|
-
.replace("${embeddedStyle}", embeddedStyle)
|
|
301
|
-
.replace("${searchIndex}", JSON.stringify(searchIndex))
|
|
302
|
-
.replace("${footer}", footer);
|
|
303
|
-
|
|
304
|
-
// Resolve links and mark broken internal links as inactive (debug mode on)
|
|
305
|
-
// Pass docUrlPath so relative links can be resolved correctly
|
|
306
|
-
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
307
|
-
|
|
308
|
-
console.log(`writing article to ${outputFilename}`);
|
|
395
|
+
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
396
|
+
const template =
|
|
397
|
+
templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
|
|
309
398
|
|
|
310
|
-
|
|
399
|
+
if (!template) {
|
|
400
|
+
throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
401
|
+
}
|
|
311
402
|
|
|
312
|
-
|
|
403
|
+
// Build final HTML with all replacements in a single chain to reduce intermediate strings
|
|
404
|
+
let finalHtml = template;
|
|
405
|
+
// Use a map of replacements to minimize string allocations
|
|
406
|
+
const replacements = {
|
|
407
|
+
"${title}": title,
|
|
408
|
+
"${menu}": menu,
|
|
409
|
+
"${meta}": JSON.stringify(fileMeta),
|
|
410
|
+
"${transformedMetadata}": transformedMetadata,
|
|
411
|
+
"${body}": body,
|
|
412
|
+
"${embeddedStyle}": embeddedStyle,
|
|
413
|
+
"${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
|
|
414
|
+
"${footer}": footer
|
|
415
|
+
};
|
|
416
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
417
|
+
finalHtml = finalHtml.replace(key, value);
|
|
418
|
+
}
|
|
313
419
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
// Extract sections for markdown files
|
|
317
|
-
const sections = type === '.md' ? extractSections(rawBody) : [];
|
|
318
|
-
|
|
319
|
-
const jsonObject = {
|
|
320
|
-
name: base,
|
|
321
|
-
url,
|
|
322
|
-
contents: rawBody,
|
|
323
|
-
// bodyLessMeta: bodyLessMeta,
|
|
324
|
-
bodyHtml: body,
|
|
325
|
-
metadata: meta,
|
|
326
|
-
sections,
|
|
327
|
-
transformedMetadata,
|
|
328
|
-
// html: finalHtml,
|
|
329
|
-
};
|
|
330
|
-
jsonCache.set(file, jsonObject);
|
|
331
|
-
const json = JSON.stringify(jsonObject);
|
|
332
|
-
console.log(`writing article to ${jsonOutputFilename}`);
|
|
333
|
-
await outputFile(jsonOutputFilename, json);
|
|
420
|
+
// Resolve links and mark broken internal links as inactive
|
|
421
|
+
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
334
422
|
|
|
335
|
-
|
|
423
|
+
await outputFile(outputFilename, finalHtml);
|
|
424
|
+
|
|
425
|
+
// Clear finalHtml reference to allow GC
|
|
426
|
+
finalHtml = null;
|
|
336
427
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
428
|
+
// JSON output
|
|
429
|
+
const jsonOutputFilename = outputFilename.replace(".html", ".json");
|
|
430
|
+
|
|
431
|
+
// Extract sections for markdown files
|
|
432
|
+
const sections = type === '.md' ? extractSections(rawBody) : [];
|
|
433
|
+
|
|
434
|
+
const jsonObject = {
|
|
435
|
+
name: base,
|
|
436
|
+
url,
|
|
437
|
+
contents: rawBody,
|
|
438
|
+
bodyHtml: body,
|
|
439
|
+
metadata: fileMeta,
|
|
440
|
+
sections,
|
|
441
|
+
transformedMetadata,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Store minimal data for directory indices
|
|
445
|
+
dirIndexCache.set(file, {
|
|
446
|
+
name: base,
|
|
447
|
+
url,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const json = JSON.stringify(jsonObject);
|
|
451
|
+
await outputFile(jsonOutputFilename, json);
|
|
349
452
|
|
|
350
|
-
|
|
351
|
-
|
|
453
|
+
// XML output
|
|
454
|
+
const xmlOutputFilename = outputFilename.replace(".html", ".xml");
|
|
455
|
+
const xml = `<article>${o2x(jsonObject)}</article>`;
|
|
456
|
+
await outputFile(xmlOutputFilename, xml);
|
|
457
|
+
|
|
458
|
+
// Update the content hash for this file
|
|
459
|
+
updateHash(file, rawBody, hashCache);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
progress.log(`Error processing ${file}: ${e.message}`);
|
|
462
|
+
errors.push({ file, phase: 'article-generation', error: e });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
352
465
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// process directory indices
|
|
356
|
-
await Promise.all(
|
|
357
|
-
allSourceFilenamesThatAreDirectories.map(async (dir) => {
|
|
358
|
-
try {
|
|
359
|
-
console.log(`processing directory ${dir}`);
|
|
466
|
+
// Complete the articles status line
|
|
467
|
+
progress.done('Articles', `${totalArticles} done (${regeneratedCount} regenerated, ${skippedCount} unchanged)`);
|
|
360
468
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
469
|
+
// Write search index as a separate JSON file (not embedded in each page)
|
|
470
|
+
const searchIndexPath = join(output, 'public', 'search-index.json');
|
|
471
|
+
progress.log(`Writing search index with ${searchIndex.length} entries`);
|
|
472
|
+
await outputFile(searchIndexPath, JSON.stringify(searchIndex));
|
|
364
473
|
|
|
365
|
-
|
|
474
|
+
// Process directory indices with batched concurrency
|
|
475
|
+
const totalDirs = allSourceFilenamesThatAreDirectories.length;
|
|
476
|
+
let processedDirs = 0;
|
|
477
|
+
progress.log(`Processing ${totalDirs} directories...`);
|
|
478
|
+
await processBatched(allSourceFilenamesThatAreDirectories, async (dirPath) => {
|
|
479
|
+
try {
|
|
480
|
+
processedDirs++;
|
|
481
|
+
const shortDir = dirPath.replace(source, '');
|
|
482
|
+
progress.status('Directories', `${processedDirs}/${totalDirs} ${shortDir}`);
|
|
483
|
+
|
|
484
|
+
const pathsInThisDirectory = allSourceFilenames.filter((filename) =>
|
|
485
|
+
filename.match(new RegExp(`${dirPath}.+`))
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// Use minimal directory index cache instead of full jsonCache
|
|
489
|
+
const jsonObjects = pathsInThisDirectory
|
|
490
|
+
.map((path) => {
|
|
491
|
+
const object = dirIndexCache.get(path);
|
|
492
|
+
return typeof object === "object" ? object : null;
|
|
493
|
+
})
|
|
494
|
+
.filter((a) => a);
|
|
495
|
+
|
|
496
|
+
const json = JSON.stringify(jsonObjects);
|
|
497
|
+
|
|
498
|
+
const outputFilename = dirPath.replace(source, output) + ".json";
|
|
499
|
+
await outputFile(outputFilename, json);
|
|
500
|
+
|
|
501
|
+
// html
|
|
502
|
+
const htmlOutputFilename = dirPath.replace(source, output) + ".html";
|
|
503
|
+
const indexAlreadyExists = fileExists(htmlOutputFilename);
|
|
504
|
+
if (!indexAlreadyExists) {
|
|
505
|
+
const template = templates["default-template"];
|
|
506
|
+
const indexHtml = `<ul>${pathsInThisDirectory
|
|
366
507
|
.map((path) => {
|
|
367
|
-
const
|
|
368
|
-
|
|
508
|
+
const partialPath = path
|
|
509
|
+
.replace(source, "")
|
|
510
|
+
.replace(parse(path).ext, ".html");
|
|
511
|
+
const name = basename(path, parse(path).ext);
|
|
512
|
+
return `<li><a href="${partialPath}">${name}</a></li>`;
|
|
369
513
|
})
|
|
370
|
-
.
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const indexHtml = `<ul>${pathsInThisDirectory
|
|
385
|
-
.map((path) => {
|
|
386
|
-
const partialPath = path
|
|
387
|
-
.replace(source, "")
|
|
388
|
-
.replace(parse(path).ext, ".html");
|
|
389
|
-
const name = basename(path, parse(path).ext);
|
|
390
|
-
return `<li><a href="${partialPath}">${name}</a></li>`;
|
|
391
|
-
})
|
|
392
|
-
.join("")}</ul>`;
|
|
393
|
-
const finalHtml = template
|
|
394
|
-
.replace("${menu}", menu)
|
|
395
|
-
.replace("${body}", indexHtml)
|
|
396
|
-
.replace("${searchIndex}", JSON.stringify(searchIndex))
|
|
397
|
-
.replace("${title}", "Index")
|
|
398
|
-
.replace("${meta}", "{}")
|
|
399
|
-
.replace("${transformedMetadata}", "")
|
|
400
|
-
.replace("${embeddedStyle}", "")
|
|
401
|
-
.replace("${footer}", footer);
|
|
402
|
-
console.log(`writing directory index to ${htmlOutputFilename}`);
|
|
403
|
-
await outputFile(htmlOutputFilename, finalHtml);
|
|
514
|
+
.join("")}</ul>`;
|
|
515
|
+
let finalHtml = template;
|
|
516
|
+
const replacements = {
|
|
517
|
+
"${menu}": menu,
|
|
518
|
+
"${body}": indexHtml,
|
|
519
|
+
"${searchIndex}": "[]", // Search index now in separate file
|
|
520
|
+
"${title}": "Index",
|
|
521
|
+
"${meta}": "{}",
|
|
522
|
+
"${transformedMetadata}": "",
|
|
523
|
+
"${embeddedStyle}": "",
|
|
524
|
+
"${footer}": footer
|
|
525
|
+
};
|
|
526
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
527
|
+
finalHtml = finalHtml.replace(key, value);
|
|
404
528
|
}
|
|
405
|
-
|
|
406
|
-
console.error(`Error processing directory ${dir}: ${e.message}`);
|
|
407
|
-
errors.push({ file: dir, phase: 'directory-index', error: e });
|
|
529
|
+
await outputFile(htmlOutputFilename, finalHtml);
|
|
408
530
|
}
|
|
409
|
-
})
|
|
410
|
-
|
|
531
|
+
} catch (e) {
|
|
532
|
+
progress.log(`Error processing directory ${dirPath}: ${e.message}`);
|
|
533
|
+
errors.push({ file: dirPath, phase: 'directory-index', error: e });
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
progress.done('Directories', `${totalDirs} done`);
|
|
538
|
+
|
|
539
|
+
// Clear directory index cache to free memory before processing static files
|
|
540
|
+
dirIndexCache.clear();
|
|
411
541
|
|
|
412
|
-
// copy all static files (i.e. images)
|
|
542
|
+
// copy all static files (i.e. images) with batched concurrency
|
|
413
543
|
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/; // static asset extensions
|
|
414
544
|
const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
|
|
415
545
|
(filename) => filename.match(imageExtensions)
|
|
416
546
|
);
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
547
|
+
const totalStatic = allSourceFilenamesThatAreImages.length;
|
|
548
|
+
let processedStatic = 0;
|
|
549
|
+
let copiedStatic = 0;
|
|
550
|
+
progress.log(`Processing ${totalStatic} static files...`);
|
|
551
|
+
await processBatched(allSourceFilenamesThatAreImages, async (file) => {
|
|
552
|
+
try {
|
|
553
|
+
processedStatic++;
|
|
554
|
+
const shortFile = file.replace(source, '');
|
|
555
|
+
progress.status('Static files', `${processedStatic}/${totalStatic} ${shortFile}`);
|
|
556
|
+
|
|
557
|
+
// Check if file has changed using file stat as a quick check
|
|
558
|
+
const fileStat = await stat(file);
|
|
559
|
+
const statKey = `${file}:stat`;
|
|
560
|
+
const newStatHash = `${fileStat.size}:${fileStat.mtimeMs}`;
|
|
561
|
+
if (hashCache.get(statKey) === newStatHash) {
|
|
562
|
+
return; // Skip unchanged static file
|
|
563
|
+
}
|
|
564
|
+
hashCache.set(statKey, newStatHash);
|
|
565
|
+
copiedStatic++;
|
|
432
566
|
|
|
433
|
-
|
|
567
|
+
const outputFilename = file.replace(source, output);
|
|
434
568
|
|
|
435
|
-
|
|
569
|
+
await mkdir(dirname(outputFilename), { recursive: true });
|
|
570
|
+
return await copyFile(file, outputFilename);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
progress.log(`Error processing static file ${file}: ${e.message}`);
|
|
573
|
+
errors.push({ file, phase: 'static-file', error: e });
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
progress.done('Static files', `${totalStatic} done (${copiedStatic} copied)`);
|
|
436
578
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
console.error(`Error processing static file ${file}: ${e.message}`);
|
|
441
|
-
errors.push({ file, phase: 'static-file', error: e });
|
|
442
|
-
}
|
|
443
|
-
})
|
|
444
|
-
);
|
|
579
|
+
// Automatic index generation for folders without index.html
|
|
580
|
+
progress.log(`Checking for missing index files...`);
|
|
581
|
+
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer);
|
|
445
582
|
|
|
446
583
|
// Save the hash cache to .ursa folder in source directory
|
|
447
584
|
if (hashCache.size > 0) {
|
|
@@ -478,10 +615,138 @@ export async function generate({
|
|
|
478
615
|
});
|
|
479
616
|
|
|
480
617
|
await outputFile(errorReportPath, report);
|
|
481
|
-
|
|
482
|
-
|
|
618
|
+
progress.log(`\nā ļø ${errors.length} error(s) occurred during generation.`);
|
|
619
|
+
progress.log(` Error report written to: ${errorReportPath}\n`);
|
|
620
|
+
} else {
|
|
621
|
+
progress.log(`\nā
Generation complete with no errors.\n`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Generate automatic index.html files for folders that don't have one
|
|
627
|
+
* @param {string} output - Output directory path
|
|
628
|
+
* @param {string[]} directories - List of source directories
|
|
629
|
+
* @param {string} source - Source directory path
|
|
630
|
+
* @param {object} templates - Template map
|
|
631
|
+
* @param {string} menu - Rendered menu HTML
|
|
632
|
+
* @param {string} footer - Footer HTML
|
|
633
|
+
*/
|
|
634
|
+
async function generateAutoIndices(output, directories, source, templates, menu, footer) {
|
|
635
|
+
// Alternate index file names to look for (in priority order)
|
|
636
|
+
const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
|
|
637
|
+
|
|
638
|
+
// Normalize paths (remove trailing slashes for consistent replacement)
|
|
639
|
+
const sourceNorm = source.replace(/\/+$/, '');
|
|
640
|
+
const outputNorm = output.replace(/\/+$/, '');
|
|
641
|
+
|
|
642
|
+
// Get all output directories (including root)
|
|
643
|
+
const outputDirs = new Set([outputNorm]);
|
|
644
|
+
for (const dir of directories) {
|
|
645
|
+
// Handle both with and without trailing slash in source
|
|
646
|
+
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
647
|
+
outputDirs.add(outputDir);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let generatedCount = 0;
|
|
651
|
+
let renamedCount = 0;
|
|
652
|
+
|
|
653
|
+
for (const dir of outputDirs) {
|
|
654
|
+
const indexPath = join(dir, 'index.html');
|
|
655
|
+
|
|
656
|
+
// Skip if index.html already exists
|
|
657
|
+
if (existsSync(indexPath)) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Get folder name for (foldername).html check
|
|
662
|
+
const folderName = basename(dir);
|
|
663
|
+
const folderNameAlternate = `${folderName}.html`;
|
|
664
|
+
|
|
665
|
+
// Check for alternate index files
|
|
666
|
+
let foundAlternate = null;
|
|
667
|
+
for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
|
|
668
|
+
const altPath = join(dir, alt);
|
|
669
|
+
if (existsSync(altPath)) {
|
|
670
|
+
foundAlternate = altPath;
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (foundAlternate) {
|
|
676
|
+
// Rename/copy alternate to index.html
|
|
677
|
+
try {
|
|
678
|
+
const content = await readFile(foundAlternate, 'utf8');
|
|
679
|
+
await outputFile(indexPath, content);
|
|
680
|
+
renamedCount++;
|
|
681
|
+
progress.status('Auto-index', `Promoted ${basename(foundAlternate)} ā index.html in ${dir.replace(outputNorm, '') || '/'}`);
|
|
682
|
+
} catch (e) {
|
|
683
|
+
progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
// Generate a simple index listing direct children
|
|
687
|
+
try {
|
|
688
|
+
const children = await readdir(dir, { withFileTypes: true });
|
|
689
|
+
|
|
690
|
+
// Filter to only include relevant files and folders
|
|
691
|
+
const items = children
|
|
692
|
+
.filter(child => {
|
|
693
|
+
// Skip hidden files and index alternates we just checked
|
|
694
|
+
if (child.name.startsWith('.')) return false;
|
|
695
|
+
if (child.name === 'index.html') return false;
|
|
696
|
+
// Include directories and html files
|
|
697
|
+
return child.isDirectory() || child.name.endsWith('.html');
|
|
698
|
+
})
|
|
699
|
+
.map(child => {
|
|
700
|
+
const isDir = child.isDirectory();
|
|
701
|
+
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
702
|
+
const href = isDir ? `${child.name}/` : child.name;
|
|
703
|
+
const displayName = toTitleCase(name);
|
|
704
|
+
const icon = isDir ? 'š' : 'š';
|
|
705
|
+
return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
if (items.length === 0) {
|
|
709
|
+
// Empty folder, skip generating index
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
|
|
714
|
+
const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
715
|
+
|
|
716
|
+
const template = templates["default-template"];
|
|
717
|
+
if (!template) {
|
|
718
|
+
progress.log(`Warning: No default template for auto-index in ${dir}`);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let finalHtml = template;
|
|
723
|
+
const replacements = {
|
|
724
|
+
"${menu}": menu,
|
|
725
|
+
"${body}": indexHtml,
|
|
726
|
+
"${searchIndex}": "[]",
|
|
727
|
+
"${title}": folderDisplayName,
|
|
728
|
+
"${meta}": "{}",
|
|
729
|
+
"${transformedMetadata}": "",
|
|
730
|
+
"${embeddedStyle}": "",
|
|
731
|
+
"${footer}": footer
|
|
732
|
+
};
|
|
733
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
734
|
+
finalHtml = finalHtml.replace(key, value);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
await outputFile(indexPath, finalHtml);
|
|
738
|
+
generatedCount++;
|
|
739
|
+
progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
|
|
740
|
+
} catch (e) {
|
|
741
|
+
progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (generatedCount > 0 || renamedCount > 0) {
|
|
747
|
+
progress.done('Auto-index', `${generatedCount} generated, ${renamedCount} promoted`);
|
|
483
748
|
} else {
|
|
484
|
-
|
|
749
|
+
progress.log(`Auto-index: All folders already have index.html`);
|
|
485
750
|
}
|
|
486
751
|
}
|
|
487
752
|
|
package/src/serve.js
CHANGED
|
@@ -4,7 +4,7 @@ import { generate } from "./jobs/generate.js";
|
|
|
4
4
|
import { join, resolve } from "path";
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import { promises } from "fs";
|
|
7
|
-
const { readdir } = promises;
|
|
7
|
+
const { readdir, mkdir } = promises;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Configurable serve function for CLI and library use
|
|
@@ -15,31 +15,39 @@ export async function serve({
|
|
|
15
15
|
_output,
|
|
16
16
|
port = 8080,
|
|
17
17
|
_whitelist = null,
|
|
18
|
-
_clean = false
|
|
18
|
+
_clean = false,
|
|
19
|
+
_exclude = null
|
|
19
20
|
} = {}) {
|
|
20
21
|
const sourceDir = resolve(_source);
|
|
21
22
|
const metaDir = resolve(_meta);
|
|
22
23
|
const outputDir = resolve(_output);
|
|
23
24
|
|
|
24
|
-
console.log({ source: sourceDir, meta: metaDir, output: outputDir, port, whitelist: _whitelist, clean: _clean });
|
|
25
|
+
console.log({ source: sourceDir, meta: metaDir, output: outputDir, port, whitelist: _whitelist, exclude: _exclude, clean: _clean });
|
|
25
26
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _clean });
|
|
29
|
-
console.log("Initial generation complete. Starting server...");
|
|
30
|
-
|
|
31
|
-
// Start file server
|
|
27
|
+
// Ensure output directory exists and start server immediately
|
|
28
|
+
await mkdir(outputDir, { recursive: true });
|
|
32
29
|
serveFiles(outputDir, port);
|
|
30
|
+
console.log(`š Development server running at http://localhost:${port}`);
|
|
31
|
+
console.log("š Serving files from:", outputDir);
|
|
32
|
+
console.log("ā³ Generating site in background...\n");
|
|
33
|
+
|
|
34
|
+
// Initial generation (use _clean flag only for initial generation)
|
|
35
|
+
generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean })
|
|
36
|
+
.then(() => console.log("\nā
Initial generation complete.\n"))
|
|
37
|
+
.catch((error) => console.error("Error during initial generation:", error.message));
|
|
33
38
|
|
|
34
39
|
// Watch for changes
|
|
35
|
-
console.log("Watching for
|
|
40
|
+
console.log("š Watching for changes in:");
|
|
41
|
+
console.log(" Source:", sourceDir, "(incremental)");
|
|
42
|
+
console.log(" Meta:", metaDir, "(full rebuild)");
|
|
43
|
+
console.log("\nPress Ctrl+C to stop the server\n");
|
|
36
44
|
|
|
37
45
|
// Meta changes trigger full rebuild (templates, CSS, etc. affect all pages)
|
|
38
46
|
watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, async (evt, name) => {
|
|
39
47
|
console.log(`Meta files changed! Event: ${evt}, File: ${name}`);
|
|
40
48
|
console.log("Full rebuild required (meta files affect all pages)...");
|
|
41
49
|
try {
|
|
42
|
-
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _clean: true });
|
|
50
|
+
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
|
|
43
51
|
console.log("Regeneration complete.");
|
|
44
52
|
} catch (error) {
|
|
45
53
|
console.error("Error during regeneration:", error.message);
|
|
@@ -64,7 +72,7 @@ export async function serve({
|
|
|
64
72
|
if (isCssChange) {
|
|
65
73
|
console.log("CSS change detected - full rebuild required...");
|
|
66
74
|
try {
|
|
67
|
-
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _clean: true });
|
|
75
|
+
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
|
|
68
76
|
console.log("Regeneration complete.");
|
|
69
77
|
} catch (error) {
|
|
70
78
|
console.error("Error during regeneration:", error.message);
|
|
@@ -72,20 +80,13 @@ export async function serve({
|
|
|
72
80
|
} else {
|
|
73
81
|
console.log("Incremental rebuild...");
|
|
74
82
|
try {
|
|
75
|
-
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist });
|
|
83
|
+
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
|
|
76
84
|
console.log("Regeneration complete.");
|
|
77
85
|
} catch (error) {
|
|
78
86
|
console.error("Error during regeneration:", error.message);
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
});
|
|
82
|
-
|
|
83
|
-
console.log(`š Development server running at http://localhost:${port}`);
|
|
84
|
-
console.log("š Serving files from:", outputDir);
|
|
85
|
-
console.log("š Watching for changes in:");
|
|
86
|
-
console.log(" Source:", sourceDir, "(incremental)");
|
|
87
|
-
console.log(" Meta:", metaDir, "(full rebuild)");
|
|
88
|
-
console.log("\nPress Ctrl+C to stop the server");
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|