@geml/geml 1.4.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/geml.js CHANGED
@@ -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",
@@ -319,12 +319,50 @@ function scanBlocks(lines, base, ctx, depth = 0) {
319
319
  }
320
320
  if (attrs.attrs["hidden"] === true)
321
321
  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 });
322
+ // Block transclusion: `src=` names the content this block stands for, and
323
+ // is registered as an ordinary reference so the existing §8 resolver
324
+ // validates the document and the id. Without that, an embed would be the
325
+ // one reference shape whose rot is silent.
326
+ if (type === "embed") {
327
+ const src = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : "";
328
+ if (src === "") {
329
+ diags.push({ severity: "error", code: "embed-missing-src", message: "embed: missing `src=`", line: openLineNo });
330
+ }
331
+ else {
332
+ const hash = src.indexOf("#");
333
+ const docPath = hash < 0 ? src : src.slice(0, hash);
334
+ const anchor = hash < 0 ? undefined : src.slice(hash + 1);
335
+ // §9.5: a destination naming a scheme outside the allowlist MUST NOT be
336
+ // emitted as a navigable or loadable target, and the check belongs HERE —
337
+ // when the model is built — so no consumer of the model can reintroduce
338
+ // it. The attribute is blanked as well as reported, the same treatment a
339
+ // media `src` already gets: a diagnostic alone would still leave the
340
+ // string in `attrs` for a renderer to put in an href.
341
+ if (!isSafeUrl(src)) {
342
+ diags.push({ severity: "error", code: "unsafe-embed-scheme", message: `embed: \`src=${src}\` names a disallowed URL scheme`, line: openLineNo });
343
+ block.attrs = { ...block.attrs, src: "" };
344
+ }
345
+ else if (docPath !== "" && !/\.geml$/i.test(docPath)) {
346
+ 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 });
347
+ }
348
+ else if (docPath === "") {
349
+ // Recorded with an empty doc so the self-cycle pass can see it.
350
+ if (anchor !== undefined)
351
+ (ctx.embeds ??= []).push({ doc: "", anchor, line: openLineNo });
352
+ // `src=#id`: a block of THIS document. Validated against local ids.
353
+ if (anchor !== undefined)
354
+ ctx.refs.push({ kind: "internal", anchor, line: openLineNo });
355
+ }
356
+ else {
357
+ ctx.refs.push({ kind: "cross", doc: docPath, anchor, line: openLineNo });
358
+ // Kept apart from refs: a transclusion can pull in a document that
359
+ // transcludes further, so cycle detection has to walk the graph.
360
+ (ctx.embeds ??= []).push(anchor === undefined ? { doc: docPath, line: openLineNo } : { doc: docPath, anchor, line: openLineNo });
361
+ }
362
+ }
363
+ if (body.some((l) => l.trim() !== "")) {
364
+ diags.push({ severity: "warning", code: "ignored-embed-body", message: "embed body is ignored; the target lives in `src=`", line: openLineNo });
365
+ }
328
366
  }
329
367
  if (mode === "flow") {
330
368
  if (depth >= MAX_NESTING) {
@@ -344,8 +382,18 @@ function scanBlocks(lines, base, ctx, depth = 0) {
344
382
  else {
345
383
  block.raw = body;
346
384
  if (type === "table") {
385
+ // `src=` and `data=` are one attribute in two spellings: where this
386
+ // table's data comes from. Recorded for the post-scan pass.
387
+ const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
388
+ const dataAttr = typeof attrs.attrs["data"] === "string" ? attrs.attrs["data"].trim() : undefined;
389
+ if (srcAttr !== undefined && dataAttr !== undefined) {
390
+ diags.push({ severity: "error", code: "source-attr-conflict", message: "table has both `src=` and `data=`; they mean the same thing — use one", line: openLineNo });
391
+ }
392
+ const target = srcAttr ?? dataAttr;
347
393
  // §6: parse the raw body (visual or csv/tsv) into one table model.
348
- const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
394
+ const { model, diagnostics } = parseTable(body, target === undefined ? attrs.attrs : { ...attrs.attrs, src: target }, openLineNo, ctx);
395
+ if (target !== undefined)
396
+ (ctx.tableSources ??= []).push({ block, line: openLineNo, target });
349
397
  block.table = model;
350
398
  for (const d of diagnostics)
351
399
  diags.push({ ...d, line: openLineNo });
@@ -448,6 +496,343 @@ function parseData(lines) {
448
496
  // ---------------------------------------------------------------------------
449
497
  // Collect the block ids of a (cross-document) source, without validation, for
450
498
  // resolving `other.geml#id` references.
499
+ // S5/S6: a transclusion may pull in a document that transcludes further, so a
500
+ // cycle is only visible by walking the graph. Reported at check time — before
501
+ // any rendering — so a build fails on the cycle rather than on a placeholder in
502
+ // the output. Paths compose the way the renderer composes them: a target inside
503
+ // a borrowed document is relative to THAT document.
504
+ // `data=rows.csv` on a chart, desugared: the anonymous table it stands for. Built
505
+ // by handing the loaded lines to the SAME body parser a `=== table {src=…}` uses,
506
+ // with the chart's own `format=`/`header=` carried over, so nothing about how the
507
+ // data is read is specific to charts. Returns null when the source could not be
508
+ // resolved — the diagnostic is already pushed, in the table rules' own words.
509
+ function chartSourceTable(ctx, opts, block, target, line) {
510
+ const scheme = schemeOf(target);
511
+ if (scheme === "http" || scheme === "https") {
512
+ // §9.4: fetched at render time, so there is nothing to chart at build time —
513
+ // the same state a remote-sourced table leaves behind.
514
+ return null;
515
+ }
516
+ if (!opts.resolveDoc) {
517
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${target}\` not checked (no document resolver)`, line });
518
+ return null;
519
+ }
520
+ const text = opts.resolveDoc(target);
521
+ if (text === null) {
522
+ ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: cannot resolve data source \`${target}\``, line });
523
+ return null;
524
+ }
525
+ const attrs = {
526
+ format: typeof block.attrs["format-data"] === "string" ? block.attrs["format-data"] : inferDataFormat(target),
527
+ header: block.attrs["header"] === undefined ? true : block.attrs["header"],
528
+ };
529
+ const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
530
+ for (const d of diagnostics)
531
+ ctx.diags.push({ ...d, line });
532
+ model.src = target;
533
+ return model;
534
+ }
535
+ const inferDataFormat = (target) => (/\.tsv$/i.test(target) ? "tsv" : "csv");
536
+ // The renderer's own cap (render.ts EMBED_DEPTH_CAP). Kept in step here so the
537
+ // check and the render agree on which documents are reachable at all.
538
+ const EMBED_DEPTH_LIMIT = 8;
539
+ function detectTransclusionCycles(ctx, opts) {
540
+ if (!opts.resolveDoc || ctx.embeds === undefined || ctx.embeds.length === 0)
541
+ return;
542
+ const resolve = opts.resolveDoc;
543
+ const embedsOf = new Map(); // memoized per path
544
+ const reported = new Set();
545
+ // A three-colour DFS over DOCUMENTS, not over paths. Enumerating every path
546
+ // through the graph is exponential in its fan-out: a chain of 21 tiny files,
547
+ // each embedding the next three times, took over two minutes — and `check` is
548
+ // the CI gate and the validator every MCP write runs twice. Grey means "on the
549
+ // current stack" and is the cycle; black means already fully explored, so each
550
+ // edge is walked once and the whole traversal is O(V+E).
551
+ const colour = new Map();
552
+ const walk = (path, base, stack, line) => {
553
+ const rel = relJoinPath(base, path);
554
+ if (colour.get(rel) === "grey") {
555
+ const chain = [...stack, rel].join(" → ");
556
+ if (reported.has(chain))
557
+ return;
558
+ reported.add(chain);
559
+ ctx.diags.push({ severity: "error", code: "transclusion-cycle", message: `transclusion cycle: ${chain}`, line });
560
+ return;
561
+ }
562
+ if (colour.get(rel) === "black")
563
+ return;
564
+ // Agree with the renderer about what is even reachable, instead of exploring
565
+ // eight times deeper than it will ever expand.
566
+ if (stack.length >= EMBED_DEPTH_LIMIT)
567
+ return;
568
+ colour.set(rel, "grey");
569
+ let inner = embedsOf.get(rel);
570
+ if (inner === undefined) {
571
+ const src = resolve(rel);
572
+ inner = src === null ? [] : gatherEmbeds(src); // an unresolvable doc is already an error
573
+ embedsOf.set(rel, inner);
574
+ }
575
+ for (const e of inner)
576
+ walk(e.doc, relDirPath(rel), [...stack, rel], line);
577
+ colour.set(rel, "black");
578
+ };
579
+ // The root is named so a chain can be seen returning to it. Falling back to ""
580
+ // only loses the A→…→A case, which is what happened before `self` existed.
581
+ const root = opts.self ?? "";
582
+ for (const e of ctx.embeds)
583
+ walk(e.doc, relDirPath(root), [root], e.line);
584
+ }
585
+ // The smallest cycle of all, and the one the cross-document walk above cannot
586
+ // see: `=== embed {src=#sec}` written INSIDE the section `#sec` selects the slice
587
+ // that contains it. Decided on spans, so the boundary is exactly the one `geml
588
+ // get` uses — a heading id spans its whole section, so an embed anywhere in that
589
+ // section is inside its own target.
590
+ function detectSelfEmbedCycles(source, ctx) {
591
+ const selfEmbeds = (ctx.embeds ?? []).filter((e) => e.doc === "" && e.anchor !== undefined);
592
+ if (selfEmbeds.length === 0)
593
+ return;
594
+ const spans = blockSpans(source);
595
+ for (const e of selfEmbeds) {
596
+ const span = spans.get(e.anchor);
597
+ if (span === undefined)
598
+ continue; // a missing id is already an unresolved reference
599
+ const line = e.line - 1; // spans are 0-based line indices
600
+ if (line >= span.start && line <= span.end) {
601
+ ctx.diags.push({
602
+ severity: "error",
603
+ code: "transclusion-cycle",
604
+ message: `transclusion cycle: \`#${e.anchor}\` selects the content this embed is part of`,
605
+ line: e.line,
606
+ });
607
+ }
608
+ }
609
+ }
610
+ // A phrase that projects itself. The same shape as detectSelfEmbedCycles, and
611
+ // deliberately the same machinery rather than a second parallel one: decided on
612
+ // spans, so a projection written anywhere inside its own target is caught.
613
+ function detectSelfProjectionCycles(source, ctx) {
614
+ const local = (ctx.projections ?? []).filter((p) => p.doc === undefined);
615
+ if (local.length === 0)
616
+ return;
617
+ const spans = blockSpans(source);
618
+ for (const p of local) {
619
+ const span = spans.get(p.anchor);
620
+ if (span === undefined)
621
+ continue;
622
+ const line = p.line - 1;
623
+ if (line >= span.start && line <= span.end) {
624
+ ctx.diags.push({
625
+ severity: "error",
626
+ code: "transclusion-cycle",
627
+ message: `transclusion cycle: \`![[#${p.anchor}]]\` projects the content it is part of`,
628
+ line: p.line,
629
+ });
630
+ }
631
+ }
632
+ }
633
+ // Inline content that a projection may stand for: a `text` block whose body is a
634
+ // single paragraph. Returned so the renderer and this validator agree on one
635
+ // definition. Anything else — a heading (and so a whole section), a table, a
636
+ // diagram, a multi-paragraph body — is block content, and no amount of syntax
637
+ // makes it fit inside a sentence.
638
+ export function projectableInlines(blocks, id) {
639
+ const found = (function find(bs) {
640
+ for (const b of bs) {
641
+ if ((b.kind === "block" || b.kind === "heading") && b.id === id)
642
+ return b;
643
+ if (b.kind === "block" && b.children) {
644
+ const inner = find(b.children);
645
+ if (inner)
646
+ return inner;
647
+ }
648
+ }
649
+ return undefined;
650
+ })(blocks);
651
+ if (found === undefined)
652
+ return null;
653
+ if (found.kind !== "block" || found.type !== "text")
654
+ return "not-inline";
655
+ const kids = (found.children ?? []).filter((c) => !(c.kind === "paragraph" && c.text.trim() === ""));
656
+ if (kids.length !== 1 || kids[0].kind !== "paragraph")
657
+ return "not-inline";
658
+ return { inlines: kids[0].inlines };
659
+ }
660
+ // A projection may only stand for inline content, and the target decides — the
661
+ // same shape of rule as `table-source-not-a-table`, not a rule about where the
662
+ // reference was written.
663
+ function validateProjections(children, ctx, opts) {
664
+ for (const p of ctx.projections ?? []) {
665
+ let blocks = null;
666
+ if (p.doc === undefined)
667
+ blocks = children;
668
+ else if (opts.resolveDoc) {
669
+ const src = opts.resolveDoc(p.doc);
670
+ if (src === null)
671
+ continue; // already an unresolvable-document error
672
+ blocks = parse(src).children;
673
+ }
674
+ if (blocks === null)
675
+ continue; // unchecked without a resolver, like any cross-doc ref
676
+ const got = projectableInlines(blocks, p.anchor);
677
+ if (got === null)
678
+ continue; // already an unresolved-reference error
679
+ if (got === "not-inline") {
680
+ const target = p.doc === undefined ? `#${p.anchor}` : `${p.doc}#${p.anchor}`;
681
+ ctx.diags.push({
682
+ severity: "error",
683
+ code: "inline-transclusion-not-inline",
684
+ message: `\`![[${target}]]\` projects inline content, but the target is not a single-paragraph \`text\` block; for block content use \`=== embed {src=${target}}\``,
685
+ line: p.line,
686
+ });
687
+ }
688
+ }
689
+ }
690
+ // Same pure-string path composition the renderer uses (relJoin/relDir there).
691
+ function relJoinPath(base, target) {
692
+ if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
693
+ return target;
694
+ const out = [];
695
+ for (const s of (base + "/" + target).split("/")) {
696
+ if (s === "" || s === ".")
697
+ continue;
698
+ if (s === ".." && out.length > 0 && out[out.length - 1] !== "..")
699
+ out.pop();
700
+ else
701
+ out.push(s);
702
+ }
703
+ return out.join("/");
704
+ }
705
+ function relDirPath(p) {
706
+ const i = p.lastIndexOf("/");
707
+ return i < 0 ? "" : p.slice(0, i);
708
+ }
709
+ function gatherEmbeds(source) {
710
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
711
+ scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
712
+ return (ctx.embeds ?? []).map((e) => (e.anchor === undefined ? { doc: e.doc } : { doc: e.doc, anchor: e.anchor }));
713
+ }
714
+ // One rule for "where this data comes from", shared by a table's `src=`/`data=`
715
+ // and a chart's `data=`. Three target forms: a data file, `#id` naming a table
716
+ // block in this document, or `doc.geml#id` naming one in another document. An
717
+ // unresolvable target is an error — a table whose source silently produced no
718
+ // rows used to render as an empty table with no diagnostic at all.
719
+ function tableFromDocument(source, id) {
720
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
721
+ const blocks = scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
722
+ const found = ctx.tables?.get(id);
723
+ if (found !== undefined)
724
+ return found;
725
+ const anyBlock = (function find(bs) {
726
+ for (const b of bs) {
727
+ if ((b.kind === "block" || b.kind === "heading") && b.id === id)
728
+ return b;
729
+ if (b.kind === "block" && b.children) {
730
+ const inner = find(b.children);
731
+ if (inner)
732
+ return inner;
733
+ }
734
+ }
735
+ return undefined;
736
+ })(blocks);
737
+ return anyBlock === undefined ? null : "not-a-table";
738
+ }
739
+ function resolveTableSources(ctx, opts) {
740
+ const pending = ctx.tableSources ?? [];
741
+ if (pending.length === 0)
742
+ return;
743
+ const err = (line, code, message) => void ctx.diags.push({ severity: "error", code, message, line });
744
+ // Data files first: a `#id` target may point at a table whose OWN rows come
745
+ // from a file, and this way that table is already populated when it is read.
746
+ for (const { block, line, target } of pending) {
747
+ if (target.includes("#"))
748
+ continue;
749
+ // §9.4: a remote source is fetched by the RENDERER, not the parser. Leaving
750
+ // `model.src` set with no columns is the state resolveCharts already handles,
751
+ // so a chart over it defers too. Passing it to resolveDoc treated a URL as a
752
+ // filesystem path and failed a spec-conformant document.
753
+ const scheme = schemeOf(target);
754
+ if (scheme === "http" || scheme === "https")
755
+ continue;
756
+ if (scheme !== null) {
757
+ err(line, "unresolvable-table-source", `table source \`${target}\` names a disallowed URL scheme`);
758
+ continue;
759
+ }
760
+ // A data source is data. Without this the loader read any file under the base
761
+ // — a `.env`, a private key — split it into rows, and put it in the model and
762
+ // the page, with no diagnostic. `embed` already applies the same shape of rule.
763
+ if (!/\.(csv|tsv)$/i.test(target)) {
764
+ err(line, "unresolvable-table-source", `table source \`${target}\` is not a \`.csv\`/\`.tsv\` data file`);
765
+ continue;
766
+ }
767
+ if (!opts.resolveDoc) {
768
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `table source \`${target}\` not checked (no document resolver)`, line });
769
+ continue;
770
+ }
771
+ const text = opts.resolveDoc(target);
772
+ if (text === null) {
773
+ err(line, "unresolvable-table-source", `cannot resolve table source \`${target}\``);
774
+ continue;
775
+ }
776
+ // Reuse the body parser: with `src`/`data` dropped, the file's lines are just
777
+ // this table's body, so format/header/compute/summary all behave identically.
778
+ const attrs = { ...block.attrs };
779
+ delete attrs["src"];
780
+ delete attrs["data"];
781
+ const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
782
+ model.src = target;
783
+ block.table = model;
784
+ for (const d of diagnostics)
785
+ ctx.diags.push({ ...d, line });
786
+ if (block.id !== undefined)
787
+ (ctx.tables ??= new Map()).set(block.id, model);
788
+ }
789
+ for (const { block, line, target } of pending) {
790
+ const hash = target.indexOf("#");
791
+ if (hash < 0)
792
+ continue;
793
+ const docPath = target.slice(0, hash);
794
+ const id = target.slice(hash + 1);
795
+ let model;
796
+ if (docPath === "") {
797
+ const local = ctx.tables?.get(id);
798
+ if (local === undefined) {
799
+ if (ctx.ids.has(id))
800
+ err(line, "table-source-not-a-table", `table source \`#${id}\` is not a table`);
801
+ else
802
+ err(line, "unresolved-reference", `unresolved reference \`#${id}\``);
803
+ continue;
804
+ }
805
+ model = local;
806
+ }
807
+ else {
808
+ if (!opts.resolveDoc) {
809
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `table source \`${target}\` not checked (no document resolver)`, line });
810
+ continue;
811
+ }
812
+ const text = opts.resolveDoc(docPath);
813
+ if (text === null) {
814
+ err(line, "unresolvable-document", `cannot resolve document \`${docPath}\``);
815
+ continue;
816
+ }
817
+ const remote = tableFromDocument(text, id);
818
+ if (remote === null) {
819
+ err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
820
+ continue;
821
+ }
822
+ if (remote === "not-a-table") {
823
+ err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
824
+ continue;
825
+ }
826
+ model = remote;
827
+ }
828
+ // Borrowed, not copied in the source: the model is shared, so the borrowing
829
+ // table means exactly what the original means. Its own caption still wins.
830
+ const caption = block.table?.caption;
831
+ block.table = caption === undefined ? model : { ...model, caption };
832
+ if (block.id !== undefined)
833
+ (ctx.tables ??= new Map()).set(block.id, block.table);
834
+ }
835
+ }
451
836
  function gatherIds(source) {
452
837
  const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
453
838
  scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
@@ -512,26 +897,77 @@ function validateRefs(ctx, opts) {
512
897
  }
513
898
  // §7: resolve every geml-chart against its referenced table. Runs after the
514
899
  // scan so that `data=#id` may point at a table defined anywhere in the doc.
515
- function resolveCharts(ctx) {
900
+ function resolveCharts(ctx, opts) {
516
901
  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 === "") {
902
+ const ref = typeof block.attrs["data"] === "string" ? block.attrs["data"].trim() : "";
903
+ if (ref === "" || ref === "#") {
520
904
  ctx.diags.push({ severity: "error", code: "chart-missing-data", message: "geml-chart: missing `data=#id`", line });
521
905
  continue;
522
906
  }
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;
907
+ // `data=` resolves by the same rule as a table's source: `#id` (or a bare id)
908
+ // names a table in THIS document, `doc.geml#id` one in another. Splitting on
909
+ // the LAST `#` is what the old code got wrong — it stripped the leading one
910
+ // and reported `#other.geml#fy25`, a target that never existed.
911
+ const hash = ref.indexOf("#");
912
+ const docPath = hash <= 0 ? "" : ref.slice(0, hash);
913
+ const id = hash < 0 ? ref : ref.slice(hash + 1);
914
+ let table;
915
+ if (docPath === "") {
916
+ table = ctx.tables?.get(id);
917
+ if (!table) {
918
+ // A chart is a view of a table, and a data file is one of the three ways
919
+ // §6 lets a table name its content. So `data=rows.csv` desugars: it is an
920
+ // anonymous table with that source, feeding this chart. Nothing new is
921
+ // invented — the resolution, the `.csv`/`.tsv` gate, the §9.4 remote rule
922
+ // and `format=` all come from the table rules, which is what makes the one
923
+ // source rule hold for charts too instead of charts being its exception.
924
+ if (hash < 0 && /\.(csv|tsv)$/i.test(id)) {
925
+ const sugar = chartSourceTable(ctx, opts, block, id, line);
926
+ if (sugar === null)
927
+ continue; // already reported by the table rules
928
+ table = sugar;
929
+ }
930
+ else if (hash < 0 && /\.[a-z0-9]+$/i.test(id)) {
931
+ 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 });
932
+ continue;
933
+ }
934
+ else {
935
+ const known = ctx.ids.has(id);
936
+ const what = known ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
937
+ const code = known ? "chart-data-not-a-table" : "unresolved-reference";
938
+ ctx.diags.push({ severity: "error", code, message: `geml-chart: ${what}`, line });
939
+ continue;
940
+ }
941
+ }
530
942
  }
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.
943
+ else {
944
+ if (!opts.resolveDoc) {
945
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data target \`${ref}\` not checked (no document resolver)`, line });
946
+ continue;
947
+ }
948
+ const text = opts.resolveDoc(docPath);
949
+ if (text === null) {
950
+ ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `geml-chart: cannot resolve document \`${docPath}\``, line });
951
+ continue;
952
+ }
953
+ const remote = tableFromDocument(text, id);
954
+ if (remote === null) {
955
+ ctx.diags.push({ severity: "error", code: "unresolved-cross-document-reference", message: `geml-chart: unresolved reference \`${ref}\``, line });
956
+ continue;
957
+ }
958
+ if (remote === "not-a-table") {
959
+ ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is not a table`, line });
960
+ continue;
961
+ }
962
+ table = remote;
963
+ }
964
+ if (table.src !== undefined && table.columns.length === 0) {
965
+ // §6: the table names a source whose data did not arrive at build time — a
966
+ // remote URL, or any source with no document resolver supplied. The chart is
967
+ // therefore resolved at render time, and its column names are checked there.
968
+ // The test is whether the data is actually here, not what the source looks
969
+ // like: skipping every `src` table unconditionally is what left a chart
970
+ // unbuilt with no diagnostic while the page said to go and read one.
535
971
  continue;
536
972
  }
537
973
  const { model, diagnostics } = buildChart(block.attrs, table);
@@ -545,8 +981,25 @@ export function parse(source, opts = {}) {
545
981
  const lines = normalizeSource(source).split("\n");
546
982
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
547
983
  const children = scanBlocks(lines, 0, ctx);
548
- resolveCharts(ctx);
984
+ // Table sources first: a chart reads the build-time model of the table it
985
+ // charts, so that model has to be filled before charts are resolved.
986
+ resolveTableSources(ctx, opts);
987
+ resolveCharts(ctx, opts);
549
988
  validateRefs(ctx, opts);
989
+ detectTransclusionCycles(ctx, opts);
990
+ detectSelfEmbedCycles(source, ctx);
991
+ validateProjections(children, ctx, opts);
992
+ detectSelfProjectionCycles(source, ctx);
993
+ for (const m of ctx.mediaDocTargets ?? []) {
994
+ ctx.diags.push({
995
+ severity: "error",
996
+ code: "media-target-is-document",
997
+ // `!` projects, so a GEML target here is a near-miss an author will reach for
998
+ // once that reading is established. Name both forms it could have meant.
999
+ 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}]]\``,
1000
+ line: m.line,
1001
+ });
1002
+ }
550
1003
  return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
551
1004
  }
552
1005
  // The id that a fence/heading line defines, matching how scanBlocks derives it
@@ -797,7 +1250,10 @@ export const PARSER_VERSION = (() => {
797
1250
  const USAGE = `geml — GEML reference CLI
798
1251
 
799
1252
  Usage:
800
- geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
1253
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
1254
+ (--root widens cross-doc resolution to dir d, as on check — an
1255
+ === embed whose target sits above the file's own directory
1256
+ needs it, or it renders unresolved)
801
1257
  --to <output>: json | html | md | geml
802
1258
  --to md -> Markdown (lossy)
803
1259
  --to html -> self-contained HTML
@@ -826,9 +1282,10 @@ Usage:
826
1282
  geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
827
1283
  geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
828
1284
  geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
829
- (9 tools: list/read/check/history + write/add/delete/rename/revert;
830
- every write is validated before it reaches disk. A code graph under
831
- --root adds resolve_name/open_symbol/get_backlinks to the same server)
1285
+ (10 tools, each geml_ + its CLI verb: list/get/check/history/to +
1286
+ set/add/delete/rename/revert; every write is validated before it
1287
+ reaches disk. A code graph under --root adds four read-only
1288
+ geml_codemap_* tools to the same server)
832
1289
  geml --help | --version [--json]
833
1290
 
834
1291
  Use '-' as the file to read from stdin.
@@ -861,20 +1318,22 @@ const SUBHELP = {
861
1318
  mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
862
1319
 
863
1320
  Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
864
- Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
865
- geml_write_block · geml_add_block · geml_delete_block
866
- geml_rename_id · geml_revert_block
867
- With a code graph under --root, three more (read-only), so one client entry
868
- covers both: resolve_name · open_symbol · get_backlinks
1321
+ Every tool is geml_ + its CLI verb, so the terminal and the assistant share
1322
+ one vocabulary.
1323
+ Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1324
+ geml_set · geml_add · geml_delete · geml_rename · geml_revert
1325
+ With a code graph under --root, four more (read-only), so one client entry
1326
+ covers both: geml_codemap_search · geml_codemap_callchain
1327
+ geml_codemap_list · geml_codemap_node
869
1328
 
870
1329
  --root <dir> REQUIRED. Root holding the .geml documents. Every path a
871
1330
  client names is confined here; a client cannot widen it.
872
1331
  --graph <dir> Code-graph directory, inside --root. Defaults to
873
1332
  <root>/.geml-code-graph when it holds an index.geml; with
874
- no graph the three graph tools are not served at all.
1333
+ no graph the four graph tools are not served at all.
875
1334
  --no-history Skip the .gemlhistory commit taken before each write
876
- (default: commit, so geml_revert_block always has a
877
- revision to undo to).
1335
+ (default: commit, so geml_revert always has a revision to
1336
+ undo to).
878
1337
 
879
1338
  Register with a client:
880
1339
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
@@ -999,7 +1458,7 @@ function runCheck(args) {
999
1458
  if (!isDir)
1000
1459
  fail(`--root ${root} is not a directory`);
1001
1460
  }
1002
- const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
1461
+ const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
1003
1462
  if (json) {
1004
1463
  console.log(JSON.stringify(doc.diagnostics, null, 2));
1005
1464
  }
@@ -1087,7 +1546,15 @@ function runTransform(argv) {
1087
1546
  const out = flag(argv, "-o") ?? flag(argv, "--out");
1088
1547
  const fromRaw = flag(argv, "--from");
1089
1548
  const toRaw = flag(argv, "--to");
1090
- const [file] = positionals(argv, ["-o", "--out", "--from", "--to"]);
1549
+ // Same `--root` as `check`, and for the same reason: cross-document resolution is
1550
+ // fail-closed at the document's own directory, so a reference that climbs out of
1551
+ // it needs the tree's root named. Without this the transform silently ignored the
1552
+ // flag — a document whose embeds `check --root .` validated still rendered with
1553
+ // every one of them unresolved, which reads as "transclusion does not work".
1554
+ const root = flag(argv, "--root");
1555
+ if (argv.includes("--root") && root === undefined)
1556
+ fail("--root needs a directory", 2);
1557
+ const [file] = positionals(argv, ["-o", "--out", "--from", "--to", "--root"]);
1091
1558
  if (!file)
1092
1559
  fail("no input file (use '-' to read from stdin)", 2);
1093
1560
  // A bare `--to`/`--from` (no following value) is a mistyped flag, not a
@@ -1146,10 +1613,10 @@ function runTransform(argv) {
1146
1613
  else if (inFmt === "md") {
1147
1614
  const conv = mdToGeml(src);
1148
1615
  notes = conv.notes;
1149
- doc = parse(conv.geml, { resolveDoc: resolverFor(file) });
1616
+ doc = parse(conv.geml, { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
1150
1617
  }
1151
1618
  else {
1152
- doc = parse(src, { resolveDoc: resolverFor(file) });
1619
+ doc = parse(src, { resolveDoc: resolverFor(file, root), self: file === "-" ? undefined : basename(file) });
1153
1620
  }
1154
1621
  let output;
1155
1622
  switch (outFmt) {
@@ -1163,8 +1630,8 @@ function runTransform(argv) {
1163
1630
  output = renderHtml(doc, {
1164
1631
  source: file === "-" ? "stdin" : basename(file),
1165
1632
  // geml-code-graph embeds load + parse sibling codemap docs on demand.
1166
- loadDoc: resolverFor(file),
1167
- parseDoc: (s) => parse(s),
1633
+ loadDoc: resolverFor(file, root),
1634
+ parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
1168
1635
  });
1169
1636
  break;
1170
1637
  case "md": {
@@ -1288,7 +1755,7 @@ function resolveSelector(source, file, raw) {
1288
1755
  return bare;
1289
1756
  const level = m[1].length;
1290
1757
  const want = m[2];
1291
- const doc = parse(source, { resolveDoc: resolverFor(file) });
1758
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1292
1759
  const heads = doc.ids.flatMap((id) => {
1293
1760
  const site = findBlockSite(doc.children, id);
1294
1761
  const b = site?.siblings[site.index];
@@ -1329,7 +1796,7 @@ function resolveSelector(source, file, raw) {
1329
1796
  // (the registration order parse() records), covering the same set `get #id`
1330
1797
  // resolves against: typed blocks, headings, and footnote definitions.
1331
1798
  function listIds(source, file, json) {
1332
- const doc = parse(source, { resolveDoc: resolverFor(file) });
1799
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1333
1800
  const rows = doc.ids.map((id) => {
1334
1801
  const site = findBlockSite(doc.children, id);
1335
1802
  const b = site?.siblings[site.index];
@@ -1388,7 +1855,7 @@ function getByType(source, file, type, json, headOnly) {
1388
1855
  // The ONLY block of its type: locating it in the model needs no index, so
1389
1856
  // --json can still answer with the parsed node (meta's key/values, a
1390
1857
  // table's model) rather than a mere location.
1391
- const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file) }).children, type);
1858
+ const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
1392
1859
  if (node) {
1393
1860
  console.log(JSON.stringify(node, null, 2));
1394
1861
  return;
@@ -1454,7 +1921,7 @@ function runGet(args) {
1454
1921
  if (json) {
1455
1922
  // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1456
1923
  // to resolve the tree (and nested-block ids), but only the target prints.
1457
- const doc = parse(source, { resolveDoc: resolverFor(file) });
1924
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1458
1925
  const site = findBlockSite(doc.children, id);
1459
1926
  if (!site)
1460
1927
  fail(`no block with id \`${id}\``, 1);
@@ -1529,6 +1996,11 @@ function runSet(args) {
1529
1996
  return;
1530
1997
  }
1531
1998
  // default / --head: content is a whole block (default) or a bare head line.
1999
+ // Does the target exist? Asked FIRST: the shape checks below name the id in
2000
+ // their advice ("use --body to set the body of #far"), which reads as though the
2001
+ // id were there. Whether the content is prose is the second question.
2002
+ if (!blockSpans(source).has(id))
2003
+ fail(`no block with id \`${id}\``, 1);
1532
2004
  let content;
1533
2005
  if (rawChannel) {
1534
2006
  content = readInput("-");
@@ -1652,7 +2124,7 @@ function runAdd(args) {
1652
2124
  // or duplicate id surfaces as an error diagnostic) and no pre-existing id may
1653
2125
  // vanish. Returns the updated text; on any violation fail()s and writes nothing.
1654
2126
  function insertFragment(source, lines, at, fragment, file) {
1655
- const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
2127
+ const beforeIds = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).ids;
1656
2128
  const before = lines.slice(0, at);
1657
2129
  const after = lines.slice(at);
1658
2130
  const nl = newlineOf(source); // the fragment AND every separator we add
@@ -1669,7 +2141,7 @@ function insertFragment(source, lines, at, fragment, file) {
1669
2141
  const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
1670
2142
  const sepAfter = after.length && !blank(after[0]) ? nl : "";
1671
2143
  const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
1672
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2144
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1673
2145
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1674
2146
  if (errs.length) {
1675
2147
  const first = errs[0];
@@ -1719,7 +2191,7 @@ function runDelete(args) {
1719
2191
  const updated = splitLines(source).filter((_, i) => !toDelete.has(i)).join("");
1720
2192
  // Lenient guard: surface any resulting error diagnostic (a reference now
1721
2193
  // dangling) as a WARNING, but write regardless.
1722
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2194
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1723
2195
  for (const d of reparsed.diagnostics.filter((x) => x.severity === "error")) {
1724
2196
  console.error(`warning: ${d.message} (line ${d.line}) — left dangling by delete; run 'geml check' to see it as an error`);
1725
2197
  }
@@ -1738,7 +2210,7 @@ function runRename(args) {
1738
2210
  if (oldId === newId)
1739
2211
  fail("#old and #new are the same id — nothing to rename", 2);
1740
2212
  const source = readInput(file);
1741
- const before = parse(source, { resolveDoc: resolverFor(file) });
2213
+ const before = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1742
2214
  if (!before.ids.includes(oldId))
1743
2215
  fail(`no block with id \`${oldId}\``, 1);
1744
2216
  if (before.ids.includes(newId))
@@ -1758,7 +2230,7 @@ function runRename(args) {
1758
2230
  }
1759
2231
  }
1760
2232
  const updated = rewriteId(source, oldId, newId, file);
1761
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2233
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1762
2234
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1763
2235
  if (errs.length) {
1764
2236
  const e = errs[0];
@@ -1789,7 +2261,7 @@ function runRename(args) {
1789
2261
  // text, not a reference. (Known residual: id-less raw bodies and inline
1790
2262
  // code/math spans in flow content — see design §8.)
1791
2263
  function rewriteId(source, oldId, newId, file) {
1792
- const doc = parse(source, { resolveDoc: resolverFor(file) });
2264
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1793
2265
  const spans = blockSpans(source);
1794
2266
  const protectedLines = new Set();
1795
2267
  for (const b of doc.children) {
@@ -1897,7 +2369,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1897
2369
  const found = blockSpans(source).get(id);
1898
2370
  if (!found)
1899
2371
  fail(`no block with id \`${id}\``, 1);
1900
- const beforeDoc = parse(source, { resolveDoc: resolverFor(file) });
2372
+ const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1901
2373
  const beforeIds = beforeDoc.ids;
1902
2374
  // Keep the bytes before and after the target span exactly; give the new block
1903
2375
  // a single trailing newline so the following block still starts on its own
@@ -1921,7 +2393,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1921
2393
  // surface as error diagnostics (registerId flags dups); one check covers both.
1922
2394
  // Then require the target id to survive, and — because a malformed replacement
1923
2395
  // can swallow a neighbour — that every other pre-existing id survives too.
1924
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2396
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1925
2397
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1926
2398
  if (errs.length) {
1927
2399
  const first = errs[0];
@@ -2104,9 +2576,9 @@ function runRevert(args) {
2104
2576
  return;
2105
2577
  }
2106
2578
  const span = curFull;
2107
- const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
2579
+ const beforeIds = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).ids;
2108
2580
  const updated = splitLines(source).filter((_, i) => i < span.start || i >= span.end).join("");
2109
- const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
2581
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2110
2582
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
2111
2583
  if (errs.length) {
2112
2584
  const first = errs[0];