@kenjura/ursa 0.85.0 → 0.87.1
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 +34 -0
- package/bin/ursa.js +23 -36
- package/meta/templates/default-template/default.css +5 -0
- package/package.json +2 -1
- package/src/dev.js +16 -1
- package/src/helper/__test__/dependencyTracker.test.js +157 -0
- package/src/helper/__test__/documentTemplates.test.js +354 -0
- package/src/helper/automenu.js +1 -1
- package/src/helper/build/__test__/autoIndex.test.js +95 -0
- package/src/helper/build/__test__/graph.test.js +529 -0
- package/src/helper/build/autoIndex.js +7 -1
- package/src/helper/build/graph.js +542 -0
- package/src/helper/build/index.js +1 -0
- package/src/helper/build/ursaMetadata.js +62 -0
- package/src/helper/dependencyTracker.js +116 -1
- package/src/helper/documentTemplates.js +454 -0
- package/src/jobs/generate.js +83 -6
- package/src/serve.js +66 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
# 0.87.1
|
|
2
|
+
2026-06-15
|
|
3
|
+
|
|
4
|
+
- nothing
|
|
5
|
+
|
|
6
|
+
# 0.87.0
|
|
7
|
+
2026-06-11
|
|
8
|
+
|
|
9
|
+
- Added support for definition lists in Markdown/MDX documents, allowing for structured term-definition pairs to be rendered as HTML definition lists (<dl>, <dt>, <dd>).
|
|
10
|
+
- Added response headers for json files with the ursa and doc repo version numbers, so consumers can invalidate caches when a new version is deployed.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Serve Revamp (Phases 0–1)
|
|
14
|
+
|
|
15
|
+
`ursa serve` could miss cascading updates — stylesheet edits, static files in `meta/`, template changes, and any change saved while a rebuild was already running — forcing a restart with `--clean`. This release ships the first two phases of the rework; design, root-cause analysis, and remaining TODOs: [docs/changes/serve-logic.md](docs/changes/serve-logic.md).
|
|
16
|
+
|
|
17
|
+
- **Serve-mode reliability fixes (Phase 0):**
|
|
18
|
+
- Static assets in `meta/` (images, fonts, PDFs, media) are now watched; replacing one re-copies it to output and reloads clients, without a full rebuild. Previously these changes were invisible until restart.
|
|
19
|
+
- Changes saved while a regeneration is in flight are queued and processed when the current pass finishes, instead of being dropped with "changes lost".
|
|
20
|
+
- Every full-rebuild path now deletes `content-hashes.json` and `nav-cache.json` first, so a rebuild after a template/meta change actually regenerates unchanged articles instead of hash-skipping them and leaving stale HTML.
|
|
21
|
+
- The dependency tracker is persisted to `.ursa/dependency-graph.json` and reloaded on warm start, so hash-skipped documents keep their invalidation edges. Single-file regeneration now registers dependencies too, and template edits are recognized under the `templates/{name}/index.html` folder structure — a template edit on a warm start is now a selective rebuild of just the documents using that template.
|
|
22
|
+
- **New incremental build engine (Phase 1):** `src/helper/build/graph.js` — a fingerprinted dependency graph (Make/Shake-style) with dynamic dependency discovery (`ctx.read`/`ctx.exists`/`ctx.get`), lookup nodes so file *creation* invalidates, size+mtime fast-path fingerprinting with content-hash confirmation, early cutoff, topological demand-driven scheduling, failure isolation with retry, and versioned persistence to `.ursa/graph.json`. Fully unit-tested; the build/serve pipelines will be ported onto it in the next phases (2–4).
|
|
23
|
+
|
|
24
|
+
# 0.86.0
|
|
25
|
+
2026-05-18
|
|
26
|
+
|
|
27
|
+
- small CSS fix
|
|
28
|
+
|
|
29
|
+
# 0.85.0
|
|
30
|
+
2026-05-15
|
|
31
|
+
|
|
32
|
+
- deflists in MDX
|
|
33
|
+
|
|
34
|
+
|
|
1
35
|
# 0.84.0
|
|
2
36
|
2026-05-08
|
|
3
37
|
|
package/bin/ursa.js
CHANGED
|
@@ -6,6 +6,7 @@ import { generate } from '../src/jobs/generate.js';
|
|
|
6
6
|
import { resolve, dirname, join } from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { stagePromotedChangelog, registerCleanupOnExit } from '../src/helper/promoteChangelog.js';
|
|
9
|
+
import { instantiateTemplate } from '../src/helper/documentTemplates.js';
|
|
9
10
|
|
|
10
11
|
// Get the directory where ursa is installed
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -14,7 +15,7 @@ const PACKAGE_META = join(__dirname, '..', 'meta');
|
|
|
14
15
|
|
|
15
16
|
yargs(hideBin(process.argv))
|
|
16
17
|
.command(
|
|
17
|
-
'generate <source>',
|
|
18
|
+
['generate <source>', '$0 <source>'],
|
|
18
19
|
'Generate a static site from source files',
|
|
19
20
|
(yargs) => {
|
|
20
21
|
return yargs
|
|
@@ -235,54 +236,40 @@ yargs(hideBin(process.argv))
|
|
|
235
236
|
}
|
|
236
237
|
)
|
|
237
238
|
.command(
|
|
238
|
-
'
|
|
239
|
-
'
|
|
239
|
+
'template <source> <templatePath> <destination>',
|
|
240
|
+
'Create a new document from a document template',
|
|
240
241
|
(yargs) => {
|
|
241
242
|
return yargs
|
|
242
243
|
.positional('source', {
|
|
243
|
-
describe: 'Source directory
|
|
244
|
+
describe: 'Source directory (docroot) of the Ursa site',
|
|
244
245
|
type: 'string',
|
|
245
246
|
demandOption: true
|
|
246
247
|
})
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
type: 'string'
|
|
252
|
-
})
|
|
253
|
-
.option('output', {
|
|
254
|
-
alias: 'o',
|
|
255
|
-
default: 'output',
|
|
256
|
-
describe: 'Output directory for generated site',
|
|
257
|
-
type: 'string'
|
|
248
|
+
.positional('templatePath', {
|
|
249
|
+
describe: 'Path to the template file (relative to source, e.g. _templates/city.md)',
|
|
250
|
+
type: 'string',
|
|
251
|
+
demandOption: true
|
|
258
252
|
})
|
|
259
|
-
.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
253
|
+
.positional('destination', {
|
|
254
|
+
describe: 'Path for the new document (relative to source, e.g. places/springfield.md)',
|
|
255
|
+
type: 'string',
|
|
256
|
+
demandOption: true
|
|
263
257
|
});
|
|
264
258
|
},
|
|
265
259
|
async (argv) => {
|
|
266
260
|
const source = resolve(argv.source);
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
|
|
272
|
-
if (whitelist) {
|
|
273
|
-
console.log(`Using whitelist: ${whitelist}`);
|
|
274
|
-
}
|
|
275
|
-
|
|
261
|
+
const templateAbsPath = resolve(source, argv.templatePath);
|
|
262
|
+
const destAbsPath = resolve(source, argv.destination);
|
|
263
|
+
|
|
276
264
|
try {
|
|
277
|
-
await
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
283
|
-
console.log('Site generation completed successfully!');
|
|
265
|
+
const { templateRelPath, destRelPath } = await instantiateTemplate(
|
|
266
|
+
templateAbsPath,
|
|
267
|
+
destAbsPath,
|
|
268
|
+
source
|
|
269
|
+
);
|
|
270
|
+
console.log(`✅ Created ${destRelPath} from template ${templateRelPath}`);
|
|
284
271
|
} catch (error) {
|
|
285
|
-
console.error(
|
|
272
|
+
console.error(`Error creating template instance: ${error.message}`);
|
|
286
273
|
process.exit(1);
|
|
287
274
|
}
|
|
288
275
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@kenjura/ursa",
|
|
3
3
|
"author": "Andrew London <andrew@kenjura.com>",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.87.1",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"markdown-it-front-matter": "^0.2.3",
|
|
36
36
|
"markdown-it-sup": "^1.0.0",
|
|
37
37
|
"mdx-bundler": "^10.1.1",
|
|
38
|
+
"node-diff3": "^3.2.0",
|
|
38
39
|
"node-watch": "^0.7.3",
|
|
39
40
|
"object-to-xml": "^2.0.0",
|
|
40
41
|
"react": "^19.2.4",
|
package/src/dev.js
CHANGED
|
@@ -34,7 +34,7 @@ import { extractImageReferences } from "./helper/imageExtractor.js";
|
|
|
34
34
|
import { recurse } from "./helper/recursive-readdir.js";
|
|
35
35
|
import { isFolderHidden, clearConfigCache } from "./helper/folderConfig.js";
|
|
36
36
|
import { extractSections } from "./helper/sectionExtractor.js";
|
|
37
|
-
import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
|
|
37
|
+
import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, getUrsaMetadata, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
|
|
38
38
|
import { findCustomMenu, extractMenuFrontmatter, parseCustomMenu, combineAutoAndManualMenu } from "./helper/customMenu.js";
|
|
39
39
|
import { getAndIncrementBuildId } from "./helper/ursaConfig.js";
|
|
40
40
|
import { resolvePort } from "./helper/portUtils.js";
|
|
@@ -47,6 +47,9 @@ const devState = {
|
|
|
47
47
|
source: null,
|
|
48
48
|
meta: null,
|
|
49
49
|
output: null,
|
|
50
|
+
|
|
51
|
+
// Ursa + doc repo versions, embedded in JSON response headers
|
|
52
|
+
ursaMetadata: null,
|
|
50
53
|
|
|
51
54
|
// Background cache status
|
|
52
55
|
cacheReady: false,
|
|
@@ -766,6 +769,7 @@ export async function dev({
|
|
|
766
769
|
devState.source = sourceDir + '/';
|
|
767
770
|
devState.meta = metaDir;
|
|
768
771
|
devState.output = outputDir + '/';
|
|
772
|
+
devState.ursaMetadata = await getUrsaMetadata(_source);
|
|
769
773
|
|
|
770
774
|
console.log('🚀 Ursa Dev Mode');
|
|
771
775
|
console.log('━'.repeat(50));
|
|
@@ -810,6 +814,17 @@ export async function dev({
|
|
|
810
814
|
threshold: 1024,
|
|
811
815
|
level: 6
|
|
812
816
|
}));
|
|
817
|
+
|
|
818
|
+
// Add ursa-version and doc-version headers to all JSON responses
|
|
819
|
+
// (per-document JSON, directory index arrays, and public/*.json index files)
|
|
820
|
+
app.use((req, res, next) => {
|
|
821
|
+
if (req.path.endsWith('.json')) {
|
|
822
|
+
const meta = devState.ursaMetadata || {};
|
|
823
|
+
res.setHeader('X-ursa-version', meta.ursaVersion || 'unknown');
|
|
824
|
+
res.setHeader('X-doc-version', meta.docVersion || 'unknown');
|
|
825
|
+
}
|
|
826
|
+
next();
|
|
827
|
+
});
|
|
813
828
|
|
|
814
829
|
// Dev mode document handler
|
|
815
830
|
app.use(async (req, res, next) => {
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
});
|