canon-atlas 0.2.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 +10 -2
- package/package.json +6 -1
- package/src/serve.mjs +1 -1
- package/src/ui/pipeline.cjs +119 -9
- package/src/ui/shell.js +1 -2
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
|
|
|
@@ -62,7 +70,7 @@ References that resolve nowhere are not errors: they render as dashed, unwritten
|
|
|
62
70
|
|
|
63
71
|
### The article schema
|
|
64
72
|
|
|
65
|
-
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 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.
|
|
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.
|
|
66
74
|
|
|
67
75
|
## Color
|
|
68
76
|
|
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/serve.mjs
CHANGED
|
@@ -161,7 +161,7 @@ export function corpusApi(rootArg) {
|
|
|
161
161
|
if (col.immutable) return [];
|
|
162
162
|
/* A broken declaration enforces nothing, but every write says so. */
|
|
163
163
|
if (!cfg.schema) return cfg.schemaProblems || [];
|
|
164
|
-
const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields
|
|
164
|
+
const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields);
|
|
165
165
|
if (r.rejects.length) {
|
|
166
166
|
throw httpError(422, "Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
|
|
167
167
|
}
|
package/src/ui/pipeline.cjs
CHANGED
|
@@ -248,6 +248,15 @@ var AtlasPipeline = (function () {
|
|
|
248
248
|
const SCHEMA_FILE = "schema.json";
|
|
249
249
|
const SCHEMA_FIELDS = ["capsule", "title", "body"];
|
|
250
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
|
+
};
|
|
251
260
|
|
|
252
261
|
/* The store lives either at the corpus root or nested under .canon/; the
|
|
253
262
|
schema file sits beside the collections, wherever they are. */
|
|
@@ -271,8 +280,9 @@ var AtlasPipeline = (function () {
|
|
|
271
280
|
return { schema: undefined, problems: [SCHEMA_FILE + " must hold a JSON object; its rules are not being enforced."] };
|
|
272
281
|
}
|
|
273
282
|
const problems = [];
|
|
274
|
-
|
|
275
|
-
|
|
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 ?? {};
|
|
276
286
|
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
277
287
|
return { schema: undefined, problems: [SCHEMA_FILE + ': "article" must be an object; its rules are not being enforced.'] };
|
|
278
288
|
}
|
|
@@ -297,9 +307,51 @@ var AtlasPipeline = (function () {
|
|
|
297
307
|
}
|
|
298
308
|
schema[name] = rule;
|
|
299
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
|
+
}
|
|
300
340
|
return { schema, problems };
|
|
301
341
|
}
|
|
302
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
|
+
|
|
303
355
|
/* The title the schema means: the body's leading # heading, nothing later. */
|
|
304
356
|
function titleOf(body) {
|
|
305
357
|
for (const line of body.split("\n")) {
|
|
@@ -311,15 +363,19 @@ var AtlasPipeline = (function () {
|
|
|
311
363
|
}
|
|
312
364
|
|
|
313
365
|
/* One save (or one read, when prevRaw === raw so nothing counts as touched)
|
|
314
|
-
against the schema.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
missing capsule
|
|
319
|
-
|
|
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) {
|
|
320
373
|
const rejects = [];
|
|
321
374
|
const warns = [];
|
|
322
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";
|
|
323
379
|
const created = prevRaw == null;
|
|
324
380
|
const extract = (text) => {
|
|
325
381
|
const { meta, body } = parseFrontmatter(text);
|
|
@@ -348,6 +404,20 @@ var AtlasPipeline = (function () {
|
|
|
348
404
|
warns.push("schema: " + name + " is over " + rule.max_chars + " characters (" + value.length + ")" + hint);
|
|
349
405
|
}
|
|
350
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
|
+
}
|
|
351
421
|
return { rejects, warns };
|
|
352
422
|
}
|
|
353
423
|
|
|
@@ -395,7 +465,7 @@ var AtlasPipeline = (function () {
|
|
|
395
465
|
/* A read never rejects: a document in violation carries heal notes. */
|
|
396
466
|
let schemaNotes;
|
|
397
467
|
if (config.schema && !col.immutable) {
|
|
398
|
-
const w = checkDoc(text, text, config.schema, f
|
|
468
|
+
const w = checkDoc(text, text, config.schema, f).warns;
|
|
399
469
|
if (w.length) schemaNotes = w;
|
|
400
470
|
}
|
|
401
471
|
return {
|
|
@@ -911,6 +981,46 @@ var AtlasPipeline = (function () {
|
|
|
911
981
|
|
|
912
982
|
function buildData(config, graph, mode) {
|
|
913
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
|
+
}
|
|
914
1024
|
const { basis, clusters, assign } = assignClusters(nodes, edges, config.vectors || null);
|
|
915
1025
|
const counts = new Map();
|
|
916
1026
|
for (const n of nodes) {
|
package/src/ui/shell.js
CHANGED
|
@@ -286,8 +286,7 @@
|
|
|
286
286
|
if (col.immutable) return [];
|
|
287
287
|
/* A broken declaration enforces nothing, but every write says so. */
|
|
288
288
|
if (!storeSchema.schema) return storeSchema.problems || [];
|
|
289
|
-
var
|
|
290
|
-
var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, summaryKey);
|
|
289
|
+
var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, col.fields || {});
|
|
291
290
|
if (r.rejects.length) {
|
|
292
291
|
throw new Error("Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
|
|
293
292
|
}
|