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 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
- ![The constellation over a pi-canon memory store](docs/constellation.png)
7
+ ![The constellation over a knowledge store of two thousand documents](docs/constellation.png)
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
+ ![Navigating the atlas: gliding into a cluster, opening a document, hopping a connection](docs/atlas.gif)
12
+
13
+ The same footage plays smoother as [a short video](docs/atlas.mp4).
14
+
15
+ ![A pinned document: the neighborhood lit, the sky dimmed](docs/selection.png)
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.1.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/ is pi-canon), then the
26
- bare default of one mutable collection over every markdown file. A config
27
- that names a title but no collections keeps the detected preset. */
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
- isDir(join(root, "articles")) && isDir(join(root, "journal")) ? P.PI_CANON_PRESET : P.DEFAULT_CONFIG;
33
- return P.buildConfig(P.composeConfig(raw, detect));
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
  }
@@ -96,13 +96,22 @@ var AtlasPipeline = (function () {
96
96
  }
97
97
 
98
98
  function detectPreset(relPaths) {
99
- let hasArticles = false;
100
- let hasJournal = false;
101
- for (const p of relPaths) {
102
- if (p.startsWith("articles/")) hasArticles = true;
103
- if (p.startsWith("journal/")) hasJournal = true;
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 hasArticles && hasJournal ? PI_CANON_PRESET : DEFAULT_CONFIG;
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 readJson(vecName).then(function (vectorsJson) {
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: 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).then(function (fh) { return writeThrough(fh, content); }).then(function () {
268
- return { path: path };
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
- var data = AtlasPipeline.buildWireData(docs, {
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; }