@kenjura/ursa 0.87.1 → 0.89.1

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.
@@ -1,1715 +1,1772 @@
1
- import { recurse } from "../helper/recursive-readdir.js";
2
- import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
3
- import { getAutomenu } from "../helper/automenu.js";
4
- import { filterAsync } from "../helper/filterAsync.js";
5
- import { isDirectory } from "../helper/isDirectory.js";
6
- import { isFolderHidden, clearConfigCache } from "../helper/folderConfig.js";
7
- import {
8
- extractMetadata,
9
- extractRawMetadata,
10
- isMetadataOnly,
11
- getAutoIndexConfig,
12
- } from "../helper/metadataExtractor.js";
13
- import { injectFrontmatterTable } from "../helper/frontmatterTable.js";
14
- import {
15
- hashContent,
16
- loadHashCache,
17
- saveHashCache,
18
- needsRegeneration,
19
- updateHash,
20
- getUrsaDir,
21
- } from "../helper/contentHash.js";
22
- import {
23
- buildValidPaths,
24
- markInactiveLinks,
25
- resolveRelativeUrls,
26
- } from "../helper/linkValidator.js";
27
- import { getAndIncrementBuildId, loadContentTimestamps, saveContentTimestamps, updateContentTimestamp } from "../helper/ursaConfig.js";
28
- import { extractSections } from "../helper/sectionExtractor.js";
29
- import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
30
- import { buildReactRuntime } from "../helper/mdxRenderer.js";
31
- import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
32
- import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
33
- import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMetaBundleCache, generateSeparateCssTags, generateSeparateJsTags } from "../helper/assetBundler.js";
34
- import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
35
- import { dependencyTracker, loadDependencyTracker, saveDependencyTracker } from "../helper/dependencyTracker.js";
36
- import { CacheBustHashMap } from "../helper/build/cacheBust.js";
37
- import { copy as copyDir, emptyDir, outputFile, remove } from "fs-extra";
38
- import { basename, dirname, extname, join, parse, resolve } from "path";
39
- import { URL } from "url";
40
- import o2x from "object-to-xml";
41
- import { existsSync } from "fs";
42
- import { fileExists } from "../helper/fileExists.js";
43
- import { createWhitelistFilter } from "../helper/whitelistFilter.js";
44
- import { processAllImages, transformImageTags, clearImageCache, copyAllImagesFast } from "../helper/imageProcessor.js";
45
- import { extractImageReferences } from "../helper/imageExtractor.js";
46
- import { checkFileSize, readFileStreaming, formatFileSize } from "../helper/streamingReader.js";
47
- import { generateBreadcrumbs } from "../helper/breadcrumbs.js";
48
- import {
49
- loadNavCache,
50
- saveNavCache,
51
- hashFileList,
52
- hashFileStats,
53
- isNavCacheValid,
54
- createNavCacheEntry,
55
- restoreMap,
56
- } from "../helper/build/navCache.js";
57
-
58
- // Import build helpers from organized modules
59
- import {
60
- generateCacheBustTimestamp,
61
- addTimestampToCssUrls,
62
- addTimestampToHtmlStaticRefs,
63
- processBatched,
64
- ProgressReporter,
65
- watchModeCache,
66
- clearWatchCache as clearWatchCacheBase,
67
- toTitleCase,
68
- parseExcludeOption,
69
- createExcludeFilter,
70
- addTrailingSlash,
71
- getTemplates,
72
- getMenu,
73
- findAllCustomMenus,
74
- getCustomMenuForFile,
75
- getTransformedMetadata,
76
- getFooter,
77
- getUrsaMetadata,
78
- generateAutoIndices,
79
- generateAutoIndexHtmlFromSource,
80
- copyMetaAssets,
81
- } from "../helper/build/index.js";
82
- import { getProfiler } from "../helper/build/profiler.js";
83
- import { reconcileAll, isInsideTemplatesFolder } from "../helper/documentTemplates.js";
84
-
85
- // Concurrency limiter for batch processing to avoid memory exhaustion
86
- const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
87
-
88
- // Cache for CSS path lookups to avoid repeated filesystem walks
89
- const cssPathCache = new Map();
90
-
91
- // Cache for script path lookups to avoid repeated filesystem walks
92
- const scriptPathCache = new Map();
93
-
94
- // Cache for document-level CSS/JS bundle paths to avoid re-bundling identical sets
95
- const docBundleCache = new Map();
96
-
97
- // Wrapper for clearWatchCache that passes cssPathCache and scriptPathCache
98
- export function clearWatchCache() {
99
- clearWatchCacheBase(cssPathCache);
100
- scriptPathCache.clear();
101
- docBundleCache.clear();
102
- clearMetaBundleCache();
103
- }
104
-
105
- // Clear just the script-related caches (for when script.js changes)
106
- export function clearScriptCache() {
107
- scriptPathCache.clear();
108
- // Clear all JS bundle cache entries
109
- for (const key of docBundleCache.keys()) {
110
- if (key.startsWith('js:')) {
111
- docBundleCache.delete(key);
112
- }
113
- }
114
- }
115
-
116
- // Clear just the CSS-related caches (for when style.css changes)
117
- export function clearStyleCache() {
118
- cssPathCache.clear();
119
- // Clear all CSS bundle cache entries
120
- for (const key of docBundleCache.keys()) {
121
- if (key.startsWith('css:')) {
122
- docBundleCache.delete(key);
123
- }
124
- }
125
- }
126
-
127
- const progress = new ProgressReporter();
128
-
129
- const DEFAULT_TEMPLATE_NAME =
130
- process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
131
-
132
- export async function generate({
133
- _source = join(process.cwd(), "."),
134
- _meta = join(process.cwd(), "meta"),
135
- _output = join(process.cwd(), "build"),
136
- _whitelist = null,
137
- _exclude = null,
138
- _incremental = false, // Legacy flag, now ignored (always incremental)
139
- _clean = false, // When true, ignore cache and regenerate all files
140
- _deferImages = false, // When true, copy images without processing, return promise for background processing
141
- _deferSearchIndex = false, // When true, return promise for search index building (for faster startup)
142
- } = {}) {
143
- // Initialize profiler for this build
144
- const profiler = getProfiler(true);
145
-
146
- console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex });
147
- const source = resolve(_source) + "/";
148
- const meta = resolve(_meta);
149
- const output = resolve(_output) + "/";
150
- console.log({ source, meta, output });
151
-
152
- // Generate cache-busting timestamp for this build
153
- const cacheBustTimestamp = generateCacheBustTimestamp();
154
- const cacheBustHashes = new CacheBustHashMap();
155
- progress.logTimed(`Cache-bust timestamp: ${cacheBustTimestamp}`);
156
-
157
- // Initialize dependency tracker for this build
158
- dependencyTracker.init(source);
159
-
160
- // Clear output directory and cache when --clean is specified
161
- if (_clean) {
162
- progress.startTimer('Clean');
163
- const ursaDir = getUrsaDir(source);
164
- progress.logTimed(`Clean build: deleting cache folder ${ursaDir}`);
165
- await remove(ursaDir);
166
- progress.logTimed(`Clean build: clearing output directory ${output}`);
167
- await emptyDir(output);
168
- progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
169
- } else {
170
- // Warm start: reload persisted dependency registrations so hash-skipped
171
- // documents keep their edges (current-run registrations take precedence)
172
- const loaded = await loadDependencyTracker(source);
173
- if (loaded) {
174
- const stats = dependencyTracker.getStats();
175
- progress.logTimed(`Dependency graph loaded: ${stats.totalDocuments} documents, ${stats.uniqueFiles} dependencies`);
176
- }
177
- }
178
-
179
- // Phase: Scan source files
180
- profiler.startPhase('Scan source files');
181
- progress.startTimer('Scan');
182
- const allSourceFilenamesUnfiltered = await recurse(source, [() => false]);
183
- progress.logTimed(`Scanned ${allSourceFilenamesUnfiltered.length} files [${progress.stopTimer('Scan')}]`);
184
- profiler.endPhase('Scan source files');
185
-
186
- // Phase: Filter and classify files
187
- profiler.startPhase('Filter & classify');
188
- progress.startTimer('Filter');
189
-
190
- // Apply include filter (existing functionality)
191
- const includeFilter = process.env.INCLUDE_FILTER
192
- ? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
193
- : Boolean;
194
- let allSourceFilenames = allSourceFilenamesUnfiltered.filter(includeFilter);
195
-
196
- // Apply exclude filter if specified
197
- if (_exclude) {
198
- const excludedPaths = await parseExcludeOption(_exclude, source);
199
- const excludeFilter = createExcludeFilter(excludedPaths, source);
200
- const beforeCount = allSourceFilenames.length;
201
- allSourceFilenames = allSourceFilenames.filter(excludeFilter);
202
- progress.logTimed(`Exclude filter applied: ${beforeCount - allSourceFilenames.length} files excluded`);
203
- }
204
-
205
- // Apply whitelist filter if specified
206
- if (_whitelist) {
207
- const whitelistFilter = await createWhitelistFilter(_whitelist, source);
208
- allSourceFilenames = allSourceFilenames.filter(whitelistFilter);
209
- progress.logTimed(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
210
- }
211
-
212
- // Clear config cache at start of generation to pick up any changes
213
- clearConfigCache();
214
-
215
- // Helper to check if a path is inside a config-hidden folder
216
- const isInHiddenFolder = (filePath) => {
217
- const dir = dirname(filePath);
218
- return isFolderHidden(dir, source);
219
- };
220
-
221
- // read all articles, process them, copy them to build
222
- const articleExtensions = /\.(md|mdx|txt|yml)$/;
223
- const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]|[\/\\]_templates[\/\\]|[\/\\]_templates$/; // Matches hidden folders (starting with .), node_modules, or _templates
224
- const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
225
- (filename) => filename.match(articleExtensions) && !filename.match(hiddenOrSystemDirs) && !isInHiddenFolder(filename)
226
- );
227
- const allSourceFilenamesThatAreDirectories = (await filterAsync(
228
- allSourceFilenames,
229
- (filename) => isDirectory(filename)
230
- )).filter((filename) => !filename.match(hiddenOrSystemDirs) && !isFolderHidden(filename, source));
231
-
232
- // Build set of existing HTML files in source directory (these should not be overwritten)
233
- const htmlExtensions = /\.html$/;
234
- const existingHtmlFiles = new Set(
235
- allSourceFilenames
236
- .filter(f => f.match(htmlExtensions) && !f.match(hiddenOrSystemDirs))
237
- .map(f => f.replace(source, '')) // Store relative paths for easy lookup
238
- );
239
-
240
- progress.logTimed(`Classified: ${allSourceFilenamesThatAreArticles.length} articles, ${allSourceFilenamesThatAreDirectories.length} dirs, ${existingHtmlFiles.size} HTML [${progress.stopTimer('Filter')}]`);
241
- profiler.endPhase('Filter & classify');
242
-
243
- // Drop persisted dependency registrations for documents that no longer
244
- // exist (or are excluded), so stale entries don't accumulate across runs
245
- dependencyTracker.prune(new Set(allSourceFilenamesThatAreArticles));
246
-
247
- // Phase: Document template reconciliation
248
- // Must run BEFORE article processing so that any template-driven changes
249
- // to source .md files are picked up during rendering.
250
- profiler.startPhase('Template reconciliation');
251
- progress.startTimer('Templates');
252
- const templateReconciliation = await reconcileAll(
253
- allSourceFilenamesThatAreArticles,
254
- allSourceFilenamesUnfiltered, // templates live in _templates which is filtered out of articles
255
- source
256
- );
257
- if (templateReconciliation.updated > 0 || templateReconciliation.conflicts > 0 || templateReconciliation.initialized > 0) {
258
- progress.logTimed(
259
- `📄 Document templates: ${templateReconciliation.initialized} initialized, ` +
260
- `${templateReconciliation.updated} auto-merged, ` +
261
- `${templateReconciliation.conflicts} conflicts, ` +
262
- `${templateReconciliation.unchanged} unchanged, ` +
263
- `${templateReconciliation.errors} errors`
264
- );
265
- if (templateReconciliation.conflicts > 0) {
266
- console.warn(`\n⚠️ Template conflicts require manual resolution:`);
267
- for (const msg of templateReconciliation.messages) {
268
- if (msg.includes('Conflict')) console.warn(` ${msg}`);
269
- }
270
- console.warn('');
271
- }
272
- if (templateReconciliation.errors > 0) {
273
- for (const msg of templateReconciliation.messages) {
274
- if (msg.includes('Error') || msg.includes('not found')) console.warn(` ⚠️ ${msg}`);
275
- }
276
- }
277
- }
278
- progress.logTimed(`Document templates processed [${progress.stopTimer('Templates')}]`);
279
- profiler.endPhase('Template reconciliation');
280
-
281
- // Phase: Build navigation and metadata
282
- profiler.startPhase('Build navigation');
283
- progress.startTimer('Navigation');
284
-
285
- // Check if we can use cached navigation
286
- let validPaths, templates, menu, menuData, customMenus, footer, buildId;
287
- let navCacheUsed = false;
288
-
289
- if (!_clean) {
290
- const fileListHash = hashFileList(allSourceFilenames);
291
- const fileStatsHash = await hashFileStats(allSourceFilenames);
292
- const navCache = await loadNavCache(source);
293
-
294
- if (isNavCacheValid(navCache, fileListHash, fileStatsHash)) {
295
- // Use cached navigation data
296
- navCacheUsed = true;
297
- validPaths = restoreMap(navCache.validPaths);
298
- menuData = navCache.menuData;
299
- menu = navCache.menuHtml;
300
- customMenus = restoreMap(navCache.customMenus);
301
-
302
- // Templates and footer still need to be loaded (they depend on meta directory)
303
- templates = await getTemplates(meta);
304
- buildId = getAndIncrementBuildId(resolve(_source));
305
- footer = await getFooter(source, _source, buildId);
306
-
307
- progress.logTimed(`Navigation loaded from cache: ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
308
- } else {
309
- // Cache miss - build navigation from scratch
310
- validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
311
- templates = await getTemplates(meta);
312
-
313
- const menuResult = await getMenu(allSourceFilenames, source, validPaths);
314
- menu = menuResult.html;
315
- menuData = menuResult.menuData;
316
-
317
- customMenus = findAllCustomMenus(allSourceFilenames, source);
318
- buildId = getAndIncrementBuildId(resolve(_source));
319
- footer = await getFooter(source, _source, buildId);
320
-
321
- // Save to cache for next run
322
- const cacheEntry = createNavCacheEntry(
323
- fileListHash,
324
- fileStatsHash,
325
- menuData,
326
- menu,
327
- Array.from(validPaths.entries()),
328
- Array.from(customMenus.entries())
329
- );
330
- await saveNavCache(source, cacheEntry);
331
-
332
- progress.logTimed(`Navigation built: ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
333
- }
334
- } else {
335
- // Clean build - ignore cache
336
- validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
337
- templates = await getTemplates(meta);
338
-
339
- const menuResult = await getMenu(allSourceFilenames, source, validPaths);
340
- menu = menuResult.html;
341
- menuData = menuResult.menuData;
342
-
343
- customMenus = findAllCustomMenus(allSourceFilenames, source);
344
- buildId = getAndIncrementBuildId(resolve(_source));
345
- footer = await getFooter(source, _source, buildId);
346
-
347
- progress.logTimed(`Navigation built (clean): ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
348
- }
349
-
350
- profiler.endPhase('Build navigation');
351
-
352
- // Build the _ursa_metadata embedded in every generated JSON file (ursa + doc repo versions)
353
- const ursaMetadata = await getUrsaMetadata(_source);
354
-
355
- // Phase: Load cache
356
- profiler.startPhase('Load cache');
357
- progress.startTimer('Cache');
358
-
359
- // Load content hash cache from .ursa folder in source directory
360
- let hashCache = new Map();
361
- if (!_clean) {
362
- hashCache = await loadHashCache(source);
363
- progress.logTimed(`Loaded ${hashCache.size} cached hashes [${progress.stopTimer('Cache')}]`);
364
- } else {
365
- progress.logTimed(`Clean build: ignoring cached hashes`);
366
- progress.stopTimer('Cache');
367
- }
368
-
369
- // Load content timestamps from .ursa.json (survives --clean)
370
- // These track when content actually changed, not filesystem mtime
371
- const contentTimestamps = loadContentTimestamps(source);
372
- const buildTimestamp = Date.now();
373
- progress.logTimed(`Loaded ${contentTimestamps.size} content timestamps`);
374
- profiler.endPhase('Load cache');
375
-
376
- // Phase: Copy meta/public files
377
- profiler.startPhase('Copy meta files');
378
- progress.startTimer('Meta');
379
-
380
- // create public folder
381
- const pub = join(output, "public");
382
- await mkdir(pub, { recursive: true });
383
-
384
- // Copy meta assets with new template folder structure
385
- const { copiedFiles, orphanedFiles } = await copyMetaAssets(meta, pub);
386
-
387
- // Warn about orphaned files in meta that aren't part of any template
388
- if (orphanedFiles.length > 0) {
389
- console.warn(`\n⚠️ Warning: Found ${orphanedFiles.length} orphaned file(s) in meta directory:`);
390
- console.warn(` These files are not in meta/templates/ or meta/shared/ and won't be included:`);
391
- for (const file of orphanedFiles.slice(0, 10)) {
392
- console.warn(` - ${file}`);
393
- }
394
- if (orphanedFiles.length > 10) {
395
- console.warn(` ... and ${orphanedFiles.length - 10} more`);
396
- }
397
- console.warn(` Move them to meta/templates/{templateName}/ or meta/shared/ to include them.\n`);
398
- }
399
-
400
- // Bundle meta template assets (CSS + JS) into single files per template
401
- // This must happen after copying meta to public but before cache-busting
402
- templates = await bundleMetaTemplateAssets(templates, meta, pub, { minify: true, sourcemap: false });
403
- progress.logTimed(`Meta template assets bundled`);
404
-
405
- // Build React runtime for MDX hydration (React 19 has no UMD, so we bundle locally)
406
- await buildReactRuntime(pub);
407
-
408
- // Process all CSS files in the entire output directory tree for cache-busting
409
- const allOutputFiles = await recurse(output, [() => false]);
410
- for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
411
- const cssContent = await readFile(cssFile, 'utf8');
412
- const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
413
- await outputFile(cssFile, processedCss);
414
- }
415
-
416
- // Process JS files in output for cache-busting fetch URLs
417
- for (const jsFile of allOutputFiles.filter(f => f.endsWith('.js'))) {
418
- let jsContent = await readFile(jsFile, 'utf8');
419
- jsContent = jsContent.replace(
420
- /fetch\(['"]([^'"\)]+\.(json))['"](?!\s*\+)/g,
421
- `fetch('$1?v=${cacheBustTimestamp}'`
422
- );
423
- await outputFile(jsFile, jsContent);
424
- }
425
-
426
- progress.logTimed(`Meta files copied and processed [${progress.stopTimer('Meta')}]`);
427
- profiler.endPhase('Copy meta files');
428
-
429
- // Track errors for error report
430
- const errors = [];
431
-
432
- // Search index: built incrementally during article processing (lighter memory footprint)
433
- const searchIndex = [];
434
- // Full-text index: collect documents for word-to-document mapping
435
- const fullTextDocs = [];
436
- // Recent activity: collect {title, url, mtime} for all articles, keep top 10 by mtime
437
- const recentActivity = [];
438
- // Track paths of documents that were regenerated (for incremental index updates)
439
- const changedPaths = new Set();
440
- // Directory index cache: only stores minimal data needed for directory indices
441
- // Uses WeakRef-style approach - store only what's needed, clear as we go
442
- const dirIndexCache = new Map();
443
-
444
- // Track CSS files that have been copied to avoid duplicates
445
- const copiedCssFiles = new Set();
446
-
447
- // Identify all image files from the filtered source list
448
- const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/;
449
- let allSourceFilenamesThatAreImages = allSourceFilenames.filter(
450
- (filename) => filename.match(imageExtensions) && !filename.match(hiddenOrSystemDirs)
451
- );
452
-
453
- // When using a whitelist, also include images referenced by whitelisted documents
454
- // This ensures that images used in whitelisted articles are processed even if not explicitly whitelisted
455
- if (_whitelist) {
456
- progress.logTimed('Scanning whitelisted articles for image references...');
457
- const referencedImages = new Set();
458
-
459
- for (const articlePath of allSourceFilenamesThatAreArticles) {
460
- try {
461
- const content = await readFile(articlePath, 'utf8');
462
- const imageRefs = extractImageReferences(content, articlePath, source);
463
- imageRefs.forEach(img => referencedImages.add(img));
464
- } catch (e) {
465
- // Ignore read errors - file might not exist or be unreadable
466
- }
467
- }
468
-
469
- // Get all images from the unfiltered source list that are referenced
470
- const allImagesUnfiltered = allSourceFilenamesUnfiltered.filter(
471
- (filename) => filename.match(imageExtensions) && !filename.match(hiddenOrSystemDirs)
472
- );
473
-
474
- // Add referenced images that aren't already in the list
475
- const additionalImages = allImagesUnfiltered.filter(
476
- img => referencedImages.has(img) && !allSourceFilenamesThatAreImages.includes(img)
477
- );
478
-
479
- if (additionalImages.length > 0) {
480
- progress.logTimed(`Found ${additionalImages.length} additional images referenced by whitelisted documents`);
481
- allSourceFilenamesThatAreImages = [...allSourceFilenamesThatAreImages, ...additionalImages];
482
- }
483
- }
484
-
485
- // Phase: Process images
486
- profiler.startPhase('Process images');
487
- progress.startTimer('Images');
488
-
489
- // Handle images based on deferred mode
490
- let imageMap = new Map();
491
- let deferredImageProcessingPromise = null;
492
-
493
- if (_deferImages) {
494
- // Fast mode: just copy images without processing, defer preview generation
495
- progress.logTimed(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
496
- await copyAllImagesFast(
497
- allSourceFilenamesThatAreImages,
498
- source,
499
- output,
500
- (current, total, path) => {
501
- progress.status('Images (copy)', `${current}/${total} ${path}`);
502
- }
503
- );
504
- progress.done('Images (copy)', `${allSourceFilenamesThatAreImages.length} copied (previews deferred)`);
505
- profiler.endPhase('Process images');
506
-
507
- // Create promise for background image processing (will be returned to caller)
508
- deferredImageProcessingPromise = (async () => {
509
- progress.logTimed(`\n🖼️ Starting deferred image preview generation...`);
510
- const startTime = Date.now();
511
- const processedImageMap = await processAllImages(
512
- allSourceFilenamesThatAreImages,
513
- source,
514
- output,
515
- (current, total, path) => {
516
- progress.status('Images (previews)', `${current}/${total} ${path}`);
517
- }
518
- );
519
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
520
- progress.done('Images (previews)', `${allSourceFilenamesThatAreImages.length} done (${processedImageMap.size} with previews) in ${elapsed}s`);
521
-
522
- // Update the watch cache with the processed image map
523
- watchModeCache.imageMap = processedImageMap;
524
-
525
- return processedImageMap;
526
- })();
527
- } else {
528
- // Normal mode: process all images FIRST to build the preview image map
529
- // This is done before articles so we can transform img tags in the HTML
530
- progress.logTimed(`Processing ${allSourceFilenamesThatAreImages.length} images for preview generation...`);
531
- imageMap = await processAllImages(
532
- allSourceFilenamesThatAreImages,
533
- source,
534
- output,
535
- (current, total, path) => {
536
- progress.status('Images', `${current}/${total} ${path}`);
537
- }
538
- );
539
- progress.done('Images', `${allSourceFilenamesThatAreImages.length} done (${imageMap.size} with previews) [${progress.stopTimer('Images')}]`);
540
- profiler.endPhase('Process images');
541
- }
542
-
543
- // Phase: Process articles
544
- profiler.startPhase('Process articles');
545
- progress.startTimer('Articles');
546
-
547
- // Track files that were regenerated (for incremental mode stats)
548
- let regeneratedCount = 0;
549
- let skippedCount = 0;
550
- let processedCount = 0;
551
- const totalArticles = allSourceFilenamesThatAreArticles.length;
552
-
553
- progress.logTimed(`Processing ${totalArticles} articles in batches of ${BATCH_SIZE}...`);
554
-
555
- // Single pass: process all articles with batched concurrency to limit memory usage
556
- await processBatched(allSourceFilenamesThatAreArticles, async (file) => {
557
- try {
558
- processedCount++;
559
- const shortFile = file.replace(source, '');
560
- progress.status('Articles', `${processedCount}/${totalArticles} ${shortFile}`);
561
-
562
- // Use streaming for large files to reduce memory pressure
563
- const { size: fileSize, useStreaming } = await checkFileSize(file);
564
- let rawBody;
565
- if (useStreaming) {
566
- rawBody = await readFileStreaming(file, 'utf8');
567
- progress.log(`📄 Streaming large file ${shortFile} (${formatFileSize(fileSize)})`);
568
- } else {
569
- rawBody = await readFile(file, "utf8");
570
- }
571
-
572
- const type = parse(file).ext;
573
- const ext = extname(file);
574
- const base = basename(file, ext);
575
- const dir = addTrailingSlash(dirname(file)).replace(source, "");
576
-
577
- // Calculate output paths for this file
578
- const outputFilename = file
579
- .replace(source, output)
580
- .replace(parse(file).ext, ".html");
581
- const url = '/' + outputFilename.replace(output, '');
582
-
583
- // Generate URL path relative to output (for search index)
584
- const relativePath = file.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
585
- const searchUrl = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
586
-
587
- // Generate title from filename (in title case)
588
- // For index/home files, use parent folder name instead
589
- const titleBase = (base === 'index' || base === 'home') ? basename(dirname(file)) : base;
590
- const title = toTitleCase(titleBase || base);
591
-
592
- // Always add to search index (lightweight: title + path only, content added lazily)
593
- searchIndex.push({
594
- title: title,
595
- path: relativePath,
596
- url: searchUrl,
597
- content: '' // Content excerpts built lazily to save memory
598
- });
599
-
600
- // Add document to full-text index (uses raw markdown content)
601
- fullTextDocs.push({
602
- path: relativePath,
603
- title: title,
604
- content: rawBody
605
- });
606
-
607
- // Collect timestamp for recent activity tracking
608
- // Use stored content timestamp if available, otherwise fall back to file mtime
609
- // Content timestamps track when content actually changed, not filesystem mtime
610
- const storedTimestamp = contentTimestamps.get(relativePath);
611
- let activityTimestamp = storedTimestamp;
612
- if (!activityTimestamp) {
613
- // No stored timestamp - use file mtime as initial value
614
- try {
615
- const fileStat = await stat(file);
616
- activityTimestamp = fileStat.mtimeMs;
617
- } catch (e) {
618
- activityTimestamp = 0;
619
- }
620
- }
621
- recentActivity.push({
622
- title: title,
623
- url: searchUrl,
624
- mtime: activityTimestamp
625
- });
626
-
627
- // Check if a corresponding .html file already exists in source directory
628
- const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
629
- if (existingHtmlFiles.has(outputHtmlRelative)) {
630
- progress.log(`⚠️ Warning: Skipping ${shortFile} - would overwrite existing ${outputHtmlRelative} in source`);
631
- skippedCount++;
632
- return;
633
- }
634
-
635
- // Skip metadata-only index files - they exist only to provide folder metadata
636
- // The auto-index system will generate the actual index.html for these folders
637
- if (base === 'index' && (type === '.md' || type === '.mdx') && isMetadataOnly(rawBody)) {
638
- progress.log(`ℹ️ Skipping metadata-only ${shortFile} - auto-index will generate listing`);
639
- skippedCount++;
640
- return;
641
- }
642
-
643
- // Check if file needs regeneration
644
- const needsRegen = _clean || needsRegeneration(file, rawBody, hashCache);
645
-
646
- if (!needsRegen) {
647
- skippedCount++;
648
- // For directory indices, store minimal data (not full bodyHtml)
649
- // But include metadata for directory JSON files
650
- const skippedMeta = extractMetadata(rawBody);
651
- dirIndexCache.set(file, {
652
- name: base,
653
- url,
654
- metadata: skippedMeta,
655
- });
656
- return; // Skip regenerating this file
657
- }
658
-
659
- regeneratedCount++;
660
- // Track this path for incremental search index updates
661
- changedPaths.add(relativePath);
662
-
663
- const fileMeta = extractMetadata(rawBody);
664
- const rawMeta = extractRawMetadata(rawBody);
665
-
666
- // Lazy metadata transform - only compute if template actually uses it
667
- // This defers the potentially expensive custom transform function load
668
- let transformedMetadata = null;
669
- const getTransformedMeta = async () => {
670
- if (transformedMetadata === null) {
671
- transformedMetadata = await getTransformedMetadata(dirname(file), fileMeta);
672
- }
673
- return transformedMetadata;
674
- };
675
-
676
- // Calculate the document's URL path (e.g., "/character/index.html")
677
- const docUrlPath = '/' + dir + base + '.html';
678
-
679
- // Use async rendering with worker threads for parallel markdown parsing
680
- // Wikitext (.txt) files will fall back to main thread
681
- // For MDX files, enable hydration if frontmatter has `hydrate: true`
682
- const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
683
-
684
- let renderResult = await renderFileAsync({
685
- fileContents: rawBody,
686
- type,
687
- dirname: dir,
688
- basename: base,
689
- filePath: file,
690
- sourceRoot: source,
691
- useWorker: true,
692
- hydrate: shouldHydrate,
693
- });
694
-
695
- // Handle the result - can be string or { html, hydrationScript }
696
- let body;
697
- let hydrationScript = '';
698
- if (typeof renderResult === 'object' && renderResult.html) {
699
- body = renderResult.html;
700
- hydrationScript = renderResult.hydrationScript || '';
701
- } else {
702
- body = renderResult;
703
- }
704
-
705
- // Inject default H1 if body doesn't start with one
706
- if (!body || !body.trimStart().startsWith('<h1')) {
707
- const h1Title = fileMeta?.title || title;
708
- body = `<h1>${h1Title}</h1>\n` + (body || '');
709
- }
710
-
711
- // Inject breadcrumbs before the H1
712
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
713
- if (breadcrumbs) {
714
- body = breadcrumbs + body;
715
- }
716
-
717
- // Inject frontmatter table after first H1 (for markdown files with metadata)
718
- if ((type === '.md' || type === '.mdx') && fileMeta) {
719
- body = injectFrontmatterTable(body, fileMeta);
720
- }
721
-
722
- // Handle auto-index generation for index files with generate-auto-index: true
723
- if (base === 'index' && fileMeta) {
724
- const autoIndexConfig = getAutoIndexConfig(fileMeta);
725
- if (autoIndexConfig.enabled) {
726
- // Generate auto-index HTML for this directory from source
727
- // Using source avoids race conditions with concurrent file generation
728
- const sourceDir = dirname(file);
729
- const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
730
-
731
- if (autoIndexHtml) {
732
- if (autoIndexConfig.position === 'bottom') {
733
- body = body + '\n' + autoIndexHtml;
734
- } else {
735
- body = autoIndexHtml + '\n' + body;
736
- }
737
- }
738
- }
739
- }
740
-
741
- // Find all style.css files up the tree and bundle them into a single CSS file per folder path
742
- // (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
743
- let styleLink = "";
744
- try {
745
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
746
- const folderRelative = (dir === "/" || dir === "") ? "" : dir;
747
-
748
- // Check bundle cache first (dirs with same CSS ancestry share the same bundle)
749
- let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
750
- if (cachedBundleUrl !== undefined) {
751
- if (cachedBundleUrl) {
752
- styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
753
- }
754
- } else {
755
- let cssPaths = cssPathCache.get(dirKey);
756
- if (cssPaths === undefined) {
757
- cssPaths = await findAllStyleCss(dirKey, _source);
758
- cssPathCache.set(dirKey, cssPaths);
759
- }
760
- if (cssPaths.length > 0) {
761
- // Copy all source CSS files to output (still needed for serve mode fallback)
762
- for (const cssPath of cssPaths) {
763
- if (!copiedCssFiles.has(cssPath)) {
764
- const cssOutputPath = cssPath.replace(source, output);
765
- const cssContent = await readFile(cssPath, 'utf8');
766
- await outputFile(cssOutputPath, cssContent);
767
- copiedCssFiles.add(cssPath);
768
- }
769
- }
770
- // Bundle into a single file
771
- const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
772
- docBundleCache.set(`css:${dirKey}`, bundleUrl);
773
- styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
774
- } else {
775
- docBundleCache.set(`css:${dirKey}`, null);
776
- }
777
- }
778
- } catch (e) {
779
- // ignore
780
- console.error(e);
781
- }
782
-
783
- // Find all script.js files from docroot to current dir and bundle them
784
- // (Generate mode: one JS bundle per unique folder, external not inlined)
785
- let customScript = "";
786
- try {
787
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
788
- const folderRelative = (dir === "/" || dir === "") ? "" : dir;
789
-
790
- let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
791
- if (cachedBundleUrl !== undefined) {
792
- if (cachedBundleUrl) {
793
- customScript = `<script src="${cachedBundleUrl}"></script>`;
794
- }
795
- } else {
796
- let scriptPaths = scriptPathCache.get(dirKey);
797
- if (scriptPaths === undefined) {
798
- scriptPaths = await findAllScriptJs(dirKey, _source);
799
- scriptPathCache.set(dirKey, scriptPaths);
800
- }
801
- if (scriptPaths.length > 0) {
802
- const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
803
- docBundleCache.set(`js:${dirKey}`, bundleUrl);
804
- customScript = `<script src="${bundleUrl}"></script>`;
805
- } else {
806
- docBundleCache.set(`js:${dirKey}`, null);
807
- }
808
- }
809
- } catch (e) {
810
- // ignore
811
- console.error(e);
812
- }
813
-
814
- const requestedTemplateName = fileMeta && fileMeta.template;
815
- const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
816
- const template = templates[templateName];
817
-
818
- if (!template) {
819
- throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
820
- }
821
-
822
- // Register this document's dependencies for invalidation tracking
823
- {
824
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
825
- const cssDeps = cssPathCache.get(dirKey) || [];
826
- const jsDeps = scriptPathCache.get(dirKey) || [];
827
- dependencyTracker.registerDocument(file, {
828
- templateName,
829
- cssPaths: cssDeps,
830
- scriptPaths: jsDeps,
831
- });
832
- }
833
-
834
- // Check if this file has a custom menu
835
- const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
836
-
837
- // Lazy evaluation of transformed metadata - only compute if template uses it
838
- // This defers expensive custom transform function loading until actually needed
839
- const templateUsesTransformedMeta = template.includes('${transformedMetadata}');
840
- const lazyTransformedMeta = templateUsesTransformedMeta
841
- ? await getTransformedMeta()
842
- : '';
843
-
844
- // Build final HTML with all replacements in a single regex pass
845
- // This avoids creating 8 intermediate strings
846
- // Append hydration script to customScript if present (for MDX with hydrate: true)
847
- const finalCustomScript = hydrationScript
848
- ? customScript + '\n' + hydrationScript
849
- : customScript;
850
-
851
- const replacements = {
852
- "${title}": fileMeta?.title || title,
853
- "${menu}": menu,
854
- "${meta}": JSON.stringify(fileMeta),
855
- "${transformedMetadata}": lazyTransformedMeta,
856
- "${body}": body,
857
- "${styleLink}": styleLink,
858
- "${customScript}": finalCustomScript,
859
- "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
860
- "${footer}": footer
861
- };
862
- // Single-pass replacement using regex alternation
863
- const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
864
- let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
865
-
866
- // Add menu data attributes to body
867
- if (customMenuInfo) {
868
- const menuPosition = customMenuInfo.menuPosition || 'top';
869
- finalHtml = finalHtml.replace(
870
- /<body([^>]*)>/,
871
- `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
872
- );
873
- } else {
874
- // No custom menu — default to top menu
875
- finalHtml = finalHtml.replace(
876
- /<body([^>]*)>/,
877
- `<body$1 data-menu-position="top">`
878
- );
879
- }
880
-
881
- // Resolve relative URLs in raw HTML elements (img src, etc.)
882
- finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
883
-
884
- // Resolve links and mark broken internal links as inactive
885
- finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
886
-
887
- // Transform image tags to use preview images with data-fullsrc for originals
888
- // Skip in deferred mode - images will use original paths until preview generation completes
889
- if (!_deferImages) {
890
- finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
891
- }
892
-
893
- // Add cache-busting timestamps to static file references
894
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
895
-
896
- await outputFile(outputFilename, finalHtml);
897
-
898
- // Clear finalHtml reference to allow GC
899
- finalHtml = null;
900
-
901
- // JSON output
902
- const jsonOutputFilename = outputFilename.replace(".html", ".json");
903
-
904
- // Extract sections for markdown files
905
- const sections = (type === '.md' || type === '.mdx') ? extractSections(rawBody) : [];
906
-
907
- // Use lazy metadata for JSON output - may have been computed above for HTML
908
- const jsonTransformedMeta = await getTransformedMeta();
909
-
910
- const jsonObject = {
911
- name: base,
912
- url,
913
- contents: rawBody,
914
- bodyHtml: body,
915
- metadata: fileMeta,
916
- sections,
917
- transformedMetadata: jsonTransformedMeta,
918
- _ursa_metadata: ursaMetadata,
919
- };
920
-
921
- // Store minimal data for directory indices, including metadata
922
- dirIndexCache.set(file, {
923
- name: base,
924
- url,
925
- metadata: fileMeta,
926
- });
927
-
928
- const json = JSON.stringify(jsonObject);
929
- await outputFile(jsonOutputFilename, json);
930
-
931
- // XML output
932
- const xmlOutputFilename = outputFilename.replace(".html", ".xml");
933
- const xml = `<article>${o2x(jsonObject)}</article>`;
934
- await outputFile(xmlOutputFilename, xml);
935
-
936
- // Update the content hash for this file
937
- updateHash(file, rawBody, hashCache);
938
-
939
- // Update content timestamp since this file was regenerated (content changed)
940
- contentTimestamps.set(relativePath, buildTimestamp);
941
- // Also update the recentActivity entry we pushed earlier with the new timestamp
942
- const activityEntry = recentActivity.find(e => e.url === searchUrl);
943
- if (activityEntry) {
944
- activityEntry.mtime = buildTimestamp;
945
- }
946
- } catch (e) {
947
- progress.log(`Error processing ${file}: ${e.message}`);
948
- errors.push({ file, phase: 'article-generation', error: e });
949
- }
950
- });
951
-
952
- // Complete the articles status line
953
- progress.done('Articles', `${totalArticles} done (${regeneratedCount} regenerated, ${skippedCount} unchanged) [${progress.stopTimer('Articles')}]`);
954
- profiler.endPhase('Process articles');
955
-
956
- // Phase: Write search index
957
- // Can be deferred in serve mode for faster startup
958
- let deferredSearchIndexPromise = null;
959
-
960
- const buildSearchIndex = async () => {
961
- const indexStartTime = Date.now();
962
- progress.logTimed('Building search index...');
963
-
964
- // Write search index as a separate JSON file (not embedded in each page)
965
- const searchIndexPath = join(output, 'public', 'search-index.json');
966
- progress.log(`Writing search index with ${searchIndex.length} entries`);
967
- await outputFile(searchIndexPath, JSON.stringify(searchIndex));
968
-
969
- // Build full-text index - use incremental mode when possible
970
- let fullTextIndex;
971
- if (!_clean && changedPaths.size > 0 && changedPaths.size < fullTextDocs.length) {
972
- // Incremental update: only re-index changed documents
973
- progress.log(`Incremental full-text index: ${changedPaths.size} changed, ${fullTextDocs.length - changedPaths.size} cached`);
974
- fullTextIndex = buildIncrementalIndex(fullTextDocs, changedPaths, source);
975
- } else {
976
- // Full rebuild: clean build or all documents changed
977
- progress.log(`Building full-text index from ${fullTextDocs.length} documents...`);
978
- fullTextIndex = buildFullTextIndex(fullTextDocs);
979
- // Save to cache for future incremental updates
980
- saveIndexCache(source, fullTextIndex);
981
- }
982
-
983
- const fullTextIndexPath = join(output, 'public', 'fulltext-index.json');
984
- const fullTextIndexJson = JSON.stringify(fullTextIndex);
985
- const wordCount = Object.keys(fullTextIndex).length;
986
- progress.log(`Writing full-text index (${wordCount} unique words, ${(fullTextIndexJson.length / 1024).toFixed(1)} KB)`);
987
- await outputFile(fullTextIndexPath, fullTextIndexJson);
988
-
989
- const elapsed = ((Date.now() - indexStartTime) / 1000).toFixed(1);
990
- return { entries: searchIndex.length, words: wordCount, elapsed };
991
- };
992
-
993
- if (_deferSearchIndex) {
994
- // Deferred mode: start building in background, return promise
995
- profiler.startPhase('Write search index (deferred)');
996
- progress.startTimer('Search index');
997
- progress.log('Search index building deferred for faster startup...');
998
- deferredSearchIndexPromise = buildSearchIndex().then(result => {
999
- progress.done('Search index (background)', `${result.entries} entries, ${result.words} words in ${result.elapsed}s`);
1000
- return result;
1001
- });
1002
- progress.done('Search index', 'deferred [0ms]');
1003
- profiler.endPhase('Write search index (deferred)');
1004
- } else {
1005
- // Normal mode: build search index now
1006
- profiler.startPhase('Write search index');
1007
- progress.startTimer('Search index');
1008
- const result = await buildSearchIndex();
1009
- progress.done('Search index', `${result.entries} entries, ${result.words} words [${progress.stopTimer('Search index')}]`);
1010
- profiler.endPhase('Write search index');
1011
- }
1012
-
1013
- // Phase: Write recent activity data
1014
- profiler.startPhase('Write recent activity');
1015
- progress.startTimer('Recent activity');
1016
- // Sort by mtime descending, keep top 10
1017
- recentActivity.sort((a, b) => b.mtime - a.mtime);
1018
- const top10 = recentActivity.slice(0, 10);
1019
- const recentActivityPath = join(output, 'public', 'recent-activity.json');
1020
- await outputFile(recentActivityPath, JSON.stringify(top10));
1021
- progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
1022
- profiler.endPhase('Write recent activity');
1023
-
1024
- // Phase: Write menu data
1025
- profiler.startPhase('Write menu data');
1026
- progress.startTimer('Menu data');
1027
- // Write menu data as a separate JSON file (not embedded in each page)
1028
- // This dramatically reduces HTML file sizes for large sites
1029
- const menuDataPath = join(output, 'public', 'menu-data.json');
1030
- const menuDataJson = JSON.stringify(menuData);
1031
- progress.log(`Writing menu data (${(menuDataJson.length / 1024).toFixed(1)} KB)`);
1032
- await outputFile(menuDataPath, menuDataJson);
1033
-
1034
- // Write custom menu JSON files
1035
- for (const [menuDir, menuInfo] of customMenus) {
1036
- const customMenuPath = join(output, menuInfo.menuJsonPath);
1037
- // Include menuPosition in the JSON so client knows how to render
1038
- const customMenuJson = JSON.stringify({
1039
- menuData: menuInfo.menuData,
1040
- menuPosition: menuInfo.menuPosition || 'top',
1041
- });
1042
- progress.log(`Writing custom menu: ${menuInfo.menuJsonPath}`);
1043
- await outputFile(customMenuPath, customMenuJson);
1044
- }
1045
- progress.done('Menu data', `${customMenus.size + 1} files [${progress.stopTimer('Menu data')}]`);
1046
- profiler.endPhase('Write menu data');
1047
-
1048
- // Phase: Process directory indices
1049
- profiler.startPhase('Process directories');
1050
- progress.startTimer('Directories');
1051
- // Process directory indices with batched concurrency
1052
- const totalDirs = allSourceFilenamesThatAreDirectories.length;
1053
- let processedDirs = 0;
1054
- progress.log(`Processing ${totalDirs} directories...`);
1055
- await processBatched(allSourceFilenamesThatAreDirectories, async (dirPath) => {
1056
- try {
1057
- processedDirs++;
1058
- const shortDir = dirPath.replace(source, '');
1059
- progress.status('Directories', `${processedDirs}/${totalDirs} ${shortDir}`);
1060
-
1061
- const pathsInThisDirectory = allSourceFilenames.filter((filename) =>
1062
- filename.match(new RegExp(`${dirPath}.+`))
1063
- );
1064
-
1065
- // Use minimal directory index cache instead of full jsonCache
1066
- const jsonObjects = pathsInThisDirectory
1067
- .map((path) => {
1068
- const object = dirIndexCache.get(path);
1069
- return typeof object === "object" ? object : null;
1070
- })
1071
- .filter((a) => a);
1072
-
1073
- const json = JSON.stringify(jsonObjects);
1074
-
1075
- const outputFilename = dirPath.replace(source, output) + ".json";
1076
- await outputFile(outputFilename, json);
1077
-
1078
- // html
1079
- const htmlOutputFilename = dirPath.replace(source, output) + ".html";
1080
- const indexAlreadyExists = await fileExists(htmlOutputFilename);
1081
- if (!indexAlreadyExists) {
1082
- const template = templates["default-template"];
1083
- const indexHtml = `<ul>${pathsInThisDirectory
1084
- .map((path) => {
1085
- const partialPath = path
1086
- .replace(source, "")
1087
- .replace(parse(path).ext, ".html");
1088
- const name = basename(path, parse(path).ext);
1089
- return `<li><a href="${partialPath}">${name}</a></li>`;
1090
- })
1091
- .join("")}</ul>`;
1092
- let finalHtml = template;
1093
- const replacements = {
1094
- "${menu}": menu,
1095
- "${body}": indexHtml,
1096
- "${searchIndex}": "[]", // Search index now in separate file
1097
- "${title}": "Index",
1098
- "${meta}": "{}",
1099
- "${transformedMetadata}": "",
1100
- "${styleLink}": "",
1101
- "${footer}": footer
1102
- };
1103
- for (const [key, value] of Object.entries(replacements)) {
1104
- finalHtml = finalHtml.replace(key, value);
1105
- }
1106
- // Add cache-busting timestamps to static file references
1107
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
1108
- await outputFile(htmlOutputFilename, finalHtml);
1109
- }
1110
- } catch (e) {
1111
- progress.log(`Error processing directory ${dirPath}: ${e.message}`);
1112
- errors.push({ file: dirPath, phase: 'directory-index', error: e });
1113
- }
1114
- });
1115
-
1116
- progress.done('Directories', `${totalDirs} done [${progress.stopTimer('Directories')}]`);
1117
- profiler.endPhase('Process directories');
1118
-
1119
- // Clear directory index cache to free memory before processing static files
1120
- dirIndexCache.clear();
1121
-
1122
- // Phase: Process static files
1123
- profiler.startPhase('Process static files');
1124
- progress.startTimer('Static files');
1125
- // Copy static HTML files (images were already processed above with preview generation)
1126
- // Note: Images are processed before articles to enable preview transformation in HTML
1127
-
1128
- // Also copy existing HTML files from source to output (they're treated as static)
1129
- const allSourceFilenamesThatAreHtml = allSourceFilenames.filter(
1130
- (filename) => filename.match(/\.html$/) && !filename.match(hiddenOrSystemDirs)
1131
- );
1132
-
1133
- const allStaticFiles = allSourceFilenamesThatAreHtml;
1134
- const totalStatic = allStaticFiles.length;
1135
- let processedStatic = 0;
1136
- let copiedStatic = 0;
1137
- progress.log(`Processing ${totalStatic} static HTML files...`);
1138
- await processBatched(allStaticFiles, async (file) => {
1139
- try {
1140
- processedStatic++;
1141
- const shortFile = file.replace(source, '');
1142
- progress.status('Static files', `${processedStatic}/${totalStatic} ${shortFile}`);
1143
-
1144
- // Check if file has changed using file stat as a quick check
1145
- const fileStat = await stat(file);
1146
- const statKey = `${file}:stat`;
1147
- const newStatHash = `${fileStat.size}:${fileStat.mtimeMs}`;
1148
- if (hashCache.get(statKey) === newStatHash) {
1149
- return; // Skip unchanged static file
1150
- }
1151
- hashCache.set(statKey, newStatHash);
1152
- copiedStatic++;
1153
-
1154
- const outputFilename = file.replace(source, output);
1155
- await mkdir(dirname(outputFilename), { recursive: true });
1156
-
1157
- if (file.endsWith('.css')) {
1158
- // Process CSS for cache busting
1159
- const cssContent = await readFile(file, 'utf8');
1160
- const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
1161
- await outputFile(outputFilename, processedCss);
1162
- } else if (file.endsWith('.html')) {
1163
- // Process HTML files for link resolution
1164
- let htmlContent = await readFile(file, 'utf8');
1165
- // Calculate the document's URL path for relative link resolution
1166
- const docUrlPath = '/' + file.replace(source, '').replace(/^\//, '');
1167
- // Resolve relative URLs in raw HTML elements (img src, etc.)
1168
- htmlContent = resolveRelativeUrls(htmlContent, docUrlPath);
1169
- // Resolve internal links to have proper .html extensions
1170
- htmlContent = markInactiveLinks(htmlContent, validPaths, docUrlPath, false);
1171
- // Transform image tags to use preview images with data-fullsrc for originals
1172
- // Skip in deferred mode - images will use original paths until preview generation completes
1173
- if (!_deferImages) {
1174
- htmlContent = transformImageTags(htmlContent, imageMap, docUrlPath);
1175
- }
1176
- // Add cache-busting timestamps
1177
- htmlContent = addTimestampToHtmlStaticRefs(htmlContent, cacheBustTimestamp);
1178
- await outputFile(outputFilename, htmlContent);
1179
- } else {
1180
- await copyFile(file, outputFilename);
1181
- }
1182
- } catch (e) {
1183
- progress.log(`Error processing static file ${file}: ${e.message}`);
1184
- errors.push({ file, phase: 'static-file', error: e });
1185
- }
1186
- });
1187
-
1188
- progress.done('Static files', `${totalStatic} done (${copiedStatic} copied) [${progress.stopTimer('Static files')}]`);
1189
- profiler.endPhase('Process static files');
1190
-
1191
- // Phase: Auto-index generation
1192
- profiler.startPhase('Auto-index generation');
1193
- progress.startTimer('Auto-index');
1194
- // Automatic index generation for folders without index.html
1195
- progress.log(`Checking for missing index files...`);
1196
- await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus);
1197
- progress.done('Auto-index', `checked ${allSourceFilenamesThatAreDirectories.length} directories [${progress.stopTimer('Auto-index')}]`);
1198
- profiler.endPhase('Auto-index generation');
1199
-
1200
- // Phase: Finalization
1201
- profiler.startPhase('Finalization');
1202
- progress.startTimer('Finalization');
1203
- // Save the hash cache to .ursa folder in source directory
1204
- if (hashCache.size > 0) {
1205
- await saveHashCache(source, hashCache);
1206
- }
1207
-
1208
- // Save content timestamps to .ursa.json (tracks when content actually changed)
1209
- if (contentTimestamps.size > 0) {
1210
- saveContentTimestamps(source, contentTimestamps);
1211
- progress.log(`Saved ${contentTimestamps.size} content timestamps`);
1212
- }
1213
-
1214
- // Persist the dependency tracker so hash-skipped documents keep their
1215
- // edges on the next warm start (invalidation plans stay accurate)
1216
- await saveDependencyTracker(source);
1217
-
1218
- // Populate watch mode cache for fast single-file regeneration
1219
- watchModeCache.templates = templates;
1220
- watchModeCache.menu = menu;
1221
- watchModeCache.footer = footer;
1222
- watchModeCache.validPaths = validPaths;
1223
- watchModeCache.source = source;
1224
- watchModeCache.meta = meta;
1225
- watchModeCache.output = output;
1226
- watchModeCache.hashCache = hashCache;
1227
- watchModeCache.cacheBustTimestamp = cacheBustTimestamp;
1228
- watchModeCache.cacheBustHashes = cacheBustHashes;
1229
- watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
1230
- watchModeCache.imageMap = imageMap;
1231
- watchModeCache.customMenus = customMenus;
1232
- watchModeCache.ursaMetadata = ursaMetadata;
1233
- watchModeCache.lastFullBuild = Date.now();
1234
- watchModeCache.isInitialized = true;
1235
- const depStats = dependencyTracker.getStats();
1236
- progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
1237
-
1238
- // Write error report if there were any errors
1239
- if (errors.length > 0) {
1240
- const errorReportPath = join(output, '_errors.log');
1241
- const failedFiles = errors.map(e => e.file);
1242
-
1243
- let report = `URSA GENERATION ERROR REPORT\n`;
1244
- report += `Generated: ${new Date().toISOString()}\n`;
1245
- report += `Total errors: ${errors.length}\n\n`;
1246
- report += `${'='.repeat(60)}\n`;
1247
- report += `FAILED FILES:\n`;
1248
- report += `${'='.repeat(60)}\n\n`;
1249
- failedFiles.forEach(f => {
1250
- report += ` - ${f}\n`;
1251
- });
1252
- report += `\n${'='.repeat(60)}\n`;
1253
- report += `ERROR DETAILS:\n`;
1254
- report += `${'='.repeat(60)}\n\n`;
1255
-
1256
- errors.forEach(({ file, phase, error }) => {
1257
- report += `${'─'.repeat(60)}\n`;
1258
- report += `File: ${file}\n`;
1259
- report += `Phase: ${phase}\n`;
1260
- report += `Error: ${error.message}\n`;
1261
- if (error.stack) {
1262
- report += `Stack:\n${error.stack}\n`;
1263
- }
1264
- report += `\n`;
1265
- });
1266
-
1267
- await outputFile(errorReportPath, report);
1268
- progress.log(`\n⚠️ ${errors.length} error(s) occurred during generation.`);
1269
- progress.log(` Error report written to: ${errorReportPath}\n`);
1270
- } else {
1271
- progress.log(`\n✅ Generation complete with no errors.\n`);
1272
- }
1273
-
1274
- progress.done('Finalization', `complete [${progress.stopTimer('Finalization')}]`);
1275
- profiler.endPhase('Finalization');
1276
-
1277
- // Print profiler report
1278
- progress.log(profiler.report());
1279
-
1280
- // Terminate worker pool so threads don't keep the process alive
1281
- await terminateParserPool();
1282
-
1283
- // Return deferred processing promises if in deferred mode
1284
- // Caller can await these to know when background processing is complete
1285
- return {
1286
- deferredImageProcessing: deferredImageProcessingPromise,
1287
- deferredSearchIndex: deferredSearchIndexPromise
1288
- };
1289
- }
1290
-
1291
- /**
1292
- * Regenerate multiple documents affected by a dependency change (e.g., style.css, script.js, template).
1293
- * Uses the watchModeCache and dependency tracker to efficiently re-render affected documents
1294
- * with updated cache-bust timestamps.
1295
- *
1296
- * @param {string[]} documentPaths - Absolute paths to documents to regenerate
1297
- * @param {Object} options
1298
- * @param {string} options._source - Source directory
1299
- * @param {string} options._meta - Meta directory
1300
- * @param {string} options._output - Output directory
1301
- * @param {string} [options.reason] - Reason for regeneration (for logging)
1302
- * @param {string[]} [options.priorityPaths] - Document paths to regenerate first (e.g. client-viewed docs)
1303
- * @param {function} [options.onPriorityComplete] - Callback after priority paths are done (receives { regenerated, failed })
1304
- * @returns {Promise<{success: boolean, message: string, regenerated: number, failed: number}>}
1305
- */
1306
- export async function regenerateAffectedDocuments(documentPaths, {
1307
- _source,
1308
- _meta,
1309
- _output,
1310
- reason = "dependency change",
1311
- priorityPaths = [],
1312
- onPriorityComplete = null,
1313
- } = {}) {
1314
- const startTime = Date.now();
1315
-
1316
- if (!watchModeCache.isInitialized) {
1317
- return { success: false, message: "Cache not initialized - need full build first", regenerated: 0, failed: 0 };
1318
- }
1319
-
1320
- if (documentPaths.length === 0) {
1321
- return { success: true, message: "No documents to regenerate", regenerated: 0, failed: 0 };
1322
- }
1323
-
1324
- // Generate a fresh cache-bust timestamp for this invalidation pass
1325
- const newTimestamp = generateCacheBustTimestamp();
1326
- watchModeCache.cacheBustTimestamp = newTimestamp;
1327
-
1328
- let regenerated = 0;
1329
- let failed = 0;
1330
-
1331
- // Separate priority paths from the rest
1332
- const prioritySet = new Set(priorityPaths.map(p => resolve(p)));
1333
- const priorityDocs = documentPaths.filter(p => prioritySet.has(resolve(p)));
1334
- const remainingDocs = documentPaths.filter(p => !prioritySet.has(resolve(p)));
1335
-
1336
- if (priorityDocs.length > 0) {
1337
- console.log(`🔄 Regenerating ${priorityDocs.length} priority documents first, then ${remainingDocs.length} remaining (${reason})`);
1338
- } else {
1339
- console.log(`🔄 Regenerating ${documentPaths.length} documents (${reason})`);
1340
- }
1341
-
1342
- // Process priority documents first
1343
- for (const docPath of priorityDocs) {
1344
- try {
1345
- const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
1346
- if (result.success) {
1347
- regenerated++;
1348
- } else {
1349
- console.warn(` ⚠️ ${docPath}: ${result.message}`);
1350
- failed++;
1351
- }
1352
- } catch (e) {
1353
- console.error(` ❌ ${docPath}: ${e.message}`);
1354
- failed++;
1355
- }
1356
- }
1357
-
1358
- // Notify caller that priority docs are done (so server can reload those clients immediately)
1359
- if (priorityDocs.length > 0 && onPriorityComplete) {
1360
- try {
1361
- onPriorityComplete({ regenerated, failed, priorityDocs });
1362
- } catch (e) {
1363
- console.error(` ⚠️ onPriorityComplete callback error: ${e.message}`);
1364
- }
1365
- }
1366
-
1367
- // Process remaining documents
1368
- for (const docPath of remainingDocs) {
1369
- try {
1370
- const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
1371
- if (result.success) {
1372
- regenerated++;
1373
- } else {
1374
- console.warn(` ⚠️ ${docPath}: ${result.message}`);
1375
- failed++;
1376
- }
1377
- } catch (e) {
1378
- console.error(` ❌ ${docPath}: ${e.message}`);
1379
- failed++;
1380
- }
1381
- }
1382
-
1383
- // Persist updated dependency registrations (e.g. a doc switched templates)
1384
- if (regenerated > 0) {
1385
- await saveDependencyTracker(resolve(_source) + "/");
1386
- }
1387
-
1388
- const elapsed = Date.now() - startTime;
1389
- const msg = `Regenerated ${regenerated}/${documentPaths.length} documents in ${elapsed}ms (${reason})${failed > 0 ? `, ${failed} failed` : ""}`;
1390
- console.log(`✅ ${msg}`);
1391
- return { success: failed === 0, message: msg, regenerated, failed };
1392
- }
1393
-
1394
- /**
1395
- * Regenerate a single file without scanning the entire source directory.
1396
- * This is much faster for watch mode - only regenerate what changed.
1397
- *
1398
- * @param {string} changedFile - Absolute path to the file that changed
1399
- * @param {Object} options - Same options as generate()
1400
- * @returns {Promise<{success: boolean, message: string}>}
1401
- */
1402
- export async function regenerateSingleFile(changedFile, {
1403
- _source,
1404
- _meta,
1405
- _output,
1406
- } = {}) {
1407
- const startTime = Date.now();
1408
- const source = resolve(_source) + "/";
1409
- const meta = resolve(_meta);
1410
- const output = resolve(_output) + "/";
1411
-
1412
- // Check if this is an article file we can regenerate
1413
- const articleExtensions = /\.(md|mdx|txt|yml)$/;
1414
- if (!changedFile.match(articleExtensions)) {
1415
- return { success: false, message: `Not an article file: ${changedFile}` };
1416
- }
1417
-
1418
- // Check if cache is initialized
1419
- if (!watchModeCache.isInitialized) {
1420
- return { success: false, message: 'Cache not initialized - need full build first' };
1421
- }
1422
-
1423
- // Verify paths match cached paths
1424
- if (watchModeCache.source !== source || watchModeCache.output !== output) {
1425
- return { success: false, message: 'Paths changed - need full rebuild' };
1426
- }
1427
-
1428
- try {
1429
- const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus, ursaMetadata } = watchModeCache;
1430
-
1431
- const rawBody = await readFile(changedFile, "utf8");
1432
- const type = parse(changedFile).ext;
1433
- const ext = extname(changedFile);
1434
- const base = basename(changedFile, ext);
1435
- const dir = addTrailingSlash(dirname(changedFile)).replace(source, "");
1436
-
1437
- // Calculate output paths
1438
- const outputFilename = changedFile
1439
- .replace(source, output)
1440
- .replace(parse(changedFile).ext, ".html");
1441
- const url = '/' + outputFilename.replace(output, '');
1442
-
1443
- // Title from filename (for index/home, use parent folder name)
1444
- const titleBase = (base === 'index' || base === 'home') ? basename(dirname(changedFile)) : base;
1445
- const title = toTitleCase(titleBase || base);
1446
-
1447
- // Extract metadata
1448
- const fileMeta = extractMetadata(rawBody);
1449
- const transformedMetadata = await getTransformedMetadata(
1450
- dirname(changedFile),
1451
- fileMeta
1452
- );
1453
-
1454
- // Calculate the document's URL path
1455
- const docUrlPath = '/' + dir + base + '.html';
1456
-
1457
- // Check if hydration should be enabled for MDX files
1458
- const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
1459
-
1460
- // Render body (use async for .mdx, sync for .md/.txt)
1461
- let body;
1462
- let hydrationScript = '';
1463
- if (type === '.mdx') {
1464
- const renderResult = await renderFileAsync({
1465
- fileContents: rawBody,
1466
- type,
1467
- dirname: dir,
1468
- basename: base,
1469
- filePath: changedFile,
1470
- sourceRoot: source,
1471
- useWorker: false,
1472
- hydrate: shouldHydrate,
1473
- });
1474
-
1475
- // Handle the result - can be string or { html, hydrationScript }
1476
- if (typeof renderResult === 'object' && renderResult.html) {
1477
- body = renderResult.html;
1478
- hydrationScript = renderResult.hydrationScript || '';
1479
- } else {
1480
- body = renderResult;
1481
- }
1482
- } else {
1483
- body = renderFile({
1484
- fileContents: rawBody,
1485
- type,
1486
- dirname: dir,
1487
- basename: base,
1488
- });
1489
- }
1490
-
1491
- // Inject default H1 if body doesn't start with one
1492
- if (!body || !body.trimStart().startsWith('<h1')) {
1493
- const h1Title = fileMeta?.title || title;
1494
- body = `<h1>${h1Title}</h1>\n` + (body || '');
1495
- }
1496
-
1497
- // Inject breadcrumbs before the H1
1498
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
1499
- if (breadcrumbs) {
1500
- body = breadcrumbs + body;
1501
- }
1502
-
1503
- // Inject frontmatter table for markdown/mdx files
1504
- if ((type === '.md' || type === '.mdx') && fileMeta) {
1505
- body = injectFrontmatterTable(body, fileMeta);
1506
- }
1507
-
1508
- // Handle auto-index generation for index files with generate-auto-index: true
1509
- if (base === 'index' && fileMeta) {
1510
- const autoIndexConfig = getAutoIndexConfig(fileMeta);
1511
- if (autoIndexConfig.enabled) {
1512
- // Generate auto-index HTML for this directory from source
1513
- const sourceDir = dirname(changedFile);
1514
- const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
1515
-
1516
- if (autoIndexHtml) {
1517
- if (autoIndexConfig.position === 'bottom') {
1518
- body = body + '\n' + autoIndexHtml;
1519
- } else {
1520
- body = autoIndexHtml + '\n' + body;
1521
- }
1522
- }
1523
- }
1524
- }
1525
-
1526
- // Find all CSS files up the tree and create separate link tags
1527
- // (regenerateSingleFile is used in serve mode, so separate tags per level for invalidation)
1528
- let styleLink = "";
1529
- let cssPaths = [];
1530
- try {
1531
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1532
- cssPaths = await findAllStyleCss(dirKey, _source);
1533
- if (cssPaths.length > 0) {
1534
- // Copy all CSS files to output (always copy in single-file mode to ensure up to date)
1535
- for (const cssPath of cssPaths) {
1536
- const cssOutputPath = cssPath.replace(source, output);
1537
- const cssContent = await readFile(cssPath, 'utf8');
1538
- await outputFile(cssOutputPath, cssContent);
1539
- }
1540
- styleLink = generateSeparateCssTags(cssPaths, source);
1541
- }
1542
- } catch (e) {
1543
- // ignore
1544
- }
1545
-
1546
- // Get template
1547
- const requestedTemplateName = fileMeta && fileMeta.template;
1548
- const template =
1549
- templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
1550
-
1551
- if (!template) {
1552
- return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
1553
- }
1554
-
1555
- // Find all script.js files from docroot to current dir and serve as separate external tags
1556
- // (Serve mode: separate tags per level for individual invalidation)
1557
- let customScript = "";
1558
- let scriptPaths = [];
1559
- try {
1560
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1561
- scriptPaths = await findAllScriptJs(dirKey, _source);
1562
- if (scriptPaths.length > 0) {
1563
- // Copy all script files to output so they can be served
1564
- for (const scriptPath of scriptPaths) {
1565
- const scriptOutputPath = scriptPath.replace(source, output);
1566
- const scriptContent = await readFile(scriptPath, 'utf8');
1567
- await outputFile(scriptOutputPath, scriptContent);
1568
- }
1569
- customScript = generateSeparateJsTags(scriptPaths, source);
1570
- }
1571
- } catch (e) {
1572
- // ignore
1573
- }
1574
-
1575
- // Register this document's dependencies (template, inherited CSS/JS) so
1576
- // invalidation plans stay accurate after frontmatter/template changes.
1577
- // Persisted to .ursa/dependency-graph.json by regenerateAffectedDocuments.
1578
- const usedTemplateName = (requestedTemplateName && templates[requestedTemplateName])
1579
- ? requestedTemplateName
1580
- : DEFAULT_TEMPLATE_NAME;
1581
- dependencyTracker.registerDocument(changedFile, {
1582
- templateName: usedTemplateName,
1583
- cssPaths,
1584
- scriptPaths,
1585
- });
1586
-
1587
- // Check if this file has a custom menu
1588
- const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
1589
-
1590
- // Append hydration script to customScript if present (for MDX with hydrate: true)
1591
- const finalCustomScript = hydrationScript
1592
- ? customScript + '\n' + hydrationScript
1593
- : customScript;
1594
-
1595
- // Build final HTML
1596
- let finalHtml = template;
1597
- const replacements = {
1598
- "${title}": fileMeta?.title || title,
1599
- "${menu}": menu,
1600
- "${meta}": JSON.stringify(fileMeta),
1601
- "${transformedMetadata}": transformedMetadata,
1602
- "${body}": body,
1603
- "${styleLink}": styleLink,
1604
- "${customScript}": finalCustomScript,
1605
- "${searchIndex}": "[]",
1606
- "${footer}": footer
1607
- };
1608
- for (const [key, value] of Object.entries(replacements)) {
1609
- finalHtml = finalHtml.replace(key, value);
1610
- }
1611
-
1612
- // If this page has a custom menu, add data attributes to body
1613
- if (customMenuInfo) {
1614
- const menuPosition = customMenuInfo.menuPosition || 'top';
1615
- finalHtml = finalHtml.replace(
1616
- /<body([^>]*)>/,
1617
- `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
1618
- );
1619
- } else {
1620
- // No custom menu — default to top menu
1621
- finalHtml = finalHtml.replace(
1622
- /<body([^>]*)>/,
1623
- `<body$1 data-menu-position="top">`
1624
- );
1625
- }
1626
-
1627
- // Resolve relative URLs in raw HTML elements (img src, etc.)
1628
- finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
1629
-
1630
- // Mark broken links
1631
- finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
1632
-
1633
- // Transform image tags to use preview images with data-fullsrc for originals
1634
- if (imageMap) {
1635
- finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
1636
- }
1637
-
1638
- // Add cache-busting timestamps to static file references
1639
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
1640
-
1641
- await outputFile(outputFilename, finalHtml);
1642
-
1643
- // JSON output
1644
- const jsonOutputFilename = outputFilename.replace(".html", ".json");
1645
- const sections = (type === '.md' || type === '.mdx') ? extractSections(rawBody) : [];
1646
- const jsonObject = {
1647
- name: base,
1648
- url,
1649
- contents: rawBody,
1650
- bodyHtml: body,
1651
- metadata: fileMeta,
1652
- sections,
1653
- transformedMetadata,
1654
- _ursa_metadata: ursaMetadata,
1655
- };
1656
- const json = JSON.stringify(jsonObject);
1657
- await outputFile(jsonOutputFilename, json);
1658
-
1659
- // XML output
1660
- const xmlOutputFilename = outputFilename.replace(".html", ".xml");
1661
- const xml = `<article>${o2x(jsonObject)}</article>`;
1662
- await outputFile(xmlOutputFilename, xml);
1663
-
1664
- // Folder-named index promotion (mirrors autoIndex behavior on full builds):
1665
- // If the file is named like its parent folder (e.g. aletheia/aletheia.md) and
1666
- // no explicit index.{md,mdx,txt,yml,html} exists alongside it, also write the
1667
- // same outputs to <dir>/index.html|json|xml so the canonical URL stays fresh.
1668
- const sourceDirOfFile = dirname(changedFile);
1669
- const parentFolderName = basename(sourceDirOfFile);
1670
- if (base && parentFolderName && base === parentFolderName) {
1671
- const hasExplicitIndex = ['index.md', 'index.mdx', 'index.txt', 'index.yml', 'index.html']
1672
- .some(name => existsSync(join(sourceDirOfFile, name)));
1673
- if (!hasExplicitIndex) {
1674
- const outDirOfFile = dirname(outputFilename);
1675
- await outputFile(join(outDirOfFile, 'index.html'), finalHtml);
1676
- await outputFile(join(outDirOfFile, 'index.json'), json);
1677
- await outputFile(join(outDirOfFile, 'index.xml'), xml);
1678
- }
1679
- }
1680
-
1681
- // Update hash cache
1682
- updateHash(changedFile, rawBody, hashCache);
1683
-
1684
- // Update recent-activity.json with this file's new content timestamp
1685
- try {
1686
- const now = Date.now();
1687
- const recentActivityPath = join(output, 'public', 'recent-activity.json');
1688
- let recentActivity = [];
1689
- try {
1690
- const existing = await readFile(recentActivityPath, 'utf8');
1691
- recentActivity = JSON.parse(existing);
1692
- } catch (e) { /* no existing file, start fresh */ }
1693
- // Remove old entry for this URL if present
1694
- recentActivity = recentActivity.filter(r => r.url !== url);
1695
- // Add updated entry with current timestamp (content changed now)
1696
- recentActivity.push({ title, url, mtime: now });
1697
- // Sort by mtime descending, keep top 10
1698
- recentActivity.sort((a, b) => b.mtime - a.mtime);
1699
- recentActivity = recentActivity.slice(0, 10);
1700
- await outputFile(recentActivityPath, JSON.stringify(recentActivity));
1701
-
1702
- // Also update content timestamp in .ursa.json for persistence
1703
- const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
1704
- updateContentTimestamp(source, relativePath, now);
1705
- } catch (e) {
1706
- // ignore recent activity update errors
1707
- }
1708
-
1709
- const elapsed = Date.now() - startTime;
1710
- const shortFile = changedFile.replace(source, '');
1711
- return { success: true, message: `Regenerated ${shortFile} in ${elapsed}ms` };
1712
- } catch (e) {
1713
- return { success: false, message: `Error: ${e.message}` };
1714
- }
1
+ import { recurse } from "../helper/recursive-readdir.js";
2
+ import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
3
+ import { getAutomenu } from "../helper/automenu.js";
4
+ import { filterAsync } from "../helper/filterAsync.js";
5
+ import { isDirectory } from "../helper/isDirectory.js";
6
+ import { isFolderHidden, clearConfigCache } from "../helper/folderConfig.js";
7
+ import { isHiddenOrSystemPath } from "../helper/hiddenPaths.js";
8
+ import { IMAGE_EXTENSIONS, isMedia } from "../helper/staticAssets.js";
9
+ import {
10
+ extractMetadata,
11
+ extractRawMetadata,
12
+ isMetadataOnly,
13
+ getAutoIndexConfig,
14
+ } from "../helper/metadataExtractor.js";
15
+ import { injectFrontmatterTable } from "../helper/frontmatterTable.js";
16
+ import {
17
+ hashContent,
18
+ loadHashCache,
19
+ saveHashCache,
20
+ needsRegeneration,
21
+ outputsExist,
22
+ updateHash,
23
+ getUrsaDir,
24
+ } from "../helper/contentHash.js";
25
+ import {
26
+ buildValidPaths,
27
+ markInactiveLinks,
28
+ resolveRelativeUrls,
29
+ } from "../helper/linkValidator.js";
30
+ import { getAndIncrementBuildId, loadContentTimestamps, saveContentTimestamps, updateContentTimestamp } from "../helper/ursaConfig.js";
31
+ import { extractSections } from "../helper/sectionExtractor.js";
32
+ import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
33
+ import { buildReactRuntime } from "../helper/mdxRenderer.js";
34
+ import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
35
+ import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
36
+ import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMetaBundleCache, generateSeparateCssTags, generateSeparateJsTags } from "../helper/assetBundler.js";
37
+ import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
38
+ import { dependencyTracker, loadDependencyTracker, saveDependencyTracker } from "../helper/dependencyTracker.js";
39
+ import { CacheBustHashMap } from "../helper/build/cacheBust.js";
40
+ import { copy as copyDir, emptyDir, outputFile, remove } from "fs-extra";
41
+ import { basename, dirname, extname, join, parse, resolve } from "path";
42
+ import { URL } from "url";
43
+ import o2x from "object-to-xml";
44
+ import { existsSync } from "fs";
45
+ import { createWhitelistFilter } from "../helper/whitelistFilter.js";
46
+ import { processAllImages, transformImageTags, clearImageCache, copyAllImagesFast } from "../helper/imageProcessor.js";
47
+ import { extractImageReferences } from "../helper/imageExtractor.js";
48
+ import { checkFileSize, readFileStreaming, formatFileSize } from "../helper/streamingReader.js";
49
+ import { generateBreadcrumbs } from "../helper/breadcrumbs.js";
50
+ import {
51
+ loadNavCache,
52
+ saveNavCache,
53
+ hashFileList,
54
+ hashFileStats,
55
+ isNavCacheValid,
56
+ createNavCacheEntry,
57
+ restoreMap,
58
+ } from "../helper/build/navCache.js";
59
+
60
+ // Import build helpers from organized modules
61
+ import {
62
+ generateCacheBustTimestamp,
63
+ addTimestampToCssUrls,
64
+ addTimestampToHtmlStaticRefs,
65
+ processBatched,
66
+ ProgressReporter,
67
+ watchModeCache,
68
+ clearWatchCache as clearWatchCacheBase,
69
+ toTitleCase,
70
+ parseExcludeOption,
71
+ createExcludeFilter,
72
+ addTrailingSlash,
73
+ getTemplates,
74
+ getMenu,
75
+ findAllCustomMenus,
76
+ getCustomMenuForFile,
77
+ getTransformedMetadata,
78
+ getFooter,
79
+ getUrsaMetadata,
80
+ generateAutoIndices,
81
+ generateAutoIndexHtmlFromSource,
82
+ copyMetaAssets,
83
+ } from "../helper/build/index.js";
84
+ import { getProfiler } from "../helper/build/profiler.js";
85
+ import { reconcileAll, isInsideTemplatesFolder } from "../helper/documentTemplates.js";
86
+
87
+ // Concurrency limiter for batch processing to avoid memory exhaustion
88
+ const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
89
+
90
+ // Cache for CSS path lookups to avoid repeated filesystem walks
91
+ const cssPathCache = new Map();
92
+
93
+ // Cache for script path lookups to avoid repeated filesystem walks
94
+ const scriptPathCache = new Map();
95
+
96
+ // Cache for document-level CSS/JS bundle paths to avoid re-bundling identical sets
97
+ const docBundleCache = new Map();
98
+
99
+ // Wrapper for clearWatchCache that passes cssPathCache and scriptPathCache
100
+ export function clearWatchCache() {
101
+ clearWatchCacheBase(cssPathCache);
102
+ scriptPathCache.clear();
103
+ docBundleCache.clear();
104
+ clearMetaBundleCache();
105
+ }
106
+
107
+ // Clear just the script-related caches (for when script.js changes)
108
+ export function clearScriptCache() {
109
+ scriptPathCache.clear();
110
+ // Clear all JS bundle cache entries
111
+ for (const key of docBundleCache.keys()) {
112
+ if (key.startsWith('js:')) {
113
+ docBundleCache.delete(key);
114
+ }
115
+ }
116
+ }
117
+
118
+ // Clear just the CSS-related caches (for when style.css changes)
119
+ export function clearStyleCache() {
120
+ cssPathCache.clear();
121
+ // Clear all CSS bundle cache entries
122
+ for (const key of docBundleCache.keys()) {
123
+ if (key.startsWith('css:')) {
124
+ docBundleCache.delete(key);
125
+ }
126
+ }
127
+ }
128
+
129
+ const progress = new ProgressReporter();
130
+
131
+ const DEFAULT_TEMPLATE_NAME =
132
+ process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
133
+
134
+ export async function generate({
135
+ _source = join(process.cwd(), "."),
136
+ _meta = join(process.cwd(), "meta"),
137
+ _output = join(process.cwd(), "build"),
138
+ _whitelist = null,
139
+ _exclude = null,
140
+ _incremental = false, // Legacy flag, now ignored (always incremental)
141
+ _clean = false, // When true, ignore cache and regenerate all files
142
+ _deferImages = false, // When true, copy images without processing, return promise for background processing
143
+ _deferSearchIndex = false, // When true, return promise for search index building (for faster startup)
144
+ } = {}) {
145
+ // Initialize profiler for this build
146
+ const profiler = getProfiler(true);
147
+
148
+ console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex });
149
+ const source = resolve(_source) + "/";
150
+ const meta = resolve(_meta);
151
+ const output = resolve(_output) + "/";
152
+ console.log({ source, meta, output });
153
+
154
+ // Generate cache-busting timestamp for this build
155
+ const cacheBustTimestamp = generateCacheBustTimestamp();
156
+ const cacheBustHashes = new CacheBustHashMap();
157
+ progress.logTimed(`Cache-bust timestamp: ${cacheBustTimestamp}`);
158
+
159
+ // Initialize dependency tracker for this build
160
+ dependencyTracker.init(source);
161
+
162
+ // Clear output directory and cache when --clean is specified
163
+ if (_clean) {
164
+ progress.startTimer('Clean');
165
+ const ursaDir = getUrsaDir(source);
166
+ progress.logTimed(`Clean build: deleting cache folder ${ursaDir}`);
167
+ await remove(ursaDir);
168
+ progress.logTimed(`Clean build: clearing output directory ${output}`);
169
+ await emptyDir(output);
170
+ progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
171
+ } else {
172
+ // Warm start: reload persisted dependency registrations so hash-skipped
173
+ // documents keep their edges (current-run registrations take precedence)
174
+ const loaded = await loadDependencyTracker(source);
175
+ if (loaded) {
176
+ const stats = dependencyTracker.getStats();
177
+ progress.logTimed(`Dependency graph loaded: ${stats.totalDocuments} documents, ${stats.uniqueFiles} dependencies`);
178
+ }
179
+ }
180
+
181
+ // Phase: Scan source files
182
+ profiler.startPhase('Scan source files');
183
+ progress.startTimer('Scan');
184
+ const allSourceFilenamesUnfiltered = await recurse(source, [() => false]);
185
+ progress.logTimed(`Scanned ${allSourceFilenamesUnfiltered.length} files [${progress.stopTimer('Scan')}]`);
186
+ profiler.endPhase('Scan source files');
187
+
188
+ // Phase: Filter and classify files
189
+ profiler.startPhase('Filter & classify');
190
+ progress.startTimer('Filter');
191
+
192
+ // Apply include filter (existing functionality)
193
+ const includeFilter = process.env.INCLUDE_FILTER
194
+ ? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
195
+ : Boolean;
196
+ let allSourceFilenames = allSourceFilenamesUnfiltered.filter(includeFilter);
197
+
198
+ // Apply exclude filter if specified
199
+ if (_exclude) {
200
+ const excludedPaths = await parseExcludeOption(_exclude, source);
201
+ const excludeFilter = createExcludeFilter(excludedPaths, source);
202
+ const beforeCount = allSourceFilenames.length;
203
+ allSourceFilenames = allSourceFilenames.filter(excludeFilter);
204
+ progress.logTimed(`Exclude filter applied: ${beforeCount - allSourceFilenames.length} files excluded`);
205
+ }
206
+
207
+ // Apply whitelist filter if specified
208
+ if (_whitelist) {
209
+ const whitelistFilter = await createWhitelistFilter(_whitelist, source);
210
+ allSourceFilenames = allSourceFilenames.filter(whitelistFilter);
211
+ progress.logTimed(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
212
+ }
213
+
214
+ // Clear config cache at start of generation to pick up any changes
215
+ clearConfigCache();
216
+
217
+ // Helper to check if a path is inside a config-hidden folder
218
+ const isInHiddenFolder = (filePath) => {
219
+ const dir = dirname(filePath);
220
+ return isFolderHidden(dir, source);
221
+ };
222
+
223
+ // read all articles, process them, copy them to build
224
+ const articleExtensions = /\.(md|mdx|txt|yml)$/;
225
+ // Hidden/system folders are judged RELATIVE to the docroot — see
226
+ // helper/hiddenPaths.js for why testing the absolute path silently produces
227
+ // an empty site when the checkout lives under a dot-directory.
228
+ const isHiddenOrSystem = (filename) => isHiddenOrSystemPath(filename, source);
229
+ const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
230
+ (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename) && !isInHiddenFolder(filename)
231
+ );
232
+ const allSourceFilenamesThatAreDirectories = (await filterAsync(
233
+ allSourceFilenames,
234
+ (filename) => isDirectory(filename)
235
+ )).filter((filename) => !isHiddenOrSystem(filename) && !isFolderHidden(filename, source));
236
+
237
+ // Build set of existing HTML files in source directory (these should not be overwritten)
238
+ const htmlExtensions = /\.html$/;
239
+ const existingHtmlFiles = new Set(
240
+ allSourceFilenames
241
+ .filter(f => f.match(htmlExtensions) && !isHiddenOrSystem(f))
242
+ .map(f => f.replace(source, '')) // Store relative paths for easy lookup
243
+ );
244
+
245
+ progress.logTimed(`Classified: ${allSourceFilenamesThatAreArticles.length} articles, ${allSourceFilenamesThatAreDirectories.length} dirs, ${existingHtmlFiles.size} HTML [${progress.stopTimer('Filter')}]`);
246
+ profiler.endPhase('Filter & classify');
247
+
248
+ // Drop persisted dependency registrations for documents that no longer
249
+ // exist (or are excluded), so stale entries don't accumulate across runs
250
+ dependencyTracker.prune(new Set(allSourceFilenamesThatAreArticles));
251
+
252
+ // Phase: Document template reconciliation
253
+ // Must run BEFORE article processing so that any template-driven changes
254
+ // to source .md files are picked up during rendering.
255
+ profiler.startPhase('Template reconciliation');
256
+ progress.startTimer('Templates');
257
+ const templateReconciliation = await reconcileAll(
258
+ allSourceFilenamesThatAreArticles,
259
+ allSourceFilenamesUnfiltered, // templates live in _templates which is filtered out of articles
260
+ source
261
+ );
262
+ if (templateReconciliation.updated > 0 || templateReconciliation.conflicts > 0 || templateReconciliation.initialized > 0) {
263
+ progress.logTimed(
264
+ `📄 Document templates: ${templateReconciliation.initialized} initialized, ` +
265
+ `${templateReconciliation.updated} auto-merged, ` +
266
+ `${templateReconciliation.conflicts} conflicts, ` +
267
+ `${templateReconciliation.unchanged} unchanged, ` +
268
+ `${templateReconciliation.errors} errors`
269
+ );
270
+ if (templateReconciliation.conflicts > 0) {
271
+ console.warn(`\n⚠️ Template conflicts require manual resolution:`);
272
+ for (const msg of templateReconciliation.messages) {
273
+ if (msg.includes('Conflict')) console.warn(` ${msg}`);
274
+ }
275
+ console.warn('');
276
+ }
277
+ if (templateReconciliation.errors > 0) {
278
+ for (const msg of templateReconciliation.messages) {
279
+ if (msg.includes('Error') || msg.includes('not found')) console.warn(` ⚠️ ${msg}`);
280
+ }
281
+ }
282
+ }
283
+ progress.logTimed(`Document templates processed [${progress.stopTimer('Templates')}]`);
284
+ profiler.endPhase('Template reconciliation');
285
+
286
+ // Phase: Build navigation and metadata
287
+ profiler.startPhase('Build navigation');
288
+ progress.startTimer('Navigation');
289
+
290
+ // Check if we can use cached navigation
291
+ let validPaths, templates, menu, menuData, customMenus, footer, buildId;
292
+ let navCacheUsed = false;
293
+
294
+ if (!_clean) {
295
+ const fileListHash = hashFileList(allSourceFilenames);
296
+ const fileStatsHash = await hashFileStats(allSourceFilenames);
297
+ const navCache = await loadNavCache(source);
298
+
299
+ if (isNavCacheValid(navCache, fileListHash, fileStatsHash)) {
300
+ // Use cached navigation data
301
+ navCacheUsed = true;
302
+ validPaths = restoreMap(navCache.validPaths);
303
+ menuData = navCache.menuData;
304
+ menu = navCache.menuHtml;
305
+ customMenus = restoreMap(navCache.customMenus);
306
+
307
+ // Templates and footer still need to be loaded (they depend on meta directory)
308
+ templates = await getTemplates(meta);
309
+ buildId = getAndIncrementBuildId(resolve(_source));
310
+ footer = await getFooter(source, _source, buildId);
311
+
312
+ progress.logTimed(`Navigation loaded from cache: ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
313
+ } else {
314
+ // Cache miss - build navigation from scratch
315
+ validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
316
+ templates = await getTemplates(meta);
317
+
318
+ const menuResult = await getMenu(allSourceFilenames, source, validPaths);
319
+ menu = menuResult.html;
320
+ menuData = menuResult.menuData;
321
+
322
+ customMenus = findAllCustomMenus(allSourceFilenames, source);
323
+ buildId = getAndIncrementBuildId(resolve(_source));
324
+ footer = await getFooter(source, _source, buildId);
325
+
326
+ // Save to cache for next run
327
+ const cacheEntry = createNavCacheEntry(
328
+ fileListHash,
329
+ fileStatsHash,
330
+ menuData,
331
+ menu,
332
+ Array.from(validPaths.entries()),
333
+ Array.from(customMenus.entries())
334
+ );
335
+ await saveNavCache(source, cacheEntry);
336
+
337
+ progress.logTimed(`Navigation built: ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
338
+ }
339
+ } else {
340
+ // Clean build - ignore cache
341
+ validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
342
+ templates = await getTemplates(meta);
343
+
344
+ const menuResult = await getMenu(allSourceFilenames, source, validPaths);
345
+ menu = menuResult.html;
346
+ menuData = menuResult.menuData;
347
+
348
+ customMenus = findAllCustomMenus(allSourceFilenames, source);
349
+ buildId = getAndIncrementBuildId(resolve(_source));
350
+ footer = await getFooter(source, _source, buildId);
351
+
352
+ progress.logTimed(`Navigation built (clean): ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
353
+ }
354
+
355
+ profiler.endPhase('Build navigation');
356
+
357
+ // Build the _ursa_metadata embedded in every generated JSON file (ursa + doc repo versions)
358
+ const ursaMetadata = await getUrsaMetadata(_source);
359
+
360
+ // Phase: Load cache
361
+ profiler.startPhase('Load cache');
362
+ progress.startTimer('Cache');
363
+
364
+ // Load content hash cache from .ursa folder in source directory
365
+ let hashCache = new Map();
366
+ if (!_clean) {
367
+ hashCache = await loadHashCache(source);
368
+ progress.logTimed(`Loaded ${hashCache.size} cached hashes [${progress.stopTimer('Cache')}]`);
369
+ } else {
370
+ progress.logTimed(`Clean build: ignoring cached hashes`);
371
+ progress.stopTimer('Cache');
372
+ }
373
+
374
+ // Load content timestamps from .ursa.json (survives --clean)
375
+ // These track when content actually changed, not filesystem mtime
376
+ const contentTimestamps = loadContentTimestamps(source);
377
+ const buildTimestamp = Date.now();
378
+ progress.logTimed(`Loaded ${contentTimestamps.size} content timestamps`);
379
+ profiler.endPhase('Load cache');
380
+
381
+ // Phase: Copy meta/public files
382
+ profiler.startPhase('Copy meta files');
383
+ progress.startTimer('Meta');
384
+
385
+ // create public folder
386
+ const pub = join(output, "public");
387
+ await mkdir(pub, { recursive: true });
388
+
389
+ // Copy meta assets with new template folder structure
390
+ const { copiedFiles, orphanedFiles } = await copyMetaAssets(meta, pub);
391
+
392
+ // Warn about orphaned files in meta that aren't part of any template
393
+ if (orphanedFiles.length > 0) {
394
+ console.warn(`\n⚠️ Warning: Found ${orphanedFiles.length} orphaned file(s) in meta directory:`);
395
+ console.warn(` These files are not in meta/templates/ or meta/shared/ and won't be included:`);
396
+ for (const file of orphanedFiles.slice(0, 10)) {
397
+ console.warn(` - ${file}`);
398
+ }
399
+ if (orphanedFiles.length > 10) {
400
+ console.warn(` ... and ${orphanedFiles.length - 10} more`);
401
+ }
402
+ console.warn(` Move them to meta/templates/{templateName}/ or meta/shared/ to include them.\n`);
403
+ }
404
+
405
+ // Bundle meta template assets (CSS + JS) into single files per template
406
+ // This must happen after copying meta to public but before cache-busting
407
+ templates = await bundleMetaTemplateAssets(templates, meta, pub, { minify: true, sourcemap: false });
408
+ progress.logTimed(`Meta template assets bundled`);
409
+
410
+ // Build React runtime for MDX hydration (React 19 has no UMD, so we bundle locally)
411
+ await buildReactRuntime(pub);
412
+
413
+ // Process all CSS files in the entire output directory tree for cache-busting
414
+ const allOutputFiles = await recurse(output, [() => false]);
415
+ for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
416
+ const cssContent = await readFile(cssFile, 'utf8');
417
+ const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
418
+ await outputFile(cssFile, processedCss);
419
+ }
420
+
421
+ // Process JS files in output for cache-busting fetch URLs
422
+ for (const jsFile of allOutputFiles.filter(f => f.endsWith('.js'))) {
423
+ let jsContent = await readFile(jsFile, 'utf8');
424
+ jsContent = jsContent.replace(
425
+ /fetch\(['"]([^'"\)]+\.(json))['"](?!\s*\+)/g,
426
+ `fetch('$1?v=${cacheBustTimestamp}'`
427
+ );
428
+ await outputFile(jsFile, jsContent);
429
+ }
430
+
431
+ progress.logTimed(`Meta files copied and processed [${progress.stopTimer('Meta')}]`);
432
+ profiler.endPhase('Copy meta files');
433
+
434
+ // Track errors for error report
435
+ const errors = [];
436
+
437
+ // Search index: built incrementally during article processing (lighter memory footprint)
438
+ const searchIndex = [];
439
+ // Full-text index: collect documents for word-to-document mapping
440
+ const fullTextDocs = [];
441
+ // Recent activity: collect {title, url, mtime} for all articles, keep top 10 by mtime
442
+ const recentActivity = [];
443
+ // Track paths of documents that were regenerated (for incremental index updates)
444
+ const changedPaths = new Set();
445
+ // Directory index cache: only stores minimal data needed for directory indices
446
+ // Uses WeakRef-style approach - store only what's needed, clear as we go
447
+ const dirIndexCache = new Map();
448
+
449
+ // Track CSS files that have been copied to avoid duplicates
450
+ const copiedCssFiles = new Set();
451
+
452
+ // Identify all image files from the filtered source list
453
+ const imageExtensions = IMAGE_EXTENSIONS;
454
+ let allSourceFilenamesThatAreImages = allSourceFilenames.filter(
455
+ (filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
456
+ );
457
+
458
+ // When using a whitelist, also include images referenced by whitelisted documents
459
+ // This ensures that images used in whitelisted articles are processed even if not explicitly whitelisted
460
+ if (_whitelist) {
461
+ progress.logTimed('Scanning whitelisted articles for image references...');
462
+ const referencedImages = new Set();
463
+
464
+ for (const articlePath of allSourceFilenamesThatAreArticles) {
465
+ try {
466
+ const content = await readFile(articlePath, 'utf8');
467
+ const imageRefs = extractImageReferences(content, articlePath, source);
468
+ imageRefs.forEach(img => referencedImages.add(img));
469
+ } catch (e) {
470
+ // Ignore read errors - file might not exist or be unreadable
471
+ }
472
+ }
473
+
474
+ // Get all images from the unfiltered source list that are referenced
475
+ const allImagesUnfiltered = allSourceFilenamesUnfiltered.filter(
476
+ (filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
477
+ );
478
+
479
+ // Add referenced images that aren't already in the list
480
+ const additionalImages = allImagesUnfiltered.filter(
481
+ img => referencedImages.has(img) && !allSourceFilenamesThatAreImages.includes(img)
482
+ );
483
+
484
+ if (additionalImages.length > 0) {
485
+ progress.logTimed(`Found ${additionalImages.length} additional images referenced by whitelisted documents`);
486
+ allSourceFilenamesThatAreImages = [...allSourceFilenamesThatAreImages, ...additionalImages];
487
+ }
488
+ }
489
+
490
+ // Phase: Process images
491
+ profiler.startPhase('Process images');
492
+ progress.startTimer('Images');
493
+
494
+ // Handle images based on deferred mode
495
+ let imageMap = new Map();
496
+ let deferredImageProcessingPromise = null;
497
+
498
+ if (_deferImages) {
499
+ // Fast mode: just copy images without processing, defer preview generation
500
+ progress.logTimed(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
501
+ await copyAllImagesFast(
502
+ allSourceFilenamesThatAreImages,
503
+ source,
504
+ output,
505
+ (current, total, path) => {
506
+ progress.status('Images (copy)', `${current}/${total} ${path}`);
507
+ }
508
+ );
509
+ progress.done('Images (copy)', `${allSourceFilenamesThatAreImages.length} copied (previews deferred)`);
510
+ profiler.endPhase('Process images');
511
+
512
+ // Create promise for background image processing (will be returned to caller)
513
+ deferredImageProcessingPromise = (async () => {
514
+ progress.logTimed(`\n🖼️ Starting deferred image preview generation...`);
515
+ const startTime = Date.now();
516
+ const processedImageMap = await processAllImages(
517
+ allSourceFilenamesThatAreImages,
518
+ source,
519
+ output,
520
+ (current, total, path) => {
521
+ progress.status('Images (previews)', `${current}/${total} ${path}`);
522
+ }
523
+ );
524
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
525
+ progress.done('Images (previews)', `${allSourceFilenamesThatAreImages.length} done (${processedImageMap.size} with previews) in ${elapsed}s`);
526
+
527
+ // Update the watch cache with the processed image map
528
+ watchModeCache.imageMap = processedImageMap;
529
+
530
+ return processedImageMap;
531
+ })();
532
+ } else {
533
+ // Normal mode: process all images FIRST to build the preview image map
534
+ // This is done before articles so we can transform img tags in the HTML
535
+ progress.logTimed(`Processing ${allSourceFilenamesThatAreImages.length} images for preview generation...`);
536
+ imageMap = await processAllImages(
537
+ allSourceFilenamesThatAreImages,
538
+ source,
539
+ output,
540
+ (current, total, path) => {
541
+ progress.status('Images', `${current}/${total} ${path}`);
542
+ }
543
+ );
544
+ progress.done('Images', `${allSourceFilenamesThatAreImages.length} done (${imageMap.size} with previews) [${progress.stopTimer('Images')}]`);
545
+ profiler.endPhase('Process images');
546
+ }
547
+
548
+ // Phase: Process articles
549
+ profiler.startPhase('Process articles');
550
+ progress.startTimer('Articles');
551
+
552
+ // Track files that were regenerated (for incremental mode stats)
553
+ let regeneratedCount = 0;
554
+ let skippedCount = 0;
555
+ let processedCount = 0;
556
+ const totalArticles = allSourceFilenamesThatAreArticles.length;
557
+
558
+ progress.logTimed(`Processing ${totalArticles} articles in batches of ${BATCH_SIZE}...`);
559
+
560
+ // Single pass: process all articles with batched concurrency to limit memory usage
561
+ await processBatched(allSourceFilenamesThatAreArticles, async (file) => {
562
+ try {
563
+ processedCount++;
564
+ const shortFile = file.replace(source, '');
565
+ progress.status('Articles', `${processedCount}/${totalArticles} ${shortFile}`);
566
+
567
+ // Use streaming for large files to reduce memory pressure
568
+ const { size: fileSize, useStreaming } = await checkFileSize(file);
569
+ let rawBody;
570
+ if (useStreaming) {
571
+ rawBody = await readFileStreaming(file, 'utf8');
572
+ progress.log(`📄 Streaming large file ${shortFile} (${formatFileSize(fileSize)})`);
573
+ } else {
574
+ rawBody = await readFile(file, "utf8");
575
+ }
576
+
577
+ const type = parse(file).ext;
578
+ const ext = extname(file);
579
+ const base = basename(file, ext);
580
+ const dir = addTrailingSlash(dirname(file)).replace(source, "");
581
+
582
+ // Calculate output paths for this file
583
+ const outputFilename = file
584
+ .replace(source, output)
585
+ .replace(parse(file).ext, ".html");
586
+ const url = '/' + outputFilename.replace(output, '');
587
+
588
+ // Generate URL path relative to output (for search index)
589
+ const relativePath = file.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
590
+ const searchUrl = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
591
+
592
+ // Generate title from filename (in title case)
593
+ // For index/home files, use parent folder name instead
594
+ const titleBase = (base === 'index' || base === 'home') ? basename(dirname(file)) : base;
595
+ const title = toTitleCase(titleBase || base);
596
+
597
+ // Always add to search index (lightweight: title + path only, content added lazily)
598
+ searchIndex.push({
599
+ title: title,
600
+ path: relativePath,
601
+ url: searchUrl,
602
+ content: '' // Content excerpts built lazily to save memory
603
+ });
604
+
605
+ // Add document to full-text index (uses raw markdown content)
606
+ fullTextDocs.push({
607
+ path: relativePath,
608
+ title: title,
609
+ content: rawBody
610
+ });
611
+
612
+ // Collect timestamp for recent activity tracking
613
+ // Use stored content timestamp if available, otherwise fall back to file mtime
614
+ // Content timestamps track when content actually changed, not filesystem mtime
615
+ const storedTimestamp = contentTimestamps.get(relativePath);
616
+ let activityTimestamp = storedTimestamp;
617
+ if (!activityTimestamp) {
618
+ // No stored timestamp - use file mtime as initial value
619
+ try {
620
+ const fileStat = await stat(file);
621
+ activityTimestamp = fileStat.mtimeMs;
622
+ } catch (e) {
623
+ activityTimestamp = 0;
624
+ }
625
+ }
626
+ recentActivity.push({
627
+ title: title,
628
+ url: searchUrl,
629
+ mtime: activityTimestamp
630
+ });
631
+
632
+ // Check if a corresponding .html file already exists in source directory
633
+ const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
634
+ if (existingHtmlFiles.has(outputHtmlRelative)) {
635
+ progress.log(`⚠️ Warning: Skipping ${shortFile} - would overwrite existing ${outputHtmlRelative} in source`);
636
+ skippedCount++;
637
+ return;
638
+ }
639
+
640
+ // Skip metadata-only index files - they exist only to provide folder metadata
641
+ // The auto-index system will generate the actual index.html for these folders
642
+ if (base === 'index' && (type === '.md' || type === '.mdx') && isMetadataOnly(rawBody)) {
643
+ progress.log(`ℹ️ Skipping metadata-only ${shortFile} - auto-index will generate listing`);
644
+ skippedCount++;
645
+ return;
646
+ }
647
+
648
+ // Metadata is needed both for the cache decision below and for rendering.
649
+ const fileMeta = extractMetadata(rawBody);
650
+
651
+ // A document with `generate-auto-index: true` renders a listing of the
652
+ // source tree, so its output depends on which folders and files exist —
653
+ // not just on its own text. A content hash cannot see that: adding,
654
+ // renaming, or deleting a folder anywhere in the tree leaves the hash
655
+ // untouched and the listing stale forever. Only a handful of documents
656
+ // opt in, so always rebuild them.
657
+ const hasAutoIndex = getAutoIndexConfig(fileMeta).enabled;
658
+
659
+ // Check if file needs regeneration.
660
+ // An unchanged hash is not enough: the hash cache lives in the source tree
661
+ // and is shared across output dirs, so also require that every output this
662
+ // document emits is actually present before skipping it.
663
+ const needsRegen =
664
+ _clean ||
665
+ hasAutoIndex ||
666
+ needsRegeneration(file, rawBody, hashCache) ||
667
+ !outputsExist([
668
+ outputFilename,
669
+ outputFilename.replace(".html", ".json"),
670
+ outputFilename.replace(".html", ".xml"),
671
+ ]);
672
+
673
+ if (!needsRegen) {
674
+ skippedCount++;
675
+ // For directory indices, store minimal data (not full bodyHtml)
676
+ // But include metadata for directory JSON files
677
+ dirIndexCache.set(file, {
678
+ name: base,
679
+ url,
680
+ metadata: fileMeta,
681
+ });
682
+ return; // Skip regenerating this file
683
+ }
684
+
685
+ regeneratedCount++;
686
+ // Track this path for incremental search index updates
687
+ changedPaths.add(relativePath);
688
+
689
+ const rawMeta = extractRawMetadata(rawBody);
690
+
691
+ // Lazy metadata transform - only compute if template actually uses it
692
+ // This defers the potentially expensive custom transform function load
693
+ let transformedMetadata = null;
694
+ const getTransformedMeta = async () => {
695
+ if (transformedMetadata === null) {
696
+ transformedMetadata = await getTransformedMetadata(dirname(file), fileMeta);
697
+ }
698
+ return transformedMetadata;
699
+ };
700
+
701
+ // Calculate the document's URL path (e.g., "/character/index.html")
702
+ const docUrlPath = '/' + dir + base + '.html';
703
+
704
+ // Use async rendering with worker threads for parallel markdown parsing
705
+ // Wikitext (.txt) files will fall back to main thread
706
+ // For MDX files, enable hydration if frontmatter has `hydrate: true`
707
+ const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
708
+
709
+ let renderResult = await renderFileAsync({
710
+ fileContents: rawBody,
711
+ type,
712
+ dirname: dir,
713
+ basename: base,
714
+ filePath: file,
715
+ sourceRoot: source,
716
+ useWorker: true,
717
+ hydrate: shouldHydrate,
718
+ });
719
+
720
+ // Handle the result - can be string or { html, hydrationScript }
721
+ let body;
722
+ let hydrationScript = '';
723
+ if (typeof renderResult === 'object' && renderResult.html) {
724
+ body = renderResult.html;
725
+ hydrationScript = renderResult.hydrationScript || '';
726
+ } else {
727
+ body = renderResult;
728
+ }
729
+
730
+ // Inject default H1 if body doesn't start with one
731
+ if (!body || !body.trimStart().startsWith('<h1')) {
732
+ const h1Title = fileMeta?.title || title;
733
+ body = `<h1>${h1Title}</h1>\n` + (body || '');
734
+ }
735
+
736
+ // Inject breadcrumbs before the H1
737
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
738
+ if (breadcrumbs) {
739
+ body = breadcrumbs + body;
740
+ }
741
+
742
+ // Inject frontmatter table after first H1 (for markdown files with metadata)
743
+ if ((type === '.md' || type === '.mdx') && fileMeta) {
744
+ body = injectFrontmatterTable(body, fileMeta);
745
+ }
746
+
747
+ // Handle auto-index generation for index files with generate-auto-index: true
748
+ if (base === 'index' && fileMeta) {
749
+ const autoIndexConfig = getAutoIndexConfig(fileMeta);
750
+ if (autoIndexConfig.enabled) {
751
+ // Generate auto-index HTML for this directory from source
752
+ // Using source avoids race conditions with concurrent file generation
753
+ const sourceDir = dirname(file);
754
+ const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
755
+
756
+ if (autoIndexHtml) {
757
+ if (autoIndexConfig.position === 'bottom') {
758
+ body = body + '\n' + autoIndexHtml;
759
+ } else {
760
+ body = autoIndexHtml + '\n' + body;
761
+ }
762
+ }
763
+ }
764
+ }
765
+
766
+ // Find all style.css files up the tree and bundle them into a single CSS file per folder path
767
+ // (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
768
+ let styleLink = "";
769
+ try {
770
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
771
+ const folderRelative = (dir === "/" || dir === "") ? "" : dir;
772
+
773
+ // Check bundle cache first (dirs with same CSS ancestry share the same bundle)
774
+ let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
775
+ if (cachedBundleUrl !== undefined) {
776
+ if (cachedBundleUrl) {
777
+ styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
778
+ }
779
+ } else {
780
+ let cssPaths = cssPathCache.get(dirKey);
781
+ if (cssPaths === undefined) {
782
+ cssPaths = await findAllStyleCss(dirKey, _source);
783
+ cssPathCache.set(dirKey, cssPaths);
784
+ }
785
+ if (cssPaths.length > 0) {
786
+ // Copy all source CSS files to output (still needed for serve mode fallback)
787
+ for (const cssPath of cssPaths) {
788
+ if (!copiedCssFiles.has(cssPath)) {
789
+ const cssOutputPath = cssPath.replace(source, output);
790
+ const cssContent = await readFile(cssPath, 'utf8');
791
+ await outputFile(cssOutputPath, cssContent);
792
+ copiedCssFiles.add(cssPath);
793
+ }
794
+ }
795
+ // Bundle into a single file
796
+ const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
797
+ docBundleCache.set(`css:${dirKey}`, bundleUrl);
798
+ styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
799
+ } else {
800
+ docBundleCache.set(`css:${dirKey}`, null);
801
+ }
802
+ }
803
+ } catch (e) {
804
+ // ignore
805
+ console.error(e);
806
+ }
807
+
808
+ // Find all script.js files from docroot to current dir and bundle them
809
+ // (Generate mode: one JS bundle per unique folder, external not inlined)
810
+ let customScript = "";
811
+ try {
812
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
813
+ const folderRelative = (dir === "/" || dir === "") ? "" : dir;
814
+
815
+ let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
816
+ if (cachedBundleUrl !== undefined) {
817
+ if (cachedBundleUrl) {
818
+ customScript = `<script src="${cachedBundleUrl}"></script>`;
819
+ }
820
+ } else {
821
+ let scriptPaths = scriptPathCache.get(dirKey);
822
+ if (scriptPaths === undefined) {
823
+ scriptPaths = await findAllScriptJs(dirKey, _source);
824
+ scriptPathCache.set(dirKey, scriptPaths);
825
+ }
826
+ if (scriptPaths.length > 0) {
827
+ const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
828
+ docBundleCache.set(`js:${dirKey}`, bundleUrl);
829
+ customScript = `<script src="${bundleUrl}"></script>`;
830
+ } else {
831
+ docBundleCache.set(`js:${dirKey}`, null);
832
+ }
833
+ }
834
+ } catch (e) {
835
+ // ignore
836
+ console.error(e);
837
+ }
838
+
839
+ const requestedTemplateName = fileMeta && fileMeta.template;
840
+ const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
841
+ const template = templates[templateName];
842
+
843
+ if (!template) {
844
+ throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
845
+ }
846
+
847
+ // Register this document's dependencies for invalidation tracking
848
+ {
849
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
850
+ const cssDeps = cssPathCache.get(dirKey) || [];
851
+ const jsDeps = scriptPathCache.get(dirKey) || [];
852
+ dependencyTracker.registerDocument(file, {
853
+ templateName,
854
+ cssPaths: cssDeps,
855
+ scriptPaths: jsDeps,
856
+ });
857
+ }
858
+
859
+ // Check if this file has a custom menu
860
+ const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
861
+
862
+ // Lazy evaluation of transformed metadata - only compute if template uses it
863
+ // This defers expensive custom transform function loading until actually needed
864
+ const templateUsesTransformedMeta = template.includes('${transformedMetadata}');
865
+ const lazyTransformedMeta = templateUsesTransformedMeta
866
+ ? await getTransformedMeta()
867
+ : '';
868
+
869
+ // Build final HTML with all replacements in a single regex pass
870
+ // This avoids creating 8 intermediate strings
871
+ // Append hydration script to customScript if present (for MDX with hydrate: true)
872
+ const finalCustomScript = hydrationScript
873
+ ? customScript + '\n' + hydrationScript
874
+ : customScript;
875
+
876
+ const replacements = {
877
+ "${title}": fileMeta?.title || title,
878
+ "${menu}": menu,
879
+ "${meta}": JSON.stringify(fileMeta),
880
+ "${transformedMetadata}": lazyTransformedMeta,
881
+ "${body}": body,
882
+ "${styleLink}": styleLink,
883
+ "${customScript}": finalCustomScript,
884
+ "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
885
+ "${footer}": footer
886
+ };
887
+ // Single-pass replacement using regex alternation
888
+ const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
889
+ let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
890
+
891
+ // Add menu data attributes to body
892
+ if (customMenuInfo) {
893
+ const menuPosition = customMenuInfo.menuPosition || 'top';
894
+ finalHtml = finalHtml.replace(
895
+ /<body([^>]*)>/,
896
+ `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
897
+ );
898
+ } else {
899
+ // No custom menu — default to top menu
900
+ finalHtml = finalHtml.replace(
901
+ /<body([^>]*)>/,
902
+ `<body$1 data-menu-position="top">`
903
+ );
904
+ }
905
+
906
+ // Resolve relative URLs in raw HTML elements (img src, etc.)
907
+ finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
908
+
909
+ // Resolve links and mark broken internal links as inactive
910
+ finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
911
+
912
+ // Transform image tags to use preview images with data-fullsrc for originals
913
+ // Skip in deferred mode - images will use original paths until preview generation completes
914
+ if (!_deferImages) {
915
+ finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
916
+ }
917
+
918
+ // Add cache-busting timestamps to static file references
919
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
920
+
921
+ await outputFile(outputFilename, finalHtml);
922
+
923
+ // Clear finalHtml reference to allow GC
924
+ finalHtml = null;
925
+
926
+ // JSON output
927
+ const jsonOutputFilename = outputFilename.replace(".html", ".json");
928
+
929
+ // Extract sections for markdown files
930
+ const sections = (type === '.md' || type === '.mdx') ? extractSections(rawBody) : [];
931
+
932
+ // Use lazy metadata for JSON output - may have been computed above for HTML
933
+ const jsonTransformedMeta = await getTransformedMeta();
934
+
935
+ const jsonObject = {
936
+ name: base,
937
+ url,
938
+ contents: rawBody,
939
+ bodyHtml: body,
940
+ metadata: fileMeta,
941
+ sections,
942
+ transformedMetadata: jsonTransformedMeta,
943
+ _ursa_metadata: ursaMetadata,
944
+ };
945
+
946
+ // Store minimal data for directory indices, including metadata
947
+ dirIndexCache.set(file, {
948
+ name: base,
949
+ url,
950
+ metadata: fileMeta,
951
+ });
952
+
953
+ const json = JSON.stringify(jsonObject);
954
+ await outputFile(jsonOutputFilename, json);
955
+
956
+ // XML output
957
+ const xmlOutputFilename = outputFilename.replace(".html", ".xml");
958
+ const xml = `<article>${o2x(jsonObject)}</article>`;
959
+ await outputFile(xmlOutputFilename, xml);
960
+
961
+ // Update the content hash for this file
962
+ updateHash(file, rawBody, hashCache);
963
+
964
+ // Update content timestamp since this file was regenerated (content changed)
965
+ contentTimestamps.set(relativePath, buildTimestamp);
966
+ // Also update the recentActivity entry we pushed earlier with the new timestamp
967
+ const activityEntry = recentActivity.find(e => e.url === searchUrl);
968
+ if (activityEntry) {
969
+ activityEntry.mtime = buildTimestamp;
970
+ }
971
+ } catch (e) {
972
+ progress.log(`Error processing ${file}: ${e.message}`);
973
+ errors.push({ file, phase: 'article-generation', error: e });
974
+ }
975
+ });
976
+
977
+ // Complete the articles status line
978
+ progress.done('Articles', `${totalArticles} done (${regeneratedCount} regenerated, ${skippedCount} unchanged) [${progress.stopTimer('Articles')}]`);
979
+ profiler.endPhase('Process articles');
980
+
981
+ // Phase: Write search index
982
+ // Can be deferred in serve mode for faster startup
983
+ let deferredSearchIndexPromise = null;
984
+
985
+ const buildSearchIndex = async () => {
986
+ const indexStartTime = Date.now();
987
+ progress.logTimed('Building search index...');
988
+
989
+ // Write search index as a separate JSON file (not embedded in each page)
990
+ const searchIndexPath = join(output, 'public', 'search-index.json');
991
+ progress.log(`Writing search index with ${searchIndex.length} entries`);
992
+ await outputFile(searchIndexPath, JSON.stringify(searchIndex));
993
+
994
+ // Build full-text index - use incremental mode when possible
995
+ let fullTextIndex;
996
+ if (!_clean && changedPaths.size > 0 && changedPaths.size < fullTextDocs.length) {
997
+ // Incremental update: only re-index changed documents
998
+ progress.log(`Incremental full-text index: ${changedPaths.size} changed, ${fullTextDocs.length - changedPaths.size} cached`);
999
+ fullTextIndex = buildIncrementalIndex(fullTextDocs, changedPaths, source);
1000
+ } else {
1001
+ // Full rebuild: clean build or all documents changed
1002
+ progress.log(`Building full-text index from ${fullTextDocs.length} documents...`);
1003
+ fullTextIndex = buildFullTextIndex(fullTextDocs);
1004
+ // Save to cache for future incremental updates
1005
+ saveIndexCache(source, fullTextIndex);
1006
+ }
1007
+
1008
+ const fullTextIndexPath = join(output, 'public', 'fulltext-index.json');
1009
+ const fullTextIndexJson = JSON.stringify(fullTextIndex);
1010
+ const wordCount = Object.keys(fullTextIndex).length;
1011
+ progress.log(`Writing full-text index (${wordCount} unique words, ${(fullTextIndexJson.length / 1024).toFixed(1)} KB)`);
1012
+ await outputFile(fullTextIndexPath, fullTextIndexJson);
1013
+
1014
+ const elapsed = ((Date.now() - indexStartTime) / 1000).toFixed(1);
1015
+ return { entries: searchIndex.length, words: wordCount, elapsed };
1016
+ };
1017
+
1018
+ if (_deferSearchIndex) {
1019
+ // Deferred mode: start building in background, return promise
1020
+ profiler.startPhase('Write search index (deferred)');
1021
+ progress.startTimer('Search index');
1022
+ progress.log('Search index building deferred for faster startup...');
1023
+ deferredSearchIndexPromise = buildSearchIndex().then(result => {
1024
+ progress.done('Search index (background)', `${result.entries} entries, ${result.words} words in ${result.elapsed}s`);
1025
+ return result;
1026
+ });
1027
+ progress.done('Search index', 'deferred [0ms]');
1028
+ profiler.endPhase('Write search index (deferred)');
1029
+ } else {
1030
+ // Normal mode: build search index now
1031
+ profiler.startPhase('Write search index');
1032
+ progress.startTimer('Search index');
1033
+ const result = await buildSearchIndex();
1034
+ progress.done('Search index', `${result.entries} entries, ${result.words} words [${progress.stopTimer('Search index')}]`);
1035
+ profiler.endPhase('Write search index');
1036
+ }
1037
+
1038
+ // Phase: Write recent activity data
1039
+ profiler.startPhase('Write recent activity');
1040
+ progress.startTimer('Recent activity');
1041
+ // Sort by mtime descending, keep top 10
1042
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
1043
+ const top10 = recentActivity.slice(0, 10);
1044
+ const recentActivityPath = join(output, 'public', 'recent-activity.json');
1045
+ await outputFile(recentActivityPath, JSON.stringify(top10));
1046
+ progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
1047
+ profiler.endPhase('Write recent activity');
1048
+
1049
+ // Phase: Write menu data
1050
+ profiler.startPhase('Write menu data');
1051
+ progress.startTimer('Menu data');
1052
+ // Write menu data as a separate JSON file (not embedded in each page)
1053
+ // This dramatically reduces HTML file sizes for large sites
1054
+ const menuDataPath = join(output, 'public', 'menu-data.json');
1055
+ const menuDataJson = JSON.stringify(menuData);
1056
+ progress.log(`Writing menu data (${(menuDataJson.length / 1024).toFixed(1)} KB)`);
1057
+ await outputFile(menuDataPath, menuDataJson);
1058
+
1059
+ // Write custom menu JSON files
1060
+ for (const [menuDir, menuInfo] of customMenus) {
1061
+ const customMenuPath = join(output, menuInfo.menuJsonPath);
1062
+ // Include menuPosition in the JSON so client knows how to render
1063
+ const customMenuJson = JSON.stringify({
1064
+ menuData: menuInfo.menuData,
1065
+ menuPosition: menuInfo.menuPosition || 'top',
1066
+ });
1067
+ progress.log(`Writing custom menu: ${menuInfo.menuJsonPath}`);
1068
+ await outputFile(customMenuPath, customMenuJson);
1069
+ }
1070
+ progress.done('Menu data', `${customMenus.size + 1} files [${progress.stopTimer('Menu data')}]`);
1071
+ profiler.endPhase('Write menu data');
1072
+
1073
+ // Phase: Process directory indices
1074
+ profiler.startPhase('Process directories');
1075
+ progress.startTimer('Directories');
1076
+
1077
+ // Output paths that a source document already owns. The generated directory
1078
+ // listing below writes to <dir>.html, which collides with two things: an
1079
+ // article named after its own folder (settings/dying-light.md renders to
1080
+ // settings/dying-light.html) and a hand-written .html copied from the source
1081
+ // tree. Those documents win — the listing must never overwrite them.
1082
+ const documentOwnedOutputs = new Set(
1083
+ allSourceFilenamesThatAreArticles.map((filename) =>
1084
+ filename.replace(source, output).replace(/\.(md|mdx|txt|yml)$/, ".html")
1085
+ )
1086
+ );
1087
+ for (const relativeHtmlPath of existingHtmlFiles) {
1088
+ documentOwnedOutputs.add(join(output, relativeHtmlPath));
1089
+ }
1090
+
1091
+ // Process directory indices with batched concurrency
1092
+ const totalDirs = allSourceFilenamesThatAreDirectories.length;
1093
+ let processedDirs = 0;
1094
+ progress.log(`Processing ${totalDirs} directories...`);
1095
+ await processBatched(allSourceFilenamesThatAreDirectories, async (dirPath) => {
1096
+ try {
1097
+ processedDirs++;
1098
+ const shortDir = dirPath.replace(source, '');
1099
+ progress.status('Directories', `${processedDirs}/${totalDirs} ${shortDir}`);
1100
+
1101
+ const pathsInThisDirectory = allSourceFilenames.filter((filename) =>
1102
+ filename.match(new RegExp(`${dirPath}.+`))
1103
+ );
1104
+
1105
+ // Use minimal directory index cache instead of full jsonCache
1106
+ const jsonObjects = pathsInThisDirectory
1107
+ .map((path) => {
1108
+ const object = dirIndexCache.get(path);
1109
+ return typeof object === "object" ? object : null;
1110
+ })
1111
+ .filter((a) => a);
1112
+
1113
+ const json = JSON.stringify(jsonObjects);
1114
+
1115
+ const outputFilename = dirPath.replace(source, output) + ".json";
1116
+ await outputFile(outputFilename, json);
1117
+
1118
+ // html
1119
+ // Rewritten every build: the listing reflects the directory's contents,
1120
+ // so skipping it whenever the file already exists (the old behaviour)
1121
+ // froze it at whatever the tree looked like the first time it was
1122
+ // written, and new or removed documents never showed up again.
1123
+ const htmlOutputFilename = dirPath.replace(source, output) + ".html";
1124
+ if (!documentOwnedOutputs.has(htmlOutputFilename)) {
1125
+ const template = templates["default-template"];
1126
+ const indexHtml = `<ul>${pathsInThisDirectory
1127
+ .map((path) => {
1128
+ const partialPath = path
1129
+ .replace(source, "")
1130
+ .replace(parse(path).ext, ".html");
1131
+ const name = basename(path, parse(path).ext);
1132
+ return `<li><a href="${partialPath}">${name}</a></li>`;
1133
+ })
1134
+ .join("")}</ul>`;
1135
+ let finalHtml = template;
1136
+ const replacements = {
1137
+ "${menu}": menu,
1138
+ "${body}": indexHtml,
1139
+ "${searchIndex}": "[]", // Search index now in separate file
1140
+ "${title}": "Index",
1141
+ "${meta}": "{}",
1142
+ "${transformedMetadata}": "",
1143
+ "${styleLink}": "",
1144
+ "${footer}": footer
1145
+ };
1146
+ for (const [key, value] of Object.entries(replacements)) {
1147
+ finalHtml = finalHtml.replace(key, value);
1148
+ }
1149
+ // Add cache-busting timestamps to static file references
1150
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
1151
+ await outputFile(htmlOutputFilename, finalHtml);
1152
+ }
1153
+ } catch (e) {
1154
+ progress.log(`Error processing directory ${dirPath}: ${e.message}`);
1155
+ errors.push({ file: dirPath, phase: 'directory-index', error: e });
1156
+ }
1157
+ });
1158
+
1159
+ progress.done('Directories', `${totalDirs} done [${progress.stopTimer('Directories')}]`);
1160
+ profiler.endPhase('Process directories');
1161
+
1162
+ // Clear directory index cache to free memory before processing static files
1163
+ dirIndexCache.clear();
1164
+
1165
+ // Phase: Process static files
1166
+ profiler.startPhase('Process static files');
1167
+ progress.startTimer('Static files');
1168
+ // Copy static HTML files (images were already processed above with preview generation)
1169
+ // Note: Images are processed before articles to enable preview transformation in HTML
1170
+
1171
+ // Also copy existing HTML files from source to output (they're treated as static)
1172
+ const allSourceFilenamesThatAreHtml = allSourceFilenames.filter(
1173
+ (filename) => filename.match(/\.html$/) && !isHiddenOrSystem(filename)
1174
+ );
1175
+
1176
+ // Fonts, audio, video, PDFs: copied through untouched. Images are handled
1177
+ // separately above because they also get previews; everything else that is
1178
+ // neither an article nor a stylesheet belongs here. Leaving this out is what
1179
+ // let `ursa serve` and `ursa generate` disagree — serve reads these straight
1180
+ // off disk, so the missing copy step only ever showed up in a built site.
1181
+ const allSourceFilenamesThatAreMedia = allSourceFilenames.filter(
1182
+ (filename) => isMedia(filename) && !isHiddenOrSystem(filename)
1183
+ );
1184
+
1185
+ const allStaticFiles = [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
1186
+ const totalStatic = allStaticFiles.length;
1187
+ let processedStatic = 0;
1188
+ let copiedStatic = 0;
1189
+ progress.log(
1190
+ `Processing ${totalStatic} static files ` +
1191
+ `(${allSourceFilenamesThatAreHtml.length} HTML, ${allSourceFilenamesThatAreMedia.length} media)...`
1192
+ );
1193
+ await processBatched(allStaticFiles, async (file) => {
1194
+ try {
1195
+ processedStatic++;
1196
+ const shortFile = file.replace(source, '');
1197
+ progress.status('Static files', `${processedStatic}/${totalStatic} ${shortFile}`);
1198
+
1199
+ // Check if file has changed using file stat as a quick check
1200
+ const fileStat = await stat(file);
1201
+ const statKey = `${file}:stat`;
1202
+ const newStatHash = `${fileStat.size}:${fileStat.mtimeMs}`;
1203
+ const outputFilename = file.replace(source, output);
1204
+ // As with articles: an unchanged stat only means the source is untouched,
1205
+ // so the output must exist before this copy can be skipped.
1206
+ if (hashCache.get(statKey) === newStatHash && outputsExist([outputFilename])) {
1207
+ return; // Skip unchanged static file
1208
+ }
1209
+ hashCache.set(statKey, newStatHash);
1210
+ copiedStatic++;
1211
+
1212
+ await mkdir(dirname(outputFilename), { recursive: true });
1213
+
1214
+ if (file.endsWith('.css')) {
1215
+ // Process CSS for cache busting
1216
+ const cssContent = await readFile(file, 'utf8');
1217
+ const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
1218
+ await outputFile(outputFilename, processedCss);
1219
+ } else if (file.endsWith('.html')) {
1220
+ // Process HTML files for link resolution
1221
+ let htmlContent = await readFile(file, 'utf8');
1222
+ // Calculate the document's URL path for relative link resolution
1223
+ const docUrlPath = '/' + file.replace(source, '').replace(/^\//, '');
1224
+ // Resolve relative URLs in raw HTML elements (img src, etc.)
1225
+ htmlContent = resolveRelativeUrls(htmlContent, docUrlPath);
1226
+ // Resolve internal links to have proper .html extensions
1227
+ htmlContent = markInactiveLinks(htmlContent, validPaths, docUrlPath, false);
1228
+ // Transform image tags to use preview images with data-fullsrc for originals
1229
+ // Skip in deferred mode - images will use original paths until preview generation completes
1230
+ if (!_deferImages) {
1231
+ htmlContent = transformImageTags(htmlContent, imageMap, docUrlPath);
1232
+ }
1233
+ // Add cache-busting timestamps
1234
+ htmlContent = addTimestampToHtmlStaticRefs(htmlContent, cacheBustTimestamp);
1235
+ await outputFile(outputFilename, htmlContent);
1236
+ } else {
1237
+ await copyFile(file, outputFilename);
1238
+ }
1239
+ } catch (e) {
1240
+ progress.log(`Error processing static file ${file}: ${e.message}`);
1241
+ errors.push({ file, phase: 'static-file', error: e });
1242
+ }
1243
+ });
1244
+
1245
+ progress.done('Static files', `${totalStatic} done (${copiedStatic} copied) [${progress.stopTimer('Static files')}]`);
1246
+ profiler.endPhase('Process static files');
1247
+
1248
+ // Phase: Auto-index generation
1249
+ profiler.startPhase('Auto-index generation');
1250
+ progress.startTimer('Auto-index');
1251
+ // Automatic index generation for folders without index.html
1252
+ progress.log(`Checking for missing index files...`);
1253
+ await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus);
1254
+ progress.done('Auto-index', `checked ${allSourceFilenamesThatAreDirectories.length} directories [${progress.stopTimer('Auto-index')}]`);
1255
+ profiler.endPhase('Auto-index generation');
1256
+
1257
+ // Phase: Finalization
1258
+ profiler.startPhase('Finalization');
1259
+ progress.startTimer('Finalization');
1260
+ // Save the hash cache to .ursa folder in source directory
1261
+ if (hashCache.size > 0) {
1262
+ await saveHashCache(source, hashCache);
1263
+ }
1264
+
1265
+ // Save content timestamps to .ursa.json (tracks when content actually changed)
1266
+ if (contentTimestamps.size > 0) {
1267
+ saveContentTimestamps(source, contentTimestamps);
1268
+ progress.log(`Saved ${contentTimestamps.size} content timestamps`);
1269
+ }
1270
+
1271
+ // Persist the dependency tracker so hash-skipped documents keep their
1272
+ // edges on the next warm start (invalidation plans stay accurate)
1273
+ await saveDependencyTracker(source);
1274
+
1275
+ // Populate watch mode cache for fast single-file regeneration
1276
+ watchModeCache.templates = templates;
1277
+ watchModeCache.menu = menu;
1278
+ watchModeCache.footer = footer;
1279
+ watchModeCache.validPaths = validPaths;
1280
+ watchModeCache.source = source;
1281
+ watchModeCache.meta = meta;
1282
+ watchModeCache.output = output;
1283
+ watchModeCache.hashCache = hashCache;
1284
+ watchModeCache.cacheBustTimestamp = cacheBustTimestamp;
1285
+ watchModeCache.cacheBustHashes = cacheBustHashes;
1286
+ watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
1287
+ watchModeCache.imageMap = imageMap;
1288
+ watchModeCache.customMenus = customMenus;
1289
+ watchModeCache.ursaMetadata = ursaMetadata;
1290
+ watchModeCache.lastFullBuild = Date.now();
1291
+ watchModeCache.isInitialized = true;
1292
+ const depStats = dependencyTracker.getStats();
1293
+ progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
1294
+
1295
+ // Write error report if there were any errors
1296
+ if (errors.length > 0) {
1297
+ const errorReportPath = join(output, '_errors.log');
1298
+ const failedFiles = errors.map(e => e.file);
1299
+
1300
+ let report = `URSA GENERATION ERROR REPORT\n`;
1301
+ report += `Generated: ${new Date().toISOString()}\n`;
1302
+ report += `Total errors: ${errors.length}\n\n`;
1303
+ report += `${'='.repeat(60)}\n`;
1304
+ report += `FAILED FILES:\n`;
1305
+ report += `${'='.repeat(60)}\n\n`;
1306
+ failedFiles.forEach(f => {
1307
+ report += ` - ${f}\n`;
1308
+ });
1309
+ report += `\n${'='.repeat(60)}\n`;
1310
+ report += `ERROR DETAILS:\n`;
1311
+ report += `${'='.repeat(60)}\n\n`;
1312
+
1313
+ errors.forEach(({ file, phase, error }) => {
1314
+ report += `${'─'.repeat(60)}\n`;
1315
+ report += `File: ${file}\n`;
1316
+ report += `Phase: ${phase}\n`;
1317
+ report += `Error: ${error.message}\n`;
1318
+ if (error.stack) {
1319
+ report += `Stack:\n${error.stack}\n`;
1320
+ }
1321
+ report += `\n`;
1322
+ });
1323
+
1324
+ await outputFile(errorReportPath, report);
1325
+ progress.log(`\n⚠️ ${errors.length} error(s) occurred during generation.`);
1326
+ progress.log(` Error report written to: ${errorReportPath}\n`);
1327
+ } else {
1328
+ progress.log(`\n✅ Generation complete with no errors.\n`);
1329
+ }
1330
+
1331
+ progress.done('Finalization', `complete [${progress.stopTimer('Finalization')}]`);
1332
+ profiler.endPhase('Finalization');
1333
+
1334
+ // Print profiler report
1335
+ progress.log(profiler.report());
1336
+
1337
+ // Terminate worker pool so threads don't keep the process alive
1338
+ await terminateParserPool();
1339
+
1340
+ // Return deferred processing promises if in deferred mode
1341
+ // Caller can await these to know when background processing is complete
1342
+ return {
1343
+ deferredImageProcessing: deferredImageProcessingPromise,
1344
+ deferredSearchIndex: deferredSearchIndexPromise
1345
+ };
1346
+ }
1347
+
1348
+ /**
1349
+ * Regenerate multiple documents affected by a dependency change (e.g., style.css, script.js, template).
1350
+ * Uses the watchModeCache and dependency tracker to efficiently re-render affected documents
1351
+ * with updated cache-bust timestamps.
1352
+ *
1353
+ * @param {string[]} documentPaths - Absolute paths to documents to regenerate
1354
+ * @param {Object} options
1355
+ * @param {string} options._source - Source directory
1356
+ * @param {string} options._meta - Meta directory
1357
+ * @param {string} options._output - Output directory
1358
+ * @param {string} [options.reason] - Reason for regeneration (for logging)
1359
+ * @param {string[]} [options.priorityPaths] - Document paths to regenerate first (e.g. client-viewed docs)
1360
+ * @param {function} [options.onPriorityComplete] - Callback after priority paths are done (receives { regenerated, failed })
1361
+ * @returns {Promise<{success: boolean, message: string, regenerated: number, failed: number}>}
1362
+ */
1363
+ export async function regenerateAffectedDocuments(documentPaths, {
1364
+ _source,
1365
+ _meta,
1366
+ _output,
1367
+ reason = "dependency change",
1368
+ priorityPaths = [],
1369
+ onPriorityComplete = null,
1370
+ } = {}) {
1371
+ const startTime = Date.now();
1372
+
1373
+ if (!watchModeCache.isInitialized) {
1374
+ return { success: false, message: "Cache not initialized - need full build first", regenerated: 0, failed: 0 };
1375
+ }
1376
+
1377
+ if (documentPaths.length === 0) {
1378
+ return { success: true, message: "No documents to regenerate", regenerated: 0, failed: 0 };
1379
+ }
1380
+
1381
+ // Generate a fresh cache-bust timestamp for this invalidation pass
1382
+ const newTimestamp = generateCacheBustTimestamp();
1383
+ watchModeCache.cacheBustTimestamp = newTimestamp;
1384
+
1385
+ let regenerated = 0;
1386
+ let failed = 0;
1387
+
1388
+ // Separate priority paths from the rest
1389
+ const prioritySet = new Set(priorityPaths.map(p => resolve(p)));
1390
+ const priorityDocs = documentPaths.filter(p => prioritySet.has(resolve(p)));
1391
+ const remainingDocs = documentPaths.filter(p => !prioritySet.has(resolve(p)));
1392
+
1393
+ if (priorityDocs.length > 0) {
1394
+ console.log(`🔄 Regenerating ${priorityDocs.length} priority documents first, then ${remainingDocs.length} remaining (${reason})`);
1395
+ } else {
1396
+ console.log(`🔄 Regenerating ${documentPaths.length} documents (${reason})`);
1397
+ }
1398
+
1399
+ // Process priority documents first
1400
+ for (const docPath of priorityDocs) {
1401
+ try {
1402
+ const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
1403
+ if (result.success) {
1404
+ regenerated++;
1405
+ } else {
1406
+ console.warn(` ⚠️ ${docPath}: ${result.message}`);
1407
+ failed++;
1408
+ }
1409
+ } catch (e) {
1410
+ console.error(` ❌ ${docPath}: ${e.message}`);
1411
+ failed++;
1412
+ }
1413
+ }
1414
+
1415
+ // Notify caller that priority docs are done (so server can reload those clients immediately)
1416
+ if (priorityDocs.length > 0 && onPriorityComplete) {
1417
+ try {
1418
+ onPriorityComplete({ regenerated, failed, priorityDocs });
1419
+ } catch (e) {
1420
+ console.error(` ⚠️ onPriorityComplete callback error: ${e.message}`);
1421
+ }
1422
+ }
1423
+
1424
+ // Process remaining documents
1425
+ for (const docPath of remainingDocs) {
1426
+ try {
1427
+ const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
1428
+ if (result.success) {
1429
+ regenerated++;
1430
+ } else {
1431
+ console.warn(` ⚠️ ${docPath}: ${result.message}`);
1432
+ failed++;
1433
+ }
1434
+ } catch (e) {
1435
+ console.error(` ❌ ${docPath}: ${e.message}`);
1436
+ failed++;
1437
+ }
1438
+ }
1439
+
1440
+ // Persist updated dependency registrations (e.g. a doc switched templates)
1441
+ if (regenerated > 0) {
1442
+ await saveDependencyTracker(resolve(_source) + "/");
1443
+ }
1444
+
1445
+ const elapsed = Date.now() - startTime;
1446
+ const msg = `Regenerated ${regenerated}/${documentPaths.length} documents in ${elapsed}ms (${reason})${failed > 0 ? `, ${failed} failed` : ""}`;
1447
+ console.log(`✅ ${msg}`);
1448
+ return { success: failed === 0, message: msg, regenerated, failed };
1449
+ }
1450
+
1451
+ /**
1452
+ * Regenerate a single file without scanning the entire source directory.
1453
+ * This is much faster for watch mode - only regenerate what changed.
1454
+ *
1455
+ * @param {string} changedFile - Absolute path to the file that changed
1456
+ * @param {Object} options - Same options as generate()
1457
+ * @returns {Promise<{success: boolean, message: string}>}
1458
+ */
1459
+ export async function regenerateSingleFile(changedFile, {
1460
+ _source,
1461
+ _meta,
1462
+ _output,
1463
+ } = {}) {
1464
+ const startTime = Date.now();
1465
+ const source = resolve(_source) + "/";
1466
+ const meta = resolve(_meta);
1467
+ const output = resolve(_output) + "/";
1468
+
1469
+ // Check if this is an article file we can regenerate
1470
+ const articleExtensions = /\.(md|mdx|txt|yml)$/;
1471
+ if (!changedFile.match(articleExtensions)) {
1472
+ return { success: false, message: `Not an article file: ${changedFile}` };
1473
+ }
1474
+
1475
+ // Check if cache is initialized
1476
+ if (!watchModeCache.isInitialized) {
1477
+ return { success: false, message: 'Cache not initialized - need full build first' };
1478
+ }
1479
+
1480
+ // Verify paths match cached paths
1481
+ if (watchModeCache.source !== source || watchModeCache.output !== output) {
1482
+ return { success: false, message: 'Paths changed - need full rebuild' };
1483
+ }
1484
+
1485
+ try {
1486
+ const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus, ursaMetadata } = watchModeCache;
1487
+
1488
+ const rawBody = await readFile(changedFile, "utf8");
1489
+ const type = parse(changedFile).ext;
1490
+ const ext = extname(changedFile);
1491
+ const base = basename(changedFile, ext);
1492
+ const dir = addTrailingSlash(dirname(changedFile)).replace(source, "");
1493
+
1494
+ // Calculate output paths
1495
+ const outputFilename = changedFile
1496
+ .replace(source, output)
1497
+ .replace(parse(changedFile).ext, ".html");
1498
+ const url = '/' + outputFilename.replace(output, '');
1499
+
1500
+ // Title from filename (for index/home, use parent folder name)
1501
+ const titleBase = (base === 'index' || base === 'home') ? basename(dirname(changedFile)) : base;
1502
+ const title = toTitleCase(titleBase || base);
1503
+
1504
+ // Extract metadata
1505
+ const fileMeta = extractMetadata(rawBody);
1506
+ const transformedMetadata = await getTransformedMetadata(
1507
+ dirname(changedFile),
1508
+ fileMeta
1509
+ );
1510
+
1511
+ // Calculate the document's URL path
1512
+ const docUrlPath = '/' + dir + base + '.html';
1513
+
1514
+ // Check if hydration should be enabled for MDX files
1515
+ const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
1516
+
1517
+ // Render body (use async for .mdx, sync for .md/.txt)
1518
+ let body;
1519
+ let hydrationScript = '';
1520
+ if (type === '.mdx') {
1521
+ const renderResult = await renderFileAsync({
1522
+ fileContents: rawBody,
1523
+ type,
1524
+ dirname: dir,
1525
+ basename: base,
1526
+ filePath: changedFile,
1527
+ sourceRoot: source,
1528
+ useWorker: false,
1529
+ hydrate: shouldHydrate,
1530
+ });
1531
+
1532
+ // Handle the result - can be string or { html, hydrationScript }
1533
+ if (typeof renderResult === 'object' && renderResult.html) {
1534
+ body = renderResult.html;
1535
+ hydrationScript = renderResult.hydrationScript || '';
1536
+ } else {
1537
+ body = renderResult;
1538
+ }
1539
+ } else {
1540
+ body = renderFile({
1541
+ fileContents: rawBody,
1542
+ type,
1543
+ dirname: dir,
1544
+ basename: base,
1545
+ });
1546
+ }
1547
+
1548
+ // Inject default H1 if body doesn't start with one
1549
+ if (!body || !body.trimStart().startsWith('<h1')) {
1550
+ const h1Title = fileMeta?.title || title;
1551
+ body = `<h1>${h1Title}</h1>\n` + (body || '');
1552
+ }
1553
+
1554
+ // Inject breadcrumbs before the H1
1555
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
1556
+ if (breadcrumbs) {
1557
+ body = breadcrumbs + body;
1558
+ }
1559
+
1560
+ // Inject frontmatter table for markdown/mdx files
1561
+ if ((type === '.md' || type === '.mdx') && fileMeta) {
1562
+ body = injectFrontmatterTable(body, fileMeta);
1563
+ }
1564
+
1565
+ // Handle auto-index generation for index files with generate-auto-index: true
1566
+ if (base === 'index' && fileMeta) {
1567
+ const autoIndexConfig = getAutoIndexConfig(fileMeta);
1568
+ if (autoIndexConfig.enabled) {
1569
+ // Generate auto-index HTML for this directory from source
1570
+ const sourceDir = dirname(changedFile);
1571
+ const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
1572
+
1573
+ if (autoIndexHtml) {
1574
+ if (autoIndexConfig.position === 'bottom') {
1575
+ body = body + '\n' + autoIndexHtml;
1576
+ } else {
1577
+ body = autoIndexHtml + '\n' + body;
1578
+ }
1579
+ }
1580
+ }
1581
+ }
1582
+
1583
+ // Find all CSS files up the tree and create separate link tags
1584
+ // (regenerateSingleFile is used in serve mode, so separate tags per level for invalidation)
1585
+ let styleLink = "";
1586
+ let cssPaths = [];
1587
+ try {
1588
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1589
+ cssPaths = await findAllStyleCss(dirKey, _source);
1590
+ if (cssPaths.length > 0) {
1591
+ // Copy all CSS files to output (always copy in single-file mode to ensure up to date)
1592
+ for (const cssPath of cssPaths) {
1593
+ const cssOutputPath = cssPath.replace(source, output);
1594
+ const cssContent = await readFile(cssPath, 'utf8');
1595
+ await outputFile(cssOutputPath, cssContent);
1596
+ }
1597
+ styleLink = generateSeparateCssTags(cssPaths, source);
1598
+ }
1599
+ } catch (e) {
1600
+ // ignore
1601
+ }
1602
+
1603
+ // Get template
1604
+ const requestedTemplateName = fileMeta && fileMeta.template;
1605
+ const template =
1606
+ templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
1607
+
1608
+ if (!template) {
1609
+ return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
1610
+ }
1611
+
1612
+ // Find all script.js files from docroot to current dir and serve as separate external tags
1613
+ // (Serve mode: separate tags per level for individual invalidation)
1614
+ let customScript = "";
1615
+ let scriptPaths = [];
1616
+ try {
1617
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1618
+ scriptPaths = await findAllScriptJs(dirKey, _source);
1619
+ if (scriptPaths.length > 0) {
1620
+ // Copy all script files to output so they can be served
1621
+ for (const scriptPath of scriptPaths) {
1622
+ const scriptOutputPath = scriptPath.replace(source, output);
1623
+ const scriptContent = await readFile(scriptPath, 'utf8');
1624
+ await outputFile(scriptOutputPath, scriptContent);
1625
+ }
1626
+ customScript = generateSeparateJsTags(scriptPaths, source);
1627
+ }
1628
+ } catch (e) {
1629
+ // ignore
1630
+ }
1631
+
1632
+ // Register this document's dependencies (template, inherited CSS/JS) so
1633
+ // invalidation plans stay accurate after frontmatter/template changes.
1634
+ // Persisted to .ursa/dependency-graph.json by regenerateAffectedDocuments.
1635
+ const usedTemplateName = (requestedTemplateName && templates[requestedTemplateName])
1636
+ ? requestedTemplateName
1637
+ : DEFAULT_TEMPLATE_NAME;
1638
+ dependencyTracker.registerDocument(changedFile, {
1639
+ templateName: usedTemplateName,
1640
+ cssPaths,
1641
+ scriptPaths,
1642
+ });
1643
+
1644
+ // Check if this file has a custom menu
1645
+ const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
1646
+
1647
+ // Append hydration script to customScript if present (for MDX with hydrate: true)
1648
+ const finalCustomScript = hydrationScript
1649
+ ? customScript + '\n' + hydrationScript
1650
+ : customScript;
1651
+
1652
+ // Build final HTML
1653
+ let finalHtml = template;
1654
+ const replacements = {
1655
+ "${title}": fileMeta?.title || title,
1656
+ "${menu}": menu,
1657
+ "${meta}": JSON.stringify(fileMeta),
1658
+ "${transformedMetadata}": transformedMetadata,
1659
+ "${body}": body,
1660
+ "${styleLink}": styleLink,
1661
+ "${customScript}": finalCustomScript,
1662
+ "${searchIndex}": "[]",
1663
+ "${footer}": footer
1664
+ };
1665
+ for (const [key, value] of Object.entries(replacements)) {
1666
+ finalHtml = finalHtml.replace(key, value);
1667
+ }
1668
+
1669
+ // If this page has a custom menu, add data attributes to body
1670
+ if (customMenuInfo) {
1671
+ const menuPosition = customMenuInfo.menuPosition || 'top';
1672
+ finalHtml = finalHtml.replace(
1673
+ /<body([^>]*)>/,
1674
+ `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
1675
+ );
1676
+ } else {
1677
+ // No custom menu — default to top menu
1678
+ finalHtml = finalHtml.replace(
1679
+ /<body([^>]*)>/,
1680
+ `<body$1 data-menu-position="top">`
1681
+ );
1682
+ }
1683
+
1684
+ // Resolve relative URLs in raw HTML elements (img src, etc.)
1685
+ finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
1686
+
1687
+ // Mark broken links
1688
+ finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
1689
+
1690
+ // Transform image tags to use preview images with data-fullsrc for originals
1691
+ if (imageMap) {
1692
+ finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
1693
+ }
1694
+
1695
+ // Add cache-busting timestamps to static file references
1696
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
1697
+
1698
+ await outputFile(outputFilename, finalHtml);
1699
+
1700
+ // JSON output
1701
+ const jsonOutputFilename = outputFilename.replace(".html", ".json");
1702
+ const sections = (type === '.md' || type === '.mdx') ? extractSections(rawBody) : [];
1703
+ const jsonObject = {
1704
+ name: base,
1705
+ url,
1706
+ contents: rawBody,
1707
+ bodyHtml: body,
1708
+ metadata: fileMeta,
1709
+ sections,
1710
+ transformedMetadata,
1711
+ _ursa_metadata: ursaMetadata,
1712
+ };
1713
+ const json = JSON.stringify(jsonObject);
1714
+ await outputFile(jsonOutputFilename, json);
1715
+
1716
+ // XML output
1717
+ const xmlOutputFilename = outputFilename.replace(".html", ".xml");
1718
+ const xml = `<article>${o2x(jsonObject)}</article>`;
1719
+ await outputFile(xmlOutputFilename, xml);
1720
+
1721
+ // Folder-named index promotion (mirrors autoIndex behavior on full builds):
1722
+ // If the file is named like its parent folder (e.g. aletheia/aletheia.md) and
1723
+ // no explicit index.{md,mdx,txt,yml,html} exists alongside it, also write the
1724
+ // same outputs to <dir>/index.html|json|xml so the canonical URL stays fresh.
1725
+ const sourceDirOfFile = dirname(changedFile);
1726
+ const parentFolderName = basename(sourceDirOfFile);
1727
+ if (base && parentFolderName && base === parentFolderName) {
1728
+ const hasExplicitIndex = ['index.md', 'index.mdx', 'index.txt', 'index.yml', 'index.html']
1729
+ .some(name => existsSync(join(sourceDirOfFile, name)));
1730
+ if (!hasExplicitIndex) {
1731
+ const outDirOfFile = dirname(outputFilename);
1732
+ await outputFile(join(outDirOfFile, 'index.html'), finalHtml);
1733
+ await outputFile(join(outDirOfFile, 'index.json'), json);
1734
+ await outputFile(join(outDirOfFile, 'index.xml'), xml);
1735
+ }
1736
+ }
1737
+
1738
+ // Update hash cache
1739
+ updateHash(changedFile, rawBody, hashCache);
1740
+
1741
+ // Update recent-activity.json with this file's new content timestamp
1742
+ try {
1743
+ const now = Date.now();
1744
+ const recentActivityPath = join(output, 'public', 'recent-activity.json');
1745
+ let recentActivity = [];
1746
+ try {
1747
+ const existing = await readFile(recentActivityPath, 'utf8');
1748
+ recentActivity = JSON.parse(existing);
1749
+ } catch (e) { /* no existing file, start fresh */ }
1750
+ // Remove old entry for this URL if present
1751
+ recentActivity = recentActivity.filter(r => r.url !== url);
1752
+ // Add updated entry with current timestamp (content changed now)
1753
+ recentActivity.push({ title, url, mtime: now });
1754
+ // Sort by mtime descending, keep top 10
1755
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
1756
+ recentActivity = recentActivity.slice(0, 10);
1757
+ await outputFile(recentActivityPath, JSON.stringify(recentActivity));
1758
+
1759
+ // Also update content timestamp in .ursa.json for persistence
1760
+ const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
1761
+ updateContentTimestamp(source, relativePath, now);
1762
+ } catch (e) {
1763
+ // ignore recent activity update errors
1764
+ }
1765
+
1766
+ const elapsed = Date.now() - startTime;
1767
+ const shortFile = changedFile.replace(source, '');
1768
+ return { success: true, message: `Regenerated ${shortFile} in ${elapsed}ms` };
1769
+ } catch (e) {
1770
+ return { success: false, message: `Error: ${e.message}` };
1771
+ }
1715
1772
  }