@kenjura/ursa 0.86.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 +23 -0
- package/package.json +1 -1
- package/src/dev.js +16 -1
- package/src/helper/__test__/dependencyTracker.test.js +157 -0
- 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/jobs/generate.js +47 -5
- package/src/serve.js +41 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
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
|
+
|
|
1
24
|
# 0.86.0
|
|
2
25
|
2026-05-18
|
|
3
26
|
|
package/package.json
CHANGED
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
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { generateAutoIndices } from "../autoIndex.js";
|
|
6
|
+
|
|
7
|
+
let tempDir;
|
|
8
|
+
let source;
|
|
9
|
+
let output;
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await mkdtemp(join(tmpdir(), "ursa-autoindex-"));
|
|
12
|
+
source = join(tempDir, "source");
|
|
13
|
+
output = join(tempDir, "output");
|
|
14
|
+
await mkdir(source, { recursive: true });
|
|
15
|
+
await mkdir(output, { recursive: true });
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const TEMPLATE =
|
|
22
|
+
"<html><head>${styleLink}</head><body>${menu}${body}${footer}${customScript}</body></html>";
|
|
23
|
+
|
|
24
|
+
function makeProgress() {
|
|
25
|
+
const logs = [];
|
|
26
|
+
return {
|
|
27
|
+
logs,
|
|
28
|
+
log: (msg) => logs.push(msg),
|
|
29
|
+
status: () => {},
|
|
30
|
+
done: () => {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runAutoIndices(directories, generatedArticles, progress) {
|
|
35
|
+
return generateAutoIndices(
|
|
36
|
+
output,
|
|
37
|
+
directories,
|
|
38
|
+
source,
|
|
39
|
+
{ "default-template": TEMPLATE },
|
|
40
|
+
"",
|
|
41
|
+
"",
|
|
42
|
+
generatedArticles,
|
|
43
|
+
new Set(),
|
|
44
|
+
new Set(),
|
|
45
|
+
"20260101000000",
|
|
46
|
+
progress,
|
|
47
|
+
null
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("generateAutoIndices with empty source folders", () => {
|
|
52
|
+
it("skips output directories that were never created instead of logging an error", async () => {
|
|
53
|
+
// Source has an empty folder (guides) and a folder with a document (docs).
|
|
54
|
+
// Only docs produced output files, so output/guides does not exist.
|
|
55
|
+
await mkdir(join(source, "guides"));
|
|
56
|
+
await mkdir(join(source, "docs"));
|
|
57
|
+
await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
|
|
58
|
+
await mkdir(join(output, "docs"));
|
|
59
|
+
await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
|
|
60
|
+
|
|
61
|
+
const progress = makeProgress();
|
|
62
|
+
await runAutoIndices(
|
|
63
|
+
[join(source, "guides"), join(source, "docs")],
|
|
64
|
+
[join(source, "docs", "hello.md")],
|
|
65
|
+
progress
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const errors = progress.logs.filter((m) => /Error generating auto-index/i.test(m));
|
|
69
|
+
expect(errors).toEqual([]);
|
|
70
|
+
// The missing output directory is skipped, not created
|
|
71
|
+
expect(existsSync(join(output, "guides"))).toBe(false);
|
|
72
|
+
expect(existsSync(join(output, "guides", "index.html"))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("still generates auto-indices for folders that produced output", async () => {
|
|
76
|
+
await mkdir(join(source, "guides"));
|
|
77
|
+
await mkdir(join(source, "docs"));
|
|
78
|
+
await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
|
|
79
|
+
await mkdir(join(output, "docs"));
|
|
80
|
+
await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
|
|
81
|
+
|
|
82
|
+
const progress = makeProgress();
|
|
83
|
+
await runAutoIndices(
|
|
84
|
+
[join(source, "guides"), join(source, "docs")],
|
|
85
|
+
[join(source, "docs", "hello.md")],
|
|
86
|
+
progress
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Root and docs both exist in output, so both get an index.html
|
|
90
|
+
const docsIndex = await readFile(join(output, "docs", "index.html"), "utf8");
|
|
91
|
+
expect(docsIndex).toContain('<a href="hello.html">');
|
|
92
|
+
const rootIndex = await readFile(join(output, "index.html"), "utf8");
|
|
93
|
+
expect(rootIndex).toContain('<a href="docs/index.html">');
|
|
94
|
+
});
|
|
95
|
+
});
|