@geml/geml 1.4.6 → 1.5.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
@@ -18,7 +18,7 @@ import { renderHtml } from "./render-html.js";
18
18
  import { normalizeBlockId } from "./block-edit.js";
19
19
  import { normalizeSource } from "./diagnostics.js";
20
20
  import { coerce, parseAttrs } from "./attrs.js";
21
- import { META_REF_SRC, parseInline } from "./inline.js";
21
+ import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
22
22
  import { parseTable } from "./table.js";
23
23
  import { buildChart } from "./chart.js";
24
24
  import { mdToGeml } from "./from-md.js";
@@ -46,7 +46,7 @@ const REGISTRY = {
46
46
  diagram: "raw",
47
47
  math: "raw",
48
48
  table: "raw", // structured table parsing lands in M3
49
- output: "raw", // captured result of a code block (stored, never executed)
49
+ embed: "raw", // block transclusion: `src=` points at the content, body unused
50
50
  note: "flow",
51
51
  text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
52
52
  meta: "data",
@@ -154,7 +154,7 @@ function registerId(ctx, id, line) {
154
154
  }
155
155
  }
156
156
  // §5: a list marker — `-`/`*` (unordered) or `N.` (ordered) — capturing the
157
- // leading indent (in spaces; a tab counts as one) and the item content. Nesting
157
+ // leading indent (in spaces; a tab counts as 4) and the item content. Nesting
158
158
  // is decided by that indent.
159
159
  const MARKER = /^([ \t]*)(?:[-*]|(\d+)\.)[ \t]+(.*)$/;
160
160
  function matchMarker(line) {
@@ -162,7 +162,14 @@ function matchMarker(line) {
162
162
  if (!m)
163
163
  return null;
164
164
  const ordered = m[2] !== undefined;
165
- const mk = { indent: m[1].length, ordered, rest: m[3] };
165
+ let indent = 0;
166
+ for (const ch of m[1]) {
167
+ if (ch === '\t')
168
+ indent += 4;
169
+ else
170
+ indent += 1;
171
+ }
172
+ const mk = { indent, ordered, rest: m[3] };
166
173
  if (ordered)
167
174
  mk.start = parseInt(m[2], 10);
168
175
  return mk;
@@ -249,7 +256,26 @@ function scanBlocks(lines, base, ctx, depth = 0) {
249
256
  const diags = ctx.diags;
250
257
  let i = 0;
251
258
  while (i < lines.length) {
252
- const line = lines[i];
259
+ let line = lines[i];
260
+ let consumed = 1;
261
+ // C-01: Attribute line continuation via `\`.
262
+ // If a line looks like a fence or heading and ends with `\`, fold subsequent lines.
263
+ if ((line.startsWith("===") || line.startsWith("#")) && line.endsWith("\\")) {
264
+ let folded = line.slice(0, -1).trimEnd();
265
+ while (i + consumed < lines.length) {
266
+ const next = lines[i + consumed].trim();
267
+ if (next.endsWith("\\")) {
268
+ folded += " " + next.slice(0, -1).trimEnd();
269
+ consumed++;
270
+ }
271
+ else {
272
+ folded += " " + next;
273
+ consumed++;
274
+ break;
275
+ }
276
+ }
277
+ line = folded;
278
+ }
253
279
  if (line.trim() === "") {
254
280
  i++;
255
281
  continue;
@@ -259,24 +285,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
259
285
  const hid = /^[ \t]*%%[ \t]?(.*)$/.exec(line);
260
286
  if (hid) {
261
287
  blocks.push({ kind: "hidden", text: hid[1] });
262
- i++;
263
- continue;
264
- }
265
- // §5.2: a Markdown-style footnote definition `[^id]: text` defines the
266
- // target a `[^id]` reference points at — recorded as a note block with that
267
- // id, so the reference resolves. (A model that reaches for Markdown
268
- // footnotes by habit then "just works" instead of leaving a dangling ref.)
269
- const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
270
- if (fndef) {
271
- const id = fndef[1].trim();
272
- const lineNo = base + i + 1;
273
- registerId(ctx, id, lineNo);
274
- const text = interpolate(fndef[2], lineNo, ctx);
275
- blocks.push({
276
- kind: "block", type: "note", mode: "flow", id, classes: ["footnote"], attrs: {},
277
- children: [{ kind: "paragraph", text, inlines: parseInline(text, lineNo, ctx) }],
278
- });
279
- i++;
288
+ i += consumed;
280
289
  continue;
281
290
  }
282
291
  const open = FENCE_OPEN.exec(line);
@@ -292,7 +301,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
292
301
  // safe way to nest (§3).
293
302
  const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
294
303
  const body = [];
295
- let j = i + 1;
304
+ let j = i + consumed;
296
305
  let closed = false;
297
306
  for (; j < lines.length; j++) {
298
307
  if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
@@ -310,6 +319,31 @@ function scanBlocks(lines, base, ctx, depth = 0) {
310
319
  diags.push({ severity: "warning", code: "unknown-block-type", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
311
320
  mode = "raw";
312
321
  }
322
+ else {
323
+ // `hidden` (§4) and `caption` (§4, and the label an auto-reference takes
324
+ // per §5.2) are not type-specific: every typed block may carry them. Only
325
+ // the extras below are per type.
326
+ let validRe;
327
+ if (type === "table")
328
+ validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
329
+ else if (type === "embed")
330
+ validRe = /^(src)$/;
331
+ else if (type === "diagram")
332
+ validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
333
+ // `src`/`anchor` on a `code` block are the code-graph profile's
334
+ // (docs/codemap-profile.md): every document `geml codemap build` writes
335
+ // carries them, so warning on them would warn on our own output.
336
+ else if (type === "code")
337
+ validRe = /^(lang|src|anchor|name|entry-via)$/;
338
+ else
339
+ validRe = /^$/;
340
+ const universal = /^(hidden|caption)$/;
341
+ for (const key of Object.keys(attrs.attrs)) {
342
+ if (!universal.test(key) && !validRe.test(key)) {
343
+ diags.push({ severity: "warning", code: "unknown-attribute", message: `unknown attribute \`${key}\` for block type \`${type}\``, line: openLineNo });
344
+ }
345
+ }
346
+ }
313
347
  const block = {
314
348
  kind: "block", type, mode, classes: attrs.classes, attrs: attrs.attrs,
315
349
  };
@@ -319,12 +353,50 @@ function scanBlocks(lines, base, ctx, depth = 0) {
319
353
  }
320
354
  if (attrs.attrs["hidden"] === true)
321
355
  block.hidden = true; // §4: not rendered, still in model
322
- // §3: an `output` block stores a code block's captured result; `of=#id`
323
- // (when present) binds it to that block and is checked like any reference.
324
- if (type === "output" && typeof attrs.attrs["of"] === "string") {
325
- const of = attrs.attrs["of"];
326
- if (of.startsWith("#"))
327
- ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
356
+ // Block transclusion: `src=` names the content this block stands for, and
357
+ // is registered as an ordinary reference so the existing §8 resolver
358
+ // validates the document and the id. Without that, an embed would be the
359
+ // one reference shape whose rot is silent.
360
+ if (type === "embed") {
361
+ const src = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : "";
362
+ if (src === "") {
363
+ diags.push({ severity: "error", code: "embed-missing-src", message: "embed: missing `src=`", line: openLineNo });
364
+ }
365
+ else {
366
+ const hash = src.indexOf("#");
367
+ const docPath = hash < 0 ? src : src.slice(0, hash);
368
+ const anchor = hash < 0 ? undefined : src.slice(hash + 1);
369
+ // §9.5: a destination naming a scheme outside the allowlist MUST NOT be
370
+ // emitted as a navigable or loadable target, and the check belongs HERE —
371
+ // when the model is built — so no consumer of the model can reintroduce
372
+ // it. The attribute is blanked as well as reported, the same treatment a
373
+ // media `src` already gets: a diagnostic alone would still leave the
374
+ // string in `attrs` for a renderer to put in an href.
375
+ if (!isSafeUrl(src)) {
376
+ diags.push({ severity: "error", code: "unsafe-embed-scheme", message: `embed: \`src=${src}\` names a disallowed URL scheme`, line: openLineNo });
377
+ block.attrs = { ...block.attrs, src: "" };
378
+ }
379
+ else if (docPath !== "" && !/\.geml$/i.test(docPath)) {
380
+ diags.push({ severity: "error", code: "embed-target-not-geml", message: `embed: \`${docPath}\` is not a GEML document; \`src=\` names a \`.geml\` file (optionally with a #fragment)`, line: openLineNo });
381
+ }
382
+ else if (docPath === "") {
383
+ // Recorded with an empty doc so the self-cycle pass can see it.
384
+ if (anchor !== undefined)
385
+ (ctx.embeds ??= []).push({ doc: "", anchor, line: openLineNo });
386
+ // `src=#id`: a block of THIS document. Validated against local ids.
387
+ if (anchor !== undefined)
388
+ ctx.refs.push({ kind: "internal", anchor, line: openLineNo });
389
+ }
390
+ else {
391
+ ctx.refs.push({ kind: "cross", doc: docPath, anchor, line: openLineNo });
392
+ // Kept apart from refs: a transclusion can pull in a document that
393
+ // transcludes further, so cycle detection has to walk the graph.
394
+ (ctx.embeds ??= []).push(anchor === undefined ? { doc: docPath, line: openLineNo } : { doc: docPath, anchor, line: openLineNo });
395
+ }
396
+ }
397
+ if (body.some((l) => l.trim() !== "")) {
398
+ diags.push({ severity: "warning", code: "ignored-embed-body", message: "embed body is ignored; the target lives in `src=`", line: openLineNo });
399
+ }
328
400
  }
329
401
  if (mode === "flow") {
330
402
  if (depth >= MAX_NESTING) {
@@ -344,8 +416,11 @@ function scanBlocks(lines, base, ctx, depth = 0) {
344
416
  else {
345
417
  block.raw = body;
346
418
  if (type === "table") {
419
+ const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
347
420
  // §6: parse the raw body (visual or csv/tsv) into one table model.
348
421
  const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
422
+ if (srcAttr !== undefined)
423
+ (ctx.tableSources ??= []).push({ block, line: openLineNo, target: srcAttr });
349
424
  block.table = model;
350
425
  for (const d of diagnostics)
351
426
  diags.push({ ...d, line: openLineNo });
@@ -394,9 +469,10 @@ function scanBlocks(lines, base, ctx, depth = 0) {
394
469
  if (h) {
395
470
  const lineNo = base + i + 1;
396
471
  const level = h[1].length;
397
- const a = h[3] ? parseAttrs(h[3]) : { classes: [], attrs: {} };
398
- const text = interpolate(h[2], lineNo, ctx);
399
- const id = a.id ?? slug(text);
472
+ const rawText = h[2];
473
+ const a = parseAttrs(h[3] ?? "");
474
+ const text = interpolate(rawText, lineNo, ctx);
475
+ const id = a.id ?? slug(rawText);
400
476
  registerId(ctx, id, lineNo);
401
477
  const block = {
402
478
  kind: "heading", level, text, inlines: parseInline(text, lineNo, ctx), id, classes: a.classes, attrs: a.attrs,
@@ -404,7 +480,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
404
480
  if (a.attrs["hidden"] === true)
405
481
  block.hidden = true;
406
482
  blocks.push(block);
407
- i++;
483
+ i += consumed;
408
484
  continue;
409
485
  }
410
486
  if (LIST_ITEM.test(line)) {
@@ -448,6 +524,342 @@ function parseData(lines) {
448
524
  // ---------------------------------------------------------------------------
449
525
  // Collect the block ids of a (cross-document) source, without validation, for
450
526
  // resolving `other.geml#id` references.
527
+ // S5/S6: a transclusion may pull in a document that transcludes further, so a
528
+ // cycle is only visible by walking the graph. Reported at check time — before
529
+ // any rendering — so a build fails on the cycle rather than on a placeholder in
530
+ // the output. Paths compose the way the renderer composes them: a target inside
531
+ // a borrowed document is relative to THAT document.
532
+ // `data=rows.csv` on a chart, desugared: the anonymous table it stands for. Built
533
+ // by handing the loaded lines to the SAME body parser a `=== table {src=…}` uses,
534
+ // with the chart's own `format=`/`header=` carried over, so nothing about how the
535
+ // data is read is specific to charts. Returns null when the source could not be
536
+ // resolved — the diagnostic is already pushed, in the table rules' own words.
537
+ function chartSourceTable(ctx, opts, block, target, line) {
538
+ const scheme = schemeOf(target);
539
+ if (scheme === "http" || scheme === "https") {
540
+ // §9.4: fetched at render time, so there is nothing to chart at build time —
541
+ // the same state a remote-sourced table leaves behind.
542
+ return null;
543
+ }
544
+ if (!opts.resolveDoc) {
545
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${target}\` not checked (no document resolver)`, line });
546
+ return null;
547
+ }
548
+ const text = opts.resolveDoc(target);
549
+ if (text === null) {
550
+ ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: cannot resolve data source \`${target}\``, line });
551
+ return null;
552
+ }
553
+ const attrs = {
554
+ format: typeof block.attrs["format-data"] === "string" ? block.attrs["format-data"] : inferDataFormat(target),
555
+ header: block.attrs["header"] === undefined ? true : block.attrs["header"],
556
+ };
557
+ const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
558
+ for (const d of diagnostics)
559
+ ctx.diags.push({ ...d, line });
560
+ model.src = target;
561
+ return model;
562
+ }
563
+ const inferDataFormat = (target) => (/\.tsv$/i.test(target) ? "tsv" : "csv");
564
+ // The renderer's own cap (render.ts EMBED_DEPTH_CAP). Kept in step here so the
565
+ // check and the render agree on which documents are reachable at all.
566
+ const EMBED_DEPTH_LIMIT = 8;
567
+ function detectTransclusionCycles(ctx, opts) {
568
+ if (!opts.resolveDoc || ctx.embeds === undefined || ctx.embeds.length === 0)
569
+ return;
570
+ const resolve = opts.resolveDoc;
571
+ const embedsOf = new Map(); // memoized per path
572
+ const reported = new Set();
573
+ // A three-colour DFS over DOCUMENTS, not over paths. Enumerating every path
574
+ // through the graph is exponential in its fan-out: a chain of 21 tiny files,
575
+ // each embedding the next three times, took over two minutes — and `check` is
576
+ // the CI gate and the validator every MCP write runs twice. Grey means "on the
577
+ // current stack" and is the cycle; black means already fully explored, so each
578
+ // edge is walked once and the whole traversal is O(V+E).
579
+ const colour = new Map();
580
+ const walk = (path, base, stack, line) => {
581
+ const rel = relJoinPath(base, path);
582
+ if (colour.get(rel) === "grey") {
583
+ const chain = [...stack, rel].join(" → ");
584
+ if (reported.has(chain))
585
+ return;
586
+ reported.add(chain);
587
+ ctx.diags.push({ severity: "error", code: "transclusion-cycle", message: `transclusion cycle: ${chain}`, line });
588
+ return;
589
+ }
590
+ if (colour.get(rel) === "black")
591
+ return;
592
+ // Agree with the renderer about what is even reachable, instead of exploring
593
+ // eight times deeper than it will ever expand.
594
+ if (stack.length >= EMBED_DEPTH_LIMIT)
595
+ return;
596
+ colour.set(rel, "grey");
597
+ let inner = embedsOf.get(rel);
598
+ if (inner === undefined) {
599
+ const src = resolve(rel);
600
+ inner = src === null ? [] : gatherEmbeds(src); // an unresolvable doc is already an error
601
+ embedsOf.set(rel, inner);
602
+ }
603
+ for (const e of inner)
604
+ walk(e.doc, relDirPath(rel), [...stack, rel], line);
605
+ colour.set(rel, "black");
606
+ };
607
+ // The root is named so a chain can be seen returning to it. Falling back to ""
608
+ // only loses the A→…→A case, which is what happened before `self` existed.
609
+ const root = opts.self ?? "";
610
+ for (const e of ctx.embeds)
611
+ walk(e.doc, relDirPath(root), [root], e.line);
612
+ }
613
+ // The smallest cycle of all, and the one the cross-document walk above cannot
614
+ // see: `=== embed {src=#sec}` written INSIDE the section `#sec` selects the slice
615
+ // that contains it. Decided on spans, so the boundary is exactly the one `geml
616
+ // get` uses — a heading id spans its whole section, so an embed anywhere in that
617
+ // section is inside its own target.
618
+ function detectSelfEmbedCycles(source, ctx) {
619
+ const selfEmbeds = (ctx.embeds ?? []).filter((e) => e.doc === "" && e.anchor !== undefined);
620
+ if (selfEmbeds.length === 0)
621
+ return;
622
+ const spans = blockSpans(source);
623
+ for (const e of selfEmbeds) {
624
+ const span = spans.get(e.anchor);
625
+ if (span === undefined)
626
+ continue; // a missing id is already an unresolved reference
627
+ const line = e.line - 1; // spans are 0-based line indices
628
+ if (line >= span.start && line <= span.end) {
629
+ ctx.diags.push({
630
+ severity: "error",
631
+ code: "transclusion-cycle",
632
+ message: `transclusion cycle: \`#${e.anchor}\` selects the content this embed is part of`,
633
+ line: e.line,
634
+ });
635
+ }
636
+ }
637
+ }
638
+ // A phrase that projects itself. The same shape as detectSelfEmbedCycles, and
639
+ // deliberately the same machinery rather than a second parallel one: decided on
640
+ // spans, so a projection written anywhere inside its own target is caught.
641
+ function detectSelfProjectionCycles(source, ctx) {
642
+ const local = (ctx.projections ?? []).filter((p) => p.doc === undefined);
643
+ if (local.length === 0)
644
+ return;
645
+ const spans = blockSpans(source);
646
+ for (const p of local) {
647
+ const span = spans.get(p.anchor);
648
+ if (span === undefined)
649
+ continue;
650
+ const line = p.line - 1;
651
+ if (line >= span.start && line <= span.end) {
652
+ ctx.diags.push({
653
+ severity: "error",
654
+ code: "transclusion-cycle",
655
+ message: `transclusion cycle: \`![[#${p.anchor}]]\` projects the content it is part of`,
656
+ line: p.line,
657
+ });
658
+ }
659
+ }
660
+ }
661
+ // Inline content that a projection may stand for: a `text` block whose body is a
662
+ // single paragraph. Returned so the renderer and this validator agree on one
663
+ // definition. Anything else — a heading (and so a whole section), a table, a
664
+ // diagram, a multi-paragraph body — is block content, and no amount of syntax
665
+ // makes it fit inside a sentence.
666
+ export function projectableInlines(blocks, id) {
667
+ const found = (function find(bs) {
668
+ for (const b of bs) {
669
+ if ((b.kind === "block" || b.kind === "heading") && b.id === id)
670
+ return b;
671
+ if (b.kind === "block" && b.children) {
672
+ const inner = find(b.children);
673
+ if (inner)
674
+ return inner;
675
+ }
676
+ }
677
+ return undefined;
678
+ })(blocks);
679
+ if (found === undefined)
680
+ return null;
681
+ if (found.kind !== "block" || found.type !== "text")
682
+ return "not-inline";
683
+ const kids = (found.children ?? []).filter((c) => !(c.kind === "paragraph" && c.text.trim() === ""));
684
+ if (kids.length !== 1 || kids[0].kind !== "paragraph")
685
+ return "not-inline";
686
+ return { inlines: kids[0].inlines };
687
+ }
688
+ // A projection may only stand for inline content, and the target decides — the
689
+ // same shape of rule as `table-source-not-a-table`, not a rule about where the
690
+ // reference was written.
691
+ function validateProjections(children, ctx, opts) {
692
+ for (const p of ctx.projections ?? []) {
693
+ let blocks = null;
694
+ if (p.doc === undefined)
695
+ blocks = children;
696
+ else if (opts.resolveDoc) {
697
+ const src = opts.resolveDoc(p.doc);
698
+ if (src === null)
699
+ continue; // already an unresolvable-document error
700
+ blocks = parse(src).children;
701
+ }
702
+ if (blocks === null)
703
+ continue; // unchecked without a resolver, like any cross-doc ref
704
+ const got = projectableInlines(blocks, p.anchor);
705
+ if (got === null)
706
+ continue; // already an unresolved-reference error
707
+ if (got === "not-inline") {
708
+ const target = p.doc === undefined ? `#${p.anchor}` : `${p.doc}#${p.anchor}`;
709
+ ctx.diags.push({
710
+ severity: "error",
711
+ code: "inline-transclusion-not-inline",
712
+ message: `\`![[${target}]]\` projects inline content, but the target is not a single-paragraph \`text\` block; for block content use \`=== embed {src=${target}}\``,
713
+ line: p.line,
714
+ });
715
+ }
716
+ }
717
+ }
718
+ // Same pure-string path composition the renderer uses (relJoin/relDir there).
719
+ function relJoinPath(base, target) {
720
+ if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
721
+ return target;
722
+ const out = [];
723
+ for (const s of (base + "/" + target).split("/")) {
724
+ if (s === "" || s === ".")
725
+ continue;
726
+ if (s === ".." && out.length > 0 && out[out.length - 1] !== "..")
727
+ out.pop();
728
+ else
729
+ out.push(s);
730
+ }
731
+ return out.join("/");
732
+ }
733
+ function relDirPath(p) {
734
+ const i = p.lastIndexOf("/");
735
+ return i < 0 ? "" : p.slice(0, i);
736
+ }
737
+ function gatherEmbeds(source) {
738
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
739
+ scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
740
+ return (ctx.embeds ?? []).map((e) => (e.anchor === undefined ? { doc: e.doc } : { doc: e.doc, anchor: e.anchor }));
741
+ }
742
+ // One rule for "where this data comes from", shared by a table's `src=`
743
+ // and a chart's `data=`. Three target forms: a data file, `#id` naming a table
744
+ // block in this document, or `doc.geml#id` naming one in another document. An
745
+ // unresolvable target is an error — a table whose source silently produced no
746
+ // rows used to render as an empty table with no diagnostic at all.
747
+ function tableFromDocument(source, id) {
748
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
749
+ const blocks = scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
750
+ const found = ctx.tables?.get(id);
751
+ if (found !== undefined)
752
+ return found;
753
+ const anyBlock = (function find(bs) {
754
+ for (const b of bs) {
755
+ if ((b.kind === "block" || b.kind === "heading") && b.id === id)
756
+ return b;
757
+ if (b.kind === "block" && b.children) {
758
+ const inner = find(b.children);
759
+ if (inner)
760
+ return inner;
761
+ }
762
+ }
763
+ return undefined;
764
+ })(blocks);
765
+ return anyBlock === undefined ? null : "not-a-table";
766
+ }
767
+ function resolveTableSources(ctx, opts) {
768
+ const pending = ctx.tableSources ?? [];
769
+ if (pending.length === 0)
770
+ return;
771
+ const err = (line, code, message) => void ctx.diags.push({ severity: "error", code, message, line });
772
+ // Data files first: a `#id` target may point at a table whose OWN rows come
773
+ // from a file, and this way that table is already populated when it is read.
774
+ for (const { block, line, target } of pending) {
775
+ if (target.includes("#"))
776
+ continue;
777
+ // §9.4: a remote source is fetched by the RENDERER, not the parser. Leaving
778
+ // `model.src` set with no columns is the state resolveCharts already handles,
779
+ // so a chart over it defers too. Passing it to resolveDoc treated a URL as a
780
+ // filesystem path and failed a spec-conformant document.
781
+ const scheme = schemeOf(target);
782
+ if (scheme === "http" || scheme === "https")
783
+ continue;
784
+ if (scheme !== null) {
785
+ err(line, "unresolvable-table-source", `table source \`${target}\` names a disallowed URL scheme`);
786
+ continue;
787
+ }
788
+ // A data source is data. Without this the loader read any file under the base
789
+ // — a `.env`, a private key — split it into rows, and put it in the model and
790
+ // the page, with no diagnostic. `embed` already applies the same shape of rule.
791
+ if (!/\.(csv|tsv)$/i.test(target)) {
792
+ err(line, "unresolvable-table-source", `table source \`${target}\` is not a \`.csv\`/\`.tsv\` data file`);
793
+ continue;
794
+ }
795
+ if (!opts.resolveDoc) {
796
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `table source \`${target}\` not checked (no document resolver)`, line });
797
+ continue;
798
+ }
799
+ const text = opts.resolveDoc(target);
800
+ if (text === null) {
801
+ err(line, "unresolvable-table-source", `cannot resolve table source \`${target}\``);
802
+ continue;
803
+ }
804
+ // Reuse the body parser: with `src` dropped, the file's lines are just
805
+ // this table's body, so format/header/compute/summary all behave identically.
806
+ const attrs = { ...block.attrs };
807
+ delete attrs["src"];
808
+ const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
809
+ model.src = target;
810
+ block.table = model;
811
+ for (const d of diagnostics)
812
+ ctx.diags.push({ ...d, line });
813
+ if (block.id !== undefined)
814
+ (ctx.tables ??= new Map()).set(block.id, model);
815
+ }
816
+ for (const { block, line, target } of pending) {
817
+ const hash = target.indexOf("#");
818
+ if (hash < 0)
819
+ continue;
820
+ const docPath = target.slice(0, hash);
821
+ const id = target.slice(hash + 1);
822
+ let model;
823
+ if (docPath === "") {
824
+ const local = ctx.tables?.get(id);
825
+ if (local === undefined) {
826
+ if (ctx.ids.has(id))
827
+ err(line, "table-source-not-a-table", `table source \`#${id}\` is not a table`);
828
+ else
829
+ err(line, "unresolved-reference", `unresolved reference \`#${id}\``);
830
+ continue;
831
+ }
832
+ model = local;
833
+ }
834
+ else {
835
+ if (!opts.resolveDoc) {
836
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `table source \`${target}\` not checked (no document resolver)`, line });
837
+ continue;
838
+ }
839
+ const text = opts.resolveDoc(docPath);
840
+ if (text === null) {
841
+ err(line, "unresolvable-document", `cannot resolve document \`${docPath}\``);
842
+ continue;
843
+ }
844
+ const remote = tableFromDocument(text, id);
845
+ if (remote === null) {
846
+ err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
847
+ continue;
848
+ }
849
+ if (remote === "not-a-table") {
850
+ err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
851
+ continue;
852
+ }
853
+ model = remote;
854
+ }
855
+ // Borrowed, not copied in the source: the model is shared, so the borrowing
856
+ // table means exactly what the original means. Its own caption still wins.
857
+ const caption = block.table?.caption;
858
+ block.table = caption === undefined ? model : { ...model, caption };
859
+ if (block.id !== undefined)
860
+ (ctx.tables ??= new Map()).set(block.id, block.table);
861
+ }
862
+ }
451
863
  function gatherIds(source) {
452
864
  const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
453
865
  scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
@@ -512,26 +924,77 @@ function validateRefs(ctx, opts) {
512
924
  }
513
925
  // §7: resolve every geml-chart against its referenced table. Runs after the
514
926
  // scan so that `data=#id` may point at a table defined anywhere in the doc.
515
- function resolveCharts(ctx) {
927
+ function resolveCharts(ctx, opts) {
516
928
  for (const { block, line } of ctx.charts ?? []) {
517
- const ref = typeof block.attrs["data"] === "string" ? block.attrs["data"] : "";
518
- const id = ref.replace(/^#/, "");
519
- if (id === "") {
929
+ const ref = typeof block.attrs["data"] === "string" ? block.attrs["data"].trim() : "";
930
+ if (ref === "" || ref === "#") {
520
931
  ctx.diags.push({ severity: "error", code: "chart-missing-data", message: "geml-chart: missing `data=#id`", line });
521
932
  continue;
522
933
  }
523
- const table = ctx.tables?.get(id);
524
- if (!table) {
525
- const known = ctx.ids.has(id);
526
- const what = known ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
527
- const code = known ? "chart-data-not-a-table" : "unresolved-reference";
528
- ctx.diags.push({ severity: "error", code, message: `geml-chart: ${what}`, line });
529
- continue;
934
+ // `data=` resolves by the same rule as a table's source: `#id` (or a bare id)
935
+ // names a table in THIS document, `doc.geml#id` one in another. Splitting on
936
+ // the LAST `#` is what the old code got wrong — it stripped the leading one
937
+ // and reported `#other.geml#fy25`, a target that never existed.
938
+ const hash = ref.indexOf("#");
939
+ const docPath = hash <= 0 ? "" : ref.slice(0, hash);
940
+ const id = hash < 0 ? ref : ref.slice(hash + 1);
941
+ let table;
942
+ if (docPath === "") {
943
+ table = ctx.tables?.get(id);
944
+ if (!table) {
945
+ // A chart is a view of a table, and a data file is one of the three ways
946
+ // §6 lets a table name its content. So `data=rows.csv` desugars: it is an
947
+ // anonymous table with that source, feeding this chart. Nothing new is
948
+ // invented — the resolution, the `.csv`/`.tsv` gate, the §9.4 remote rule
949
+ // and `format=` all come from the table rules, which is what makes the one
950
+ // source rule hold for charts too instead of charts being its exception.
951
+ if (hash < 0 && /\.(csv|tsv)$/i.test(id)) {
952
+ const sugar = chartSourceTable(ctx, opts, block, id, line);
953
+ if (sugar === null)
954
+ continue; // already reported by the table rules
955
+ table = sugar;
956
+ }
957
+ else if (hash < 0 && /\.[a-z0-9]+$/i.test(id)) {
958
+ 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 });
959
+ continue;
960
+ }
961
+ else {
962
+ const known = ctx.ids.has(id);
963
+ const what = known ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
964
+ const code = known ? "chart-data-not-a-table" : "unresolved-reference";
965
+ ctx.diags.push({ severity: "error", code, message: `geml-chart: ${what}`, line });
966
+ continue;
967
+ }
968
+ }
969
+ }
970
+ else {
971
+ if (!opts.resolveDoc) {
972
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data target \`${ref}\` not checked (no document resolver)`, line });
973
+ continue;
974
+ }
975
+ const text = opts.resolveDoc(docPath);
976
+ if (text === null) {
977
+ ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `geml-chart: cannot resolve document \`${docPath}\``, line });
978
+ continue;
979
+ }
980
+ const remote = tableFromDocument(text, id);
981
+ if (remote === null) {
982
+ ctx.diags.push({ severity: "error", code: "unresolved-cross-document-reference", message: `geml-chart: unresolved reference \`${ref}\``, line });
983
+ continue;
984
+ }
985
+ if (remote === "not-a-table") {
986
+ ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is not a table`, line });
987
+ continue;
988
+ }
989
+ table = remote;
530
990
  }
531
- if (table.src !== undefined) {
532
- // §6: the table's data is external (src=), loaded at render time. The
533
- // chart is therefore resolved at render time too its column references
534
- // are checked there, not here so skip build-time chart resolution.
991
+ if (table.src !== undefined && table.columns.length === 0) {
992
+ // §6: the table names a source whose data did not arrive at build time — a
993
+ // remote URL, or any source with no document resolver supplied. The chart is
994
+ // therefore resolved at render time, and its column names are checked there.
995
+ // The test is whether the data is actually here, not what the source looks
996
+ // like: skipping every `src` table unconditionally is what left a chart
997
+ // unbuilt with no diagnostic while the page said to go and read one.
535
998
  continue;
536
999
  }
537
1000
  const { model, diagnostics } = buildChart(block.attrs, table);
@@ -545,19 +1008,34 @@ export function parse(source, opts = {}) {
545
1008
  const lines = normalizeSource(source).split("\n");
546
1009
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
547
1010
  const children = scanBlocks(lines, 0, ctx);
548
- resolveCharts(ctx);
1011
+ // Table sources first: a chart reads the build-time model of the table it
1012
+ // charts, so that model has to be filled before charts are resolved.
1013
+ resolveTableSources(ctx, opts);
1014
+ resolveCharts(ctx, opts);
549
1015
  validateRefs(ctx, opts);
1016
+ detectTransclusionCycles(ctx, opts);
1017
+ detectSelfEmbedCycles(source, ctx);
1018
+ validateProjections(children, ctx, opts);
1019
+ detectSelfProjectionCycles(source, ctx);
1020
+ for (const m of ctx.mediaDocTargets ?? []) {
1021
+ ctx.diags.push({
1022
+ severity: "error",
1023
+ code: "media-target-is-document",
1024
+ // `!` projects, so a GEML target here is a near-miss an author will reach for
1025
+ // once that reading is established. Name both forms it could have meant.
1026
+ message: `\`![](${m.src})\` projects a GEML document, which is not media: for block content use \`=== embed {src=${m.src}}\`, for a phrase use \`![[${m.src}]]\``,
1027
+ line: m.line,
1028
+ });
1029
+ }
550
1030
  return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
551
1031
  }
552
1032
  // The id that a fence/heading line defines, matching how scanBlocks derives it
553
1033
  // (parseAttrs for the attribute object; heading text slug when no explicit id).
554
- // The slug MUST come from the INTERPOLATED text scanBlocks slugs after
555
- // interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
556
- // the raw text here would create a phantom id the parser never registered and
557
- // make the real one unaddressable. `ctx` is an inert context carrying the
558
- // document's meta (diagnostics are discarded — spans never report).
1034
+ // The slug MUST come from the RAW text, before interpolation, so that changing
1035
+ // a meta variable does not silently change the block's addressable id.
1036
+ // `ctx` is passed just in case future features need context.
559
1037
  function idOfHeading(braces, text, line, ctx) {
560
- return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
1038
+ return (braces ? parseAttrs(braces).id : undefined) ?? slug(text);
561
1039
  }
562
1040
  // The matching close of the fence opened at lines[i] (equal-length run, or the
563
1041
  // labeled `=== #id` close when the block carries an id): the index just past
@@ -594,7 +1072,7 @@ function sectionEnd(lines, i, level) {
594
1072
  }
595
1073
  // Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
596
1074
  // or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
597
- // source span of every addressable id (typed block, heading, footnote def).
1075
+ // source span of every addressable id (typed block, heading).
598
1076
  // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
599
1077
  // `get`/`set` operate on the one the parser actually registered). `base` is the
600
1078
  // absolute line offset of this slice within the whole document.
@@ -662,8 +1140,8 @@ types) {
662
1140
  export function blockSpans(source) {
663
1141
  const out = new Map();
664
1142
  const lines = normalizeSource(source).split("\n");
665
- // Inert context: heading auto-ids slug the interpolated text (parser parity);
666
- // its diagnostics are discarded the span scan never reports.
1143
+ // Inert context: heading auto-ids slug the raw text, but parseDoc still
1144
+ // requires a valid context to parse the document.
667
1145
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
668
1146
  collectSpans(lines, 0, out, ctx);
669
1147
  return out;
@@ -700,8 +1178,8 @@ function toNewline(text, nl) {
700
1178
  return nl === "\n" ? lf : lf.replace(/\n/g, nl);
701
1179
  }
702
1180
  // `--head`: narrow any id's span to its HEAD line — the single declaring line
703
- // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
704
- // `[^id]:` line). The head is by construction the FIRST line of the span, so
1181
+ // (a heading's `# … {#id}` line, or a typed block's opening fence). The head is
1182
+ // by construction the FIRST line of the span, so
705
1183
  // the narrowing is parse-free and needs no type check. Main use: `set --head`
706
1184
  // edits a block's attributes (caption/compute/lang/…) without re-sending its
707
1185
  // body, or renames a heading without rewriting its section.
@@ -794,51 +1272,54 @@ export const PARSER_VERSION = (() => {
794
1272
  }
795
1273
  return "0.0.0";
796
1274
  })();
797
- const USAGE = `geml — GEML reference CLI
798
-
799
- Usage:
800
- geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
801
- --to <output>: json | html | md | geml
802
- --to md -> Markdown (lossy)
803
- --to html -> self-contained HTML
804
- --to geml -> canonical re-format
805
- --to json -> document-model JSON (default)
806
- --from <input>: geml | md | json (overrides extension; html is output-only)
807
- geml notes.md -> GEML (md inferred from extension)
808
- geml model.json --to geml -> GEML (round-trips a prior --to json)
809
- geml - --from md read Markdown on stdin
810
- geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
811
- (a heading id = its whole section; --head = head line;
812
- --json = model node). Without #id: list all addressable
813
- ids (--json = array).
814
- geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
815
- (--in F takes F's block #id, F#src takes #src, else stdin raw;
816
- default = whole block · --head = head line · --body = body)
817
- geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
818
- (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
819
- geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
820
- (a missing id is skipped; a dangling reference is a warning, not a refusal)
821
- geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
822
- geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
823
- (sel: 0 | -N | id-prefix | changed; default -1)
824
- geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
825
- (--root widens cross-doc refs to dir d, e.g. the repo root)
826
- geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
827
- geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
828
- geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
829
- (10 tools, each geml_ + its CLI verb: list/get/check/history/to +
830
- set/add/delete/rename/revert; every write is validated before it
831
- reaches disk. A code graph under --root adds four read-only
832
- geml_codemap_* tools to the same server)
833
- geml --help | --version [--json]
834
-
835
- Use '-' as the file to read from stdin.
836
- Mutations (set/add/delete/rename) write the whole updated document in place for a
837
- file, or to stdout for '-' input; -o redirects it (-o - = stdout).
838
- Exit codes:
839
- 0 ok
840
- 1 document/operation error
841
- 2 command usage error.
1275
+ const USAGE = `geml — GEML reference CLI
1276
+
1277
+ Usage:
1278
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
1279
+ (--root widens cross-doc resolution to dir d, as on check an
1280
+ === embed whose target sits above the file's own directory
1281
+ needs it, or it renders unresolved)
1282
+ --to <output>: json | html | md | geml
1283
+ --to md -> Markdown (lossy)
1284
+ --to html -> self-contained HTML
1285
+ --to geml -> canonical re-format
1286
+ --to json -> document-model JSON (default)
1287
+ --from <input>: geml | md | json (overrides extension; html is output-only)
1288
+ geml notes.md -> GEML (md inferred from extension)
1289
+ geml model.json --to geml -> GEML (round-trips a prior --to json)
1290
+ geml - --from md read Markdown on stdin
1291
+ geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
1292
+ (a heading id = its whole section; --head = head line;
1293
+ --json = model node). Without #id: list all addressable
1294
+ ids (--json = array).
1295
+ geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
1296
+ (--in F takes F's block #id, F#src takes #src, else stdin raw;
1297
+ default = whole block · --head = head line · --body = body)
1298
+ geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
1299
+ (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
1300
+ geml delete <file.geml|-> #id [#id2 ] [-o f] remove one or more blocks
1301
+ (a missing id is skipped; a dangling reference is a warning, not a refusal)
1302
+ geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
1303
+ geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
1304
+ (sel: 0 | -N | id-prefix | changed; default -1)
1305
+ geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
1306
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
1307
+ geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
1308
+ geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
1309
+ geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
1310
+ (10 tools, each geml_ + its CLI verb: list/get/check/history/to +
1311
+ set/add/delete/rename/revert; every write is validated before it
1312
+ reaches disk. A code graph under --root adds four read-only
1313
+ geml_codemap_* tools to the same server)
1314
+ geml --help | --version [--json]
1315
+
1316
+ Use '-' as the file to read from stdin.
1317
+ Mutations (set/add/delete/rename) write the whole updated document in place for a
1318
+ file, or to stdout for '-' input; -o redirects it (-o - = stdout).
1319
+ Exit codes:
1320
+ 0 ok
1321
+ 1 document/operation error
1322
+ 2 command usage error.
842
1323
  `;
843
1324
  // One-line usage for each subcommand — the single source for both the error
844
1325
  // shown on misuse and the `<cmd> --help` text.
@@ -851,35 +1332,35 @@ const SUBHELP = {
851
1332
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
852
1333
  revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
853
1334
  history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
854
- codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
855
- geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
856
- geml codemap verify [dir] geml check + profile reference checks
857
- geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
858
- geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
859
- geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
860
- geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
1335
+ codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
1336
+ geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
1337
+ geml codemap verify [dir] geml check + profile reference checks
1338
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
1339
+ geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
1340
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
1341
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
861
1342
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
862
- mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
863
-
864
- Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
865
- Every tool is geml_ + its CLI verb, so the terminal and the assistant share
866
- one vocabulary.
867
- Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
868
- geml_set · geml_add · geml_delete · geml_rename · geml_revert
869
- With a code graph under --root, four more (read-only), so one client entry
870
- covers both: geml_codemap_search · geml_codemap_callchain
871
- geml_codemap_list · geml_codemap_node
872
-
873
- --root <dir> REQUIRED. Root holding the .geml documents. Every path a
874
- client names is confined here; a client cannot widen it.
875
- --graph <dir> Code-graph directory, inside --root. Defaults to
876
- <root>/.geml-code-graph when it holds an index.geml; with
877
- no graph the four graph tools are not served at all.
878
- --no-history Skip the .gemlhistory commit taken before each write
879
- (default: commit, so geml_revert always has a revision to
880
- undo to).
881
-
882
- Register with a client:
1343
+ mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
1344
+
1345
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
1346
+ Every tool is geml_ + its CLI verb, so the terminal and the assistant share
1347
+ one vocabulary.
1348
+ Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1349
+ geml_set · geml_add · geml_delete · geml_rename · geml_revert
1350
+ With a code graph under --root, four more (read-only), so one client entry
1351
+ covers both: geml_codemap_search · geml_codemap_callchain
1352
+ geml_codemap_list · geml_codemap_node
1353
+
1354
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
1355
+ client names is confined here; a client cannot widen it.
1356
+ --graph <dir> Code-graph directory, inside --root. Defaults to
1357
+ <root>/.geml-code-graph when it holds an index.geml; with
1358
+ no graph the four graph tools are not served at all.
1359
+ --no-history Skip the .gemlhistory commit taken before each write
1360
+ (default: commit, so geml_revert always has a revision to
1361
+ undo to).
1362
+
1363
+ Register with a client:
883
1364
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
884
1365
  };
885
1366
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
@@ -1002,7 +1483,7 @@ function runCheck(args) {
1002
1483
  if (!isDir)
1003
1484
  fail(`--root ${root} is not a directory`);
1004
1485
  }
1005
- const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
1486
+ const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
1006
1487
  if (json) {
1007
1488
  console.log(JSON.stringify(doc.diagnostics, null, 2));
1008
1489
  }
@@ -1090,7 +1571,15 @@ function runTransform(argv) {
1090
1571
  const out = flag(argv, "-o") ?? flag(argv, "--out");
1091
1572
  const fromRaw = flag(argv, "--from");
1092
1573
  const toRaw = flag(argv, "--to");
1093
- const [file] = positionals(argv, ["-o", "--out", "--from", "--to"]);
1574
+ // Same `--root` as `check`, and for the same reason: cross-document resolution is
1575
+ // fail-closed at the document's own directory, so a reference that climbs out of
1576
+ // it needs the tree's root named. Without this the transform silently ignored the
1577
+ // flag — a document whose embeds `check --root .` validated still rendered with
1578
+ // every one of them unresolved, which reads as "transclusion does not work".
1579
+ const root = flag(argv, "--root");
1580
+ if (argv.includes("--root") && root === undefined)
1581
+ fail("--root needs a directory", 2);
1582
+ const [file] = positionals(argv, ["-o", "--out", "--from", "--to", "--root"]);
1094
1583
  if (!file)
1095
1584
  fail("no input file (use '-' to read from stdin)", 2);
1096
1585
  // A bare `--to`/`--from` (no following value) is a mistyped flag, not a
@@ -1149,10 +1638,10 @@ function runTransform(argv) {
1149
1638
  else if (inFmt === "md") {
1150
1639
  const conv = mdToGeml(src);
1151
1640
  notes = conv.notes;
1152
- doc = parse(conv.geml, { resolveDoc: resolverFor(file) });
1641
+ doc = parse(conv.geml, { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
1153
1642
  }
1154
1643
  else {
1155
- doc = parse(src, { resolveDoc: resolverFor(file) });
1644
+ doc = parse(src, { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
1156
1645
  }
1157
1646
  let output;
1158
1647
  switch (outFmt) {
@@ -1166,8 +1655,8 @@ function runTransform(argv) {
1166
1655
  output = renderHtml(doc, {
1167
1656
  source: file === "-" ? "stdin" : basename(file),
1168
1657
  // geml-code-graph embeds load + parse sibling codemap docs on demand.
1169
- loadDoc: resolverFor(file),
1170
- parseDoc: (s) => parse(s),
1658
+ loadDoc: resolverFor(file, root),
1659
+ parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
1171
1660
  });
1172
1661
  break;
1173
1662
  case "md": {
@@ -1291,7 +1780,7 @@ function resolveSelector(source, file, raw) {
1291
1780
  return bare;
1292
1781
  const level = m[1].length;
1293
1782
  const want = m[2];
1294
- const doc = parse(source, { resolveDoc: resolverFor(file) });
1783
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1295
1784
  const heads = doc.ids.flatMap((id) => {
1296
1785
  const site = findBlockSite(doc.children, id);
1297
1786
  const b = site?.siblings[site.index];
@@ -1330,9 +1819,10 @@ function resolveSelector(source, file, raw) {
1330
1819
  // a heading, its level and text); `--json` is a machine-readable array so an
1331
1820
  // agent can pick its next `get #id` target. Ids are listed in document order
1332
1821
  // (the registration order parse() records), covering the same set `get #id`
1333
- // resolves against: typed blocks, headings, and footnote definitions.
1822
+ // resolves against: typed blocks and headings. A `[^id]` reference names one
1823
+ // of those (§5.2); the `[^id]: text` definition line was withdrawn.
1334
1824
  function listIds(source, file, json) {
1335
- const doc = parse(source, { resolveDoc: resolverFor(file) });
1825
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1336
1826
  const rows = doc.ids.map((id) => {
1337
1827
  const site = findBlockSite(doc.children, id);
1338
1828
  const b = site?.siblings[site.index];
@@ -1340,8 +1830,10 @@ function listIds(source, file, json) {
1340
1830
  return { id, kind: "heading", level: b.level, text: b.text };
1341
1831
  if (b?.kind === "block") {
1342
1832
  const row = { id, kind: b.type };
1833
+ // `.footnote` is authored, not synthesized (the `[^id]: text` definition
1834
+ // line was withdrawn) — but it still marks a block meant as a footnote.
1343
1835
  if (b.classes.includes("footnote"))
1344
- row.footnote = true; // §5.2 footnote definition
1836
+ row.footnote = true;
1345
1837
  return row;
1346
1838
  }
1347
1839
  return { id, kind: b?.kind ?? "unknown" };
@@ -1391,7 +1883,7 @@ function getByType(source, file, type, json, headOnly) {
1391
1883
  // The ONLY block of its type: locating it in the model needs no index, so
1392
1884
  // --json can still answer with the parsed node (meta's key/values, a
1393
1885
  // table's model) rather than a mere location.
1394
- const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file) }).children, type);
1886
+ const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
1395
1887
  if (node) {
1396
1888
  console.log(JSON.stringify(node, null, 2));
1397
1889
  return;
@@ -1457,7 +1949,7 @@ function runGet(args) {
1457
1949
  if (json) {
1458
1950
  // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1459
1951
  // to resolve the tree (and nested-block ids), but only the target prints.
1460
- const doc = parse(source, { resolveDoc: resolverFor(file) });
1952
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1461
1953
  const site = findBlockSite(doc.children, id);
1462
1954
  if (!site)
1463
1955
  fail(`no block with id \`${id}\``, 1);
@@ -1532,6 +2024,11 @@ function runSet(args) {
1532
2024
  return;
1533
2025
  }
1534
2026
  // default / --head: content is a whole block (default) or a bare head line.
2027
+ // Does the target exist? Asked FIRST: the shape checks below name the id in
2028
+ // their advice ("use --body to set the body of #far"), which reads as though the
2029
+ // id were there. Whether the content is prose is the second question.
2030
+ if (!blockSpans(source).has(id))
2031
+ fail(`no block with id \`${id}\``, 1);
1535
2032
  let content;
1536
2033
  if (rawChannel) {
1537
2034
  content = readInput("-");
@@ -1655,7 +2152,7 @@ function runAdd(args) {
1655
2152
  // or duplicate id surfaces as an error diagnostic) and no pre-existing id may
1656
2153
  // vanish. Returns the updated text; on any violation fail()s and writes nothing.
1657
2154
  function insertFragment(source, lines, at, fragment, file) {
1658
- const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
2155
+ const beforeIds = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).ids;
1659
2156
  const before = lines.slice(0, at);
1660
2157
  const after = lines.slice(at);
1661
2158
  const nl = newlineOf(source); // the fragment AND every separator we add
@@ -1672,7 +2169,7 @@ function insertFragment(source, lines, at, fragment, file) {
1672
2169
  const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
1673
2170
  const sepAfter = after.length && !blank(after[0]) ? nl : "";
1674
2171
  const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
1675
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2172
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1676
2173
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1677
2174
  if (errs.length) {
1678
2175
  const first = errs[0];
@@ -1722,7 +2219,7 @@ function runDelete(args) {
1722
2219
  const updated = splitLines(source).filter((_, i) => !toDelete.has(i)).join("");
1723
2220
  // Lenient guard: surface any resulting error diagnostic (a reference now
1724
2221
  // dangling) as a WARNING, but write regardless.
1725
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2222
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1726
2223
  for (const d of reparsed.diagnostics.filter((x) => x.severity === "error")) {
1727
2224
  console.error(`warning: ${d.message} (line ${d.line}) — left dangling by delete; run 'geml check' to see it as an error`);
1728
2225
  }
@@ -1741,7 +2238,7 @@ function runRename(args) {
1741
2238
  if (oldId === newId)
1742
2239
  fail("#old and #new are the same id — nothing to rename", 2);
1743
2240
  const source = readInput(file);
1744
- const before = parse(source, { resolveDoc: resolverFor(file) });
2241
+ const before = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1745
2242
  if (!before.ids.includes(oldId))
1746
2243
  fail(`no block with id \`${oldId}\``, 1);
1747
2244
  if (before.ids.includes(newId))
@@ -1761,7 +2258,7 @@ function runRename(args) {
1761
2258
  }
1762
2259
  }
1763
2260
  const updated = rewriteId(source, oldId, newId, file);
1764
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2261
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1765
2262
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1766
2263
  if (errs.length) {
1767
2264
  const e = errs[0];
@@ -1792,7 +2289,7 @@ function runRename(args) {
1792
2289
  // text, not a reference. (Known residual: id-less raw bodies and inline
1793
2290
  // code/math spans in flow content — see design §8.)
1794
2291
  function rewriteId(source, oldId, newId, file) {
1795
- const doc = parse(source, { resolveDoc: resolverFor(file) });
2292
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1796
2293
  const spans = blockSpans(source);
1797
2294
  const protectedLines = new Set();
1798
2295
  for (const b of doc.children) {
@@ -1900,7 +2397,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1900
2397
  const found = blockSpans(source).get(id);
1901
2398
  if (!found)
1902
2399
  fail(`no block with id \`${id}\``, 1);
1903
- const beforeDoc = parse(source, { resolveDoc: resolverFor(file) });
2400
+ const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1904
2401
  const beforeIds = beforeDoc.ids;
1905
2402
  // Keep the bytes before and after the target span exactly; give the new block
1906
2403
  // a single trailing newline so the following block still starts on its own
@@ -1924,7 +2421,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1924
2421
  // surface as error diagnostics (registerId flags dups); one check covers both.
1925
2422
  // Then require the target id to survive, and — because a malformed replacement
1926
2423
  // can swallow a neighbour — that every other pre-existing id survives too.
1927
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2424
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1928
2425
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1929
2426
  if (errs.length) {
1930
2427
  const first = errs[0];
@@ -2107,9 +2604,9 @@ function runRevert(args) {
2107
2604
  return;
2108
2605
  }
2109
2606
  const span = curFull;
2110
- const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
2607
+ const beforeIds = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).ids;
2111
2608
  const updated = splitLines(source).filter((_, i) => i < span.start || i >= span.end).join("");
2112
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2609
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2113
2610
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
2114
2611
  if (errs.length) {
2115
2612
  const first = errs[0];