@kenjura/ursa 0.95.0 → 0.97.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 +48 -0
- package/README.md +114 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/content-hooks.js +45 -0
- package/meta/templates/default-template/index.html +1 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sticky.js +7 -1
- package/meta/templates/default-template/toc-generator.js +58 -38
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/__test__/mdxRenderer.test.js +159 -0
- package/src/helper/__test__/sourceTimestamps.test.js +0 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +36 -11
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +553 -0
- package/src/helper/build/autoIndex.js +2 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1270 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +1 -1
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +225 -26
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/sourceTimestamps.js +139 -0
- package/src/helper/ursaConfig.js +3 -49
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1859
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
package/src/jobs/generate.js
CHANGED
|
@@ -1,139 +1,12 @@
|
|
|
1
|
-
import { recurse } from "../helper/recursive-readdir.js";
|
|
2
|
-
import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
|
|
3
|
-
import { getAutomenu } from "../helper/automenu.js";
|
|
4
|
-
import { filterAsync } from "../helper/filterAsync.js";
|
|
5
|
-
import { isDirectory } from "../helper/isDirectory.js";
|
|
6
|
-
import { isFolderHidden, clearConfigCache } from "../helper/folderConfig.js";
|
|
7
|
-
import { isHiddenOrSystemPath } from "../helper/hiddenPaths.js";
|
|
8
|
-
import { IMAGE_EXTENSIONS, isMedia } from "../helper/staticAssets.js";
|
|
9
|
-
import {
|
|
10
|
-
extractMetadata,
|
|
11
|
-
extractRawMetadata,
|
|
12
|
-
isMetadataOnly,
|
|
13
|
-
getAutoIndexConfig,
|
|
14
|
-
} from "../helper/metadataExtractor.js";
|
|
15
|
-
import { injectFrontmatterTable } from "../helper/frontmatterTable.js";
|
|
16
|
-
import {
|
|
17
|
-
hashContent,
|
|
18
|
-
loadHashCache,
|
|
19
|
-
saveHashCache,
|
|
20
|
-
needsRegeneration,
|
|
21
|
-
outputsExist,
|
|
22
|
-
updateHash,
|
|
23
|
-
getUrsaDir,
|
|
24
|
-
enforceCacheVersion,
|
|
25
|
-
} from "../helper/contentHash.js";
|
|
26
|
-
import {
|
|
27
|
-
buildValidPaths,
|
|
28
|
-
markInactiveLinks,
|
|
29
|
-
resolveRelativeUrls,
|
|
30
|
-
} from "../helper/linkValidator.js";
|
|
31
|
-
import { getAndIncrementBuildId, loadContentTimestamps, saveContentTimestamps, updateContentTimestamp } from "../helper/ursaConfig.js";
|
|
32
|
-
import { extractSections } from "../helper/sectionExtractor.js";
|
|
33
|
-
import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
|
|
34
|
-
import { buildReactRuntime } from "../helper/mdxRenderer.js";
|
|
35
|
-
import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
|
|
36
|
-
import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
|
|
37
|
-
import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMetaBundleCache, generateSeparateCssTags, generateSeparateJsTags } from "../helper/assetBundler.js";
|
|
38
|
-
import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
|
|
39
|
-
import { dependencyTracker, loadDependencyTracker, saveDependencyTracker } from "../helper/dependencyTracker.js";
|
|
40
|
-
import { CacheBustHashMap } from "../helper/build/cacheBust.js";
|
|
41
|
-
import { copy as copyDir, emptyDir, outputFile, remove } from "fs-extra";
|
|
42
|
-
import { basename, dirname, extname, join, parse, resolve } from "path";
|
|
43
|
-
import { URL } from "url";
|
|
44
|
-
import o2x from "object-to-xml";
|
|
45
|
-
import { existsSync } from "fs";
|
|
46
|
-
import { createWhitelistFilter } from "../helper/whitelistFilter.js";
|
|
47
|
-
import { processAllImages, transformImageTags, clearImageCache, copyAllImagesFast } from "../helper/imageProcessor.js";
|
|
48
|
-
import { extractImageReferences } from "../helper/imageExtractor.js";
|
|
49
|
-
import { checkFileSize, readFileStreaming, formatFileSize } from "../helper/streamingReader.js";
|
|
50
|
-
import { generateBreadcrumbs } from "../helper/breadcrumbs.js";
|
|
51
|
-
import {
|
|
52
|
-
loadNavCache,
|
|
53
|
-
saveNavCache,
|
|
54
|
-
hashFileList,
|
|
55
|
-
hashFileStats,
|
|
56
|
-
isNavCacheValid,
|
|
57
|
-
createNavCacheEntry,
|
|
58
|
-
restoreMap,
|
|
59
|
-
} from "../helper/build/navCache.js";
|
|
60
|
-
|
|
61
|
-
// Import build helpers from organized modules
|
|
62
|
-
import {
|
|
63
|
-
generateCacheBustTimestamp,
|
|
64
|
-
addTimestampToCssUrls,
|
|
65
|
-
addTimestampToHtmlStaticRefs,
|
|
66
|
-
processBatched,
|
|
67
|
-
ProgressReporter,
|
|
68
|
-
watchModeCache,
|
|
69
|
-
clearWatchCache as clearWatchCacheBase,
|
|
70
|
-
toTitleCase,
|
|
71
|
-
parseExcludeOption,
|
|
72
|
-
createExcludeFilter,
|
|
73
|
-
addTrailingSlash,
|
|
74
|
-
getTemplates,
|
|
75
|
-
getMenu,
|
|
76
|
-
findAllCustomMenus,
|
|
77
|
-
getCustomMenuForFile,
|
|
78
|
-
getTransformedMetadata,
|
|
79
|
-
getFooter,
|
|
80
|
-
getUrsaMetadata,
|
|
81
|
-
generateAutoIndices,
|
|
82
|
-
generateAutoIndexHtmlFromSource,
|
|
83
|
-
copyMetaAssets,
|
|
84
|
-
} from "../helper/build/index.js";
|
|
85
|
-
import { getProfiler } from "../helper/build/profiler.js";
|
|
86
|
-
import { reconcileAll, isInsideTemplatesFolder } from "../helper/documentTemplates.js";
|
|
87
|
-
|
|
88
|
-
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
89
|
-
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
90
|
-
|
|
91
|
-
// Cache for CSS path lookups to avoid repeated filesystem walks
|
|
92
|
-
const cssPathCache = new Map();
|
|
93
|
-
|
|
94
|
-
// Cache for script path lookups to avoid repeated filesystem walks
|
|
95
|
-
const scriptPathCache = new Map();
|
|
96
|
-
|
|
97
|
-
// Cache for document-level CSS/JS bundle paths to avoid re-bundling identical sets
|
|
98
|
-
const docBundleCache = new Map();
|
|
99
|
-
|
|
100
|
-
// Wrapper for clearWatchCache that passes cssPathCache and scriptPathCache
|
|
101
|
-
export function clearWatchCache() {
|
|
102
|
-
clearWatchCacheBase(cssPathCache);
|
|
103
|
-
scriptPathCache.clear();
|
|
104
|
-
docBundleCache.clear();
|
|
105
|
-
clearMetaBundleCache();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Clear just the script-related caches (for when script.js changes)
|
|
109
|
-
export function clearScriptCache() {
|
|
110
|
-
scriptPathCache.clear();
|
|
111
|
-
// Clear all JS bundle cache entries
|
|
112
|
-
for (const key of docBundleCache.keys()) {
|
|
113
|
-
if (key.startsWith('js:')) {
|
|
114
|
-
docBundleCache.delete(key);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Clear just the CSS-related caches (for when style.css changes)
|
|
120
|
-
export function clearStyleCache() {
|
|
121
|
-
cssPathCache.clear();
|
|
122
|
-
// Clear all CSS bundle cache entries
|
|
123
|
-
for (const key of docBundleCache.keys()) {
|
|
124
|
-
if (key.startsWith('css:')) {
|
|
125
|
-
docBundleCache.delete(key);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const progress = new ProgressReporter();
|
|
131
|
-
|
|
132
|
-
const DEFAULT_TEMPLATE_NAME =
|
|
133
|
-
process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
|
|
134
|
-
|
|
135
1
|
/**
|
|
136
|
-
*
|
|
2
|
+
* `ursa generate`: one build pass over the incremental build graph.
|
|
3
|
+
*
|
|
4
|
+
* The pass is the same one `ursa serve` runs on every change (see
|
|
5
|
+
* docs/SERVE.md §5.4 and src/helper/build/pass.js). A cold start is a pass in
|
|
6
|
+
* which every leaf is new; a warm start re-checks the persisted graph in
|
|
7
|
+
* `.ursa/graph.json` and recomputes only what its inputs say has changed.
|
|
8
|
+
* Outputs whose source is gone are deleted, so the output directory converges
|
|
9
|
+
* on what a clean build would produce without `--clean`.
|
|
137
10
|
*
|
|
138
11
|
* ## JSON-ONLY MODE (`_jsonOnly`)
|
|
139
12
|
*
|
|
@@ -143,1751 +16,86 @@ const DEFAULT_TEMPLATE_NAME =
|
|
|
143
16
|
* CSS/JS bundles, static file copying, the search and full-text indices,
|
|
144
17
|
* recent-activity and menu data, and auto-generated index pages.
|
|
145
18
|
*
|
|
146
|
-
* The emitted JSON is byte-identical to what a full build writes
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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.
|
|
19
|
+
* The emitted JSON is byte-identical to what a full build writes: the JSON's
|
|
20
|
+
* `bodyHtml` is the pre-template render, which none of the skipped steps touch.
|
|
21
|
+
* Mixing modes against one source tree is safe — the graph records what each
|
|
22
|
+
* output consumed, and a node that was never demanded keeps its state.
|
|
154
23
|
*/
|
|
24
|
+
|
|
25
|
+
import { join, resolve } from "path";
|
|
26
|
+
import { outputFile } from "fs-extra";
|
|
27
|
+
import { createBuild } from "../helper/build/pass.js";
|
|
28
|
+
import { getProfiler } from "../helper/build/profiler.js";
|
|
29
|
+
|
|
155
30
|
export async function generate({
|
|
156
31
|
_source = join(process.cwd(), "."),
|
|
157
32
|
_meta = join(process.cwd(), "meta"),
|
|
158
33
|
_output = join(process.cwd(), "build"),
|
|
159
34
|
_whitelist = null,
|
|
160
35
|
_exclude = null,
|
|
161
|
-
_incremental = false,
|
|
162
|
-
_clean = false,
|
|
163
|
-
_deferImages = false,
|
|
164
|
-
_deferSearchIndex = false,
|
|
165
|
-
_jsonOnly = false,
|
|
36
|
+
_incremental = false, // Legacy flag, now ignored (always incremental)
|
|
37
|
+
_clean = false, // When true, ignore the graph and regenerate all files
|
|
38
|
+
_deferImages = false, // Legacy: previews are always scheduled after pages
|
|
39
|
+
_deferSearchIndex = false, // Legacy: indices are always scheduled after pages
|
|
40
|
+
_jsonOnly = false, // When true, emit only the .json data files (see JSON-ONLY MODE below)
|
|
41
|
+
_explain = false, // Log why each recomputed node recomputed
|
|
166
42
|
} = {}) {
|
|
167
|
-
// Initialize profiler for this build
|
|
168
43
|
const profiler = getProfiler(true);
|
|
169
|
-
|
|
170
|
-
console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages, _deferSearchIndex, _jsonOnly });
|
|
171
|
-
const source = resolve(_source) + "/";
|
|
44
|
+
const source = resolve(_source);
|
|
172
45
|
const meta = resolve(_meta);
|
|
173
|
-
const output = resolve(_output)
|
|
174
|
-
console.log({ source, meta, output });
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
progress.startTimer('Clean');
|
|
187
|
-
const ursaDir = getUrsaDir(source);
|
|
188
|
-
progress.logTimed(`Clean build: deleting cache folder ${ursaDir}`);
|
|
189
|
-
await remove(ursaDir);
|
|
190
|
-
progress.logTimed(`Clean build: clearing output directory ${output}`);
|
|
191
|
-
await emptyDir(output);
|
|
192
|
-
progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
|
|
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) {
|
|
207
|
-
// Warm start: reload persisted dependency registrations so hash-skipped
|
|
208
|
-
// documents keep their edges (current-run registrations take precedence)
|
|
209
|
-
const loaded = await loadDependencyTracker(source);
|
|
210
|
-
if (loaded) {
|
|
211
|
-
const stats = dependencyTracker.getStats();
|
|
212
|
-
progress.logTimed(`Dependency graph loaded: ${stats.totalDocuments} documents, ${stats.uniqueFiles} dependencies`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Phase: Scan source files
|
|
217
|
-
profiler.startPhase('Scan source files');
|
|
218
|
-
progress.startTimer('Scan');
|
|
219
|
-
const allSourceFilenamesUnfiltered = await recurse(source, [() => false]);
|
|
220
|
-
progress.logTimed(`Scanned ${allSourceFilenamesUnfiltered.length} files [${progress.stopTimer('Scan')}]`);
|
|
221
|
-
profiler.endPhase('Scan source files');
|
|
222
|
-
|
|
223
|
-
// Phase: Filter and classify files
|
|
224
|
-
profiler.startPhase('Filter & classify');
|
|
225
|
-
progress.startTimer('Filter');
|
|
226
|
-
|
|
227
|
-
// Clear config cache at start of generation to pick up any changes
|
|
228
|
-
clearConfigCache();
|
|
229
|
-
|
|
230
|
-
// Apply include filter (existing functionality)
|
|
231
|
-
const includeFilter = process.env.INCLUDE_FILTER
|
|
232
|
-
? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
|
|
233
|
-
: Boolean;
|
|
234
|
-
let allSourceFilenames = allSourceFilenamesUnfiltered.filter(includeFilter);
|
|
235
|
-
|
|
236
|
-
// Apply exclude filter if specified
|
|
237
|
-
if (_exclude) {
|
|
238
|
-
const excludedPaths = await parseExcludeOption(_exclude, source);
|
|
239
|
-
const excludeFilter = createExcludeFilter(excludedPaths, source);
|
|
240
|
-
const beforeCount = allSourceFilenames.length;
|
|
241
|
-
allSourceFilenames = allSourceFilenames.filter(excludeFilter);
|
|
242
|
-
progress.logTimed(`Exclude filter applied: ${beforeCount - allSourceFilenames.length} files excluded`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Apply whitelist filter if specified
|
|
246
|
-
if (_whitelist) {
|
|
247
|
-
const whitelistFilter = await createWhitelistFilter(_whitelist, source);
|
|
248
|
-
allSourceFilenames = allSourceFilenames.filter(whitelistFilter);
|
|
249
|
-
progress.logTimed(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
|
|
250
|
-
}
|
|
251
|
-
|
|
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
|
-
}
|
|
269
|
-
|
|
270
|
-
// read all articles, process them, copy them to build
|
|
271
|
-
const articleExtensions = /\.(md|mdx|txt|yml)$/;
|
|
272
|
-
// Hidden/system folders are judged RELATIVE to the docroot — see
|
|
273
|
-
// helper/hiddenPaths.js for why testing the absolute path silently produces
|
|
274
|
-
// an empty site when the checkout lives under a dot-directory.
|
|
275
|
-
const isHiddenOrSystem = (filename) => isHiddenOrSystemPath(filename, source);
|
|
276
|
-
const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
|
|
277
|
-
(filename) => filename.match(articleExtensions) && !isHiddenOrSystem(filename)
|
|
278
|
-
);
|
|
279
|
-
const allSourceFilenamesThatAreDirectories = (await filterAsync(
|
|
280
|
-
allSourceFilenames,
|
|
281
|
-
(filename) => isDirectory(filename)
|
|
282
|
-
)).filter((filename) => !isHiddenOrSystem(filename));
|
|
283
|
-
|
|
284
|
-
// Build set of existing HTML files in source directory (these should not be overwritten)
|
|
285
|
-
const htmlExtensions = /\.html$/;
|
|
286
|
-
const existingHtmlFiles = new Set(
|
|
287
|
-
allSourceFilenames
|
|
288
|
-
.filter(f => f.match(htmlExtensions) && !isHiddenOrSystem(f))
|
|
289
|
-
.map(f => f.replace(source, '')) // Store relative paths for easy lookup
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
progress.logTimed(`Classified: ${allSourceFilenamesThatAreArticles.length} articles, ${allSourceFilenamesThatAreDirectories.length} dirs, ${existingHtmlFiles.size} HTML [${progress.stopTimer('Filter')}]`);
|
|
293
|
-
profiler.endPhase('Filter & classify');
|
|
294
|
-
|
|
295
|
-
// Drop persisted dependency registrations for documents that no longer
|
|
296
|
-
// exist (or are excluded), so stale entries don't accumulate across runs
|
|
297
|
-
dependencyTracker.prune(new Set(allSourceFilenamesThatAreArticles));
|
|
298
|
-
|
|
299
|
-
// Phase: Document template reconciliation
|
|
300
|
-
// Must run BEFORE article processing so that any template-driven changes
|
|
301
|
-
// to source .md files are picked up during rendering.
|
|
302
|
-
profiler.startPhase('Template reconciliation');
|
|
303
|
-
progress.startTimer('Templates');
|
|
304
|
-
const templateReconciliation = await reconcileAll(
|
|
305
|
-
allSourceFilenamesThatAreArticles,
|
|
306
|
-
allSourceFilenamesUnfiltered, // templates live in _templates which is filtered out of articles
|
|
307
|
-
source
|
|
308
|
-
);
|
|
309
|
-
if (templateReconciliation.updated > 0 || templateReconciliation.conflicts > 0 || templateReconciliation.initialized > 0) {
|
|
310
|
-
progress.logTimed(
|
|
311
|
-
`📄 Document templates: ${templateReconciliation.initialized} initialized, ` +
|
|
312
|
-
`${templateReconciliation.updated} auto-merged, ` +
|
|
313
|
-
`${templateReconciliation.conflicts} conflicts, ` +
|
|
314
|
-
`${templateReconciliation.unchanged} unchanged, ` +
|
|
315
|
-
`${templateReconciliation.errors} errors`
|
|
316
|
-
);
|
|
317
|
-
if (templateReconciliation.conflicts > 0) {
|
|
318
|
-
console.warn(`\n⚠️ Template conflicts require manual resolution:`);
|
|
319
|
-
for (const msg of templateReconciliation.messages) {
|
|
320
|
-
if (msg.includes('Conflict')) console.warn(` ${msg}`);
|
|
321
|
-
}
|
|
322
|
-
console.warn('');
|
|
323
|
-
}
|
|
324
|
-
if (templateReconciliation.errors > 0) {
|
|
325
|
-
for (const msg of templateReconciliation.messages) {
|
|
326
|
-
if (msg.includes('Error') || msg.includes('not found')) console.warn(` ⚠️ ${msg}`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
progress.logTimed(`Document templates processed [${progress.stopTimer('Templates')}]`);
|
|
331
|
-
profiler.endPhase('Template reconciliation');
|
|
332
|
-
|
|
333
|
-
// Phase: Build navigation and metadata
|
|
334
|
-
profiler.startPhase('Build navigation');
|
|
335
|
-
progress.startTimer('Navigation');
|
|
336
|
-
|
|
337
|
-
// Check if we can use cached navigation
|
|
338
|
-
let validPaths, templates, menu, menuData, customMenus, footer, buildId;
|
|
339
|
-
let navCacheUsed = false;
|
|
340
|
-
|
|
341
|
-
if (!_clean) {
|
|
342
|
-
const fileListHash = hashFileList(allSourceFilenames);
|
|
343
|
-
const fileStatsHash = await hashFileStats(allSourceFilenames);
|
|
344
|
-
const navCache = await loadNavCache(source);
|
|
345
|
-
|
|
346
|
-
if (isNavCacheValid(navCache, fileListHash, fileStatsHash)) {
|
|
347
|
-
// Use cached navigation data
|
|
348
|
-
navCacheUsed = true;
|
|
349
|
-
validPaths = restoreMap(navCache.validPaths);
|
|
350
|
-
menuData = navCache.menuData;
|
|
351
|
-
menu = navCache.menuHtml;
|
|
352
|
-
customMenus = restoreMap(navCache.customMenus);
|
|
353
|
-
|
|
354
|
-
// Templates and footer still need to be loaded (they depend on meta directory)
|
|
355
|
-
templates = await getTemplates(meta);
|
|
356
|
-
buildId = getAndIncrementBuildId(resolve(_source));
|
|
357
|
-
footer = await getFooter(source, _source, buildId);
|
|
358
|
-
|
|
359
|
-
progress.logTimed(`Navigation loaded from cache: ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
|
|
360
|
-
} else {
|
|
361
|
-
// Cache miss - build navigation from scratch
|
|
362
|
-
validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
|
|
363
|
-
templates = await getTemplates(meta);
|
|
364
|
-
|
|
365
|
-
const menuResult = await getMenu(allSourceFilenames, source, validPaths);
|
|
366
|
-
menu = menuResult.html;
|
|
367
|
-
menuData = menuResult.menuData;
|
|
368
|
-
|
|
369
|
-
customMenus = findAllCustomMenus(allSourceFilenames, source);
|
|
370
|
-
buildId = getAndIncrementBuildId(resolve(_source));
|
|
371
|
-
footer = await getFooter(source, _source, buildId);
|
|
372
|
-
|
|
373
|
-
// Save to cache for next run
|
|
374
|
-
const cacheEntry = createNavCacheEntry(
|
|
375
|
-
fileListHash,
|
|
376
|
-
fileStatsHash,
|
|
377
|
-
menuData,
|
|
378
|
-
menu,
|
|
379
|
-
Array.from(validPaths.entries()),
|
|
380
|
-
Array.from(customMenus.entries())
|
|
381
|
-
);
|
|
382
|
-
await saveNavCache(source, cacheEntry);
|
|
383
|
-
|
|
384
|
-
progress.logTimed(`Navigation built: ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
|
|
385
|
-
}
|
|
386
|
-
} else {
|
|
387
|
-
// Clean build - ignore cache
|
|
388
|
-
validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
|
|
389
|
-
templates = await getTemplates(meta);
|
|
390
|
-
|
|
391
|
-
const menuResult = await getMenu(allSourceFilenames, source, validPaths);
|
|
392
|
-
menu = menuResult.html;
|
|
393
|
-
menuData = menuResult.menuData;
|
|
394
|
-
|
|
395
|
-
customMenus = findAllCustomMenus(allSourceFilenames, source);
|
|
396
|
-
buildId = getAndIncrementBuildId(resolve(_source));
|
|
397
|
-
footer = await getFooter(source, _source, buildId);
|
|
398
|
-
|
|
399
|
-
progress.logTimed(`Navigation built (clean): ${validPaths.size} paths, ${customMenus.size} custom menus [${progress.stopTimer('Navigation')}]`);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
profiler.endPhase('Build navigation');
|
|
403
|
-
|
|
404
|
-
// Build the _ursa_metadata embedded in every generated JSON file (ursa + doc repo versions)
|
|
405
|
-
const ursaMetadata = await getUrsaMetadata(_source);
|
|
406
|
-
|
|
407
|
-
// Phase: Load cache
|
|
408
|
-
profiler.startPhase('Load cache');
|
|
409
|
-
progress.startTimer('Cache');
|
|
410
|
-
|
|
411
|
-
// Load content hash cache from .ursa folder in source directory
|
|
412
|
-
let hashCache = new Map();
|
|
413
|
-
if (!_clean) {
|
|
414
|
-
hashCache = await loadHashCache(source);
|
|
415
|
-
progress.logTimed(`Loaded ${hashCache.size} cached hashes [${progress.stopTimer('Cache')}]`);
|
|
416
|
-
} else {
|
|
417
|
-
progress.logTimed(`Clean build: ignoring cached hashes`);
|
|
418
|
-
progress.stopTimer('Cache');
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Load content timestamps from .ursa.json (survives --clean)
|
|
422
|
-
// These track when content actually changed, not filesystem mtime
|
|
423
|
-
const contentTimestamps = loadContentTimestamps(source);
|
|
424
|
-
const buildTimestamp = Date.now();
|
|
425
|
-
progress.logTimed(`Loaded ${contentTimestamps.size} content timestamps`);
|
|
426
|
-
profiler.endPhase('Load cache');
|
|
427
|
-
|
|
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.
|
|
434
|
-
profiler.startPhase('Copy meta files');
|
|
435
|
-
progress.startTimer('Meta');
|
|
436
|
-
|
|
437
|
-
if (!_jsonOnly) {
|
|
438
|
-
// create public folder
|
|
439
|
-
const pub = join(output, "public");
|
|
440
|
-
await mkdir(pub, { recursive: true });
|
|
441
|
-
|
|
442
|
-
// Copy meta assets with new template folder structure
|
|
443
|
-
const { copiedFiles, orphanedFiles } = await copyMetaAssets(meta, pub);
|
|
444
|
-
|
|
445
|
-
// Warn about orphaned files in meta that aren't part of any template
|
|
446
|
-
if (orphanedFiles.length > 0) {
|
|
447
|
-
console.warn(`\n⚠️ Warning: Found ${orphanedFiles.length} orphaned file(s) in meta directory:`);
|
|
448
|
-
console.warn(` These files are not in meta/templates/ or meta/shared/ and won't be included:`);
|
|
449
|
-
for (const file of orphanedFiles.slice(0, 10)) {
|
|
450
|
-
console.warn(` - ${file}`);
|
|
451
|
-
}
|
|
452
|
-
if (orphanedFiles.length > 10) {
|
|
453
|
-
console.warn(` ... and ${orphanedFiles.length - 10} more`);
|
|
454
|
-
}
|
|
455
|
-
console.warn(` Move them to meta/templates/{templateName}/ or meta/shared/ to include them.\n`);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Bundle meta template assets (CSS + JS) into single files per template
|
|
459
|
-
// This must happen after copying meta to public but before cache-busting
|
|
460
|
-
templates = await bundleMetaTemplateAssets(templates, meta, pub, { minify: true, sourcemap: false });
|
|
461
|
-
progress.logTimed(`Meta template assets bundled`);
|
|
462
|
-
|
|
463
|
-
// Build React runtime for MDX hydration (React 19 has no UMD, so we bundle locally)
|
|
464
|
-
await buildReactRuntime(pub);
|
|
465
|
-
|
|
466
|
-
// Process all CSS files in the entire output directory tree for cache-busting
|
|
467
|
-
const allOutputFiles = await recurse(output, [() => false]);
|
|
468
|
-
for (const cssFile of allOutputFiles.filter(f => f.endsWith('.css'))) {
|
|
469
|
-
const cssContent = await readFile(cssFile, 'utf8');
|
|
470
|
-
const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
|
|
471
|
-
await outputFile(cssFile, processedCss);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Process JS files in output for cache-busting fetch URLs
|
|
475
|
-
for (const jsFile of allOutputFiles.filter(f => f.endsWith('.js'))) {
|
|
476
|
-
let jsContent = await readFile(jsFile, 'utf8');
|
|
477
|
-
jsContent = jsContent.replace(
|
|
478
|
-
/fetch\(['"]([^'"\)]+\.(json))['"](?!\s*\+)/g,
|
|
479
|
-
`fetch('$1?v=${cacheBustTimestamp}'`
|
|
480
|
-
);
|
|
481
|
-
await outputFile(jsFile, jsContent);
|
|
482
|
-
}
|
|
483
|
-
|
|
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
|
-
}
|
|
488
|
-
profiler.endPhase('Copy meta files');
|
|
489
|
-
|
|
490
|
-
// Track errors for error report
|
|
491
|
-
const errors = [];
|
|
492
|
-
|
|
493
|
-
// Search index: built incrementally during article processing (lighter memory footprint)
|
|
494
|
-
const searchIndex = [];
|
|
495
|
-
// Full-text index: collect documents for word-to-document mapping
|
|
496
|
-
const fullTextDocs = [];
|
|
497
|
-
// Recent activity: collect {title, url, mtime} for all articles, keep top 10 by mtime
|
|
498
|
-
const recentActivity = [];
|
|
499
|
-
// Track paths of documents that were regenerated (for incremental index updates)
|
|
500
|
-
const changedPaths = new Set();
|
|
501
|
-
// Directory index cache: only stores minimal data needed for directory indices
|
|
502
|
-
// Uses WeakRef-style approach - store only what's needed, clear as we go
|
|
503
|
-
const dirIndexCache = new Map();
|
|
504
|
-
|
|
505
|
-
// Track CSS files that have been copied to avoid duplicates
|
|
506
|
-
const copiedCssFiles = new Set();
|
|
507
|
-
|
|
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.
|
|
511
|
-
const imageExtensions = IMAGE_EXTENSIONS;
|
|
512
|
-
let allSourceFilenamesThatAreImages = _jsonOnly ? [] : allSourceFilenames.filter(
|
|
513
|
-
(filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
|
|
514
|
-
);
|
|
515
|
-
|
|
516
|
-
// When using a whitelist, also include images referenced by whitelisted documents
|
|
517
|
-
// This ensures that images used in whitelisted articles are processed even if not explicitly whitelisted
|
|
518
|
-
if (_whitelist && !_jsonOnly) {
|
|
519
|
-
progress.logTimed('Scanning whitelisted articles for image references...');
|
|
520
|
-
const referencedImages = new Set();
|
|
521
|
-
|
|
522
|
-
for (const articlePath of allSourceFilenamesThatAreArticles) {
|
|
523
|
-
try {
|
|
524
|
-
const content = await readFile(articlePath, 'utf8');
|
|
525
|
-
const imageRefs = extractImageReferences(content, articlePath, source);
|
|
526
|
-
imageRefs.forEach(img => referencedImages.add(img));
|
|
527
|
-
} catch (e) {
|
|
528
|
-
// Ignore read errors - file might not exist or be unreadable
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Get all images from the unfiltered source list that are referenced
|
|
533
|
-
const allImagesUnfiltered = allSourceFilenamesUnfiltered.filter(
|
|
534
|
-
(filename) => filename.match(imageExtensions) && !isHiddenOrSystem(filename)
|
|
535
|
-
);
|
|
536
|
-
|
|
537
|
-
// Add referenced images that aren't already in the list
|
|
538
|
-
const additionalImages = allImagesUnfiltered.filter(
|
|
539
|
-
img => referencedImages.has(img) && !allSourceFilenamesThatAreImages.includes(img)
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
if (additionalImages.length > 0) {
|
|
543
|
-
progress.logTimed(`Found ${additionalImages.length} additional images referenced by whitelisted documents`);
|
|
544
|
-
allSourceFilenamesThatAreImages = [...allSourceFilenamesThatAreImages, ...additionalImages];
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Phase: Process images
|
|
549
|
-
profiler.startPhase('Process images');
|
|
550
|
-
progress.startTimer('Images');
|
|
551
|
-
|
|
552
|
-
// Handle images based on deferred mode
|
|
553
|
-
let imageMap = new Map();
|
|
554
|
-
let deferredImageProcessingPromise = null;
|
|
555
|
-
|
|
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) {
|
|
563
|
-
// Fast mode: just copy images without processing, defer preview generation
|
|
564
|
-
progress.logTimed(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
|
|
565
|
-
await copyAllImagesFast(
|
|
566
|
-
allSourceFilenamesThatAreImages,
|
|
567
|
-
source,
|
|
568
|
-
output,
|
|
569
|
-
(current, total, path) => {
|
|
570
|
-
progress.status('Images (copy)', `${current}/${total} ${path}`);
|
|
571
|
-
}
|
|
572
|
-
);
|
|
573
|
-
progress.done('Images (copy)', `${allSourceFilenamesThatAreImages.length} copied (previews deferred)`);
|
|
574
|
-
profiler.endPhase('Process images');
|
|
575
|
-
|
|
576
|
-
// Create promise for background image processing (will be returned to caller)
|
|
577
|
-
deferredImageProcessingPromise = (async () => {
|
|
578
|
-
progress.logTimed(`\n🖼️ Starting deferred image preview generation...`);
|
|
579
|
-
const startTime = Date.now();
|
|
580
|
-
const processedImageMap = await processAllImages(
|
|
581
|
-
allSourceFilenamesThatAreImages,
|
|
582
|
-
source,
|
|
583
|
-
output,
|
|
584
|
-
(current, total, path) => {
|
|
585
|
-
progress.status('Images (previews)', `${current}/${total} ${path}`);
|
|
586
|
-
}
|
|
587
|
-
);
|
|
588
|
-
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
589
|
-
progress.done('Images (previews)', `${allSourceFilenamesThatAreImages.length} done (${processedImageMap.size} with previews) in ${elapsed}s`);
|
|
590
|
-
|
|
591
|
-
// Update the watch cache with the processed image map
|
|
592
|
-
watchModeCache.imageMap = processedImageMap;
|
|
593
|
-
|
|
594
|
-
return processedImageMap;
|
|
595
|
-
})();
|
|
596
|
-
} else {
|
|
597
|
-
// Normal mode: process all images FIRST to build the preview image map
|
|
598
|
-
// This is done before articles so we can transform img tags in the HTML
|
|
599
|
-
progress.logTimed(`Processing ${allSourceFilenamesThatAreImages.length} images for preview generation...`);
|
|
600
|
-
imageMap = await processAllImages(
|
|
601
|
-
allSourceFilenamesThatAreImages,
|
|
602
|
-
source,
|
|
603
|
-
output,
|
|
604
|
-
(current, total, path) => {
|
|
605
|
-
progress.status('Images', `${current}/${total} ${path}`);
|
|
606
|
-
}
|
|
607
|
-
);
|
|
608
|
-
progress.done('Images', `${allSourceFilenamesThatAreImages.length} done (${imageMap.size} with previews) [${progress.stopTimer('Images')}]`);
|
|
609
|
-
profiler.endPhase('Process images');
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Phase: Process articles
|
|
613
|
-
profiler.startPhase('Process articles');
|
|
614
|
-
progress.startTimer('Articles');
|
|
615
|
-
|
|
616
|
-
// Track files that were regenerated (for incremental mode stats)
|
|
617
|
-
let regeneratedCount = 0;
|
|
618
|
-
let skippedCount = 0;
|
|
619
|
-
let processedCount = 0;
|
|
620
|
-
const totalArticles = allSourceFilenamesThatAreArticles.length;
|
|
621
|
-
|
|
622
|
-
progress.logTimed(`Processing ${totalArticles} articles in batches of ${BATCH_SIZE}...`);
|
|
623
|
-
|
|
624
|
-
// Single pass: process all articles with batched concurrency to limit memory usage
|
|
625
|
-
await processBatched(allSourceFilenamesThatAreArticles, async (file) => {
|
|
626
|
-
try {
|
|
627
|
-
processedCount++;
|
|
628
|
-
const shortFile = file.replace(source, '');
|
|
629
|
-
progress.status('Articles', `${processedCount}/${totalArticles} ${shortFile}`);
|
|
630
|
-
|
|
631
|
-
// Use streaming for large files to reduce memory pressure
|
|
632
|
-
const { size: fileSize, useStreaming } = await checkFileSize(file);
|
|
633
|
-
let rawBody;
|
|
634
|
-
if (useStreaming) {
|
|
635
|
-
rawBody = await readFileStreaming(file, 'utf8');
|
|
636
|
-
progress.log(`📄 Streaming large file ${shortFile} (${formatFileSize(fileSize)})`);
|
|
637
|
-
} else {
|
|
638
|
-
rawBody = await readFile(file, "utf8");
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const type = parse(file).ext;
|
|
642
|
-
const ext = extname(file);
|
|
643
|
-
const base = basename(file, ext);
|
|
644
|
-
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
645
|
-
|
|
646
|
-
// Calculate output paths for this file
|
|
647
|
-
const outputFilename = file
|
|
648
|
-
.replace(source, output)
|
|
649
|
-
.replace(parse(file).ext, ".html");
|
|
650
|
-
const url = '/' + outputFilename.replace(output, '');
|
|
651
|
-
|
|
652
|
-
// Generate URL path relative to output (for search index)
|
|
653
|
-
const relativePath = file.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
|
|
654
|
-
const searchUrl = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
655
|
-
|
|
656
|
-
// Generate title from filename (in title case)
|
|
657
|
-
// For index/home files, use parent folder name instead
|
|
658
|
-
const titleBase = (base === 'index' || base === 'home') ? basename(dirname(file)) : base;
|
|
659
|
-
const title = toTitleCase(titleBase || base);
|
|
660
|
-
|
|
661
|
-
// Always add to search index (lightweight: title + path only, content added lazily)
|
|
662
|
-
searchIndex.push({
|
|
663
|
-
title: title,
|
|
664
|
-
path: relativePath,
|
|
665
|
-
url: searchUrl,
|
|
666
|
-
content: '' // Content excerpts built lazily to save memory
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
// Add document to full-text index (uses raw markdown content)
|
|
670
|
-
fullTextDocs.push({
|
|
671
|
-
path: relativePath,
|
|
672
|
-
title: title,
|
|
673
|
-
content: rawBody
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// Collect timestamp for recent activity tracking
|
|
677
|
-
// Use stored content timestamp if available, otherwise fall back to file mtime
|
|
678
|
-
// Content timestamps track when content actually changed, not filesystem mtime
|
|
679
|
-
const storedTimestamp = contentTimestamps.get(relativePath);
|
|
680
|
-
let activityTimestamp = storedTimestamp;
|
|
681
|
-
if (!activityTimestamp) {
|
|
682
|
-
// No stored timestamp - use file mtime as initial value
|
|
683
|
-
try {
|
|
684
|
-
const fileStat = await stat(file);
|
|
685
|
-
activityTimestamp = fileStat.mtimeMs;
|
|
686
|
-
} catch (e) {
|
|
687
|
-
activityTimestamp = 0;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
recentActivity.push({
|
|
691
|
-
title: title,
|
|
692
|
-
url: searchUrl,
|
|
693
|
-
mtime: activityTimestamp
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
// Check if a corresponding .html file already exists in source directory
|
|
697
|
-
const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
|
698
|
-
if (existingHtmlFiles.has(outputHtmlRelative)) {
|
|
699
|
-
progress.log(`⚠️ Warning: Skipping ${shortFile} - would overwrite existing ${outputHtmlRelative} in source`);
|
|
700
|
-
skippedCount++;
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Skip metadata-only index files - they exist only to provide folder metadata
|
|
705
|
-
// The auto-index system will generate the actual index.html for these folders
|
|
706
|
-
if (base === 'index' && (type === '.md' || type === '.mdx') && isMetadataOnly(rawBody)) {
|
|
707
|
-
progress.log(`ℹ️ Skipping metadata-only ${shortFile} - auto-index will generate listing`);
|
|
708
|
-
skippedCount++;
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Metadata is needed both for the cache decision below and for rendering.
|
|
713
|
-
const fileMeta = extractMetadata(rawBody);
|
|
714
|
-
|
|
715
|
-
// A document with `generate-auto-index: true` renders a listing of the
|
|
716
|
-
// source tree, so its output depends on which folders and files exist —
|
|
717
|
-
// not just on its own text. A content hash cannot see that: adding,
|
|
718
|
-
// renaming, or deleting a folder anywhere in the tree leaves the hash
|
|
719
|
-
// untouched and the listing stale forever. Only a handful of documents
|
|
720
|
-
// opt in, so always rebuild them.
|
|
721
|
-
const hasAutoIndex = getAutoIndexConfig(fileMeta).enabled;
|
|
722
|
-
|
|
723
|
-
// Check if file needs regeneration.
|
|
724
|
-
// An unchanged hash is not enough: the hash cache lives in the source tree
|
|
725
|
-
// and is shared across output dirs, so also require that every output this
|
|
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
|
-
];
|
|
739
|
-
const needsRegen =
|
|
740
|
-
_clean ||
|
|
741
|
-
hasAutoIndex ||
|
|
742
|
-
needsRegeneration(file, rawBody, hashCache) ||
|
|
743
|
-
!outputsExist(expectedOutputs);
|
|
744
|
-
|
|
745
|
-
if (!needsRegen) {
|
|
746
|
-
skippedCount++;
|
|
747
|
-
// For directory indices, store minimal data (not full bodyHtml)
|
|
748
|
-
// But include metadata for directory JSON files
|
|
749
|
-
dirIndexCache.set(file, {
|
|
750
|
-
name: base,
|
|
751
|
-
url,
|
|
752
|
-
metadata: fileMeta,
|
|
753
|
-
});
|
|
754
|
-
return; // Skip regenerating this file
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
regeneratedCount++;
|
|
758
|
-
// Track this path for incremental search index updates
|
|
759
|
-
changedPaths.add(relativePath);
|
|
760
|
-
|
|
761
|
-
const rawMeta = extractRawMetadata(rawBody);
|
|
762
|
-
|
|
763
|
-
// Lazy metadata transform - only compute if template actually uses it
|
|
764
|
-
// This defers the potentially expensive custom transform function load
|
|
765
|
-
let transformedMetadata = null;
|
|
766
|
-
const getTransformedMeta = async () => {
|
|
767
|
-
if (transformedMetadata === null) {
|
|
768
|
-
transformedMetadata = await getTransformedMetadata(dirname(file), fileMeta);
|
|
769
|
-
}
|
|
770
|
-
return transformedMetadata;
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
// Calculate the document's URL path (e.g., "/character/index.html")
|
|
774
|
-
const docUrlPath = '/' + dir + base + '.html';
|
|
775
|
-
|
|
776
|
-
// Use async rendering with worker threads for parallel markdown parsing
|
|
777
|
-
// Wikitext (.txt) files will fall back to main thread
|
|
778
|
-
// For MDX files, enable hydration if frontmatter has `hydrate: true`
|
|
779
|
-
const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
|
|
780
|
-
|
|
781
|
-
let renderResult = await renderFileAsync({
|
|
782
|
-
fileContents: rawBody,
|
|
783
|
-
type,
|
|
784
|
-
dirname: dir,
|
|
785
|
-
basename: base,
|
|
786
|
-
filePath: file,
|
|
787
|
-
sourceRoot: source,
|
|
788
|
-
useWorker: true,
|
|
789
|
-
hydrate: shouldHydrate,
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
// Handle the result - can be string or { html, hydrationScript }
|
|
793
|
-
let body;
|
|
794
|
-
let hydrationScript = '';
|
|
795
|
-
if (typeof renderResult === 'object' && renderResult.html) {
|
|
796
|
-
body = renderResult.html;
|
|
797
|
-
hydrationScript = renderResult.hydrationScript || '';
|
|
798
|
-
} else {
|
|
799
|
-
body = renderResult;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Inject default H1 if body doesn't start with one
|
|
803
|
-
if (!body || !body.trimStart().startsWith('<h1')) {
|
|
804
|
-
const h1Title = fileMeta?.title || title;
|
|
805
|
-
body = `<h1>${h1Title}</h1>\n` + (body || '');
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// Inject breadcrumbs before the H1
|
|
809
|
-
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
|
|
810
|
-
if (breadcrumbs) {
|
|
811
|
-
body = breadcrumbs + body;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// Inject frontmatter table after first H1 (for markdown files with metadata)
|
|
815
|
-
if ((type === '.md' || type === '.mdx') && fileMeta) {
|
|
816
|
-
body = injectFrontmatterTable(body, fileMeta);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// Handle auto-index generation for index files with generate-auto-index: true
|
|
820
|
-
if (base === 'index' && fileMeta) {
|
|
821
|
-
const autoIndexConfig = getAutoIndexConfig(fileMeta);
|
|
822
|
-
if (autoIndexConfig.enabled) {
|
|
823
|
-
// Generate auto-index HTML for this directory from source
|
|
824
|
-
// Using source avoids race conditions with concurrent file generation
|
|
825
|
-
const sourceDir = dirname(file);
|
|
826
|
-
const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
|
|
827
|
-
|
|
828
|
-
if (autoIndexHtml) {
|
|
829
|
-
if (autoIndexConfig.position === 'bottom') {
|
|
830
|
-
body = body + '\n' + autoIndexHtml;
|
|
831
|
-
} else {
|
|
832
|
-
body = autoIndexHtml + '\n' + body;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
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}" />`;
|
|
855
|
-
}
|
|
856
|
-
} else {
|
|
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
|
-
}
|
|
879
|
-
}
|
|
880
|
-
} catch (e) {
|
|
881
|
-
// ignore
|
|
882
|
-
console.error(e);
|
|
883
|
-
}
|
|
884
|
-
|
|
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;
|
|
891
|
-
|
|
892
|
-
let cachedBundleUrl = docBundleCache.get(`js:${dirKey}`);
|
|
893
|
-
if (cachedBundleUrl !== undefined) {
|
|
894
|
-
if (cachedBundleUrl) {
|
|
895
|
-
customScript = `<script src="${cachedBundleUrl}"></script>`;
|
|
896
|
-
}
|
|
897
|
-
} else {
|
|
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
|
-
}
|
|
910
|
-
}
|
|
911
|
-
} catch (e) {
|
|
912
|
-
// ignore
|
|
913
|
-
console.error(e);
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
917
|
-
const templateName = requestedTemplateName || DEFAULT_TEMPLATE_NAME;
|
|
918
|
-
const template = templates[templateName];
|
|
919
|
-
|
|
920
|
-
if (!template) {
|
|
921
|
-
throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
922
|
-
}
|
|
923
|
-
|
|
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
|
-
}
|
|
935
|
-
|
|
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;
|
|
952
|
-
|
|
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
|
-
}
|
|
982
|
-
|
|
983
|
-
// Resolve relative URLs in raw HTML elements (img src, etc.)
|
|
984
|
-
finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
|
|
985
|
-
|
|
986
|
-
// Resolve links and mark broken internal links as inactive
|
|
987
|
-
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
988
|
-
|
|
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
|
-
}
|
|
994
|
-
|
|
995
|
-
// Add cache-busting timestamps to static file references
|
|
996
|
-
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
997
|
-
|
|
998
|
-
await outputFile(outputFilename, finalHtml);
|
|
999
|
-
|
|
1000
|
-
// Clear finalHtml reference to allow GC
|
|
1001
|
-
finalHtml = null;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// JSON output
|
|
1005
|
-
const jsonOutputFilename = outputFilename.replace(".html", ".json");
|
|
1006
|
-
|
|
1007
|
-
// Extract sections for markdown files
|
|
1008
|
-
const sections = (type === '.md' || type === '.mdx') ? extractSections(rawBody) : [];
|
|
1009
|
-
|
|
1010
|
-
// Use lazy metadata for JSON output - may have been computed above for HTML
|
|
1011
|
-
const jsonTransformedMeta = await getTransformedMeta();
|
|
1012
|
-
|
|
1013
|
-
const jsonObject = {
|
|
1014
|
-
name: base,
|
|
1015
|
-
url,
|
|
1016
|
-
contents: rawBody,
|
|
1017
|
-
bodyHtml: body,
|
|
1018
|
-
metadata: fileMeta,
|
|
1019
|
-
sections,
|
|
1020
|
-
transformedMetadata: jsonTransformedMeta,
|
|
1021
|
-
_ursa_metadata: ursaMetadata,
|
|
1022
|
-
};
|
|
1023
|
-
|
|
1024
|
-
// Store minimal data for directory indices, including metadata
|
|
1025
|
-
dirIndexCache.set(file, {
|
|
1026
|
-
name: base,
|
|
1027
|
-
url,
|
|
1028
|
-
metadata: fileMeta,
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
const json = JSON.stringify(jsonObject);
|
|
1032
|
-
await outputFile(jsonOutputFilename, json);
|
|
1033
|
-
|
|
1034
|
-
// XML output
|
|
1035
|
-
if (!_jsonOnly) {
|
|
1036
|
-
const xmlOutputFilename = outputFilename.replace(".html", ".xml");
|
|
1037
|
-
const xml = `<article>${o2x(jsonObject)}</article>`;
|
|
1038
|
-
await outputFile(xmlOutputFilename, xml);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
// Update the content hash for this file
|
|
1042
|
-
updateHash(file, rawBody, hashCache);
|
|
1043
|
-
|
|
1044
|
-
// Update content timestamp since this file was regenerated (content changed)
|
|
1045
|
-
contentTimestamps.set(relativePath, buildTimestamp);
|
|
1046
|
-
// Also update the recentActivity entry we pushed earlier with the new timestamp
|
|
1047
|
-
const activityEntry = recentActivity.find(e => e.url === searchUrl);
|
|
1048
|
-
if (activityEntry) {
|
|
1049
|
-
activityEntry.mtime = buildTimestamp;
|
|
1050
|
-
}
|
|
1051
|
-
} catch (e) {
|
|
1052
|
-
progress.log(`Error processing ${file}: ${e.message}`);
|
|
1053
|
-
errors.push({ file, phase: 'article-generation', error: e });
|
|
1054
|
-
}
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
// Complete the articles status line
|
|
1058
|
-
progress.done('Articles', `${totalArticles} done (${regeneratedCount} regenerated, ${skippedCount} unchanged) [${progress.stopTimer('Articles')}]`);
|
|
1059
|
-
profiler.endPhase('Process articles');
|
|
1060
|
-
|
|
1061
|
-
// Phase: Write search index
|
|
1062
|
-
// Can be deferred in serve mode for faster startup
|
|
1063
|
-
let deferredSearchIndexPromise = null;
|
|
1064
|
-
|
|
1065
|
-
const buildSearchIndex = async () => {
|
|
1066
|
-
const indexStartTime = Date.now();
|
|
1067
|
-
progress.logTimed('Building search index...');
|
|
1068
|
-
|
|
1069
|
-
// Write search index as a separate JSON file (not embedded in each page)
|
|
1070
|
-
const searchIndexPath = join(output, 'public', 'search-index.json');
|
|
1071
|
-
progress.log(`Writing search index with ${searchIndex.length} entries`);
|
|
1072
|
-
await outputFile(searchIndexPath, JSON.stringify(searchIndex));
|
|
1073
|
-
|
|
1074
|
-
// Build full-text index - use incremental mode when possible
|
|
1075
|
-
let fullTextIndex;
|
|
1076
|
-
if (!_clean && changedPaths.size > 0 && changedPaths.size < fullTextDocs.length) {
|
|
1077
|
-
// Incremental update: only re-index changed documents
|
|
1078
|
-
progress.log(`Incremental full-text index: ${changedPaths.size} changed, ${fullTextDocs.length - changedPaths.size} cached`);
|
|
1079
|
-
fullTextIndex = buildIncrementalIndex(fullTextDocs, changedPaths, source);
|
|
1080
|
-
} else {
|
|
1081
|
-
// Full rebuild: clean build or all documents changed
|
|
1082
|
-
progress.log(`Building full-text index from ${fullTextDocs.length} documents...`);
|
|
1083
|
-
fullTextIndex = buildFullTextIndex(fullTextDocs);
|
|
1084
|
-
// Save to cache for future incremental updates
|
|
1085
|
-
saveIndexCache(source, fullTextIndex);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
const fullTextIndexPath = join(output, 'public', 'fulltext-index.json');
|
|
1089
|
-
const fullTextIndexJson = JSON.stringify(fullTextIndex);
|
|
1090
|
-
const wordCount = Object.keys(fullTextIndex).length;
|
|
1091
|
-
progress.log(`Writing full-text index (${wordCount} unique words, ${(fullTextIndexJson.length / 1024).toFixed(1)} KB)`);
|
|
1092
|
-
await outputFile(fullTextIndexPath, fullTextIndexJson);
|
|
1093
|
-
|
|
1094
|
-
const elapsed = ((Date.now() - indexStartTime) / 1000).toFixed(1);
|
|
1095
|
-
return { entries: searchIndex.length, words: wordCount, elapsed };
|
|
1096
|
-
};
|
|
1097
|
-
|
|
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) {
|
|
1106
|
-
// Deferred mode: start building in background, return promise
|
|
1107
|
-
profiler.startPhase('Write search index (deferred)');
|
|
1108
|
-
progress.startTimer('Search index');
|
|
1109
|
-
progress.log('Search index building deferred for faster startup...');
|
|
1110
|
-
deferredSearchIndexPromise = buildSearchIndex().then(result => {
|
|
1111
|
-
progress.done('Search index (background)', `${result.entries} entries, ${result.words} words in ${result.elapsed}s`);
|
|
1112
|
-
return result;
|
|
1113
|
-
});
|
|
1114
|
-
progress.done('Search index', 'deferred [0ms]');
|
|
1115
|
-
profiler.endPhase('Write search index (deferred)');
|
|
1116
|
-
} else {
|
|
1117
|
-
// Normal mode: build search index now
|
|
1118
|
-
profiler.startPhase('Write search index');
|
|
1119
|
-
progress.startTimer('Search index');
|
|
1120
|
-
const result = await buildSearchIndex();
|
|
1121
|
-
progress.done('Search index', `${result.entries} entries, ${result.words} words [${progress.stopTimer('Search index')}]`);
|
|
1122
|
-
profiler.endPhase('Write search index');
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// Phase: Write recent activity data
|
|
1126
|
-
profiler.startPhase('Write recent activity');
|
|
1127
|
-
progress.startTimer('Recent activity');
|
|
1128
|
-
if (!_jsonOnly) {
|
|
1129
|
-
// Sort by mtime descending, keep top 10
|
|
1130
|
-
recentActivity.sort((a, b) => b.mtime - a.mtime);
|
|
1131
|
-
const top10 = recentActivity.slice(0, 10);
|
|
1132
|
-
const recentActivityPath = join(output, 'public', 'recent-activity.json');
|
|
1133
|
-
await outputFile(recentActivityPath, JSON.stringify(top10));
|
|
1134
|
-
progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
|
|
1135
|
-
} else {
|
|
1136
|
-
progress.done('Recent activity', `skipped (JSON-only) [${progress.stopTimer('Recent activity')}]`);
|
|
1137
|
-
}
|
|
1138
|
-
profiler.endPhase('Write recent activity');
|
|
1139
|
-
|
|
1140
|
-
// Phase: Write menu data
|
|
1141
|
-
profiler.startPhase('Write menu data');
|
|
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 {
|
|
1148
|
-
// Write menu data as a separate JSON file (not embedded in each page)
|
|
1149
|
-
// This dramatically reduces HTML file sizes for large sites
|
|
1150
|
-
const menuDataPath = join(output, 'public', 'menu-data.json');
|
|
1151
|
-
const menuDataJson = JSON.stringify(menuData);
|
|
1152
|
-
progress.log(`Writing menu data (${(menuDataJson.length / 1024).toFixed(1)} KB)`);
|
|
1153
|
-
await outputFile(menuDataPath, menuDataJson);
|
|
1154
|
-
|
|
1155
|
-
// Write custom menu JSON files
|
|
1156
|
-
for (const [menuDir, menuInfo] of customMenus) {
|
|
1157
|
-
const customMenuPath = join(output, menuInfo.menuJsonPath);
|
|
1158
|
-
// Include menuPosition in the JSON so client knows how to render
|
|
1159
|
-
const customMenuJson = JSON.stringify({
|
|
1160
|
-
menuData: menuInfo.menuData,
|
|
1161
|
-
menuPosition: menuInfo.menuPosition || 'top',
|
|
1162
|
-
});
|
|
1163
|
-
progress.log(`Writing custom menu: ${menuInfo.menuJsonPath}`);
|
|
1164
|
-
await outputFile(customMenuPath, customMenuJson);
|
|
1165
|
-
}
|
|
1166
|
-
progress.done('Menu data', `${customMenus.size + 1} files [${progress.stopTimer('Menu data')}]`);
|
|
1167
|
-
}
|
|
1168
|
-
profiler.endPhase('Write menu data');
|
|
1169
|
-
|
|
1170
|
-
// Phase: Process directory indices
|
|
1171
|
-
profiler.startPhase('Process directories');
|
|
1172
|
-
progress.startTimer('Directories');
|
|
1173
|
-
|
|
1174
|
-
// Output paths that a source document already owns. The generated directory
|
|
1175
|
-
// listing below writes to <dir>.html, which collides with two things: an
|
|
1176
|
-
// article named after its own folder (settings/dying-light.md renders to
|
|
1177
|
-
// settings/dying-light.html) and a hand-written .html copied from the source
|
|
1178
|
-
// tree. Those documents win — the listing must never overwrite them.
|
|
1179
|
-
const documentOwnedOutputs = new Set(
|
|
1180
|
-
allSourceFilenamesThatAreArticles.map((filename) =>
|
|
1181
|
-
filename.replace(source, output).replace(/\.(md|mdx|txt|yml)$/, ".html")
|
|
1182
|
-
)
|
|
1183
|
-
);
|
|
1184
|
-
for (const relativeHtmlPath of existingHtmlFiles) {
|
|
1185
|
-
documentOwnedOutputs.add(join(output, relativeHtmlPath));
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
// Process directory indices with batched concurrency
|
|
1189
|
-
const totalDirs = allSourceFilenamesThatAreDirectories.length;
|
|
1190
|
-
let processedDirs = 0;
|
|
1191
|
-
progress.log(`Processing ${totalDirs} directories...`);
|
|
1192
|
-
await processBatched(allSourceFilenamesThatAreDirectories, async (dirPath) => {
|
|
1193
|
-
try {
|
|
1194
|
-
processedDirs++;
|
|
1195
|
-
const shortDir = dirPath.replace(source, '');
|
|
1196
|
-
progress.status('Directories', `${processedDirs}/${totalDirs} ${shortDir}`);
|
|
1197
|
-
|
|
1198
|
-
const pathsInThisDirectory = allSourceFilenames.filter((filename) =>
|
|
1199
|
-
filename.match(new RegExp(`${dirPath}.+`))
|
|
1200
|
-
);
|
|
1201
|
-
|
|
1202
|
-
// Use minimal directory index cache instead of full jsonCache
|
|
1203
|
-
const jsonObjects = pathsInThisDirectory
|
|
1204
|
-
.map((path) => {
|
|
1205
|
-
const object = dirIndexCache.get(path);
|
|
1206
|
-
return typeof object === "object" ? object : null;
|
|
1207
|
-
})
|
|
1208
|
-
.filter((a) => a);
|
|
1209
|
-
|
|
1210
|
-
const json = JSON.stringify(jsonObjects);
|
|
1211
|
-
|
|
1212
|
-
const outputFilename = dirPath.replace(source, output) + ".json";
|
|
1213
|
-
await outputFile(outputFilename, json);
|
|
1214
|
-
|
|
1215
|
-
// html
|
|
1216
|
-
// Rewritten every build: the listing reflects the directory's contents,
|
|
1217
|
-
// so skipping it whenever the file already exists (the old behaviour)
|
|
1218
|
-
// froze it at whatever the tree looked like the first time it was
|
|
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.
|
|
1223
|
-
const htmlOutputFilename = dirPath.replace(source, output) + ".html";
|
|
1224
|
-
if (!_jsonOnly && !documentOwnedOutputs.has(htmlOutputFilename)) {
|
|
1225
|
-
const template = templates["default-template"];
|
|
1226
|
-
const indexHtml = `<ul>${pathsInThisDirectory
|
|
1227
|
-
.map((path) => {
|
|
1228
|
-
const partialPath = path
|
|
1229
|
-
.replace(source, "")
|
|
1230
|
-
.replace(parse(path).ext, ".html");
|
|
1231
|
-
const name = basename(path, parse(path).ext);
|
|
1232
|
-
return `<li><a href="${partialPath}">${name}</a></li>`;
|
|
1233
|
-
})
|
|
1234
|
-
.join("")}</ul>`;
|
|
1235
|
-
let finalHtml = template;
|
|
1236
|
-
const replacements = {
|
|
1237
|
-
"${menu}": menu,
|
|
1238
|
-
"${body}": indexHtml,
|
|
1239
|
-
"${searchIndex}": "[]", // Search index now in separate file
|
|
1240
|
-
"${title}": "Index",
|
|
1241
|
-
"${meta}": "{}",
|
|
1242
|
-
"${transformedMetadata}": "",
|
|
1243
|
-
"${styleLink}": "",
|
|
1244
|
-
"${footer}": footer
|
|
1245
|
-
};
|
|
1246
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
1247
|
-
finalHtml = finalHtml.replace(key, value);
|
|
1248
|
-
}
|
|
1249
|
-
// Add cache-busting timestamps to static file references
|
|
1250
|
-
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
1251
|
-
await outputFile(htmlOutputFilename, finalHtml);
|
|
1252
|
-
}
|
|
1253
|
-
} catch (e) {
|
|
1254
|
-
progress.log(`Error processing directory ${dirPath}: ${e.message}`);
|
|
1255
|
-
errors.push({ file: dirPath, phase: 'directory-index', error: e });
|
|
1256
|
-
}
|
|
46
|
+
const output = resolve(_output);
|
|
47
|
+
console.log({ source, meta, output, whitelist: _whitelist, exclude: _exclude, clean: _clean, jsonOnly: _jsonOnly });
|
|
48
|
+
|
|
49
|
+
profiler.startPhase("Prepare");
|
|
50
|
+
const build = await createBuild({
|
|
51
|
+
source,
|
|
52
|
+
meta,
|
|
53
|
+
output,
|
|
54
|
+
whitelist: _whitelist,
|
|
55
|
+
exclude: _exclude,
|
|
56
|
+
clean: _clean,
|
|
57
|
+
jsonOnly: _jsonOnly,
|
|
58
|
+
explain: _explain,
|
|
1257
59
|
});
|
|
1258
|
-
|
|
1259
|
-
progress.done('Directories', `${totalDirs} done [${progress.stopTimer('Directories')}]`);
|
|
1260
|
-
profiler.endPhase('Process directories');
|
|
1261
|
-
|
|
1262
|
-
// Clear directory index cache to free memory before processing static files
|
|
1263
|
-
dirIndexCache.clear();
|
|
60
|
+
profiler.endPhase("Prepare");
|
|
1264
61
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
// Also copy existing HTML files from source to output (they're treated as static)
|
|
1272
|
-
const allSourceFilenamesThatAreHtml = allSourceFilenames.filter(
|
|
1273
|
-
(filename) => filename.match(/\.html$/) && !isHiddenOrSystem(filename)
|
|
1274
|
-
);
|
|
1275
|
-
|
|
1276
|
-
// Fonts, audio, video, PDFs: copied through untouched. Images are handled
|
|
1277
|
-
// separately above because they also get previews; everything else that is
|
|
1278
|
-
// neither an article nor a stylesheet belongs here. Leaving this out is what
|
|
1279
|
-
// let `ursa serve` and `ursa generate` disagree — serve reads these straight
|
|
1280
|
-
// off disk, so the missing copy step only ever showed up in a built site.
|
|
1281
|
-
const allSourceFilenamesThatAreMedia = allSourceFilenames.filter(
|
|
1282
|
-
(filename) => isMedia(filename) && !isHiddenOrSystem(filename)
|
|
1283
|
-
);
|
|
1284
|
-
|
|
1285
|
-
// JSON-only emits data, not a servable site, so nothing is copied through.
|
|
1286
|
-
const allStaticFiles = _jsonOnly
|
|
1287
|
-
? []
|
|
1288
|
-
: [...allSourceFilenamesThatAreHtml, ...allSourceFilenamesThatAreMedia];
|
|
1289
|
-
const totalStatic = allStaticFiles.length;
|
|
1290
|
-
let processedStatic = 0;
|
|
1291
|
-
let copiedStatic = 0;
|
|
1292
|
-
progress.log(
|
|
1293
|
-
`Processing ${totalStatic} static files ` +
|
|
1294
|
-
`(${allSourceFilenamesThatAreHtml.length} HTML, ${allSourceFilenamesThatAreMedia.length} media)...`
|
|
1295
|
-
);
|
|
1296
|
-
await processBatched(allStaticFiles, async (file) => {
|
|
1297
|
-
try {
|
|
1298
|
-
processedStatic++;
|
|
1299
|
-
const shortFile = file.replace(source, '');
|
|
1300
|
-
progress.status('Static files', `${processedStatic}/${totalStatic} ${shortFile}`);
|
|
1301
|
-
|
|
1302
|
-
// Check if file has changed using file stat as a quick check
|
|
1303
|
-
const fileStat = await stat(file);
|
|
1304
|
-
const statKey = `${file}:stat`;
|
|
1305
|
-
const newStatHash = `${fileStat.size}:${fileStat.mtimeMs}`;
|
|
1306
|
-
const outputFilename = file.replace(source, output);
|
|
1307
|
-
// As with articles: an unchanged stat only means the source is untouched,
|
|
1308
|
-
// so the output must exist before this copy can be skipped.
|
|
1309
|
-
if (hashCache.get(statKey) === newStatHash && outputsExist([outputFilename])) {
|
|
1310
|
-
return; // Skip unchanged static file
|
|
1311
|
-
}
|
|
1312
|
-
hashCache.set(statKey, newStatHash);
|
|
1313
|
-
copiedStatic++;
|
|
1314
|
-
|
|
1315
|
-
await mkdir(dirname(outputFilename), { recursive: true });
|
|
1316
|
-
|
|
1317
|
-
if (file.endsWith('.css')) {
|
|
1318
|
-
// Process CSS for cache busting
|
|
1319
|
-
const cssContent = await readFile(file, 'utf8');
|
|
1320
|
-
const processedCss = addTimestampToCssUrls(cssContent, cacheBustTimestamp);
|
|
1321
|
-
await outputFile(outputFilename, processedCss);
|
|
1322
|
-
} else if (file.endsWith('.html')) {
|
|
1323
|
-
// Process HTML files for link resolution
|
|
1324
|
-
let htmlContent = await readFile(file, 'utf8');
|
|
1325
|
-
// Calculate the document's URL path for relative link resolution
|
|
1326
|
-
const docUrlPath = '/' + file.replace(source, '').replace(/^\//, '');
|
|
1327
|
-
// Resolve relative URLs in raw HTML elements (img src, etc.)
|
|
1328
|
-
htmlContent = resolveRelativeUrls(htmlContent, docUrlPath);
|
|
1329
|
-
// Resolve internal links to have proper .html extensions
|
|
1330
|
-
htmlContent = markInactiveLinks(htmlContent, validPaths, docUrlPath, false);
|
|
1331
|
-
// Transform image tags to use preview images with data-fullsrc for originals
|
|
1332
|
-
// Skip in deferred mode - images will use original paths until preview generation completes
|
|
1333
|
-
if (!_deferImages) {
|
|
1334
|
-
htmlContent = transformImageTags(htmlContent, imageMap, docUrlPath);
|
|
1335
|
-
}
|
|
1336
|
-
// Add cache-busting timestamps
|
|
1337
|
-
htmlContent = addTimestampToHtmlStaticRefs(htmlContent, cacheBustTimestamp);
|
|
1338
|
-
await outputFile(outputFilename, htmlContent);
|
|
1339
|
-
} else {
|
|
1340
|
-
await copyFile(file, outputFilename);
|
|
1341
|
-
}
|
|
1342
|
-
} catch (e) {
|
|
1343
|
-
progress.log(`Error processing static file ${file}: ${e.message}`);
|
|
1344
|
-
errors.push({ file, phase: 'static-file', error: e });
|
|
1345
|
-
}
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
progress.done('Static files', `${totalStatic} done (${copiedStatic} copied) [${progress.stopTimer('Static files')}]`);
|
|
1349
|
-
profiler.endPhase('Process static files');
|
|
1350
|
-
|
|
1351
|
-
// Phase: Auto-index generation
|
|
1352
|
-
profiler.startPhase('Auto-index generation');
|
|
1353
|
-
progress.startTimer('Auto-index');
|
|
1354
|
-
// Automatic index generation for folders without index.html
|
|
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
|
-
}
|
|
1364
|
-
profiler.endPhase('Auto-index generation');
|
|
1365
|
-
|
|
1366
|
-
// Phase: Finalization
|
|
1367
|
-
profiler.startPhase('Finalization');
|
|
1368
|
-
progress.startTimer('Finalization');
|
|
1369
|
-
// Save the hash cache to .ursa folder in source directory
|
|
1370
|
-
if (hashCache.size > 0) {
|
|
1371
|
-
await saveHashCache(source, hashCache);
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
// Save content timestamps to .ursa.json (tracks when content actually changed)
|
|
1375
|
-
if (contentTimestamps.size > 0) {
|
|
1376
|
-
saveContentTimestamps(source, contentTimestamps);
|
|
1377
|
-
progress.log(`Saved ${contentTimestamps.size} content timestamps`);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// Persist the dependency tracker so hash-skipped documents keep their
|
|
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
|
-
}
|
|
1390
|
-
|
|
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) {
|
|
1396
|
-
watchModeCache.templates = templates;
|
|
1397
|
-
watchModeCache.menu = menu;
|
|
1398
|
-
watchModeCache.footer = footer;
|
|
1399
|
-
watchModeCache.validPaths = validPaths;
|
|
1400
|
-
watchModeCache.source = source;
|
|
1401
|
-
watchModeCache.meta = meta;
|
|
1402
|
-
watchModeCache.output = output;
|
|
1403
|
-
watchModeCache.hashCache = hashCache;
|
|
1404
|
-
watchModeCache.cacheBustTimestamp = cacheBustTimestamp;
|
|
1405
|
-
watchModeCache.cacheBustHashes = cacheBustHashes;
|
|
1406
|
-
watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
|
|
1407
|
-
watchModeCache.imageMap = imageMap;
|
|
1408
|
-
watchModeCache.customMenus = customMenus;
|
|
1409
|
-
watchModeCache.ursaMetadata = ursaMetadata;
|
|
1410
|
-
watchModeCache.lastFullBuild = Date.now();
|
|
1411
|
-
watchModeCache.isInitialized = true;
|
|
1412
|
-
const depStats = dependencyTracker.getStats();
|
|
1413
|
-
progress.log(`Watch cache initialized (${depStats.totalDocuments} documents, ${depStats.uniqueFiles} dependencies tracked)`);
|
|
62
|
+
profiler.startPhase("Pass");
|
|
63
|
+
let summary;
|
|
64
|
+
try {
|
|
65
|
+
summary = await build.runPass();
|
|
66
|
+
} finally {
|
|
67
|
+
await build.close();
|
|
1414
68
|
}
|
|
69
|
+
profiler.endPhase("Pass");
|
|
1415
70
|
|
|
1416
|
-
//
|
|
1417
|
-
if (
|
|
1418
|
-
const errorReportPath = join(output,
|
|
1419
|
-
const failedFiles = errors.map(e => e.file);
|
|
1420
|
-
|
|
71
|
+
// Error report
|
|
72
|
+
if (summary.failures.size > 0) {
|
|
73
|
+
const errorReportPath = join(output, "_errors.log");
|
|
1421
74
|
let report = `URSA GENERATION ERROR REPORT\n`;
|
|
1422
75
|
report += `Generated: ${new Date().toISOString()}\n`;
|
|
1423
|
-
report += `Total errors: ${
|
|
1424
|
-
report += `${
|
|
1425
|
-
report += `
|
|
1426
|
-
report +=
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
report += `ERROR DETAILS:\n`;
|
|
1432
|
-
report += `${'='.repeat(60)}\n\n`;
|
|
1433
|
-
|
|
1434
|
-
errors.forEach(({ file, phase, error }) => {
|
|
1435
|
-
report += `${'─'.repeat(60)}\n`;
|
|
1436
|
-
report += `File: ${file}\n`;
|
|
1437
|
-
report += `Phase: ${phase}\n`;
|
|
1438
|
-
report += `Error: ${error.message}\n`;
|
|
1439
|
-
if (error.stack) {
|
|
1440
|
-
report += `Stack:\n${error.stack}\n`;
|
|
1441
|
-
}
|
|
76
|
+
report += `Total errors: ${summary.failures.size}\n\n`;
|
|
77
|
+
report += `${"=".repeat(60)}\nFAILED NODES:\n${"=".repeat(60)}\n\n`;
|
|
78
|
+
for (const id of summary.failures.keys()) report += ` - ${id}\n`;
|
|
79
|
+
report += `\n${"=".repeat(60)}\nERROR DETAILS:\n${"=".repeat(60)}\n\n`;
|
|
80
|
+
for (const [id, error] of summary.failures) {
|
|
81
|
+
const cause = error.cause ?? error;
|
|
82
|
+
report += `${"─".repeat(60)}\nNode: ${id}\nError: ${cause.message}\n`;
|
|
83
|
+
if (cause.stack) report += `Stack:\n${cause.stack}\n`;
|
|
1442
84
|
report += `\n`;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
85
|
+
}
|
|
1445
86
|
await outputFile(errorReportPath, report);
|
|
1446
|
-
|
|
1447
|
-
|
|
87
|
+
console.log(`\n⚠️ ${summary.failures.size} error(s) occurred during generation.`);
|
|
88
|
+
console.log(` Error report written to: ${errorReportPath}\n`);
|
|
1448
89
|
} else {
|
|
1449
|
-
|
|
90
|
+
console.log(`\n✅ Generation complete with no errors.\n`);
|
|
1450
91
|
}
|
|
1451
|
-
|
|
1452
|
-
progress.done('Finalization', `complete [${progress.stopTimer('Finalization')}]`);
|
|
1453
|
-
profiler.endPhase('Finalization');
|
|
1454
|
-
|
|
1455
|
-
// Print profiler report
|
|
1456
|
-
progress.log(profiler.report());
|
|
1457
|
-
|
|
1458
|
-
// Terminate worker pool so threads don't keep the process alive
|
|
1459
|
-
await terminateParserPool();
|
|
1460
92
|
|
|
1461
|
-
|
|
1462
|
-
|
|
93
|
+
console.log(profiler.report());
|
|
94
|
+
|
|
1463
95
|
return {
|
|
1464
|
-
|
|
1465
|
-
|
|
96
|
+
summary,
|
|
97
|
+
// Legacy shape: nothing is deferred any more
|
|
98
|
+
deferredImageProcessing: null,
|
|
99
|
+
deferredSearchIndex: null,
|
|
1466
100
|
};
|
|
1467
101
|
}
|
|
1468
|
-
|
|
1469
|
-
/**
|
|
1470
|
-
* Regenerate multiple documents affected by a dependency change (e.g., style.css, script.js, template).
|
|
1471
|
-
* Uses the watchModeCache and dependency tracker to efficiently re-render affected documents
|
|
1472
|
-
* with updated cache-bust timestamps.
|
|
1473
|
-
*
|
|
1474
|
-
* @param {string[]} documentPaths - Absolute paths to documents to regenerate
|
|
1475
|
-
* @param {Object} options
|
|
1476
|
-
* @param {string} options._source - Source directory
|
|
1477
|
-
* @param {string} options._meta - Meta directory
|
|
1478
|
-
* @param {string} options._output - Output directory
|
|
1479
|
-
* @param {string} [options.reason] - Reason for regeneration (for logging)
|
|
1480
|
-
* @param {string[]} [options.priorityPaths] - Document paths to regenerate first (e.g. client-viewed docs)
|
|
1481
|
-
* @param {function} [options.onPriorityComplete] - Callback after priority paths are done (receives { regenerated, failed })
|
|
1482
|
-
* @returns {Promise<{success: boolean, message: string, regenerated: number, failed: number}>}
|
|
1483
|
-
*/
|
|
1484
|
-
export async function regenerateAffectedDocuments(documentPaths, {
|
|
1485
|
-
_source,
|
|
1486
|
-
_meta,
|
|
1487
|
-
_output,
|
|
1488
|
-
reason = "dependency change",
|
|
1489
|
-
priorityPaths = [],
|
|
1490
|
-
onPriorityComplete = null,
|
|
1491
|
-
} = {}) {
|
|
1492
|
-
const startTime = Date.now();
|
|
1493
|
-
|
|
1494
|
-
if (!watchModeCache.isInitialized) {
|
|
1495
|
-
return { success: false, message: "Cache not initialized - need full build first", regenerated: 0, failed: 0 };
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
if (documentPaths.length === 0) {
|
|
1499
|
-
return { success: true, message: "No documents to regenerate", regenerated: 0, failed: 0 };
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
// Generate a fresh cache-bust timestamp for this invalidation pass
|
|
1503
|
-
const newTimestamp = generateCacheBustTimestamp();
|
|
1504
|
-
watchModeCache.cacheBustTimestamp = newTimestamp;
|
|
1505
|
-
|
|
1506
|
-
let regenerated = 0;
|
|
1507
|
-
let failed = 0;
|
|
1508
|
-
|
|
1509
|
-
// Separate priority paths from the rest
|
|
1510
|
-
const prioritySet = new Set(priorityPaths.map(p => resolve(p)));
|
|
1511
|
-
const priorityDocs = documentPaths.filter(p => prioritySet.has(resolve(p)));
|
|
1512
|
-
const remainingDocs = documentPaths.filter(p => !prioritySet.has(resolve(p)));
|
|
1513
|
-
|
|
1514
|
-
if (priorityDocs.length > 0) {
|
|
1515
|
-
console.log(`🔄 Regenerating ${priorityDocs.length} priority documents first, then ${remainingDocs.length} remaining (${reason})`);
|
|
1516
|
-
} else {
|
|
1517
|
-
console.log(`🔄 Regenerating ${documentPaths.length} documents (${reason})`);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
// Process priority documents first
|
|
1521
|
-
for (const docPath of priorityDocs) {
|
|
1522
|
-
try {
|
|
1523
|
-
const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
|
|
1524
|
-
if (result.success) {
|
|
1525
|
-
regenerated++;
|
|
1526
|
-
} else {
|
|
1527
|
-
console.warn(` ⚠️ ${docPath}: ${result.message}`);
|
|
1528
|
-
failed++;
|
|
1529
|
-
}
|
|
1530
|
-
} catch (e) {
|
|
1531
|
-
console.error(` ❌ ${docPath}: ${e.message}`);
|
|
1532
|
-
failed++;
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
// Notify caller that priority docs are done (so server can reload those clients immediately)
|
|
1537
|
-
if (priorityDocs.length > 0 && onPriorityComplete) {
|
|
1538
|
-
try {
|
|
1539
|
-
onPriorityComplete({ regenerated, failed, priorityDocs });
|
|
1540
|
-
} catch (e) {
|
|
1541
|
-
console.error(` ⚠️ onPriorityComplete callback error: ${e.message}`);
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// Process remaining documents
|
|
1546
|
-
for (const docPath of remainingDocs) {
|
|
1547
|
-
try {
|
|
1548
|
-
const result = await regenerateSingleFile(docPath, { _source, _meta, _output });
|
|
1549
|
-
if (result.success) {
|
|
1550
|
-
regenerated++;
|
|
1551
|
-
} else {
|
|
1552
|
-
console.warn(` ⚠️ ${docPath}: ${result.message}`);
|
|
1553
|
-
failed++;
|
|
1554
|
-
}
|
|
1555
|
-
} catch (e) {
|
|
1556
|
-
console.error(` ❌ ${docPath}: ${e.message}`);
|
|
1557
|
-
failed++;
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
// Persist updated dependency registrations (e.g. a doc switched templates)
|
|
1562
|
-
if (regenerated > 0) {
|
|
1563
|
-
await saveDependencyTracker(resolve(_source) + "/");
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
const elapsed = Date.now() - startTime;
|
|
1567
|
-
const msg = `Regenerated ${regenerated}/${documentPaths.length} documents in ${elapsed}ms (${reason})${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
1568
|
-
console.log(`✅ ${msg}`);
|
|
1569
|
-
return { success: failed === 0, message: msg, regenerated, failed };
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
/**
|
|
1573
|
-
* Regenerate a single file without scanning the entire source directory.
|
|
1574
|
-
* This is much faster for watch mode - only regenerate what changed.
|
|
1575
|
-
*
|
|
1576
|
-
* @param {string} changedFile - Absolute path to the file that changed
|
|
1577
|
-
* @param {Object} options - Same options as generate()
|
|
1578
|
-
* @returns {Promise<{success: boolean, message: string}>}
|
|
1579
|
-
*/
|
|
1580
|
-
export async function regenerateSingleFile(changedFile, {
|
|
1581
|
-
_source,
|
|
1582
|
-
_meta,
|
|
1583
|
-
_output,
|
|
1584
|
-
} = {}) {
|
|
1585
|
-
const startTime = Date.now();
|
|
1586
|
-
const source = resolve(_source) + "/";
|
|
1587
|
-
const meta = resolve(_meta);
|
|
1588
|
-
const output = resolve(_output) + "/";
|
|
1589
|
-
|
|
1590
|
-
// Check if this is an article file we can regenerate
|
|
1591
|
-
const articleExtensions = /\.(md|mdx|txt|yml)$/;
|
|
1592
|
-
if (!changedFile.match(articleExtensions)) {
|
|
1593
|
-
return { success: false, message: `Not an article file: ${changedFile}` };
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
// Check if cache is initialized
|
|
1597
|
-
if (!watchModeCache.isInitialized) {
|
|
1598
|
-
return { success: false, message: 'Cache not initialized - need full build first' };
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
// Verify paths match cached paths
|
|
1602
|
-
if (watchModeCache.source !== source || watchModeCache.output !== output) {
|
|
1603
|
-
return { success: false, message: 'Paths changed - need full rebuild' };
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
try {
|
|
1607
|
-
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus, ursaMetadata } = watchModeCache;
|
|
1608
|
-
|
|
1609
|
-
const rawBody = await readFile(changedFile, "utf8");
|
|
1610
|
-
const type = parse(changedFile).ext;
|
|
1611
|
-
const ext = extname(changedFile);
|
|
1612
|
-
const base = basename(changedFile, ext);
|
|
1613
|
-
const dir = addTrailingSlash(dirname(changedFile)).replace(source, "");
|
|
1614
|
-
|
|
1615
|
-
// Calculate output paths
|
|
1616
|
-
const outputFilename = changedFile
|
|
1617
|
-
.replace(source, output)
|
|
1618
|
-
.replace(parse(changedFile).ext, ".html");
|
|
1619
|
-
const url = '/' + outputFilename.replace(output, '');
|
|
1620
|
-
|
|
1621
|
-
// Title from filename (for index/home, use parent folder name)
|
|
1622
|
-
const titleBase = (base === 'index' || base === 'home') ? basename(dirname(changedFile)) : base;
|
|
1623
|
-
const title = toTitleCase(titleBase || base);
|
|
1624
|
-
|
|
1625
|
-
// Extract metadata
|
|
1626
|
-
const fileMeta = extractMetadata(rawBody);
|
|
1627
|
-
const transformedMetadata = await getTransformedMetadata(
|
|
1628
|
-
dirname(changedFile),
|
|
1629
|
-
fileMeta
|
|
1630
|
-
);
|
|
1631
|
-
|
|
1632
|
-
// Calculate the document's URL path
|
|
1633
|
-
const docUrlPath = '/' + dir + base + '.html';
|
|
1634
|
-
|
|
1635
|
-
// Check if hydration should be enabled for MDX files
|
|
1636
|
-
const shouldHydrate = type === '.mdx' && fileMeta?.hydrate === true;
|
|
1637
|
-
|
|
1638
|
-
// Render body (use async for .mdx, sync for .md/.txt)
|
|
1639
|
-
let body;
|
|
1640
|
-
let hydrationScript = '';
|
|
1641
|
-
if (type === '.mdx') {
|
|
1642
|
-
const renderResult = await renderFileAsync({
|
|
1643
|
-
fileContents: rawBody,
|
|
1644
|
-
type,
|
|
1645
|
-
dirname: dir,
|
|
1646
|
-
basename: base,
|
|
1647
|
-
filePath: changedFile,
|
|
1648
|
-
sourceRoot: source,
|
|
1649
|
-
useWorker: false,
|
|
1650
|
-
hydrate: shouldHydrate,
|
|
1651
|
-
});
|
|
1652
|
-
|
|
1653
|
-
// Handle the result - can be string or { html, hydrationScript }
|
|
1654
|
-
if (typeof renderResult === 'object' && renderResult.html) {
|
|
1655
|
-
body = renderResult.html;
|
|
1656
|
-
hydrationScript = renderResult.hydrationScript || '';
|
|
1657
|
-
} else {
|
|
1658
|
-
body = renderResult;
|
|
1659
|
-
}
|
|
1660
|
-
} else {
|
|
1661
|
-
body = renderFile({
|
|
1662
|
-
fileContents: rawBody,
|
|
1663
|
-
type,
|
|
1664
|
-
dirname: dir,
|
|
1665
|
-
basename: base,
|
|
1666
|
-
});
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
// Inject default H1 if body doesn't start with one
|
|
1670
|
-
if (!body || !body.trimStart().startsWith('<h1')) {
|
|
1671
|
-
const h1Title = fileMeta?.title || title;
|
|
1672
|
-
body = `<h1>${h1Title}</h1>\n` + (body || '');
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
// Inject breadcrumbs before the H1
|
|
1676
|
-
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
|
|
1677
|
-
if (breadcrumbs) {
|
|
1678
|
-
body = breadcrumbs + body;
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
// Inject frontmatter table for markdown/mdx files
|
|
1682
|
-
if ((type === '.md' || type === '.mdx') && fileMeta) {
|
|
1683
|
-
body = injectFrontmatterTable(body, fileMeta);
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
// Handle auto-index generation for index files with generate-auto-index: true
|
|
1687
|
-
if (base === 'index' && fileMeta) {
|
|
1688
|
-
const autoIndexConfig = getAutoIndexConfig(fileMeta);
|
|
1689
|
-
if (autoIndexConfig.enabled) {
|
|
1690
|
-
// Generate auto-index HTML for this directory from source
|
|
1691
|
-
const sourceDir = dirname(changedFile);
|
|
1692
|
-
const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
|
|
1693
|
-
|
|
1694
|
-
if (autoIndexHtml) {
|
|
1695
|
-
if (autoIndexConfig.position === 'bottom') {
|
|
1696
|
-
body = body + '\n' + autoIndexHtml;
|
|
1697
|
-
} else {
|
|
1698
|
-
body = autoIndexHtml + '\n' + body;
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
// Find all CSS files up the tree and create separate link tags
|
|
1705
|
-
// (regenerateSingleFile is used in serve mode, so separate tags per level for invalidation)
|
|
1706
|
-
let styleLink = "";
|
|
1707
|
-
let cssPaths = [];
|
|
1708
|
-
try {
|
|
1709
|
-
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1710
|
-
cssPaths = await findAllStyleCss(dirKey, _source);
|
|
1711
|
-
if (cssPaths.length > 0) {
|
|
1712
|
-
// Copy all CSS files to output (always copy in single-file mode to ensure up to date)
|
|
1713
|
-
for (const cssPath of cssPaths) {
|
|
1714
|
-
const cssOutputPath = cssPath.replace(source, output);
|
|
1715
|
-
const cssContent = await readFile(cssPath, 'utf8');
|
|
1716
|
-
await outputFile(cssOutputPath, cssContent);
|
|
1717
|
-
}
|
|
1718
|
-
styleLink = generateSeparateCssTags(cssPaths, source);
|
|
1719
|
-
}
|
|
1720
|
-
} catch (e) {
|
|
1721
|
-
// ignore
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
// Get template
|
|
1725
|
-
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
1726
|
-
const template =
|
|
1727
|
-
templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
|
|
1728
|
-
|
|
1729
|
-
if (!template) {
|
|
1730
|
-
return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
// Find all script.js files from docroot to current dir and serve as separate external tags
|
|
1734
|
-
// (Serve mode: separate tags per level for individual invalidation)
|
|
1735
|
-
let customScript = "";
|
|
1736
|
-
let scriptPaths = [];
|
|
1737
|
-
try {
|
|
1738
|
-
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1739
|
-
scriptPaths = await findAllScriptJs(dirKey, _source);
|
|
1740
|
-
if (scriptPaths.length > 0) {
|
|
1741
|
-
// Copy all script files to output so they can be served
|
|
1742
|
-
for (const scriptPath of scriptPaths) {
|
|
1743
|
-
const scriptOutputPath = scriptPath.replace(source, output);
|
|
1744
|
-
const scriptContent = await readFile(scriptPath, 'utf8');
|
|
1745
|
-
await outputFile(scriptOutputPath, scriptContent);
|
|
1746
|
-
}
|
|
1747
|
-
customScript = generateSeparateJsTags(scriptPaths, source);
|
|
1748
|
-
}
|
|
1749
|
-
} catch (e) {
|
|
1750
|
-
// ignore
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
// Register this document's dependencies (template, inherited CSS/JS) so
|
|
1754
|
-
// invalidation plans stay accurate after frontmatter/template changes.
|
|
1755
|
-
// Persisted to .ursa/dependency-graph.json by regenerateAffectedDocuments.
|
|
1756
|
-
const usedTemplateName = (requestedTemplateName && templates[requestedTemplateName])
|
|
1757
|
-
? requestedTemplateName
|
|
1758
|
-
: DEFAULT_TEMPLATE_NAME;
|
|
1759
|
-
dependencyTracker.registerDocument(changedFile, {
|
|
1760
|
-
templateName: usedTemplateName,
|
|
1761
|
-
cssPaths,
|
|
1762
|
-
scriptPaths,
|
|
1763
|
-
});
|
|
1764
|
-
|
|
1765
|
-
// Check if this file has a custom menu
|
|
1766
|
-
const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
|
|
1767
|
-
|
|
1768
|
-
// Append hydration script to customScript if present (for MDX with hydrate: true)
|
|
1769
|
-
const finalCustomScript = hydrationScript
|
|
1770
|
-
? customScript + '\n' + hydrationScript
|
|
1771
|
-
: customScript;
|
|
1772
|
-
|
|
1773
|
-
// Build final HTML
|
|
1774
|
-
let finalHtml = template;
|
|
1775
|
-
const replacements = {
|
|
1776
|
-
"${title}": fileMeta?.title || title,
|
|
1777
|
-
"${menu}": menu,
|
|
1778
|
-
"${meta}": JSON.stringify(fileMeta),
|
|
1779
|
-
"${transformedMetadata}": transformedMetadata,
|
|
1780
|
-
"${body}": body,
|
|
1781
|
-
"${styleLink}": styleLink,
|
|
1782
|
-
"${customScript}": finalCustomScript,
|
|
1783
|
-
"${searchIndex}": "[]",
|
|
1784
|
-
"${footer}": footer
|
|
1785
|
-
};
|
|
1786
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
1787
|
-
finalHtml = finalHtml.replace(key, value);
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
// If this page has a custom menu, add data attributes to body
|
|
1791
|
-
if (customMenuInfo) {
|
|
1792
|
-
const menuPosition = customMenuInfo.menuPosition || 'top';
|
|
1793
|
-
finalHtml = finalHtml.replace(
|
|
1794
|
-
/<body([^>]*)>/,
|
|
1795
|
-
`<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
|
|
1796
|
-
);
|
|
1797
|
-
} else {
|
|
1798
|
-
// No custom menu — default to top menu
|
|
1799
|
-
finalHtml = finalHtml.replace(
|
|
1800
|
-
/<body([^>]*)>/,
|
|
1801
|
-
`<body$1 data-menu-position="top">`
|
|
1802
|
-
);
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
// Resolve relative URLs in raw HTML elements (img src, etc.)
|
|
1806
|
-
finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
|
|
1807
|
-
|
|
1808
|
-
// Mark broken links
|
|
1809
|
-
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
1810
|
-
|
|
1811
|
-
// Transform image tags to use preview images with data-fullsrc for originals
|
|
1812
|
-
if (imageMap) {
|
|
1813
|
-
finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
// Add cache-busting timestamps to static file references
|
|
1817
|
-
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
1818
|
-
|
|
1819
|
-
await outputFile(outputFilename, finalHtml);
|
|
1820
|
-
|
|
1821
|
-
// JSON output
|
|
1822
|
-
const jsonOutputFilename = outputFilename.replace(".html", ".json");
|
|
1823
|
-
const sections = (type === '.md' || type === '.mdx') ? extractSections(rawBody) : [];
|
|
1824
|
-
const jsonObject = {
|
|
1825
|
-
name: base,
|
|
1826
|
-
url,
|
|
1827
|
-
contents: rawBody,
|
|
1828
|
-
bodyHtml: body,
|
|
1829
|
-
metadata: fileMeta,
|
|
1830
|
-
sections,
|
|
1831
|
-
transformedMetadata,
|
|
1832
|
-
_ursa_metadata: ursaMetadata,
|
|
1833
|
-
};
|
|
1834
|
-
const json = JSON.stringify(jsonObject);
|
|
1835
|
-
await outputFile(jsonOutputFilename, json);
|
|
1836
|
-
|
|
1837
|
-
// XML output
|
|
1838
|
-
const xmlOutputFilename = outputFilename.replace(".html", ".xml");
|
|
1839
|
-
const xml = `<article>${o2x(jsonObject)}</article>`;
|
|
1840
|
-
await outputFile(xmlOutputFilename, xml);
|
|
1841
|
-
|
|
1842
|
-
// Folder-named index promotion (mirrors autoIndex behavior on full builds):
|
|
1843
|
-
// If the file is named like its parent folder (e.g. aletheia/aletheia.md) and
|
|
1844
|
-
// no explicit index.{md,mdx,txt,yml,html} exists alongside it, also write the
|
|
1845
|
-
// same outputs to <dir>/index.html|json|xml so the canonical URL stays fresh.
|
|
1846
|
-
const sourceDirOfFile = dirname(changedFile);
|
|
1847
|
-
const parentFolderName = basename(sourceDirOfFile);
|
|
1848
|
-
if (base && parentFolderName && base === parentFolderName) {
|
|
1849
|
-
const hasExplicitIndex = ['index.md', 'index.mdx', 'index.txt', 'index.yml', 'index.html']
|
|
1850
|
-
.some(name => existsSync(join(sourceDirOfFile, name)));
|
|
1851
|
-
if (!hasExplicitIndex) {
|
|
1852
|
-
const outDirOfFile = dirname(outputFilename);
|
|
1853
|
-
await outputFile(join(outDirOfFile, 'index.html'), finalHtml);
|
|
1854
|
-
await outputFile(join(outDirOfFile, 'index.json'), json);
|
|
1855
|
-
await outputFile(join(outDirOfFile, 'index.xml'), xml);
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
// Update hash cache
|
|
1860
|
-
updateHash(changedFile, rawBody, hashCache);
|
|
1861
|
-
|
|
1862
|
-
// Update recent-activity.json with this file's new content timestamp
|
|
1863
|
-
try {
|
|
1864
|
-
const now = Date.now();
|
|
1865
|
-
const recentActivityPath = join(output, 'public', 'recent-activity.json');
|
|
1866
|
-
let recentActivity = [];
|
|
1867
|
-
try {
|
|
1868
|
-
const existing = await readFile(recentActivityPath, 'utf8');
|
|
1869
|
-
recentActivity = JSON.parse(existing);
|
|
1870
|
-
} catch (e) { /* no existing file, start fresh */ }
|
|
1871
|
-
// Remove old entry for this URL if present
|
|
1872
|
-
recentActivity = recentActivity.filter(r => r.url !== url);
|
|
1873
|
-
// Add updated entry with current timestamp (content changed now)
|
|
1874
|
-
recentActivity.push({ title, url, mtime: now });
|
|
1875
|
-
// Sort by mtime descending, keep top 10
|
|
1876
|
-
recentActivity.sort((a, b) => b.mtime - a.mtime);
|
|
1877
|
-
recentActivity = recentActivity.slice(0, 10);
|
|
1878
|
-
await outputFile(recentActivityPath, JSON.stringify(recentActivity));
|
|
1879
|
-
|
|
1880
|
-
// Also update content timestamp in .ursa.json for persistence
|
|
1881
|
-
const relativePath = '/' + changedFile.replace(source, '').replace(/\.(md|mdx|txt|yml)$/, '.html');
|
|
1882
|
-
updateContentTimestamp(source, relativePath, now);
|
|
1883
|
-
} catch (e) {
|
|
1884
|
-
// ignore recent activity update errors
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
const elapsed = Date.now() - startTime;
|
|
1888
|
-
const shortFile = changedFile.replace(source, '');
|
|
1889
|
-
return { success: true, message: `Regenerated ${shortFile} in ${elapsed}ms` };
|
|
1890
|
-
} catch (e) {
|
|
1891
|
-
return { success: false, message: `Error: ${e.message}` };
|
|
1892
|
-
}
|
|
1893
|
-
}
|