yamlover 0.3.2 → 0.3.4

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.
@@ -37,12 +37,13 @@ import type { IncomingMessage, ServerResponse } from "node:http";
37
37
  import { Store, reindex, reindexAsync, hashFileAsync, watchTree, loadSettings, mv, relinkMoved, evalQuery } from "../../../engine/ts/src/index.ts";
38
38
  import type { NodeRow, EdgeRow, Settings, IndexDiff } from "../../../engine/ts/src/index.ts";
39
39
  import { parseYamlover } from "../../../parser/ts/src/yamlover.ts";
40
- import { pointerToken, anchorToken } from "../../../parser/ts/src/serialize-yamlover.ts";
40
+ import { pointerToken } from "../../../parser/ts/src/serialize-yamlover.ts";
41
+ import { appendAnnotation, upsertFragment, removeAnnotation as removeAnnotationItem, keyToken } from "./embed.js";
41
42
  import { colonSegment } from "../../../parser/ts/src/pointer.ts";
42
43
  import { isPointer } from "../../../parser/ts/src/ir.ts";
43
44
  import type { Node as IrNode } from "../../../parser/ts/src/ir.ts";
44
45
  import { buildGitIgnore } from "./gitignore.js";
45
- import { displayKind, ownedEntries, typeName } from "./node-kind.js";
46
+ import { displayKind, ownedEntries, typeName, facetsOf } from "./node-kind.js";
46
47
  import { TaskRegistry } from "./tasks.js";
47
48
  import type { TaskHandle } from "./tasks.js";
48
49
 
@@ -311,31 +312,48 @@ export function createHandlers(dataRoot: string, opts: Options = {}): Handler &
311
312
  return;
312
313
  }
313
314
 
314
- // Create an annotation — ONE TAG APPLICATION (a WRITE path): persist it as a yamlover file
315
- // under the project's default annotation location (settings.yamlover; `/annotations` by
316
- // default), then index it so it joins the graph (reverse-linked to its material, member of
317
- // its tag). An annotation is NOT tied to that location it may be moved to (or authored
318
- // in) any directory and keeps working; the setting only says where NEW ones land. Body:
319
- // { target, tag, selector?, description? } target/tag are the material's and the applied
320
- // tag's JSON paths; no selector applies the tag to the WHOLE node.
315
+ // Create an annotation — TAG a target (a WRITE path; ANNOTATIONS.md). The tag application is
316
+ // appended to the target's own `yamlover-annotations` array, embedded in the target's host
317
+ // body (a `*.yamlover` document, or a directory's `.yamlover/body.yamlover` overlay keyed by
318
+ // filename). The target may be a whole node OR a fragment (`…:yamlover-fragments:<slug>`).
319
+ // Body: { target, tag, description?, params? } target/tag are JSON paths; description/params
320
+ // make it a PARAMETRIZED annotation (an object element), else it is a bare tag pointer.
321
321
  if (req.method === "POST" && url.pathname === "/api/annotate") {
322
322
  readBody(req)
323
323
  .then((data) =>
324
- // Queued: an incremental row added while a full walk (whose disk snapshot predates
325
- // this annotation) is committing would be silently swapped away.
326
- enqueue(() => {
327
- const a = data as AnnotationInput;
324
+ enqueue(async () => {
325
+ const a = data as AnnotateInput;
328
326
  const tagStore = storePath(strToSegs(a.tag ?? ""));
329
327
  if (!a?.tag || s.node(tagStore)?.format !== TAG_FORMAT) {
330
328
  throw new Error("annotation needs a `tag` that is an x-yamlover-tag node");
331
329
  }
332
- const annPath = writeAnnotation(dataRoot, settings.annotations.location, a);
333
- // Update the index INCREMENTALLY (not a full rebuild that re-reads every changed
334
- // file and blocks the next click). Add just this annotation's nodes + its edges.
335
- const doc = parseYamlover(fs.readFileSync(path.join(dataRoot, ...strToSegs(annPath).map(String)), "utf8"), annPath);
336
- s.addAnnotation(storePath(strToSegs(annPath)), storePath(strToSegs(a.target)), doc, tagStore);
337
- announce({ added: [relFileOf(annPath)] });
338
- return { path: annPath };
330
+ embedAnnotation(dataRoot, s, a);
331
+ // A surgical body edit changes a file's hash; the manifest-cached reconcile re-reads
332
+ // only the edited body (the /api/paste pattern), so the graph trues up in one pass.
333
+ broadcast(await doReindex());
334
+ scheduleHasher();
335
+ return { ok: true };
336
+ }),
337
+ )
338
+ .then((body) => sendJson(res, 201, body))
339
+ .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
340
+ return;
341
+ }
342
+
343
+ // Create a FRAGMENT — a user-marked region inside a target (a WRITE path; ANNOTATIONS.md).
344
+ // Stored under the target's `yamlover-fragments` mapping keyed by a fresh slug; for an
345
+ // image-like selection the optional `imageBase64` crop is written as a sidecar blob the
346
+ // fragment references. Body: { target, selector, imageBase64? } → { slug, fragmentPath }.
347
+ if (req.method === "POST" && url.pathname === "/api/fragment") {
348
+ readBody(req)
349
+ .then((data) =>
350
+ enqueue(async () => {
351
+ const f = data as FragmentInput;
352
+ if (!f?.selector || typeof f.selector !== "object") throw new Error("a fragment needs a selector");
353
+ const made = embedFragment(dataRoot, s, f);
354
+ broadcast(await doReindex());
355
+ scheduleHasher();
356
+ return made;
339
357
  }),
340
358
  )
341
359
  .then((body) => sendJson(res, 201, body))
@@ -343,15 +361,15 @@ export function createHandlers(dataRoot: string, opts: Options = {}): Handler &
343
361
  return;
344
362
  }
345
363
 
346
- // Delete an annotation by its node path (recolor = delete + create, client-side). Removes
347
- // the annotation FILE and its index rows incrementally. Works wherever the annotation
348
- // lives: the guard is its schema (`x-yamlover-annotation`), not a directory.
364
+ // Delete an annotation (recolor = delete + create, client-side): remove the matching element
365
+ // from the target's `yamlover-annotations`. Body/query: { target, tag } (JSON paths).
349
366
  if (req.method === "DELETE" && url.pathname === "/api/annotate") {
350
- const annPath = url.searchParams.get("path") || "";
351
- enqueue(() => {
352
- deleteAnnotation(dataRoot, s, annPath);
353
- s.removeAnnotation(storePath(strToSegs(annPath)));
354
- announce({ removed: [relFileOf(annPath)] });
367
+ const target = url.searchParams.get("target") ?? "";
368
+ const tag = url.searchParams.get("tag") || "";
369
+ enqueue(async () => {
370
+ if (!tag) throw new Error("delete needs a `tag`");
371
+ unembedAnnotation(dataRoot, s, target, tag);
372
+ broadcast(await doReindex());
355
373
  })
356
374
  .then(() => sendJson(res, 200, { ok: true }))
357
375
  .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
@@ -502,6 +520,7 @@ export function createHandlers(dataRoot: string, opts: Options = {}): Handler &
502
520
  path: segsToStr(segs),
503
521
  type: tocType(s, p, row),
504
522
  format: row.format ?? null,
523
+ ...facetsOf(s, p, row), // valueType / hasKeyed / hasOrdinal — the renderer dispatch facets (TYPES.md §9)
505
524
  concrete: concreteOf(dataRoot, segs, row), // dir | yamlover | null (stat-derived; engine tracks no per-node concrete yet)
506
525
  documentPath: documentPath(s, segs), // nearest enclosing document root (for `/…` links)
507
526
  title: titleOf(s, p),
@@ -675,7 +694,7 @@ function linkMarker(dataRoot: string, s: Store, segs: Seg[]): Record<string, unk
675
694
  const p = storePath(segs);
676
695
  const row = s.node(p)!;
677
696
  const k = displayKind(s, p, row);
678
- const info: Record<string, unknown> = { kind: k, type: tocType(s, p, row), path: segsToStr(segs) };
697
+ const info: Record<string, unknown> = { kind: k, type: tocType(s, p, row), ...facetsOf(s, p, row), path: segsToStr(segs) };
679
698
  if (row.format) info.format = row.format;
680
699
  const concrete = concreteOf(dataRoot, segs, row);
681
700
  if (concrete) info.concrete = concrete; // a folder child renders with a folder icon
@@ -752,6 +771,7 @@ function binaryContent(dataRoot: string, segs: Seg[], row: NodeRow): Record<stri
752
771
 
753
772
  interface TreeNode {
754
773
  path: string; label: string; type: string; format: string | null;
774
+ valueType?: string | null; hasKeyed?: boolean; hasOrdinal?: boolean; // renderer dispatch facets (TYPES.md §9)
755
775
  concrete: string | null; hasChildren: boolean; children: TreeNode[];
756
776
  }
757
777
 
@@ -764,6 +784,7 @@ function buildTree(dataRoot: string, s: Store, segs: Seg[], label: string, depth
764
784
  label,
765
785
  type: tocType(s, p, row),
766
786
  format: row.format ?? null,
787
+ ...facetsOf(s, p, row),
767
788
  concrete: concreteOf(dataRoot, segs, row),
768
789
  hasChildren: s.hasChildren(p),
769
790
  children: [],
@@ -785,75 +806,123 @@ function labelFor(s: Store, p: string, keyOrIdx: Seg): string {
785
806
  }
786
807
 
787
808
  // --------------------------------------------------------------------------- //
788
- // Annotationsgraph-native TAG APPLICATIONS: each is a yamlover object under
789
- // `<root>/annotations/`, pointing (`target: *//…`) at its material and holding a keyless `~-`
790
- // membership in its applied tag; a material's annotations are the inverse of the target edges.
809
+ // Tags, fragments & annotations EMBEDDED in the target (ANNOTATIONS.md). A user-marked region
810
+ // is a FRAGMENT under the target's `yamlover-fragments` mapping (keyed by slug; selector + an
811
+ // optional binary crop). TAGGING a target — a whole node or a fragment appends to its
812
+ // `yamlover-annotations` array: a bare tag pointer (`- *::tag`) or a `{tag, …params}` object. The
813
+ // applied tag drives the color. A material's annotations / a tag's materials are derived from
814
+ // these forward `*` edges. Writes edit the target's host body (a `*.yamlover` doc or a directory
815
+ // `.yamlover/body.yamlover` overlay) surgically — see ./embed.ts.
791
816
  // --------------------------------------------------------------------------- //
792
817
 
793
818
  const TAG_FORMAT = "x-yamlover-tag";
819
+ const ANN_KEY = "yamlover-annotations";
820
+ const FRAG_KEY = "yamlover-fragments";
821
+ const CROP_DIR = "fragments"; // crop sidecar blobs live here (a normal, indexable dir) at the served root
822
+
823
+ interface AnnotateInput {
824
+ target: string; // the target's JSON path — a node, or a fragment (`…:yamlover-fragments:<slug>`)
825
+ tag: string; // the applied tag's JSON path
826
+ description?: string; // a parametrized annotation's comment
827
+ params?: Record<string, unknown>; // any other parameters (parametrized form)
828
+ }
794
829
 
795
- interface AnnotationInput {
796
- target: string; // the material's JSON path (e.g. "/60-simple-chapter.yamlover")
797
- tag: string; // the applied tag's JSON path (e.g. "/yamlover/tags/colors/yellow")
798
- selector?: Record<string, unknown>; // { type: "text", exact, prefix, suffix } | { type:"rect", … }; absent = whole node
799
- description?: string; // the per-application comment
800
- }
801
-
802
- /** The tag an annotation applies its keyless `back` edge to an `x-yamlover-tag` node
803
- * projected as { path, name, color } (color = the tag's explicit `color`, else null: the
804
- * client derives a hue from the name). Null for a legacy annotation with no tag. */
805
- function appliedTag(s: Store, annStorePath: string): { path: string; name: string; color: string | null } | null {
806
- const e = s.relationships(annStorePath).out.find(
807
- (t) => t.kind === "back" && t.label === null && s.node(t.to)?.format === TAG_FORMAT,
808
- );
809
- if (!e) return null;
810
- const segs = storePathToSegs(e.to);
811
- const color = s.node(e.to + ":color")?.value;
830
+ interface FragmentInput {
831
+ target: string; // the node the region lives in (its JSON path)
832
+ selector: Record<string, unknown>; // { type:"text", exact, } | { type:"pdf", page, x, y, w, h } | …
833
+ imageBase64?: string; // an optional PNG crop (image-like selections)
834
+ }
835
+
836
+ /** A child store-path: `parent` + `:key` (root `:` has no leading owner). */
837
+ const childPath = (parent: string, key: string): string => (parent === ":" ? "" : parent) + ":" + key;
838
+
839
+ /** A tag store-path projected as { path, name, color } color = its explicit `color`, else null
840
+ * (the client derives a hue from the name). Null when `tagStore` is not an x-yamlover-tag node. */
841
+ function projectTag(s: Store, tagStore: string): { path: string; name: string; color: string | null } | null {
842
+ if (s.node(tagStore)?.format !== TAG_FORMAT) return null;
843
+ const segs = storePathToSegs(tagStore);
844
+ const color = s.node(tagStore + ":color")?.value;
812
845
  return { path: segsToStr(segs), name: String(segs[segs.length - 1] ?? ""), color: typeof color === "string" ? color : null };
813
846
  }
814
847
 
815
- /** The annotations whose `target` resolves to this material the incoming `ref` edges from
816
- * `x-yamlover-annotation` nodes each projected to its full object (selector, description,
817
- * created) plus its applied `tag` { path, name, color }. */
848
+ /** The tag applications in a host node's `yamlover-annotations` array: a bare tag pointer (a `ref`
849
+ * entry straight to the tag) or a `{tag, …params}` object (a `contain` entry whose `tag` field
850
+ * refs the tag and whose scalar children are parameters). */
851
+ function readAnnotations(s: Store, hostStore: string): { tag: ReturnType<typeof projectTag>; description?: string; params?: Record<string, unknown> }[] {
852
+ const arr = childPath(hostStore, ANN_KEY);
853
+ if (!s.node(arr)) return [];
854
+ const out: { tag: ReturnType<typeof projectTag>; description?: string; params?: Record<string, unknown> }[] = [];
855
+ for (const e of s.entries(arr)) {
856
+ if (e.kind === "ref") {
857
+ const tag = projectTag(s, e.to);
858
+ if (tag) out.push({ tag });
859
+ } else if (e.kind === "contain") {
860
+ const tagEdge = s.relationships(e.to).out.find((o) => o.kind === "ref" && o.label === "tag");
861
+ const tag = tagEdge ? projectTag(s, tagEdge.to) : null;
862
+ if (!tag) continue;
863
+ const params: Record<string, unknown> = {};
864
+ let description: string | undefined;
865
+ for (const c of s.children(e.to)) {
866
+ const v = s.node(c.to)?.value;
867
+ if (c.label === "description") description = v == null ? undefined : String(v);
868
+ else if (c.label) params[c.label] = v;
869
+ }
870
+ out.push({ tag, description, params: Object.keys(params).length ? params : undefined });
871
+ }
872
+ }
873
+ return out;
874
+ }
875
+
876
+ /** A host node's fragments: each slug's selector fields (geometry / text quote) + its crop URL,
877
+ * read from the `yamlover-fragments` mapping. `image` is a `*` pointer (a ref edge) to the crop. */
878
+ function readFragments(s: Store, hostStore: string): { slug: string; node: string; selector: Record<string, unknown>; imageUrl?: string }[] {
879
+ const frags = childPath(hostStore, FRAG_KEY);
880
+ if (!s.node(frags)) return [];
881
+ const out: { slug: string; node: string; selector: Record<string, unknown>; imageUrl?: string }[] = [];
882
+ for (const fc of s.children(frags)) {
883
+ if (!fc.label) continue;
884
+ const selector: Record<string, unknown> = {};
885
+ for (const c of s.children(fc.to)) {
886
+ if (c.label && c.label !== ANN_KEY && c.label !== "created") selector[c.label] = s.node(c.to)?.value;
887
+ }
888
+ const imgEdge = s.relationships(fc.to).out.find((o) => o.kind === "ref" && o.label === "image");
889
+ const imageUrl = imgEdge ? `/api/blob?path=${encodeURIComponent(segsToStr(storePathToSegs(imgEdge.to)))}` : undefined;
890
+ out.push({ slug: fc.label, node: fc.to, selector, imageUrl });
891
+ }
892
+ return out;
893
+ }
894
+
895
+ /** The annotations ON this material: its own whole-node tags, plus each fragment's tags carrying
896
+ * that fragment's selector + crop (so the client highlights the region and colors by tag). */
818
897
  function annotationsFor(dataRoot: string, s: Store, segs: Seg[]): unknown[] {
898
+ void dataRoot;
819
899
  const p = storePath(segs);
820
900
  const out: unknown[] = [];
821
- for (const e of s.relationships(p).in) {
822
- if (e.kind !== "ref") continue;
823
- const src = s.node(e.from);
824
- if (src?.format !== "x-yamlover-annotation") continue;
825
- const aSegs = storePathToSegs(e.from);
826
- out.push({
827
- path: segsToStr(aSegs),
828
- tag: appliedTag(s, e.from),
829
- ...(projectValue(dataRoot, s, aSegs, 6, true) as Record<string, unknown>),
830
- });
901
+ for (const a of readAnnotations(s, p)) out.push({ ...a });
902
+ for (const f of readFragments(s, p)) {
903
+ for (const a of readAnnotations(s, f.node)) {
904
+ out.push({ ...a, selector: f.selector, fragmentSlug: f.slug, ...(f.imageUrl ? { imageUrl: f.imageUrl } : {}) });
905
+ }
831
906
  }
832
907
  return out;
833
908
  }
834
909
 
835
- /** The MATERIALS filed under a tag — every node holding a `~` membership in it, with an
836
- * annotation (one tag APPLICATION) resolved to its `target` material. Deduped by material:
837
- * two annotations applying the same tag to one node, or a direct `~- *tag` alongside an
838
- * annotation, show the material once. Subtags are containment children, not memberships —
839
- * they never appear here. Ordered lexicographically by the member's path, like
840
- * {@link downstreamEntries}' back-edge tail. */
910
+ /** The MATERIALS filed under a tag — the reverse of the forward `*::tag` pointers authored in
911
+ * `yamlover-annotations` arrays (a bare element's edge from the array, an object element's `tag`
912
+ * field, or a legacy direct `~`/`&` membership). Each is climbed to its owning material or
913
+ * fragment and deduped, ordered lexicographically by path. */
841
914
  function taggedMaterials(dataRoot: string, s: Store, tagStorePath: string): unknown[] {
842
915
  const seen = new Set<string>();
843
916
  const out: unknown[] = [];
844
- const backs = s.relationships(tagStorePath).in
845
- .filter((e) => e.kind === "back" && e.from)
917
+ const ins = s.relationships(tagStorePath).in
918
+ .filter((e) => (e.kind === "ref" || e.kind === "back") && e.from)
846
919
  .sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
847
- for (const e of backs) {
848
- let material = e.from;
849
- if (s.node(e.from)?.format === "x-yamlover-annotation") {
850
- const t = s.relationships(e.from).out.find((o) => o.kind === "ref" && o.label === "target");
851
- if (!t) continue; // a dangling annotation — no resolvable material
852
- material = t.to;
853
- }
854
- if (seen.has(material) || !s.node(material)) continue;
855
- seen.add(material);
856
- out.push(linkMarker(dataRoot, s, storePathToSegs(material)));
920
+ for (const e of ins) {
921
+ const arrOwner = e.from.replace(/\[\d+\]$/, "").match(/^(.*):yamlover-annotations$/);
922
+ const owner = arrOwner ? arrOwner[1] || ":" : e.from; // an annotation array → its host; else a direct member
923
+ if (owner === tagStorePath || seen.has(owner) || !s.node(owner)) continue;
924
+ seen.add(owner);
925
+ out.push(linkMarker(dataRoot, s, storePathToSegs(owner)));
857
926
  }
858
927
  return out;
859
928
  }
@@ -874,30 +943,97 @@ function yScalar(v: unknown): string {
874
943
  return typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(String(v ?? ""));
875
944
  }
876
945
 
877
- /** Persist a new annotation (one tag application) as a yamlover file under the project's default
878
- * annotation location (`settings.yamlover`; `/annotations` unless configured); returns its node
879
- * path. The material and the applied tag are referenced with project-scoped deref pointers so
880
- * the engine reverse-links them on re-index (a leading star + a "//path" project path; the tag
881
- * as an ordinal path anchor `&//path/to/tag[]` the deprecated `~-` spelling still parses).
882
- * The location is only the CREATION default an annotation file works from any directory. */
883
- function writeAnnotation(dataRoot: string, location: string, a: AnnotationInput): string {
884
- if (!a?.target || !a?.tag) throw new Error("annotation needs a target and a tag");
885
- const dir = path.resolve(dataRoot, ...strToSegs(location).map(String));
886
- const root = path.resolve(dataRoot);
887
- if (dir !== root && !dir.startsWith(root + path.sep)) throw new Error("annotation location escapes the data root");
888
- fs.mkdirSync(dir, { recursive: true });
889
- const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
890
- const file = `${id}.yamlover`;
891
- const lines = [
892
- "!!<*yamlover/$defs/annotation>",
893
- `target: ${pointerToken(pointerRaw(a.target))}`,
894
- anchorToken(`${pointerRaw(a.tag)}[]`), // the applied tag holds me (ordinal path anchor)
895
- ];
896
- if (a.selector) lines.push("selector:", ...Object.entries(a.selector).map(([k, v]) => ` ${k}: ${yScalar(v)}`));
897
- if (a.description) lines.push(`description: ${yScalar(a.description)}`);
898
- lines.push(`created: ${new Date().toISOString()}`, "");
899
- fs.writeFileSync(path.join(dir, file), lines.join("\n"));
900
- return `${location}:${file}`;
946
+ /** The yamlover host body holding the node at `segs`, and the mapping-key path WITHIN it to that
947
+ * node (ANNOTATIONS.md §3). A standalone `*.yamlover` document the file itself (within = the
948
+ * path inside it); a directory its `.yamlover/body.yamlover` overlay; an on-disk blob (a PDF) →
949
+ * the ENCLOSING directory's overlay, keyed by the filename. */
950
+ function hostFor(dataRoot: string, s: Store, segs: Seg[]): { bodyFile: string; within: string[] } {
951
+ for (let i = segs.length; i >= 0; i--) {
952
+ const sub = segs.slice(0, i);
953
+ const abs = path.resolve(dataRoot, ...sub.map(String));
954
+ let st: fs.Stats | undefined;
955
+ try { st = fs.statSync(abs); } catch { continue; }
956
+ if (st.isDirectory()) return { bodyFile: path.join(abs, ".yamlover", "body.yamlover"), within: segs.slice(i).map(String) };
957
+ if (st.isFile()) {
958
+ const node = s.node(storePath(sub));
959
+ // Edit a MAPPING document in place (a new top-level key is valid). A leaf file scalar,
960
+ // blob, or array — would become an UNTAGGED omni/mix if a key were appended to its source
961
+ // (a parse error under the current parser), so route it through the enclosing directory's
962
+ // overlay keyed by the filename: the engine merges the fields onto the file at IR level
963
+ // (augmentEntry — omni-blob), never reparsing a mixed source. ANNOTATIONS.md §3.
964
+ if (node?.meta?.documentRoot && node.type === "mapping" && !node.is_array) {
965
+ return { bodyFile: abs, within: segs.slice(i).map(String) };
966
+ }
967
+ const dir = path.resolve(dataRoot, ...sub.slice(0, -1).map(String));
968
+ return { bodyFile: path.join(dir, ".yamlover", "body.yamlover"), within: segs.slice(i - 1).map(String) };
969
+ }
970
+ }
971
+ return { bodyFile: path.join(dataRoot, ".yamlover", "body.yamlover"), within: segs.map(String) };
972
+ }
973
+
974
+ /** One `yamlover-annotations` element's source lines at the list `indent`: a bare tag pointer when
975
+ * there are no parameters, else a `{tag, …}` object (block form). */
976
+ function annotationItemLines(a: AnnotateInput, indent: number): string[] {
977
+ const pad = " ".repeat(indent);
978
+ const ptr = pointerToken(pointerRaw(a.tag));
979
+ const params: Record<string, unknown> = { ...(a.params ?? {}) };
980
+ if (a.description != null && a.description !== "") params.description = a.description;
981
+ const keys = Object.keys(params);
982
+ if (keys.length === 0) return [`${pad}- ${ptr}`];
983
+ return [`${pad}- tag: ${ptr}`, ...keys.map((k) => `${pad} ${keyToken(k)}: ${yScalar(params[k])}`)];
984
+ }
985
+
986
+ /** A fragment's source lines at the fragments-map `indent` (`<slug>:` + selector + crop + created),
987
+ * tagged so it indexes as an x-yamlover-fragment node. */
988
+ function fragmentBlockLines(slug: string, selector: Record<string, unknown>, imagePtr: string | null, indent: number): string[] {
989
+ const pad = " ".repeat(indent);
990
+ const lines = [`${pad}${keyToken(slug)}: !!<*::yamlover:$defs:fragment>`];
991
+ for (const [k, v] of Object.entries(selector)) lines.push(`${pad} ${keyToken(k)}: ${yScalar(v)}`);
992
+ if (imagePtr) lines.push(`${pad} image: ${imagePtr}`);
993
+ lines.push(`${pad} created: ${new Date().toISOString()}`);
994
+ return lines;
995
+ }
996
+
997
+ /** Embed a tag application into the target's `yamlover-annotations` array (editing the target's
998
+ * host body in place — ANNOTATIONS.md). */
999
+ function embedAnnotation(dataRoot: string, s: Store, a: AnnotateInput): void {
1000
+ const { bodyFile, within } = hostFor(dataRoot, s, strToSegs(a.target || ":"));
1001
+ fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
1002
+ const src = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
1003
+ fs.writeFileSync(bodyFile, appendAnnotation(src, within, (indent) => annotationItemLines(a, indent)));
1004
+ }
1005
+
1006
+ /** Embed a fragment under the target's `yamlover-fragments` mapping; for an image-like selection,
1007
+ * write the PNG crop as a sidecar blob the fragment references. Returns its slug + node path. */
1008
+ function embedFragment(dataRoot: string, s: Store, f: FragmentInput): { slug: string; fragmentPath: string } {
1009
+ const segs = strToSegs(f.target || ":");
1010
+ const { bodyFile, within } = hostFor(dataRoot, s, segs);
1011
+ const slug = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1012
+ let imagePtr: string | null = null;
1013
+ if (f.imageBase64) {
1014
+ const bytes = Buffer.from(String(f.imageBase64).replace(/^data:[^,]*,/, ""), "base64");
1015
+ if (bytes.length > 0) {
1016
+ const cropDir = path.join(dataRoot, CROP_DIR);
1017
+ fs.mkdirSync(cropDir, { recursive: true });
1018
+ const cropName = `${slug}.png`;
1019
+ writeInside(dataRoot, cropDir, cropName, bytes);
1020
+ imagePtr = pointerToken(pointerRaw(segsToStr([CROP_DIR, cropName])));
1021
+ }
1022
+ }
1023
+ fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
1024
+ const src = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
1025
+ fs.writeFileSync(bodyFile, upsertFragment(src, within, slug, (indent) => fragmentBlockLines(slug, f.selector, imagePtr, indent)));
1026
+ return { slug, fragmentPath: segsToStr([...segs, FRAG_KEY, slug]) };
1027
+ }
1028
+
1029
+ /** Remove a tag application from the target's `yamlover-annotations` array — the first element
1030
+ * referencing `tag` (bare pointer or object `tag:` field). */
1031
+ function unembedAnnotation(dataRoot: string, s: Store, target: string, tag: string): void {
1032
+ const { bodyFile, within } = hostFor(dataRoot, s, strToSegs(target || ":"));
1033
+ if (!fs.existsSync(bodyFile)) return;
1034
+ const needle = pointerRaw(tag); // ::path:to:tag — present in both the bare and object forms
1035
+ const src = fs.readFileSync(bodyFile, "utf8");
1036
+ fs.writeFileSync(bodyFile, removeAnnotationItem(src, within, (itemText) => itemText.includes(needle)));
901
1037
  }
902
1038
 
903
1039
  /** Persist a NEW named tag as a key of the tag-taxonomy body at the project's default tags
@@ -918,7 +1054,7 @@ function writeTag(
918
1054
  const createdFile = !fs.existsSync(file);
919
1055
  const head = "# Named tags created from the annotation picker (settings.yamlover: tags.location).\n";
920
1056
  const existing = createdFile ? head : fs.readFileSync(file, "utf8");
921
- const body = (existing === "" || existing.endsWith("\n") ? existing : existing + "\n") + `${name}: !!<*yamlover/$defs/tag>\n`;
1057
+ const body = (existing === "" || existing.endsWith("\n") ? existing : existing + "\n") + `${name}: !!<*::yamlover:$defs:tag>\n`;
922
1058
  const entries = parseYamlover(body, file).root.entries ?? [];
923
1059
  const pos = entries.findIndex((e) => e.key === name);
924
1060
  const entry = pos >= 0 ? entries[pos] : undefined;
@@ -930,21 +1066,6 @@ function writeTag(
930
1066
  return { node: entry.value, pos, file: [...strToSegs(location).map(String), ".yamlover", "body.yamlover"].join("/"), createdFile };
931
1067
  }
932
1068
 
933
- /** Delete an annotation file given its node path. The guard is the GRAPH, not a directory: the
934
- * node must be indexed as an `x-yamlover-annotation` and be a whole standalone `.yamlover` file
935
- * (an annotation authored inline in a shared document cannot be deleted this way), inside the
936
- * served root. So an annotation moved to any directory remains deletable. */
937
- function deleteAnnotation(dataRoot: string, s: Store, annPath: string): void {
938
- const segs = strToSegs(annPath);
939
- if (!String(segs[segs.length - 1] ?? "").endsWith(".yamlover")) throw new Error("not an annotation file");
940
- if (s.node(storePath(segs))?.format !== "x-yamlover-annotation") throw new Error("not an annotation node");
941
- const root = path.resolve(dataRoot);
942
- const file = path.resolve(dataRoot, ...segs.map(String));
943
- if (!file.startsWith(root + path.sep)) throw new Error("outside the served root");
944
- if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) throw new Error("not an annotation file");
945
- fs.rmSync(file, { force: true });
946
- }
947
-
948
1069
  // --------------------------------------------------------------------------- //
949
1070
  // Paste / upload — drop a clipboard file OR plain text into the tree. A file: a directory target
950
1071
  // takes it as a new child; a chapter target takes it into its owning directory and gains a `*…`
@@ -1061,7 +1182,7 @@ function pasteTextAsChapterFile(dataRoot: string, segs: Seg[], text: string): Re
1061
1182
  const dir = path.resolve(dataRoot, ...dirSegs.map(String));
1062
1183
  const title = titleFromText(text);
1063
1184
  const final = uniqueName(dir, chapterFileName(title));
1064
- const src = ["!!<*yamlover/$defs/chapter>", `title: ${JSON.stringify(title)}`, "chunks:", ...textChunkLines(text, 0), ""].join("\n");
1185
+ const src = ["!!<*::yamlover:$defs:chapter>", `title: ${JSON.stringify(title)}`, "chunks:", ...textChunkLines(text, 0), ""].join("\n");
1065
1186
  writeInside(dataRoot, dir, final, Buffer.from(src, "utf8"));
1066
1187
  return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
1067
1188
  }
@@ -1181,7 +1302,7 @@ function pasteRichAsChapter(dataRoot: string, segs: Seg[], rich: Rich): Record<s
1181
1302
 
1182
1303
  /** The whole .yamlover source of a new rich chapter (the tag, the title, chunks, children). */
1183
1304
  function renderChapterSource(title: string, rich: Rich, pointerFor: (name: string, bytes: Buffer) => string): string {
1184
- const lines = ["!!<*yamlover/$defs/chapter>", `title: ${JSON.stringify(title)}`];
1305
+ const lines = ["!!<*::yamlover:$defs:chapter>", `title: ${JSON.stringify(title)}`];
1185
1306
  if (rich.chunks.length) lines.push("chunks:", ...rich.chunks.flatMap((c) => richItemLines(c, 0, pointerFor)));
1186
1307
  if (rich.children.length) lines.push("children:", ...rich.children.flatMap((k) => richChildLines(k, 0, pointerFor)));
1187
1308
  return lines.join("\n") + "\n";
@@ -5,7 +5,7 @@ import type { NodeRow, Store } from "../../../engine/ts/src/index.ts";
5
5
 
6
6
  // One ordered container, classified for display: a pure-keyed mapping is `object`, a pure-keyless
7
7
  // one `array`; a mapping mixing keyed + keyless OWNED entries is `mix`; a scalar/blob that ALSO
8
- // carries OWNED fields is `omni` (the `!!mix`/`!!omni` shapes); plain scalars/blobs are
8
+ // carries OWNED fields is `omni` (the `!!mix`/`!!var` shapes); plain scalars/blobs are
9
9
  // `scalar`/`binary`.
10
10
  export type Kind = "object" | "array" | "scalar" | "binary" | "omni" | "mix";
11
11
 
@@ -30,7 +30,7 @@ export function displayKind(s: Store, p: string, row: NodeRow): Kind {
30
30
  }
31
31
 
32
32
  // Internal kind → the JSON-Schema-style `type:` name shown in the header/TOC and the schema view.
33
- // The YAML-tag shapes `!!mix`/`!!omni` get full-word schema names (cf. !!seq→array, !!map→object):
33
+ // The YAML-tag shapes `!!mix`/`!!var` get full-word schema names (cf. !!seq→array, !!map→object):
34
34
  // `mix` → "mixed", `omni` → "variant". Scalars resolve to their JSON-ish primitive type.
35
35
  export function typeName(s: Store, p: string, row: NodeRow): string {
36
36
  const k = displayKind(s, p, row);
@@ -46,3 +46,16 @@ export function scalarType(v: unknown): string {
46
46
  if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
47
47
  return "string";
48
48
  }
49
+
50
+ /** The three TYPE FACETS the client dispatches on (TYPES.md §1): the scalar self-VALUE's type
51
+ * (`null|boolean|integer|number|string|binary`, or null when there is no value facet), and
52
+ * whether the node OWNS any KEYED / ORDINAL (keyless) elements. Reverse `~` members are excluded
53
+ * (ownedEntries) — a tagged node keeps its facets, so a renderer can tolerate the extra keys. */
54
+ export function facetsOf(s: Store, p: string, row: NodeRow): { valueType: string | null; hasKeyed: boolean; hasOrdinal: boolean } {
55
+ const ents = ownedEntries(s, p);
56
+ return {
57
+ valueType: row.type === "scalar" ? scalarType(row.value) : row.type === "blob" ? "binary" : null,
58
+ hasKeyed: ents.some((e) => e.label !== null),
59
+ hasOrdinal: ents.some((e) => e.label === null),
60
+ };
61
+ }