canon-atlas 0.1.0 → 0.3.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.
- package/README.md +13 -1
- package/package.json +6 -1
- package/src/config.mjs +22 -6
- package/src/serve.mjs +24 -3
- package/src/ui/app.js +13 -0
- package/src/ui/pipeline.cjs +263 -6
- package/src/ui/shell.js +62 -8
- package/src/ui/style.css +4 -1
package/README.md
CHANGED
|
@@ -4,7 +4,15 @@ An atlas over a catalog of markdown documents that reference each other. Point i
|
|
|
4
4
|
|
|
5
5
|
It is agnostic to what wrote the catalog. It was built as the human viewport onto [pi-canon](https://github.com/shaneconner/pi-canon) project memory, where mutable articles carry current ground truth and an immutable journal carries the journey to it, but any folder of markdown with `[[wikilinks]]` or relative links works: an Obsidian vault, a wiki, a notes directory, an agent memory store.
|
|
6
6
|
|
|
7
|
-

|
|
8
|
+
|
|
9
|
+
Click a star and the camera flies to it, its neighborhood lights while the rest of the sky dims, and the reader opens. Follow a link and you hop the connection; back walks the trail home.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
The same footage plays smoother as [a short video](docs/atlas.mp4).
|
|
14
|
+
|
|
15
|
+

|
|
8
16
|
|
|
9
17
|
## Quick start
|
|
10
18
|
|
|
@@ -60,6 +68,10 @@ To define your own, put a `canon-atlas.json` at the root:
|
|
|
60
68
|
|
|
61
69
|
References that resolve nowhere are not errors: they render as dashed, unwritten nodes, because a name the corpus reaches for but has not written yet is a fact worth seeing. In live mode an unwritten node offers to be written.
|
|
62
70
|
|
|
71
|
+
### The article schema
|
|
72
|
+
|
|
73
|
+
A store may carry a `schema.json` beside its collections ([pi-canon](https://github.com/shaneconner/pi-canon) writes one when it creates a store), declaring rules for an article's `capsule`, `title`, and `body`: `required`, `min_chars`, `max_chars`, and a `hint`. The reference graph is part of the contract too, under `relations`: `refs` (`required`, `min_count`) judges a document's own citations at the write door, counting body links and refs frontmatter once each with code examples stripped, and touching means the reference set changed, so a body edit that keeps its citations is never re-litigated; `orphan.warn` notes a document nothing references; `children.listed` notes a document that fails to reference a direct child under its address. The graph rules are exactly what a whole-graph reader is positioned to enforce, so the atlas enforces them and pi-canon enforces `refs` at its own boundary, both from the same file. The atlas enforces the same contract at every one of its write doors. Enforcement is asymmetric on purpose: a `required` rule rejects a save that touches the field (and everything on create), with nothing written and the editor keeping your text while it tells you what to correct; every other rule warns, and a document already in violation shows heal notes in the reader instead of errors, so a body edit is never held hostage to a legacy omission. A malformed `schema.json` fails open and loud: nothing is enforced, and the map panel and every save say so, because a contract the owner believes is enforced while a typo disabled it is the worst state.
|
|
74
|
+
|
|
63
75
|
## Color
|
|
64
76
|
|
|
65
77
|
Color is the cluster a document belongs to, sampled from one perceptual ramp (Batlow), and the force layout pulls each cluster into its own region, so a band of the palette and a region of the map mean the same thing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canon-atlas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "An atlas over a catalog of markdown documents that reference each other: a constellation graph, search, a reader, and local editing. Agnostic to what wrote the catalog.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/build.mjs",
|
|
11
|
+
"./serve": "./src/serve.mjs",
|
|
12
|
+
"./page": "./src/page.mjs",
|
|
13
|
+
"./pipeline": "./src/ui/pipeline.cjs",
|
|
14
|
+
"./assets/*": "./src/ui/*",
|
|
15
|
+
"./vendor/*": "./vendor/*",
|
|
11
16
|
"./package.json": "./package.json"
|
|
12
17
|
},
|
|
13
18
|
"files": [
|
package/src/config.mjs
CHANGED
|
@@ -22,15 +22,31 @@ function isDir(p) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/* Resolution order: an explicit canon-atlas.json at the root wins, then preset
|
|
25
|
-
detection (a store with both articles/ and journal
|
|
26
|
-
bare default of one mutable collection
|
|
27
|
-
that names a title but no collections
|
|
25
|
+
detection (a store with both articles/ and journal/, at the root or nested
|
|
26
|
+
under .canon/, is pi-canon), then the bare default of one mutable collection
|
|
27
|
+
over every markdown file. A config that names a title but no collections
|
|
28
|
+
keeps the detected preset. The prefix handling lives in the shared
|
|
29
|
+
detectPreset so Node and the browser resolve the same store the same way. */
|
|
28
30
|
export function loadConfig(root) {
|
|
29
31
|
const file = join(root, CONFIG_NAME);
|
|
30
32
|
const raw = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
31
|
-
const detect = () =>
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const detect = () => {
|
|
34
|
+
for (const prefix of ["", ".canon/"]) {
|
|
35
|
+
if (isDir(join(root, prefix, "articles")) && isDir(join(root, prefix, "journal"))) {
|
|
36
|
+
return P.detectPreset([prefix + "articles/x.md", prefix + "journal/x.md"]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return P.DEFAULT_CONFIG;
|
|
40
|
+
};
|
|
41
|
+
const config = P.buildConfig(P.composeConfig(raw, detect));
|
|
42
|
+
/* The store's article contract, when it carries one: schema.json beside the
|
|
43
|
+
collections (pi-canon writes it at store creation). Parsed by the shared
|
|
44
|
+
pipeline so the browser and this process enforce identical rules. */
|
|
45
|
+
const sf = join(root, P.schemaPrefix(config), P.SCHEMA_FILE);
|
|
46
|
+
const s = P.parseSchema(existsSync(sf) ? readFileSync(sf, "utf8") : null);
|
|
47
|
+
config.schema = s.schema;
|
|
48
|
+
config.schemaProblems = s.problems;
|
|
49
|
+
return config;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
/* Optional embedding vectors at the path config.embeddings names. Absent is
|
package/src/serve.mjs
CHANGED
|
@@ -26,11 +26,14 @@ import {
|
|
|
26
26
|
} from "node:fs";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
29
|
+
import { createRequire } from "node:module";
|
|
29
30
|
import { loadConfig, loadVectors, collectionFor } from "./config.mjs";
|
|
30
31
|
import { scanCorpus } from "./scan.mjs";
|
|
31
32
|
import { buildGraph } from "./graph.mjs";
|
|
32
33
|
import { buildData, renderPage, renderAppPage } from "./page.mjs";
|
|
33
34
|
|
|
35
|
+
const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
|
|
36
|
+
|
|
34
37
|
const CACHE_TTL_MS = 3000;
|
|
35
38
|
const BODY_LIMIT = 4_000_000;
|
|
36
39
|
|
|
@@ -149,6 +152,22 @@ export function corpusApi(rootArg) {
|
|
|
149
152
|
return { full, rel: norm, col };
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/* The store's schema, enforced at this door the way pi-canon enforces it at
|
|
156
|
+
its own: a required violation the save touched (or a create) rejects with
|
|
157
|
+
nothing written, so the client keeps the text and says what to correct;
|
|
158
|
+
everything else comes back as warnings beside the success. */
|
|
159
|
+
function schemaGate(content, prevRaw, col) {
|
|
160
|
+
const cfg = config();
|
|
161
|
+
if (col.immutable) return [];
|
|
162
|
+
/* A broken declaration enforces nothing, but every write says so. */
|
|
163
|
+
if (!cfg.schema) return cfg.schemaProblems || [];
|
|
164
|
+
const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields);
|
|
165
|
+
if (r.rejects.length) {
|
|
166
|
+
throw httpError(422, "Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
|
|
167
|
+
}
|
|
168
|
+
return r.warns.concat(cfg.schemaProblems || []);
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
/* Serve one API request whose path inside the API is sub ("/graph",
|
|
153
172
|
"/doc"). Returns true when the route was taken; errors throw httpError
|
|
154
173
|
for the caller's catch, so both hosts report them the same way. */
|
|
@@ -169,12 +188,13 @@ export function corpusApi(rootArg) {
|
|
|
169
188
|
if (req.method === "POST") {
|
|
170
189
|
guardMutation(req);
|
|
171
190
|
const body = await readBody(req);
|
|
172
|
-
const { full, rel } = confine(body.path);
|
|
191
|
+
const { full, rel, col } = confine(body.path);
|
|
173
192
|
if (existsSync(full)) throw httpError(409, "document already exists");
|
|
193
|
+
const warnings = schemaGate(body.content, null, col);
|
|
174
194
|
mkdirSync(dirname(full), { recursive: true });
|
|
175
195
|
writeFileSync(full, String(body.content ?? ""), { flag: "wx" });
|
|
176
196
|
bust();
|
|
177
|
-
send(201, { path: rel });
|
|
197
|
+
send(201, warnings.length ? { path: rel, warnings } : { path: rel });
|
|
178
198
|
return true;
|
|
179
199
|
}
|
|
180
200
|
if (req.method === "PUT") {
|
|
@@ -183,9 +203,10 @@ export function corpusApi(rootArg) {
|
|
|
183
203
|
const { full, rel, col } = confine(body.path);
|
|
184
204
|
if (col.immutable) throw httpError(403, `${col.name} is immutable`);
|
|
185
205
|
if (!existsSync(full)) throw httpError(404, "no such document");
|
|
206
|
+
const warnings = schemaGate(body.content, readFileSync(full, "utf8"), col);
|
|
186
207
|
atomicWrite(full, String(body.content ?? ""));
|
|
187
208
|
bust();
|
|
188
|
-
send(200, { path: rel });
|
|
209
|
+
send(200, warnings.length ? { path: rel, warnings } : { path: rel });
|
|
189
210
|
return true;
|
|
190
211
|
}
|
|
191
212
|
if (req.method === "DELETE") {
|
package/src/ui/app.js
CHANGED
|
@@ -554,6 +554,11 @@
|
|
|
554
554
|
none: "No clusters yet: add links between documents, or provide embeddings, and regions will form.",
|
|
555
555
|
}[data.basis];
|
|
556
556
|
ov.appendChild(el("p", "side-meta", basisLine + " Select a cluster to isolate it, or any node to read it."));
|
|
557
|
+
/* A schema the owner believes is enforced while a typo disabled it is
|
|
558
|
+
the worst state, so declaration problems surface here, loud. */
|
|
559
|
+
if (data.schemaProblems && data.schemaProblems.length) {
|
|
560
|
+
ov.appendChild(el("p", "err", data.schemaProblems.join("\n")));
|
|
561
|
+
}
|
|
557
562
|
data.collections.forEach(function (c) {
|
|
558
563
|
if (state.mode[c.name] === "off" && c.count) {
|
|
559
564
|
ov.appendChild(el("p", "side-meta",
|
|
@@ -779,6 +784,11 @@
|
|
|
779
784
|
}
|
|
780
785
|
var kind = n.exists ? n.collection + (n.immutable ? ", immutable" : "") : "unwritten reference";
|
|
781
786
|
side.appendChild(el("p", "side-kind", kind));
|
|
787
|
+
/* A document in violation of the store's schema reads fine; the notes
|
|
788
|
+
say what an edit would heal. */
|
|
789
|
+
if (n.schemaNotes && n.schemaNotes.length) {
|
|
790
|
+
side.appendChild(el("p", "schema-note", n.schemaNotes.join("\n")));
|
|
791
|
+
}
|
|
782
792
|
var t = el("p", "side-title", n.title);
|
|
783
793
|
if (n.exists) t.style.color = d3.hcl(d3.color(n.color)).brighter(1.1) + "";
|
|
784
794
|
side.appendChild(t);
|
|
@@ -862,6 +872,9 @@
|
|
|
862
872
|
}
|
|
863
873
|
|
|
864
874
|
function showErr(err) {
|
|
875
|
+
/* Replace any standing error: a corrected retry must not stack lines. */
|
|
876
|
+
var old = side.querySelector(".err");
|
|
877
|
+
if (old) old.parentNode.removeChild(old);
|
|
865
878
|
var p = el("p", "err", String((err && err.message) || err));
|
|
866
879
|
side.insertBefore(p, side.firstChild);
|
|
867
880
|
}
|
package/src/ui/pipeline.cjs
CHANGED
|
@@ -96,13 +96,22 @@ var AtlasPipeline = (function () {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function detectPreset(relPaths) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
for (const prefix of ["", ".canon/"]) {
|
|
100
|
+
let hasArticles = false;
|
|
101
|
+
let hasJournal = false;
|
|
102
|
+
for (const p of relPaths) {
|
|
103
|
+
if (p.startsWith(prefix + "articles/")) hasArticles = true;
|
|
104
|
+
if (p.startsWith(prefix + "journal/")) hasJournal = true;
|
|
105
|
+
}
|
|
106
|
+
if (hasArticles && hasJournal) {
|
|
107
|
+
if (!prefix) return PI_CANON_PRESET;
|
|
108
|
+
return Object.assign({}, PI_CANON_PRESET, {
|
|
109
|
+
collections: PI_CANON_PRESET.collections.map((c) =>
|
|
110
|
+
Object.assign({}, c, { match: prefix + c.match })),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
104
113
|
}
|
|
105
|
-
return
|
|
114
|
+
return DEFAULT_CONFIG;
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/* Optional embedding vectors: a parsed JSON object of root-relative markdown
|
|
@@ -224,6 +233,194 @@ var AtlasPipeline = (function () {
|
|
|
224
233
|
return m ? m[1].trim() : "";
|
|
225
234
|
}
|
|
226
235
|
|
|
236
|
+
/* ── article schema ────────────────────────────────────────────────────────
|
|
237
|
+
A store may carry a schema.json beside its collections: pi-canon writes one
|
|
238
|
+
when it creates a store, and this file is the shared contract, so the atlas
|
|
239
|
+
enforces the same rules at its own write doors. The shape mirrors pi-canon
|
|
240
|
+
exactly: an "article" object with capsule / title / body rules, each rule
|
|
241
|
+
taking required, min_chars, max_chars, hint. Enforcement is asymmetric on
|
|
242
|
+
purpose: required rejects a save that touches the field (and everything on
|
|
243
|
+
create), everything else warns, and a document read in violation carries
|
|
244
|
+
heal notes instead of errors. A malformed file fails open and loud: nothing
|
|
245
|
+
is enforced and the problems say so, because a contract the owner believes
|
|
246
|
+
is enforced while a typo disabled it is the worst state. */
|
|
247
|
+
|
|
248
|
+
const SCHEMA_FILE = "schema.json";
|
|
249
|
+
const SCHEMA_FIELDS = ["capsule", "title", "body"];
|
|
250
|
+
const SCHEMA_RULE_KEYS = ["required", "min_chars", "max_chars", "hint"];
|
|
251
|
+
/* Relations rules are about the reference graph, not any one field. The
|
|
252
|
+
contract declares them once; each tool enforces what it can see. This
|
|
253
|
+
page sees the whole graph, so all three hold here: refs at the write
|
|
254
|
+
door and on read, orphan and children as read-side heal notes. */
|
|
255
|
+
const RELATION_KEYS = {
|
|
256
|
+
refs: ["required", "min_count", "hint"],
|
|
257
|
+
orphan: ["warn", "hint"],
|
|
258
|
+
children: ["listed", "hint"],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/* The store lives either at the corpus root or nested under .canon/; the
|
|
262
|
+
schema file sits beside the collections, wherever they are. */
|
|
263
|
+
function schemaPrefix(config) {
|
|
264
|
+
return config.collections.some((c) => (c.match || "").startsWith(".canon/")) ? ".canon/" : "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Raw file text (or null when absent) into { schema, problems }. */
|
|
268
|
+
function parseSchema(text) {
|
|
269
|
+
if (text == null) return { schema: undefined, problems: [] };
|
|
270
|
+
let raw;
|
|
271
|
+
try {
|
|
272
|
+
raw = JSON.parse(text);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return {
|
|
275
|
+
schema: undefined,
|
|
276
|
+
problems: [SCHEMA_FILE + " is not valid JSON (" + e.message + "); its rules are not being enforced."],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
280
|
+
return { schema: undefined, problems: [SCHEMA_FILE + " must hold a JSON object; its rules are not being enforced."] };
|
|
281
|
+
}
|
|
282
|
+
const problems = [];
|
|
283
|
+
/* A missing article block is an empty one, not an early exit: a schema
|
|
284
|
+
may carry only relations rules. */
|
|
285
|
+
const declared = raw.article ?? {};
|
|
286
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
287
|
+
return { schema: undefined, problems: [SCHEMA_FILE + ': "article" must be an object; its rules are not being enforced.'] };
|
|
288
|
+
}
|
|
289
|
+
const schema = {};
|
|
290
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
291
|
+
if (!SCHEMA_FIELDS.includes(name)) {
|
|
292
|
+
problems.push(SCHEMA_FILE + ': unknown article field "' + name + '" is ignored (fields: ' + SCHEMA_FIELDS.join(", ") + ").");
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
296
|
+
problems.push(SCHEMA_FILE + ': rule for "' + name + '" must be an object and is ignored.');
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const rule = {};
|
|
300
|
+
for (const [key, val] of Object.entries(value)) {
|
|
301
|
+
if (!SCHEMA_RULE_KEYS.includes(key)) {
|
|
302
|
+
problems.push(SCHEMA_FILE + ': unknown rule key "' + name + "." + key + '" is ignored (keys: ' + SCHEMA_RULE_KEYS.join(", ") + ").");
|
|
303
|
+
} else if (key === "required" && typeof val === "boolean") rule.required = val;
|
|
304
|
+
else if ((key === "min_chars" || key === "max_chars") && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
305
|
+
else if (key === "hint" && typeof val === "string") rule.hint = val;
|
|
306
|
+
else problems.push(SCHEMA_FILE + ': "' + name + "." + key + '" has the wrong type and is ignored.');
|
|
307
|
+
}
|
|
308
|
+
schema[name] = rule;
|
|
309
|
+
}
|
|
310
|
+
const rel = raw.relations;
|
|
311
|
+
if (rel !== undefined) {
|
|
312
|
+
if (typeof rel !== "object" || rel === null || Array.isArray(rel)) {
|
|
313
|
+
problems.push(SCHEMA_FILE + ': "relations" must be an object and is ignored.');
|
|
314
|
+
} else {
|
|
315
|
+
const relations = {};
|
|
316
|
+
for (const [name, value] of Object.entries(rel)) {
|
|
317
|
+
const keys = RELATION_KEYS[name];
|
|
318
|
+
if (!keys) {
|
|
319
|
+
problems.push(SCHEMA_FILE + ': unknown relations field "' + name + '" is ignored (fields: refs, orphan, children).');
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
323
|
+
problems.push(SCHEMA_FILE + ': rule for "relations.' + name + '" must be an object and is ignored.');
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const rule = {};
|
|
327
|
+
for (const [key, val] of Object.entries(value)) {
|
|
328
|
+
if (!keys.includes(key)) {
|
|
329
|
+
problems.push(SCHEMA_FILE + ': unknown rule key "relations.' + name + "." + key + '" is ignored (keys: ' + keys.join(", ") + ").");
|
|
330
|
+
} else if ((key === "required" || key === "warn" || key === "listed") && typeof val === "boolean") rule[key] = val;
|
|
331
|
+
else if (key === "min_count" && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
332
|
+
else if (key === "hint" && typeof val === "string") rule.hint = val;
|
|
333
|
+
else problems.push(SCHEMA_FILE + ': "relations.' + name + "." + key + '" has the wrong type and is ignored.');
|
|
334
|
+
}
|
|
335
|
+
relations[name] = rule;
|
|
336
|
+
}
|
|
337
|
+
schema.relations = relations;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { schema, problems };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* The outgoing references a document authors, as the write door can see them:
|
|
344
|
+
body links (code stripped by the extractor) plus the collection's refs
|
|
345
|
+
frontmatter, deduplicated case-insensitively. */
|
|
346
|
+
function outgoingOf(raw, refsKey) {
|
|
347
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
348
|
+
const out = new Set();
|
|
349
|
+
for (const l of extractLinks(body)) out.add(l.target.trim().toLowerCase());
|
|
350
|
+
for (const r of asRefs(meta[refsKey])) out.add(String(r).trim().toLowerCase());
|
|
351
|
+
out.delete("");
|
|
352
|
+
return [...out].sort();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* The title the schema means: the body's leading # heading, nothing later. */
|
|
356
|
+
function titleOf(body) {
|
|
357
|
+
for (const line of body.split("\n")) {
|
|
358
|
+
if (!line.trim()) continue;
|
|
359
|
+
const m = /^#\s+(.+)$/.exec(line);
|
|
360
|
+
return m ? m[1].trim() : "";
|
|
361
|
+
}
|
|
362
|
+
return "";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* One save (or one read, when prevRaw === raw so nothing counts as touched)
|
|
366
|
+
against the schema. fields is the collection's frontmatter mapping (a bare
|
|
367
|
+
string is taken as the summary key, for callers predating relations). A
|
|
368
|
+
required violation rejects only when the save touched that field or
|
|
369
|
+
created the document; a legacy violation the save left alone warns
|
|
370
|
+
instead, so a body edit is never held hostage to an old missing capsule,
|
|
371
|
+
and a kept reference set is never re-litigated. */
|
|
372
|
+
function checkDoc(raw, prevRaw, schema, fields) {
|
|
373
|
+
const rejects = [];
|
|
374
|
+
const warns = [];
|
|
375
|
+
if (!schema) return { rejects, warns };
|
|
376
|
+
const f = typeof fields === "string" ? { summary: fields } : fields || {};
|
|
377
|
+
const summaryKey = f.summary || "summary";
|
|
378
|
+
const refsKey = f.refs || "refs";
|
|
379
|
+
const created = prevRaw == null;
|
|
380
|
+
const extract = (text) => {
|
|
381
|
+
const { meta, body } = parseFrontmatter(text);
|
|
382
|
+
return {
|
|
383
|
+
capsule: String(meta[summaryKey] || "").trim(),
|
|
384
|
+
title: titleOf(body),
|
|
385
|
+
body: body.trim(),
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
const now = extract(raw);
|
|
389
|
+
const before = created ? null : extract(prevRaw);
|
|
390
|
+
for (const name of SCHEMA_FIELDS) {
|
|
391
|
+
const rule = schema[name];
|
|
392
|
+
if (!rule) continue;
|
|
393
|
+
const value = now[name];
|
|
394
|
+
const touched = created || value !== before[name];
|
|
395
|
+
const hint = rule.hint ? " (" + rule.hint + ")" : "";
|
|
396
|
+
if (rule.required && !value) {
|
|
397
|
+
if (touched) rejects.push(name + " is required" + hint);
|
|
398
|
+
else warns.push("schema: " + name + " is required and missing; this document can be healed by editing" + hint);
|
|
399
|
+
}
|
|
400
|
+
if (value && rule.min_chars != null && value.length < rule.min_chars) {
|
|
401
|
+
warns.push("schema: " + name + " is under " + rule.min_chars + " characters" + hint);
|
|
402
|
+
}
|
|
403
|
+
if (value && rule.max_chars != null && value.length > rule.max_chars) {
|
|
404
|
+
warns.push("schema: " + name + " is over " + rule.max_chars + " characters (" + value.length + ")" + hint);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/* refs is judged from the save alone: touched means the reference SET
|
|
408
|
+
changed, so a body edit that keeps its citations passes untouched. */
|
|
409
|
+
const refsRule = schema.relations && schema.relations.refs;
|
|
410
|
+
if (refsRule) {
|
|
411
|
+
const cited = outgoingOf(raw, refsKey);
|
|
412
|
+
const touched = created || cited.join("\n") !== outgoingOf(prevRaw, refsKey).join("\n");
|
|
413
|
+
const hint = refsRule.hint ? " (" + refsRule.hint + ")" : "";
|
|
414
|
+
if (refsRule.required && cited.length === 0) {
|
|
415
|
+
if (touched) rejects.push("refs is required and this document references nothing" + hint);
|
|
416
|
+
else warns.push("schema: refs is required and this document references nothing; this document can be healed by editing" + hint);
|
|
417
|
+
} else if (refsRule.min_count != null && cited.length < refsRule.min_count) {
|
|
418
|
+
warns.push("schema: refs has " + cited.length + " outgoing (min " + refsRule.min_count + ")" + hint);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { rejects, warns };
|
|
422
|
+
}
|
|
423
|
+
|
|
227
424
|
function extractLinks(body) {
|
|
228
425
|
const out = [];
|
|
229
426
|
// Fenced code carries example links that were never authored as references.
|
|
@@ -265,7 +462,14 @@ var AtlasPipeline = (function () {
|
|
|
265
462
|
const allTags = asTags(meta[f.tags]);
|
|
266
463
|
const lift = config.preset === "pi-canon";
|
|
267
464
|
const fromPath = lift ? allTags.filter((t) => t.startsWith("path:")) : [];
|
|
465
|
+
/* A read never rejects: a document in violation carries heal notes. */
|
|
466
|
+
let schemaNotes;
|
|
467
|
+
if (config.schema && !col.immutable) {
|
|
468
|
+
const w = checkDoc(text, text, config.schema, f).warns;
|
|
469
|
+
if (w.length) schemaNotes = w;
|
|
470
|
+
}
|
|
268
471
|
return {
|
|
472
|
+
schemaNotes,
|
|
269
473
|
path: rel,
|
|
270
474
|
address,
|
|
271
475
|
collection: col.name,
|
|
@@ -777,6 +981,46 @@ var AtlasPipeline = (function () {
|
|
|
777
981
|
|
|
778
982
|
function buildData(config, graph, mode) {
|
|
779
983
|
const { nodes, edges } = graph;
|
|
984
|
+
/* The graph-wide relations rules, which only a whole-graph reader can
|
|
985
|
+
judge: orphan (nothing references this document) and children (a parent
|
|
986
|
+
must reference each direct child under its address). Read-side heal
|
|
987
|
+
notes, never a wall, and only over mutable documents. */
|
|
988
|
+
const rel = config.schema && config.schema.relations;
|
|
989
|
+
if (rel && ((rel.orphan && rel.orphan.warn) || (rel.children && rel.children.listed))) {
|
|
990
|
+
const inbound = new Set();
|
|
991
|
+
const outTargets = new Map();
|
|
992
|
+
for (const e of edges) {
|
|
993
|
+
inbound.add(e.target);
|
|
994
|
+
if (!outTargets.has(e.source)) outTargets.set(e.source, new Set());
|
|
995
|
+
outTargets.get(e.source).add(e.target);
|
|
996
|
+
}
|
|
997
|
+
const byAddress = new Map();
|
|
998
|
+
nodes.forEach((n, i) => {
|
|
999
|
+
if (n.exists && !n.immutable && n.address) byAddress.set(n.address, i);
|
|
1000
|
+
});
|
|
1001
|
+
nodes.forEach((n, i) => {
|
|
1002
|
+
if (!n.exists || n.immutable) return;
|
|
1003
|
+
const notes = [];
|
|
1004
|
+
if (rel.orphan && rel.orphan.warn && !inbound.has(i)) {
|
|
1005
|
+
notes.push("schema: nothing references this document" + (rel.orphan.hint ? " (" + rel.orphan.hint + ")" : ""));
|
|
1006
|
+
}
|
|
1007
|
+
if (rel.children && rel.children.listed && n.address) {
|
|
1008
|
+
const prefix = n.address + "/";
|
|
1009
|
+
const named = outTargets.get(i) || new Set();
|
|
1010
|
+
const missing = [];
|
|
1011
|
+
for (const [addr, ci] of byAddress) {
|
|
1012
|
+
if (!addr.startsWith(prefix) || addr.slice(prefix.length).includes("/")) continue;
|
|
1013
|
+
if (!named.has(ci)) missing.push(addr);
|
|
1014
|
+
}
|
|
1015
|
+
if (missing.length) {
|
|
1016
|
+
missing.sort();
|
|
1017
|
+
const shown = missing.slice(0, 3).join(", ") + (missing.length > 3 ? " and " + (missing.length - 3) + " more" : "");
|
|
1018
|
+
notes.push("schema: children not referenced: " + shown + (rel.children.hint ? " (" + rel.children.hint + ")" : ""));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (notes.length) n.schemaNotes = (n.schemaNotes || []).concat(notes);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
780
1024
|
const { basis, clusters, assign } = assignClusters(nodes, edges, config.vectors || null);
|
|
781
1025
|
const counts = new Map();
|
|
782
1026
|
for (const n of nodes) {
|
|
@@ -787,6 +1031,7 @@ var AtlasPipeline = (function () {
|
|
|
787
1031
|
preset: config.preset,
|
|
788
1032
|
mode,
|
|
789
1033
|
basis,
|
|
1034
|
+
schemaProblems: config.schemaProblems && config.schemaProblems.length ? config.schemaProblems : undefined,
|
|
790
1035
|
collections: config.collections.map((c) => ({
|
|
791
1036
|
name: c.name,
|
|
792
1037
|
match: c.match,
|
|
@@ -811,6 +1056,7 @@ var AtlasPipeline = (function () {
|
|
|
811
1056
|
exists: n.exists,
|
|
812
1057
|
degree: n.degree,
|
|
813
1058
|
rank: n.rank,
|
|
1059
|
+
schemaNotes: n.schemaNotes,
|
|
814
1060
|
html: n.body ? renderMarkdown(n.body) : "",
|
|
815
1061
|
})),
|
|
816
1062
|
edges,
|
|
@@ -832,6 +1078,13 @@ var AtlasPipeline = (function () {
|
|
|
832
1078
|
const raw = composeConfig(opts.configJson, () => detectPreset(md.map((f) => f.path)));
|
|
833
1079
|
const config = buildConfig(raw);
|
|
834
1080
|
config.vectors = parseVectors(opts.vectorsJson);
|
|
1081
|
+
/* schemaTexts carries both candidate files ({ root, nested }); which one is
|
|
1082
|
+
the store's contract depends on where the collections landed, so the
|
|
1083
|
+
choice waits for the config. */
|
|
1084
|
+
const texts = opts.schemaTexts || {};
|
|
1085
|
+
const s = parseSchema((schemaPrefix(config) ? texts.nested : texts.root) ?? null);
|
|
1086
|
+
config.schema = s.schema;
|
|
1087
|
+
config.schemaProblems = s.problems;
|
|
835
1088
|
const docs = scanFiles(md, config);
|
|
836
1089
|
const graph = buildGraph(docs);
|
|
837
1090
|
return buildData(config, graph, opts.mode || "fs");
|
|
@@ -839,12 +1092,16 @@ var AtlasPipeline = (function () {
|
|
|
839
1092
|
|
|
840
1093
|
return {
|
|
841
1094
|
CONFIG_NAME,
|
|
1095
|
+
SCHEMA_FILE,
|
|
842
1096
|
PI_CANON_PRESET,
|
|
843
1097
|
DEFAULT_CONFIG,
|
|
844
1098
|
buildConfig,
|
|
845
1099
|
composeConfig,
|
|
846
1100
|
detectPreset,
|
|
847
1101
|
parseVectors,
|
|
1102
|
+
parseSchema,
|
|
1103
|
+
schemaPrefix,
|
|
1104
|
+
checkDoc,
|
|
848
1105
|
collectionFor,
|
|
849
1106
|
parseFrontmatter,
|
|
850
1107
|
extractLinks,
|
package/src/ui/shell.js
CHANGED
|
@@ -193,10 +193,29 @@
|
|
|
193
193
|
.catch(function () { return null; });
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/* Raw text at a nested path under the handle, or null when absent. */
|
|
197
|
+
function readTextAt(parts) {
|
|
198
|
+
var dir = Promise.resolve(handle);
|
|
199
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
200
|
+
(function (name) {
|
|
201
|
+
dir = dir.then(function (d) { return d.getDirectoryHandle(name); });
|
|
202
|
+
})(parts[i]);
|
|
203
|
+
}
|
|
204
|
+
return dir
|
|
205
|
+
.then(function (d) { return d.getFileHandle(parts[parts.length - 1]); })
|
|
206
|
+
.then(function (fh) { return fh.getFile(); })
|
|
207
|
+
.then(function (f) { return f.text(); })
|
|
208
|
+
.catch(function () { return null; });
|
|
209
|
+
}
|
|
210
|
+
|
|
196
211
|
/* "fs" when the handle came with write permission, "browse" when it is
|
|
197
212
|
read-only (a dropped folder whose write grant was declined still reads). */
|
|
198
213
|
var handleMode = "fs";
|
|
199
214
|
|
|
215
|
+
/* The store's article contract, parsed after each mount so the write path
|
|
216
|
+
enforces the same rules the wire data's heal notes report. */
|
|
217
|
+
var storeSchema = { schema: undefined, problems: [] };
|
|
218
|
+
|
|
200
219
|
function buildFromFolder(progress) {
|
|
201
220
|
return listDir(handle, "", [])
|
|
202
221
|
.then(function (list) {
|
|
@@ -206,12 +225,20 @@
|
|
|
206
225
|
.then(function (files) {
|
|
207
226
|
return readJson(CONFIG).then(function (configJson) {
|
|
208
227
|
var vecName = (configJson && configJson.embeddings) || "embeddings.json";
|
|
209
|
-
return
|
|
228
|
+
return Promise.all([
|
|
229
|
+
readJson(vecName),
|
|
230
|
+
readTextAt(["schema.json"]),
|
|
231
|
+
readTextAt([".canon", "schema.json"]),
|
|
232
|
+
]).then(function (loaded) {
|
|
233
|
+
var schemaTexts = { root: loaded[1], nested: loaded[2] };
|
|
210
234
|
currentData = AtlasPipeline.buildWireData(files, {
|
|
211
235
|
configJson: configJson,
|
|
212
|
-
vectorsJson:
|
|
236
|
+
vectorsJson: loaded[0],
|
|
237
|
+
schemaTexts: schemaTexts,
|
|
213
238
|
mode: handleMode,
|
|
214
239
|
});
|
|
240
|
+
var nested = currentData.collections.some(function (c) { return (c.match || "").indexOf(".canon/") === 0; });
|
|
241
|
+
storeSchema = AtlasPipeline.parseSchema(nested ? schemaTexts.nested : schemaTexts.root);
|
|
215
242
|
return currentData;
|
|
216
243
|
});
|
|
217
244
|
});
|
|
@@ -252,6 +279,20 @@
|
|
|
252
279
|
});
|
|
253
280
|
}
|
|
254
281
|
|
|
282
|
+
/* The same asymmetric enforcement the server applies: a required violation
|
|
283
|
+
the save touched (or a create) rejects with nothing written, so the editor
|
|
284
|
+
keeps the text and says what to correct; everything else is a warning. */
|
|
285
|
+
function schemaGateFs(content, prevRaw, col) {
|
|
286
|
+
if (col.immutable) return [];
|
|
287
|
+
/* A broken declaration enforces nothing, but every write says so. */
|
|
288
|
+
if (!storeSchema.schema) return storeSchema.problems || [];
|
|
289
|
+
var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, col.fields || {});
|
|
290
|
+
if (r.rejects.length) {
|
|
291
|
+
throw new Error("Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
|
|
292
|
+
}
|
|
293
|
+
return r.warns.concat(storeSchema.problems);
|
|
294
|
+
}
|
|
295
|
+
|
|
255
296
|
var fsBackend = {
|
|
256
297
|
graph: function () { return buildFromFolder(); },
|
|
257
298
|
readDoc: function (path) {
|
|
@@ -264,18 +305,25 @@
|
|
|
264
305
|
writeDoc: function (path, content) {
|
|
265
306
|
var col = confine(path);
|
|
266
307
|
if (col.immutable) return Promise.reject(new Error(col.name + " is immutable"));
|
|
267
|
-
return fileHandleFor(path, false)
|
|
268
|
-
|
|
269
|
-
|
|
308
|
+
return fileHandleFor(path, false)
|
|
309
|
+
.then(function (fh) {
|
|
310
|
+
return fh.getFile().then(function (f) { return f.text(); }).then(function (prev) {
|
|
311
|
+
var warnings = schemaGateFs(content, prev, col);
|
|
312
|
+
return writeThrough(fh, content).then(function () {
|
|
313
|
+
return warnings.length ? { path: path, warnings: warnings } : { path: path };
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
270
317
|
},
|
|
271
318
|
createDoc: function (path, content) {
|
|
272
|
-
confine(path);
|
|
319
|
+
var col = confine(path);
|
|
273
320
|
// Refuse to overwrite: succeed only if the file does not already exist.
|
|
274
321
|
return fileHandleFor(path, false).then(
|
|
275
322
|
function () { throw new Error("document already exists"); },
|
|
276
323
|
function () {
|
|
324
|
+
var warnings = schemaGateFs(content, null, col);
|
|
277
325
|
return fileHandleFor(path, true).then(function (fh) { return writeThrough(fh, content); }).then(function () {
|
|
278
|
-
return { path: path };
|
|
326
|
+
return warnings.length ? { path: path, warnings: warnings } : { path: path };
|
|
279
327
|
});
|
|
280
328
|
}
|
|
281
329
|
);
|
|
@@ -361,9 +409,15 @@
|
|
|
361
409
|
).then(function (docs) {
|
|
362
410
|
var mem = {};
|
|
363
411
|
docs.forEach(function (f) { mem[f.path] = f.text; });
|
|
364
|
-
|
|
412
|
+
return Promise.all([textOf("schema.json"), textOf(".canon/schema.json")]).then(function (st) {
|
|
413
|
+
return { docs: docs, mem: mem, schemaTexts: { root: st[0], nested: st[1] } };
|
|
414
|
+
});
|
|
415
|
+
}).then(function (got) {
|
|
416
|
+
var mem = got.mem;
|
|
417
|
+
var data = AtlasPipeline.buildWireData(got.docs, {
|
|
365
418
|
configJson: configJson,
|
|
366
419
|
vectorsJson: vectorsJson,
|
|
420
|
+
schemaTexts: got.schemaTexts,
|
|
367
421
|
mode: "browse",
|
|
368
422
|
});
|
|
369
423
|
currentData = data;
|
package/src/ui/style.css
CHANGED
|
@@ -555,4 +555,7 @@ select.abtn option { background: var(--bg-elevated); color: var(--text); }
|
|
|
555
555
|
|
|
556
556
|
.abtn.danger:hover { border-color: #8b2942; color: #e08a9c; }
|
|
557
557
|
|
|
558
|
-
.err { margin: 0.5rem 0; font-size: 0.75rem; color: #e08a9c; }
|
|
558
|
+
.err { margin: 0.5rem 0; font-size: 0.75rem; color: #e08a9c; white-space: pre-line; }
|
|
559
|
+
|
|
560
|
+
/* Schema heal notes: information beside a document, never a wall. */
|
|
561
|
+
.schema-note { margin: 0.35rem 0 0.6rem; font-size: 0.72rem; color: #a89a5f; white-space: pre-line; }
|