@kenjura/ursa 0.96.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 (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/menu.js +18 -1
  5. package/meta/templates/default-template/search.js +11 -0
  6. package/meta/templates/default-template/widgets.js +4 -0
  7. package/package.json +1 -2
  8. package/src/dev.js +13 -23
  9. package/src/helper/__test__/contentHash.test.js +16 -6
  10. package/src/helper/assetBundler.js +93 -19
  11. package/src/helper/automenu.js +36 -11
  12. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  13. package/src/helper/build/__test__/graph.test.js +259 -3
  14. package/src/helper/build/__test__/pass.test.js +553 -0
  15. package/src/helper/build/autoIndex.js +2 -371
  16. package/src/helper/build/excludeFilter.js +1 -2
  17. package/src/helper/build/footer.js +27 -14
  18. package/src/helper/build/graph.js +575 -152
  19. package/src/helper/build/index.js +0 -2
  20. package/src/helper/build/metadata.js +19 -5
  21. package/src/helper/build/pass.js +497 -0
  22. package/src/helper/build/precedence.js +174 -0
  23. package/src/helper/build/site.js +1270 -0
  24. package/src/helper/build/templates.js +1 -2
  25. package/src/helper/build/tracedFs.js +247 -0
  26. package/src/helper/contentHash.js +0 -78
  27. package/src/helper/customMenu.js +1 -1
  28. package/src/helper/fileRenderer.js +119 -111
  29. package/src/helper/findScriptJs.js +1 -1
  30. package/src/helper/findStyleCss.js +1 -1
  31. package/src/helper/folderConfig.js +7 -18
  32. package/src/helper/fullTextIndex.js +41 -29
  33. package/src/helper/imageProcessor.js +45 -0
  34. package/src/helper/linkValidator.js +118 -127
  35. package/src/helper/mdxRenderer.js +27 -5
  36. package/src/helper/menuLabels.js +30 -5
  37. package/src/helper/whitelistFilter.js +1 -2
  38. package/src/jobs/generate.js +67 -1829
  39. package/src/serve.js +317 -697
  40. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  41. package/src/helper/build/cacheBust.js +0 -141
  42. package/src/helper/build/navCache.js +0 -145
  43. package/src/helper/build/watchCache.js +0 -33
  44. package/src/helper/dependencyTracker.js +0 -384
@@ -1,157 +0,0 @@
1
- import { join } from "path";
2
- import { mkdtemp, rm, readFile } from "fs/promises";
3
- import { existsSync } from "fs";
4
- import { tmpdir } from "os";
5
- import {
6
- DependencyTracker,
7
- loadDependencyTracker,
8
- saveDependencyTracker,
9
- getDependencyGraphPath,
10
- } from "../dependencyTracker.js";
11
-
12
- let tempDir;
13
- beforeEach(async () => {
14
- tempDir = await mkdtemp(join(tmpdir(), "ursa-deptracker-"));
15
- });
16
- afterEach(async () => {
17
- await rm(tempDir, { recursive: true, force: true });
18
- });
19
-
20
- function makeTracker(sourceDir) {
21
- const tracker = new DependencyTracker();
22
- tracker.init(sourceDir);
23
- return tracker;
24
- }
25
-
26
- describe("serialize / load", () => {
27
- it("round-trips registrations through serialize + load", () => {
28
- const t1 = makeTracker("/site/docs");
29
- t1.registerDocument("/site/docs/a.md", {
30
- templateName: "default-template",
31
- cssPaths: ["/site/docs/style.css"],
32
- scriptPaths: ["/site/docs/script.js"],
33
- });
34
- t1.registerDocument("/site/docs/sub/b.md", {
35
- templateName: "wiki",
36
- cssPaths: ["/site/docs/style.css", "/site/docs/sub/style.css"],
37
- });
38
-
39
- const data = JSON.parse(JSON.stringify(t1.serialize()));
40
- const t2 = makeTracker("/site/docs");
41
- expect(t2.load(data)).toBe(true);
42
-
43
- expect([...t2.getAffectedDocuments("/site/docs/style.css")].sort()).toEqual([
44
- "/site/docs/a.md",
45
- "/site/docs/sub/b.md",
46
- ]);
47
- expect([...t2.getDocumentsUsingTemplate("wiki")]).toEqual(["/site/docs/sub/b.md"]);
48
- expect(t2.getStats()).toEqual(t1.getStats());
49
- });
50
-
51
- it("merges with current-run registrations, which take precedence", () => {
52
- const t1 = makeTracker("/site/docs");
53
- t1.registerDocument("/site/docs/a.md", { templateName: "old-template" });
54
- t1.registerDocument("/site/docs/b.md", { templateName: "default-template" });
55
- const persisted = t1.serialize();
56
-
57
- // New run: a.md was re-rendered with a different template before load
58
- const t2 = makeTracker("/site/docs");
59
- t2.registerDocument("/site/docs/a.md", { templateName: "new-template" });
60
- expect(t2.load(persisted)).toBe(true);
61
-
62
- // Live registration wins; persisted fills in the hash-skipped doc
63
- expect([...t2.getDocumentsUsingTemplate("new-template")]).toEqual(["/site/docs/a.md"]);
64
- expect([...t2.getDocumentsUsingTemplate("old-template")]).toEqual([]);
65
- expect([...t2.getDocumentsUsingTemplate("default-template")]).toEqual(["/site/docs/b.md"]);
66
- });
67
-
68
- it("rejects mismatched schema versions and source dirs", () => {
69
- const t = makeTracker("/site/docs");
70
- expect(t.load(null)).toBe(false);
71
- expect(t.load({ version: 99, documents: {} })).toBe(false);
72
- const other = makeTracker("/different/source").serialize();
73
- other.documents["/different/source/a.md"] = ["template:default-template"];
74
- expect(t.load(other)).toBe(false);
75
- expect(t.getStats().totalDocuments).toBe(0);
76
- });
77
- });
78
-
79
- describe("prune", () => {
80
- it("drops registrations for documents not in the keep set", () => {
81
- const t = makeTracker("/site/docs");
82
- t.registerDocument("/site/docs/keep.md", { cssPaths: ["/site/docs/style.css"] });
83
- t.registerDocument("/site/docs/deleted.md", { cssPaths: ["/site/docs/style.css"] });
84
-
85
- t.prune(new Set(["/site/docs/keep.md"]));
86
-
87
- expect([...t.getAffectedDocuments("/site/docs/style.css")]).toEqual(["/site/docs/keep.md"]);
88
- expect(t.getStats().totalDocuments).toBe(1);
89
- });
90
- });
91
-
92
- describe("file persistence helpers", () => {
93
- it("saves to and loads from .ursa/dependency-graph.json", async () => {
94
- const t1 = makeTracker(tempDir);
95
- t1.registerDocument(join(tempDir, "a.md"), {
96
- templateName: "default-template",
97
- cssPaths: [join(tempDir, "style.css")],
98
- });
99
- expect(await saveDependencyTracker(tempDir, t1)).toBe(true);
100
- expect(existsSync(getDependencyGraphPath(tempDir))).toBe(true);
101
- const onDisk = JSON.parse(await readFile(getDependencyGraphPath(tempDir), "utf8"));
102
- expect(onDisk.version).toBe(1);
103
-
104
- const t2 = makeTracker(tempDir);
105
- expect(await loadDependencyTracker(tempDir, t2)).toBe(true);
106
- expect([...t2.getAffectedDocuments(join(tempDir, "style.css"))]).toEqual([
107
- join(tempDir, "a.md"),
108
- ]);
109
- });
110
-
111
- it("returns false when no persisted graph exists", async () => {
112
- const t = makeTracker(tempDir);
113
- expect(await loadDependencyTracker(tempDir, t)).toBe(false);
114
- });
115
- });
116
-
117
- describe("getMetaInvalidationPlan", () => {
118
- it("does not force a full rebuild for static assets in meta", () => {
119
- const t = makeTracker("/site/docs");
120
- t.registerDocument("/site/docs/a.md", { templateName: "default-template" });
121
-
122
- for (const file of ["logo.png", "font.woff2", "manual.pdf", "icon.SVG"]) {
123
- const plan = t.getMetaInvalidationPlan(`/site/meta/shared/${file}`, "/site/meta");
124
- expect(plan.requiresFullRebuild).toBe(false);
125
- expect(plan.affectedDocuments).toEqual([]);
126
- }
127
- });
128
-
129
- it("still regenerates documents for template and css/js meta changes", () => {
130
- const t = makeTracker("/site/docs");
131
- t.registerDocument("/site/docs/a.md", { templateName: "default-template" });
132
-
133
- // New folder structure: templates/{name}/index.html → name from the folder
134
- const tplPlan = t.getMetaInvalidationPlan(
135
- "/site/meta/templates/default-template/index.html",
136
- "/site/meta"
137
- );
138
- expect(tplPlan.requiresFullRebuild).toBe(false);
139
- expect(tplPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
140
-
141
- // Legacy flat structure: {name}.html at the meta root
142
- const legacyPlan = t.getMetaInvalidationPlan(
143
- "/site/meta/default-template.html",
144
- "/site/meta"
145
- );
146
- expect(legacyPlan.requiresFullRebuild).toBe(false);
147
- expect(legacyPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
148
-
149
- const cssPlan = t.getMetaInvalidationPlan("/site/meta/shared/theme.css", "/site/meta");
150
- expect(cssPlan.requiresFullRebuild).toBe(false);
151
- expect(cssPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
152
-
153
- // Unknown meta file types still fall back to a full rebuild
154
- const unknownPlan = t.getMetaInvalidationPlan("/site/meta/shared/data.json", "/site/meta");
155
- expect(unknownPlan.requiresFullRebuild).toBe(true);
156
- });
157
- });
@@ -1,141 +0,0 @@
1
- // Cache busting helpers for build
2
-
3
- /**
4
- * Generate a cache-busting timestamp in ISO format (e.g., 20251221T221700Z)
5
- * @returns {string} Timestamp string suitable for query params
6
- */
7
- export function generateCacheBustTimestamp() {
8
- const now = new Date();
9
- const year = now.getUTCFullYear();
10
- const month = String(now.getUTCMonth() + 1).padStart(2, '0');
11
- const day = String(now.getUTCDate()).padStart(2, '0');
12
- const hours = String(now.getUTCHours()).padStart(2, '0');
13
- const minutes = String(now.getUTCMinutes()).padStart(2, '0');
14
- const seconds = String(now.getUTCSeconds()).padStart(2, '0');
15
- return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
16
- }
17
-
18
- /**
19
- * Add cache-busting timestamp to url() references in CSS content
20
- * @param {string} cssContent - The CSS file content
21
- * @param {string} timestamp - The cache-busting timestamp
22
- * @returns {string} CSS with timestamped URLs
23
- */
24
- export function addTimestampToCssUrls(cssContent, timestamp) {
25
- // Match url(...) in any context, including CSS variables, with optional whitespace and quotes
26
- // Exclude data: URLs and already-timestamped URLs
27
- return cssContent.replace(
28
- /url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi,
29
- (match, quote, url) => {
30
- // Don't add timestamp if already has query string
31
- if (url.includes('?')) {
32
- return match;
33
- }
34
- return `url(${quote}${url}?v=${timestamp}${quote})`;
35
- }
36
- );
37
- }
38
-
39
- /**
40
- * Add cache-busting timestamp to static file references in HTML
41
- * @param {string} html - The HTML content
42
- * @param {string} timestamp - The cache-busting timestamp
43
- * @returns {string} HTML with timestamped static file references
44
- */
45
- export function addTimestampToHtmlStaticRefs(html, timestamp) {
46
- // Add timestamp to CSS links
47
- html = html.replace(
48
- /(<link[^>]+href=["'])([^"']+\.css)(["'][^>]*>)/gi,
49
- `$1$2?v=${timestamp}$3`
50
- );
51
- // Add timestamp to JS scripts
52
- html = html.replace(
53
- /(<script[^>]+src=["'])([^"']+\.js)(["'][^>]*>)/gi,
54
- `$1$2?v=${timestamp}$3`
55
- );
56
- // Add timestamp to images in img tags
57
- html = html.replace(
58
- /(<img[^>]+src=["'])([^"']+\.(jpg|jpeg|png|gif|webp|svg|ico))(["'][^>]*>)/gi,
59
- `$1$2?v=${timestamp}$4`
60
- );
61
- return html;
62
- }
63
-
64
- /**
65
- * Generate a short content-based hash for a file's contents.
66
- * Used for per-file cache-busting so that only changed files invalidate caches.
67
- * @param {string} content - File content to hash
68
- * @returns {string} Short hex hash (8 chars)
69
- */
70
- export function generateFileHash(content) {
71
- let hash = 0;
72
- for (let i = 0; i < content.length; i++) {
73
- const char = content.charCodeAt(i);
74
- hash = ((hash << 5) - hash) + char;
75
- hash = hash & hash; // Convert to 32-bit integer
76
- }
77
- return Math.abs(hash).toString(16).padStart(8, "0").substring(0, 8);
78
- }
79
-
80
- /**
81
- * A cache-bust hash map that stores per-file content hashes.
82
- * When a static file changes, its hash changes, which invalidates
83
- * any document referencing it via ?v= query parameters.
84
- */
85
- export class CacheBustHashMap {
86
- constructor() {
87
- /** @type {Map<string, string>} relative file path → content hash */
88
- this.hashes = new Map();
89
- /** @type {string} fallback timestamp for files not individually tracked */
90
- this.fallbackTimestamp = generateCacheBustTimestamp();
91
- }
92
-
93
- /**
94
- * Update the hash for a file.
95
- * @param {string} relativePath - File path relative to output dir (e.g., "campaigns/abs/style.css")
96
- * @param {string} content - File content
97
- * @returns {string} The new hash
98
- */
99
- update(relativePath, content) {
100
- const hash = generateFileHash(content);
101
- this.hashes.set(relativePath, hash);
102
- return hash;
103
- }
104
-
105
- /**
106
- * Get the cache-bust version string for a file.
107
- * Returns the per-file hash if available, otherwise the fallback timestamp.
108
- * @param {string} relativePath - File path relative to output dir
109
- * @returns {string} Version string
110
- */
111
- getVersion(relativePath) {
112
- return this.hashes.get(relativePath) || this.fallbackTimestamp;
113
- }
114
-
115
- /**
116
- * Check if a file's hash has changed.
117
- * @param {string} relativePath
118
- * @param {string} content
119
- * @returns {boolean} true if the hash differs from the stored value
120
- */
121
- hasChanged(relativePath, content) {
122
- const newHash = generateFileHash(content);
123
- const oldHash = this.hashes.get(relativePath);
124
- return newHash !== oldHash;
125
- }
126
-
127
- /**
128
- * Refresh the fallback timestamp (e.g., at the start of a new build).
129
- */
130
- refreshTimestamp() {
131
- this.fallbackTimestamp = generateCacheBustTimestamp();
132
- }
133
-
134
- /**
135
- * Get the fallback timestamp (for backward compatibility).
136
- * @returns {string}
137
- */
138
- get timestamp() {
139
- return this.fallbackTimestamp;
140
- }
141
- }
@@ -1,145 +0,0 @@
1
- // Navigation cache for build performance
2
- // Caches the menu structure and only rebuilds when the file list changes
3
-
4
- import { readFile, writeFile, mkdir, stat } from 'fs/promises';
5
- import { existsSync, readFileSync } from 'fs';
6
- import { join, dirname, extname, basename } from 'path';
7
- import { createHash } from 'crypto';
8
-
9
- const NAV_CACHE_FILE = 'nav-cache.json';
10
-
11
- /**
12
- * Generate a hash of the file list to detect changes
13
- * @param {string[]} files - Array of file paths
14
- * @returns {string} Hash of the file list
15
- */
16
- export function hashFileList(files) {
17
- const sorted = [...files].sort();
18
- return createHash('md5').update(sorted.join('\n')).digest('hex').substring(0, 16);
19
- }
20
-
21
- /**
22
- * Generate a hash of file stats for detecting content changes
23
- * Uses mtime and size for speed (avoids reading file contents)
24
- * @param {string[]} files - Array of file paths to check
25
- * @returns {Promise<string>} Hash of file stats
26
- */
27
- export async function hashFileStats(files) {
28
- // Only check index files and config files that affect menu generation
29
- const relevantFiles = files.filter(f => {
30
- const base = basename(f).toLowerCase();
31
- return base === 'index.md' ||
32
- base === 'index.mdx' ||
33
- base === 'index.txt' ||
34
- base === 'index.yml' ||
35
- base === 'config.json' ||
36
- base === 'menu.md' ||
37
- base === 'menu.txt' ||
38
- base === '_menu.md' ||
39
- base === '_menu.txt' ||
40
- base.endsWith('-icon.png') ||
41
- base.endsWith('-icon.svg') ||
42
- base === 'icon.png' ||
43
- base === 'icon.svg';
44
- }).sort();
45
-
46
- // Stat files in parallel batches for speed
47
- const BATCH_SIZE = 100;
48
- const stats = [];
49
-
50
- for (let i = 0; i < relevantFiles.length; i += BATCH_SIZE) {
51
- const batch = relevantFiles.slice(i, i + BATCH_SIZE);
52
- const batchResults = await Promise.all(batch.map(async (file) => {
53
- try {
54
- const s = await stat(file);
55
- return `${file}:${s.mtimeMs}:${s.size}`;
56
- } catch (e) {
57
- return null; // File might not exist
58
- }
59
- }));
60
- stats.push(...batchResults.filter(Boolean));
61
- }
62
-
63
- return createHash('md5').update(stats.join('\n')).digest('hex').substring(0, 16);
64
- }
65
-
66
- /**
67
- * Load the navigation cache from disk
68
- * @param {string} sourceDir - Source directory root
69
- * @returns {Promise<object|null>} Cached nav data or null
70
- */
71
- export async function loadNavCache(sourceDir) {
72
- const cachePath = join(sourceDir, '.ursa', NAV_CACHE_FILE);
73
- try {
74
- if (existsSync(cachePath)) {
75
- const data = await readFile(cachePath, 'utf8');
76
- return JSON.parse(data);
77
- }
78
- } catch (e) {
79
- // Ignore errors
80
- }
81
- return null;
82
- }
83
-
84
- /**
85
- * Save the navigation cache to disk
86
- * @param {string} sourceDir - Source directory root
87
- * @param {object} cache - Cache data to save
88
- */
89
- export async function saveNavCache(sourceDir, cache) {
90
- const ursaDir = join(sourceDir, '.ursa');
91
- const cachePath = join(ursaDir, NAV_CACHE_FILE);
92
- try {
93
- await mkdir(ursaDir, { recursive: true });
94
- await writeFile(cachePath, JSON.stringify(cache, null, 2));
95
- } catch (e) {
96
- console.warn('Could not save nav cache:', e.message);
97
- }
98
- }
99
-
100
- /**
101
- * Check if the navigation cache is valid
102
- * @param {object} cache - The cached data
103
- * @param {string} fileListHash - Current file list hash
104
- * @param {string} fileStatsHash - Current file stats hash
105
- * @returns {boolean} True if cache is valid
106
- */
107
- export function isNavCacheValid(cache, fileListHash, fileStatsHash) {
108
- if (!cache) return false;
109
- if (cache.fileListHash !== fileListHash) return false;
110
- if (cache.fileStatsHash !== fileStatsHash) return false;
111
- if (!cache.menuData || !cache.menuHtml || !cache.validPaths) return false;
112
- return true;
113
- }
114
-
115
- /**
116
- * Create a cache entry
117
- * @param {string} fileListHash - Hash of file list
118
- * @param {string} fileStatsHash - Hash of file stats
119
- * @param {object} menuData - Menu data structure
120
- * @param {string} menuHtml - Rendered menu HTML
121
- * @param {Array} validPathsArray - Valid paths as array of [key, value] pairs
122
- * @param {Map} customMenus - Custom menus map
123
- * @returns {object} Cache entry
124
- */
125
- export function createNavCacheEntry(fileListHash, fileStatsHash, menuData, menuHtml, validPathsArray, customMenusArray) {
126
- return {
127
- version: 1,
128
- timestamp: Date.now(),
129
- fileListHash,
130
- fileStatsHash,
131
- menuData,
132
- menuHtml,
133
- validPaths: validPathsArray,
134
- customMenus: customMenusArray,
135
- };
136
- }
137
-
138
- /**
139
- * Restore a Map from cached array
140
- * @param {Array} arr - Array of [key, value] pairs
141
- * @returns {Map} Restored map
142
- */
143
- export function restoreMap(arr) {
144
- return new Map(arr);
145
- }
@@ -1,33 +0,0 @@
1
- // Watch mode cache and clear function for build
2
-
3
- import { dependencyTracker } from "../dependencyTracker.js";
4
-
5
- export const watchModeCache = {
6
- templates: null,
7
- menu: null,
8
- footer: null,
9
- validPaths: null,
10
- source: null,
11
- meta: null,
12
- output: null,
13
- hashCache: null,
14
- cacheBustTimestamp: null,
15
- cacheBustHashes: null, // CacheBustHashMap instance for per-file cache-busting
16
- allArticlePaths: null, // Array of all article paths (for full rebuild tracking)
17
- lastFullBuild: 0,
18
- isInitialized: false,
19
- };
20
-
21
- export function clearWatchCache(cssPathCache) {
22
- watchModeCache.templates = null;
23
- watchModeCache.menu = null;
24
- watchModeCache.footer = null;
25
- watchModeCache.validPaths = null;
26
- watchModeCache.hashCache = null;
27
- watchModeCache.cacheBustHashes = null;
28
- watchModeCache.allArticlePaths = null;
29
- watchModeCache.isInitialized = false;
30
- dependencyTracker.init("");
31
- if (cssPathCache) cssPathCache.clear();
32
- console.log('Watch cache cleared');
33
- }