@kenjura/ursa 0.93.0 → 0.96.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.
@@ -28,7 +28,8 @@ import {
28
28
  markInactiveLinks,
29
29
  resolveRelativeUrls,
30
30
  } from "../helper/linkValidator.js";
31
- import { getAndIncrementBuildId, loadContentTimestamps, saveContentTimestamps, updateContentTimestamp } from "../helper/ursaConfig.js";
31
+ import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
32
+ import { buildSourceTimestampIndex } from "../helper/sourceTimestamps.js";
32
33
  import { extractSections } from "../helper/sectionExtractor.js";
33
34
  import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
34
35
  import { buildReactRuntime } from "../helper/mdxRenderer.js";
@@ -132,6 +133,26 @@ const progress = new ProgressReporter();
132
133
  const DEFAULT_TEMPLATE_NAME =
133
134
  process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
134
135
 
136
+ /**
137
+ * Build a site from `_source` into `_output`.
138
+ *
139
+ * ## JSON-ONLY MODE (`_jsonOnly`)
140
+ *
141
+ * Emits only the `.json` data files — every document's `<name>.json` and every
142
+ * directory's `<dir>.json` record list — and nothing else. Skipped: HTML, XML,
143
+ * images and their previews, meta/template assets, the React runtime, per-folder
144
+ * CSS/JS bundles, static file copying, the search and full-text indices,
145
+ * recent-activity and menu data, and auto-generated index pages.
146
+ *
147
+ * The emitted JSON is byte-identical to what a full build writes. Every step
148
+ * that is skipped operates on the assembled *page*; the JSON's `bodyHtml` is the
149
+ * pre-template render, which none of them touch.
150
+ *
151
+ * For pipelines that consume ursa's JSON as data rather than publishing a site.
152
+ * Mixing modes against one source tree is safe: the two share a hash cache, and
153
+ * the per-document output check (`expectedOutputs`) asks only for the outputs
154
+ * the current mode emits.
155
+ */
135
156
  export async function generate({
136
157
  _source = join(process.cwd(), "."),
137
158
  _meta = join(process.cwd(), "meta"),
@@ -142,11 +163,12 @@ export async function generate({
142
163
  _clean = false, // When true, ignore cache and regenerate all files
143
164
  _deferImages = false, // When true, copy images without processing, return promise for background processing
144
165
  _deferSearchIndex = false, // When true, return promise for search index building (for faster startup)
166
+ _jsonOnly = false, // When true, emit only the .json data files (see JSON-ONLY MODE below)
145
167
  } = {}) {
146
168
  // Initialize profiler for this build
147
169
  const profiler = getProfiler(true);
148
170
 
149
- console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex });
171
+ console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex, _jsonOnly });
150
172
  const source = resolve(_source) + "/";
151
173
  const meta = resolve(_meta);
152
174
  const output = resolve(_output) + "/";
@@ -203,6 +225,9 @@ export async function generate({
203
225
  profiler.startPhase('Filter & classify');
204
226
  progress.startTimer('Filter');
205
227
 
228
+ // Clear config cache at start of generation to pick up any changes
229
+ clearConfigCache();
230
+
206
231
  // Apply include filter (existing functionality)
207
232
  const includeFilter = process.env.INCLUDE_FILTER
208
233
  ? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
@@ -225,14 +250,23 @@ export async function generate({
225
250
  progress.logTimed(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
226
251
  }
227
252
 
228
- // Clear config cache at start of generation to pick up any changes
229
- clearConfigCache();
230
-
231
- // Helper to check if a path is inside a config-hidden folder
232
- const isInHiddenFolder = (filePath) => {
233
- const dir = dirname(filePath);
234
- return isFolderHidden(dir, source);
235
- };
253
+ // Drop everything inside a folder that config.json marks `hidden: true`.
254
+ //
255
+ // Applied once, to the whole file list, rather than to each category
256
+ // downstream. `hidden` means the folder takes no part in the build at all,
257
+ // and filtering here is the only way to actually mean it: articles,
258
+ // directories, images, fonts and other media, and hand-written HTML are all
259
+ // derived from this list, so each of them inherits the exclusion instead of
260
+ // needing its own check (and instead of silently missing one — images and
261
+ // media used to be copied out of hidden folders for exactly that reason).
262
+ const beforeHiddenCount = allSourceFilenames.length;
263
+ allSourceFilenames = allSourceFilenames.filter(
264
+ (filename) => !isFolderHidden(filename, source)
265
+ );
266
+ const hiddenCount = beforeHiddenCount - allSourceFilenames.length;
267
+ if (hiddenCount > 0) {
268
+ progress.logTimed(`Hidden folders: ${hiddenCount} paths ignored`);
269
+ }
236
270
 
237
271
  // read all articles, process them, copy them to build
238
272
  const articleExtensions = /\.(md|mdx|txt|yml)$/;
@@ -241,12 +275,12 @@ export async function generate({
241
275
  // an empty site when the checkout lives under a dot-directory.
242
276
  const isHiddenOrSystem = (filename) => isHiddenOrSystemPath(filename, source);
243
277
  const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
244
- (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename) && !isInHiddenFolder(filename)
278
+ (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename)
245
279
  );
246
280
  const allSourceFilenamesThatAreDirectories = (await filterAsync(
247
281
  allSourceFilenames,
248
282
  (filename) => isDirectory(filename)
249
- )).filter((filename) => !isHiddenOrSystem(filename) && !isFolderHidden(filename, source));
283
+ )).filter((filename) => !isHiddenOrSystem(filename));
250
284
 
251
285
  // Build set of existing HTML files in source directory (these should not be overwritten)
252
286
  const htmlExtensions = /\.html$/;
@@ -385,17 +419,23 @@ export async function generate({
385
419
  progress.stopTimer('Cache');
386
420
  }
387
421
 
388
- // Load content timestamps from .ursa.json (survives --clean)
389
- // These track when content actually changed, not filesystem mtime
390
- const contentTimestamps = loadContentTimestamps(source);
391
- const buildTimestamp = Date.now();
392
- progress.logTimed(`Loaded ${contentTimestamps.size} content timestamps`);
422
+ // Last-edited times for the recent-activity feed come from git history (or
423
+ // file mtimes outside git), never from when ursa ran, so --clean cannot
424
+ // stamp every document with the build time.
425
+ const sourceTimestamps = await buildSourceTimestampIndex(source, { log: (m) => progress.log(m) });
426
+ progress.logTimed(`Source timestamps: ${sourceTimestamps.source}`);
393
427
  profiler.endPhase('Load cache');
394
428
 
395
429
  // Phase: Copy meta/public files
430
+ //
431
+ // Entirely HTML support: templates, their bundled CSS/JS, the React runtime
432
+ // for MDX hydration, and a cache-bust rewrite over every .css/.js already in
433
+ // the output tree. A JSON-only build renders no page, so none of it is
434
+ // reachable — and the cache-bust pass alone walks the whole output dir.
396
435
  profiler.startPhase('Copy meta files');
397
436
  progress.startTimer('Meta');
398
-
437
+
438
+ if (!_jsonOnly) {
399
439
  // create public folder
400
440
  const pub = join(output, "public");
401
441
  await mkdir(pub, { recursive: true });
@@ -443,6 +483,9 @@ export async function generate({
443
483
  }
444
484
 
445
485
  progress.logTimed(`Meta files copied and processed [${progress.stopTimer('Meta')}]`);
486
+ } else {
487
+ progress.logTimed(`JSON-only: skipped meta assets, template bundles and React runtime [${progress.stopTimer('Meta')}]`);
488
+ }
446
489
  profiler.endPhase('Copy meta files');
447
490
 
448
491
  // Track errors for error report
@@ -463,15 +506,17 @@ export async function generate({
463
506
  // Track CSS files that have been copied to avoid duplicates
464
507
  const copiedCssFiles = new Set();
465
508
 
466
- // Identify all image files from the filtered source list
509
+ // Identify all image files from the filtered source list.
510
+ // A JSON-only build copies and resizes nothing, so the list stays empty and
511
+ // the whitelist reference scan below (which reads every article) is skipped.
467
512
  const imageExtensions = IMAGE_EXTENSIONS;
468
- let allSourceFilenamesThatAreImages = allSourceFilenames.filter(
513
+ let allSourceFilenamesThatAreImages = _jsonOnly ? [] : allSourceFilenames.filter(
469
514
  (filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
470
515
  );
471
516
 
472
517
  // When using a whitelist, also include images referenced by whitelisted documents
473
518
  // This ensures that images used in whitelisted articles are processed even if not explicitly whitelisted
474
- if (_whitelist) {
519
+ if (_whitelist && !_jsonOnly) {
475
520
  progress.logTimed('Scanning whitelisted articles for image references...');
476
521
  const referencedImages = new Set();
477
522
 
@@ -509,7 +554,13 @@ export async function generate({
509
554
  let imageMap = new Map();
510
555
  let deferredImageProcessingPromise = null;
511
556
 
512
- if (_deferImages) {
557
+ if (_jsonOnly) {
558
+ // `bodyHtml` in the JSON is the pre-template render; `transformImageTags`
559
+ // only ever rewrote the assembled page, never this. So skipping image
560
+ // processing leaves the JSON byte-identical to a full build's.
561
+ progress.done('Images', `skipped (JSON-only) [${progress.stopTimer('Images')}]`);
562
+ profiler.endPhase('Process images');
563
+ } else if (_deferImages) {
513
564
  // Fast mode: just copy images without processing, defer preview generation
514
565
  progress.logTimed(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
515
566
  await copyAllImagesFast(
@@ -623,24 +674,11 @@ export async function generate({
623
674
  content: rawBody
624
675
  });
625
676
 
626
- // Collect timestamp for recent activity tracking
627
- // Use stored content timestamp if available, otherwise fall back to file mtime
628
- // Content timestamps track when content actually changed, not filesystem mtime
629
- const storedTimestamp = contentTimestamps.get(relativePath);
630
- let activityTimestamp = storedTimestamp;
631
- if (!activityTimestamp) {
632
- // No stored timestamp - use file mtime as initial value
633
- try {
634
- const fileStat = await stat(file);
635
- activityTimestamp = fileStat.mtimeMs;
636
- } catch (e) {
637
- activityTimestamp = 0;
638
- }
639
- }
677
+ // Collect last-edited time for recent activity tracking
640
678
  recentActivity.push({
641
679
  title: title,
642
680
  url: searchUrl,
643
- mtime: activityTimestamp
681
+ mtime: await sourceTimestamps.get(file)
644
682
  });
645
683
 
646
684
  // Check if a corresponding .html file already exists in source directory
@@ -674,15 +712,23 @@ export async function generate({
674
712
  // An unchanged hash is not enough: the hash cache lives in the source tree
675
713
  // and is shared across output dirs, so also require that every output this
676
714
  // document emits is actually present before skipping it.
715
+ //
716
+ // In JSON-only mode only the .json is required. That is what lets the two
717
+ // modes share one hash cache safely: a JSON-only run after a full build
718
+ // skips (the .json is there and is identical either way), and a full build
719
+ // after a JSON-only run regenerates (the .html and .xml are missing).
720
+ const expectedOutputs = _jsonOnly
721
+ ? [outputFilename.replace(".html", ".json")]
722
+ : [
723
+ outputFilename,
724
+ outputFilename.replace(".html", ".json"),
725
+ outputFilename.replace(".html", ".xml"),
726
+ ];
677
727
  const needsRegen =
678
728
  _clean ||
679
729
  hasAutoIndex ||
680
730
  needsRegeneration(file, rawBody, hashCache) ||
681
- !outputsExist([
682
- outputFilename,
683
- outputFilename.replace(".html", ".json"),
684
- outputFilename.replace(".html", ".xml"),
685
- ]);
731
+ !outputsExist(expectedOutputs);
686
732
 
687
733
  if (!needsRegen) {
688
734
  skippedCount++;
@@ -777,165 +823,171 @@ export async function generate({
777
823
  }
778
824
  }
779
825
 
780
- // Find all style.css files up the tree and bundle them into a single CSS file per folder path
781
- // (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
782
- let styleLink = "";
783
- try {
784
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
785
- const folderRelative = (dir === "/" || dir === "") ? "" : dir;
786
-
787
- // Check bundle cache first (dirs with same CSS ancestry share the same bundle)
788
- let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
789
- if (cachedBundleUrl !== undefined) {
790
- if (cachedBundleUrl) {
791
- styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
792
- }
793
- } else {
794
- let cssPaths = cssPathCache.get(dirKey);
795
- if (cssPaths === undefined) {
796
- cssPaths = await findAllStyleCss(dirKey, _source);
797
- cssPathCache.set(dirKey, cssPaths);
798
- }
799
- if (cssPaths.length > 0) {
800
- // Copy all source CSS files to output (still needed for serve mode fallback)
801
- for (const cssPath of cssPaths) {
802
- if (!copiedCssFiles.has(cssPath)) {
803
- const cssOutputPath = cssPath.replace(source, output);
804
- const cssContent = await readFile(cssPath, 'utf8');
805
- await outputFile(cssOutputPath, cssContent);
806
- copiedCssFiles.add(cssPath);
807
- }
826
+ // Everything from here to the HTML write is page assembly: per-folder
827
+ // CSS/JS bundles, the template, the custom menu, link resolution and
828
+ // image-tag rewriting. The JSON below is built from `body`, which is
829
+ // already final — none of it feeds the JSON, so JSON-only skips it all.
830
+ if (!_jsonOnly) {
831
+ // Find all style.css files up the tree and bundle them into a single CSS file per folder path
832
+ // (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
833
+ let styleLink = "";
834
+ try {
835
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
836
+ const folderRelative = (dir === "/" || dir === "") ? "" : dir;
837
+
838
+ // Check bundle cache first (dirs with same CSS ancestry share the same bundle)
839
+ let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
840
+ if (cachedBundleUrl !== undefined) {
841
+ if (cachedBundleUrl) {
842
+ styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
808
843
  }
809
- // Bundle into a single file
810
- const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
811
- docBundleCache.set(`css:${dirKey}`, bundleUrl);
812
- styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
813
844
  } else {
814
- docBundleCache.set(`css:${dirKey}`, null);
845
+ let cssPaths = cssPathCache.get(dirKey);
846
+ if (cssPaths === undefined) {
847
+ cssPaths = await findAllStyleCss(dirKey, _source);
848
+ cssPathCache.set(dirKey, cssPaths);
849
+ }
850
+ if (cssPaths.length > 0) {
851
+ // Copy all source CSS files to output (still needed for serve mode fallback)
852
+ for (const cssPath of cssPaths) {
853
+ if (!copiedCssFiles.has(cssPath)) {
854
+ const cssOutputPath = cssPath.replace(source, output);
855
+ const cssContent = await readFile(cssPath, 'utf8');
856
+ await outputFile(cssOutputPath, cssContent);
857
+ copiedCssFiles.add(cssPath);
858
+ }
859
+ }
860
+ // Bundle into a single file
861
+ const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
862
+ docBundleCache.set(`css:${dirKey}`, bundleUrl);
863
+ styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
864
+ } else {
865
+ docBundleCache.set(`css:${dirKey}`, null);
866
+ }
815
867
  }
868
+ } catch (e) {
869
+ // ignore
870
+ console.error(e);
816
871
  }
817
- } catch (e) {
818
- // ignore
819
- console.error(e);
820
- }
821
872
 
822
- // Find all script.js files from docroot to current dir and bundle them
823
- // (Generate mode: one JS bundle per unique folder, external not inlined)
824
- let customScript = "";
825
- try {
826
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
827
- const folderRelative = (dir === "/" || dir === "") ? "" : dir;
873
+ // Find all script.js files from docroot to current dir and bundle them
874
+ // (Generate mode: one JS bundle per unique folder, external not inlined)
875
+ let customScript = "";
876
+ try {
877
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
878
+ const folderRelative = (dir === "/" || dir === "") ? "" : dir;
828
879
 
829
- let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
830
- if (cachedBundleUrl !== undefined) {
831
- if (cachedBundleUrl) {
832
- customScript = `<script src="${cachedBundleUrl}"></script>`;
833
- }
834
- } else {
835
- let scriptPaths = scriptPathCache.get(dirKey);
836
- if (scriptPaths === undefined) {
837
- scriptPaths = await findAllScriptJs(dirKey, _source);
838
- scriptPathCache.set(dirKey, scriptPaths);
839
- }
840
- if (scriptPaths.length > 0) {
841
- const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
842
- docBundleCache.set(`js:${dirKey}`, bundleUrl);
843
- customScript = `<script src="${bundleUrl}"></script>`;
880
+ let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
881
+ if (cachedBundleUrl !== undefined) {
882
+ if (cachedBundleUrl) {
883
+ customScript = `<script src="${cachedBundleUrl}"></script>`;
884
+ }
844
885
  } else {
845
- docBundleCache.set(`js:${dirKey}`, null);
886
+ let scriptPaths = scriptPathCache.get(dirKey);
887
+ if (scriptPaths === undefined) {
888
+ scriptPaths = await findAllScriptJs(dirKey, _source);
889
+ scriptPathCache.set(dirKey, scriptPaths);
890
+ }
891
+ if (scriptPaths.length > 0) {
892
+ const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
893
+ docBundleCache.set(`js:${dirKey}`, bundleUrl);
894
+ customScript = `<script src="${bundleUrl}"></script>`;
895
+ } else {
896
+ docBundleCache.set(`js:${dirKey}`, null);
897
+ }
846
898
  }
899
+ } catch (e) {
900
+ // ignore
901
+ console.error(e);
847
902
  }
848
- } catch (e) {
849
- // ignore
850
- console.error(e);
851
- }
852
903
 
853
- const requestedTemplateName = fileMeta && fileMeta.template;
854
- const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
855
- const template = templates[templateName];
904
+ const requestedTemplateName = fileMeta && fileMeta.template;
905
+ const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
906
+ const template = templates[templateName];
856
907
 
857
- if (!template) {
858
- throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
859
- }
908
+ if (!template) {
909
+ throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
910
+ }
860
911
 
861
- // Register this document's dependencies for invalidation tracking
862
- {
863
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
864
- const cssDeps = cssPathCache.get(dirKey) || [];
865
- const jsDeps = scriptPathCache.get(dirKey) || [];
866
- dependencyTracker.registerDocument(file, {
867
- templateName,
868
- cssPaths: cssDeps,
869
- scriptPaths: jsDeps,
870
- });
871
- }
912
+ // Register this document's dependencies for invalidation tracking
913
+ {
914
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
915
+ const cssDeps = cssPathCache.get(dirKey) || [];
916
+ const jsDeps = scriptPathCache.get(dirKey) || [];
917
+ dependencyTracker.registerDocument(file, {
918
+ templateName,
919
+ cssPaths: cssDeps,
920
+ scriptPaths: jsDeps,
921
+ });
922
+ }
872
923
 
873
- // Check if this file has a custom menu
874
- const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
875
-
876
- // Lazy evaluation of transformed metadata - only compute if template uses it
877
- // This defers expensive custom transform function loading until actually needed
878
- const templateUsesTransformedMeta = template.includes('${transformedMetadata}');
879
- const lazyTransformedMeta = templateUsesTransformedMeta
880
- ? await getTransformedMeta()
881
- : '';
882
-
883
- // Build final HTML with all replacements in a single regex pass
884
- // This avoids creating 8 intermediate strings
885
- // Append hydration script to customScript if present (for MDX with hydrate: true)
886
- const finalCustomScript = hydrationScript
887
- ? customScript + '\n' + hydrationScript
888
- : customScript;
924
+ // Check if this file has a custom menu
925
+ const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
926
+
927
+ // Lazy evaluation of transformed metadata - only compute if template uses it
928
+ // This defers expensive custom transform function loading until actually needed
929
+ const templateUsesTransformedMeta = template.includes('${transformedMetadata}');
930
+ const lazyTransformedMeta = templateUsesTransformedMeta
931
+ ? await getTransformedMeta()
932
+ : '';
933
+
934
+ // Build final HTML with all replacements in a single regex pass
935
+ // This avoids creating 8 intermediate strings
936
+ // Append hydration script to customScript if present (for MDX with hydrate: true)
937
+ const finalCustomScript = hydrationScript
938
+ ? customScript + '\n' + hydrationScript
939
+ : customScript;
889
940
 
890
- const replacements = {
891
- "${title}": fileMeta?.title || title,
892
- "${menu}": menu,
893
- "${meta}": JSON.stringify(fileMeta),
894
- "${transformedMetadata}": lazyTransformedMeta,
895
- "${body}": body,
896
- "${styleLink}": styleLink,
897
- "${customScript}": finalCustomScript,
898
- "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
899
- "${footer}": footer
900
- };
901
- // Single-pass replacement using regex alternation
902
- const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
903
- let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
904
-
905
- // Add menu data attributes to body
906
- if (customMenuInfo) {
907
- const menuPosition = customMenuInfo.menuPosition || 'top';
908
- finalHtml = finalHtml.replace(
909
- /<body([^>]*)>/,
910
- `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
911
- );
912
- } else {
913
- // No custom menu — default to top menu
914
- finalHtml = finalHtml.replace(
915
- /<body([^>]*)>/,
916
- `<body$1 data-menu-position="top">`
917
- );
918
- }
941
+ const replacements = {
942
+ "${title}": fileMeta?.title || title,
943
+ "${menu}": menu,
944
+ "${meta}": JSON.stringify(fileMeta),
945
+ "${transformedMetadata}": lazyTransformedMeta,
946
+ "${body}": body,
947
+ "${styleLink}": styleLink,
948
+ "${customScript}": finalCustomScript,
949
+ "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
950
+ "${footer}": footer
951
+ };
952
+ // Single-pass replacement using regex alternation
953
+ const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
954
+ let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
955
+
956
+ // Add menu data attributes to body
957
+ if (customMenuInfo) {
958
+ const menuPosition = customMenuInfo.menuPosition || 'top';
959
+ finalHtml = finalHtml.replace(
960
+ /<body([^>]*)>/,
961
+ `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
962
+ );
963
+ } else {
964
+ // No custom menu — default to top menu
965
+ finalHtml = finalHtml.replace(
966
+ /<body([^>]*)>/,
967
+ `<body$1 data-menu-position="top">`
968
+ );
969
+ }
919
970
 
920
- // Resolve relative URLs in raw HTML elements (img src, etc.)
921
- finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
971
+ // Resolve relative URLs in raw HTML elements (img src, etc.)
972
+ finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
922
973
 
923
- // Resolve links and mark broken internal links as inactive
924
- finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
974
+ // Resolve links and mark broken internal links as inactive
975
+ finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
925
976
 
926
- // Transform image tags to use preview images with data-fullsrc for originals
927
- // Skip in deferred mode - images will use original paths until preview generation completes
928
- if (!_deferImages) {
929
- finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
930
- }
977
+ // Transform image tags to use preview images with data-fullsrc for originals
978
+ // Skip in deferred mode - images will use original paths until preview generation completes
979
+ if (!_deferImages) {
980
+ finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
981
+ }
931
982
 
932
- // Add cache-busting timestamps to static file references
933
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
983
+ // Add cache-busting timestamps to static file references
984
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
934
985
 
935
- await outputFile(outputFilename, finalHtml);
986
+ await outputFile(outputFilename, finalHtml);
936
987
 
937
- // Clear finalHtml reference to allow GC
938
- finalHtml = null;
988
+ // Clear finalHtml reference to allow GC
989
+ finalHtml = null;
990
+ }
939
991
 
940
992
  // JSON output
941
993
  const jsonOutputFilename = outputFilename.replace(".html", ".json");
@@ -968,20 +1020,14 @@ export async function generate({
968
1020
  await outputFile(jsonOutputFilename, json);
969
1021
 
970
1022
  // XML output
971
- const xmlOutputFilename = outputFilename.replace(".html", ".xml");
972
- const xml = `<article>${o2x(jsonObject)}</article>`;
973
- await outputFile(xmlOutputFilename, xml);
1023
+ if (!_jsonOnly) {
1024
+ const xmlOutputFilename = outputFilename.replace(".html", ".xml");
1025
+ const xml = `<article>${o2x(jsonObject)}</article>`;
1026
+ await outputFile(xmlOutputFilename, xml);
1027
+ }
974
1028
 
975
1029
  // Update the content hash for this file
976
1030
  updateHash(file, rawBody, hashCache);
977
-
978
- // Update content timestamp since this file was regenerated (content changed)
979
- contentTimestamps.set(relativePath, buildTimestamp);
980
- // Also update the recentActivity entry we pushed earlier with the new timestamp
981
- const activityEntry = recentActivity.find(e => e.url === searchUrl);
982
- if (activityEntry) {
983
- activityEntry.mtime = buildTimestamp;
984
- }
985
1031
  } catch (e) {
986
1032
  progress.log(`Error processing ${file}: ${e.message}`);
987
1033
  errors.push({ file, phase: 'article-generation', error: e });
@@ -1029,7 +1075,14 @@ export async function generate({
1029
1075
  return { entries: searchIndex.length, words: wordCount, elapsed };
1030
1076
  };
1031
1077
 
1032
- if (_deferSearchIndex) {
1078
+ if (_jsonOnly) {
1079
+ // Search and full-text indices exist for the site's client-side search UI,
1080
+ // which a JSON-only build does not emit. The full-text build is also the
1081
+ // most expensive step after rendering.
1082
+ profiler.startPhase('Write search index');
1083
+ progress.done('Search index', 'skipped (JSON-only)');
1084
+ profiler.endPhase('Write search index');
1085
+ } else if (_deferSearchIndex) {
1033
1086
  // Deferred mode: start building in background, return promise
1034
1087
  profiler.startPhase('Write search index (deferred)');
1035
1088
  progress.startTimer('Search index');
@@ -1052,17 +1105,26 @@ export async function generate({
1052
1105
  // Phase: Write recent activity data
1053
1106
  profiler.startPhase('Write recent activity');
1054
1107
  progress.startTimer('Recent activity');
1055
- // Sort by mtime descending, keep top 10
1056
- recentActivity.sort((a, b) => b.mtime - a.mtime);
1057
- const top10 = recentActivity.slice(0, 10);
1058
- const recentActivityPath = join(output, 'public', 'recent-activity.json');
1059
- await outputFile(recentActivityPath, JSON.stringify(top10));
1060
- progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
1108
+ if (!_jsonOnly) {
1109
+ // Sort by mtime descending, keep top 10
1110
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
1111
+ const top10 = recentActivity.slice(0, 10);
1112
+ const recentActivityPath = join(output, 'public', 'recent-activity.json');
1113
+ await outputFile(recentActivityPath, JSON.stringify(top10));
1114
+ progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
1115
+ } else {
1116
+ progress.done('Recent activity', `skipped (JSON-only) [${progress.stopTimer('Recent activity')}]`);
1117
+ }
1061
1118
  profiler.endPhase('Write recent activity');
1062
1119
 
1063
1120
  // Phase: Write menu data
1064
1121
  profiler.startPhase('Write menu data');
1065
1122
  progress.startTimer('Menu data');
1123
+ if (_jsonOnly) {
1124
+ // menu-data.json and the custom-menu files are read by the page shell's
1125
+ // script at runtime. No pages, no readers.
1126
+ progress.done('Menu data', `skipped (JSON-only) [${progress.stopTimer('Menu data')}]`);
1127
+ } else {
1066
1128
  // Write menu data as a separate JSON file (not embedded in each page)
1067
1129
  // This dramatically reduces HTML file sizes for large sites
1068
1130
  const menuDataPath = join(output, 'public', 'menu-data.json');
@@ -1082,6 +1144,7 @@ export async function generate({
1082
1144
  await outputFile(customMenuPath, customMenuJson);
1083
1145
  }
1084
1146
  progress.done('Menu data', `${customMenus.size + 1} files [${progress.stopTimer('Menu data')}]`);
1147
+ }
1085
1148
  profiler.endPhase('Write menu data');
1086
1149
 
1087
1150
  // Phase: Process directory indices
@@ -1134,8 +1197,11 @@ export async function generate({
1134
1197
  // so skipping it whenever the file already exists (the old behaviour)
1135
1198
  // froze it at whatever the tree looked like the first time it was
1136
1199
  // written, and new or removed documents never showed up again.
1200
+ //
1201
+ // The <dir>.json above is NOT skipped in JSON-only mode: it is the
1202
+ // directory's record list, which is the main thing a data consumer wants.
1137
1203
  const htmlOutputFilename = dirPath.replace(source, output) + ".html";
1138
- if (!documentOwnedOutputs.has(htmlOutputFilename)) {
1204
+ if (!_jsonOnly && !documentOwnedOutputs.has(htmlOutputFilename)) {
1139
1205
  const template = templates["default-template"];
1140
1206
  const indexHtml = `<ul>${pathsInThisDirectory
1141
1207
  .map((path) => {
@@ -1196,7 +1262,10 @@ export async function generate({
1196
1262
  (filename) => isMedia(filename) && !isHiddenOrSystem(filename)
1197
1263
  );
1198
1264
 
1199
- const allStaticFiles = [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
1265
+ // JSON-only emits data, not a servable site, so nothing is copied through.
1266
+ const allStaticFiles = _jsonOnly
1267
+ ? []
1268
+ : [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
1200
1269
  const totalStatic = allStaticFiles.length;
1201
1270
  let processedStatic = 0;
1202
1271
  let copiedStatic = 0;
@@ -1263,9 +1332,15 @@ export async function generate({
1263
1332
  profiler.startPhase('Auto-index generation');
1264
1333
  progress.startTimer('Auto-index');
1265
1334
  // Automatic index generation for folders without index.html
1266
- progress.log(`Checking for missing index files...`);
1267
- await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus);
1268
- progress.done('Auto-index', `checked ${allSourceFilenamesThatAreDirectories.length} directories [${progress.stopTimer('Auto-index')}]`);
1335
+ if (_jsonOnly) {
1336
+ // Auto-indices only ever emit index.html for a folder that has no index
1337
+ // document of its own.
1338
+ progress.done('Auto-index', `skipped (JSON-only) [${progress.stopTimer('Auto-index')}]`);
1339
+ } else {
1340
+ progress.log(`Checking for missing index files...`);
1341
+ await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus);
1342
+ progress.done('Auto-index', `checked ${allSourceFilenamesThatAreDirectories.length} directories [${progress.stopTimer('Auto-index')}]`);
1343
+ }
1269
1344
  profiler.endPhase('Auto-index generation');
1270
1345
 
1271
1346
  // Phase: Finalization
@@ -1276,17 +1351,22 @@ export async function generate({
1276
1351
  await saveHashCache(source, hashCache);
1277
1352
  }
1278
1353
 
1279
- // Save content timestamps to .ursa.json (tracks when content actually changed)
1280
- if (contentTimestamps.size > 0) {
1281
- saveContentTimestamps(source, contentTimestamps);
1282
- progress.log(`Saved ${contentTimestamps.size} content timestamps`);
1283
- }
1284
-
1285
1354
  // Persist the dependency tracker so hash-skipped documents keep their
1286
- // edges on the next warm start (invalidation plans stay accurate)
1287
- await saveDependencyTracker(source);
1355
+ // edges on the next warm start (invalidation plans stay accurate).
1356
+ //
1357
+ // Not in JSON-only mode: it registers nothing (registration lives in the page
1358
+ // assembly it skips), so saving would overwrite a full build's graph with an
1359
+ // empty one. The hash cache is safe to share — see `expectedOutputs` above —
1360
+ // but the dependency graph is not, because nothing rebuilds it.
1361
+ if (!_jsonOnly) {
1362
+ await saveDependencyTracker(source);
1363
+ }
1288
1364
 
1289
- // Populate watch mode cache for fast single-file regeneration
1365
+ // Populate watch mode cache for fast single-file regeneration.
1366
+ // A JSON-only build never bundled the template assets and never processed
1367
+ // images, so seeding the cache from it would make a later single-file
1368
+ // regeneration emit a page with no styles.
1369
+ if (!_jsonOnly) {
1290
1370
  watchModeCache.templates = templates;
1291
1371
  watchModeCache.menu = menu;
1292
1372
  watchModeCache.footer = footer;
@@ -1305,6 +1385,7 @@ export async function generate({
1305
1385
  watchModeCache.isInitialized = true;
1306
1386
  const depStats = dependencyTracker.getStats();
1307
1387
  progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
1388
+ }
1308
1389
 
1309
1390
  // Write error report if there were any errors
1310
1391
  if (errors.length > 0) {
@@ -1752,9 +1833,10 @@ export async function regenerateSingleFile(changedFile, {
1752
1833
  // Update hash cache
1753
1834
  updateHash(changedFile, rawBody, hashCache);
1754
1835
 
1755
- // Update recent-activity.json with this file's new content timestamp
1836
+ // Update recent-activity.json with this file's last-edited time
1756
1837
  try {
1757
- const now = Date.now();
1838
+ const sourceTimestamps = await buildSourceTimestampIndex(source);
1839
+ const now = await sourceTimestamps.get(changedFile);
1758
1840
  const recentActivityPath = join(output, 'public', 'recent-activity.json');
1759
1841
  let recentActivity = [];
1760
1842
  try {
@@ -1763,16 +1845,11 @@ export async function regenerateSingleFile(changedFile, {
1763
1845
  } catch (e) { /* no existing file, start fresh */ }
1764
1846
  // Remove old entry for this URL if present
1765
1847
  recentActivity = recentActivity.filter(r => r.url !== url);
1766
- // Add updated entry with current timestamp (content changed now)
1767
1848
  recentActivity.push({ title, url, mtime: now });
1768
1849
  // Sort by mtime descending, keep top 10
1769
1850
  recentActivity.sort((a, b) => b.mtime - a.mtime);
1770
1851
  recentActivity = recentActivity.slice(0, 10);
1771
1852
  await outputFile(recentActivityPath, JSON.stringify(recentActivity));
1772
-
1773
- // Also update content timestamp in .ursa.json for persistence
1774
- const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
1775
- updateContentTimestamp(source, relativePath, now);
1776
1853
  } catch (e) {
1777
1854
  // ignore recent activity update errors
1778
1855
  }