@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +114 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/content-hooks.js +45 -0
  5. package/meta/templates/default-template/index.html +1 -0
  6. package/meta/templates/default-template/menu.js +18 -1
  7. package/meta/templates/default-template/search.js +11 -0
  8. package/meta/templates/default-template/sticky.js +7 -1
  9. package/meta/templates/default-template/toc-generator.js +58 -38
  10. package/meta/templates/default-template/widgets.js +4 -0
  11. package/package.json +1 -2
  12. package/src/dev.js +13 -23
  13. package/src/helper/__test__/contentHash.test.js +16 -6
  14. package/src/helper/__test__/mdxRenderer.test.js +159 -0
  15. package/src/helper/__test__/sourceTimestamps.test.js +0 -0
  16. package/src/helper/assetBundler.js +93 -19
  17. package/src/helper/automenu.js +36 -11
  18. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  19. package/src/helper/build/__test__/graph.test.js +259 -3
  20. package/src/helper/build/__test__/pass.test.js +553 -0
  21. package/src/helper/build/autoIndex.js +2 -371
  22. package/src/helper/build/excludeFilter.js +1 -2
  23. package/src/helper/build/footer.js +27 -14
  24. package/src/helper/build/graph.js +575 -152
  25. package/src/helper/build/index.js +0 -2
  26. package/src/helper/build/metadata.js +19 -5
  27. package/src/helper/build/pass.js +497 -0
  28. package/src/helper/build/precedence.js +174 -0
  29. package/src/helper/build/site.js +1270 -0
  30. package/src/helper/build/templates.js +1 -2
  31. package/src/helper/build/tracedFs.js +247 -0
  32. package/src/helper/contentHash.js +0 -78
  33. package/src/helper/customMenu.js +1 -1
  34. package/src/helper/fileRenderer.js +119 -111
  35. package/src/helper/findScriptJs.js +1 -1
  36. package/src/helper/findStyleCss.js +1 -1
  37. package/src/helper/folderConfig.js +7 -18
  38. package/src/helper/fullTextIndex.js +41 -29
  39. package/src/helper/imageProcessor.js +45 -0
  40. package/src/helper/linkValidator.js +118 -127
  41. package/src/helper/mdxRenderer.js +225 -26
  42. package/src/helper/menuLabels.js +30 -5
  43. package/src/helper/sourceTimestamps.js +139 -0
  44. package/src/helper/ursaConfig.js +3 -49
  45. package/src/helper/whitelistFilter.js +1 -2
  46. package/src/jobs/generate.js +67 -1859
  47. package/src/serve.js +317 -697
  48. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  49. package/src/helper/build/cacheBust.js +0 -141
  50. package/src/helper/build/navCache.js +0 -145
  51. package/src/helper/build/watchCache.js +0 -33
  52. package/src/helper/dependencyTracker.js +0 -384
@@ -1,384 +0,0 @@
1
- /**
2
- * Dependency tracker for Ursa's regeneration system.
3
- *
4
- * Tracks which documents depend on which files so that when a file changes,
5
- * we can determine exactly which documents need regeneration.
6
- *
7
- * Dependency types:
8
- * - template: document uses a specific meta template
9
- * - style: document inherits a specific style.css
10
- * - script: document inherits a specific script.js
11
- * - static: document references a static asset (image, font, etc.)
12
- * - meta-asset: document depends on a meta CSS/JS file (via template bundling)
13
- *
14
- * The tracker maintains two indexes:
15
- * 1. fileToDocuments: Map<dependencyPath, Set<documentPath>> — given a changed file, which documents need regeneration?
16
- * 2. documentToFiles: Map<documentPath, Set<dependencyPath>> — given a document, what are its dependencies? (for cleanup)
17
- */
18
-
19
- import { dirname, join, relative, resolve } from "path";
20
- import { existsSync } from "fs";
21
- import { mkdir, readFile, writeFile } from "fs/promises";
22
- import { getUrsaDir } from "./contentHash.js";
23
-
24
- const DEP_GRAPH_FILE = "dependency-graph.json";
25
- const DEP_GRAPH_VERSION = 1;
26
-
27
- // Static assets in meta (copied verbatim to output/public by copyMetaAssets);
28
- // these are never embedded in document HTML, so no regeneration is needed.
29
- const META_STATIC_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
30
-
31
- export class DependencyTracker {
32
- constructor() {
33
- /** @type {Map<string, Set<string>>} dependency file path → set of document paths */
34
- this.fileToDocuments = new Map();
35
- /** @type {Map<string, Set<string>>} document path → set of dependency file paths */
36
- this.documentToFiles = new Map();
37
- /** @type {string} source directory root (absolute, with trailing slash) */
38
- this.sourceDir = "";
39
- }
40
-
41
- /**
42
- * Initialize with the source directory.
43
- * @param {string} sourceDir - Absolute path to source directory (with or without trailing slash)
44
- */
45
- init(sourceDir) {
46
- this.sourceDir = resolve(sourceDir) + "/";
47
- this.fileToDocuments.clear();
48
- this.documentToFiles.clear();
49
- }
50
-
51
- /**
52
- * Register that a document depends on a file.
53
- * @param {string} documentPath - Absolute path to the document
54
- * @param {string} dependencyPath - Absolute path to the dependency file
55
- */
56
- addDependency(documentPath, dependencyPath) {
57
- // file → documents
58
- if (!this.fileToDocuments.has(dependencyPath)) {
59
- this.fileToDocuments.set(dependencyPath, new Set());
60
- }
61
- this.fileToDocuments.get(dependencyPath).add(documentPath);
62
-
63
- // document → files
64
- if (!this.documentToFiles.has(documentPath)) {
65
- this.documentToFiles.set(documentPath, new Set());
66
- }
67
- this.documentToFiles.get(documentPath).add(dependencyPath);
68
- }
69
-
70
- /**
71
- * Register dependencies for a document: template, style.css files, script.js files.
72
- * @param {string} documentPath - Absolute path to the document
73
- * @param {{ templateName: string, cssPaths: string[], scriptPaths: string[], metaAssets: string[] }} deps
74
- */
75
- registerDocument(documentPath, { templateName, cssPaths = [], scriptPaths = [], metaAssets = [] } = {}) {
76
- // Clear old dependencies for this document
77
- this.clearDocument(documentPath);
78
-
79
- // Template dependency (use a virtual path so template changes can be looked up)
80
- if (templateName) {
81
- this.addDependency(documentPath, `template:${templateName}`);
82
- }
83
-
84
- // Style.css dependencies
85
- for (const cssPath of cssPaths) {
86
- this.addDependency(documentPath, cssPath);
87
- }
88
-
89
- // Script.js dependencies
90
- for (const scriptPath of scriptPaths) {
91
- this.addDependency(documentPath, scriptPath);
92
- }
93
-
94
- // Meta template assets (CSS/JS referenced by the template)
95
- for (const metaAsset of metaAssets) {
96
- this.addDependency(documentPath, metaAsset);
97
- }
98
- }
99
-
100
- /**
101
- * Clear all dependencies for a document.
102
- * @param {string} documentPath
103
- */
104
- clearDocument(documentPath) {
105
- const deps = this.documentToFiles.get(documentPath);
106
- if (deps) {
107
- for (const dep of deps) {
108
- const docSet = this.fileToDocuments.get(dep);
109
- if (docSet) {
110
- docSet.delete(documentPath);
111
- if (docSet.size === 0) this.fileToDocuments.delete(dep);
112
- }
113
- }
114
- this.documentToFiles.delete(documentPath);
115
- }
116
- }
117
-
118
- /**
119
- * Get all documents that depend on a given file.
120
- * @param {string} filePath - Absolute path to the changed file
121
- * @returns {Set<string>} Set of document paths that need regeneration
122
- */
123
- getAffectedDocuments(filePath) {
124
- return this.fileToDocuments.get(filePath) || new Set();
125
- }
126
-
127
- /**
128
- * Get all documents that use a specific template.
129
- * @param {string} templateName - Template name (e.g., "default-template")
130
- * @returns {Set<string>} Set of document paths
131
- */
132
- getDocumentsUsingTemplate(templateName) {
133
- return this.getAffectedDocuments(`template:${templateName}`);
134
- }
135
-
136
- /**
137
- * Determine which documents are affected by a changed file.
138
- * This is the main entry point for the invalidation logic.
139
- *
140
- * @param {string} changedFile - Absolute path to the changed file
141
- * @param {string} sourceDir - Absolute path to source directory
142
- * @returns {{ affectedDocuments: string[], reason: string, requiresFullRebuild: boolean }}
143
- */
144
- getInvalidationPlan(changedFile, sourceDir) {
145
- const normalizedSource = resolve(sourceDir) + "/";
146
- const relativePath = changedFile.replace(normalizedSource, "");
147
- const fileName = relativePath.split("/").pop();
148
-
149
- // 1. Menu/config changes → full rebuild (affects navigation structure)
150
- if (
151
- fileName === "menu.md" || fileName === "menu.txt" || fileName === "_menu" ||
152
- fileName === "config.json" || fileName === "_config"
153
- ) {
154
- return {
155
- affectedDocuments: [],
156
- reason: `Menu/config change: ${relativePath}`,
157
- requiresFullRebuild: true,
158
- };
159
- }
160
-
161
- // 2. style.css or script.js → affects current folder + all subfolders
162
- // These are "inherited" files — documents in the folder and all subfolders include them.
163
- if (
164
- fileName === "style.css" || fileName === "_style.css" || fileName === "style-ursa.css" ||
165
- fileName === "script.js" || fileName === "_script.js"
166
- ) {
167
- // Get all documents directly registered as depending on this file
168
- const directDeps = this.getAffectedDocuments(changedFile);
169
-
170
- if (directDeps.size > 0) {
171
- return {
172
- affectedDocuments: [...directDeps],
173
- reason: `Inherited ${fileName} changed: ${relativePath} (${directDeps.size} documents)`,
174
- requiresFullRebuild: false,
175
- };
176
- }
177
-
178
- // Fallback: if dependency tracker wasn't populated, scope by directory
179
- const changedDir = dirname(changedFile);
180
- const allDocs = [...this.documentToFiles.keys()];
181
- const affected = allDocs.filter((doc) => doc.startsWith(changedDir));
182
- return {
183
- affectedDocuments: affected,
184
- reason: `Inherited ${fileName} changed: ${relativePath} (${affected.length} documents in subtree, fallback)`,
185
- requiresFullRebuild: false,
186
- };
187
- }
188
-
189
- // 3. Article file changes → just that document
190
- if (/\.(md|mdx|txt|yml|yaml)$/.test(fileName)) {
191
- return {
192
- affectedDocuments: [changedFile],
193
- reason: `Article changed: ${relativePath}`,
194
- requiresFullRebuild: false,
195
- };
196
- }
197
-
198
- // 4. Other static files (images, fonts, etc.) → find documents that reference them
199
- const directDeps = this.getAffectedDocuments(changedFile);
200
- if (directDeps.size > 0) {
201
- return {
202
- affectedDocuments: [...directDeps],
203
- reason: `Static asset changed: ${relativePath} (${directDeps.size} referencing documents)`,
204
- requiresFullRebuild: false,
205
- };
206
- }
207
-
208
- // If we don't track this file, it's safe to just broadcast reload (dev mode only)
209
- return {
210
- affectedDocuments: [],
211
- reason: `Unknown file changed: ${relativePath} (no registered dependents)`,
212
- requiresFullRebuild: false,
213
- };
214
- }
215
-
216
- /**
217
- * Determine which documents are affected by a meta file change.
218
- * @param {string} changedFile - Absolute path to the changed meta file
219
- * @param {string} metaDir - Absolute path to meta directory
220
- * @returns {{ affectedDocuments: string[], reason: string, requiresFullRebuild: boolean }}
221
- */
222
- getMetaInvalidationPlan(changedFile, metaDir) {
223
- const normalizedMeta = resolve(metaDir) + "/";
224
- const relativePath = changedFile.replace(normalizedMeta, "");
225
- const fileName = relativePath.split("/").pop();
226
-
227
- // Template file changed → regenerate all documents using that template
228
- if (fileName.endsWith(".html")) {
229
- // New structure: templates/{templateName}/index.html → name is the folder;
230
- // legacy flat structure: {templateName}.html at the meta root
231
- const parts = relativePath.split("/");
232
- const templateName =
233
- parts[0] === "templates" && parts.length >= 3
234
- ? parts[1]
235
- : fileName.replace(".html", "");
236
- const affected = this.getDocumentsUsingTemplate(templateName);
237
- if (affected.size > 0) {
238
- return {
239
- affectedDocuments: [...affected],
240
- reason: `Template changed: ${templateName} (${affected.size} documents)`,
241
- requiresFullRebuild: false,
242
- };
243
- }
244
- // If no documents tracked, fall back to full rebuild (safe default)
245
- return {
246
- affectedDocuments: [],
247
- reason: `Template changed: ${templateName} (no tracked documents, full rebuild)`,
248
- requiresFullRebuild: true,
249
- };
250
- }
251
-
252
- // Meta CSS or JS file changed → all documents are affected
253
- // (meta assets are bundled into every template)
254
- if (fileName.endsWith(".css") || fileName.endsWith(".js")) {
255
- const allDocs = [...this.documentToFiles.keys()];
256
- return {
257
- affectedDocuments: allDocs,
258
- reason: `Meta asset changed: ${relativePath} (affects all ${allDocs.length} documents)`,
259
- requiresFullRebuild: false,
260
- };
261
- }
262
-
263
- // Static asset in meta (image, font, PDF, media) → copyMetaAssets already
264
- // re-copied it to output/public; documents reference it by URL, so no
265
- // document regeneration (and no full rebuild) is needed.
266
- if (META_STATIC_EXTENSIONS.test(fileName)) {
267
- return {
268
- affectedDocuments: [],
269
- reason: `Meta static asset copied: ${relativePath}`,
270
- requiresFullRebuild: false,
271
- };
272
- }
273
-
274
- // Other meta file → full rebuild to be safe
275
- return {
276
- affectedDocuments: [],
277
- reason: `Meta file changed: ${relativePath}`,
278
- requiresFullRebuild: true,
279
- };
280
- }
281
-
282
- /**
283
- * Get stats about the dependency graph.
284
- * @returns {{ totalDocuments: number, totalDependencies: number, uniqueFiles: number }}
285
- */
286
- getStats() {
287
- return {
288
- totalDocuments: this.documentToFiles.size,
289
- totalDependencies: [...this.documentToFiles.values()].reduce((sum, s) => sum + s.size, 0),
290
- uniqueFiles: this.fileToDocuments.size,
291
- };
292
- }
293
-
294
- /**
295
- * Serialize the tracker for persistence to .ursa/dependency-graph.json.
296
- * @returns {{ version: number, sourceDir: string, documents: Object<string, string[]> }}
297
- */
298
- serialize() {
299
- return {
300
- version: DEP_GRAPH_VERSION,
301
- sourceDir: this.sourceDir,
302
- documents: Object.fromEntries(
303
- [...this.documentToFiles.entries()].map(([doc, deps]) => [doc, [...deps]])
304
- ),
305
- };
306
- }
307
-
308
- /**
309
- * Load persisted registrations, merging with the current run: documents
310
- * already registered in this run keep their (fresher) edges; persisted
311
- * edges only fill in documents not yet registered (e.g. hash-skipped docs).
312
- * Rejects data from a different source directory or schema version.
313
- * @param {object} data - Previously serialized tracker
314
- * @returns {boolean} Whether the data was loaded
315
- */
316
- load(data) {
317
- if (!data || data.version !== DEP_GRAPH_VERSION) return false;
318
- if (data.sourceDir && this.sourceDir && data.sourceDir !== this.sourceDir) return false;
319
- for (const [doc, deps] of Object.entries(data.documents || {})) {
320
- if (this.documentToFiles.has(doc)) continue; // live registrations win
321
- if (!Array.isArray(deps)) continue;
322
- for (const dep of deps) {
323
- this.addDependency(doc, dep);
324
- }
325
- }
326
- return true;
327
- }
328
-
329
- /**
330
- * Drop registrations for documents not in the given set (e.g. deleted or
331
- * excluded files), so persisted state doesn't accumulate stale entries.
332
- * @param {Set<string>} keepDocuments - Document paths that should survive
333
- */
334
- prune(keepDocuments) {
335
- for (const doc of [...this.documentToFiles.keys()]) {
336
- if (!keepDocuments.has(doc)) this.clearDocument(doc);
337
- }
338
- }
339
- }
340
-
341
- // Singleton instance
342
- export const dependencyTracker = new DependencyTracker();
343
-
344
- /** Path to the persisted dependency graph for a source directory. */
345
- export function getDependencyGraphPath(sourceDir) {
346
- return join(getUrsaDir(sourceDir), DEP_GRAPH_FILE);
347
- }
348
-
349
- /**
350
- * Load the persisted dependency tracker state from .ursa/dependency-graph.json
351
- * and merge it into the tracker (current-run registrations win).
352
- * @param {string} sourceDir - Source directory root
353
- * @param {DependencyTracker} [tracker] - Defaults to the singleton
354
- * @returns {Promise<boolean>} Whether a valid graph was loaded
355
- */
356
- export async function loadDependencyTracker(sourceDir, tracker = dependencyTracker) {
357
- const path = getDependencyGraphPath(sourceDir);
358
- try {
359
- if (!existsSync(path)) return false;
360
- const data = JSON.parse(await readFile(path, "utf8"));
361
- return tracker.load(data);
362
- } catch (e) {
363
- console.warn(`Could not load dependency graph: ${e.message}`);
364
- return false;
365
- }
366
- }
367
-
368
- /**
369
- * Persist the dependency tracker to .ursa/dependency-graph.json so that
370
- * hash-skipped documents keep their edges across warm starts.
371
- * @param {string} sourceDir - Source directory root
372
- * @param {DependencyTracker} [tracker] - Defaults to the singleton
373
- * @returns {Promise<boolean>} Whether the graph was saved
374
- */
375
- export async function saveDependencyTracker(sourceDir, tracker = dependencyTracker) {
376
- try {
377
- await mkdir(getUrsaDir(sourceDir), { recursive: true });
378
- await writeFile(getDependencyGraphPath(sourceDir), JSON.stringify(tracker.serialize()));
379
- return true;
380
- } catch (e) {
381
- console.warn(`Could not save dependency graph: ${e.message}`);
382
- return false;
383
- }
384
- }