@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.
- package/CHANGELOG.md +32 -0
- package/README.md +67 -0
- package/bin/ursa.js +14 -3
- package/package.json +1 -1
- package/src/dev.js +29 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/automenu.js +4 -2
- package/src/helper/build/__test__/autoIndex.test.js +67 -0
- package/src/helper/build/autoIndex.js +22 -5
- package/src/helper/folderConfig.js +34 -4
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +285 -178
package/src/jobs/generate.js
CHANGED
|
@@ -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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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)
|
|
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)
|
|
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 (
|
|
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
|
-
//
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
916
|
+
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
917
|
+
const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
|
|
918
|
+
const template = templates[templateName];
|
|
856
919
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
920
|
+
if (!template) {
|
|
921
|
+
throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
922
|
+
}
|
|
860
923
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
921
|
-
|
|
983
|
+
// Resolve relative URLs in raw HTML elements (img src, etc.)
|
|
984
|
+
finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
|
|
922
985
|
|
|
923
|
-
|
|
924
|
-
|
|
986
|
+
// Resolve links and mark broken internal links as inactive
|
|
987
|
+
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
925
988
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
933
|
-
|
|
995
|
+
// Add cache-busting timestamps to static file references
|
|
996
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
934
997
|
|
|
935
|
-
|
|
998
|
+
await outputFile(outputFilename, finalHtml);
|
|
936
999
|
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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 (
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
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) {
|