@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 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
- // Embed search index data
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.44.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 dirents = await readdir(dir, { withFileTypes: true });
6
- const files = await Promise.all(
7
- dirents.map(async (dirent) => {
8
- const res = resolve(dir, dirent.name);
9
- return dirent.isDirectory() ? [res, ...(await recurse(res))] : res;
10
- })
11
- );
12
- return Array.prototype.concat(...files);
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
  }
@@ -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
- console.log(`Built ${validPaths.size} valid paths for link validation`);
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
- console.log(`Build #${buildId}`);
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
- console.log(`Loaded ${hashCache.size} cached content hashes from .ursa folder`);
289
+ progress.log(`Loaded ${hashCache.size} cached content hashes from .ursa folder`);
145
290
  } else {
146
- console.log(`Clean build: ignoring cached hashes`);
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
- // First pass: collect search index data
302
+ // Search index: built incrementally during article processing (lighter memory footprint)
158
303
  const searchIndex = [];
159
- const jsonCache = new Map();
160
-
161
- // Collect basic data for search index
162
- for (const file of allSourceFilenamesThatAreArticles) {
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
- // Generate URL path relative to output
174
- const relativePath = file.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
175
- const url = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
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
- // Basic content processing for search (without full rendering)
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
- // Track files that were regenerated (for incremental mode stats)
204
- let regeneratedCount = 0;
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 rawBody = await readFile(file, "utf8");
212
- const type = parse(file).ext;
213
- const ext = extname(file);
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
- // Insert embeddedStyle just before </head> if present, else at top
294
- let finalHtml = template
295
- .replace("${title}", title)
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
- await outputFile(outputFilename, finalHtml);
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
- // json
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
- const jsonOutputFilename = outputFilename.replace(".html", ".json");
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
- // xml
423
+ await outputFile(outputFilename, finalHtml);
424
+
425
+ // Clear finalHtml reference to allow GC
426
+ finalHtml = null;
336
427
 
337
- const xmlOutputFilename = outputFilename.replace(".html", ".xml");
338
- const xml = `<article>${o2x(jsonObject)}</article>`;
339
- await outputFile(xmlOutputFilename, xml);
340
-
341
- // Update the content hash for this file
342
- updateHash(file, rawBody, hashCache);
343
- } catch (e) {
344
- console.error(`Error processing ${file} (second pass): ${e.message}`);
345
- errors.push({ file, phase: 'article-generation', error: e });
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
- // Log build stats
351
- console.log(`Build: ${regeneratedCount} regenerated, ${skippedCount} unchanged`);
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
- console.log(jsonCache.keys());
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
- const pathsInThisDirectory = allSourceFilenames.filter((filename) =>
362
- filename.match(new RegExp(`${dir}.+`))
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
- const jsonObjects = pathsInThisDirectory
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 object = jsonCache.get(path);
368
- return typeof object === "object" ? object : null;
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
- .filter((a) => a);
371
-
372
- const json = JSON.stringify(jsonObjects);
373
-
374
- const outputFilename = dir.replace(source, output) + ".json";
375
-
376
- console.log(`writing directory index to ${outputFilename}`);
377
- await outputFile(outputFilename, json);
378
-
379
- // html
380
- const htmlOutputFilename = dir.replace(source, output) + ".html";
381
- const indexAlreadyExists = fileExists(htmlOutputFilename);
382
- if (!indexAlreadyExists) {
383
- const template = templates["default-template"]; // TODO: figure out a way to specify template for a directory index
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
- } catch (e) {
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
- await Promise.all(
418
- allSourceFilenamesThatAreImages.map(async (file) => {
419
- try {
420
- // For incremental mode, check if file has changed using file stat as a quick check
421
- if (_incremental) {
422
- const fileStat = await stat(file);
423
- const statKey = `${file}:stat`;
424
- const newStatHash = `${fileStat.size}:${fileStat.mtimeMs}`;
425
- if (hashCache.get(statKey) === newStatHash) {
426
- return; // Skip unchanged static file
427
- }
428
- hashCache.set(statKey, newStatHash);
429
- }
430
-
431
- console.log(`processing static file ${file}`);
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
- const outputFilename = file.replace(source, output);
567
+ const outputFilename = file.replace(source, output);
434
568
 
435
- console.log(`writing static file to ${outputFilename}`);
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
- await mkdir(dirname(outputFilename), { recursive: true });
438
- return await copyFile(file, outputFilename);
439
- } catch (e) {
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
- console.log(`\nāš ļø ${errors.length} error(s) occurred during generation.`);
482
- console.log(` Error report written to: ${errorReportPath}\n`);
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
- console.log(`\nāœ… Generation complete with no errors.\n`);
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
- // Initial generation (use _clean flag only for initial generation)
27
- console.log("Generating initial site...");
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 file changes...");
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
  /**