@geml/geml 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -284,16 +284,37 @@ export const TOOLS = [
284
284
  type: "string",
285
285
  description: "What to read: a block id (with or without `#`), a `## Heading` line (its whole section), `=== type` for every block of a type, or a `@<hex>` content address for a block with no id — the forms `geml_list` prints",
286
286
  },
287
+ view: {
288
+ type: "boolean",
289
+ description: "Read THROUGH an `embed` block to the entity block it stands for, following a multi-layer chain to its end. An `embed` has no content of its own, so this is the only way to see what it points at; on any other block it changes nothing. Returns {from, content}: `from` names the document the content actually came from, and its references and relative paths resolve against THAT document, not this one.",
290
+ },
291
+ part: {
292
+ type: "string",
293
+ enum: ["whole", "head", "body"],
294
+ description: "How much of the block to return (default: whole). `body` is usually what you want together with `view`.",
295
+ },
287
296
  },
288
297
  required: ["file", "id"],
289
298
  },
290
299
  run: (args) => {
291
300
  const real = resolveInRoot(args.file);
292
301
  const sel = selectorArg(args.id);
293
- const run = runCli(["get", real, sel]);
302
+ // Same name, same enum, same validation as `geml_set` — one concept for a
303
+ // model to learn, and `body` is already taken there for the replacement text.
304
+ const part = args.part ?? "whole";
305
+ if (!["whole", "head", "body"].includes(part))
306
+ throw new Error(`part must be whole|head|body, got \`${part}\``);
307
+ const flag = part === "head" ? ["--head"] : part === "body" ? ["--body"] : [];
308
+ const run = runCli(["get", real, sel, ...flag, ...(args.view ? ["--view"] : [])]);
294
309
  if (!run.ok)
295
310
  throw new Error(run.stderr || `nothing matches ${sel}`);
296
- return run.stdout;
311
+ if (!args.view)
312
+ return run.stdout;
313
+ // There is no stderr across an MCP call, and provenance is mandatory: lift
314
+ // it out of the CLI's pinned `view: <sel> -> <doc>[#<id>]` line into a
315
+ // field of its own.
316
+ const m = /^view: .*? -> (.+)$/m.exec(run.stderr);
317
+ return { from: m ? m[1].trim() : null, content: run.stdout };
297
318
  },
298
319
  },
299
320
  {
@@ -1,3 +1,8 @@
1
1
  import { type Document } from "./geml.js";
2
2
  import { type RenderOptions } from "./render.js";
3
3
  export declare function renderHtml(doc: Document, opts?: RenderOptions): string;
4
+ export declare const pageAssets: {
5
+ readonly css: "\n:root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }\n* { box-sizing: border-box; }\nbody { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,\"PingFang SC\",\"Microsoft Yahei\",sans-serif; }\nmain { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }\nh1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }\nh1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh3 { font-size:1.25em; } h4 { font-size:1em; }\np { margin:.7em 0; }\na { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }\ncode { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }\npre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }\npre code { background:none; padding:0; font-size:.85em; }\npre.output { background:#0d1117; color:#e6edf3; }\npre.output code { color:inherit; }\nul,ol { padding-left:1.6em; } li { margin:.2em 0; }\nul.task-list { list-style:none; padding-left:.2em; }\nli.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }\nli.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }\nli.task input[type=checkbox]:checked::after { content:\"✓\"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }\naside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }\naside.aside { border-left-color:#8b949e; background:#f6f8fa; }\naside.warning { border-left-color:#d97706; background:#fff8f0; }\naside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }\nfigure { margin:1.2em 0; }\nfigcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }\ntable.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }\ntable.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }\ntable.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }\ntable.geml-table thead th::after { content:\" \\2195\"; color:var(--muted); font-size:.8em; }\ntable.geml-table thead th.asc::after { content:\" \\2191\"; color:var(--accent); }\ntable.geml-table thead th.desc::after { content:\" \\2193\"; color:var(--accent); }\ntable.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }\ntable.geml-table td.computed { color:#0a7c52; }\ntable.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }\n.table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }\n.table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }\n.table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }\n.geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }\n.c-title { font-size:15px; font-weight:600; fill:var(--fg); }\n.c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }\n.media { max-width:100%; border-radius:8px; }\n.diagram-src { color:var(--muted); } .render-error { color:#cf222e; }\n.math-block { overflow-x:auto; padding:.4em 0; }\nsup.fn a { font-size:.75em; }\n.geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }\n.geml-footer code { font-size:.95em; }\n.code-graph { margin:1.4em 0; }\n.cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }\n.cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }\n.cg-svg { display:block; }\n.cg-search-wrap { position:relative; display:inline-block; }\n.cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }\n.cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }\n.cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }\n.cg-search-row:hover { background:var(--bd); }\n.cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }\n.cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }\n.cg-search-grp:first-of-type { border-top:0; }\n.cg-stage { display:flex; gap:10px; align-items:flex-start; }\n.cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }\n.cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }\n.cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }\n.cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }\n.cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }\n.cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }\n.cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }\n.cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }\n.cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }\n.cg-crumb .cg-seg:hover { text-decoration:underline; }\n.cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }\n.cg-flash { color:#b42318; }\n.cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }\n.cg-upbtn { cursor:pointer; }\n.cg-upbtn circle { fill:#fff; stroke:#94a3b8; }\n.cg-upbtn text { font-size:11px; fill:#57606a; }\n.cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }\n.cg-upbtn:hover text { fill:var(--accent); }\n.cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }\n.cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }\n.cg-chip { display:inline-flex; align-items:center; gap:4px; }\n.cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }\n.cg-note { font-size:.8em; color:#9a6700; }\n.cg-n rect { fill:#eef2f7; stroke:#94a3b8; }\n.cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }\n.cg-n { cursor:pointer; }\n.cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }\n.cg-n.leaf { opacity:.45; }\n.cg-n.test rect { stroke-dasharray:3 2; }\n.cg-n.grp rect { stroke-width:1.8; }\n.cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }\n.cg-e.cand { stroke-dasharray:2 3; }\n.cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }\n.cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */\n.cg-e.soft { opacity:.55; }\n.cg-svg.hl .cg-n { opacity:.22; }\n.cg-svg.hl .cg-e { opacity:.1; }\n.cg-svg.hl .cg-n.hl { opacity:1; }\n.cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }\n";
6
+ readonly js: "\n(function () {\n function cmp(a, b) {\n var na = a.dataset.sort, nb = b.dataset.sort;\n if (na !== undefined && nb !== undefined) return parseFloat(na) - parseFloat(nb);\n return (a.textContent || \"\").localeCompare(b.textContent || \"\");\n }\n document.querySelectorAll(\"table.geml-table\").forEach(function (table) {\n var tbody = table.tBodies[0];\n if (!tbody) return;\n // Sort on header click.\n var ths = table.tHead ? table.tHead.rows[0].cells : [];\n Array.prototype.forEach.call(ths, function (th, col) {\n th.addEventListener(\"click\", function () {\n var dir = th.classList.contains(\"asc\") ? \"desc\" : \"asc\";\n Array.prototype.forEach.call(ths, function (h) { h.classList.remove(\"asc\", \"desc\"); });\n th.classList.add(dir);\n var rows = Array.prototype.slice.call(tbody.rows);\n rows.sort(function (r1, r2) {\n var c = cmp(r1.cells[col], r2.cells[col]);\n return dir === \"asc\" ? c : -c;\n });\n rows.forEach(function (r) { tbody.appendChild(r); });\n });\n });\n // Filter rows.\n var fig = table.closest(\".table-figure\");\n var input = fig ? fig.querySelector(\".table-filter\") : null;\n if (input) input.addEventListener(\"input\", function () {\n var q = input.value.toLowerCase();\n Array.prototype.forEach.call(tbody.rows, function (r) {\n r.style.display = (r.textContent || \"\").toLowerCase().indexOf(q) >= 0 ? \"\" : \"none\";\n });\n });\n });\n})();\n";
7
+ readonly codeGraphJs: string;
8
+ };
@@ -33,7 +33,7 @@ function page(title, body, ctx, source) {
33
33
  const wantLive = ctx.usedCodeGraph && !!ctx.opts.liveGraph;
34
34
  const lg = wantLive ? escAttr(ctx.opts.liveGraph) : "";
35
35
  const importMap = wantLive
36
- ? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
36
+ ? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js","node:os":"${lg}_node-stub.js"}}</script>\n`
37
37
  : "";
38
38
  const liveJs = wantLive
39
39
  ? `<script type="module">
@@ -90,6 +90,15 @@ export function renderHtml(doc, opts = {}) {
90
90
  : `layered method flow — roots: in-degree-zero methods (no <code>entry</code> declared)`;
91
91
  body = ctx.codeGraphFigure(opts.source, "", `<figcaption>${cap}</figcaption>`) + "\n" + body;
92
92
  }
93
+ // Fragment mode: the body markup alone, for embedding into an existing
94
+ // layout. No shell, no CDN tags, no inline CSS/JS — see RenderOptions.
95
+ if (opts.fragment)
96
+ return body + "\n";
93
97
  const title = opts.title ?? ctx.docTitle() ?? "GEML document";
94
98
  return page(title, body, ctx, opts.source);
95
99
  }
100
+ // The page shell's static assets, for fragment consumers: `css` styles every
101
+ // geml-* class a fragment emits (include once per site); `js` is the tables'
102
+ // sort/filter enhancement (once per page); `codeGraphJs` matters only when a
103
+ // fragment carries a code-graph mount. The full-page output inlines all three.
104
+ export const pageAssets = { css: CSS, js: JS, codeGraphJs: CODE_GRAPH_JS };
package/dist/render.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface RenderOptions {
8
8
  tableRows?: number;
9
9
  liveGraph?: string;
10
10
  graphSidecar?: string;
11
+ fragment?: boolean;
11
12
  }
12
13
  export declare function esc(s: string): string;
13
14
  export declare function escAttr(s: string): string;
package/dist/render.js CHANGED
@@ -538,6 +538,42 @@ export class RenderCtx {
538
538
  const classes = classAttr(["text", ...b.classes]);
539
539
  return `<div class="${classes}"${idAttr}>\n${inner}\n</div>`;
540
540
  }
541
+ case "data": {
542
+ // GEP-0005: the page shows a PREVIEW under the same row discipline
543
+ // tables use (opts.tableRows, default 500) — the MODEL always keeps
544
+ // everything. Direction follows the format's reading order: jsonl is
545
+ // an append-log, so the newest (last) lines are the preview; json is
546
+ // one value and reads from the top.
547
+ const limit = this.opts.tableRows ?? 500;
548
+ const src = typeof b.attrs["src"] === "string" ? b.attrs["src"] : undefined;
549
+ const fmt = typeof b.attrs["format"] === "string" ? b.attrs["format"]
550
+ : src !== undefined && /\.jsonl$/i.test(src) ? "jsonl" : "json";
551
+ let lines = b.raw ?? [];
552
+ if (lines.every((l) => l.trim() === "")) {
553
+ if (b.value !== undefined) {
554
+ // src= content loaded at build time: preview the canonical form.
555
+ lines = fmt === "jsonl" && Array.isArray(b.value)
556
+ ? b.value.map((v) => JSON.stringify(v))
557
+ : JSON.stringify(b.value, null, 2).split("\n");
558
+ }
559
+ else if (src !== undefined) {
560
+ // §9.4: a render-time source (http, or no resolver at build).
561
+ const cap0 = caption ? `<figcaption>${esc(caption)}</figcaption>` : "";
562
+ return `<figure${idAttr}><p class="table-note">external data <code>${esc(src)}</code> — loaded at render time</p>${cap0}</figure>`;
563
+ }
564
+ }
565
+ const tail = fmt === "jsonl" && lines.length > limit;
566
+ const shown = tail ? lines.slice(-limit) : lines.slice(0, limit);
567
+ const omitted = lines.length - shown.length;
568
+ const note = omitted > 0
569
+ ? `<p class="table-note">${tail
570
+ ? `showing the last ${shown.length} of ${lines.length} lines — earlier lines are in the document source`
571
+ : `showing the first ${shown.length} of ${lines.length} lines — the complete data is in the document source`}</p>`
572
+ : "";
573
+ const cap = caption ? `<figcaption>${esc(caption)}</figcaption>` : "";
574
+ const pre = `<pre class="data-src" data-format="${escAttr(fmt)}">${esc(shown.join("\n"))}</pre>`;
575
+ return `<figure${idAttr}>${tail ? note + pre : pre + note}${cap}</figure>`;
576
+ }
541
577
  case "table":
542
578
  return b.table ? this.table(b.table, b.id, caption) : `<p class="render-error">table failed to parse</p>`;
543
579
  case "diagram":
@@ -1954,7 +1990,24 @@ export function codeGraphRuntime(root) {
1954
1990
  // Rendered pages sit next to their codemap documents: a live mount
1955
1991
  // (viewer/playground) carries data-src, a CLI embed carries the src
1956
1992
  // path in data.start — either directory anchors doc-relative links.
1957
- var navBase = String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, "");
1993
+ // navBase prefixes EVERY url this view reaches for — the breadcrumb's
1994
+ // `location.href`, the search-index `<script src>`, a hit's jump target.
1995
+ // Both of its inputs are page data (a DOM attribute, the embedded graph
1996
+ // JSON), so a document that carried `data-src="javascript:…"` would turn
1997
+ // a breadcrumb click into script execution, and a `//host/` or
1998
+ // `https://host/` value would pull the search index off another origin.
1999
+ // A base is a doc-relative DIRECTORY and nothing else: anything bearing a
2000
+ // scheme or a network-path prefix is refused outright and links resolve
2001
+ // against the current page instead.
2002
+ // Everything this view navigates to or loads is built from page data, so
2003
+ // every such string is filtered here first: keep it if it is a
2004
+ // document-RELATIVE path, drop it to "" if it carries a scheme
2005
+ // (`javascript:`, `data:`) or a `//host` network-path prefix.
2006
+ function relOnly(u) {
2007
+ var s = String(u == null ? "" : u);
2008
+ return /^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s) || s.slice(0, 2) === "//" ? "" : s;
2009
+ }
2010
+ var navBase = relOnly(String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, ""));
1958
2011
  // A live mount (viewer/playground/served page) navigates IN PLACE over
1959
2012
  // the geml documents through this loader; only truly static pages fall
1960
2013
  // back to their pre-rendered sibling .html pages. Read LAZILY on every
@@ -1992,7 +2045,16 @@ export function codeGraphRuntime(root) {
1992
2045
  }
1993
2046
  catch (e) { /* stub */ }
1994
2047
  }
1995
- function openDoc(rel, gpath) {
2048
+ function openDoc(rel0, gpath) {
2049
+ // The target can come from a node's own data (a `data-k` key), not just
2050
+ // from a breadcrumb we built — so it goes through the same relative-path
2051
+ // filter as navBase. An absolute or scheme-bearing target says why it
2052
+ // was refused rather than navigating.
2053
+ var rel = relOnly(rel0);
2054
+ if (!rel) {
2055
+ flash("refusing to open " + String(rel0) + " — not a document-relative path");
2056
+ return;
2057
+ }
1996
2058
  var lv = live();
1997
2059
  if (lv) {
1998
2060
  Promise.resolve(lv({ doc: rel })).then(function (nd) {
@@ -2304,8 +2366,15 @@ export function codeGraphRuntime(root) {
2304
2366
  });
2305
2367
  }
2306
2368
  }
2307
- function gotoHit(doc, id, locate) {
2369
+ function gotoHit(doc0, id, locate) {
2308
2370
  searchMenu.hidden = true;
2371
+ // `doc` is a row from the loaded search index — page data, same as any
2372
+ // other target, so it gets the same relative-path filter.
2373
+ var doc = relOnly(doc0);
2374
+ if (!doc) {
2375
+ flash("refusing to open " + String(doc0) + " — not a document-relative path");
2376
+ return;
2377
+ }
2309
2378
  if (live() && !locate) {
2310
2379
  showCallees(doc + "#" + id);
2311
2380
  return;
package/dist/serialize.js CHANGED
@@ -174,6 +174,18 @@ function serTypedBlock(b) {
174
174
  else if (b.mode === "data") {
175
175
  body = Object.entries(b.data ?? {}).map(([k, v]) => `${k} = ${serDataValue(v)}`);
176
176
  }
177
+ else if (b.type === "data" && b.value !== undefined && b.attrs["src"] === undefined) {
178
+ // src= content lives in the FILE: a value loaded from it must never be
179
+ // inlined into the document by a re-format — the body stays as authored
180
+ // (normally empty), or the source of truth would silently fork.
181
+ // GEP-0005 canonical form — legal because this is DATA, not verbatim text
182
+ // (code stays byte-preserved): json pretty-prints at two spaces, jsonl is
183
+ // one compact value per line. Engine-less bodies (yaml/toml/unknown)
184
+ // never reach here (no `value`) and fall through byte-preserved.
185
+ body = String(b.attrs["format"] ?? "json") === "jsonl" && Array.isArray(b.value)
186
+ ? b.value.map((v) => JSON.stringify(v))
187
+ : JSON.stringify(b.value, null, 2).split("\n");
188
+ }
177
189
  else {
178
190
  body = b.raw ?? [];
179
191
  }
package/dist/table.js CHANGED
@@ -60,6 +60,27 @@ function parseDelimited(body, sep, header) {
60
60
  const width = rows.reduce((m, r) => Math.max(m, r.length), 0);
61
61
  return { columns: letters(width), align: [], header: false, cells: rows };
62
62
  }
63
+ // The character a data body splits on: the format's natural delimiter unless
64
+ // `delim=` names another single one — `;` for a European CSV, `|` for a
65
+ // pipe-delimited export (§6). Measured in code points, so an astral character
66
+ // counts as one. §4 attribute values carry no escape syntax, so a tab
67
+ // delimiter is spelled `format=tsv`, never `delim="\t"`; a value that is not
68
+ // exactly one character is an error and the natural delimiter is used, which
69
+ // keeps the rest of the table readable.
70
+ function resolveDelim(fmt, raw, diagnostics) {
71
+ const natural = fmt === "tsv" ? "\t" : ",";
72
+ if (raw === undefined)
73
+ return natural;
74
+ const d = String(raw);
75
+ if ([...d].length === 1)
76
+ return d;
77
+ diagnostics.push({
78
+ severity: "error",
79
+ code: "bad-table-delimiter",
80
+ message: `\`delim="${d}"\` must be exactly one character (for a tab use \`format=tsv\`); split on ${fmt === "tsv" ? "a tab" : "`,`"} instead`,
81
+ });
82
+ return natural;
83
+ }
63
84
  function letters(n) {
64
85
  const out = [];
65
86
  for (let i = 0; i < n; i++)
@@ -271,11 +292,16 @@ export function parseTable(body, attrs, line, sink) {
271
292
  if (fmt === "csv" || fmt === "tsv") {
272
293
  const headerAttr = attrs["header"];
273
294
  const header = headerAttr === undefined ? true : headerAttr === true || headerAttr === 1 || headerAttr === "1";
274
- raw = parseDelimited(body, fmt === "tsv" ? "\t" : ",", header);
295
+ raw = parseDelimited(body, resolveDelim(fmt, attrs["delim"], diagnostics), header);
275
296
  }
276
297
  else {
277
298
  if (fmt !== undefined)
278
299
  diagnostics.push({ severity: "warning", code: "unknown-table-format", message: `unknown table format \`${fmt}\`; parsed as visual grid` });
300
+ // `delim` refines the data form; it does not select it. Silently dropping it
301
+ // would leave a `=== table {delim=";"}` parsed as a one-column visual grid
302
+ // with nothing to say why.
303
+ if (attrs["delim"] !== undefined)
304
+ diagnostics.push({ severity: "warning", code: "ignored-table-delimiter", message: "`delim` applies to a data body (`format=csv`/`tsv`); this body was parsed as a visual grid, so it is ignored" });
279
305
  raw = parseVisual(body);
280
306
  }
281
307
  const columns = [...raw.columns];
package/dist/to-md.js CHANGED
@@ -48,10 +48,32 @@ function inline(n) {
48
48
  function seq(ns) {
49
49
  return ns.map(inline).join("");
50
50
  }
51
+ // Escape a `|` so GFM keeps it inside the cell instead of splitting the row.
52
+ // GFM resolves backslash escapes in a row BEFORE it splits on `|`, so a
53
+ // backslash run sitting right in front of our escape would eat it: a code span
54
+ // holding `a\|b` became `a\\|b`, which reads as a literal backslash followed by
55
+ // an UNescaped pipe — a spurious cell break. Double any such run first, then
56
+ // escape the pipe. Runs already produced by escText (`\\` for a literal
57
+ // backslash) survive this unchanged, so pre-rendered Markdown stays intact.
58
+ //
59
+ // The backslash run is matched as `\\+\|?` — one ATOMIC token, run and pipe
60
+ // together — not as `(\\*)\|`. The latter is quadratic: on a cell holding a
61
+ // long run of backslashes and no pipe, the engine matches the run from every
62
+ // index in it and fails at the required `|` each time. Here the greedy `\\+`
63
+ // takes the whole run in one match and the trailing `\|?` is optional, so
64
+ // nothing backtracks and each character is visited once.
65
+ function escPipe(s) {
66
+ return s.replace(/\\+\|?|\|/g, (m) => {
67
+ if (m.charAt(m.length - 1) !== "|")
68
+ return m; // a run with no pipe after it
69
+ const bs = m.slice(0, -1); // the run that would otherwise eat our escape
70
+ return bs + bs + "\\|";
71
+ });
72
+ }
51
73
  // Inline text for a table cell: render inlines, then neutralise the two bytes
52
74
  // that would break a GFM cell.
53
75
  function cellText(c) {
54
- return seq(c.inlines).replace(/\|/g, "\\|").replace(/\n/g, " ");
76
+ return escPipe(seq(c.inlines)).replace(/\n/g, " ");
55
77
  }
56
78
  // ---------------------------------------------------------------------------
57
79
  // Tables
@@ -72,7 +94,7 @@ function tableToMd(t, notes) {
72
94
  const lines = [];
73
95
  if (t.caption)
74
96
  lines.push(`*${t.caption}*`, "");
75
- lines.push(`| ${cols.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`);
97
+ lines.push(`| ${cols.map(escPipe).join(" | ")} |`);
76
98
  lines.push(`| ${cols.map((_, i) => sep(t.align[i])).join(" | ")} |`);
77
99
  const pad = (cells) => {
78
100
  while (cells.length < cols.length)
@@ -144,6 +166,11 @@ function typedToMd(b, notes) {
144
166
  const raw = b.raw ?? [];
145
167
  if (b.type === "code")
146
168
  return fence(attr(b, "lang") ?? "", raw);
169
+ // GEP-0005: a data block projects as a fenced code block in its format —
170
+ // the nearest GFM shape (Markdown has no verified-data construct; that loss
171
+ // is the usual --to md lossiness, not a defect of the projection).
172
+ if (b.type === "data")
173
+ return fence(attr(b, "format") ?? "json", raw);
147
174
  if (b.type === "math")
148
175
  return ["$$", ...raw, "$$"].join("\n");
149
176
  if (b.type === "table" && b.table)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "mcpName": "io.github.geml-spec/geml",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "dist",
17
17
  "codemap",
18
+ "skill",
18
19
  "README.md",
19
20
  "LICENSE"
20
21
  ],
package/skill/SKILL.md ADDED
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: geml
3
+ description: >-
4
+ Read, author, edit, or validate GEML — the General Expressive Markup Language
5
+ (.geml files) and its .gemlhistory versioning sidecar. Use whenever creating
6
+ or modifying a .geml/.gemlhistory file, converting Markdown to GEML, or when
7
+ the user mentions GEML, typed blocks, === fences, or geml-chart. Ensures the
8
+ output parses cleanly (zero error diagnostics) against the reference parser.
9
+ ---
10
+
11
+ # Writing and reading GEML correctly
12
+
13
+ GEML expresses **every** kind of structured content — code, tables, diagrams,
14
+ math, callouts, metadata — through **one** primitive: the **typed block**
15
+ (`=== <type> {#id .class key=val}` … `===`). Always finish by **validating**: a
16
+ GEML file is correct only when `geml check` reports **no error diagnostics**
17
+ (exit 0).
18
+
19
+ ## Golden rules (the things that are easy to get wrong)
20
+
21
+ 1. **Fences are runs of `=` (≥3).** A block closes at a `=` run of **exactly
22
+ the opening length**, or — when the block has an `#id` — at the labeled
23
+ fence `=== #id` (any `=` run ≥3 followed by the id; no length counting).
24
+ 2. **Nest with longer fences.** A body containing `===` lines needs a
25
+ **longer** outer fence: `====` wraps `===`. Careful: a same-length bare
26
+ `===` in the body closes the block even if you intend a labeled close —
27
+ the labeled close only spares you length-counting, it does NOT protect
28
+ same-length inner fences.
29
+ 3. **Headings are ATX `#` only** (`#`…`######`). No setext underlines, no
30
+ `---` breaks, no YAML frontmatter — metadata is a `=== meta` block, and the
31
+ document TITLE lives there (`title = "…"`), not in an H1. A heading may
32
+ carry a stable explicit id: `## Title {#sec}`.
33
+ 4. **Every `#id` is unique per document**, and **every reference must
34
+ resolve** — `[t](#id)`, `[[#id]]`, `[^id]`, `src=`, `data=`,
35
+ `other.geml#id`. An unresolved reference is a build **error**.
36
+ 5. **No raw HTML.** Notes → `=== note`, comments → `%%` lines, hidden content
37
+ → `{hidden}`, addressable prose → `=== text`, verified data → `=== data`
38
+ (json/jsonl; `code` shows text, `data` IS data).
39
+
40
+ ## Validate every time
41
+
42
+ ```sh
43
+ geml check file.geml # diagnostics + exit code only; exit 0 = correct
44
+ geml check --json file.geml # machine-readable diagnostics array
45
+ ```
46
+
47
+ If `geml` is not on PATH: `npm i -g @geml/geml` (package `@geml/geml`, command
48
+ `geml`), or run without installing via `npx -y @geml/geml check file.geml`.
49
+ Inside the geml-spec repo prefer the local build:
50
+ `node geml-parser/dist/geml.js <args>`. If no parser is reachable, follow the
51
+ golden rules and validate once it is.
52
+
53
+ ## Work blockwise (agent editing)
54
+
55
+ ```sh
56
+ geml get file.geml '#id' # read ONE block (a heading id = its whole section)
57
+ geml set file.geml '#id' --in f # replace ONE block (re-parsed; never writes a broken doc)
58
+ geml history save file.geml -m "…" # snapshot to .gemlhistory after each meaningful edit
59
+ geml revert file.geml '#id' # roll ONE block back (--rev -2 | changed | <rev-id>)
60
+ ```
61
+
62
+ ## Full reference — pull ONE section, not the whole file
63
+
64
+ `references/authoring.geml` (under this skill's base directory) holds the
65
+ detailed reference. Fetch just the section you need:
66
+
67
+ ```sh
68
+ geml get <skill-base>/references/authoring.geml '#tables'
69
+ ```
70
+
71
+ | section | covers |
72
+ |---|---|
73
+ | `#typed-block` | block anatomy, attribute object, examples of every registered type |
74
+ | `#tables` | pipe/CSV bodies, `compute=`, `summary=`, printf display, `span=` merges |
75
+ | `#charts` | `geml-chart` diagrams bound to a table via `data=#id` |
76
+ | `#data` | the `data` block — value tree, `json`/`jsonl` formats, blind append, chart binding |
77
+ | `#inline` | inline markup, links/refs/footnotes, task lists, media embeds |
78
+ | `#hidden` | `%%` comments, `{hidden}`, `{{key}}` interpolation, `=== embed` |
79
+ | `#cli` | every CLI verb — get/set/add/delete/rename, `--to` conversion, check |
80
+ | `#editing` | the blockwise editing loop + `.gemlhistory` versioning |
81
+ | `#project-config` | carrying a project's Claude config docs in GEML, quietly |
82
+ | `#checklist` | full pre-flight authoring checklist |