@kenjura/ursa 0.90.1 → 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.
@@ -21,6 +21,7 @@ import {
21
21
  outputsExist,
22
22
  updateHash,
23
23
  getUrsaDir,
24
+ enforceCacheVersion,
24
25
  } from "../helper/contentHash.js";
25
26
  import {
26
27
  buildValidPaths,
@@ -131,6 +132,26 @@ const progress = new ProgressReporter();
131
132
  const DEFAULT_TEMPLATE_NAME =
132
133
  process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
133
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
+ */
134
155
  export async function generate({
135
156
  _source = join(process.cwd(), "."),
136
157
  _meta = join(process.cwd(), "meta"),
@@ -141,11 +162,12 @@ export async function generate({
141
162
  _clean = false, // When true, ignore cache and regenerate all files
142
163
  _deferImages = false, // When true, copy images without processing, return promise for background processing
143
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)
144
166
  } = {}) {
145
167
  // Initialize profiler for this build
146
168
  const profiler = getProfiler(true);
147
169
 
148
- console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex });
170
+ console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex, _jsonOnly });
149
171
  const source = resolve(_source) + "/";
150
172
  const meta = resolve(_meta);
151
173
  const output = resolve(_output) + "/";
@@ -168,7 +190,20 @@ export async function generate({
168
190
  progress.logTimed(`Clean build: clearing output directory ${output}`);
169
191
  await emptyDir(output);
170
192
  progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
171
- } else {
193
+ }
194
+
195
+ // Stamp the cache with ursa's version, discarding it if a different ursa
196
+ // wrote it. Hash-skipping only compares source content, so without this an
197
+ // upgrade leaves every unchanged document frozen at whatever the previous
198
+ // version's templates and renderers produced.
199
+ const cacheStamp = await enforceCacheVersion(source);
200
+ if (cacheStamp.reset) {
201
+ progress.logTimed(
202
+ `Cache discarded: written by ursa ${cacheStamp.previous ?? '(unstamped)'}, now running ${cacheStamp.version}`
203
+ );
204
+ }
205
+
206
+ if (!_clean) {
172
207
  // Warm start: reload persisted dependency registrations so hash-skipped
173
208
  // documents keep their edges (current-run registrations take precedence)
174
209
  const loaded = await loadDependencyTracker(source);
@@ -189,6 +224,9 @@ export async function generate({
189
224
  profiler.startPhase('Filter & classify');
190
225
  progress.startTimer('Filter');
191
226
 
227
+ // Clear config cache at start of generation to pick up any changes
228
+ clearConfigCache();
229
+
192
230
  // Apply include filter (existing functionality)
193
231
  const includeFilter = process.env.INCLUDE_FILTER
194
232
  ? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
@@ -211,14 +249,23 @@ export async function generate({
211
249
  progress.logTimed(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
212
250
  }
213
251
 
214
- // Clear config cache at start of generation to pick up any changes
215
- clearConfigCache();
216
-
217
- // Helper to check if a path is inside a config-hidden folder
218
- const isInHiddenFolder = (filePath) => {
219
- const dir = dirname(filePath);
220
- return isFolderHidden(dir, source);
221
- };
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
+ }
222
269
 
223
270
  // read all articles, process them, copy them to build
224
271
  const articleExtensions = /\.(md|mdx|txt|yml)$/;
@@ -227,12 +274,12 @@ export async function generate({
227
274
  // an empty site when the checkout lives under a dot-directory.
228
275
  const isHiddenOrSystem = (filename) => isHiddenOrSystemPath(filename, source);
229
276
  const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
230
- (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename) && !isInHiddenFolder(filename)
277
+ (filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename)
231
278
  );
232
279
  const allSourceFilenamesThatAreDirectories = (await filterAsync(
233
280
  allSourceFilenames,
234
281
  (filename) => isDirectory(filename)
235
- )).filter((filename) => !isHiddenOrSystem(filename) && !isFolderHidden(filename, source));
282
+ )).filter((filename) => !isHiddenOrSystem(filename));
236
283
 
237
284
  // Build set of existing HTML files in source directory (these should not be overwritten)
238
285
  const htmlExtensions = /\.html$/;
@@ -379,9 +426,15 @@ export async function generate({
379
426
  profiler.endPhase('Load cache');
380
427
 
381
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.
382
434
  profiler.startPhase('Copy meta files');
383
435
  progress.startTimer('Meta');
384
-
436
+
437
+ if (!_jsonOnly) {
385
438
  // create public folder
386
439
  const pub = join(output, "public");
387
440
  await mkdir(pub, { recursive: true });
@@ -429,6 +482,9 @@ export async function generate({
429
482
  }
430
483
 
431
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
+ }
432
488
  profiler.endPhase('Copy meta files');
433
489
 
434
490
  // Track errors for error report
@@ -449,15 +505,17 @@ export async function generate({
449
505
  // Track CSS files that have been copied to avoid duplicates
450
506
  const copiedCssFiles = new Set();
451
507
 
452
- // 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.
453
511
  const imageExtensions = IMAGE_EXTENSIONS;
454
- let allSourceFilenamesThatAreImages = allSourceFilenames.filter(
512
+ let allSourceFilenamesThatAreImages = _jsonOnly ? [] : allSourceFilenames.filter(
455
513
  (filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
456
514
  );
457
515
 
458
516
  // When using a whitelist, also include images referenced by whitelisted documents
459
517
  // This ensures that images used in whitelisted articles are processed even if not explicitly whitelisted
460
- if (_whitelist) {
518
+ if (_whitelist && !_jsonOnly) {
461
519
  progress.logTimed('Scanning whitelisted articles for image references...');
462
520
  const referencedImages = new Set();
463
521
 
@@ -495,7 +553,13 @@ export async function generate({
495
553
  let imageMap = new Map();
496
554
  let deferredImageProcessingPromise = null;
497
555
 
498
- 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) {
499
563
  // Fast mode: just copy images without processing, defer preview generation
500
564
  progress.logTimed(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
501
565
  await copyAllImagesFast(
@@ -660,15 +724,23 @@ export async function generate({
660
724
  // An unchanged hash is not enough: the hash cache lives in the source tree
661
725
  // and is shared across output dirs, so also require that every output this
662
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
+ ];
663
739
  const needsRegen =
664
740
  _clean ||
665
741
  hasAutoIndex ||
666
742
  needsRegeneration(file, rawBody, hashCache) ||
667
- !outputsExist([
668
- outputFilename,
669
- outputFilename.replace(".html", ".json"),
670
- outputFilename.replace(".html", ".xml"),
671
- ]);
743
+ !outputsExist(expectedOutputs);
672
744
 
673
745
  if (!needsRegen) {
674
746
  skippedCount++;
@@ -734,7 +806,7 @@ export async function generate({
734
806
  }
735
807
 
736
808
  // Inject breadcrumbs before the H1
737
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
809
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
738
810
  if (breadcrumbs) {
739
811
  body = breadcrumbs + body;
740
812
  }
@@ -763,165 +835,171 @@ export async function generate({
763
835
  }
764
836
  }
765
837
 
766
- // Find all style.css files up the tree and bundle them into a single CSS file per folder path
767
- // (Generate mode: one CSS bundle per unique folder, minimizing requests per page load)
768
- let styleLink = "";
769
- try {
770
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
771
- const folderRelative = (dir === "/" || dir === "") ? "" : dir;
772
-
773
- // Check bundle cache first (dirs with same CSS ancestry share the same bundle)
774
- let cachedBundleUrl = docBundleCache.get(`css:${dirKey}`);
775
- if (cachedBundleUrl !== undefined) {
776
- if (cachedBundleUrl) {
777
- styleLink = `<link rel="stylesheet" href="${cachedBundleUrl}" />`;
778
- }
779
- } else {
780
- let cssPaths = cssPathCache.get(dirKey);
781
- if (cssPaths === undefined) {
782
- cssPaths = await findAllStyleCss(dirKey, _source);
783
- cssPathCache.set(dirKey, cssPaths);
784
- }
785
- if (cssPaths.length > 0) {
786
- // Copy all source CSS files to output (still needed for serve mode fallback)
787
- for (const cssPath of cssPaths) {
788
- if (!copiedCssFiles.has(cssPath)) {
789
- const cssOutputPath = cssPath.replace(source, output);
790
- const cssContent = await readFile(cssPath, 'utf8');
791
- await outputFile(cssOutputPath, cssContent);
792
- copiedCssFiles.add(cssPath);
793
- }
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}" />`;
794
855
  }
795
- // Bundle into a single file
796
- const bundleUrl = await bundleDocumentCss(cssPaths, output, source, folderRelative, { minify: true });
797
- docBundleCache.set(`css:${dirKey}`, bundleUrl);
798
- styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
799
856
  } else {
800
- 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
+ }
801
879
  }
880
+ } catch (e) {
881
+ // ignore
882
+ console.error(e);
802
883
  }
803
- } catch (e) {
804
- // ignore
805
- console.error(e);
806
- }
807
884
 
808
- // Find all script.js files from docroot to current dir and bundle them
809
- // (Generate mode: one JS bundle per unique folder, external not inlined)
810
- let customScript = "";
811
- try {
812
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
813
- const folderRelative = (dir === "/" || dir === "") ? "" : dir;
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;
814
891
 
815
- let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
816
- if (cachedBundleUrl !== undefined) {
817
- if (cachedBundleUrl) {
818
- customScript = `<script src="${cachedBundleUrl}"></script>`;
819
- }
820
- } else {
821
- let scriptPaths = scriptPathCache.get(dirKey);
822
- if (scriptPaths === undefined) {
823
- scriptPaths = await findAllScriptJs(dirKey, _source);
824
- scriptPathCache.set(dirKey, scriptPaths);
825
- }
826
- if (scriptPaths.length > 0) {
827
- const bundleUrl = await bundleDocumentJs(scriptPaths, output, source, folderRelative, { minify: true });
828
- docBundleCache.set(`js:${dirKey}`, bundleUrl);
829
- customScript = `<script src="${bundleUrl}"></script>`;
892
+ let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
893
+ if (cachedBundleUrl !== undefined) {
894
+ if (cachedBundleUrl) {
895
+ customScript = `<script src="${cachedBundleUrl}"></script>`;
896
+ }
830
897
  } else {
831
- 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
+ }
832
910
  }
911
+ } catch (e) {
912
+ // ignore
913
+ console.error(e);
833
914
  }
834
- } catch (e) {
835
- // ignore
836
- console.error(e);
837
- }
838
915
 
839
- const requestedTemplateName = fileMeta && fileMeta.template;
840
- const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
841
- const template = templates[templateName];
916
+ const requestedTemplateName = fileMeta && fileMeta.template;
917
+ const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
918
+ const template = templates[templateName];
842
919
 
843
- if (!template) {
844
- throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
845
- }
920
+ if (!template) {
921
+ throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
922
+ }
846
923
 
847
- // Register this document's dependencies for invalidation tracking
848
- {
849
- const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
850
- const cssDeps = cssPathCache.get(dirKey) || [];
851
- const jsDeps = scriptPathCache.get(dirKey) || [];
852
- dependencyTracker.registerDocument(file, {
853
- templateName,
854
- cssPaths: cssDeps,
855
- scriptPaths: jsDeps,
856
- });
857
- }
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
+ }
858
935
 
859
- // Check if this file has a custom menu
860
- const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
861
-
862
- // Lazy evaluation of transformed metadata - only compute if template uses it
863
- // This defers expensive custom transform function loading until actually needed
864
- const templateUsesTransformedMeta = template.includes('${transformedMetadata}');
865
- const lazyTransformedMeta = templateUsesTransformedMeta
866
- ? await getTransformedMeta()
867
- : '';
868
-
869
- // Build final HTML with all replacements in a single regex pass
870
- // This avoids creating 8 intermediate strings
871
- // Append hydration script to customScript if present (for MDX with hydrate: true)
872
- const finalCustomScript = hydrationScript
873
- ? customScript + '\n' + hydrationScript
874
- : customScript;
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;
875
952
 
876
- const replacements = {
877
- "${title}": fileMeta?.title || title,
878
- "${menu}": menu,
879
- "${meta}": JSON.stringify(fileMeta),
880
- "${transformedMetadata}": lazyTransformedMeta,
881
- "${body}": body,
882
- "${styleLink}": styleLink,
883
- "${customScript}": finalCustomScript,
884
- "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
885
- "${footer}": footer
886
- };
887
- // Single-pass replacement using regex alternation
888
- const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
889
- let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
890
-
891
- // Add menu data attributes to body
892
- if (customMenuInfo) {
893
- const menuPosition = customMenuInfo.menuPosition || 'top';
894
- finalHtml = finalHtml.replace(
895
- /<body([^>]*)>/,
896
- `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
897
- );
898
- } else {
899
- // No custom menu — default to top menu
900
- finalHtml = finalHtml.replace(
901
- /<body([^>]*)>/,
902
- `<body$1 data-menu-position="top">`
903
- );
904
- }
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
+ }
905
982
 
906
- // Resolve relative URLs in raw HTML elements (img src, etc.)
907
- finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
983
+ // Resolve relative URLs in raw HTML elements (img src, etc.)
984
+ finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
908
985
 
909
- // Resolve links and mark broken internal links as inactive
910
- finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
986
+ // Resolve links and mark broken internal links as inactive
987
+ finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
911
988
 
912
- // Transform image tags to use preview images with data-fullsrc for originals
913
- // Skip in deferred mode - images will use original paths until preview generation completes
914
- if (!_deferImages) {
915
- finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
916
- }
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
+ }
917
994
 
918
- // Add cache-busting timestamps to static file references
919
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
995
+ // Add cache-busting timestamps to static file references
996
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
920
997
 
921
- await outputFile(outputFilename, finalHtml);
998
+ await outputFile(outputFilename, finalHtml);
922
999
 
923
- // Clear finalHtml reference to allow GC
924
- finalHtml = null;
1000
+ // Clear finalHtml reference to allow GC
1001
+ finalHtml = null;
1002
+ }
925
1003
 
926
1004
  // JSON output
927
1005
  const jsonOutputFilename = outputFilename.replace(".html", ".json");
@@ -954,9 +1032,11 @@ export async function generate({
954
1032
  await outputFile(jsonOutputFilename, json);
955
1033
 
956
1034
  // XML output
957
- const xmlOutputFilename = outputFilename.replace(".html", ".xml");
958
- const xml = `<article>${o2x(jsonObject)}</article>`;
959
- 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
+ }
960
1040
 
961
1041
  // Update the content hash for this file
962
1042
  updateHash(file, rawBody, hashCache);
@@ -1015,7 +1095,14 @@ export async function generate({
1015
1095
  return { entries: searchIndex.length, words: wordCount, elapsed };
1016
1096
  };
1017
1097
 
1018
- 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) {
1019
1106
  // Deferred mode: start building in background, return promise
1020
1107
  profiler.startPhase('Write search index (deferred)');
1021
1108
  progress.startTimer('Search index');
@@ -1038,17 +1125,26 @@ export async function generate({
1038
1125
  // Phase: Write recent activity data
1039
1126
  profiler.startPhase('Write recent activity');
1040
1127
  progress.startTimer('Recent activity');
1041
- // Sort by mtime descending, keep top 10
1042
- recentActivity.sort((a, b) => b.mtime - a.mtime);
1043
- const top10 = recentActivity.slice(0, 10);
1044
- const recentActivityPath = join(output, 'public', 'recent-activity.json');
1045
- await outputFile(recentActivityPath, JSON.stringify(top10));
1046
- progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
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
+ }
1047
1138
  profiler.endPhase('Write recent activity');
1048
1139
 
1049
1140
  // Phase: Write menu data
1050
1141
  profiler.startPhase('Write menu data');
1051
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 {
1052
1148
  // Write menu data as a separate JSON file (not embedded in each page)
1053
1149
  // This dramatically reduces HTML file sizes for large sites
1054
1150
  const menuDataPath = join(output, 'public', 'menu-data.json');
@@ -1068,6 +1164,7 @@ export async function generate({
1068
1164
  await outputFile(customMenuPath, customMenuJson);
1069
1165
  }
1070
1166
  progress.done('Menu data', `${customMenus.size + 1} files [${progress.stopTimer('Menu data')}]`);
1167
+ }
1071
1168
  profiler.endPhase('Write menu data');
1072
1169
 
1073
1170
  // Phase: Process directory indices
@@ -1120,8 +1217,11 @@ export async function generate({
1120
1217
  // so skipping it whenever the file already exists (the old behaviour)
1121
1218
  // froze it at whatever the tree looked like the first time it was
1122
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.
1123
1223
  const htmlOutputFilename = dirPath.replace(source, output) + ".html";
1124
- if (!documentOwnedOutputs.has(htmlOutputFilename)) {
1224
+ if (!_jsonOnly && !documentOwnedOutputs.has(htmlOutputFilename)) {
1125
1225
  const template = templates["default-template"];
1126
1226
  const indexHtml = `<ul>${pathsInThisDirectory
1127
1227
  .map((path) => {
@@ -1182,7 +1282,10 @@ export async function generate({
1182
1282
  (filename) => isMedia(filename) && !isHiddenOrSystem(filename)
1183
1283
  );
1184
1284
 
1185
- 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];
1186
1289
  const totalStatic = allStaticFiles.length;
1187
1290
  let processedStatic = 0;
1188
1291
  let copiedStatic = 0;
@@ -1249,9 +1352,15 @@ export async function generate({
1249
1352
  profiler.startPhase('Auto-index generation');
1250
1353
  progress.startTimer('Auto-index');
1251
1354
  // Automatic index generation for folders without index.html
1252
- progress.log(`Checking for missing index files...`);
1253
- await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus);
1254
- progress.done('Auto-index', `checked ${allSourceFilenamesThatAreDirectories.length} directories [${progress.stopTimer('Auto-index')}]`);
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
+ }
1255
1364
  profiler.endPhase('Auto-index generation');
1256
1365
 
1257
1366
  // Phase: Finalization
@@ -1269,10 +1378,21 @@ export async function generate({
1269
1378
  }
1270
1379
 
1271
1380
  // Persist the dependency tracker so hash-skipped documents keep their
1272
- // edges on the next warm start (invalidation plans stay accurate)
1273
- await saveDependencyTracker(source);
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
+ }
1274
1390
 
1275
- // 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) {
1276
1396
  watchModeCache.templates = templates;
1277
1397
  watchModeCache.menu = menu;
1278
1398
  watchModeCache.footer = footer;
@@ -1291,6 +1411,7 @@ export async function generate({
1291
1411
  watchModeCache.isInitialized = true;
1292
1412
  const depStats = dependencyTracker.getStats();
1293
1413
  progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
1414
+ }
1294
1415
 
1295
1416
  // Write error report if there were any errors
1296
1417
  if (errors.length > 0) {
@@ -1552,7 +1673,7 @@ export async function regenerateSingleFile(changedFile, {
1552
1673
  }
1553
1674
 
1554
1675
  // Inject breadcrumbs before the H1
1555
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
1676
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
1556
1677
  if (breadcrumbs) {
1557
1678
  body = breadcrumbs + body;
1558
1679
  }