@geml/geml 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +248 -217
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +34 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +49 -49
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +431 -431
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +77 -77
- package/codemap/serve.mjs +585 -585
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +155 -148
- package/dist/chart.d.ts +1 -0
- package/dist/chart.js +4 -1
- package/dist/diagnostics.d.ts +1 -1
- package/dist/diagnostics.js +16 -0
- package/dist/geml.d.ts +5 -1
- package/dist/geml.js +1277 -283
- package/dist/history.d.ts +11 -8
- package/dist/history.js +20 -15
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +108 -38
- package/dist/render-html.d.ts +5 -0
- package/dist/render-html.js +45 -36
- package/dist/render.d.ts +1 -0
- package/dist/render.js +172 -136
- package/dist/selector.d.ts +55 -0
- package/dist/selector.js +112 -0
- package/dist/serialize.js +12 -0
- package/dist/table.js +27 -1
- package/dist/to-md.js +5 -0
- package/package.json +67 -66
- package/skill/SKILL.md +82 -0
- package/skill/references/authoring.geml +333 -0
package/dist/geml.js
CHANGED
|
@@ -9,23 +9,25 @@
|
|
|
9
9
|
// math, media embeds, links, auto-references, footnotes) and build-time
|
|
10
10
|
// reference validation (§8 — unique ids, resolvable internal/cross-document
|
|
11
11
|
// references).
|
|
12
|
-
import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, realpathSync, statSync, existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs";
|
|
13
13
|
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { spawnSync } from "node:child_process";
|
|
16
|
-
import {
|
|
17
|
+
import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
17
18
|
import { renderHtml } from "./render-html.js";
|
|
18
19
|
import { normalizeBlockId } from "./block-edit.js";
|
|
19
20
|
import { normalizeSource } from "./diagnostics.js";
|
|
20
21
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
21
22
|
import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
|
|
22
23
|
import { parseTable } from "./table.js";
|
|
23
|
-
import { buildChart } from "./chart.js";
|
|
24
|
+
import { USES, buildChart } from "./chart.js";
|
|
24
25
|
import { mdToGeml } from "./from-md.js";
|
|
25
26
|
import { serialize } from "./serialize.js";
|
|
27
|
+
import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
|
|
26
28
|
import { gemlToMd } from "./to-md.js";
|
|
27
29
|
export { mdToGeml } from "./from-md.js";
|
|
28
|
-
export { renderHtml } from "./render-html.js";
|
|
30
|
+
export { renderHtml, pageAssets } from "./render-html.js";
|
|
29
31
|
export { serialize } from "./serialize.js";
|
|
30
32
|
export { gemlToMd } from "./to-md.js";
|
|
31
33
|
// A block id is any non-whitespace run (§4), so it may contain regex
|
|
@@ -36,6 +38,57 @@ export { gemlToMd } from "./to-md.js";
|
|
|
36
38
|
function reLit(s) {
|
|
37
39
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
40
|
}
|
|
41
|
+
// The `data` block's format engines (GEP-0005), shared by the inline-body
|
|
42
|
+
// path and the `src=` pass: parse `body` under `fmt`, returning the value
|
|
43
|
+
// and/or diagnostics. `openLineNo` anchors line numbers — the open fence for
|
|
44
|
+
// an inline body, the block's own line for external content.
|
|
45
|
+
function parseDataBody(fmt, body, openLineNo) {
|
|
46
|
+
const diags = [];
|
|
47
|
+
if (fmt === "json") {
|
|
48
|
+
const text = body.join("\n");
|
|
49
|
+
try {
|
|
50
|
+
return { value: JSON.parse(text), diags };
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
diags.push({ severity: "error", code: "data-parse", message: `data: body is not valid JSON (${e instanceof Error ? e.message : String(e)})`, line: jsonErrorLine(e, text, openLineNo) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (fmt === "jsonl") {
|
|
57
|
+
const values = [];
|
|
58
|
+
let ok = true;
|
|
59
|
+
for (let li = 0; li < body.length; li++) {
|
|
60
|
+
const t = body[li].trim();
|
|
61
|
+
if (t === "")
|
|
62
|
+
continue; // blank lines are permitted and ignored
|
|
63
|
+
try {
|
|
64
|
+
values.push(JSON.parse(t));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
diags.push({ severity: "error", code: "data-parse", message: `data: body line ${li + 1} is not one JSON value`, line: openLineNo + 1 + li });
|
|
68
|
+
ok = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (ok)
|
|
72
|
+
return { value: values, diags };
|
|
73
|
+
}
|
|
74
|
+
else if (fmt === "yaml" || fmt === "toml") {
|
|
75
|
+
diags.push({ severity: "warning", code: "data-format-no-engine", message: `data: no \`${fmt}\` engine in this processor; body kept raw, not verified`, line: openLineNo });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
diags.push({ severity: "warning", code: "unknown-data-format", message: `unknown data format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
79
|
+
}
|
|
80
|
+
return { diags };
|
|
81
|
+
}
|
|
82
|
+
// Map a JSON.parse failure to the document line it happened on. V8 messages
|
|
83
|
+
// carry "at position N" (newer Nodes add line/column, but position is the
|
|
84
|
+
// stable token); counting newlines up to it gives the 1-based body line, and
|
|
85
|
+
// the open fence line offsets it into the document. No position -> the fence.
|
|
86
|
+
function jsonErrorLine(e, text, openLineNo) {
|
|
87
|
+
const m = /position (\d+)/.exec(e instanceof Error ? e.message : "");
|
|
88
|
+
if (!m)
|
|
89
|
+
return openLineNo;
|
|
90
|
+
return openLineNo + text.slice(0, Number(m[1])).split("\n").length;
|
|
91
|
+
}
|
|
39
92
|
// Re-exported from ./diagnostics.js so that `Diagnostic` stays importable from
|
|
40
93
|
// the package root. The catalogue of codes lives there (spec Appendix A).
|
|
41
94
|
export { SEVERITY } from "./diagnostics.js";
|
|
@@ -46,6 +99,7 @@ const REGISTRY = {
|
|
|
46
99
|
diagram: "raw",
|
|
47
100
|
math: "raw",
|
|
48
101
|
table: "raw", // structured table parsing lands in M3
|
|
102
|
+
data: "raw", // GEP-0005: value tree — a format engine parses the raw body in a second stage
|
|
49
103
|
embed: "raw", // block transclusion: `src=` points at the content, body unused
|
|
50
104
|
note: "flow",
|
|
51
105
|
text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
|
|
@@ -59,6 +113,24 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
|
|
|
59
113
|
// ---------------------------------------------------------------------------
|
|
60
114
|
const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
|
|
61
115
|
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
|
|
116
|
+
// A line with the exact shape of a labeled close (§3): a `=` run and a `#id`,
|
|
117
|
+
// nothing else. Matched against lines that fell through to paragraph text,
|
|
118
|
+
// where such a line means the close closed nothing (stray-labeled-fence).
|
|
119
|
+
const STRAY_LABELED_FENCE = /^={3,}[ \t]+#(\S+)[ \t]*$/;
|
|
120
|
+
// The registered block types (§3's registry), for the fence-like check below:
|
|
121
|
+
// an unknown word after `===` is likelier a wall of `=` art or foreign syntax,
|
|
122
|
+
// so only a KNOWN type name earns the warning.
|
|
123
|
+
const REGISTERED_TYPES = new Set(["code", "diagram", "table", "math", "embed", "note", "text", "meta", "data"]);
|
|
124
|
+
// A line that WANTS to open a fence — a `=` run and a registered type name —
|
|
125
|
+
// but failed the fence production. The classic shape is bare, unbraced
|
|
126
|
+
// attributes (`=== embed src=#a`): the line silently became prose and any
|
|
127
|
+
// reference in it was never checked, which buried a real bug (all eight
|
|
128
|
+
// embeds of the playground showcase shipped in this shape, rendering as
|
|
129
|
+
// paragraphs under a green `check`). Matched, like STRAY_LABELED_FENCE, only
|
|
130
|
+
// against lines that fell through to paragraph text — raw block bodies and
|
|
131
|
+
// `\`-folded fence lines never reach that position, so the measured corpus
|
|
132
|
+
// false-positive rate is zero.
|
|
133
|
+
const FENCE_LIKE = /^={3,}[ \t]+([A-Za-z][A-Za-z0-9_-]*)\b/;
|
|
62
134
|
const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
63
135
|
// Maximum block/list nesting depth the recursive-descent scanner will build
|
|
64
136
|
// before emitting a diagnostic instead of recursing further. Guards parse()
|
|
@@ -294,22 +366,36 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
294
366
|
const type = open[2];
|
|
295
367
|
const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
|
|
296
368
|
const openLineNo = base + i + 1;
|
|
297
|
-
// Collect the body. A block closes on
|
|
298
|
-
// length, OR — when it has an id —
|
|
299
|
-
// of any length ≥ 3 followed by the block's id). The
|
|
300
|
-
//
|
|
301
|
-
//
|
|
369
|
+
// Collect the body. A block closes on the FIRST line that is a bare fence
|
|
370
|
+
// of exactly the opening length, OR — when it has an id — a labeled fence
|
|
371
|
+
// `=== #id` (a `=` run of any length ≥ 3 followed by the block's id). The
|
|
372
|
+
// labeled close can't be gotten wrong by miscounting `=`, but it does NOT
|
|
373
|
+
// shadow the bare close: a same-length bare fence in the body still ends
|
|
374
|
+
// the block first, so nesting needs a longer outer fence (§3).
|
|
302
375
|
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
|
|
303
376
|
const body = [];
|
|
304
377
|
let j = i + consumed;
|
|
305
378
|
let closed = false;
|
|
379
|
+
let closedByBare = false;
|
|
306
380
|
for (; j < lines.length; j++) {
|
|
307
|
-
if (isCloseFence(lines[j], openLen)
|
|
381
|
+
if (isCloseFence(lines[j], openLen)) {
|
|
382
|
+
closed = true;
|
|
383
|
+
closedByBare = true;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
if (labeled && labeled.test(lines[j])) {
|
|
308
387
|
closed = true;
|
|
309
388
|
break;
|
|
310
389
|
}
|
|
311
390
|
body.push(lines[j]);
|
|
312
391
|
}
|
|
392
|
+
// Remember a bare close of an id-bearing block (first definition wins,
|
|
393
|
+
// mirroring ctx.ids): if a `=== #id` line for it turns up later as plain
|
|
394
|
+
// text, the stray-labeled-fence warning can name the line that really
|
|
395
|
+
// closed the block.
|
|
396
|
+
if (closedByBare && attrs.id !== undefined && !ctx.bareClosed?.has(attrs.id)) {
|
|
397
|
+
(ctx.bareClosed ??= new Map()).set(attrs.id, base + j + 1);
|
|
398
|
+
}
|
|
313
399
|
if (!closed) {
|
|
314
400
|
const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
|
|
315
401
|
diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
|
|
@@ -325,11 +411,13 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
325
411
|
// the extras below are per type.
|
|
326
412
|
let validRe;
|
|
327
413
|
if (type === "table")
|
|
328
|
-
validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
|
|
414
|
+
validRe = /^(src|format|delim|header|format-data|compute\d*|summary\d*|span\d*)$/;
|
|
415
|
+
else if (type === "data")
|
|
416
|
+
validRe = /^(format|schema|src)$/;
|
|
329
417
|
else if (type === "embed")
|
|
330
418
|
validRe = /^(src)$/;
|
|
331
419
|
else if (type === "diagram")
|
|
332
|
-
validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
|
|
420
|
+
validRe = /^(src|data|format|format-data|delim|header|type|rows|x|y|size|series)$/;
|
|
333
421
|
// `src`/`anchor` on a `code` block are the code-graph profile's
|
|
334
422
|
// (docs/codemap-profile.md): every document `geml codemap build` writes
|
|
335
423
|
// carries them, so warning on them would warn on our own output.
|
|
@@ -415,7 +503,72 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
415
503
|
}
|
|
416
504
|
else {
|
|
417
505
|
block.raw = body;
|
|
418
|
-
if (type === "
|
|
506
|
+
if (type === "data") {
|
|
507
|
+
// §GEP-0005: the value tree. The body stayed raw at scan time; a
|
|
508
|
+
// format engine parses it here — the same two-stage shape `table`
|
|
509
|
+
// uses. Admission to the format registry requires a SELF-DESCRIBING
|
|
510
|
+
// syntax (bytes alone determine the value): the core ships `json`
|
|
511
|
+
// (default — the model's own serialization) and `jsonl`; `yaml` and
|
|
512
|
+
// `toml` are reserved names with no engine here, and degrade exactly
|
|
513
|
+
// like an unknown `diagram` format: body kept raw, one warning.
|
|
514
|
+
const fmtRaw = attrs.attrs["format"];
|
|
515
|
+
const fmt = fmtRaw === undefined ? "json" : String(fmtRaw);
|
|
516
|
+
// `src=` names external content — the same one-source rule tables
|
|
517
|
+
// have (§6): exactly one of `src=` and an inline body. The engine
|
|
518
|
+
// runs over the file in a second pass (resolveDataSources); running
|
|
519
|
+
// it here over the empty body would report a spurious parse error.
|
|
520
|
+
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
521
|
+
const hasBody = body.some((l) => l.trim() !== "");
|
|
522
|
+
if (srcAttr !== undefined && srcAttr !== "" && hasBody) {
|
|
523
|
+
diags.push({ severity: "error", code: "data-src-and-body", message: "data: carries both `src=` and an inline body; exactly one is permitted (the body wins here)", line: openLineNo });
|
|
524
|
+
}
|
|
525
|
+
if (srcAttr !== undefined && srcAttr !== "" && !hasBody) {
|
|
526
|
+
(ctx.dataSources ??= []).push({ block, line: openLineNo, target: srcAttr });
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
const parsed = parseDataBody(fmt, body, openLineNo);
|
|
530
|
+
for (const d of parsed.diags)
|
|
531
|
+
diags.push(d);
|
|
532
|
+
if (parsed.value !== undefined)
|
|
533
|
+
block.value = parsed.value;
|
|
534
|
+
}
|
|
535
|
+
// `schema=` is reference-checked ONLY (GEP-0005): it must name a
|
|
536
|
+
// block or a GEML document; validating the value against it is a
|
|
537
|
+
// later GEP. The reference goes through the ordinary §8 resolver so
|
|
538
|
+
// a dangling schema rots loudly like any other reference.
|
|
539
|
+
const schema = attrs.attrs["schema"];
|
|
540
|
+
if (schema !== undefined) {
|
|
541
|
+
const s = typeof schema === "string" ? schema.trim() : "";
|
|
542
|
+
if (s.startsWith("#") && s.length > 1) {
|
|
543
|
+
ctx.refs.push({ kind: "internal", anchor: s.slice(1), line: openLineNo });
|
|
544
|
+
}
|
|
545
|
+
else if (/\.geml(#|$)/i.test(s)) {
|
|
546
|
+
const h = s.indexOf("#");
|
|
547
|
+
if (h < 0)
|
|
548
|
+
ctx.refs.push({ kind: "cross", doc: s, anchor: undefined, line: openLineNo });
|
|
549
|
+
else
|
|
550
|
+
ctx.refs.push({ kind: "cross", doc: s.slice(0, h), anchor: s.slice(h + 1), line: openLineNo });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
diags.push({ severity: "error", code: "bad-data-schema", message: `data: \`schema=${s}\` must name a block (\`#id\`) or a GEML document (\`doc.geml[#id]\`)`, line: openLineNo });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// First definition wins, matching ctx.ids/ctx.tables.
|
|
557
|
+
if (block.id !== undefined && block.value !== undefined && !ctx.dataValues?.has(block.id)) {
|
|
558
|
+
(ctx.dataValues ??= new Map()).set(block.id, block.value);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (type === "code") {
|
|
562
|
+
// `src=` on a code block is a ROUTE to the code it shows —
|
|
563
|
+
// `<path>[#L<start>[-<end>]]` — resolved in a second pass, like a
|
|
564
|
+
// table's `src=`. The code-graph runtime has always fetched and
|
|
565
|
+
// sliced it at render time; checking it here is what makes a stale
|
|
566
|
+
// range a build error instead of a panel that silently shows a path.
|
|
567
|
+
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
568
|
+
if (srcAttr !== undefined && srcAttr !== "")
|
|
569
|
+
(ctx.codeSources ??= []).push({ block, line: openLineNo, target: srcAttr });
|
|
570
|
+
}
|
|
571
|
+
else if (type === "table") {
|
|
419
572
|
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
420
573
|
// §6: parse the raw body (visual or csv/tsv) into one table model.
|
|
421
574
|
const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
|
|
@@ -501,6 +654,38 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
501
654
|
para.push(lines[i]);
|
|
502
655
|
i++;
|
|
503
656
|
}
|
|
657
|
+
// A line shaped exactly like a labeled close (`=== #id`) that got this far
|
|
658
|
+
// closed nothing — when the id's block was already ended by a same-length
|
|
659
|
+
// bare fence in its body (§3), everything from that fence on silently fell
|
|
660
|
+
// out of the block. Warn: "ok: no diagnostics" over a truncated document is
|
|
661
|
+
// the failure mode this diagnostic exists for. The id is used only as a Map
|
|
662
|
+
// key here — no RegExp is built from it, so reLit() does not apply.
|
|
663
|
+
for (let k = 0; k < para.length; k++) {
|
|
664
|
+
const stray = STRAY_LABELED_FENCE.exec(para[k]);
|
|
665
|
+
if (!stray)
|
|
666
|
+
continue;
|
|
667
|
+
const id = stray[1];
|
|
668
|
+
const lineNo = paraStart + k;
|
|
669
|
+
const closedAt = ctx.bareClosed?.get(id);
|
|
670
|
+
diags.push({
|
|
671
|
+
severity: "warning", code: "stray-labeled-fence", line: lineNo,
|
|
672
|
+
message: closedAt !== undefined
|
|
673
|
+
? `labeled fence for \`#${id}\` at line ${lineNo}, but block \`#${id}\` was already closed by a bare fence at line ${closedAt} — body may be silently truncated`
|
|
674
|
+
: `labeled fence for \`#${id}\` closes no block; the line is plain paragraph text`,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
for (let k = 0; k < para.length; k++) {
|
|
678
|
+
// Sibling trap to the stray labeled close: a would-be OPEN fence that
|
|
679
|
+
// missed the production and silently became prose (§3 requires braced
|
|
680
|
+
// attributes; `=== embed src=#a` is the classic miss).
|
|
681
|
+
const like = FENCE_LIKE.exec(para[k]);
|
|
682
|
+
if (like && REGISTERED_TYPES.has(like[1])) {
|
|
683
|
+
diags.push({
|
|
684
|
+
severity: "warning", code: "fence-like-line", line: paraStart + k,
|
|
685
|
+
message: `line looks like an open fence for \`${like[1]}\` but is not one — attributes must be braced (\`=== ${like[1]} {…}\`); the line reads as plain paragraph text`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
504
689
|
const text = interpolate(para.join("\n"), paraStart, ctx);
|
|
505
690
|
blocks.push({ kind: "paragraph", text, inlines: parseInline(text, paraStart, ctx) });
|
|
506
691
|
}
|
|
@@ -554,6 +739,11 @@ function chartSourceTable(ctx, opts, block, target, line) {
|
|
|
554
739
|
format: typeof block.attrs["format-data"] === "string" ? block.attrs["format-data"] : inferDataFormat(target),
|
|
555
740
|
header: block.attrs["header"] === undefined ? true : block.attrs["header"],
|
|
556
741
|
};
|
|
742
|
+
// A chart reading a `;`-delimited export needs the same delimiter override a
|
|
743
|
+
// table does; the table rules validate it (§6).
|
|
744
|
+
const delim = block.attrs["delim"];
|
|
745
|
+
if (delim !== undefined)
|
|
746
|
+
attrs["delim"] = delim;
|
|
557
747
|
const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
|
|
558
748
|
for (const d of diagnostics)
|
|
559
749
|
ctx.diags.push({ ...d, line });
|
|
@@ -734,6 +924,111 @@ function relDirPath(p) {
|
|
|
734
924
|
const i = p.lastIndexOf("/");
|
|
735
925
|
return i < 0 ? "" : p.slice(0, i);
|
|
736
926
|
}
|
|
927
|
+
// A chain that cannot reach an entity block. Carries the diagnostic code it
|
|
928
|
+
// corresponds to (§3) so the message can name it without inventing a new one.
|
|
929
|
+
class ViewError extends Error {
|
|
930
|
+
code;
|
|
931
|
+
constructor(code, message) {
|
|
932
|
+
super(message);
|
|
933
|
+
this.code = code;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
|
|
937
|
+
// content, so without a confinement root a document could name any path on the
|
|
938
|
+
// machine. And never a URL — `geml get` is a read command that agents and
|
|
939
|
+
// editors call constantly, so letting content steer it at the network would turn
|
|
940
|
+
// it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
|
|
941
|
+
function readConfined(rel, root) {
|
|
942
|
+
if (!/\.geml$/i.test(rel)) {
|
|
943
|
+
throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
|
|
944
|
+
}
|
|
945
|
+
const base = resolvePath(root);
|
|
946
|
+
const abs = resolvePath(root, rel);
|
|
947
|
+
if (abs !== base && !abs.startsWith(base + sep)) {
|
|
948
|
+
throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
return readFileSync(abs, "utf8");
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
// One hop: read the target document and select what the fragment names. Several
|
|
958
|
+
// units come back when the fragment names a section (§4.3).
|
|
959
|
+
function oneHop(file, src, root) {
|
|
960
|
+
const hash = src.indexOf("#");
|
|
961
|
+
const docPath = hash < 0 ? src : src.slice(0, hash);
|
|
962
|
+
const frag = hash < 0 ? undefined : src.slice(hash + 1);
|
|
963
|
+
// Check the scheme on what the DOCUMENT wrote, before composition: a URL can
|
|
964
|
+
// only arrive through `src=`, never from joining relative paths — and testing
|
|
965
|
+
// the composed path instead would read a Windows drive letter (`C:/…`) as a
|
|
966
|
+
// scheme and refuse every absolute path, which is exactly what the MCP layer
|
|
967
|
+
// hands the CLI.
|
|
968
|
+
if (schemeOf(docPath) !== null) {
|
|
969
|
+
throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
|
|
970
|
+
}
|
|
971
|
+
const rel = relJoinPath(relDirPath(file), docPath);
|
|
972
|
+
const text = readConfined(rel, root);
|
|
973
|
+
if (frag === undefined) {
|
|
974
|
+
// `src=other.geml`: the frame looks onto the WHOLE document. Every block
|
|
975
|
+
// comes from the same target, so the resolution base stays uniform — unlike
|
|
976
|
+
// a host-side section selector, where splicing would mix two documents.
|
|
977
|
+
// `meta` is frontmatter, not content (render.ts's selectEmbed).
|
|
978
|
+
//
|
|
979
|
+
// Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
|
|
980
|
+
// every addressed unit would emit the blocks inside a section twice.
|
|
981
|
+
const every = addressedUnits(text).map((a) => a.unit);
|
|
982
|
+
const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
|
|
983
|
+
&& (o.span.start < u.span.start || o.span.end > u.span.end)));
|
|
984
|
+
return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
|
|
985
|
+
}
|
|
986
|
+
const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
|
|
987
|
+
return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
|
|
988
|
+
}
|
|
989
|
+
// Provenance is stated relative to the confinement root, not as the path the
|
|
990
|
+
// walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
|
|
991
|
+
// so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
|
|
992
|
+
// server's layout, and not a path any caller could pass back in.
|
|
993
|
+
function shownPath(rel, root) {
|
|
994
|
+
const r = relative(root, rel).replace(/\\/g, "/");
|
|
995
|
+
return r === "" ? rel : r;
|
|
996
|
+
}
|
|
997
|
+
function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
|
|
998
|
+
const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
|
|
999
|
+
if (src === undefined)
|
|
1000
|
+
return [{ doc: file, text: source, unit, all: [], from: "" }];
|
|
1001
|
+
// The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
|
|
1002
|
+
// cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
|
|
1003
|
+
// not expanded — `--view` may not: stopping here means what we are holding is
|
|
1004
|
+
// still a frame, and returning it would break the contract silently.
|
|
1005
|
+
if (depth >= EMBED_DEPTH_LIMIT) {
|
|
1006
|
+
throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
|
|
1007
|
+
}
|
|
1008
|
+
const hop = oneHop(file, src, root);
|
|
1009
|
+
// Same key shape as the check's cycle detector: a document plus what was
|
|
1010
|
+
// selected in it.
|
|
1011
|
+
const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
|
|
1012
|
+
if (seen.has(key)) {
|
|
1013
|
+
throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
|
|
1014
|
+
}
|
|
1015
|
+
const nextSeen = new Set(seen).add(key);
|
|
1016
|
+
// Per-unit application, recursively: what a frame looks onto may itself be a
|
|
1017
|
+
// frame, and a section may hold a mix (§4.3).
|
|
1018
|
+
return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
|
|
1019
|
+
// An inner identity step has no provenance of its own, so carry this hop's:
|
|
1020
|
+
// `from` must always name where the bytes actually came from.
|
|
1021
|
+
.map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
|
|
1022
|
+
}
|
|
1023
|
+
// The `src=` of an embed unit, read off its head line: a Unit carries the span,
|
|
1024
|
+
// not parsed attributes.
|
|
1025
|
+
function embedSrcOf(source, unit) {
|
|
1026
|
+
const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, true, false));
|
|
1027
|
+
if (!braces)
|
|
1028
|
+
return undefined;
|
|
1029
|
+
const v = parseAttrs(braces[0]).attrs["src"];
|
|
1030
|
+
return typeof v === "string" ? v : undefined;
|
|
1031
|
+
}
|
|
737
1032
|
function gatherEmbeds(source) {
|
|
738
1033
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
|
|
739
1034
|
scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
|
|
@@ -750,6 +1045,11 @@ function tableFromDocument(source, id) {
|
|
|
750
1045
|
const found = ctx.tables?.get(id);
|
|
751
1046
|
if (found !== undefined)
|
|
752
1047
|
return found;
|
|
1048
|
+
// GEP-0005: a remote `data` block is the other chart-source form; its value
|
|
1049
|
+
// is projected by the CALLER (recordsToTable needs the chart's attributes).
|
|
1050
|
+
const dv = ctx.dataValues?.get(id);
|
|
1051
|
+
if (dv !== undefined)
|
|
1052
|
+
return { records: dv };
|
|
753
1053
|
const anyBlock = (function find(bs) {
|
|
754
1054
|
for (const b of bs) {
|
|
755
1055
|
if ((b.kind === "block" || b.kind === "heading") && b.id === id)
|
|
@@ -846,7 +1146,11 @@ function resolveTableSources(ctx, opts) {
|
|
|
846
1146
|
err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
|
|
847
1147
|
continue;
|
|
848
1148
|
}
|
|
849
|
-
|
|
1149
|
+
// A table's `src=` names a TABLE. A `data` block is a chart-source form
|
|
1150
|
+
// (§7.1, GEP-0005), not a table-source form — the column algebra a
|
|
1151
|
+
// borrowing table implies (compute/summary against named columns) has
|
|
1152
|
+
// no defined meaning over a value tree.
|
|
1153
|
+
if (remote === "not-a-table" || "records" in remote) {
|
|
850
1154
|
err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
|
|
851
1155
|
continue;
|
|
852
1156
|
}
|
|
@@ -922,6 +1226,213 @@ function validateRefs(ctx, opts) {
|
|
|
922
1226
|
}
|
|
923
1227
|
}
|
|
924
1228
|
}
|
|
1229
|
+
// `src=` on a `code` block: the route to the code the block shows,
|
|
1230
|
+
// `<path>[#L<start>[-<end>]]` (1-based, inclusive). The code-graph runtime has
|
|
1231
|
+
// always fetched and sliced this at render time; resolving it here is what
|
|
1232
|
+
// turns a range that no longer exists — the source moved or shrank — from a
|
|
1233
|
+
// silently empty panel into a build error. There is no extension gate (code is
|
|
1234
|
+
// any language); the safety rule is the resolver's confinement to the document
|
|
1235
|
+
// tree, widened only by `--root`.
|
|
1236
|
+
const SOURCE_RANGE = /^L(\d+)(?:-(\d+))?$/;
|
|
1237
|
+
// One route syntax for the two types whose `src=` fragment position is free —
|
|
1238
|
+
// `code` and `data`. (A table's is already taken: `src=doc.geml#id` names a
|
|
1239
|
+
// block.) `<path>[#L<start>[-<end>]]`, 1-based and inclusive; `to === 0` means
|
|
1240
|
+
// "through end of file". Returns null after reporting, so callers just skip.
|
|
1241
|
+
function parseSourceRoute(target, kind, line, ctx) {
|
|
1242
|
+
const hash = target.indexOf("#");
|
|
1243
|
+
const path = hash < 0 ? target : target.slice(0, hash);
|
|
1244
|
+
const frag = hash < 0 ? "" : target.slice(hash + 1);
|
|
1245
|
+
if (frag === "")
|
|
1246
|
+
return { path, from: 1, to: 0 };
|
|
1247
|
+
const m = SOURCE_RANGE.exec(frag);
|
|
1248
|
+
if (!m) {
|
|
1249
|
+
ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: unrecognised fragment (expected \`#L<start>\` or \`#L<start>-<end>\`)`, line });
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
const from = Number(m[1]);
|
|
1253
|
+
const to = m[2] === undefined ? from : Number(m[2]);
|
|
1254
|
+
if (from < 1 || to < from) {
|
|
1255
|
+
ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: line range is empty or starts before line 1`, line });
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
return { path, from, to };
|
|
1259
|
+
}
|
|
1260
|
+
// Slice a resolved file to a route's range, or report that the range no longer
|
|
1261
|
+
// exists — the signal a stale reference exists at all.
|
|
1262
|
+
function sliceSourceRange(text, route, target, kind, line, ctx) {
|
|
1263
|
+
const all = normalizeSource(text).split("\n");
|
|
1264
|
+
// A trailing newline yields a final empty element; it is not a line.
|
|
1265
|
+
if (all.length > 0 && all[all.length - 1] === "")
|
|
1266
|
+
all.pop();
|
|
1267
|
+
if (route.to > all.length) {
|
|
1268
|
+
ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: the file has ${all.length} line(s), so lines ${route.from}-${route.to} no longer exist — the range is stale`, line });
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
return all.slice(route.from - 1, route.to === 0 ? all.length : route.to);
|
|
1272
|
+
}
|
|
1273
|
+
function resolveCodeSources(ctx, opts) {
|
|
1274
|
+
for (const { block, line, target } of ctx.codeSources ?? []) {
|
|
1275
|
+
const scheme = schemeOf(target);
|
|
1276
|
+
// A remote source is fetched by the RENDERER (§9.4), as for a table.
|
|
1277
|
+
if (scheme === "http" || scheme === "https")
|
|
1278
|
+
continue;
|
|
1279
|
+
if (scheme !== null) {
|
|
1280
|
+
ctx.diags.push({ severity: "error", code: "bad-code-source", message: `code source \`${target}\` names a disallowed URL scheme`, line });
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
const route = parseSourceRoute(target, "code", line, ctx);
|
|
1284
|
+
if (route === null)
|
|
1285
|
+
continue;
|
|
1286
|
+
const { path, from, to } = route;
|
|
1287
|
+
if (!opts.resolveDoc) {
|
|
1288
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `code source \`${target}\` not checked (no document resolver)`, line });
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const text = opts.resolveDoc(path);
|
|
1292
|
+
if (text === null) {
|
|
1293
|
+
// A WARNING, not an error, and the code/value model split is the reason:
|
|
1294
|
+
// a value that cannot be loaded is a promise the document failed to keep
|
|
1295
|
+
// (an error — see `unresolvable-data-source`), while a code region that
|
|
1296
|
+
// cannot be reached right now is still a code region at a location. A
|
|
1297
|
+
// generated code graph read away from its sources — published on its own,
|
|
1298
|
+
// or describing another checkout — must stay valid, exactly as the
|
|
1299
|
+
// render-time runtime degrades to showing the path.
|
|
1300
|
+
ctx.diags.push({ severity: "warning", code: "unresolvable-code-source", message: `cannot resolve code source \`${path}\` — not checked`, line });
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
const slice = sliceSourceRange(text, { from, to }, target, "code", line, ctx);
|
|
1304
|
+
if (slice === null)
|
|
1305
|
+
continue;
|
|
1306
|
+
const hasBody = (block.raw ?? []).some((l) => l.trim() !== "");
|
|
1307
|
+
if (!hasBody) {
|
|
1308
|
+
block.raw = slice;
|
|
1309
|
+
}
|
|
1310
|
+
else if ((block.raw ?? []).join("\n") !== slice.join("\n")) {
|
|
1311
|
+
// A body alongside `src=` is a cached snapshot, kept for offline reading.
|
|
1312
|
+
// Silence would let the two drift — the very thing the route prevents.
|
|
1313
|
+
ctx.diags.push({ severity: "warning", code: "stale-code-snapshot", message: `code block body differs from its source \`${target}\` — the body is a snapshot and is now out of date`, line });
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// GEP-0005: `src=` on a `data` block names external content — the same
|
|
1318
|
+
// external-source discipline tables have (§6, §9.4): an http(s) source is
|
|
1319
|
+
// fetched by the RENDERER, never the parser (the block defers, and so does a
|
|
1320
|
+
// chart over it); any other scheme is refused; the file must look like data
|
|
1321
|
+
// (`.json`/`.jsonl`); a missing resolver leaves it unchecked with a warning.
|
|
1322
|
+
function resolveDataSources(ctx, opts) {
|
|
1323
|
+
for (const { block, line, target } of ctx.dataSources ?? []) {
|
|
1324
|
+
const defer = () => { if (block.id !== undefined)
|
|
1325
|
+
(ctx.dataSrcPending ??= new Set()).add(block.id); };
|
|
1326
|
+
const scheme = schemeOf(target);
|
|
1327
|
+
if (scheme === "http" || scheme === "https") {
|
|
1328
|
+
defer();
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
if (scheme !== null) {
|
|
1332
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `data source \`${target}\` names a disallowed URL scheme`, line });
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
// The route shares `code`'s syntax (§3.2): a line range MAY narrow the file.
|
|
1336
|
+
const route = parseSourceRoute(target, "data", line, ctx);
|
|
1337
|
+
if (route === null)
|
|
1338
|
+
continue;
|
|
1339
|
+
const { path } = route;
|
|
1340
|
+
// A data source is data — the same shape rule table sources enforce, so
|
|
1341
|
+
// the loader cannot be pointed at a `.env` or a private key.
|
|
1342
|
+
if (!/\.(json|jsonl)$/i.test(path)) {
|
|
1343
|
+
ctx.diags.push({ severity: "error", code: "bad-data-source", message: `data source \`${path}\` is not a \`.json\`/\`.jsonl\` data file`, line });
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
// Explicit format= wins; otherwise the (already-gated) extension names it.
|
|
1347
|
+
const fmtAttr = block.attrs["format"];
|
|
1348
|
+
const fmt = typeof fmtAttr === "string" ? fmtAttr : /\.jsonl$/i.test(path) ? "jsonl" : "json";
|
|
1349
|
+
// A range narrows the file to lines; what those lines mean is then the
|
|
1350
|
+
// format's business, unchanged. Slicing a `jsonl` log is the obvious use;
|
|
1351
|
+
// slicing a `json` file works whenever the slice is itself a value, and
|
|
1352
|
+
// when it is not, the ordinary data-parse error already names the line.
|
|
1353
|
+
// No extra rule.
|
|
1354
|
+
if (!opts.resolveDoc) {
|
|
1355
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `data source \`${target}\` not checked (no document resolver)`, line });
|
|
1356
|
+
defer();
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
const text = opts.resolveDoc(path);
|
|
1360
|
+
if (text === null) {
|
|
1361
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `cannot resolve data source \`${path}\``, line });
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
const lines = sliceSourceRange(text, route, target, "data", line, ctx);
|
|
1365
|
+
if (lines === null)
|
|
1366
|
+
continue;
|
|
1367
|
+
const parsed = parseDataBody(fmt, lines, line);
|
|
1368
|
+
for (const d of parsed.diags)
|
|
1369
|
+
ctx.diags.push(d);
|
|
1370
|
+
if (parsed.value !== undefined) {
|
|
1371
|
+
block.value = parsed.value;
|
|
1372
|
+
if (block.id !== undefined && !ctx.dataValues?.has(block.id)) {
|
|
1373
|
+
(ctx.dataValues ??= new Map()).set(block.id, parsed.value);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
// GEP-0005: a chart's `data=` may target a `data` block whose value is a
|
|
1379
|
+
// RECORD ARRAY — a non-empty array of objects. Keys project to columns in
|
|
1380
|
+
// first-seen order; every column the chart actually references (x/y/size/
|
|
1381
|
+
// series) must be present with a SCALAR value in every record, and a
|
|
1382
|
+
// violation is an error naming the first offending record. Columns the chart
|
|
1383
|
+
// does not reference may hold anything (nested values project as compact
|
|
1384
|
+
// JSON text). The projection feeds the unchanged table machinery.
|
|
1385
|
+
function recordsToTable(value, attrs, line, ctx) {
|
|
1386
|
+
const fail = (msg) => {
|
|
1387
|
+
ctx.diags.push({ severity: "error", code: "chart-data-not-records", message: `geml-chart: ${msg}`, line });
|
|
1388
|
+
return null;
|
|
1389
|
+
};
|
|
1390
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
1391
|
+
return fail("data target is not a non-empty record array");
|
|
1392
|
+
const columns = [];
|
|
1393
|
+
for (let i = 0; i < value.length; i++) {
|
|
1394
|
+
const r = value[i];
|
|
1395
|
+
if (r === null || typeof r !== "object" || Array.isArray(r))
|
|
1396
|
+
return fail(`record ${i + 1} is not an object`);
|
|
1397
|
+
for (const k of Object.keys(r))
|
|
1398
|
+
if (!columns.includes(k))
|
|
1399
|
+
columns.push(k);
|
|
1400
|
+
}
|
|
1401
|
+
// Only the channels this chart TYPE reads are "referenced" (§7.1): a stray
|
|
1402
|
+
// size= on a bar chart is buildChart's chart-unused-channel WARNING, and the
|
|
1403
|
+
// projection must not turn it into an error a table source would not raise.
|
|
1404
|
+
// An unknown/missing type validates x/y only; buildChart reports the type.
|
|
1405
|
+
const typeAttr = String(attrs["type"] ?? "");
|
|
1406
|
+
const uses = USES[typeAttr] ?? new Set(["x", "y"]);
|
|
1407
|
+
const channels = [];
|
|
1408
|
+
for (const c of ["x", "y", "size", "series"]) {
|
|
1409
|
+
if (!uses.has(c))
|
|
1410
|
+
continue;
|
|
1411
|
+
const v = attrs[c];
|
|
1412
|
+
if (typeof v === "string")
|
|
1413
|
+
for (const name of v.split(",").map((s) => s.trim()).filter(Boolean))
|
|
1414
|
+
channels.push(name);
|
|
1415
|
+
}
|
|
1416
|
+
for (const col of channels) {
|
|
1417
|
+
for (let i = 0; i < value.length; i++) {
|
|
1418
|
+
const v = value[i][col];
|
|
1419
|
+
if (v === undefined || v === null || typeof v === "object") {
|
|
1420
|
+
return fail(`column \`${col}\` is missing or non-scalar in record ${i + 1}`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const rows = value.map((r) => columns.map((c) => {
|
|
1425
|
+
const v = r[c];
|
|
1426
|
+
const text = v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
1427
|
+
// Data, not prose: cells carry plain-text inlines, never inline-parsed —
|
|
1428
|
+
// the same treatment `format=csv` cells get (a `*` in a value is a `*`).
|
|
1429
|
+
const cell = { text, inlines: text === "" ? [] : [{ type: "text", value: text }] };
|
|
1430
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
1431
|
+
cell.value = v;
|
|
1432
|
+
return cell;
|
|
1433
|
+
}));
|
|
1434
|
+
return { header: true, columns, align: columns.map(() => undefined), rows };
|
|
1435
|
+
}
|
|
925
1436
|
// §7: resolve every geml-chart against its referenced table. Runs after the
|
|
926
1437
|
// scan so that `data=#id` may point at a table defined anywhere in the doc.
|
|
927
1438
|
function resolveCharts(ctx, opts) {
|
|
@@ -941,6 +1452,21 @@ function resolveCharts(ctx, opts) {
|
|
|
941
1452
|
let table;
|
|
942
1453
|
if (docPath === "") {
|
|
943
1454
|
table = ctx.tables?.get(id);
|
|
1455
|
+
if (!table && ctx.dataValues?.has(id)) {
|
|
1456
|
+
// GEP-0005: the target is a `data` block. A RECORD ARRAY projects to
|
|
1457
|
+
// the table model (keys -> columns) and feeds the unchanged chart
|
|
1458
|
+
// machinery, so column checks and rendering stay single-sourced.
|
|
1459
|
+
const projected = recordsToTable(ctx.dataValues.get(id), block.attrs, line, ctx);
|
|
1460
|
+
if (projected === null)
|
|
1461
|
+
continue; // reported by the projection
|
|
1462
|
+
table = projected;
|
|
1463
|
+
}
|
|
1464
|
+
if (!table && ctx.dataSrcPending?.has(id)) {
|
|
1465
|
+
// GEP-0005: the target is a `data` block whose `src=` is render-time
|
|
1466
|
+
// (http, or no resolver) — defer exactly like a src table with no
|
|
1467
|
+
// columns; the renderer checks it when the data actually arrives.
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
944
1470
|
if (!table) {
|
|
945
1471
|
// A chart is a view of a table, and a data file is one of the three ways
|
|
946
1472
|
// §6 lets a table name its content. So `data=rows.csv` desugars: it is an
|
|
@@ -954,8 +1480,36 @@ function resolveCharts(ctx, opts) {
|
|
|
954
1480
|
continue; // already reported by the table rules
|
|
955
1481
|
table = sugar;
|
|
956
1482
|
}
|
|
1483
|
+
else if (hash < 0 && /\.(json|jsonl)$/i.test(id) && schemeOf(id) === null) {
|
|
1484
|
+
// GEP-0005 sugar, the json/jsonl twin of the csv path: an anonymous
|
|
1485
|
+
// LOCAL data source projected through the record-array rules. A
|
|
1486
|
+
// remote URL needs a NAMED `data` block with `src=` — its fetch is
|
|
1487
|
+
// render-time, and an anonymous source has no block to defer on.
|
|
1488
|
+
if (!opts.resolveDoc) {
|
|
1489
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${id}\` not checked (no document resolver)`, line });
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
const text = opts.resolveDoc(id);
|
|
1493
|
+
if (text === null) {
|
|
1494
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `geml-chart: cannot resolve data source \`${id}\``, line });
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
const parsed = parseDataBody(/\.jsonl$/i.test(id) ? "jsonl" : "json", normalizeSource(text).split("\n"), line);
|
|
1498
|
+
for (const d of parsed.diags)
|
|
1499
|
+
ctx.diags.push(d);
|
|
1500
|
+
if (parsed.value === undefined)
|
|
1501
|
+
continue;
|
|
1502
|
+
const projected = recordsToTable(parsed.value, block.attrs, line, ctx);
|
|
1503
|
+
if (projected === null)
|
|
1504
|
+
continue; // reported by the projection
|
|
1505
|
+
table = projected;
|
|
1506
|
+
}
|
|
1507
|
+
else if (hash < 0 && /\.(json|jsonl)$/i.test(id)) {
|
|
1508
|
+
ctx.diags.push({ severity: "error", code: "bad-data-source", message: `geml-chart: \`data=${id}\`: a remote json/jsonl source needs a named \`data\` block with \`src=\``, line });
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
957
1511
|
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 });
|
|
1512
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\`/\`.json\`/\`.jsonl\` data file, and not a \`#id\` naming a table or data block`, line });
|
|
959
1513
|
continue;
|
|
960
1514
|
}
|
|
961
1515
|
else {
|
|
@@ -983,10 +1537,18 @@ function resolveCharts(ctx, opts) {
|
|
|
983
1537
|
continue;
|
|
984
1538
|
}
|
|
985
1539
|
if (remote === "not-a-table") {
|
|
986
|
-
ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is
|
|
1540
|
+
ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is neither a table nor a data block`, line });
|
|
987
1541
|
continue;
|
|
988
1542
|
}
|
|
989
|
-
|
|
1543
|
+
if ("records" in remote) {
|
|
1544
|
+
const projected = recordsToTable(remote.records, block.attrs, line, ctx);
|
|
1545
|
+
if (projected === null)
|
|
1546
|
+
continue; // reported by the projection
|
|
1547
|
+
table = projected;
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
table = remote;
|
|
1551
|
+
}
|
|
990
1552
|
}
|
|
991
1553
|
if (table.src !== undefined && table.columns.length === 0) {
|
|
992
1554
|
// §6: the table names a source whose data did not arrive at build time — a
|
|
@@ -1011,6 +1573,8 @@ export function parse(source, opts = {}) {
|
|
|
1011
1573
|
// Table sources first: a chart reads the build-time model of the table it
|
|
1012
1574
|
// charts, so that model has to be filled before charts are resolved.
|
|
1013
1575
|
resolveTableSources(ctx, opts);
|
|
1576
|
+
resolveDataSources(ctx, opts);
|
|
1577
|
+
resolveCodeSources(ctx, opts);
|
|
1014
1578
|
resolveCharts(ctx, opts);
|
|
1015
1579
|
validateRefs(ctx, opts);
|
|
1016
1580
|
detectTransclusionCycles(ctx, opts);
|
|
@@ -1077,9 +1641,12 @@ function sectionEnd(lines, i, level) {
|
|
|
1077
1641
|
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
1078
1642
|
// absolute line offset of this slice within the whole document.
|
|
1079
1643
|
function collectSpans(lines, base, out, ctx, depth = 0,
|
|
1080
|
-
// Optional second index: every
|
|
1081
|
-
// block the author never named is still
|
|
1082
|
-
|
|
1644
|
+
// Optional second index: every addressable unit in document order — typed
|
|
1645
|
+
// blocks (id-bearing or not, so a block the author never named is still
|
|
1646
|
+
// addressable), headings, footnote definitions. A second SINK on the one
|
|
1647
|
+
// walk, not a second walk: the selector design's "one definition, one
|
|
1648
|
+
// implementation" applies to the scan as much as to the syntax.
|
|
1649
|
+
units) {
|
|
1083
1650
|
const add = (id, start, end) => {
|
|
1084
1651
|
if (!out.has(id))
|
|
1085
1652
|
out.set(id, { start, end });
|
|
@@ -1094,6 +1661,7 @@ types) {
|
|
|
1094
1661
|
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
1095
1662
|
if (fndef) {
|
|
1096
1663
|
add(fndef[1].trim(), base + i, base + i + 1);
|
|
1664
|
+
units?.push({ span: { start: base + i, end: base + i + 1 }, kind: "footnote", id: fndef[1].trim() });
|
|
1097
1665
|
i++;
|
|
1098
1666
|
continue;
|
|
1099
1667
|
}
|
|
@@ -1108,16 +1676,12 @@ types) {
|
|
|
1108
1676
|
const { end, closed } = fenceClose(lines, i, open);
|
|
1109
1677
|
if (id !== undefined)
|
|
1110
1678
|
add(id, base + i, base + end);
|
|
1111
|
-
|
|
1112
|
-
const list = types.get(type) ?? [];
|
|
1113
|
-
list.push({ span: { start: base + i, end: base + end }, id });
|
|
1114
|
-
types.set(type, list);
|
|
1115
|
-
}
|
|
1679
|
+
units?.push({ span: { start: base + i, end: base + end }, kind: "block", type, ...(id !== undefined ? { id } : {}) });
|
|
1116
1680
|
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
1117
1681
|
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
1118
1682
|
// the parser's contract.
|
|
1119
1683
|
if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
|
|
1120
|
-
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1,
|
|
1684
|
+
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, units);
|
|
1121
1685
|
}
|
|
1122
1686
|
i = end;
|
|
1123
1687
|
continue;
|
|
@@ -1128,7 +1692,10 @@ types) {
|
|
|
1128
1692
|
// still advances one line at a time so every nested id inside the
|
|
1129
1693
|
// section registers its own span — spans intentionally OVERLAP: #sec
|
|
1130
1694
|
// contains #code, and each remains addressable on its own.
|
|
1131
|
-
|
|
1695
|
+
const hid = idOfHeading(h[3], h[2], base + i + 1, ctx);
|
|
1696
|
+
const hend = base + sectionEnd(lines, i, h[1].length);
|
|
1697
|
+
add(hid, base + i, hend);
|
|
1698
|
+
units?.push({ span: { start: base + i, end: hend }, kind: "heading", id: hid, level: h[1].length, text: h[2] });
|
|
1132
1699
|
i++;
|
|
1133
1700
|
continue;
|
|
1134
1701
|
}
|
|
@@ -1146,12 +1713,23 @@ export function blockSpans(source) {
|
|
|
1146
1713
|
collectSpans(lines, 0, out, ctx);
|
|
1147
1714
|
return out;
|
|
1148
1715
|
}
|
|
1149
|
-
|
|
1716
|
+
// Every addressable unit, in document order, each decorated with its content
|
|
1717
|
+
// address (§3.2). Ids are OPTIONAL in GEML (§1: a block MAY carry one), so
|
|
1718
|
+
// `meta`, a callout `note`, a `table` — anything the author had no reason to
|
|
1719
|
+
// name — has no id to address it by; this index is what makes those addressable
|
|
1720
|
+
// anyway, by type (`=== meta`) or by content (`@<hex>`). No block type is
|
|
1721
|
+
// special-cased; meta is merely the one that is usually unique.
|
|
1722
|
+
//
|
|
1723
|
+
// The ONE index selector matching and the listing both work from, so `get`,
|
|
1724
|
+
// `set` and the listing can never disagree about what exists.
|
|
1725
|
+
function addressedUnits(source) {
|
|
1150
1726
|
const lines = normalizeSource(source).split("\n");
|
|
1151
1727
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
1152
|
-
const
|
|
1153
|
-
collectSpans(lines, 0, new Map(), ctx, 0,
|
|
1154
|
-
|
|
1728
|
+
const units = [];
|
|
1729
|
+
collectSpans(lines, 0, new Map(), ctx, 0, units);
|
|
1730
|
+
// The address hashes the block's own source text — the exact bytes `get`
|
|
1731
|
+
// would print for it — so an address can be recomputed from `get` output.
|
|
1732
|
+
return addressUnits(units, (u) => lines.slice(u.span.start, u.span.end).join("\n"));
|
|
1155
1733
|
}
|
|
1156
1734
|
// Split into physical lines while *keeping* each line's terminator, so
|
|
1157
1735
|
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
@@ -1186,6 +1764,32 @@ function toNewline(text, nl) {
|
|
|
1186
1764
|
function narrowToHead(span) {
|
|
1187
1765
|
return { start: span.start, end: span.start + 1 };
|
|
1188
1766
|
}
|
|
1767
|
+
// The unit's CLOSING fence line, or null when it has none — a heading section,
|
|
1768
|
+
// or a fence left unclosed at EOF. Extracted so `get --body` and `set --body`
|
|
1769
|
+
// decide it in ONE place: the selector design's §4 defines HEAD/BODY by the
|
|
1770
|
+
// round-trip invariant `get X --body | set X --body` leaving the file
|
|
1771
|
+
// byte-identical, and two copies of this judgement is exactly how that breaks.
|
|
1772
|
+
function closeFenceLine(lines, span) {
|
|
1773
|
+
const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
|
|
1774
|
+
if (!open)
|
|
1775
|
+
return null;
|
|
1776
|
+
const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
1777
|
+
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1778
|
+
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1779
|
+
return isCloseFence(lastText, open[1].length) || labeled ? lines[span.end - 1] ?? "" : null;
|
|
1780
|
+
}
|
|
1781
|
+
// BODY span: a fenced block's lines BETWEEN the fences; a heading's lines after
|
|
1782
|
+
// the heading through the section boundary — trailing blank lines included,
|
|
1783
|
+
// because that is the span `set --body` replaces (§4's table).
|
|
1784
|
+
function narrowToBody(lines, span) {
|
|
1785
|
+
return { start: span.start + 1, end: closeFenceLine(lines, span) !== null ? span.end - 1 : span.end };
|
|
1786
|
+
}
|
|
1787
|
+
// Slice one unit's output bytes, honouring --head / --body.
|
|
1788
|
+
function sliceUnit(source, span, headOnly, bodyOnly) {
|
|
1789
|
+
const lines = splitLines(source);
|
|
1790
|
+
const s = headOnly ? narrowToHead(span) : bodyOnly ? narrowToBody(lines, span) : span;
|
|
1791
|
+
return lines.slice(s.start, s.end).join("");
|
|
1792
|
+
}
|
|
1189
1793
|
// Depth-first search for the document-model node carrying `id`, descending into
|
|
1190
1794
|
// flow-block children (and list-item children) so a nested id is found too.
|
|
1191
1795
|
// Returns the containing sibling array and index, not just the node: the model
|
|
@@ -1238,13 +1842,9 @@ function flag(args, name) {
|
|
|
1238
1842
|
function historyPathFor(geml) {
|
|
1239
1843
|
return geml.replace(/\.geml$/, "") + ".gemlhistory";
|
|
1240
1844
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
|
|
1245
|
-
const [, y, mo, d, h, mi, se] = m;
|
|
1246
|
-
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
1247
|
-
}
|
|
1845
|
+
// (A `YYYYMMDDTHHMMSSZ` parser lived here for `history commit --at`. That flag
|
|
1846
|
+
// left the CLI with design §9-Q4 — the library API takes a real Date — so the parser
|
|
1847
|
+
// went with it rather than staying as an uncalled branch.)
|
|
1248
1848
|
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
1249
1849
|
// The published version, read from package.json rather than restated here.
|
|
1250
1850
|
// "Keep in sync with package.json" was a comment, and comments do not run: this
|
|
@@ -1272,96 +1872,124 @@ export const PARSER_VERSION = (() => {
|
|
|
1272
1872
|
}
|
|
1273
1873
|
return "0.0.0";
|
|
1274
1874
|
})();
|
|
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
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
geml
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
(--
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
geml
|
|
1301
|
-
(
|
|
1302
|
-
geml
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
geml
|
|
1306
|
-
(
|
|
1307
|
-
geml
|
|
1308
|
-
|
|
1309
|
-
geml
|
|
1310
|
-
(
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
geml --
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1875
|
+
const USAGE = `geml — GEML reference CLI
|
|
1876
|
+
|
|
1877
|
+
Usage:
|
|
1878
|
+
geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
|
|
1879
|
+
(--root widens cross-doc resolution to dir d, as on check — an
|
|
1880
|
+
=== embed whose target sits above the file's own directory
|
|
1881
|
+
needs it, or it renders unresolved)
|
|
1882
|
+
--to <output>: json | html | md | geml
|
|
1883
|
+
--to md -> Markdown (lossy)
|
|
1884
|
+
--to html -> self-contained HTML
|
|
1885
|
+
--to html --fragment -> body-only markup, no page shell
|
|
1886
|
+
(embed in your own layout; assets via pageAssets)
|
|
1887
|
+
--to geml -> canonical re-format
|
|
1888
|
+
--to json -> document-model JSON (default)
|
|
1889
|
+
--from <input>: geml | md | json (overrides extension; html is output-only)
|
|
1890
|
+
geml notes.md -> GEML (md inferred from extension)
|
|
1891
|
+
geml model.json --to geml -> GEML (round-trips a prior --to json)
|
|
1892
|
+
geml - --from md read Markdown on stdin
|
|
1893
|
+
geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
|
|
1894
|
+
(a heading id = its whole section; --head = head line;
|
|
1895
|
+
--json = model node). Without #id: list all addressable
|
|
1896
|
+
ids (--json = array).
|
|
1897
|
+
geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
1898
|
+
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
1899
|
+
default = whole block · --head = head line · --body = body)
|
|
1900
|
+
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
1901
|
+
(1+ blocks and/or prose; content keeps its own ids, a clash is refused)
|
|
1902
|
+
geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
|
|
1903
|
+
(a missing id is skipped; a dangling reference is a warning, not a refusal)
|
|
1904
|
+
geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
|
|
1905
|
+
geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
|
|
1906
|
+
(sel: 0 | -N | id-prefix | changed; default -1)
|
|
1907
|
+
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
1908
|
+
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
1909
|
+
geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
|
|
1910
|
+
(save = append the file as a revision · get = list revisions, or
|
|
1911
|
+
print one · restore = overwrite the file with one · verify = rebuild
|
|
1912
|
+
and re-hash the whole chain)
|
|
1913
|
+
geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
|
|
1914
|
+
geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
|
|
1915
|
+
(10 tools, each geml_ + its CLI command path: list/get/check/history/to +
|
|
1916
|
+
set/add/delete/rename/revert; every write is validated before it
|
|
1917
|
+
reaches disk. A code graph under --root adds four read-only
|
|
1918
|
+
geml_codemap_* tools to the same server)
|
|
1919
|
+
geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
|
|
1920
|
+
(authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
|
|
1921
|
+
MCP server registered at user scope; touches no settings.json,
|
|
1922
|
+
installs no hooks; idempotent — re-run to update)
|
|
1923
|
+
geml --help | --version [--json]
|
|
1924
|
+
|
|
1925
|
+
Use '-' as the file to read from stdin.
|
|
1926
|
+
Mutations (set/add/delete/rename) write the whole updated document in place for a
|
|
1927
|
+
file, or to stdout for '-' input; -o redirects it (-o - = stdout).
|
|
1928
|
+
Exit codes:
|
|
1929
|
+
0 ok
|
|
1930
|
+
1 document/operation error
|
|
1931
|
+
2 command usage error.
|
|
1323
1932
|
`;
|
|
1324
1933
|
// One-line usage for each subcommand — the single source for both the error
|
|
1325
1934
|
// shown on misuse and the `<cmd> --help` text.
|
|
1326
1935
|
const SUBHELP = {
|
|
1327
|
-
get: "usage: geml get <file.geml|-> [#id | '## Heading' | '=== type'
|
|
1328
|
-
set: "usage: geml set <file.geml|->
|
|
1936
|
+
get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--view [--root <dir>]] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; --view = read THROUGH an `embed` to the entity block it stands for, following a chain to its end (the identity on any other block, and on a section selector — it never splices two documents' bytes together); provenance goes to stderr as `view: <sel> -> <doc>[#<id>]`; read-only, `set` refuses it; chain reads are confined to --root (default: the document's own directory) and never fetched over the network; without a selector: list every addressable block with its shortest unique address, --json = array)",
|
|
1937
|
+
set: "usage: geml set <file.geml|-> <selector> [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body; guarded splice, refused if it breaks the doc; writing through an @<hex> address prints the new address on stderr)",
|
|
1329
1938
|
add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
|
|
1330
1939
|
delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
|
|
1331
1940
|
rename: "usage: geml rename <file.geml|-> #old #new [-o out.geml] (rewrite an id's declaration AND every reference — [[#id]], [text](#id), chart data=#id, footnote [^id] — id-boundary safe, skipping raw block bodies; #new must be free; refused if it breaks the doc)",
|
|
1332
1941
|
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
1333
1942
|
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)",
|
|
1334
|
-
history:
|
|
1335
|
-
|
|
1336
|
-
geml
|
|
1337
|
-
geml
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
geml codemap
|
|
1943
|
+
history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
|
|
1944
|
+
geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
|
|
1945
|
+
geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
|
|
1946
|
+
geml history verify <file.geml> rebuild and re-hash every revision in the chain
|
|
1947
|
+
(<rev>: 0 = the tip | -N = N revisions back | an unambiguous revision id — the strings 'get' prints.
|
|
1948
|
+
All four take --history <path> to point at a sidecar other than <file>.gemlhistory.)`,
|
|
1949
|
+
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)
|
|
1950
|
+
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]]
|
|
1951
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
1952
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
1953
|
+
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
|
|
1954
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
1955
|
+
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
1342
1956
|
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
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
|
|
1347
|
-
one vocabulary
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1957
|
+
mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
1958
|
+
|
|
1959
|
+
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
1960
|
+
Every tool is geml_ + its CLI COMMAND PATH, so the terminal and the assistant
|
|
1961
|
+
share one vocabulary — geml_history mirrors the "geml history" command group,
|
|
1962
|
+
whose read verb (get) is the only one of the four served here.
|
|
1963
|
+
Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
|
|
1964
|
+
geml_set · geml_add · geml_delete · geml_rename · geml_revert
|
|
1965
|
+
With a code graph under --root, four more (read-only), so one client entry
|
|
1966
|
+
covers both: geml_codemap_search · geml_codemap_callchain
|
|
1967
|
+
geml_codemap_list · geml_codemap_node
|
|
1968
|
+
|
|
1969
|
+
--root <dir> REQUIRED. Root holding the .geml documents. Every path a
|
|
1970
|
+
client names is confined here; a client cannot widen it.
|
|
1971
|
+
--graph <dir> Code-graph directory, inside --root. Defaults to
|
|
1972
|
+
<root>/.geml-code-graph when it holds an index.geml; with
|
|
1973
|
+
no graph the four graph tools are not served at all.
|
|
1974
|
+
--no-history Skip the .gemlhistory revision saved before each write
|
|
1975
|
+
(default: save one, so geml_revert always has a revision
|
|
1976
|
+
to undo to).
|
|
1977
|
+
|
|
1978
|
+
Register with a client:
|
|
1364
1979
|
claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
|
|
1980
|
+
skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp]
|
|
1981
|
+
|
|
1982
|
+
One command, three things, all user-global — so any Claude Code session can
|
|
1983
|
+
author, validate, and blockwise-edit GEML:
|
|
1984
|
+
1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
|
|
1985
|
+
2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
|
|
1986
|
+
3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
|
|
1987
|
+
Touches no settings.json and installs no hooks. Idempotent — re-run after an
|
|
1988
|
+
upgrade to refresh the skill text alongside the CLI it teaches.
|
|
1989
|
+
|
|
1990
|
+
--dest <dir> install the skill under <dir> instead of ~/.claude/skills
|
|
1991
|
+
--no-global skip the global npm install
|
|
1992
|
+
--no-mcp skip the MCP server registration`,
|
|
1365
1993
|
};
|
|
1366
1994
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
1367
1995
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
@@ -1440,7 +2068,19 @@ function resolverFor(file, root) {
|
|
|
1440
2068
|
return null;
|
|
1441
2069
|
// References resolve FROM the document's own directory; the gates below
|
|
1442
2070
|
// confine them to the (possibly widened) base.
|
|
1443
|
-
|
|
2071
|
+
let targetAbs = resolvePath(dirAbs, d);
|
|
2072
|
+
// A SOURCE route (`code`/`data` `src=`) may instead be written relative to
|
|
2073
|
+
// the resolution root — that is how the code-graph profile writes them
|
|
2074
|
+
// (`geml-parser/src/attrs.ts` from a document two levels down). So when
|
|
2075
|
+
// the document-relative path does not exist and a root was named, try the
|
|
2076
|
+
// root as the base. Only a widened `--root` can enable this, and both
|
|
2077
|
+
// confinement gates below still apply, so it cannot reach further than a
|
|
2078
|
+
// document-relative reference already could.
|
|
2079
|
+
if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
|
|
2080
|
+
const fromBase = resolvePath(baseAbs, d);
|
|
2081
|
+
if (existsSync(fromBase))
|
|
2082
|
+
targetAbs = fromBase;
|
|
2083
|
+
}
|
|
1444
2084
|
// Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
|
|
1445
2085
|
// before touching the filesystem.
|
|
1446
2086
|
if (outside(baseAbs, targetAbs))
|
|
@@ -1510,23 +2150,130 @@ function historyError(e, file, historyPath) {
|
|
|
1510
2150
|
}
|
|
1511
2151
|
return err?.message ?? String(e);
|
|
1512
2152
|
}
|
|
2153
|
+
// Subcommand, file and revision, read positionally around the options —
|
|
2154
|
+
// `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
|
|
2155
|
+
// indexing read `--history` itself as the file.
|
|
2156
|
+
//
|
|
2157
|
+
// The generic `positionals()` cannot be reused: it drops every `-`-leading token,
|
|
2158
|
+
// and a revision selector `-N` LOOKS exactly like a flag. That is the whole point
|
|
2159
|
+
// of the first column `history get` prints, so `-N` is admitted and every other
|
|
2160
|
+
// `-`-leading token is treated as an option.
|
|
2161
|
+
function historyPositionals(args) {
|
|
2162
|
+
const out = [];
|
|
2163
|
+
for (let i = 0; i < args.length; i++) {
|
|
2164
|
+
const a = args[i];
|
|
2165
|
+
if (a === "--history" || a === "-m" || a === "--message") {
|
|
2166
|
+
i++;
|
|
2167
|
+
continue;
|
|
2168
|
+
} // flag AND its value
|
|
2169
|
+
if (a.startsWith("-") && !/^-\d+$/.test(a))
|
|
2170
|
+
continue; // --json, --force, …
|
|
2171
|
+
out.push(a);
|
|
2172
|
+
}
|
|
2173
|
+
return out;
|
|
2174
|
+
}
|
|
1513
2175
|
function runHistory(args) {
|
|
1514
|
-
const sub = args
|
|
1515
|
-
const file = args[1];
|
|
2176
|
+
const [sub, file, rev, ...extra] = historyPositionals(args);
|
|
1516
2177
|
if (!sub || !file)
|
|
1517
2178
|
fail(SUBHELP.history);
|
|
1518
2179
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
2180
|
+
const json = args.includes("--json");
|
|
1519
2181
|
try {
|
|
1520
|
-
if (sub === "
|
|
1521
|
-
|
|
1522
|
-
|
|
2182
|
+
if (sub === "save") {
|
|
2183
|
+
// design §3.1/§9-Q4: `--author` and `--at` were withdrawn from the CLI (nothing
|
|
2184
|
+
// outside tests ever passed either). Refusing beats ignoring for the same
|
|
2185
|
+
// reason the retired verbs above refuse: a silently dropped `--author
|
|
2186
|
+
// alice` discards precisely the value the caller went out of their way to
|
|
2187
|
+
// type. Both stay on the library API (save({ author, at })).
|
|
2188
|
+
for (const gone of ["--author", "--at"]) {
|
|
2189
|
+
if (args.some((a) => a === gone || a.startsWith(`${gone}=`))) {
|
|
2190
|
+
fail(`${gone} is no longer accepted by 'geml history save' — the only option is -m/--message. (Both remain on the library API, save({ author, at }), for embedders and for tests that pin a revision id.)`);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
// design §3.1: an empty save is a NO-OP. `save` is the one non-idempotent verb,
|
|
2194
|
+
// so an agent retrying a save it is unsure landed must not lengthen the
|
|
2195
|
+
// chain by a revision with no ops. `geml mcp` already gated its
|
|
2196
|
+
// pre-write snapshot on this exact predicate (mcp.ts snapshot()); this is
|
|
2197
|
+
// the same `isCurrent()`, not a second hash comparison.
|
|
2198
|
+
if (existsSync(historyPath) && isCurrent(historyPath, file)) {
|
|
2199
|
+
console.log(`already saved as ${listRevisions(historyPath)[0].id} (no changes)`);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const r = save({
|
|
1523
2203
|
gemlPath: file,
|
|
1524
2204
|
historyPath,
|
|
1525
2205
|
summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
|
|
1526
|
-
author: flag(args, "--author"),
|
|
1527
|
-
at: at ? parseStamp(at) : undefined,
|
|
1528
2206
|
});
|
|
1529
|
-
console.log(`
|
|
2207
|
+
console.log(`saved ${r.id}`);
|
|
2208
|
+
}
|
|
2209
|
+
else if (sub === "get") {
|
|
2210
|
+
// Three tiers, split by how many addresses were given — the same rule the
|
|
2211
|
+
// top-level `geml get` follows (design §1.2). Tier 2 takes a BLOCK
|
|
2212
|
+
// selector inside the revision and reuses the top-level grammar verbatim
|
|
2213
|
+
// (§10.1): a revision rebuilt is just a document's text, so there is no
|
|
2214
|
+
// new algorithm here, and the two selector namespaces cannot collide —
|
|
2215
|
+
// position is fixed and the lexis does not overlap (§10.2).
|
|
2216
|
+
if (extra.length > 1) {
|
|
2217
|
+
fail(`history get takes ONE revision selector and ONE block selector; got ${extra.length + 1} positionals after the file`, 2);
|
|
2218
|
+
}
|
|
2219
|
+
if (rev === undefined) {
|
|
2220
|
+
// Newest-first, with each row's selector in the first column (`0` for
|
|
2221
|
+
// the tip, then `-1`, `-2`, …) so the output is copy-paste into `get`,
|
|
2222
|
+
// `restore` and `revert --rev` alike.
|
|
2223
|
+
const revs = listRevisions(historyPath);
|
|
2224
|
+
if (json) {
|
|
2225
|
+
console.log(JSON.stringify(revs, null, 2));
|
|
2226
|
+
}
|
|
2227
|
+
else {
|
|
2228
|
+
for (const r of revs) {
|
|
2229
|
+
const sel = r.current ? "0" : `-${r.offset}`;
|
|
2230
|
+
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
else {
|
|
2235
|
+
// resolveContent() routes through the ONE selector grammar
|
|
2236
|
+
// (resolveRevision) that the list above prints — see its comment for
|
|
2237
|
+
// what happened the last time that was written twice.
|
|
2238
|
+
const { id, text } = resolveContent(historyPath, rev);
|
|
2239
|
+
const blockSel = extra[0];
|
|
2240
|
+
if (blockSel === undefined) {
|
|
2241
|
+
if (json)
|
|
2242
|
+
console.log(JSON.stringify({ id, text }, null, 2));
|
|
2243
|
+
else
|
|
2244
|
+
process.stdout.write(text);
|
|
2245
|
+
}
|
|
2246
|
+
else {
|
|
2247
|
+
// Tier 2 (§10.1). Cardinality and the flag rules are the top-level
|
|
2248
|
+
// ones, checked here because this tier has its own argument list.
|
|
2249
|
+
const headOnly = args.includes("--head");
|
|
2250
|
+
const bodyOnly = args.includes("--body");
|
|
2251
|
+
if (headOnly && bodyOnly)
|
|
2252
|
+
fail("--head and --body are mutually exclusive", 2);
|
|
2253
|
+
if (json && (headOnly || bodyOnly)) {
|
|
2254
|
+
fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
|
|
2255
|
+
}
|
|
2256
|
+
const { units, all } = selectUnits(text, file, blockSel, `revision ${id}`);
|
|
2257
|
+
if (json) {
|
|
2258
|
+
// §3.2's tier table: the revision id travels with the block, so the
|
|
2259
|
+
// caller can tell WHICH version it is holding.
|
|
2260
|
+
const nodes = units.map((u) => unitNode(text, file, u, all));
|
|
2261
|
+
console.log(JSON.stringify({ id, block: units.length === 1 ? nodes[0] : nodes }, null, 2));
|
|
2262
|
+
}
|
|
2263
|
+
else {
|
|
2264
|
+
if (units.length > 1)
|
|
2265
|
+
reportMatches(units[0].type ?? "", units);
|
|
2266
|
+
for (const u of units)
|
|
2267
|
+
process.stdout.write(sliceUnit(text, u.span, headOnly, bodyOnly));
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
else if (sub === "restore") {
|
|
2273
|
+
if (!rev)
|
|
2274
|
+
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
2275
|
+
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
2276
|
+
console.log(`restored ${file} to ${rev}`);
|
|
1530
2277
|
}
|
|
1531
2278
|
else if (sub === "verify") {
|
|
1532
2279
|
const res = verify(historyPath, file);
|
|
@@ -1538,27 +2285,6 @@ function runHistory(args) {
|
|
|
1538
2285
|
if (!res.ok)
|
|
1539
2286
|
process.exit(1);
|
|
1540
2287
|
}
|
|
1541
|
-
else if (sub === "show") {
|
|
1542
|
-
const rev = args[2];
|
|
1543
|
-
if (!rev)
|
|
1544
|
-
fail("usage: geml history show <file.geml> <revision>");
|
|
1545
|
-
process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
|
|
1546
|
-
}
|
|
1547
|
-
else if (sub === "restore") {
|
|
1548
|
-
const rev = args[2];
|
|
1549
|
-
if (!rev)
|
|
1550
|
-
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
1551
|
-
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
1552
|
-
console.log(`restored ${file} to ${rev}`);
|
|
1553
|
-
}
|
|
1554
|
-
else if (sub === "log") {
|
|
1555
|
-
// Newest-first, with the `--rev` selector for each row in the first column
|
|
1556
|
-
// (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
|
|
1557
|
-
for (const r of listRevisions(historyPath)) {
|
|
1558
|
-
const sel = r.current ? "0" : `-${r.offset}`;
|
|
1559
|
-
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
2288
|
else {
|
|
1563
2289
|
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
1564
2290
|
}
|
|
@@ -1571,6 +2297,13 @@ function runTransform(argv) {
|
|
|
1571
2297
|
const out = flag(argv, "-o") ?? flag(argv, "--out");
|
|
1572
2298
|
const fromRaw = flag(argv, "--from");
|
|
1573
2299
|
const toRaw = flag(argv, "--to");
|
|
2300
|
+
// `--to html --fragment`: body-only markup for embedding in an existing
|
|
2301
|
+
// layout (library parity: RenderOptions.fragment). Consumed here so it can
|
|
2302
|
+
// be rejected on any other target — a discarded flag is a silent lie.
|
|
2303
|
+
const fragIdx = argv.indexOf("--fragment");
|
|
2304
|
+
const fragment = fragIdx >= 0;
|
|
2305
|
+
if (fragment)
|
|
2306
|
+
argv.splice(fragIdx, 1);
|
|
1574
2307
|
// Same `--root` as `check`, and for the same reason: cross-document resolution is
|
|
1575
2308
|
// fail-closed at the document's own directory, so a reference that climbs out of
|
|
1576
2309
|
// it needs the tree's root named. Without this the transform silently ignored the
|
|
@@ -1618,6 +2351,8 @@ function runTransform(argv) {
|
|
|
1618
2351
|
else {
|
|
1619
2352
|
outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
|
|
1620
2353
|
}
|
|
2354
|
+
if (fragment && outFmt !== "html")
|
|
2355
|
+
fail("--fragment only applies to --to html", 2);
|
|
1621
2356
|
const src = readInput(file);
|
|
1622
2357
|
// md -> geml is a direct projection, not a parse/serialize round-trip: emit
|
|
1623
2358
|
// the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
|
|
@@ -1654,6 +2389,7 @@ function runTransform(argv) {
|
|
|
1654
2389
|
case "html":
|
|
1655
2390
|
output = renderHtml(doc, {
|
|
1656
2391
|
source: file === "-" ? "stdin" : basename(file),
|
|
2392
|
+
fragment,
|
|
1657
2393
|
// geml-code-graph embeds load + parse sibling codemap docs on demand.
|
|
1658
2394
|
loadDoc: resolverFor(file, root),
|
|
1659
2395
|
parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
|
|
@@ -1822,39 +2558,55 @@ function resolveSelector(source, file, raw) {
|
|
|
1822
2558
|
// resolves against: typed blocks and headings. A `[^id]` reference names one
|
|
1823
2559
|
// of those (§5.2); the `[^id]: text` definition line was withdrawn.
|
|
1824
2560
|
function listIds(source, file, json) {
|
|
2561
|
+
const where = file === "-" ? "stdin" : file;
|
|
2562
|
+
const all = addressedUnits(source);
|
|
1825
2563
|
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
1826
|
-
const rows =
|
|
1827
|
-
const
|
|
1828
|
-
const
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2564
|
+
const rows = all.map((a) => {
|
|
2565
|
+
const u = a.unit;
|
|
2566
|
+
const row = {
|
|
2567
|
+
address: shortestAddress(a, all),
|
|
2568
|
+
kind: u.kind === "block" ? u.type ?? "block" : u.kind,
|
|
2569
|
+
lines: [u.span.start + 1, u.span.end],
|
|
2570
|
+
};
|
|
2571
|
+
// §6.3: EVERY id-less block is flagged, including one whose address works
|
|
2572
|
+
// only because its type happens to be unique (`=== meta`) — that it has no
|
|
2573
|
+
// id yet is precisely the fact you might want to act on (§5.2).
|
|
2574
|
+
if (u.id === undefined)
|
|
2575
|
+
row.anon = true;
|
|
2576
|
+
else
|
|
2577
|
+
row.id = u.id;
|
|
2578
|
+
if (u.kind === "heading") {
|
|
2579
|
+
row.level = u.level;
|
|
2580
|
+
row.text = u.text;
|
|
2581
|
+
}
|
|
2582
|
+
// `.footnote` is authored, not synthesized (the `[^id]: text` definition
|
|
2583
|
+
// line was withdrawn) — but it still marks a block meant as a footnote.
|
|
2584
|
+
if (u.id !== undefined) {
|
|
2585
|
+
const site = findBlockSite(doc.children, u.id);
|
|
2586
|
+
const b = site?.siblings[site.index];
|
|
2587
|
+
if (b?.kind === "block" && b.classes.includes("footnote"))
|
|
1836
2588
|
row.footnote = true;
|
|
1837
|
-
return row;
|
|
1838
2589
|
}
|
|
1839
|
-
return
|
|
2590
|
+
return row;
|
|
1840
2591
|
});
|
|
2592
|
+
// §6.6: the empty document is a legitimate empty answer to "list everything",
|
|
2593
|
+
// not a lookup failure — exit 0, and `--json` prints `[]` so a `| jq length`
|
|
2594
|
+
// over a prose-only document does not blow up.
|
|
1841
2595
|
if (json) {
|
|
1842
2596
|
console.log(JSON.stringify(rows, null, 2));
|
|
1843
2597
|
return;
|
|
1844
2598
|
}
|
|
1845
2599
|
if (rows.length === 0) {
|
|
1846
|
-
console.error(`no addressable
|
|
2600
|
+
console.error(`no addressable blocks in ${where}`);
|
|
1847
2601
|
return;
|
|
1848
2602
|
}
|
|
1849
|
-
|
|
1850
|
-
const idW = Math.max(...rows.map((r) => r.id.length + 1));
|
|
2603
|
+
const addrW = Math.max(...rows.map((r) => r.address.length));
|
|
1851
2604
|
const kindW = Math.max(...rows.map((r) => r.kind.length));
|
|
1852
2605
|
for (const r of rows) {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
line += " footnote";
|
|
2606
|
+
const mark = r.kind === "heading" ? `h${r.level}` : r.anon ? "anon" : "";
|
|
2607
|
+
const tail = r.kind === "heading" ? r.text ?? "" : `L${r.lines[0]}-${r.lines[1]}`;
|
|
2608
|
+
const line = `${r.address.padEnd(addrW)} ${r.kind.padEnd(kindW)} ${mark.padEnd(4)} ${tail}`
|
|
2609
|
+
+ (r.footnote ? " footnote" : "");
|
|
1858
2610
|
console.log(line.replace(/\s+$/, ""));
|
|
1859
2611
|
}
|
|
1860
2612
|
}
|
|
@@ -1871,42 +2623,11 @@ function listIds(source, file, json) {
|
|
|
1871
2623
|
// guessed between, so a document with three notes answers "which one" instead
|
|
1872
2624
|
// of failing. The uniqueness that makes `=== meta` work is checked here, at
|
|
1873
2625
|
// resolve time — nothing in the format has to promise a document holds only one.
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
}
|
|
1880
|
-
if (matches.length === 1) {
|
|
1881
|
-
const m = matches[0];
|
|
1882
|
-
if (json) {
|
|
1883
|
-
// The ONLY block of its type: locating it in the model needs no index, so
|
|
1884
|
-
// --json can still answer with the parsed node (meta's key/values, a
|
|
1885
|
-
// table's model) rather than a mere location.
|
|
1886
|
-
const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
|
|
1887
|
-
if (node) {
|
|
1888
|
-
console.log(JSON.stringify(node, null, 2));
|
|
1889
|
-
return;
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
const span = headOnly ? narrowToHead(m.span) : m.span;
|
|
1893
|
-
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
1894
|
-
return;
|
|
1895
|
-
}
|
|
1896
|
-
// Several: report WHERE they are (data on stdout, the explanation on stderr),
|
|
1897
|
-
// so the caller can name one — by adding an #id, or via its section.
|
|
1898
|
-
if (json) {
|
|
1899
|
-
console.log(JSON.stringify({ kind: "blocks", type, matches: matches.map((m) => ({ ...(m.id ? { id: m.id } : {}), lines: [m.span.start + 1, m.span.end] })) }, null, 2));
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
|
|
1903
|
-
for (const m of matches) {
|
|
1904
|
-
console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
// The single block of `type` in a document, or undefined when there is not
|
|
1908
|
-
// exactly one (nested flow children included, matching the span scan's reach).
|
|
1909
|
-
function onlyBlockOfType(blocks, type) {
|
|
2626
|
+
// Every block of `type` in document order, nested flow children included —
|
|
2627
|
+
// exactly the span scan's reach and order, so the k-th scan match and the k-th
|
|
2628
|
+
// model node are the same block. That correspondence is what lets an ANONYMOUS
|
|
2629
|
+
// block's `--json` find its node without an id to look it up by.
|
|
2630
|
+
function blocksOfType(blocks, type) {
|
|
1910
2631
|
const hits = [];
|
|
1911
2632
|
const walk = (list) => {
|
|
1912
2633
|
for (const b of list) {
|
|
@@ -1919,65 +2640,196 @@ function onlyBlockOfType(blocks, type) {
|
|
|
1919
2640
|
}
|
|
1920
2641
|
};
|
|
1921
2642
|
walk(blocks);
|
|
1922
|
-
return hits
|
|
1923
|
-
}
|
|
2643
|
+
return hits;
|
|
2644
|
+
}
|
|
2645
|
+
// A unit's index among the units of its own type, for the positional lookup above.
|
|
2646
|
+
function typeIndex(all, u) {
|
|
2647
|
+
return all.filter((a) => a.unit.type === u.type).findIndex((a) => a.unit === u);
|
|
2648
|
+
}
|
|
2649
|
+
// Resolve a NON-list selector to the units it matches, or fail with the reason.
|
|
2650
|
+
// `where` names the haystack for the error messages — a file for `geml get`, a
|
|
2651
|
+
// revision for `geml history get`'s tier 2. Shared by both so the one selector
|
|
2652
|
+
// grammar has one implementation: history's design §10.1 asks for exactly this,
|
|
2653
|
+
// and its §3.2 records what happened the last time a selector grammar was
|
|
2654
|
+
// written twice (the printed selectors stopped being readable back).
|
|
2655
|
+
function selectUnits(source, file, rawSel, where) {
|
|
2656
|
+
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2657
|
+
// Callers handle the empty selector themselves (list for `get`, usage error
|
|
2658
|
+
// for `set`); reaching here with one is a caller bug surfaced as usage.
|
|
2659
|
+
if (sel.form === "list")
|
|
2660
|
+
fail(`no selector given — run \`geml get ${where}\` to list addressable blocks`, 2);
|
|
2661
|
+
if (sel.form === "attr") {
|
|
2662
|
+
// §7: the wording says "not implemented yet", not "braces are meaningless" —
|
|
2663
|
+
// §2 declares attribute keys as part of the model, so implementing them
|
|
2664
|
+
// later fills in a declared slot rather than reversing this message.
|
|
2665
|
+
fail(`only \`#id\` is supported as a filter key today (got \`${sel.key}\`) — use \`=== ${sel.type}\` for every ${sel.type} block, or address one by \`#id\` / \`@<hex>\``, 2);
|
|
2666
|
+
}
|
|
2667
|
+
const all = addressedUnits(source);
|
|
2668
|
+
if (sel.form === "content") {
|
|
2669
|
+
const hit = matchContent(sel, all);
|
|
2670
|
+
if (!hit.ok) {
|
|
2671
|
+
if (hit.why === "wrong-type") {
|
|
2672
|
+
// §3.3: the type prefix is a CHECK. Ignoring a wrong one would make it
|
|
2673
|
+
// a decoration that is allowed to lie, and would silently accept a
|
|
2674
|
+
// hand-edited address.
|
|
2675
|
+
fail(`\`@${sel.hex}\` addresses a \`${hit.found}\` block, not \`${sel.type}\` — drop the type prefix to address it by content alone`, 1);
|
|
2676
|
+
}
|
|
2677
|
+
const suffix = sel.nth ? `~${sel.nth}` : "";
|
|
2678
|
+
fail(`no block matching \`@${sel.hex}${suffix}\` in ${where} — a content address goes stale when the block's content changes (that is the point: §3.2); run \`geml get ${where}\` for current addresses`, 1);
|
|
2679
|
+
}
|
|
2680
|
+
return { units: [hit.unit], all };
|
|
2681
|
+
}
|
|
2682
|
+
if (sel.form === "type") {
|
|
2683
|
+
const hits = matchType(sel.type, all);
|
|
2684
|
+
if (!hits.length)
|
|
2685
|
+
fail(`no \`${sel.type}\` block in ${where}${discoveryHint(where)}`, 1);
|
|
2686
|
+
return { units: hits, all };
|
|
2687
|
+
}
|
|
2688
|
+
// `#id` / bare id / a pasted `## Heading` line — resolveSelector needs a parse
|
|
2689
|
+
// to match heading TEXT, so it stays the one path that reaches the model.
|
|
2690
|
+
const id = resolveSelector(source, file, sel.raw);
|
|
2691
|
+
const unit = all.find((a) => a.unit.id === id)?.unit;
|
|
2692
|
+
// Bare `no block with id \`x\`` — the phrasing every caller of a missing id
|
|
2693
|
+
// has always seen, and which `set`'s own tests pin. `where` is appended only
|
|
2694
|
+
// when it is NOT the file the caller already named (a revision), so the
|
|
2695
|
+
// common case reads the same as before this selector grammar existed.
|
|
2696
|
+
if (!unit)
|
|
2697
|
+
fail(`no block with id \`${id}\`${where.startsWith("revision ") ? ` in ${where}` : ""}`, 1);
|
|
2698
|
+
return { units: [unit], all };
|
|
2699
|
+
}
|
|
2700
|
+
// The document-model node for one unit; a heading yields its SECTION envelope,
|
|
2701
|
+
// so --json covers the same content as the raw span. `kind:"section"` lets a
|
|
2702
|
+
// consumer branch — every other unit yields the single node (the model is flat).
|
|
2703
|
+
function unitNode(source, file, unit, all) {
|
|
2704
|
+
const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2705
|
+
if (unit.id !== undefined) {
|
|
2706
|
+
const site = findBlockSite(doc.children, unit.id);
|
|
2707
|
+
if (!site)
|
|
2708
|
+
fail(`no block with id \`${unit.id}\``, 1);
|
|
2709
|
+
const block = site.siblings[site.index];
|
|
2710
|
+
if (block.kind !== "heading")
|
|
2711
|
+
return block;
|
|
2712
|
+
const end = sectionEndIndex(site.siblings, site.index);
|
|
2713
|
+
return { kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) };
|
|
2714
|
+
}
|
|
2715
|
+
const node = blocksOfType(doc.children, unit.type ?? "")[typeIndex(all, unit)];
|
|
2716
|
+
if (!node)
|
|
2717
|
+
fail(`could not locate the \`${unit.type}\` block in the document model`, 1);
|
|
2718
|
+
return node;
|
|
2719
|
+
}
|
|
2720
|
+
// stderr line for an N-match selector: content stays on stdout, so a redirect
|
|
2721
|
+
// captures document bytes only, and the caller still learns how many it got (§5).
|
|
2722
|
+
function reportMatches(type, units) {
|
|
2723
|
+
const at = units.map((u) => `L${u.span.start + 1}-${u.span.end}${u.id ? ` #${u.id}` : ""}`).join(" · ");
|
|
2724
|
+
console.error(`${units.length} \`${type}\` blocks (${at})`);
|
|
2725
|
+
}
|
|
2726
|
+
// `geml get <file.geml|-> [<selector>] [--head|--body] [--json]` — read the
|
|
2727
|
+
// document's addressable structure, or one/several blocks out of it.
|
|
2728
|
+
//
|
|
2729
|
+
// The selector is a FILTER (§2 of the get/set selector design): no selector
|
|
2730
|
+
// LISTS every addressable block with its shortest unique address; `#id` /
|
|
2731
|
+
// `## Heading` / `=== type@<hex>` name at most one; `=== type` matches 0..N.
|
|
2732
|
+
// Cardinality is uniform (§5): 0 → exit 1, 1 → the content, N → N contents in
|
|
2733
|
+
// document order with the count on stderr. `--head`/`--body` narrow to one part
|
|
2734
|
+
// of each match, and every flag combination that used to be half-honoured is
|
|
2735
|
+
// now a usage error (§7) — a discarded flag is a command that quietly did
|
|
2736
|
+
// something else.
|
|
1924
2737
|
function runGet(args) {
|
|
1925
2738
|
const json = args.includes("--json");
|
|
1926
2739
|
const headOnly = args.includes("--head");
|
|
1927
|
-
const
|
|
2740
|
+
const bodyOnly = args.includes("--body");
|
|
2741
|
+
const view = args.includes("--view");
|
|
2742
|
+
const [file, rawSel] = positionals(args, ["--root"]);
|
|
1928
2743
|
if (!file)
|
|
1929
2744
|
fail(SUBHELP.get);
|
|
1930
|
-
|
|
1931
|
-
|
|
2745
|
+
if (headOnly && bodyOnly)
|
|
2746
|
+
fail("--head and --body are mutually exclusive", 2);
|
|
2747
|
+
if (json && (headOnly || bodyOnly)) {
|
|
2748
|
+
fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
|
|
2749
|
+
}
|
|
1932
2750
|
// One read: stdin can only be consumed once, and the selector resolver needs
|
|
1933
2751
|
// the same bytes the slice below works on.
|
|
1934
2752
|
const source = readInput(file);
|
|
1935
|
-
|
|
2753
|
+
const where = file === "-" ? "stdin" : file;
|
|
2754
|
+
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2755
|
+
if (sel.form === "list") {
|
|
2756
|
+
// §5.1: nothing here to narrow, and ignoring the flag would make
|
|
2757
|
+
// `get f --head` print byte-for-byte what `get f` prints.
|
|
2758
|
+
if (headOnly || bodyOnly) {
|
|
2759
|
+
fail(`${headOnly ? "--head" : "--body"} names part of ONE block, so it needs a selector — run \`geml get ${where}\` to list what to address`, 2);
|
|
2760
|
+
}
|
|
1936
2761
|
listIds(source, file, json);
|
|
1937
2762
|
return;
|
|
1938
2763
|
}
|
|
1939
|
-
|
|
1940
|
-
//
|
|
1941
|
-
//
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
2764
|
+
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
2765
|
+
// The chain is composed with `/` — relJoinPath's rule, and `src=` values are
|
|
2766
|
+
// always `/`-separated — so normalize the PLATFORM path at this boundary. On
|
|
2767
|
+
// Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
|
|
2768
|
+
// tell, and a relative `src=` resolves against the wrong base.
|
|
2769
|
+
const startDoc = where.replace(/\\/g, "/");
|
|
2770
|
+
const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
|
|
2771
|
+
if (json) {
|
|
2772
|
+
// §7: N matches yield N model nodes. The old `{kind:"blocks",
|
|
2773
|
+
// matches:[{lines}]}` coordinate envelope is gone — it answered "where are
|
|
2774
|
+
// they" when the question is "what are they" (§9 change 2).
|
|
2775
|
+
let nodes;
|
|
2776
|
+
try {
|
|
2777
|
+
nodes = units.flatMap((u) => {
|
|
2778
|
+
if (!view)
|
|
2779
|
+
return [unitNode(source, file, u, all)];
|
|
2780
|
+
return viewResolve(source, startDoc, u, viewRoot).map((res) => {
|
|
2781
|
+
const node = unitNode(res.text, res.doc, res.unit, res.all);
|
|
2782
|
+
// Provenance is mandatory (§4): the node's references and relative
|
|
2783
|
+
// paths resolve against ITS document, not the one asked about. A
|
|
2784
|
+
// whole-document target has no `#`, so it carries `doc` alone.
|
|
2785
|
+
if (res.from !== "") {
|
|
2786
|
+
const h = res.from.lastIndexOf("#");
|
|
2787
|
+
node["from"] = h < 0 ? { doc: res.from }
|
|
2788
|
+
: { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
|
|
2789
|
+
}
|
|
2790
|
+
return node;
|
|
2791
|
+
});
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
catch (e) {
|
|
2795
|
+
if (e instanceof ViewError)
|
|
2796
|
+
fail(e.message, 1);
|
|
2797
|
+
throw e;
|
|
2798
|
+
}
|
|
2799
|
+
console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
|
|
1946
2800
|
return;
|
|
1947
2801
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
//
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
//
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2802
|
+
if (units.length > 1)
|
|
2803
|
+
reportMatches(units[0].type ?? "", units);
|
|
2804
|
+
if (view) {
|
|
2805
|
+
// All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
|
|
2806
|
+
// chain that breaks halfway cannot leave a partial read on stdout for a
|
|
2807
|
+
// caller that ignores the exit code. Partial scenery is not scenery.
|
|
2808
|
+
const out = [];
|
|
2809
|
+
const notes = [];
|
|
2810
|
+
try {
|
|
2811
|
+
for (const u of units) {
|
|
2812
|
+
for (const res of viewResolve(source, startDoc, u, viewRoot)) {
|
|
2813
|
+
if (res.from !== "")
|
|
2814
|
+
notes.push(`view: ${rawSel} -> ${res.from}`);
|
|
2815
|
+
out.push(sliceUnit(res.text, res.unit.span, headOnly, bodyOnly));
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
catch (e) {
|
|
2820
|
+
// A chain that cannot reach an entity block is a failed READ, reported the
|
|
2821
|
+
// way `get` reports a selector that matches nothing: one line, exit 1.
|
|
2822
|
+
if (e instanceof ViewError)
|
|
2823
|
+
fail(e.message, 1);
|
|
2824
|
+
throw e;
|
|
1970
2825
|
}
|
|
1971
|
-
|
|
2826
|
+
for (const n of notes)
|
|
2827
|
+
console.error(n);
|
|
2828
|
+
process.stdout.write(out.join(""));
|
|
1972
2829
|
return;
|
|
1973
2830
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
const found = blockSpans(source).get(id);
|
|
1977
|
-
if (!found)
|
|
1978
|
-
fail(`no block with id \`${id}\``, 1);
|
|
1979
|
-
const span = headOnly ? narrowToHead(found) : found;
|
|
1980
|
-
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
2831
|
+
for (const u of units)
|
|
2832
|
+
process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
|
|
1981
2833
|
}
|
|
1982
2834
|
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
1983
2835
|
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
@@ -2003,14 +2855,19 @@ function runSet(args) {
|
|
|
2003
2855
|
const bodyOnly = args.includes("--body");
|
|
2004
2856
|
if (headOnly && bodyOnly)
|
|
2005
2857
|
fail("--head and --body are mutually exclusive", 2);
|
|
2006
|
-
|
|
2858
|
+
// `--view` reads THROUGH an embed (see runGet). Writing through one would mean
|
|
2859
|
+
// one `set` silently editing a different file, so it is refused rather than
|
|
2860
|
+
// ignored — and the message has to point the way, not just say no.
|
|
2861
|
+
if (args.includes("--view")) {
|
|
2862
|
+
fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
|
|
2863
|
+
}
|
|
2864
|
+
const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
|
|
2007
2865
|
if (!file)
|
|
2008
2866
|
fail(SUBHELP.set);
|
|
2009
|
-
// No
|
|
2010
|
-
// usage line — `geml get <file>` lists every
|
|
2011
|
-
if (!
|
|
2012
|
-
fail(`no
|
|
2013
|
-
const id = rawId.replace(/^#/, "");
|
|
2867
|
+
// No selector: there is no block to replace. Point the way to discovery, not a
|
|
2868
|
+
// bare usage line — `geml get <file>` lists every address `set` can target.
|
|
2869
|
+
if (!rawSel)
|
|
2870
|
+
fail(`no selector given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable blocks`, 2);
|
|
2014
2871
|
// The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
|
|
2015
2872
|
// a block from a file. Document and content can't BOTH be stdin: reject that
|
|
2016
2873
|
// up front, before consuming stdin, so the document read below is unambiguous.
|
|
@@ -2019,16 +2876,11 @@ function runSet(args) {
|
|
|
2019
2876
|
fail("reading the document from stdin needs --in for the new content", 2);
|
|
2020
2877
|
}
|
|
2021
2878
|
const source = readInput(file);
|
|
2879
|
+
const target = resolveSetTarget(source, file, rawSel);
|
|
2022
2880
|
if (bodyOnly) {
|
|
2023
|
-
runSetBody(source,
|
|
2881
|
+
runSetBody(source, target, from, rawChannel, file, out);
|
|
2024
2882
|
return;
|
|
2025
2883
|
}
|
|
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);
|
|
2032
2884
|
let content;
|
|
2033
2885
|
if (rawChannel) {
|
|
2034
2886
|
content = readInput("-");
|
|
@@ -2042,39 +2894,71 @@ function runSet(args) {
|
|
|
2042
2894
|
if (shape === "empty")
|
|
2043
2895
|
fail(NO_CONTENT, 1);
|
|
2044
2896
|
if (shape === "prose")
|
|
2045
|
-
fail(`content is prose, not a block — use --body to set the body of
|
|
2897
|
+
fail(`content is prose, not a block — use --body to set the body of ${target.label}`, 1);
|
|
2046
2898
|
if (shape === "multi")
|
|
2047
2899
|
fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
|
|
2048
2900
|
}
|
|
2049
2901
|
}
|
|
2050
2902
|
else {
|
|
2051
|
-
content = extractBlock(from, id, headOnly ? "head" : "whole");
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2903
|
+
content = extractBlock(from, target.unit.id ?? "", headOnly ? "head" : "whole");
|
|
2904
|
+
}
|
|
2905
|
+
// §5.2: `@<hex>` is not an id, so "normalize the content's id to the target's"
|
|
2906
|
+
// has no subject — the content is used verbatim, and an id it brings that
|
|
2907
|
+
// collides is caught by the splice guard like any other. An id target keeps
|
|
2908
|
+
// normalizing: naming an id on the command line IS the instruction that the
|
|
2909
|
+
// result carries that id (block-mutation design §4.0).
|
|
2910
|
+
const replacement = target.unit.id !== undefined ? normalizeBlockId(content, target.unit.id) : content;
|
|
2911
|
+
const updated = spliceSpan(source, target.unit.span, replacement, file, headOnly, false, target.unit.id);
|
|
2055
2912
|
resolveOutTarget(file, out).write(updated);
|
|
2913
|
+
reportNewAddress(updated, target);
|
|
2914
|
+
}
|
|
2915
|
+
// Resolve a selector to the ONE unit `set` will overwrite. `get` may answer with
|
|
2916
|
+
// N blocks; `set` may not — §5: with N targets there is no single id to
|
|
2917
|
+
// normalize the content to, so multi-target `set` is undefined, not merely
|
|
2918
|
+
// risky. Refused with exit 2 (a usage error), not exit 1.
|
|
2919
|
+
function resolveSetTarget(source, file, rawSel) {
|
|
2920
|
+
const where = file === "-" ? "<file>" : file;
|
|
2921
|
+
const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
|
|
2922
|
+
if (sel.form === "list")
|
|
2923
|
+
fail(`no selector given — run 'geml get ${where}' to list addressable blocks`, 2);
|
|
2924
|
+
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
2925
|
+
if (units.length > 1) {
|
|
2926
|
+
// §5: with N targets there is no single id to normalize the content to, so
|
|
2927
|
+
// multi-target `set` is UNDEFINED, not merely risky. The addresses are
|
|
2928
|
+
// printed because they ARE the fix — each is unique and pastes straight
|
|
2929
|
+
// back into this same command (§6.2).
|
|
2930
|
+
const opts = units.map((u) => {
|
|
2931
|
+
const a = all.find((x) => x.unit === u);
|
|
2932
|
+
return ` ${shortestAddress(a, all)} L${u.span.start + 1}-${u.span.end}`;
|
|
2933
|
+
}).join("\n");
|
|
2934
|
+
fail(`\`${rawSel.trim()}\` matches ${units.length} blocks — set writes ONE; address it uniquely:\n${opts}`, 2);
|
|
2935
|
+
}
|
|
2936
|
+
const unit = units[0];
|
|
2937
|
+
const label = unit.id !== undefined && sel.form === "id" ? `#${unit.id}` : `\`${rawSel.trim()}\``;
|
|
2938
|
+
return { unit, label, byContent: sel.form === "content" };
|
|
2939
|
+
}
|
|
2940
|
+
// §5.3: writing through a content address CHANGES it, so print the new one —
|
|
2941
|
+
// otherwise a script editing the same block twice has to re-list in between.
|
|
2942
|
+
// stderr, because stdout may be the document itself (`-o -`).
|
|
2943
|
+
function reportNewAddress(updated, target) {
|
|
2944
|
+
if (!target.byContent)
|
|
2945
|
+
return;
|
|
2946
|
+
const after = addressedUnits(updated).find((a) => a.unit.span.start === target.unit.span.start);
|
|
2947
|
+
if (after)
|
|
2948
|
+
console.error(`new address: ${shortestAddress(after, addressedUnits(updated))}`);
|
|
2056
2949
|
}
|
|
2057
2950
|
// `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
|
|
2058
2951
|
// for a typed block, its close fence. Assembles head + new body + close and
|
|
2059
2952
|
// reuses the guarded spliceBlock — the head carries #id, so the id survives
|
|
2060
2953
|
// with no normalization needed.
|
|
2061
|
-
function runSetBody(source,
|
|
2062
|
-
const found =
|
|
2063
|
-
if (!found)
|
|
2064
|
-
fail(`no block with id \`${id}\``, 1);
|
|
2954
|
+
function runSetBody(source, target, from, rawChannel, file, out) {
|
|
2955
|
+
const found = target.unit.span;
|
|
2065
2956
|
const lines = splitLines(source);
|
|
2066
2957
|
const headLine = lines[found.start] ?? "";
|
|
2067
|
-
|
|
2068
|
-
//
|
|
2069
|
-
|
|
2070
|
-
const
|
|
2071
|
-
if (open) {
|
|
2072
|
-
const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
2073
|
-
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
2074
|
-
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
2075
|
-
if (isCloseFence(lastText, open[1].length) || labeled)
|
|
2076
|
-
closeLine = lines[found.end - 1] ?? "";
|
|
2077
|
-
}
|
|
2958
|
+
// A typed block keeps its closing fence; a heading section has none. Decided
|
|
2959
|
+
// by the same helper `get --body` uses, so the two agree on the span and the
|
|
2960
|
+
// §4 round-trip invariant holds.
|
|
2961
|
+
const closeLine = closeFenceLine(lines, found);
|
|
2078
2962
|
let body;
|
|
2079
2963
|
if (rawChannel) {
|
|
2080
2964
|
body = readInput("-");
|
|
@@ -2082,7 +2966,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
|
|
|
2082
2966
|
fail(NO_CONTENT, 1);
|
|
2083
2967
|
}
|
|
2084
2968
|
else {
|
|
2085
|
-
body = extractBlock(from, id, "body");
|
|
2969
|
+
body = extractBlock(from, target.unit.id ?? "", "body");
|
|
2086
2970
|
}
|
|
2087
2971
|
let head = headLine;
|
|
2088
2972
|
if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
|
|
@@ -2095,8 +2979,9 @@ function runSetBody(source, id, from, rawChannel, file, out) {
|
|
|
2095
2979
|
// block-count invariant so a `===` fence in the raw body can't close it early
|
|
2096
2980
|
// and inject siblings (SEC F2). A heading section body has no close fence and
|
|
2097
2981
|
// may legitimately contain blocks, so it is not count-guarded.
|
|
2098
|
-
const updated =
|
|
2982
|
+
const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
|
|
2099
2983
|
resolveOutTarget(file, out).write(updated);
|
|
2984
|
+
reportNewAddress(updated, target);
|
|
2100
2985
|
}
|
|
2101
2986
|
// `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
|
|
2102
2987
|
// — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
|
|
@@ -2397,6 +3282,13 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2397
3282
|
const found = blockSpans(source).get(id);
|
|
2398
3283
|
if (!found)
|
|
2399
3284
|
fail(`no block with id \`${id}\``, 1);
|
|
3285
|
+
return spliceSpan(source, found, replacement, file, headOnly, guardCount, id);
|
|
3286
|
+
}
|
|
3287
|
+
// The same guarded splice addressed by SPAN rather than by id, because an
|
|
3288
|
+
// anonymous block (addressed by `@<hex>`) has no id to look one up with. `id`
|
|
3289
|
+
// is the survival guard's subject and is simply absent for those: every OTHER
|
|
3290
|
+
// pre-existing id must still survive, which the `dropped` check below covers.
|
|
3291
|
+
function spliceSpan(source, found, replacement, file, headOnly = false, guardCount = false, id) {
|
|
2400
3292
|
const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
|
|
2401
3293
|
const beforeIds = beforeDoc.ids;
|
|
2402
3294
|
// Keep the bytes before and after the target span exactly; give the new block
|
|
@@ -2428,7 +3320,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2428
3320
|
refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
2429
3321
|
}
|
|
2430
3322
|
const now = new Set(reparsed.ids);
|
|
2431
|
-
if (!now.has(id))
|
|
3323
|
+
if (id !== undefined && !now.has(id))
|
|
2432
3324
|
fail(`replacement removes id \`${id}\`; not written`, 1);
|
|
2433
3325
|
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
2434
3326
|
if (dropped !== undefined) {
|
|
@@ -2443,7 +3335,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
|
|
|
2443
3335
|
// (Not enforced for heading sections / whole-block set, whose replacement may
|
|
2444
3336
|
// legitimately span several top-level blocks.)
|
|
2445
3337
|
if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
|
|
2446
|
-
fail(`replacement changes the block count (a fence in the body closed
|
|
3338
|
+
fail(`replacement changes the block count (a fence in the body closed ${id !== undefined ? `#${id}` : "the target"} early and injected sibling block(s)?); not written`, 1);
|
|
2447
3339
|
}
|
|
2448
3340
|
return updated;
|
|
2449
3341
|
}
|
|
@@ -2695,6 +3587,105 @@ function runMcp(args) {
|
|
|
2695
3587
|
const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
|
|
2696
3588
|
process.exit(r.status ?? 1);
|
|
2697
3589
|
}
|
|
3590
|
+
// geml skill install: one command that makes GEML usable everywhere for a
|
|
3591
|
+
// Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
|
|
3592
|
+
// the CLI on the global PATH, and the MCP server registered at user scope.
|
|
3593
|
+
// Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
|
|
3594
|
+
// sidecars. Idempotent, so re-running after an upgrade refreshes everything.
|
|
3595
|
+
function runSkill(args) {
|
|
3596
|
+
const sub = args[0];
|
|
3597
|
+
if (sub !== "install")
|
|
3598
|
+
fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
|
|
3599
|
+
const rest = args.slice(1);
|
|
3600
|
+
const flag = (name) => {
|
|
3601
|
+
const i = rest.indexOf(name);
|
|
3602
|
+
if (i >= 0)
|
|
3603
|
+
rest.splice(i, 1);
|
|
3604
|
+
return i >= 0;
|
|
3605
|
+
};
|
|
3606
|
+
const opt = (name) => {
|
|
3607
|
+
const i = rest.indexOf(name);
|
|
3608
|
+
if (i < 0)
|
|
3609
|
+
return undefined;
|
|
3610
|
+
const v = rest[i + 1];
|
|
3611
|
+
if (!v)
|
|
3612
|
+
fail(`${name} needs a value.\n${SUBHELP.skill}`);
|
|
3613
|
+
rest.splice(i, 2);
|
|
3614
|
+
return v;
|
|
3615
|
+
};
|
|
3616
|
+
const noGlobal = flag("--no-global");
|
|
3617
|
+
const noMcp = flag("--no-mcp");
|
|
3618
|
+
const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
|
|
3619
|
+
if (rest.length)
|
|
3620
|
+
fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
|
|
3621
|
+
// The skill ships inside the npm package, next to dist/ — the installed
|
|
3622
|
+
// skill text always matches the CLI version it teaches.
|
|
3623
|
+
const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
|
|
3624
|
+
if (!existsSync(join(src, "SKILL.md")))
|
|
3625
|
+
fail(`bundled skill not found at ${src} (broken install?)`, 1);
|
|
3626
|
+
const target = join(dest, "geml");
|
|
3627
|
+
const copied = [];
|
|
3628
|
+
const copyTree = (from, to) => {
|
|
3629
|
+
mkdirSync(to, { recursive: true });
|
|
3630
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
3631
|
+
// Never ship a history sidecar — skill and config docs carry none.
|
|
3632
|
+
if (e.name.endsWith(".gemlhistory"))
|
|
3633
|
+
continue;
|
|
3634
|
+
const f = join(from, e.name);
|
|
3635
|
+
const t = join(to, e.name);
|
|
3636
|
+
if (e.isDirectory())
|
|
3637
|
+
copyTree(f, t);
|
|
3638
|
+
else {
|
|
3639
|
+
copyFileSync(f, t);
|
|
3640
|
+
copied.push(relative(dest, t));
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
try {
|
|
3645
|
+
copyTree(src, target);
|
|
3646
|
+
}
|
|
3647
|
+
catch (e) {
|
|
3648
|
+
// A clean one-liner, never a raw stack: --dest may name a file, a
|
|
3649
|
+
// read-only tree, or a path whose ancestor is not a directory.
|
|
3650
|
+
fail(`cannot install skill to ${target}: ${e instanceof Error ? e.message : String(e)}`, 1);
|
|
3651
|
+
}
|
|
3652
|
+
console.log(`skill installed -> ${target} (${copied.join(", ")})`);
|
|
3653
|
+
// Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
|
|
3654
|
+
// below is a fixed literal, so shell:true adds no injection surface.
|
|
3655
|
+
const sh = process.platform === "win32";
|
|
3656
|
+
const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
|
|
3657
|
+
if (!noGlobal) {
|
|
3658
|
+
const have = run("geml", ["--version"]);
|
|
3659
|
+
if (have.status === 0) {
|
|
3660
|
+
console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
|
|
3661
|
+
}
|
|
3662
|
+
else {
|
|
3663
|
+
console.log("cli installing @geml/geml globally (npm i -g)...");
|
|
3664
|
+
const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
|
|
3665
|
+
if (r.status !== 0)
|
|
3666
|
+
console.error("cli global install failed — install later with: npm i -g @geml/geml");
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
if (!noMcp) {
|
|
3670
|
+
const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
|
|
3671
|
+
const claude = run("claude", ["--version"]);
|
|
3672
|
+
if (claude.status !== 0) {
|
|
3673
|
+
console.log(`mcp claude CLI not found — register later with: ${REG}`);
|
|
3674
|
+
}
|
|
3675
|
+
else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
|
|
3676
|
+
console.log("mcp server 'geml' already registered");
|
|
3677
|
+
}
|
|
3678
|
+
else {
|
|
3679
|
+
const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
|
|
3680
|
+
if (r.status === 0)
|
|
3681
|
+
console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
|
|
3682
|
+
else
|
|
3683
|
+
console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
console.log("done — new Claude Code sessions pick up the skill.");
|
|
3687
|
+
process.exit(0);
|
|
3688
|
+
}
|
|
2698
3689
|
// npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
|
|
2699
3690
|
// CLI" by resolving argv[1] to its real path, not by its spelling.
|
|
2700
3691
|
const entry = (() => {
|
|
@@ -2767,6 +3758,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
|
|
|
2767
3758
|
else if (cmd === "mcp") {
|
|
2768
3759
|
runMcp(argv.slice(1));
|
|
2769
3760
|
}
|
|
3761
|
+
else if (cmd === "skill") {
|
|
3762
|
+
runSkill(argv.slice(1));
|
|
3763
|
+
}
|
|
2770
3764
|
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
2771
3765
|
// A bare word that is neither a known command nor a path is almost always
|
|
2772
3766
|
// a mistyped command — say so, don't try to read it as a file. (The
|