@kenjura/ursa 0.93.0 → 0.95.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.
@@ -132,6 +132,26 @@ const progress = new ProgressReporter();
132
132
  const DEFAULT_TEMPLATE_NAME =
133
133
  process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
134
134
 
135
+ /**
136
+ * Build a site from `_source` into `_output`.
137
+ *
138
+ * ## JSON-ONLY MODE (`_jsonOnly`)
139
+ *
140
+ * Emits only the `.json` data files — every document's `<name>.json` and every
141
+ * directory's `<dir>.json` record list — and nothing else. Skipped: HTML, XML,
142
+ * images and their previews, meta/template assets, the React runtime, per-folder
143
+ * CSS/JS bundles, static file copying, the search and full-text indices,
144
+ * recent-activity and menu data, and auto-generated index pages.
145
+ *
146
+ * The emitted JSON is byte-identical to what a full build writes. Every step
147
+ * that is skipped operates on the assembled *page*; the JSON's `bodyHtml` is the
148
+ * pre-template render, which none of them touch.
149
+ *
150
+ * For pipelines that consume ursa's JSON as data rather than publishing a site.
151
+ * Mixing modes against one source tree is safe: the two share a hash cache, and
152
+ * the per-document output check (`expectedOutputs`) asks only for the outputs
153
+ * the current mode emits.
154
+ */
135
155
  export async function generate({
136
156
  _source = join(process.cwd(), "."),
137
157
  _meta = join(process.cwd(), "meta"),
@@ -142,11 +162,12 @@ export async function generate({
142
162
  _clean = false, // When true, ignore cache and regenerate all files
143
163
  _deferImages = false, // When true, copy images without processing, return promise for background processing
144
164
  _deferSearchIndex = false, // When true, return promise for search index building (for faster startup)
165
+ _jsonOnly = false, // When true, emit only the .json data files (see JSON-ONLY MODE below)
145
166
  } = {}) {
146
167
  // Initialize profiler for this build
147
168
  const profiler = getProfiler(true);
148
169
 
149
- console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex });
170
+ console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex, _jsonOnly });
150
171
  const source = resolve(_source) + "/";
151
172
  const meta = resolve(_meta);
152
173
  const output = resolve(_output) + "/";
@@ -203,6 +224,9 @@ export async function generate({
203
224
  profiler.startPhase('Filter & classify');
204
225
  progress.startTimer('Filter');
205
226
 
227
+ // Clear config cache at start of generation to pick up any changes
228
+ clearConfigCache();
229
+
206
230
  // Apply include filter (existing functionality)
207
231
  const includeFilter = process.env.INCLUDE_FILTER
208
232
  ? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
@@ -225,14 +249,23 @@ export async function generate({
225
249
  progress.logTimed(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
226
250
  }
227
251
 
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
- };
252
+ // Drop everything inside a folder that config.json marks `hidden: true`.
253
+ //
254
+ // Applied once, to the whole file list, rather than to each category
255
+ // downstream. `hidden` means the folder takes no part in the build at all,
256
+ // and filtering here is the only way to actually mean it: articles,
257
+ // directories, images, fonts and other media, and hand-written HTML are all
258
+ // derived from this list, so each of them inherits the exclusion instead of
259
+ // needing its own check (and instead of silently missing one — images and
260
+ // media used to be copied out of hidden folders for exactly that reason).
261
+ const beforeHiddenCount = allSourceFilenames.length;
262
+ allSourceFilenames = allSourceFilenames.filter(
263
+ (filename) => !isFolderHidden(filename, source)
264
+ );
265
+ const hiddenCount = beforeHiddenCount - allSourceFilenames.length;
266
+ if (hiddenCount > 0) {
267
+ progress.logTimed(`Hidden folders: ${hiddenCount} paths ignored`);
268
+ }
236
269
 
237
270
  // read all articles, process them, copy them to build
238
271
  const articleExtensions = /\.(md|mdx|txt|yml)$/;
@@ -241,12 +274,12 @@ export async function generate({
241
274
  // an empty site when the checkout lives under a dot-directory.
242
275
  const isHiddenOrSystem = (filename) => isHiddenOrSystemPath(filename, source);
243
276
  const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
244
- (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename) && !isInHiddenFolder(filename)
277
+ (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename)
245
278
  );
246
279
  const allSourceFilenamesThatAreDirectories = (await filterAsync(
247
280
  allSourceFilenames,
248
281
  (filename) => isDirectory(filename)
249
- )).filter((filename) => !isHiddenOrSystem(filename) && !isFolderHidden(filename, source));
282
+ )).filter((filename) => !isHiddenOrSystem(filename));
250
283
 
251
284
  // Build set of existing HTML files in source directory (these should not be overwritten)
252
285
  const htmlExtensions = /\.html$/;
@@ -393,9 +426,15 @@ export async function generate({
393
426
  profiler.endPhase('Load cache');
394
427
 
395
428
  // Phase: Copy meta/public files
429
+ //
430
+ // Entirely HTML support: templates, their bundled CSS/JS, the React runtime
431
+ // for MDX hydration, and a cache-bust rewrite over every .css/.js already in
432
+ // the output tree. A JSON-only build renders no page, so none of it is
433
+ // reachable — and the cache-bust pass alone walks the whole output dir.
396
434
  profiler.startPhase('Copy meta files');
397
435
  progress.startTimer('Meta');
398
-
436
+
437
+ if (!_jsonOnly) {
399
438
  // create public folder
400
439
  const pub = join(output, "public");
401
440
  await mkdir(pub, { recursive: true });
@@ -443,6 +482,9 @@ export async function generate({
443
482
  }
444
483
 
445
484
  progress.logTimed(`Meta files copied and processed [${progress.stopTimer('Meta')}]`);
485
+ } else {
486
+ progress.logTimed(`JSON-only: skipped meta assets, template bundles and React runtime [${progress.stopTimer('Meta')}]`);
487
+ }
446
488
  profiler.endPhase('Copy meta files');
447
489
 
448
490
  // Track errors for error report
@@ -463,15 +505,17 @@ export async function generate({
463
505
  // Track CSS files that have been copied to avoid duplicates
464
506
  const copiedCssFiles = new Set();
465
507
 
466
- // Identify all image files from the filtered source list
508
+ // Identify all image files from the filtered source list.
509
+ // A JSON-only build copies and resizes nothing, so the list stays empty and
510
+ // the whitelist reference scan below (which reads every article) is skipped.
467
511
  const imageExtensions = IMAGE_EXTENSIONS;
468
- let allSourceFilenamesThatAreImages = allSourceFilenames.filter(
512
+ let allSourceFilenamesThatAreImages = _jsonOnly ? [] : allSourceFilenames.filter(
469
513
  (filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
470
514
  );
471
515
 
472
516
  // When using a whitelist, also include images referenced by whitelisted documents
473
517
  // This ensures that images used in whitelisted articles are processed even if not explicitly whitelisted
474
- if (_whitelist) {
518
+ if (_whitelist && !_jsonOnly) {
475
519
  progress.logTimed('Scanning whitelisted articles for image references...');
476
520
  const referencedImages = new Set();
477
521
 
@@ -509,7 +553,13 @@ export async function generate({
509
553
  let imageMap = new Map();
510
554
  let deferredImageProcessingPromise = null;
511
555
 
512
- if (_deferImages) {
556
+ if (_jsonOnly) {
557
+ // `bodyHtml` in the JSON is the pre-template render; `transformImageTags`
558
+ // only ever rewrote the assembled page, never this. So skipping image
559
+ // processing leaves the JSON byte-identical to a full build's.
560
+ progress.done('Images', `skipped (JSON-only) [${progress.stopTimer('Images')}]`);
561
+ profiler.endPhase('Process images');
562
+ } else if (_deferImages) {
513
563
  // Fast mode: just copy images without processing, defer preview generation
514
564
  progress.logTimed(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
515
565
  await copyAllImagesFast(
@@ -674,15 +724,23 @@ export async function generate({
674
724
  // An unchanged hash is not enough: the hash cache lives in the source tree
675
725
  // and is shared across output dirs, so also require that every output this
676
726
  // document emits is actually present before skipping it.
727
+ //
728
+ // In JSON-only mode only the .json is required. That is what lets the two
729
+ // modes share one hash cache safely: a JSON-only run after a full build
730
+ // skips (the .json is there and is identical either way), and a full build
731
+ // after a JSON-only run regenerates (the .html and .xml are missing).
732
+ const expectedOutputs = _jsonOnly
733
+ ? [outputFilename.replace(".html", ".json")]
734
+ : [
735
+ outputFilename,
736
+ outputFilename.replace(".html", ".json"),
737
+ outputFilename.replace(".html", ".xml"),
738
+ ];
677
739
  const needsRegen =
678
740
  _clean ||
679
741
  hasAutoIndex ||
680
742
  needsRegeneration(file, rawBody, hashCache) ||
681
- !outputsExist([
682
- outputFilename,
683
- outputFilename.replace(".html", ".json"),
684
- outputFilename.replace(".html", ".xml"),
685
- ]);
743
+ !outputsExist(expectedOutputs);
686
744
 
687
745
  if (!needsRegen) {
688
746
  skippedCount++;
@@ -777,165 +835,171 @@ export async function generate({
777
835
  }
778
836
  }
779
837
 
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
- }
838
+ // Everything from here to the HTML write is page assembly: per-folder
839
+ // CSS/JS bundles, the template, the custom menu, link resolution and
840
+ // image-tag rewriting. The JSON below is built from `body`, which is
841
+ // already final — none of it feeds the JSON, so JSON-only skips it all.
842
+ if (!_jsonOnly) {
843
+ // Find all style.css files up the tree and bundle them into a single CSS file per folder path
844
+ // (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
845
+ let styleLink = "";
846
+ try {
847
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
848
+ const folderRelative = (dir === "/" || dir === "") ? "" : dir;
849
+
850
+ // Check bundle cache first (dirs with same CSS ancestry share the same bundle)
851
+ let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
852
+ if (cachedBundleUrl !== undefined) {
853
+ if (cachedBundleUrl) {
854
+ styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
808
855
  }
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
856
  } else {
814
- docBundleCache.set(`css:${dirKey}`, null);
857
+ let cssPaths = cssPathCache.get(dirKey);
858
+ if (cssPaths === undefined) {
859
+ cssPaths = await findAllStyleCss(dirKey, _source);
860
+ cssPathCache.set(dirKey, cssPaths);
861
+ }
862
+ if (cssPaths.length > 0) {
863
+ // Copy all source CSS files to output (still needed for serve mode fallback)
864
+ for (const cssPath of cssPaths) {
865
+ if (!copiedCssFiles.has(cssPath)) {
866
+ const cssOutputPath = cssPath.replace(source, output);
867
+ const cssContent = await readFile(cssPath, 'utf8');
868
+ await outputFile(cssOutputPath, cssContent);
869
+ copiedCssFiles.add(cssPath);
870
+ }
871
+ }
872
+ // Bundle into a single file
873
+ const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
874
+ docBundleCache.set(`css:${dirKey}`, bundleUrl);
875
+ styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
876
+ } else {
877
+ docBundleCache.set(`css:${dirKey}`, null);
878
+ }
815
879
  }
880
+ } catch (e) {
881
+ // ignore
882
+ console.error(e);
816
883
  }
817
- } catch (e) {
818
- // ignore
819
- console.error(e);
820
- }
821
884
 
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;
885
+ // Find all script.js files from docroot to current dir and bundle them
886
+ // (Generate mode: one JS bundle per unique folder, external not inlined)
887
+ let customScript = "";
888
+ try {
889
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
890
+ const folderRelative = (dir === "/" || dir === "") ? "" : dir;
828
891
 
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>`;
892
+ let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
893
+ if (cachedBundleUrl !== undefined) {
894
+ if (cachedBundleUrl) {
895
+ customScript = `<script src="${cachedBundleUrl}"></script>`;
896
+ }
844
897
  } else {
845
- docBundleCache.set(`js:${dirKey}`, null);
898
+ let scriptPaths = scriptPathCache.get(dirKey);
899
+ if (scriptPaths === undefined) {
900
+ scriptPaths = await findAllScriptJs(dirKey, _source);
901
+ scriptPathCache.set(dirKey, scriptPaths);
902
+ }
903
+ if (scriptPaths.length > 0) {
904
+ const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
905
+ docBundleCache.set(`js:${dirKey}`, bundleUrl);
906
+ customScript = `<script src="${bundleUrl}"></script>`;
907
+ } else {
908
+ docBundleCache.set(`js:${dirKey}`, null);
909
+ }
846
910
  }
911
+ } catch (e) {
912
+ // ignore
913
+ console.error(e);
847
914
  }
848
- } catch (e) {
849
- // ignore
850
- console.error(e);
851
- }
852
915
 
853
- const requestedTemplateName = fileMeta && fileMeta.template;
854
- const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
855
- const template = templates[templateName];
916
+ const requestedTemplateName = fileMeta && fileMeta.template;
917
+ const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
918
+ const template = templates[templateName];
856
919
 
857
- if (!template) {
858
- throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
859
- }
920
+ if (!template) {
921
+ throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
922
+ }
860
923
 
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
- }
924
+ // Register this document's dependencies for invalidation tracking
925
+ {
926
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
927
+ const cssDeps = cssPathCache.get(dirKey) || [];
928
+ const jsDeps = scriptPathCache.get(dirKey) || [];
929
+ dependencyTracker.registerDocument(file, {
930
+ templateName,
931
+ cssPaths: cssDeps,
932
+ scriptPaths: jsDeps,
933
+ });
934
+ }
872
935
 
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;
936
+ // Check if this file has a custom menu
937
+ const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
938
+
939
+ // Lazy evaluation of transformed metadata - only compute if template uses it
940
+ // This defers expensive custom transform function loading until actually needed
941
+ const templateUsesTransformedMeta = template.includes('${transformedMetadata}');
942
+ const lazyTransformedMeta = templateUsesTransformedMeta
943
+ ? await getTransformedMeta()
944
+ : '';
945
+
946
+ // Build final HTML with all replacements in a single regex pass
947
+ // This avoids creating 8 intermediate strings
948
+ // Append hydration script to customScript if present (for MDX with hydrate: true)
949
+ const finalCustomScript = hydrationScript
950
+ ? customScript + '\n' + hydrationScript
951
+ : customScript;
889
952
 
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
- }
953
+ const replacements = {
954
+ "${title}": fileMeta?.title || title,
955
+ "${menu}": menu,
956
+ "${meta}": JSON.stringify(fileMeta),
957
+ "${transformedMetadata}": lazyTransformedMeta,
958
+ "${body}": body,
959
+ "${styleLink}": styleLink,
960
+ "${customScript}": finalCustomScript,
961
+ "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
962
+ "${footer}": footer
963
+ };
964
+ // Single-pass replacement using regex alternation
965
+ const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
966
+ let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
967
+
968
+ // Add menu data attributes to body
969
+ if (customMenuInfo) {
970
+ const menuPosition = customMenuInfo.menuPosition || 'top';
971
+ finalHtml = finalHtml.replace(
972
+ /<body([^>]*)>/,
973
+ `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
974
+ );
975
+ } else {
976
+ // No custom menu — default to top menu
977
+ finalHtml = finalHtml.replace(
978
+ /<body([^>]*)>/,
979
+ `<body$1 data-menu-position="top">`
980
+ );
981
+ }
919
982
 
920
- // Resolve relative URLs in raw HTML elements (img src, etc.)
921
- finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
983
+ // Resolve relative URLs in raw HTML elements (img src, etc.)
984
+ finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
922
985
 
923
- // Resolve links and mark broken internal links as inactive
924
- finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
986
+ // Resolve links and mark broken internal links as inactive
987
+ finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
925
988
 
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
- }
989
+ // Transform image tags to use preview images with data-fullsrc for originals
990
+ // Skip in deferred mode - images will use original paths until preview generation completes
991
+ if (!_deferImages) {
992
+ finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
993
+ }
931
994
 
932
- // Add cache-busting timestamps to static file references
933
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
995
+ // Add cache-busting timestamps to static file references
996
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
934
997
 
935
- await outputFile(outputFilename, finalHtml);
998
+ await outputFile(outputFilename, finalHtml);
936
999
 
937
- // Clear finalHtml reference to allow GC
938
- finalHtml = null;
1000
+ // Clear finalHtml reference to allow GC
1001
+ finalHtml = null;
1002
+ }
939
1003
 
940
1004
  // JSON output
941
1005
  const jsonOutputFilename = outputFilename.replace(".html", ".json");
@@ -968,9 +1032,11 @@ export async function generate({
968
1032
  await outputFile(jsonOutputFilename, json);
969
1033
 
970
1034
  // XML output
971
- const xmlOutputFilename = outputFilename.replace(".html", ".xml");
972
- const xml = `<article>${o2x(jsonObject)}</article>`;
973
- await outputFile(xmlOutputFilename, xml);
1035
+ if (!_jsonOnly) {
1036
+ const xmlOutputFilename = outputFilename.replace(".html", ".xml");
1037
+ const xml = `<article>${o2x(jsonObject)}</article>`;
1038
+ await outputFile(xmlOutputFilename, xml);
1039
+ }
974
1040
 
975
1041
  // Update the content hash for this file
976
1042
  updateHash(file, rawBody, hashCache);
@@ -1029,7 +1095,14 @@ export async function generate({
1029
1095
  return { entries: searchIndex.length, words: wordCount, elapsed };
1030
1096
  };
1031
1097
 
1032
- if (_deferSearchIndex) {
1098
+ if (_jsonOnly) {
1099
+ // Search and full-text indices exist for the site's client-side search UI,
1100
+ // which a JSON-only build does not emit. The full-text build is also the
1101
+ // most expensive step after rendering.
1102
+ profiler.startPhase('Write search index');
1103
+ progress.done('Search index', 'skipped (JSON-only)');
1104
+ profiler.endPhase('Write search index');
1105
+ } else if (_deferSearchIndex) {
1033
1106
  // Deferred mode: start building in background, return promise
1034
1107
  profiler.startPhase('Write search index (deferred)');
1035
1108
  progress.startTimer('Search index');
@@ -1052,17 +1125,26 @@ export async function generate({
1052
1125
  // Phase: Write recent activity data
1053
1126
  profiler.startPhase('Write recent activity');
1054
1127
  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')}]`);
1128
+ if (!_jsonOnly) {
1129
+ // Sort by mtime descending, keep top 10
1130
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
1131
+ const top10 = recentActivity.slice(0, 10);
1132
+ const recentActivityPath = join(output, 'public', 'recent-activity.json');
1133
+ await outputFile(recentActivityPath, JSON.stringify(top10));
1134
+ progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
1135
+ } else {
1136
+ progress.done('Recent activity', `skipped (JSON-only) [${progress.stopTimer('Recent activity')}]`);
1137
+ }
1061
1138
  profiler.endPhase('Write recent activity');
1062
1139
 
1063
1140
  // Phase: Write menu data
1064
1141
  profiler.startPhase('Write menu data');
1065
1142
  progress.startTimer('Menu data');
1143
+ if (_jsonOnly) {
1144
+ // menu-data.json and the custom-menu files are read by the page shell's
1145
+ // script at runtime. No pages, no readers.
1146
+ progress.done('Menu data', `skipped (JSON-only) [${progress.stopTimer('Menu data')}]`);
1147
+ } else {
1066
1148
  // Write menu data as a separate JSON file (not embedded in each page)
1067
1149
  // This dramatically reduces HTML file sizes for large sites
1068
1150
  const menuDataPath = join(output, 'public', 'menu-data.json');
@@ -1082,6 +1164,7 @@ export async function generate({
1082
1164
  await outputFile(customMenuPath, customMenuJson);
1083
1165
  }
1084
1166
  progress.done('Menu data', `${customMenus.size + 1} files [${progress.stopTimer('Menu data')}]`);
1167
+ }
1085
1168
  profiler.endPhase('Write menu data');
1086
1169
 
1087
1170
  // Phase: Process directory indices
@@ -1134,8 +1217,11 @@ export async function generate({
1134
1217
  // so skipping it whenever the file already exists (the old behaviour)
1135
1218
  // froze it at whatever the tree looked like the first time it was
1136
1219
  // written, and new or removed documents never showed up again.
1220
+ //
1221
+ // The <dir>.json above is NOT skipped in JSON-only mode: it is the
1222
+ // directory's record list, which is the main thing a data consumer wants.
1137
1223
  const htmlOutputFilename = dirPath.replace(source, output) + ".html";
1138
- if (!documentOwnedOutputs.has(htmlOutputFilename)) {
1224
+ if (!_jsonOnly && !documentOwnedOutputs.has(htmlOutputFilename)) {
1139
1225
  const template = templates["default-template"];
1140
1226
  const indexHtml = `<ul>${pathsInThisDirectory
1141
1227
  .map((path) => {
@@ -1196,7 +1282,10 @@ export async function generate({
1196
1282
  (filename) => isMedia(filename) && !isHiddenOrSystem(filename)
1197
1283
  );
1198
1284
 
1199
- const allStaticFiles = [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
1285
+ // JSON-only emits data, not a servable site, so nothing is copied through.
1286
+ const allStaticFiles = _jsonOnly
1287
+ ? []
1288
+ : [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
1200
1289
  const totalStatic = allStaticFiles.length;
1201
1290
  let processedStatic = 0;
1202
1291
  let copiedStatic = 0;
@@ -1263,9 +1352,15 @@ export async function generate({
1263
1352
  profiler.startPhase('Auto-index generation');
1264
1353
  progress.startTimer('Auto-index');
1265
1354
  // 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')}]`);
1355
+ if (_jsonOnly) {
1356
+ // Auto-indices only ever emit index.html for a folder that has no index
1357
+ // document of its own.
1358
+ progress.done('Auto-index', `skipped (JSON-only) [${progress.stopTimer('Auto-index')}]`);
1359
+ } else {
1360
+ progress.log(`Checking for missing index files...`);
1361
+ await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus);
1362
+ progress.done('Auto-index', `checked ${allSourceFilenamesThatAreDirectories.length} directories [${progress.stopTimer('Auto-index')}]`);
1363
+ }
1269
1364
  profiler.endPhase('Auto-index generation');
1270
1365
 
1271
1366
  // Phase: Finalization
@@ -1283,10 +1378,21 @@ export async function generate({
1283
1378
  }
1284
1379
 
1285
1380
  // 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);
1381
+ // edges on the next warm start (invalidation plans stay accurate).
1382
+ //
1383
+ // Not in JSON-only mode: it registers nothing (registration lives in the page
1384
+ // assembly it skips), so saving would overwrite a full build's graph with an
1385
+ // empty one. The hash cache is safe to share — see `expectedOutputs` above —
1386
+ // but the dependency graph is not, because nothing rebuilds it.
1387
+ if (!_jsonOnly) {
1388
+ await saveDependencyTracker(source);
1389
+ }
1288
1390
 
1289
- // Populate watch mode cache for fast single-file regeneration
1391
+ // Populate watch mode cache for fast single-file regeneration.
1392
+ // A JSON-only build never bundled the template assets and never processed
1393
+ // images, so seeding the cache from it would make a later single-file
1394
+ // regeneration emit a page with no styles.
1395
+ if (!_jsonOnly) {
1290
1396
  watchModeCache.templates = templates;
1291
1397
  watchModeCache.menu = menu;
1292
1398
  watchModeCache.footer = footer;
@@ -1305,6 +1411,7 @@ export async function generate({
1305
1411
  watchModeCache.isInitialized = true;
1306
1412
  const depStats = dependencyTracker.getStats();
1307
1413
  progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
1414
+ }
1308
1415
 
1309
1416
  // Write error report if there were any errors
1310
1417
  if (errors.length > 0) {