@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.
- package/CHANGELOG.md +78 -0
- package/README.md +102 -0
- package/bin/ursa.js +14 -3
- package/meta/templates/default-template/default.css +1574 -1323
- package/meta/templates/default-template/index.html +175 -129
- package/meta/templates/default-template/lightbox.css +225 -216
- package/meta/templates/default-template/lightbox.js +3 -0
- package/meta/templates/default-template/menu.js +9 -6
- package/meta/templates/default-template/toc-generator.js +58 -0
- package/meta/templates/default-template/widgets.js +88 -15
- package/package.json +2 -1
- package/src/dev.js +30 -1
- package/src/helper/__test__/breadcrumbs.test.js +71 -0
- package/src/helper/__test__/contentHash.test.js +114 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/automenu.js +13 -91
- package/src/helper/breadcrumbs.js +21 -6
- package/src/helper/build/__test__/autoIndex.test.js +135 -1
- package/src/helper/build/autoIndex.js +115 -46
- package/src/helper/build/ursaMetadata.js +3 -26
- package/src/helper/contentHash.js +54 -1
- package/src/helper/folderConfig.js +34 -4
- package/src/helper/menuLabels.js +136 -0
- package/src/helper/ursaVersion.js +26 -0
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +302 -181
- package/meta/default.css +0 -1206
- package/meta/menu.js +0 -898
- package/meta/sectionify.js +0 -46
package/src/jobs/generate.js
CHANGED
|
@@ -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
|
-
}
|
|
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
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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)
|
|
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)
|
|
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 (
|
|
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
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
916
|
+
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
917
|
+
const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
|
|
918
|
+
const template = templates[templateName];
|
|
842
919
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
920
|
+
if (!template) {
|
|
921
|
+
throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
922
|
+
}
|
|
846
923
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
907
|
-
|
|
983
|
+
// Resolve relative URLs in raw HTML elements (img src, etc.)
|
|
984
|
+
finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
|
|
908
985
|
|
|
909
|
-
|
|
910
|
-
|
|
986
|
+
// Resolve links and mark broken internal links as inactive
|
|
987
|
+
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
911
988
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
919
|
-
|
|
995
|
+
// Add cache-busting timestamps to static file references
|
|
996
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
920
997
|
|
|
921
|
-
|
|
998
|
+
await outputFile(outputFilename, finalHtml);
|
|
922
999
|
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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 (
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
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
|
}
|