@geml/geml 1.6.0 → 1.7.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/dist/geml.js CHANGED
@@ -9,8 +9,9 @@
9
9
  // math, media embeds, links, auto-references, footnotes) and build-time
10
10
  // reference validation (§8 — unique ids, resolvable internal/cross-document
11
11
  // references).
12
- import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, realpathSync, statSync, existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs";
13
13
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
14
+ import { homedir } from "node:os";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { spawnSync } from "node:child_process";
16
17
  import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
@@ -20,13 +21,13 @@ import { normalizeSource } from "./diagnostics.js";
20
21
  import { coerce, parseAttrs } from "./attrs.js";
21
22
  import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
22
23
  import { parseTable } from "./table.js";
23
- import { buildChart } from "./chart.js";
24
+ import { USES, buildChart } from "./chart.js";
24
25
  import { mdToGeml } from "./from-md.js";
25
26
  import { serialize } from "./serialize.js";
26
27
  import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
27
28
  import { gemlToMd } from "./to-md.js";
28
29
  export { mdToGeml } from "./from-md.js";
29
- export { renderHtml } from "./render-html.js";
30
+ export { renderHtml, pageAssets } from "./render-html.js";
30
31
  export { serialize } from "./serialize.js";
31
32
  export { gemlToMd } from "./to-md.js";
32
33
  // A block id is any non-whitespace run (§4), so it may contain regex
@@ -37,6 +38,57 @@ export { gemlToMd } from "./to-md.js";
37
38
  function reLit(s) {
38
39
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
40
  }
41
+ // The `data` block's format engines (GEP-0005), shared by the inline-body
42
+ // path and the `src=` pass: parse `body` under `fmt`, returning the value
43
+ // and/or diagnostics. `openLineNo` anchors line numbers — the open fence for
44
+ // an inline body, the block's own line for external content.
45
+ function parseDataBody(fmt, body, openLineNo) {
46
+ const diags = [];
47
+ if (fmt === "json") {
48
+ const text = body.join("\n");
49
+ try {
50
+ return { value: JSON.parse(text), diags };
51
+ }
52
+ catch (e) {
53
+ diags.push({ severity: "error", code: "data-parse", message: `data: body is not valid JSON (${e instanceof Error ? e.message : String(e)})`, line: jsonErrorLine(e, text, openLineNo) });
54
+ }
55
+ }
56
+ else if (fmt === "jsonl") {
57
+ const values = [];
58
+ let ok = true;
59
+ for (let li = 0; li < body.length; li++) {
60
+ const t = body[li].trim();
61
+ if (t === "")
62
+ continue; // blank lines are permitted and ignored
63
+ try {
64
+ values.push(JSON.parse(t));
65
+ }
66
+ catch {
67
+ diags.push({ severity: "error", code: "data-parse", message: `data: body line ${li + 1} is not one JSON value`, line: openLineNo + 1 + li });
68
+ ok = false;
69
+ }
70
+ }
71
+ if (ok)
72
+ return { value: values, diags };
73
+ }
74
+ else if (fmt === "yaml" || fmt === "toml") {
75
+ diags.push({ severity: "warning", code: "data-format-no-engine", message: `data: no \`${fmt}\` engine in this processor; body kept raw, not verified`, line: openLineNo });
76
+ }
77
+ else {
78
+ diags.push({ severity: "warning", code: "unknown-data-format", message: `unknown data format \`${fmt}\`; body kept raw`, line: openLineNo });
79
+ }
80
+ return { diags };
81
+ }
82
+ // Map a JSON.parse failure to the document line it happened on. V8 messages
83
+ // carry "at position N" (newer Nodes add line/column, but position is the
84
+ // stable token); counting newlines up to it gives the 1-based body line, and
85
+ // the open fence line offsets it into the document. No position -> the fence.
86
+ function jsonErrorLine(e, text, openLineNo) {
87
+ const m = /position (\d+)/.exec(e instanceof Error ? e.message : "");
88
+ if (!m)
89
+ return openLineNo;
90
+ return openLineNo + text.slice(0, Number(m[1])).split("\n").length;
91
+ }
40
92
  // Re-exported from ./diagnostics.js so that `Diagnostic` stays importable from
41
93
  // the package root. The catalogue of codes lives there (spec Appendix A).
42
94
  export { SEVERITY } from "./diagnostics.js";
@@ -47,6 +99,7 @@ const REGISTRY = {
47
99
  diagram: "raw",
48
100
  math: "raw",
49
101
  table: "raw", // structured table parsing lands in M3
102
+ data: "raw", // GEP-0005: value tree — a format engine parses the raw body in a second stage
50
103
  embed: "raw", // block transclusion: `src=` points at the content, body unused
51
104
  note: "flow",
52
105
  text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
@@ -60,6 +113,24 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
60
113
  // ---------------------------------------------------------------------------
61
114
  const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
62
115
  const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
116
+ // A line with the exact shape of a labeled close (§3): a `=` run and a `#id`,
117
+ // nothing else. Matched against lines that fell through to paragraph text,
118
+ // where such a line means the close closed nothing (stray-labeled-fence).
119
+ const STRAY_LABELED_FENCE = /^={3,}[ \t]+#(\S+)[ \t]*$/;
120
+ // The registered block types (§3's registry), for the fence-like check below:
121
+ // an unknown word after `===` is likelier a wall of `=` art or foreign syntax,
122
+ // so only a KNOWN type name earns the warning.
123
+ const REGISTERED_TYPES = new Set(["code", "diagram", "table", "math", "embed", "note", "text", "meta", "data"]);
124
+ // A line that WANTS to open a fence — a `=` run and a registered type name —
125
+ // but failed the fence production. The classic shape is bare, unbraced
126
+ // attributes (`=== embed src=#a`): the line silently became prose and any
127
+ // reference in it was never checked, which buried a real bug (all eight
128
+ // embeds of the playground showcase shipped in this shape, rendering as
129
+ // paragraphs under a green `check`). Matched, like STRAY_LABELED_FENCE, only
130
+ // against lines that fell through to paragraph text — raw block bodies and
131
+ // `\`-folded fence lines never reach that position, so the measured corpus
132
+ // false-positive rate is zero.
133
+ const FENCE_LIKE = /^={3,}[ \t]+([A-Za-z][A-Za-z0-9_-]*)\b/;
63
134
  const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
64
135
  // Maximum block/list nesting depth the recursive-descent scanner will build
65
136
  // before emitting a diagnostic instead of recursing further. Guards parse()
@@ -295,22 +366,36 @@ function scanBlocks(lines, base, ctx, depth = 0) {
295
366
  const type = open[2];
296
367
  const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
297
368
  const openLineNo = base + i + 1;
298
- // Collect the body. A block closes on a bare fence of exactly the opening
299
- // length, OR — when it has an id — on a labeled fence `=== #id` (a `=` run
300
- // of any length ≥ 3 followed by the block's id). The labeled close is a
301
- // *local* close: it can't be gotten wrong by miscounting `=`, so it is the
302
- // safe way to nest (§3).
369
+ // Collect the body. A block closes on the FIRST line that is a bare fence
370
+ // of exactly the opening length, OR — when it has an id — a labeled fence
371
+ // `=== #id` (a `=` run of any length ≥ 3 followed by the block's id). The
372
+ // labeled close can't be gotten wrong by miscounting `=`, but it does NOT
373
+ // shadow the bare close: a same-length bare fence in the body still ends
374
+ // the block first, so nesting needs a longer outer fence (§3).
303
375
  const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
304
376
  const body = [];
305
377
  let j = i + consumed;
306
378
  let closed = false;
379
+ let closedByBare = false;
307
380
  for (; j < lines.length; j++) {
308
- if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
381
+ if (isCloseFence(lines[j], openLen)) {
382
+ closed = true;
383
+ closedByBare = true;
384
+ break;
385
+ }
386
+ if (labeled && labeled.test(lines[j])) {
309
387
  closed = true;
310
388
  break;
311
389
  }
312
390
  body.push(lines[j]);
313
391
  }
392
+ // Remember a bare close of an id-bearing block (first definition wins,
393
+ // mirroring ctx.ids): if a `=== #id` line for it turns up later as plain
394
+ // text, the stray-labeled-fence warning can name the line that really
395
+ // closed the block.
396
+ if (closedByBare && attrs.id !== undefined && !ctx.bareClosed?.has(attrs.id)) {
397
+ (ctx.bareClosed ??= new Map()).set(attrs.id, base + j + 1);
398
+ }
314
399
  if (!closed) {
315
400
  const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
316
401
  diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
@@ -326,11 +411,13 @@ function scanBlocks(lines, base, ctx, depth = 0) {
326
411
  // the extras below are per type.
327
412
  let validRe;
328
413
  if (type === "table")
329
- validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
414
+ validRe = /^(src|format|delim|header|format-data|compute\d*|summary\d*|span\d*)$/;
415
+ else if (type === "data")
416
+ validRe = /^(format|schema|src)$/;
330
417
  else if (type === "embed")
331
418
  validRe = /^(src)$/;
332
419
  else if (type === "diagram")
333
- validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
420
+ validRe = /^(src|data|format|format-data|delim|header|type|rows|x|y|size|series)$/;
334
421
  // `src`/`anchor` on a `code` block are the code-graph profile's
335
422
  // (docs/codemap-profile.md): every document `geml codemap build` writes
336
423
  // carries them, so warning on them would warn on our own output.
@@ -416,7 +503,72 @@ function scanBlocks(lines, base, ctx, depth = 0) {
416
503
  }
417
504
  else {
418
505
  block.raw = body;
419
- if (type === "table") {
506
+ if (type === "data") {
507
+ // §GEP-0005: the value tree. The body stayed raw at scan time; a
508
+ // format engine parses it here — the same two-stage shape `table`
509
+ // uses. Admission to the format registry requires a SELF-DESCRIBING
510
+ // syntax (bytes alone determine the value): the core ships `json`
511
+ // (default — the model's own serialization) and `jsonl`; `yaml` and
512
+ // `toml` are reserved names with no engine here, and degrade exactly
513
+ // like an unknown `diagram` format: body kept raw, one warning.
514
+ const fmtRaw = attrs.attrs["format"];
515
+ const fmt = fmtRaw === undefined ? "json" : String(fmtRaw);
516
+ // `src=` names external content — the same one-source rule tables
517
+ // have (§6): exactly one of `src=` and an inline body. The engine
518
+ // runs over the file in a second pass (resolveDataSources); running
519
+ // it here over the empty body would report a spurious parse error.
520
+ const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
521
+ const hasBody = body.some((l) => l.trim() !== "");
522
+ if (srcAttr !== undefined && srcAttr !== "" && hasBody) {
523
+ diags.push({ severity: "error", code: "data-src-and-body", message: "data: carries both `src=` and an inline body; exactly one is permitted (the body wins here)", line: openLineNo });
524
+ }
525
+ if (srcAttr !== undefined && srcAttr !== "" && !hasBody) {
526
+ (ctx.dataSources ??= []).push({ block, line: openLineNo, target: srcAttr });
527
+ }
528
+ else {
529
+ const parsed = parseDataBody(fmt, body, openLineNo);
530
+ for (const d of parsed.diags)
531
+ diags.push(d);
532
+ if (parsed.value !== undefined)
533
+ block.value = parsed.value;
534
+ }
535
+ // `schema=` is reference-checked ONLY (GEP-0005): it must name a
536
+ // block or a GEML document; validating the value against it is a
537
+ // later GEP. The reference goes through the ordinary §8 resolver so
538
+ // a dangling schema rots loudly like any other reference.
539
+ const schema = attrs.attrs["schema"];
540
+ if (schema !== undefined) {
541
+ const s = typeof schema === "string" ? schema.trim() : "";
542
+ if (s.startsWith("#") && s.length > 1) {
543
+ ctx.refs.push({ kind: "internal", anchor: s.slice(1), line: openLineNo });
544
+ }
545
+ else if (/\.geml(#|$)/i.test(s)) {
546
+ const h = s.indexOf("#");
547
+ if (h < 0)
548
+ ctx.refs.push({ kind: "cross", doc: s, anchor: undefined, line: openLineNo });
549
+ else
550
+ ctx.refs.push({ kind: "cross", doc: s.slice(0, h), anchor: s.slice(h + 1), line: openLineNo });
551
+ }
552
+ else {
553
+ diags.push({ severity: "error", code: "bad-data-schema", message: `data: \`schema=${s}\` must name a block (\`#id\`) or a GEML document (\`doc.geml[#id]\`)`, line: openLineNo });
554
+ }
555
+ }
556
+ // First definition wins, matching ctx.ids/ctx.tables.
557
+ if (block.id !== undefined && block.value !== undefined && !ctx.dataValues?.has(block.id)) {
558
+ (ctx.dataValues ??= new Map()).set(block.id, block.value);
559
+ }
560
+ }
561
+ else if (type === "code") {
562
+ // `src=` on a code block is a ROUTE to the code it shows —
563
+ // `<path>[#L<start>[-<end>]]` — resolved in a second pass, like a
564
+ // table's `src=`. The code-graph runtime has always fetched and
565
+ // sliced it at render time; checking it here is what makes a stale
566
+ // range a build error instead of a panel that silently shows a path.
567
+ const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
568
+ if (srcAttr !== undefined && srcAttr !== "")
569
+ (ctx.codeSources ??= []).push({ block, line: openLineNo, target: srcAttr });
570
+ }
571
+ else if (type === "table") {
420
572
  const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
421
573
  // §6: parse the raw body (visual or csv/tsv) into one table model.
422
574
  const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
@@ -502,6 +654,38 @@ function scanBlocks(lines, base, ctx, depth = 0) {
502
654
  para.push(lines[i]);
503
655
  i++;
504
656
  }
657
+ // A line shaped exactly like a labeled close (`=== #id`) that got this far
658
+ // closed nothing — when the id's block was already ended by a same-length
659
+ // bare fence in its body (§3), everything from that fence on silently fell
660
+ // out of the block. Warn: "ok: no diagnostics" over a truncated document is
661
+ // the failure mode this diagnostic exists for. The id is used only as a Map
662
+ // key here — no RegExp is built from it, so reLit() does not apply.
663
+ for (let k = 0; k < para.length; k++) {
664
+ const stray = STRAY_LABELED_FENCE.exec(para[k]);
665
+ if (!stray)
666
+ continue;
667
+ const id = stray[1];
668
+ const lineNo = paraStart + k;
669
+ const closedAt = ctx.bareClosed?.get(id);
670
+ diags.push({
671
+ severity: "warning", code: "stray-labeled-fence", line: lineNo,
672
+ message: closedAt !== undefined
673
+ ? `labeled fence for \`#${id}\` at line ${lineNo}, but block \`#${id}\` was already closed by a bare fence at line ${closedAt} — body may be silently truncated`
674
+ : `labeled fence for \`#${id}\` closes no block; the line is plain paragraph text`,
675
+ });
676
+ }
677
+ for (let k = 0; k < para.length; k++) {
678
+ // Sibling trap to the stray labeled close: a would-be OPEN fence that
679
+ // missed the production and silently became prose (§3 requires braced
680
+ // attributes; `=== embed src=#a` is the classic miss).
681
+ const like = FENCE_LIKE.exec(para[k]);
682
+ if (like && REGISTERED_TYPES.has(like[1])) {
683
+ diags.push({
684
+ severity: "warning", code: "fence-like-line", line: paraStart + k,
685
+ message: `line looks like an open fence for \`${like[1]}\` but is not one — attributes must be braced (\`=== ${like[1]} {…}\`); the line reads as plain paragraph text`,
686
+ });
687
+ }
688
+ }
505
689
  const text = interpolate(para.join("\n"), paraStart, ctx);
506
690
  blocks.push({ kind: "paragraph", text, inlines: parseInline(text, paraStart, ctx) });
507
691
  }
@@ -555,6 +739,11 @@ function chartSourceTable(ctx, opts, block, target, line) {
555
739
  format: typeof block.attrs["format-data"] === "string" ? block.attrs["format-data"] : inferDataFormat(target),
556
740
  header: block.attrs["header"] === undefined ? true : block.attrs["header"],
557
741
  };
742
+ // A chart reading a `;`-delimited export needs the same delimiter override a
743
+ // table does; the table rules validate it (§6).
744
+ const delim = block.attrs["delim"];
745
+ if (delim !== undefined)
746
+ attrs["delim"] = delim;
558
747
  const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
559
748
  for (const d of diagnostics)
560
749
  ctx.diags.push({ ...d, line });
@@ -735,6 +924,111 @@ function relDirPath(p) {
735
924
  const i = p.lastIndexOf("/");
736
925
  return i < 0 ? "" : p.slice(0, i);
737
926
  }
927
+ // A chain that cannot reach an entity block. Carries the diagnostic code it
928
+ // corresponds to (§3) so the message can name it without inventing a new one.
929
+ class ViewError extends Error {
930
+ code;
931
+ constructor(code, message) {
932
+ super(message);
933
+ this.code = code;
934
+ }
935
+ }
936
+ // Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
937
+ // content, so without a confinement root a document could name any path on the
938
+ // machine. And never a URL — `geml get` is a read command that agents and
939
+ // editors call constantly, so letting content steer it at the network would turn
940
+ // it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
941
+ function readConfined(rel, root) {
942
+ if (!/\.geml$/i.test(rel)) {
943
+ throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
944
+ }
945
+ const base = resolvePath(root);
946
+ const abs = resolvePath(root, rel);
947
+ if (abs !== base && !abs.startsWith(base + sep)) {
948
+ throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
949
+ }
950
+ try {
951
+ return readFileSync(abs, "utf8");
952
+ }
953
+ catch {
954
+ throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
955
+ }
956
+ }
957
+ // One hop: read the target document and select what the fragment names. Several
958
+ // units come back when the fragment names a section (§4.3).
959
+ function oneHop(file, src, root) {
960
+ const hash = src.indexOf("#");
961
+ const docPath = hash < 0 ? src : src.slice(0, hash);
962
+ const frag = hash < 0 ? undefined : src.slice(hash + 1);
963
+ // Check the scheme on what the DOCUMENT wrote, before composition: a URL can
964
+ // only arrive through `src=`, never from joining relative paths — and testing
965
+ // the composed path instead would read a Windows drive letter (`C:/…`) as a
966
+ // scheme and refuse every absolute path, which is exactly what the MCP layer
967
+ // hands the CLI.
968
+ if (schemeOf(docPath) !== null) {
969
+ throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
970
+ }
971
+ const rel = relJoinPath(relDirPath(file), docPath);
972
+ const text = readConfined(rel, root);
973
+ if (frag === undefined) {
974
+ // `src=other.geml`: the frame looks onto the WHOLE document. Every block
975
+ // comes from the same target, so the resolution base stays uniform — unlike
976
+ // a host-side section selector, where splicing would mix two documents.
977
+ // `meta` is frontmatter, not content (render.ts's selectEmbed).
978
+ //
979
+ // Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
980
+ // every addressed unit would emit the blocks inside a section twice.
981
+ const every = addressedUnits(text).map((a) => a.unit);
982
+ const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
983
+ && (o.span.start < u.span.start || o.span.end > u.span.end)));
984
+ return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
985
+ }
986
+ const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
987
+ return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
988
+ }
989
+ // Provenance is stated relative to the confinement root, not as the path the
990
+ // walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
991
+ // so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
992
+ // server's layout, and not a path any caller could pass back in.
993
+ function shownPath(rel, root) {
994
+ const r = relative(root, rel).replace(/\\/g, "/");
995
+ return r === "" ? rel : r;
996
+ }
997
+ function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
998
+ const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
999
+ if (src === undefined)
1000
+ return [{ doc: file, text: source, unit, all: [], from: "" }];
1001
+ // The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
1002
+ // cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
1003
+ // not expanded — `--view` may not: stopping here means what we are holding is
1004
+ // still a frame, and returning it would break the contract silently.
1005
+ if (depth >= EMBED_DEPTH_LIMIT) {
1006
+ throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
1007
+ }
1008
+ const hop = oneHop(file, src, root);
1009
+ // Same key shape as the check's cycle detector: a document plus what was
1010
+ // selected in it.
1011
+ const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
1012
+ if (seen.has(key)) {
1013
+ throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
1014
+ }
1015
+ const nextSeen = new Set(seen).add(key);
1016
+ // Per-unit application, recursively: what a frame looks onto may itself be a
1017
+ // frame, and a section may hold a mix (§4.3).
1018
+ return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
1019
+ // An inner identity step has no provenance of its own, so carry this hop's:
1020
+ // `from` must always name where the bytes actually came from.
1021
+ .map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
1022
+ }
1023
+ // The `src=` of an embed unit, read off its head line: a Unit carries the span,
1024
+ // not parsed attributes.
1025
+ function embedSrcOf(source, unit) {
1026
+ const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, true, false));
1027
+ if (!braces)
1028
+ return undefined;
1029
+ const v = parseAttrs(braces[0]).attrs["src"];
1030
+ return typeof v === "string" ? v : undefined;
1031
+ }
738
1032
  function gatherEmbeds(source) {
739
1033
  const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
740
1034
  scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
@@ -751,6 +1045,11 @@ function tableFromDocument(source, id) {
751
1045
  const found = ctx.tables?.get(id);
752
1046
  if (found !== undefined)
753
1047
  return found;
1048
+ // GEP-0005: a remote `data` block is the other chart-source form; its value
1049
+ // is projected by the CALLER (recordsToTable needs the chart's attributes).
1050
+ const dv = ctx.dataValues?.get(id);
1051
+ if (dv !== undefined)
1052
+ return { records: dv };
754
1053
  const anyBlock = (function find(bs) {
755
1054
  for (const b of bs) {
756
1055
  if ((b.kind === "block" || b.kind === "heading") && b.id === id)
@@ -847,7 +1146,11 @@ function resolveTableSources(ctx, opts) {
847
1146
  err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
848
1147
  continue;
849
1148
  }
850
- if (remote === "not-a-table") {
1149
+ // A table's `src=` names a TABLE. A `data` block is a chart-source form
1150
+ // (§7.1, GEP-0005), not a table-source form — the column algebra a
1151
+ // borrowing table implies (compute/summary against named columns) has
1152
+ // no defined meaning over a value tree.
1153
+ if (remote === "not-a-table" || "records" in remote) {
851
1154
  err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
852
1155
  continue;
853
1156
  }
@@ -923,6 +1226,213 @@ function validateRefs(ctx, opts) {
923
1226
  }
924
1227
  }
925
1228
  }
1229
+ // `src=` on a `code` block: the route to the code the block shows,
1230
+ // `<path>[#L<start>[-<end>]]` (1-based, inclusive). The code-graph runtime has
1231
+ // always fetched and sliced this at render time; resolving it here is what
1232
+ // turns a range that no longer exists — the source moved or shrank — from a
1233
+ // silently empty panel into a build error. There is no extension gate (code is
1234
+ // any language); the safety rule is the resolver's confinement to the document
1235
+ // tree, widened only by `--root`.
1236
+ const SOURCE_RANGE = /^L(\d+)(?:-(\d+))?$/;
1237
+ // One route syntax for the two types whose `src=` fragment position is free —
1238
+ // `code` and `data`. (A table's is already taken: `src=doc.geml#id` names a
1239
+ // block.) `<path>[#L<start>[-<end>]]`, 1-based and inclusive; `to === 0` means
1240
+ // "through end of file". Returns null after reporting, so callers just skip.
1241
+ function parseSourceRoute(target, kind, line, ctx) {
1242
+ const hash = target.indexOf("#");
1243
+ const path = hash < 0 ? target : target.slice(0, hash);
1244
+ const frag = hash < 0 ? "" : target.slice(hash + 1);
1245
+ if (frag === "")
1246
+ return { path, from: 1, to: 0 };
1247
+ const m = SOURCE_RANGE.exec(frag);
1248
+ if (!m) {
1249
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: unrecognised fragment (expected \`#L<start>\` or \`#L<start>-<end>\`)`, line });
1250
+ return null;
1251
+ }
1252
+ const from = Number(m[1]);
1253
+ const to = m[2] === undefined ? from : Number(m[2]);
1254
+ if (from < 1 || to < from) {
1255
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: line range is empty or starts before line 1`, line });
1256
+ return null;
1257
+ }
1258
+ return { path, from, to };
1259
+ }
1260
+ // Slice a resolved file to a route's range, or report that the range no longer
1261
+ // exists — the signal a stale reference exists at all.
1262
+ function sliceSourceRange(text, route, target, kind, line, ctx) {
1263
+ const all = normalizeSource(text).split("\n");
1264
+ // A trailing newline yields a final empty element; it is not a line.
1265
+ if (all.length > 0 && all[all.length - 1] === "")
1266
+ all.pop();
1267
+ if (route.to > all.length) {
1268
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: the file has ${all.length} line(s), so lines ${route.from}-${route.to} no longer exist — the range is stale`, line });
1269
+ return null;
1270
+ }
1271
+ return all.slice(route.from - 1, route.to === 0 ? all.length : route.to);
1272
+ }
1273
+ function resolveCodeSources(ctx, opts) {
1274
+ for (const { block, line, target } of ctx.codeSources ?? []) {
1275
+ const scheme = schemeOf(target);
1276
+ // A remote source is fetched by the RENDERER (§9.4), as for a table.
1277
+ if (scheme === "http" || scheme === "https")
1278
+ continue;
1279
+ if (scheme !== null) {
1280
+ ctx.diags.push({ severity: "error", code: "bad-code-source", message: `code source \`${target}\` names a disallowed URL scheme`, line });
1281
+ continue;
1282
+ }
1283
+ const route = parseSourceRoute(target, "code", line, ctx);
1284
+ if (route === null)
1285
+ continue;
1286
+ const { path, from, to } = route;
1287
+ if (!opts.resolveDoc) {
1288
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `code source \`${target}\` not checked (no document resolver)`, line });
1289
+ continue;
1290
+ }
1291
+ const text = opts.resolveDoc(path);
1292
+ if (text === null) {
1293
+ // A WARNING, not an error, and the code/value model split is the reason:
1294
+ // a value that cannot be loaded is a promise the document failed to keep
1295
+ // (an error — see `unresolvable-data-source`), while a code region that
1296
+ // cannot be reached right now is still a code region at a location. A
1297
+ // generated code graph read away from its sources — published on its own,
1298
+ // or describing another checkout — must stay valid, exactly as the
1299
+ // render-time runtime degrades to showing the path.
1300
+ ctx.diags.push({ severity: "warning", code: "unresolvable-code-source", message: `cannot resolve code source \`${path}\` — not checked`, line });
1301
+ continue;
1302
+ }
1303
+ const slice = sliceSourceRange(text, { from, to }, target, "code", line, ctx);
1304
+ if (slice === null)
1305
+ continue;
1306
+ const hasBody = (block.raw ?? []).some((l) => l.trim() !== "");
1307
+ if (!hasBody) {
1308
+ block.raw = slice;
1309
+ }
1310
+ else if ((block.raw ?? []).join("\n") !== slice.join("\n")) {
1311
+ // A body alongside `src=` is a cached snapshot, kept for offline reading.
1312
+ // Silence would let the two drift — the very thing the route prevents.
1313
+ ctx.diags.push({ severity: "warning", code: "stale-code-snapshot", message: `code block body differs from its source \`${target}\` — the body is a snapshot and is now out of date`, line });
1314
+ }
1315
+ }
1316
+ }
1317
+ // GEP-0005: `src=` on a `data` block names external content — the same
1318
+ // external-source discipline tables have (§6, §9.4): an http(s) source is
1319
+ // fetched by the RENDERER, never the parser (the block defers, and so does a
1320
+ // chart over it); any other scheme is refused; the file must look like data
1321
+ // (`.json`/`.jsonl`); a missing resolver leaves it unchecked with a warning.
1322
+ function resolveDataSources(ctx, opts) {
1323
+ for (const { block, line, target } of ctx.dataSources ?? []) {
1324
+ const defer = () => { if (block.id !== undefined)
1325
+ (ctx.dataSrcPending ??= new Set()).add(block.id); };
1326
+ const scheme = schemeOf(target);
1327
+ if (scheme === "http" || scheme === "https") {
1328
+ defer();
1329
+ continue;
1330
+ }
1331
+ if (scheme !== null) {
1332
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `data source \`${target}\` names a disallowed URL scheme`, line });
1333
+ continue;
1334
+ }
1335
+ // The route shares `code`'s syntax (§3.2): a line range MAY narrow the file.
1336
+ const route = parseSourceRoute(target, "data", line, ctx);
1337
+ if (route === null)
1338
+ continue;
1339
+ const { path } = route;
1340
+ // A data source is data — the same shape rule table sources enforce, so
1341
+ // the loader cannot be pointed at a `.env` or a private key.
1342
+ if (!/\.(json|jsonl)$/i.test(path)) {
1343
+ ctx.diags.push({ severity: "error", code: "bad-data-source", message: `data source \`${path}\` is not a \`.json\`/\`.jsonl\` data file`, line });
1344
+ continue;
1345
+ }
1346
+ // Explicit format= wins; otherwise the (already-gated) extension names it.
1347
+ const fmtAttr = block.attrs["format"];
1348
+ const fmt = typeof fmtAttr === "string" ? fmtAttr : /\.jsonl$/i.test(path) ? "jsonl" : "json";
1349
+ // A range narrows the file to lines; what those lines mean is then the
1350
+ // format's business, unchanged. Slicing a `jsonl` log is the obvious use;
1351
+ // slicing a `json` file works whenever the slice is itself a value, and
1352
+ // when it is not, the ordinary data-parse error already names the line.
1353
+ // No extra rule.
1354
+ if (!opts.resolveDoc) {
1355
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `data source \`${target}\` not checked (no document resolver)`, line });
1356
+ defer();
1357
+ continue;
1358
+ }
1359
+ const text = opts.resolveDoc(path);
1360
+ if (text === null) {
1361
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `cannot resolve data source \`${path}\``, line });
1362
+ continue;
1363
+ }
1364
+ const lines = sliceSourceRange(text, route, target, "data", line, ctx);
1365
+ if (lines === null)
1366
+ continue;
1367
+ const parsed = parseDataBody(fmt, lines, line);
1368
+ for (const d of parsed.diags)
1369
+ ctx.diags.push(d);
1370
+ if (parsed.value !== undefined) {
1371
+ block.value = parsed.value;
1372
+ if (block.id !== undefined && !ctx.dataValues?.has(block.id)) {
1373
+ (ctx.dataValues ??= new Map()).set(block.id, parsed.value);
1374
+ }
1375
+ }
1376
+ }
1377
+ }
1378
+ // GEP-0005: a chart's `data=` may target a `data` block whose value is a
1379
+ // RECORD ARRAY — a non-empty array of objects. Keys project to columns in
1380
+ // first-seen order; every column the chart actually references (x/y/size/
1381
+ // series) must be present with a SCALAR value in every record, and a
1382
+ // violation is an error naming the first offending record. Columns the chart
1383
+ // does not reference may hold anything (nested values project as compact
1384
+ // JSON text). The projection feeds the unchanged table machinery.
1385
+ function recordsToTable(value, attrs, line, ctx) {
1386
+ const fail = (msg) => {
1387
+ ctx.diags.push({ severity: "error", code: "chart-data-not-records", message: `geml-chart: ${msg}`, line });
1388
+ return null;
1389
+ };
1390
+ if (!Array.isArray(value) || value.length === 0)
1391
+ return fail("data target is not a non-empty record array");
1392
+ const columns = [];
1393
+ for (let i = 0; i < value.length; i++) {
1394
+ const r = value[i];
1395
+ if (r === null || typeof r !== "object" || Array.isArray(r))
1396
+ return fail(`record ${i + 1} is not an object`);
1397
+ for (const k of Object.keys(r))
1398
+ if (!columns.includes(k))
1399
+ columns.push(k);
1400
+ }
1401
+ // Only the channels this chart TYPE reads are "referenced" (§7.1): a stray
1402
+ // size= on a bar chart is buildChart's chart-unused-channel WARNING, and the
1403
+ // projection must not turn it into an error a table source would not raise.
1404
+ // An unknown/missing type validates x/y only; buildChart reports the type.
1405
+ const typeAttr = String(attrs["type"] ?? "");
1406
+ const uses = USES[typeAttr] ?? new Set(["x", "y"]);
1407
+ const channels = [];
1408
+ for (const c of ["x", "y", "size", "series"]) {
1409
+ if (!uses.has(c))
1410
+ continue;
1411
+ const v = attrs[c];
1412
+ if (typeof v === "string")
1413
+ for (const name of v.split(",").map((s) => s.trim()).filter(Boolean))
1414
+ channels.push(name);
1415
+ }
1416
+ for (const col of channels) {
1417
+ for (let i = 0; i < value.length; i++) {
1418
+ const v = value[i][col];
1419
+ if (v === undefined || v === null || typeof v === "object") {
1420
+ return fail(`column \`${col}\` is missing or non-scalar in record ${i + 1}`);
1421
+ }
1422
+ }
1423
+ }
1424
+ const rows = value.map((r) => columns.map((c) => {
1425
+ const v = r[c];
1426
+ const text = v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
1427
+ // Data, not prose: cells carry plain-text inlines, never inline-parsed —
1428
+ // the same treatment `format=csv` cells get (a `*` in a value is a `*`).
1429
+ const cell = { text, inlines: text === "" ? [] : [{ type: "text", value: text }] };
1430
+ if (typeof v === "number" && Number.isFinite(v))
1431
+ cell.value = v;
1432
+ return cell;
1433
+ }));
1434
+ return { header: true, columns, align: columns.map(() => undefined), rows };
1435
+ }
926
1436
  // §7: resolve every geml-chart against its referenced table. Runs after the
927
1437
  // scan so that `data=#id` may point at a table defined anywhere in the doc.
928
1438
  function resolveCharts(ctx, opts) {
@@ -942,6 +1452,21 @@ function resolveCharts(ctx, opts) {
942
1452
  let table;
943
1453
  if (docPath === "") {
944
1454
  table = ctx.tables?.get(id);
1455
+ if (!table && ctx.dataValues?.has(id)) {
1456
+ // GEP-0005: the target is a `data` block. A RECORD ARRAY projects to
1457
+ // the table model (keys -> columns) and feeds the unchanged chart
1458
+ // machinery, so column checks and rendering stay single-sourced.
1459
+ const projected = recordsToTable(ctx.dataValues.get(id), block.attrs, line, ctx);
1460
+ if (projected === null)
1461
+ continue; // reported by the projection
1462
+ table = projected;
1463
+ }
1464
+ if (!table && ctx.dataSrcPending?.has(id)) {
1465
+ // GEP-0005: the target is a `data` block whose `src=` is render-time
1466
+ // (http, or no resolver) — defer exactly like a src table with no
1467
+ // columns; the renderer checks it when the data actually arrives.
1468
+ continue;
1469
+ }
945
1470
  if (!table) {
946
1471
  // A chart is a view of a table, and a data file is one of the three ways
947
1472
  // §6 lets a table name its content. So `data=rows.csv` desugars: it is an
@@ -955,8 +1480,36 @@ function resolveCharts(ctx, opts) {
955
1480
  continue; // already reported by the table rules
956
1481
  table = sugar;
957
1482
  }
1483
+ else if (hash < 0 && /\.(json|jsonl)$/i.test(id) && schemeOf(id) === null) {
1484
+ // GEP-0005 sugar, the json/jsonl twin of the csv path: an anonymous
1485
+ // LOCAL data source projected through the record-array rules. A
1486
+ // remote URL needs a NAMED `data` block with `src=` — its fetch is
1487
+ // render-time, and an anonymous source has no block to defer on.
1488
+ if (!opts.resolveDoc) {
1489
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${id}\` not checked (no document resolver)`, line });
1490
+ continue;
1491
+ }
1492
+ const text = opts.resolveDoc(id);
1493
+ if (text === null) {
1494
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `geml-chart: cannot resolve data source \`${id}\``, line });
1495
+ continue;
1496
+ }
1497
+ const parsed = parseDataBody(/\.jsonl$/i.test(id) ? "jsonl" : "json", normalizeSource(text).split("\n"), line);
1498
+ for (const d of parsed.diags)
1499
+ ctx.diags.push(d);
1500
+ if (parsed.value === undefined)
1501
+ continue;
1502
+ const projected = recordsToTable(parsed.value, block.attrs, line, ctx);
1503
+ if (projected === null)
1504
+ continue; // reported by the projection
1505
+ table = projected;
1506
+ }
1507
+ else if (hash < 0 && /\.(json|jsonl)$/i.test(id)) {
1508
+ ctx.diags.push({ severity: "error", code: "bad-data-source", message: `geml-chart: \`data=${id}\`: a remote json/jsonl source needs a named \`data\` block with \`src=\``, line });
1509
+ continue;
1510
+ }
958
1511
  else if (hash < 0 && /\.[a-z0-9]+$/i.test(id)) {
959
- ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\` data file, and not a \`#id\` naming a table`, line });
1512
+ ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\`/\`.json\`/\`.jsonl\` data file, and not a \`#id\` naming a table or data block`, line });
960
1513
  continue;
961
1514
  }
962
1515
  else {
@@ -984,10 +1537,18 @@ function resolveCharts(ctx, opts) {
984
1537
  continue;
985
1538
  }
986
1539
  if (remote === "not-a-table") {
987
- ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is not a table`, line });
1540
+ ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is neither a table nor a data block`, line });
988
1541
  continue;
989
1542
  }
990
- table = remote;
1543
+ if ("records" in remote) {
1544
+ const projected = recordsToTable(remote.records, block.attrs, line, ctx);
1545
+ if (projected === null)
1546
+ continue; // reported by the projection
1547
+ table = projected;
1548
+ }
1549
+ else {
1550
+ table = remote;
1551
+ }
991
1552
  }
992
1553
  if (table.src !== undefined && table.columns.length === 0) {
993
1554
  // §6: the table names a source whose data did not arrive at build time — a
@@ -1012,6 +1573,8 @@ export function parse(source, opts = {}) {
1012
1573
  // Table sources first: a chart reads the build-time model of the table it
1013
1574
  // charts, so that model has to be filled before charts are resolved.
1014
1575
  resolveTableSources(ctx, opts);
1576
+ resolveDataSources(ctx, opts);
1577
+ resolveCodeSources(ctx, opts);
1015
1578
  resolveCharts(ctx, opts);
1016
1579
  validateRefs(ctx, opts);
1017
1580
  detectTransclusionCycles(ctx, opts);
@@ -1319,6 +1882,8 @@ Usage:
1319
1882
  --to <output>: json | html | md | geml
1320
1883
  --to md -> Markdown (lossy)
1321
1884
  --to html -> self-contained HTML
1885
+ --to html --fragment -> body-only markup, no page shell
1886
+ (embed in your own layout; assets via pageAssets)
1322
1887
  --to geml -> canonical re-format
1323
1888
  --to json -> document-model JSON (default)
1324
1889
  --from <input>: geml | md | json (overrides extension; html is output-only)
@@ -1351,6 +1916,10 @@ Usage:
1351
1916
  set/add/delete/rename/revert; every write is validated before it
1352
1917
  reaches disk. A code graph under --root adds four read-only
1353
1918
  geml_codemap_* tools to the same server)
1919
+ geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
1920
+ (authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
1921
+ MCP server registered at user scope; touches no settings.json,
1922
+ installs no hooks; idempotent — re-run to update)
1354
1923
  geml --help | --version [--json]
1355
1924
 
1356
1925
  Use '-' as the file to read from stdin.
@@ -1364,7 +1933,7 @@ Exit codes:
1364
1933
  // One-line usage for each subcommand — the single source for both the error
1365
1934
  // shown on misuse and the `<cmd> --help` text.
1366
1935
  const SUBHELP = {
1367
- get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; without a selector: list every addressable block with its shortest unique address, --json = array)",
1936
+ get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--view [--root <dir>]] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; --view = read THROUGH an `embed` to the entity block it stands for, following a chain to its end (the identity on any other block, and on a section selector — it never splices two documents' bytes together); provenance goes to stderr as `view: <sel> -> <doc>[#<id>]`; read-only, `set` refuses it; chain reads are confined to --root (default: the document's own directory) and never fetched over the network; without a selector: list every addressable block with its shortest unique address, --json = array)",
1368
1937
  set: "usage: geml set <file.geml|-> <selector> [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body; guarded splice, refused if it breaks the doc; writing through an @<hex> address prints the new address on stderr)",
1369
1938
  add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
1370
1939
  delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
@@ -1408,6 +1977,19 @@ const SUBHELP = {
1408
1977
 
1409
1978
  Register with a client:
1410
1979
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
1980
+ skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp]
1981
+
1982
+ One command, three things, all user-global — so any Claude Code session can
1983
+ author, validate, and blockwise-edit GEML:
1984
+ 1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
1985
+ 2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
1986
+ 3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
1987
+ Touches no settings.json and installs no hooks. Idempotent — re-run after an
1988
+ upgrade to refresh the skill text alongside the CLI it teaches.
1989
+
1990
+ --dest <dir> install the skill under <dir> instead of ~/.claude/skills
1991
+ --no-global skip the global npm install
1992
+ --no-mcp skip the MCP server registration`,
1411
1993
  };
1412
1994
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
1413
1995
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -1486,7 +2068,19 @@ function resolverFor(file, root) {
1486
2068
  return null;
1487
2069
  // References resolve FROM the document's own directory; the gates below
1488
2070
  // confine them to the (possibly widened) base.
1489
- const targetAbs = resolvePath(dirAbs, d);
2071
+ let targetAbs = resolvePath(dirAbs, d);
2072
+ // A SOURCE route (`code`/`data` `src=`) may instead be written relative to
2073
+ // the resolution root — that is how the code-graph profile writes them
2074
+ // (`geml-parser/src/attrs.ts` from a document two levels down). So when
2075
+ // the document-relative path does not exist and a root was named, try the
2076
+ // root as the base. Only a widened `--root` can enable this, and both
2077
+ // confinement gates below still apply, so it cannot reach further than a
2078
+ // document-relative reference already could.
2079
+ if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
2080
+ const fromBase = resolvePath(baseAbs, d);
2081
+ if (existsSync(fromBase))
2082
+ targetAbs = fromBase;
2083
+ }
1490
2084
  // Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
1491
2085
  // before touching the filesystem.
1492
2086
  if (outside(baseAbs, targetAbs))
@@ -1556,18 +2150,6 @@ function historyError(e, file, historyPath) {
1556
2150
  }
1557
2151
  return err?.message ?? String(e);
1558
2152
  }
1559
- // The three verbs the four-verb collapse removed (design §2/§6). They are HARD
1560
- // deletions, not aliases — the same call this repo already made for `geml
1561
- // codemap mcp` and `geml mcp --workspace`: one word, one meaning, and a stale
1562
- // spelling that keeps working is a second vocabulary the docs and an agent's
1563
- // memory then both carry. So each names its replacement rather than falling into
1564
- // `unknown history subcommand`, which would leave the caller guessing which of
1565
- // four verbs took over.
1566
- const RETIRED_HISTORY = {
1567
- commit: "geml history commit was renamed: use `geml history save <file.geml> [-m msg]` (same behaviour, except that a file identical to the tip is now a no-op instead of an empty revision).",
1568
- log: "geml history log was removed: use `geml history get <file.geml>` — no revision selector lists every revision, newest first, with the same copy-pasteable first column.",
1569
- show: "geml history show was removed: use `geml history get <file.geml> <rev>` — a revision selector prints that revision's full text (`--json` wraps it as {id, text}).",
1570
- };
1571
2153
  // Subcommand, file and revision, read positionally around the options —
1572
2154
  // `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
1573
2155
  // indexing read `--history` itself as the file.
@@ -1594,8 +2176,6 @@ function runHistory(args) {
1594
2176
  const [sub, file, rev, ...extra] = historyPositionals(args);
1595
2177
  if (!sub || !file)
1596
2178
  fail(SUBHELP.history);
1597
- if (RETIRED_HISTORY[sub])
1598
- fail(RETIRED_HISTORY[sub]);
1599
2179
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1600
2180
  const json = args.includes("--json");
1601
2181
  try {
@@ -1717,6 +2297,13 @@ function runTransform(argv) {
1717
2297
  const out = flag(argv, "-o") ?? flag(argv, "--out");
1718
2298
  const fromRaw = flag(argv, "--from");
1719
2299
  const toRaw = flag(argv, "--to");
2300
+ // `--to html --fragment`: body-only markup for embedding in an existing
2301
+ // layout (library parity: RenderOptions.fragment). Consumed here so it can
2302
+ // be rejected on any other target — a discarded flag is a silent lie.
2303
+ const fragIdx = argv.indexOf("--fragment");
2304
+ const fragment = fragIdx >= 0;
2305
+ if (fragment)
2306
+ argv.splice(fragIdx, 1);
1720
2307
  // Same `--root` as `check`, and for the same reason: cross-document resolution is
1721
2308
  // fail-closed at the document's own directory, so a reference that climbs out of
1722
2309
  // it needs the tree's root named. Without this the transform silently ignored the
@@ -1764,6 +2351,8 @@ function runTransform(argv) {
1764
2351
  else {
1765
2352
  outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
1766
2353
  }
2354
+ if (fragment && outFmt !== "html")
2355
+ fail("--fragment only applies to --to html", 2);
1767
2356
  const src = readInput(file);
1768
2357
  // md -> geml is a direct projection, not a parse/serialize round-trip: emit
1769
2358
  // the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
@@ -1800,6 +2389,7 @@ function runTransform(argv) {
1800
2389
  case "html":
1801
2390
  output = renderHtml(doc, {
1802
2391
  source: file === "-" ? "stdin" : basename(file),
2392
+ fragment,
1803
2393
  // geml-code-graph embeds load + parse sibling codemap docs on demand.
1804
2394
  loadDoc: resolverFor(file, root),
1805
2395
  parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
@@ -2148,7 +2738,8 @@ function runGet(args) {
2148
2738
  const json = args.includes("--json");
2149
2739
  const headOnly = args.includes("--head");
2150
2740
  const bodyOnly = args.includes("--body");
2151
- const [file, rawSel] = positionals(args, []);
2741
+ const view = args.includes("--view");
2742
+ const [file, rawSel] = positionals(args, ["--root"]);
2152
2743
  if (!file)
2153
2744
  fail(SUBHELP.get);
2154
2745
  if (headOnly && bodyOnly)
@@ -2171,16 +2762,72 @@ function runGet(args) {
2171
2762
  return;
2172
2763
  }
2173
2764
  const { units, all } = selectUnits(source, file, rawSel, where);
2765
+ // The chain is composed with `/` — relJoinPath's rule, and `src=` values are
2766
+ // always `/`-separated — so normalize the PLATFORM path at this boundary. On
2767
+ // Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
2768
+ // tell, and a relative `src=` resolves against the wrong base.
2769
+ const startDoc = where.replace(/\\/g, "/");
2770
+ const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
2174
2771
  if (json) {
2175
2772
  // §7: N matches yield N model nodes. The old `{kind:"blocks",
2176
2773
  // matches:[{lines}]}` coordinate envelope is gone — it answered "where are
2177
2774
  // they" when the question is "what are they" (§9 change 2).
2178
- const nodes = units.map((u) => unitNode(source, file, u, all));
2179
- console.log(JSON.stringify(units.length === 1 ? nodes[0] : nodes, null, 2));
2775
+ let nodes;
2776
+ try {
2777
+ nodes = units.flatMap((u) => {
2778
+ if (!view)
2779
+ return [unitNode(source, file, u, all)];
2780
+ return viewResolve(source, startDoc, u, viewRoot).map((res) => {
2781
+ const node = unitNode(res.text, res.doc, res.unit, res.all);
2782
+ // Provenance is mandatory (§4): the node's references and relative
2783
+ // paths resolve against ITS document, not the one asked about. A
2784
+ // whole-document target has no `#`, so it carries `doc` alone.
2785
+ if (res.from !== "") {
2786
+ const h = res.from.lastIndexOf("#");
2787
+ node["from"] = h < 0 ? { doc: res.from }
2788
+ : { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
2789
+ }
2790
+ return node;
2791
+ });
2792
+ });
2793
+ }
2794
+ catch (e) {
2795
+ if (e instanceof ViewError)
2796
+ fail(e.message, 1);
2797
+ throw e;
2798
+ }
2799
+ console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
2180
2800
  return;
2181
2801
  }
2182
2802
  if (units.length > 1)
2183
2803
  reportMatches(units[0].type ?? "", units);
2804
+ if (view) {
2805
+ // All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
2806
+ // chain that breaks halfway cannot leave a partial read on stdout for a
2807
+ // caller that ignores the exit code. Partial scenery is not scenery.
2808
+ const out = [];
2809
+ const notes = [];
2810
+ try {
2811
+ for (const u of units) {
2812
+ for (const res of viewResolve(source, startDoc, u, viewRoot)) {
2813
+ if (res.from !== "")
2814
+ notes.push(`view: ${rawSel} -> ${res.from}`);
2815
+ out.push(sliceUnit(res.text, res.unit.span, headOnly, bodyOnly));
2816
+ }
2817
+ }
2818
+ }
2819
+ catch (e) {
2820
+ // A chain that cannot reach an entity block is a failed READ, reported the
2821
+ // way `get` reports a selector that matches nothing: one line, exit 1.
2822
+ if (e instanceof ViewError)
2823
+ fail(e.message, 1);
2824
+ throw e;
2825
+ }
2826
+ for (const n of notes)
2827
+ console.error(n);
2828
+ process.stdout.write(out.join(""));
2829
+ return;
2830
+ }
2184
2831
  for (const u of units)
2185
2832
  process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
2186
2833
  }
@@ -2208,6 +2855,12 @@ function runSet(args) {
2208
2855
  const bodyOnly = args.includes("--body");
2209
2856
  if (headOnly && bodyOnly)
2210
2857
  fail("--head and --body are mutually exclusive", 2);
2858
+ // `--view` reads THROUGH an embed (see runGet). Writing through one would mean
2859
+ // one `set` silently editing a different file, so it is refused rather than
2860
+ // ignored — and the message has to point the way, not just say no.
2861
+ if (args.includes("--view")) {
2862
+ fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
2863
+ }
2211
2864
  const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
2212
2865
  if (!file)
2213
2866
  fail(SUBHELP.set);
@@ -2934,6 +3587,105 @@ function runMcp(args) {
2934
3587
  const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
2935
3588
  process.exit(r.status ?? 1);
2936
3589
  }
3590
+ // geml skill install: one command that makes GEML usable everywhere for a
3591
+ // Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
3592
+ // the CLI on the global PATH, and the MCP server registered at user scope.
3593
+ // Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
3594
+ // sidecars. Idempotent, so re-running after an upgrade refreshes everything.
3595
+ function runSkill(args) {
3596
+ const sub = args[0];
3597
+ if (sub !== "install")
3598
+ fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
3599
+ const rest = args.slice(1);
3600
+ const flag = (name) => {
3601
+ const i = rest.indexOf(name);
3602
+ if (i >= 0)
3603
+ rest.splice(i, 1);
3604
+ return i >= 0;
3605
+ };
3606
+ const opt = (name) => {
3607
+ const i = rest.indexOf(name);
3608
+ if (i < 0)
3609
+ return undefined;
3610
+ const v = rest[i + 1];
3611
+ if (!v)
3612
+ fail(`${name} needs a value.\n${SUBHELP.skill}`);
3613
+ rest.splice(i, 2);
3614
+ return v;
3615
+ };
3616
+ const noGlobal = flag("--no-global");
3617
+ const noMcp = flag("--no-mcp");
3618
+ const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
3619
+ if (rest.length)
3620
+ fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
3621
+ // The skill ships inside the npm package, next to dist/ — the installed
3622
+ // skill text always matches the CLI version it teaches.
3623
+ const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
3624
+ if (!existsSync(join(src, "SKILL.md")))
3625
+ fail(`bundled skill not found at ${src} (broken install?)`, 1);
3626
+ const target = join(dest, "geml");
3627
+ const copied = [];
3628
+ const copyTree = (from, to) => {
3629
+ mkdirSync(to, { recursive: true });
3630
+ for (const e of readdirSync(from, { withFileTypes: true })) {
3631
+ // Never ship a history sidecar — skill and config docs carry none.
3632
+ if (e.name.endsWith(".gemlhistory"))
3633
+ continue;
3634
+ const f = join(from, e.name);
3635
+ const t = join(to, e.name);
3636
+ if (e.isDirectory())
3637
+ copyTree(f, t);
3638
+ else {
3639
+ copyFileSync(f, t);
3640
+ copied.push(relative(dest, t));
3641
+ }
3642
+ }
3643
+ };
3644
+ try {
3645
+ copyTree(src, target);
3646
+ }
3647
+ catch (e) {
3648
+ // A clean one-liner, never a raw stack: --dest may name a file, a
3649
+ // read-only tree, or a path whose ancestor is not a directory.
3650
+ fail(`cannot install skill to ${target}: ${e instanceof Error ? e.message : String(e)}`, 1);
3651
+ }
3652
+ console.log(`skill installed -> ${target} (${copied.join(", ")})`);
3653
+ // Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
3654
+ // below is a fixed literal, so shell:true adds no injection surface.
3655
+ const sh = process.platform === "win32";
3656
+ const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
3657
+ if (!noGlobal) {
3658
+ const have = run("geml", ["--version"]);
3659
+ if (have.status === 0) {
3660
+ console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
3661
+ }
3662
+ else {
3663
+ console.log("cli installing @geml/geml globally (npm i -g)...");
3664
+ const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
3665
+ if (r.status !== 0)
3666
+ console.error("cli global install failed — install later with: npm i -g @geml/geml");
3667
+ }
3668
+ }
3669
+ if (!noMcp) {
3670
+ const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
3671
+ const claude = run("claude", ["--version"]);
3672
+ if (claude.status !== 0) {
3673
+ console.log(`mcp claude CLI not found — register later with: ${REG}`);
3674
+ }
3675
+ else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
3676
+ console.log("mcp server 'geml' already registered");
3677
+ }
3678
+ else {
3679
+ const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
3680
+ if (r.status === 0)
3681
+ console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
3682
+ else
3683
+ console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
3684
+ }
3685
+ }
3686
+ console.log("done — new Claude Code sessions pick up the skill.");
3687
+ process.exit(0);
3688
+ }
2937
3689
  // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
2938
3690
  // CLI" by resolving argv[1] to its real path, not by its spelling.
2939
3691
  const entry = (() => {
@@ -3006,6 +3758,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
3006
3758
  else if (cmd === "mcp") {
3007
3759
  runMcp(argv.slice(1));
3008
3760
  }
3761
+ else if (cmd === "skill") {
3762
+ runSkill(argv.slice(1));
3763
+ }
3009
3764
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
3010
3765
  // A bare word that is neither a known command nor a path is almost always
3011
3766
  // a mistyped command — say so, don't try to read it as a file. (The