@kenjura/ursa 0.86.0 → 0.88.0

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