@geml/geml 1.6.0 → 1.7.1

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 });
@@ -720,6 +909,12 @@ function validateProjections(children, ctx, opts) {
720
909
  function relJoinPath(base, target) {
721
910
  if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
722
911
  return target;
912
+ // A POSIX-absolute base must stay absolute. The segment loop below drops empty
913
+ // segments, and the leading "" of "/tmp/x" IS the root — dropping it silently
914
+ // turned `/tmp/x/part.geml` into the relative `tmp/x/part.geml`, which then
915
+ // resolved against the wrong directory. Windows never showed it: a `C:\…` base
916
+ // has no "/", so relDirPath returns "" and the early return above takes over.
917
+ const rooted = base.startsWith("/");
723
918
  const out = [];
724
919
  for (const s of (base + "/" + target).split("/")) {
725
920
  if (s === "" || s === ".")
@@ -729,12 +924,117 @@ function relJoinPath(base, target) {
729
924
  else
730
925
  out.push(s);
731
926
  }
732
- return out.join("/");
927
+ return (rooted ? "/" : "") + out.join("/");
733
928
  }
734
929
  function relDirPath(p) {
735
930
  const i = p.lastIndexOf("/");
736
931
  return i < 0 ? "" : p.slice(0, i);
737
932
  }
933
+ // A chain that cannot reach an entity block. Carries the diagnostic code it
934
+ // corresponds to (§3) so the message can name it without inventing a new one.
935
+ class ViewError extends Error {
936
+ code;
937
+ constructor(code, message) {
938
+ super(message);
939
+ this.code = code;
940
+ }
941
+ }
942
+ // Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
943
+ // content, so without a confinement root a document could name any path on the
944
+ // machine. And never a URL — `geml get` is a read command that agents and
945
+ // editors call constantly, so letting content steer it at the network would turn
946
+ // it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
947
+ function readConfined(rel, root) {
948
+ if (!/\.geml$/i.test(rel)) {
949
+ throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
950
+ }
951
+ const base = resolvePath(root);
952
+ const abs = resolvePath(root, rel);
953
+ if (abs !== base && !abs.startsWith(base + sep)) {
954
+ throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
955
+ }
956
+ try {
957
+ return readFileSync(abs, "utf8");
958
+ }
959
+ catch {
960
+ throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
961
+ }
962
+ }
963
+ // One hop: read the target document and select what the fragment names. Several
964
+ // units come back when the fragment names a section (§4.3).
965
+ function oneHop(file, src, root) {
966
+ const hash = src.indexOf("#");
967
+ const docPath = hash < 0 ? src : src.slice(0, hash);
968
+ const frag = hash < 0 ? undefined : src.slice(hash + 1);
969
+ // Check the scheme on what the DOCUMENT wrote, before composition: a URL can
970
+ // only arrive through `src=`, never from joining relative paths — and testing
971
+ // the composed path instead would read a Windows drive letter (`C:/…`) as a
972
+ // scheme and refuse every absolute path, which is exactly what the MCP layer
973
+ // hands the CLI.
974
+ if (schemeOf(docPath) !== null) {
975
+ throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
976
+ }
977
+ const rel = relJoinPath(relDirPath(file), docPath);
978
+ const text = readConfined(rel, root);
979
+ if (frag === undefined) {
980
+ // `src=other.geml`: the frame looks onto the WHOLE document. Every block
981
+ // comes from the same target, so the resolution base stays uniform — unlike
982
+ // a host-side section selector, where splicing would mix two documents.
983
+ // `meta` is frontmatter, not content (render.ts's selectEmbed).
984
+ //
985
+ // Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
986
+ // every addressed unit would emit the blocks inside a section twice.
987
+ const every = addressedUnits(text).map((a) => a.unit);
988
+ const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
989
+ && (o.span.start < u.span.start || o.span.end > u.span.end)));
990
+ return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
991
+ }
992
+ const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
993
+ return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
994
+ }
995
+ // Provenance is stated relative to the confinement root, not as the path the
996
+ // walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
997
+ // so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
998
+ // server's layout, and not a path any caller could pass back in.
999
+ function shownPath(rel, root) {
1000
+ const r = relative(root, rel).replace(/\\/g, "/");
1001
+ return r === "" ? rel : r;
1002
+ }
1003
+ function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
1004
+ const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
1005
+ if (src === undefined)
1006
+ return [{ doc: file, text: source, unit, all: [], from: "" }];
1007
+ // The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
1008
+ // cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
1009
+ // not expanded — `--view` may not: stopping here means what we are holding is
1010
+ // still a frame, and returning it would break the contract silently.
1011
+ if (depth >= EMBED_DEPTH_LIMIT) {
1012
+ throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
1013
+ }
1014
+ const hop = oneHop(file, src, root);
1015
+ // Same key shape as the check's cycle detector: a document plus what was
1016
+ // selected in it.
1017
+ const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
1018
+ if (seen.has(key)) {
1019
+ throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
1020
+ }
1021
+ const nextSeen = new Set(seen).add(key);
1022
+ // Per-unit application, recursively: what a frame looks onto may itself be a
1023
+ // frame, and a section may hold a mix (§4.3).
1024
+ return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
1025
+ // An inner identity step has no provenance of its own, so carry this hop's:
1026
+ // `from` must always name where the bytes actually came from.
1027
+ .map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
1028
+ }
1029
+ // The `src=` of an embed unit, read off its head line: a Unit carries the span,
1030
+ // not parsed attributes.
1031
+ function embedSrcOf(source, unit) {
1032
+ const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, true, false));
1033
+ if (!braces)
1034
+ return undefined;
1035
+ const v = parseAttrs(braces[0]).attrs["src"];
1036
+ return typeof v === "string" ? v : undefined;
1037
+ }
738
1038
  function gatherEmbeds(source) {
739
1039
  const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
740
1040
  scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
@@ -751,6 +1051,11 @@ function tableFromDocument(source, id) {
751
1051
  const found = ctx.tables?.get(id);
752
1052
  if (found !== undefined)
753
1053
  return found;
1054
+ // GEP-0005: a remote `data` block is the other chart-source form; its value
1055
+ // is projected by the CALLER (recordsToTable needs the chart's attributes).
1056
+ const dv = ctx.dataValues?.get(id);
1057
+ if (dv !== undefined)
1058
+ return { records: dv };
754
1059
  const anyBlock = (function find(bs) {
755
1060
  for (const b of bs) {
756
1061
  if ((b.kind === "block" || b.kind === "heading") && b.id === id)
@@ -847,7 +1152,11 @@ function resolveTableSources(ctx, opts) {
847
1152
  err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
848
1153
  continue;
849
1154
  }
850
- if (remote === "not-a-table") {
1155
+ // A table's `src=` names a TABLE. A `data` block is a chart-source form
1156
+ // (§7.1, GEP-0005), not a table-source form — the column algebra a
1157
+ // borrowing table implies (compute/summary against named columns) has
1158
+ // no defined meaning over a value tree.
1159
+ if (remote === "not-a-table" || "records" in remote) {
851
1160
  err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
852
1161
  continue;
853
1162
  }
@@ -923,6 +1232,213 @@ function validateRefs(ctx, opts) {
923
1232
  }
924
1233
  }
925
1234
  }
1235
+ // `src=` on a `code` block: the route to the code the block shows,
1236
+ // `<path>[#L<start>[-<end>]]` (1-based, inclusive). The code-graph runtime has
1237
+ // always fetched and sliced this at render time; resolving it here is what
1238
+ // turns a range that no longer exists — the source moved or shrank — from a
1239
+ // silently empty panel into a build error. There is no extension gate (code is
1240
+ // any language); the safety rule is the resolver's confinement to the document
1241
+ // tree, widened only by `--root`.
1242
+ const SOURCE_RANGE = /^L(\d+)(?:-(\d+))?$/;
1243
+ // One route syntax for the two types whose `src=` fragment position is free —
1244
+ // `code` and `data`. (A table's is already taken: `src=doc.geml#id` names a
1245
+ // block.) `<path>[#L<start>[-<end>]]`, 1-based and inclusive; `to === 0` means
1246
+ // "through end of file". Returns null after reporting, so callers just skip.
1247
+ function parseSourceRoute(target, kind, line, ctx) {
1248
+ const hash = target.indexOf("#");
1249
+ const path = hash < 0 ? target : target.slice(0, hash);
1250
+ const frag = hash < 0 ? "" : target.slice(hash + 1);
1251
+ if (frag === "")
1252
+ return { path, from: 1, to: 0 };
1253
+ const m = SOURCE_RANGE.exec(frag);
1254
+ if (!m) {
1255
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: unrecognised fragment (expected \`#L<start>\` or \`#L<start>-<end>\`)`, line });
1256
+ return null;
1257
+ }
1258
+ const from = Number(m[1]);
1259
+ const to = m[2] === undefined ? from : Number(m[2]);
1260
+ if (from < 1 || to < from) {
1261
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: line range is empty or starts before line 1`, line });
1262
+ return null;
1263
+ }
1264
+ return { path, from, to };
1265
+ }
1266
+ // Slice a resolved file to a route's range, or report that the range no longer
1267
+ // exists — the signal a stale reference exists at all.
1268
+ function sliceSourceRange(text, route, target, kind, line, ctx) {
1269
+ const all = normalizeSource(text).split("\n");
1270
+ // A trailing newline yields a final empty element; it is not a line.
1271
+ if (all.length > 0 && all[all.length - 1] === "")
1272
+ all.pop();
1273
+ if (route.to > all.length) {
1274
+ 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 });
1275
+ return null;
1276
+ }
1277
+ return all.slice(route.from - 1, route.to === 0 ? all.length : route.to);
1278
+ }
1279
+ function resolveCodeSources(ctx, opts) {
1280
+ for (const { block, line, target } of ctx.codeSources ?? []) {
1281
+ const scheme = schemeOf(target);
1282
+ // A remote source is fetched by the RENDERER (§9.4), as for a table.
1283
+ if (scheme === "http" || scheme === "https")
1284
+ continue;
1285
+ if (scheme !== null) {
1286
+ ctx.diags.push({ severity: "error", code: "bad-code-source", message: `code source \`${target}\` names a disallowed URL scheme`, line });
1287
+ continue;
1288
+ }
1289
+ const route = parseSourceRoute(target, "code", line, ctx);
1290
+ if (route === null)
1291
+ continue;
1292
+ const { path, from, to } = route;
1293
+ if (!opts.resolveDoc) {
1294
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `code source \`${target}\` not checked (no document resolver)`, line });
1295
+ continue;
1296
+ }
1297
+ const text = opts.resolveDoc(path);
1298
+ if (text === null) {
1299
+ // A WARNING, not an error, and the code/value model split is the reason:
1300
+ // a value that cannot be loaded is a promise the document failed to keep
1301
+ // (an error — see `unresolvable-data-source`), while a code region that
1302
+ // cannot be reached right now is still a code region at a location. A
1303
+ // generated code graph read away from its sources — published on its own,
1304
+ // or describing another checkout — must stay valid, exactly as the
1305
+ // render-time runtime degrades to showing the path.
1306
+ ctx.diags.push({ severity: "warning", code: "unresolvable-code-source", message: `cannot resolve code source \`${path}\` — not checked`, line });
1307
+ continue;
1308
+ }
1309
+ const slice = sliceSourceRange(text, { from, to }, target, "code", line, ctx);
1310
+ if (slice === null)
1311
+ continue;
1312
+ const hasBody = (block.raw ?? []).some((l) => l.trim() !== "");
1313
+ if (!hasBody) {
1314
+ block.raw = slice;
1315
+ }
1316
+ else if ((block.raw ?? []).join("\n") !== slice.join("\n")) {
1317
+ // A body alongside `src=` is a cached snapshot, kept for offline reading.
1318
+ // Silence would let the two drift — the very thing the route prevents.
1319
+ 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 });
1320
+ }
1321
+ }
1322
+ }
1323
+ // GEP-0005: `src=` on a `data` block names external content — the same
1324
+ // external-source discipline tables have (§6, §9.4): an http(s) source is
1325
+ // fetched by the RENDERER, never the parser (the block defers, and so does a
1326
+ // chart over it); any other scheme is refused; the file must look like data
1327
+ // (`.json`/`.jsonl`); a missing resolver leaves it unchecked with a warning.
1328
+ function resolveDataSources(ctx, opts) {
1329
+ for (const { block, line, target } of ctx.dataSources ?? []) {
1330
+ const defer = () => { if (block.id !== undefined)
1331
+ (ctx.dataSrcPending ??= new Set()).add(block.id); };
1332
+ const scheme = schemeOf(target);
1333
+ if (scheme === "http" || scheme === "https") {
1334
+ defer();
1335
+ continue;
1336
+ }
1337
+ if (scheme !== null) {
1338
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `data source \`${target}\` names a disallowed URL scheme`, line });
1339
+ continue;
1340
+ }
1341
+ // The route shares `code`'s syntax (§3.2): a line range MAY narrow the file.
1342
+ const route = parseSourceRoute(target, "data", line, ctx);
1343
+ if (route === null)
1344
+ continue;
1345
+ const { path } = route;
1346
+ // A data source is data — the same shape rule table sources enforce, so
1347
+ // the loader cannot be pointed at a `.env` or a private key.
1348
+ if (!/\.(json|jsonl)$/i.test(path)) {
1349
+ ctx.diags.push({ severity: "error", code: "bad-data-source", message: `data source \`${path}\` is not a \`.json\`/\`.jsonl\` data file`, line });
1350
+ continue;
1351
+ }
1352
+ // Explicit format= wins; otherwise the (already-gated) extension names it.
1353
+ const fmtAttr = block.attrs["format"];
1354
+ const fmt = typeof fmtAttr === "string" ? fmtAttr : /\.jsonl$/i.test(path) ? "jsonl" : "json";
1355
+ // A range narrows the file to lines; what those lines mean is then the
1356
+ // format's business, unchanged. Slicing a `jsonl` log is the obvious use;
1357
+ // slicing a `json` file works whenever the slice is itself a value, and
1358
+ // when it is not, the ordinary data-parse error already names the line.
1359
+ // No extra rule.
1360
+ if (!opts.resolveDoc) {
1361
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `data source \`${target}\` not checked (no document resolver)`, line });
1362
+ defer();
1363
+ continue;
1364
+ }
1365
+ const text = opts.resolveDoc(path);
1366
+ if (text === null) {
1367
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `cannot resolve data source \`${path}\``, line });
1368
+ continue;
1369
+ }
1370
+ const lines = sliceSourceRange(text, route, target, "data", line, ctx);
1371
+ if (lines === null)
1372
+ continue;
1373
+ const parsed = parseDataBody(fmt, lines, line);
1374
+ for (const d of parsed.diags)
1375
+ ctx.diags.push(d);
1376
+ if (parsed.value !== undefined) {
1377
+ block.value = parsed.value;
1378
+ if (block.id !== undefined && !ctx.dataValues?.has(block.id)) {
1379
+ (ctx.dataValues ??= new Map()).set(block.id, parsed.value);
1380
+ }
1381
+ }
1382
+ }
1383
+ }
1384
+ // GEP-0005: a chart's `data=` may target a `data` block whose value is a
1385
+ // RECORD ARRAY — a non-empty array of objects. Keys project to columns in
1386
+ // first-seen order; every column the chart actually references (x/y/size/
1387
+ // series) must be present with a SCALAR value in every record, and a
1388
+ // violation is an error naming the first offending record. Columns the chart
1389
+ // does not reference may hold anything (nested values project as compact
1390
+ // JSON text). The projection feeds the unchanged table machinery.
1391
+ function recordsToTable(value, attrs, line, ctx) {
1392
+ const fail = (msg) => {
1393
+ ctx.diags.push({ severity: "error", code: "chart-data-not-records", message: `geml-chart: ${msg}`, line });
1394
+ return null;
1395
+ };
1396
+ if (!Array.isArray(value) || value.length === 0)
1397
+ return fail("data target is not a non-empty record array");
1398
+ const columns = [];
1399
+ for (let i = 0; i < value.length; i++) {
1400
+ const r = value[i];
1401
+ if (r === null || typeof r !== "object" || Array.isArray(r))
1402
+ return fail(`record ${i + 1} is not an object`);
1403
+ for (const k of Object.keys(r))
1404
+ if (!columns.includes(k))
1405
+ columns.push(k);
1406
+ }
1407
+ // Only the channels this chart TYPE reads are "referenced" (§7.1): a stray
1408
+ // size= on a bar chart is buildChart's chart-unused-channel WARNING, and the
1409
+ // projection must not turn it into an error a table source would not raise.
1410
+ // An unknown/missing type validates x/y only; buildChart reports the type.
1411
+ const typeAttr = String(attrs["type"] ?? "");
1412
+ const uses = USES[typeAttr] ?? new Set(["x", "y"]);
1413
+ const channels = [];
1414
+ for (const c of ["x", "y", "size", "series"]) {
1415
+ if (!uses.has(c))
1416
+ continue;
1417
+ const v = attrs[c];
1418
+ if (typeof v === "string")
1419
+ for (const name of v.split(",").map((s) => s.trim()).filter(Boolean))
1420
+ channels.push(name);
1421
+ }
1422
+ for (const col of channels) {
1423
+ for (let i = 0; i < value.length; i++) {
1424
+ const v = value[i][col];
1425
+ if (v === undefined || v === null || typeof v === "object") {
1426
+ return fail(`column \`${col}\` is missing or non-scalar in record ${i + 1}`);
1427
+ }
1428
+ }
1429
+ }
1430
+ const rows = value.map((r) => columns.map((c) => {
1431
+ const v = r[c];
1432
+ const text = v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
1433
+ // Data, not prose: cells carry plain-text inlines, never inline-parsed —
1434
+ // the same treatment `format=csv` cells get (a `*` in a value is a `*`).
1435
+ const cell = { text, inlines: text === "" ? [] : [{ type: "text", value: text }] };
1436
+ if (typeof v === "number" && Number.isFinite(v))
1437
+ cell.value = v;
1438
+ return cell;
1439
+ }));
1440
+ return { header: true, columns, align: columns.map(() => undefined), rows };
1441
+ }
926
1442
  // §7: resolve every geml-chart against its referenced table. Runs after the
927
1443
  // scan so that `data=#id` may point at a table defined anywhere in the doc.
928
1444
  function resolveCharts(ctx, opts) {
@@ -942,6 +1458,21 @@ function resolveCharts(ctx, opts) {
942
1458
  let table;
943
1459
  if (docPath === "") {
944
1460
  table = ctx.tables?.get(id);
1461
+ if (!table && ctx.dataValues?.has(id)) {
1462
+ // GEP-0005: the target is a `data` block. A RECORD ARRAY projects to
1463
+ // the table model (keys -> columns) and feeds the unchanged chart
1464
+ // machinery, so column checks and rendering stay single-sourced.
1465
+ const projected = recordsToTable(ctx.dataValues.get(id), block.attrs, line, ctx);
1466
+ if (projected === null)
1467
+ continue; // reported by the projection
1468
+ table = projected;
1469
+ }
1470
+ if (!table && ctx.dataSrcPending?.has(id)) {
1471
+ // GEP-0005: the target is a `data` block whose `src=` is render-time
1472
+ // (http, or no resolver) — defer exactly like a src table with no
1473
+ // columns; the renderer checks it when the data actually arrives.
1474
+ continue;
1475
+ }
945
1476
  if (!table) {
946
1477
  // A chart is a view of a table, and a data file is one of the three ways
947
1478
  // §6 lets a table name its content. So `data=rows.csv` desugars: it is an
@@ -955,8 +1486,36 @@ function resolveCharts(ctx, opts) {
955
1486
  continue; // already reported by the table rules
956
1487
  table = sugar;
957
1488
  }
1489
+ else if (hash < 0 && /\.(json|jsonl)$/i.test(id) && schemeOf(id) === null) {
1490
+ // GEP-0005 sugar, the json/jsonl twin of the csv path: an anonymous
1491
+ // LOCAL data source projected through the record-array rules. A
1492
+ // remote URL needs a NAMED `data` block with `src=` — its fetch is
1493
+ // render-time, and an anonymous source has no block to defer on.
1494
+ if (!opts.resolveDoc) {
1495
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${id}\` not checked (no document resolver)`, line });
1496
+ continue;
1497
+ }
1498
+ const text = opts.resolveDoc(id);
1499
+ if (text === null) {
1500
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `geml-chart: cannot resolve data source \`${id}\``, line });
1501
+ continue;
1502
+ }
1503
+ const parsed = parseDataBody(/\.jsonl$/i.test(id) ? "jsonl" : "json", normalizeSource(text).split("\n"), line);
1504
+ for (const d of parsed.diags)
1505
+ ctx.diags.push(d);
1506
+ if (parsed.value === undefined)
1507
+ continue;
1508
+ const projected = recordsToTable(parsed.value, block.attrs, line, ctx);
1509
+ if (projected === null)
1510
+ continue; // reported by the projection
1511
+ table = projected;
1512
+ }
1513
+ else if (hash < 0 && /\.(json|jsonl)$/i.test(id)) {
1514
+ 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 });
1515
+ continue;
1516
+ }
958
1517
  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 });
1518
+ 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
1519
  continue;
961
1520
  }
962
1521
  else {
@@ -984,10 +1543,18 @@ function resolveCharts(ctx, opts) {
984
1543
  continue;
985
1544
  }
986
1545
  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 });
1546
+ 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
1547
  continue;
989
1548
  }
990
- table = remote;
1549
+ if ("records" in remote) {
1550
+ const projected = recordsToTable(remote.records, block.attrs, line, ctx);
1551
+ if (projected === null)
1552
+ continue; // reported by the projection
1553
+ table = projected;
1554
+ }
1555
+ else {
1556
+ table = remote;
1557
+ }
991
1558
  }
992
1559
  if (table.src !== undefined && table.columns.length === 0) {
993
1560
  // §6: the table names a source whose data did not arrive at build time — a
@@ -1012,6 +1579,8 @@ export function parse(source, opts = {}) {
1012
1579
  // Table sources first: a chart reads the build-time model of the table it
1013
1580
  // charts, so that model has to be filled before charts are resolved.
1014
1581
  resolveTableSources(ctx, opts);
1582
+ resolveDataSources(ctx, opts);
1583
+ resolveCodeSources(ctx, opts);
1015
1584
  resolveCharts(ctx, opts);
1016
1585
  validateRefs(ctx, opts);
1017
1586
  detectTransclusionCycles(ctx, opts);
@@ -1319,6 +1888,8 @@ Usage:
1319
1888
  --to <output>: json | html | md | geml
1320
1889
  --to md -> Markdown (lossy)
1321
1890
  --to html -> self-contained HTML
1891
+ --to html --fragment -> body-only markup, no page shell
1892
+ (embed in your own layout; assets via pageAssets)
1322
1893
  --to geml -> canonical re-format
1323
1894
  --to json -> document-model JSON (default)
1324
1895
  --from <input>: geml | md | json (overrides extension; html is output-only)
@@ -1351,6 +1922,10 @@ Usage:
1351
1922
  set/add/delete/rename/revert; every write is validated before it
1352
1923
  reaches disk. A code graph under --root adds four read-only
1353
1924
  geml_codemap_* tools to the same server)
1925
+ geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
1926
+ (authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
1927
+ MCP server registered at user scope; touches no settings.json,
1928
+ installs no hooks; idempotent — re-run to update)
1354
1929
  geml --help | --version [--json]
1355
1930
 
1356
1931
  Use '-' as the file to read from stdin.
@@ -1364,7 +1939,7 @@ Exit codes:
1364
1939
  // One-line usage for each subcommand — the single source for both the error
1365
1940
  // shown on misuse and the `<cmd> --help` text.
1366
1941
  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)",
1942
+ 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
1943
  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
1944
  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
1945
  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 +1983,19 @@ const SUBHELP = {
1408
1983
 
1409
1984
  Register with a client:
1410
1985
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
1986
+ skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp]
1987
+
1988
+ One command, three things, all user-global — so any Claude Code session can
1989
+ author, validate, and blockwise-edit GEML:
1990
+ 1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
1991
+ 2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
1992
+ 3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
1993
+ Touches no settings.json and installs no hooks. Idempotent — re-run after an
1994
+ upgrade to refresh the skill text alongside the CLI it teaches.
1995
+
1996
+ --dest <dir> install the skill under <dir> instead of ~/.claude/skills
1997
+ --no-global skip the global npm install
1998
+ --no-mcp skip the MCP server registration`,
1411
1999
  };
1412
2000
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
1413
2001
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -1486,7 +2074,19 @@ function resolverFor(file, root) {
1486
2074
  return null;
1487
2075
  // References resolve FROM the document's own directory; the gates below
1488
2076
  // confine them to the (possibly widened) base.
1489
- const targetAbs = resolvePath(dirAbs, d);
2077
+ let targetAbs = resolvePath(dirAbs, d);
2078
+ // A SOURCE route (`code`/`data` `src=`) may instead be written relative to
2079
+ // the resolution root — that is how the code-graph profile writes them
2080
+ // (`geml-parser/src/attrs.ts` from a document two levels down). So when
2081
+ // the document-relative path does not exist and a root was named, try the
2082
+ // root as the base. Only a widened `--root` can enable this, and both
2083
+ // confinement gates below still apply, so it cannot reach further than a
2084
+ // document-relative reference already could.
2085
+ if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
2086
+ const fromBase = resolvePath(baseAbs, d);
2087
+ if (existsSync(fromBase))
2088
+ targetAbs = fromBase;
2089
+ }
1490
2090
  // Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
1491
2091
  // before touching the filesystem.
1492
2092
  if (outside(baseAbs, targetAbs))
@@ -1556,18 +2156,6 @@ function historyError(e, file, historyPath) {
1556
2156
  }
1557
2157
  return err?.message ?? String(e);
1558
2158
  }
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
2159
  // Subcommand, file and revision, read positionally around the options —
1572
2160
  // `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
1573
2161
  // indexing read `--history` itself as the file.
@@ -1594,8 +2182,6 @@ function runHistory(args) {
1594
2182
  const [sub, file, rev, ...extra] = historyPositionals(args);
1595
2183
  if (!sub || !file)
1596
2184
  fail(SUBHELP.history);
1597
- if (RETIRED_HISTORY[sub])
1598
- fail(RETIRED_HISTORY[sub]);
1599
2185
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1600
2186
  const json = args.includes("--json");
1601
2187
  try {
@@ -1717,6 +2303,13 @@ function runTransform(argv) {
1717
2303
  const out = flag(argv, "-o") ?? flag(argv, "--out");
1718
2304
  const fromRaw = flag(argv, "--from");
1719
2305
  const toRaw = flag(argv, "--to");
2306
+ // `--to html --fragment`: body-only markup for embedding in an existing
2307
+ // layout (library parity: RenderOptions.fragment). Consumed here so it can
2308
+ // be rejected on any other target — a discarded flag is a silent lie.
2309
+ const fragIdx = argv.indexOf("--fragment");
2310
+ const fragment = fragIdx >= 0;
2311
+ if (fragment)
2312
+ argv.splice(fragIdx, 1);
1720
2313
  // Same `--root` as `check`, and for the same reason: cross-document resolution is
1721
2314
  // fail-closed at the document's own directory, so a reference that climbs out of
1722
2315
  // it needs the tree's root named. Without this the transform silently ignored the
@@ -1764,6 +2357,8 @@ function runTransform(argv) {
1764
2357
  else {
1765
2358
  outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
1766
2359
  }
2360
+ if (fragment && outFmt !== "html")
2361
+ fail("--fragment only applies to --to html", 2);
1767
2362
  const src = readInput(file);
1768
2363
  // md -> geml is a direct projection, not a parse/serialize round-trip: emit
1769
2364
  // the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
@@ -1800,6 +2395,7 @@ function runTransform(argv) {
1800
2395
  case "html":
1801
2396
  output = renderHtml(doc, {
1802
2397
  source: file === "-" ? "stdin" : basename(file),
2398
+ fragment,
1803
2399
  // geml-code-graph embeds load + parse sibling codemap docs on demand.
1804
2400
  loadDoc: resolverFor(file, root),
1805
2401
  parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
@@ -2148,7 +2744,8 @@ function runGet(args) {
2148
2744
  const json = args.includes("--json");
2149
2745
  const headOnly = args.includes("--head");
2150
2746
  const bodyOnly = args.includes("--body");
2151
- const [file, rawSel] = positionals(args, []);
2747
+ const view = args.includes("--view");
2748
+ const [file, rawSel] = positionals(args, ["--root"]);
2152
2749
  if (!file)
2153
2750
  fail(SUBHELP.get);
2154
2751
  if (headOnly && bodyOnly)
@@ -2171,16 +2768,72 @@ function runGet(args) {
2171
2768
  return;
2172
2769
  }
2173
2770
  const { units, all } = selectUnits(source, file, rawSel, where);
2771
+ // The chain is composed with `/` — relJoinPath's rule, and `src=` values are
2772
+ // always `/`-separated — so normalize the PLATFORM path at this boundary. On
2773
+ // Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
2774
+ // tell, and a relative `src=` resolves against the wrong base.
2775
+ const startDoc = where.replace(/\\/g, "/");
2776
+ const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
2174
2777
  if (json) {
2175
2778
  // §7: N matches yield N model nodes. The old `{kind:"blocks",
2176
2779
  // matches:[{lines}]}` coordinate envelope is gone — it answered "where are
2177
2780
  // 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));
2781
+ let nodes;
2782
+ try {
2783
+ nodes = units.flatMap((u) => {
2784
+ if (!view)
2785
+ return [unitNode(source, file, u, all)];
2786
+ return viewResolve(source, startDoc, u, viewRoot).map((res) => {
2787
+ const node = unitNode(res.text, res.doc, res.unit, res.all);
2788
+ // Provenance is mandatory (§4): the node's references and relative
2789
+ // paths resolve against ITS document, not the one asked about. A
2790
+ // whole-document target has no `#`, so it carries `doc` alone.
2791
+ if (res.from !== "") {
2792
+ const h = res.from.lastIndexOf("#");
2793
+ node["from"] = h < 0 ? { doc: res.from }
2794
+ : { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
2795
+ }
2796
+ return node;
2797
+ });
2798
+ });
2799
+ }
2800
+ catch (e) {
2801
+ if (e instanceof ViewError)
2802
+ fail(e.message, 1);
2803
+ throw e;
2804
+ }
2805
+ console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
2180
2806
  return;
2181
2807
  }
2182
2808
  if (units.length > 1)
2183
2809
  reportMatches(units[0].type ?? "", units);
2810
+ if (view) {
2811
+ // All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
2812
+ // chain that breaks halfway cannot leave a partial read on stdout for a
2813
+ // caller that ignores the exit code. Partial scenery is not scenery.
2814
+ const out = [];
2815
+ const notes = [];
2816
+ try {
2817
+ for (const u of units) {
2818
+ for (const res of viewResolve(source, startDoc, u, viewRoot)) {
2819
+ if (res.from !== "")
2820
+ notes.push(`view: ${rawSel} -> ${res.from}`);
2821
+ out.push(sliceUnit(res.text, res.unit.span, headOnly, bodyOnly));
2822
+ }
2823
+ }
2824
+ }
2825
+ catch (e) {
2826
+ // A chain that cannot reach an entity block is a failed READ, reported the
2827
+ // way `get` reports a selector that matches nothing: one line, exit 1.
2828
+ if (e instanceof ViewError)
2829
+ fail(e.message, 1);
2830
+ throw e;
2831
+ }
2832
+ for (const n of notes)
2833
+ console.error(n);
2834
+ process.stdout.write(out.join(""));
2835
+ return;
2836
+ }
2184
2837
  for (const u of units)
2185
2838
  process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
2186
2839
  }
@@ -2208,6 +2861,12 @@ function runSet(args) {
2208
2861
  const bodyOnly = args.includes("--body");
2209
2862
  if (headOnly && bodyOnly)
2210
2863
  fail("--head and --body are mutually exclusive", 2);
2864
+ // `--view` reads THROUGH an embed (see runGet). Writing through one would mean
2865
+ // one `set` silently editing a different file, so it is refused rather than
2866
+ // ignored — and the message has to point the way, not just say no.
2867
+ if (args.includes("--view")) {
2868
+ fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
2869
+ }
2211
2870
  const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
2212
2871
  if (!file)
2213
2872
  fail(SUBHELP.set);
@@ -2934,6 +3593,105 @@ function runMcp(args) {
2934
3593
  const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
2935
3594
  process.exit(r.status ?? 1);
2936
3595
  }
3596
+ // geml skill install: one command that makes GEML usable everywhere for a
3597
+ // Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
3598
+ // the CLI on the global PATH, and the MCP server registered at user scope.
3599
+ // Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
3600
+ // sidecars. Idempotent, so re-running after an upgrade refreshes everything.
3601
+ function runSkill(args) {
3602
+ const sub = args[0];
3603
+ if (sub !== "install")
3604
+ fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
3605
+ const rest = args.slice(1);
3606
+ const flag = (name) => {
3607
+ const i = rest.indexOf(name);
3608
+ if (i >= 0)
3609
+ rest.splice(i, 1);
3610
+ return i >= 0;
3611
+ };
3612
+ const opt = (name) => {
3613
+ const i = rest.indexOf(name);
3614
+ if (i < 0)
3615
+ return undefined;
3616
+ const v = rest[i + 1];
3617
+ if (!v)
3618
+ fail(`${name} needs a value.\n${SUBHELP.skill}`);
3619
+ rest.splice(i, 2);
3620
+ return v;
3621
+ };
3622
+ const noGlobal = flag("--no-global");
3623
+ const noMcp = flag("--no-mcp");
3624
+ const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
3625
+ if (rest.length)
3626
+ fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
3627
+ // The skill ships inside the npm package, next to dist/ — the installed
3628
+ // skill text always matches the CLI version it teaches.
3629
+ const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
3630
+ if (!existsSync(join(src, "SKILL.md")))
3631
+ fail(`bundled skill not found at ${src} (broken install?)`, 1);
3632
+ const target = join(dest, "geml");
3633
+ const copied = [];
3634
+ const copyTree = (from, to) => {
3635
+ mkdirSync(to, { recursive: true });
3636
+ for (const e of readdirSync(from, { withFileTypes: true })) {
3637
+ // Never ship a history sidecar — skill and config docs carry none.
3638
+ if (e.name.endsWith(".gemlhistory"))
3639
+ continue;
3640
+ const f = join(from, e.name);
3641
+ const t = join(to, e.name);
3642
+ if (e.isDirectory())
3643
+ copyTree(f, t);
3644
+ else {
3645
+ copyFileSync(f, t);
3646
+ copied.push(relative(dest, t));
3647
+ }
3648
+ }
3649
+ };
3650
+ try {
3651
+ copyTree(src, target);
3652
+ }
3653
+ catch (e) {
3654
+ // A clean one-liner, never a raw stack: --dest may name a file, a
3655
+ // read-only tree, or a path whose ancestor is not a directory.
3656
+ fail(`cannot install skill to ${target}: ${e instanceof Error ? e.message : String(e)}`, 1);
3657
+ }
3658
+ console.log(`skill installed -> ${target} (${copied.join(", ")})`);
3659
+ // Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
3660
+ // below is a fixed literal, so shell:true adds no injection surface.
3661
+ const sh = process.platform === "win32";
3662
+ const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
3663
+ if (!noGlobal) {
3664
+ const have = run("geml", ["--version"]);
3665
+ if (have.status === 0) {
3666
+ console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
3667
+ }
3668
+ else {
3669
+ console.log("cli installing @geml/geml globally (npm i -g)...");
3670
+ const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
3671
+ if (r.status !== 0)
3672
+ console.error("cli global install failed — install later with: npm i -g @geml/geml");
3673
+ }
3674
+ }
3675
+ if (!noMcp) {
3676
+ const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
3677
+ const claude = run("claude", ["--version"]);
3678
+ if (claude.status !== 0) {
3679
+ console.log(`mcp claude CLI not found — register later with: ${REG}`);
3680
+ }
3681
+ else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
3682
+ console.log("mcp server 'geml' already registered");
3683
+ }
3684
+ else {
3685
+ const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
3686
+ if (r.status === 0)
3687
+ console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
3688
+ else
3689
+ console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
3690
+ }
3691
+ }
3692
+ console.log("done — new Claude Code sessions pick up the skill.");
3693
+ process.exit(0);
3694
+ }
2937
3695
  // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
2938
3696
  // CLI" by resolving argv[1] to its real path, not by its spelling.
2939
3697
  const entry = (() => {
@@ -3006,6 +3764,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
3006
3764
  else if (cmd === "mcp") {
3007
3765
  runMcp(argv.slice(1));
3008
3766
  }
3767
+ else if (cmd === "skill") {
3768
+ runSkill(argv.slice(1));
3769
+ }
3009
3770
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
3010
3771
  // A bare word that is neither a known command nor a path is almost always
3011
3772
  // a mistyped command — say so, don't try to read it as a file. (The