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