@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 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
- '$0 <source>',
239
- 'Generate a static site from source files (default command)',
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 containing markdown/wikitext files',
244
+ describe: 'Source directory (docroot) of the Ursa site',
244
245
  type: 'string',
245
246
  demandOption: true
246
247
  })
247
- .option('meta', {
248
- alias: 'm',
249
- default: 'meta',
250
- describe: 'Meta directory containing templates and styles',
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
- .option('whitelist', {
260
- alias: 'w',
261
- describe: 'Path to whitelist file containing patterns for files to include',
262
- type: 'string'
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 meta = resolve(argv.meta);
268
- const output = resolve(argv.output);
269
- const whitelist = argv.whitelist ? resolve(argv.whitelist) : null;
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 generate({
278
- _source: source,
279
- _meta: meta,
280
- _output: output,
281
- _whitelist: whitelist
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('Error generating site:', error.message);
272
+ console.error(`Error creating template instance: ${error.message}`);
286
273
  process.exit(1);
287
274
  }
288
275
  }
@@ -50,11 +50,16 @@ h1 {
50
50
  text-overflow: ellipsis;
51
51
  }
52
52
 
53
+ h1,h2,h3 {
54
+ clear: both;
55
+ }
56
+
53
57
  figure {
54
58
  margin: 0.5rem 0;
55
59
  padding: 0;
56
60
  max-width: 300px;
57
61
  float: left;
62
+ clear: both;
58
63
  margin-right: 1.5rem;
59
64
  margin-bottom: 0.5rem;
60
65
 
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.85.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
+ });