@geml/geml 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/codemap/browser-stub.mjs +5 -0
- package/codemap/build.mjs +20 -6
- package/codemap/exclude.mjs +18 -14
- package/codemap/refresh.mjs +4 -1
- package/codemap/verify.mjs +14 -4
- 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/from-md.js +6 -1
- package/dist/geml.d.ts +5 -1
- package/dist/geml.js +797 -36
- package/dist/mcp.js +23 -2
- package/dist/render-html.d.ts +5 -0
- package/dist/render-html.js +10 -1
- package/dist/render.d.ts +1 -0
- package/dist/render.js +72 -3
- package/dist/serialize.js +12 -0
- package/dist/table.js +27 -1
- package/dist/to-md.js +29 -2
- package/package.json +2 -1
- package/skill/SKILL.md +82 -0
- package/skill/references/authoring.geml +333 -0
package/dist/geml.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// math, media embeds, links, auto-references, footnotes) and build-time
|
|
10
10
|
// reference validation (§8 — unique ids, resolvable internal/cross-document
|
|
11
11
|
// references).
|
|
12
|
-
import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, realpathSync, statSync, existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs";
|
|
13
13
|
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { spawnSync } from "node:child_process";
|
|
16
17
|
import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
@@ -20,13 +21,13 @@ import { normalizeSource } from "./diagnostics.js";
|
|
|
20
21
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
21
22
|
import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
|
|
22
23
|
import { parseTable } from "./table.js";
|
|
23
|
-
import { buildChart } from "./chart.js";
|
|
24
|
+
import { USES, buildChart } from "./chart.js";
|
|
24
25
|
import { mdToGeml } from "./from-md.js";
|
|
25
26
|
import { serialize } from "./serialize.js";
|
|
26
27
|
import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
|
|
27
28
|
import { gemlToMd } from "./to-md.js";
|
|
28
29
|
export { mdToGeml } from "./from-md.js";
|
|
29
|
-
export { renderHtml } from "./render-html.js";
|
|
30
|
+
export { renderHtml, pageAssets } from "./render-html.js";
|
|
30
31
|
export { serialize } from "./serialize.js";
|
|
31
32
|
export { gemlToMd } from "./to-md.js";
|
|
32
33
|
// A block id is any non-whitespace run (§4), so it may contain regex
|
|
@@ -37,6 +38,57 @@ export { gemlToMd } from "./to-md.js";
|
|
|
37
38
|
function reLit(s) {
|
|
38
39
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
39
40
|
}
|
|
41
|
+
// The `data` block's format engines (GEP-0005), shared by the inline-body
|
|
42
|
+
// path and the `src=` pass: parse `body` under `fmt`, returning the value
|
|
43
|
+
// and/or diagnostics. `openLineNo` anchors line numbers — the open fence for
|
|
44
|
+
// an inline body, the block's own line for external content.
|
|
45
|
+
function parseDataBody(fmt, body, openLineNo) {
|
|
46
|
+
const diags = [];
|
|
47
|
+
if (fmt === "json") {
|
|
48
|
+
const text = body.join("\n");
|
|
49
|
+
try {
|
|
50
|
+
return { value: JSON.parse(text), diags };
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
diags.push({ severity: "error", code: "data-parse", message: `data: body is not valid JSON (${e instanceof Error ? e.message : String(e)})`, line: jsonErrorLine(e, text, openLineNo) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (fmt === "jsonl") {
|
|
57
|
+
const values = [];
|
|
58
|
+
let ok = true;
|
|
59
|
+
for (let li = 0; li < body.length; li++) {
|
|
60
|
+
const t = body[li].trim();
|
|
61
|
+
if (t === "")
|
|
62
|
+
continue; // blank lines are permitted and ignored
|
|
63
|
+
try {
|
|
64
|
+
values.push(JSON.parse(t));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
diags.push({ severity: "error", code: "data-parse", message: `data: body line ${li + 1} is not one JSON value`, line: openLineNo + 1 + li });
|
|
68
|
+
ok = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (ok)
|
|
72
|
+
return { value: values, diags };
|
|
73
|
+
}
|
|
74
|
+
else if (fmt === "yaml" || fmt === "toml") {
|
|
75
|
+
diags.push({ severity: "warning", code: "data-format-no-engine", message: `data: no \`${fmt}\` engine in this processor; body kept raw, not verified`, line: openLineNo });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
diags.push({ severity: "warning", code: "unknown-data-format", message: `unknown data format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
79
|
+
}
|
|
80
|
+
return { diags };
|
|
81
|
+
}
|
|
82
|
+
// Map a JSON.parse failure to the document line it happened on. V8 messages
|
|
83
|
+
// carry "at position N" (newer Nodes add line/column, but position is the
|
|
84
|
+
// stable token); counting newlines up to it gives the 1-based body line, and
|
|
85
|
+
// the open fence line offsets it into the document. No position -> the fence.
|
|
86
|
+
function jsonErrorLine(e, text, openLineNo) {
|
|
87
|
+
const m = /position (\d+)/.exec(e instanceof Error ? e.message : "");
|
|
88
|
+
if (!m)
|
|
89
|
+
return openLineNo;
|
|
90
|
+
return openLineNo + text.slice(0, Number(m[1])).split("\n").length;
|
|
91
|
+
}
|
|
40
92
|
// Re-exported from ./diagnostics.js so that `Diagnostic` stays importable from
|
|
41
93
|
// the package root. The catalogue of codes lives there (spec Appendix A).
|
|
42
94
|
export { SEVERITY } from "./diagnostics.js";
|
|
@@ -47,6 +99,7 @@ const REGISTRY = {
|
|
|
47
99
|
diagram: "raw",
|
|
48
100
|
math: "raw",
|
|
49
101
|
table: "raw", // structured table parsing lands in M3
|
|
102
|
+
data: "raw", // GEP-0005: value tree — a format engine parses the raw body in a second stage
|
|
50
103
|
embed: "raw", // block transclusion: `src=` points at the content, body unused
|
|
51
104
|
note: "flow",
|
|
52
105
|
text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
|
|
@@ -60,6 +113,24 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
|
|
|
60
113
|
// ---------------------------------------------------------------------------
|
|
61
114
|
const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
|
|
62
115
|
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
|
|
116
|
+
// A line with the exact shape of a labeled close (§3): a `=` run and a `#id`,
|
|
117
|
+
// nothing else. Matched against lines that fell through to paragraph text,
|
|
118
|
+
// where such a line means the close closed nothing (stray-labeled-fence).
|
|
119
|
+
const STRAY_LABELED_FENCE = /^={3,}[ \t]+#(\S+)[ \t]*$/;
|
|
120
|
+
// The registered block types (§3's registry), for the fence-like check below:
|
|
121
|
+
// an unknown word after `===` is likelier a wall of `=` art or foreign syntax,
|
|
122
|
+
// so only a KNOWN type name earns the warning.
|
|
123
|
+
const REGISTERED_TYPES = new Set(["code", "diagram", "table", "math", "embed", "note", "text", "meta", "data"]);
|
|
124
|
+
// A line that WANTS to open a fence — a `=` run and a registered type name —
|
|
125
|
+
// but failed the fence production. The classic shape is bare, unbraced
|
|
126
|
+
// attributes (`=== embed src=#a`): the line silently became prose and any
|
|
127
|
+
// reference in it was never checked, which buried a real bug (all eight
|
|
128
|
+
// embeds of the playground showcase shipped in this shape, rendering as
|
|
129
|
+
// paragraphs under a green `check`). Matched, like STRAY_LABELED_FENCE, only
|
|
130
|
+
// against lines that fell through to paragraph text — raw block bodies and
|
|
131
|
+
// `\`-folded fence lines never reach that position, so the measured corpus
|
|
132
|
+
// false-positive rate is zero.
|
|
133
|
+
const FENCE_LIKE = /^={3,}[ \t]+([A-Za-z][A-Za-z0-9_-]*)\b/;
|
|
63
134
|
const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
64
135
|
// Maximum block/list nesting depth the recursive-descent scanner will build
|
|
65
136
|
// before emitting a diagnostic instead of recursing further. Guards parse()
|
|
@@ -295,22 +366,36 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
295
366
|
const type = open[2];
|
|
296
367
|
const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
|
|
297
368
|
const openLineNo = base + i + 1;
|
|
298
|
-
// Collect the body. A block closes on
|
|
299
|
-
// length, OR — when it has an id —
|
|
300
|
-
// of any length ≥ 3 followed by the block's id). The
|
|
301
|
-
//
|
|
302
|
-
//
|
|
369
|
+
// Collect the body. A block closes on the FIRST line that is a bare fence
|
|
370
|
+
// of exactly the opening length, OR — when it has an id — a labeled fence
|
|
371
|
+
// `=== #id` (a `=` run of any length ≥ 3 followed by the block's id). The
|
|
372
|
+
// labeled close can't be gotten wrong by miscounting `=`, but it does NOT
|
|
373
|
+
// shadow the bare close: a same-length bare fence in the body still ends
|
|
374
|
+
// the block first, so nesting needs a longer outer fence (§3).
|
|
303
375
|
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
|
|
304
376
|
const body = [];
|
|
305
377
|
let j = i + consumed;
|
|
306
378
|
let closed = false;
|
|
379
|
+
let closedByBare = false;
|
|
307
380
|
for (; j < lines.length; j++) {
|
|
308
|
-
if (isCloseFence(lines[j], openLen)
|
|
381
|
+
if (isCloseFence(lines[j], openLen)) {
|
|
382
|
+
closed = true;
|
|
383
|
+
closedByBare = true;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
if (labeled && labeled.test(lines[j])) {
|
|
309
387
|
closed = true;
|
|
310
388
|
break;
|
|
311
389
|
}
|
|
312
390
|
body.push(lines[j]);
|
|
313
391
|
}
|
|
392
|
+
// Remember a bare close of an id-bearing block (first definition wins,
|
|
393
|
+
// mirroring ctx.ids): if a `=== #id` line for it turns up later as plain
|
|
394
|
+
// text, the stray-labeled-fence warning can name the line that really
|
|
395
|
+
// closed the block.
|
|
396
|
+
if (closedByBare && attrs.id !== undefined && !ctx.bareClosed?.has(attrs.id)) {
|
|
397
|
+
(ctx.bareClosed ??= new Map()).set(attrs.id, base + j + 1);
|
|
398
|
+
}
|
|
314
399
|
if (!closed) {
|
|
315
400
|
const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
|
|
316
401
|
diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
|
|
@@ -326,11 +411,13 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
326
411
|
// the extras below are per type.
|
|
327
412
|
let validRe;
|
|
328
413
|
if (type === "table")
|
|
329
|
-
validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
|
|
414
|
+
validRe = /^(src|format|delim|header|format-data|compute\d*|summary\d*|span\d*)$/;
|
|
415
|
+
else if (type === "data")
|
|
416
|
+
validRe = /^(format|schema|src)$/;
|
|
330
417
|
else if (type === "embed")
|
|
331
418
|
validRe = /^(src)$/;
|
|
332
419
|
else if (type === "diagram")
|
|
333
|
-
validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
|
|
420
|
+
validRe = /^(src|data|format|format-data|delim|header|type|rows|x|y|size|series)$/;
|
|
334
421
|
// `src`/`anchor` on a `code` block are the code-graph profile's
|
|
335
422
|
// (docs/codemap-profile.md): every document `geml codemap build` writes
|
|
336
423
|
// carries them, so warning on them would warn on our own output.
|
|
@@ -416,7 +503,72 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
416
503
|
}
|
|
417
504
|
else {
|
|
418
505
|
block.raw = body;
|
|
419
|
-
if (type === "
|
|
506
|
+
if (type === "data") {
|
|
507
|
+
// §GEP-0005: the value tree. The body stayed raw at scan time; a
|
|
508
|
+
// format engine parses it here — the same two-stage shape `table`
|
|
509
|
+
// uses. Admission to the format registry requires a SELF-DESCRIBING
|
|
510
|
+
// syntax (bytes alone determine the value): the core ships `json`
|
|
511
|
+
// (default — the model's own serialization) and `jsonl`; `yaml` and
|
|
512
|
+
// `toml` are reserved names with no engine here, and degrade exactly
|
|
513
|
+
// like an unknown `diagram` format: body kept raw, one warning.
|
|
514
|
+
const fmtRaw = attrs.attrs["format"];
|
|
515
|
+
const fmt = fmtRaw === undefined ? "json" : String(fmtRaw);
|
|
516
|
+
// `src=` names external content — the same one-source rule tables
|
|
517
|
+
// have (§6): exactly one of `src=` and an inline body. The engine
|
|
518
|
+
// runs over the file in a second pass (resolveDataSources); running
|
|
519
|
+
// it here over the empty body would report a spurious parse error.
|
|
520
|
+
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
521
|
+
const hasBody = body.some((l) => l.trim() !== "");
|
|
522
|
+
if (srcAttr !== undefined && srcAttr !== "" && hasBody) {
|
|
523
|
+
diags.push({ severity: "error", code: "data-src-and-body", message: "data: carries both `src=` and an inline body; exactly one is permitted (the body wins here)", line: openLineNo });
|
|
524
|
+
}
|
|
525
|
+
if (srcAttr !== undefined && srcAttr !== "" && !hasBody) {
|
|
526
|
+
(ctx.dataSources ??= []).push({ block, line: openLineNo, target: srcAttr });
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
const parsed = parseDataBody(fmt, body, openLineNo);
|
|
530
|
+
for (const d of parsed.diags)
|
|
531
|
+
diags.push(d);
|
|
532
|
+
if (parsed.value !== undefined)
|
|
533
|
+
block.value = parsed.value;
|
|
534
|
+
}
|
|
535
|
+
// `schema=` is reference-checked ONLY (GEP-0005): it must name a
|
|
536
|
+
// block or a GEML document; validating the value against it is a
|
|
537
|
+
// later GEP. The reference goes through the ordinary §8 resolver so
|
|
538
|
+
// a dangling schema rots loudly like any other reference.
|
|
539
|
+
const schema = attrs.attrs["schema"];
|
|
540
|
+
if (schema !== undefined) {
|
|
541
|
+
const s = typeof schema === "string" ? schema.trim() : "";
|
|
542
|
+
if (s.startsWith("#") && s.length > 1) {
|
|
543
|
+
ctx.refs.push({ kind: "internal", anchor: s.slice(1), line: openLineNo });
|
|
544
|
+
}
|
|
545
|
+
else if (/\.geml(#|$)/i.test(s)) {
|
|
546
|
+
const h = s.indexOf("#");
|
|
547
|
+
if (h < 0)
|
|
548
|
+
ctx.refs.push({ kind: "cross", doc: s, anchor: undefined, line: openLineNo });
|
|
549
|
+
else
|
|
550
|
+
ctx.refs.push({ kind: "cross", doc: s.slice(0, h), anchor: s.slice(h + 1), line: openLineNo });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
diags.push({ severity: "error", code: "bad-data-schema", message: `data: \`schema=${s}\` must name a block (\`#id\`) or a GEML document (\`doc.geml[#id]\`)`, line: openLineNo });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// First definition wins, matching ctx.ids/ctx.tables.
|
|
557
|
+
if (block.id !== undefined && block.value !== undefined && !ctx.dataValues?.has(block.id)) {
|
|
558
|
+
(ctx.dataValues ??= new Map()).set(block.id, block.value);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (type === "code") {
|
|
562
|
+
// `src=` on a code block is a ROUTE to the code it shows —
|
|
563
|
+
// `<path>[#L<start>[-<end>]]` — resolved in a second pass, like a
|
|
564
|
+
// table's `src=`. The code-graph runtime has always fetched and
|
|
565
|
+
// sliced it at render time; checking it here is what makes a stale
|
|
566
|
+
// range a build error instead of a panel that silently shows a path.
|
|
567
|
+
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
568
|
+
if (srcAttr !== undefined && srcAttr !== "")
|
|
569
|
+
(ctx.codeSources ??= []).push({ block, line: openLineNo, target: srcAttr });
|
|
570
|
+
}
|
|
571
|
+
else if (type === "table") {
|
|
420
572
|
const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
|
|
421
573
|
// §6: parse the raw body (visual or csv/tsv) into one table model.
|
|
422
574
|
const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
|
|
@@ -502,6 +654,38 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
502
654
|
para.push(lines[i]);
|
|
503
655
|
i++;
|
|
504
656
|
}
|
|
657
|
+
// A line shaped exactly like a labeled close (`=== #id`) that got this far
|
|
658
|
+
// closed nothing — when the id's block was already ended by a same-length
|
|
659
|
+
// bare fence in its body (§3), everything from that fence on silently fell
|
|
660
|
+
// out of the block. Warn: "ok: no diagnostics" over a truncated document is
|
|
661
|
+
// the failure mode this diagnostic exists for. The id is used only as a Map
|
|
662
|
+
// key here — no RegExp is built from it, so reLit() does not apply.
|
|
663
|
+
for (let k = 0; k < para.length; k++) {
|
|
664
|
+
const stray = STRAY_LABELED_FENCE.exec(para[k]);
|
|
665
|
+
if (!stray)
|
|
666
|
+
continue;
|
|
667
|
+
const id = stray[1];
|
|
668
|
+
const lineNo = paraStart + k;
|
|
669
|
+
const closedAt = ctx.bareClosed?.get(id);
|
|
670
|
+
diags.push({
|
|
671
|
+
severity: "warning", code: "stray-labeled-fence", line: lineNo,
|
|
672
|
+
message: closedAt !== undefined
|
|
673
|
+
? `labeled fence for \`#${id}\` at line ${lineNo}, but block \`#${id}\` was already closed by a bare fence at line ${closedAt} — body may be silently truncated`
|
|
674
|
+
: `labeled fence for \`#${id}\` closes no block; the line is plain paragraph text`,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
for (let k = 0; k < para.length; k++) {
|
|
678
|
+
// Sibling trap to the stray labeled close: a would-be OPEN fence that
|
|
679
|
+
// missed the production and silently became prose (§3 requires braced
|
|
680
|
+
// attributes; `=== embed src=#a` is the classic miss).
|
|
681
|
+
const like = FENCE_LIKE.exec(para[k]);
|
|
682
|
+
if (like && REGISTERED_TYPES.has(like[1])) {
|
|
683
|
+
diags.push({
|
|
684
|
+
severity: "warning", code: "fence-like-line", line: paraStart + k,
|
|
685
|
+
message: `line looks like an open fence for \`${like[1]}\` but is not one — attributes must be braced (\`=== ${like[1]} {…}\`); the line reads as plain paragraph text`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
505
689
|
const text = interpolate(para.join("\n"), paraStart, ctx);
|
|
506
690
|
blocks.push({ kind: "paragraph", text, inlines: parseInline(text, paraStart, ctx) });
|
|
507
691
|
}
|
|
@@ -555,6 +739,11 @@ function chartSourceTable(ctx, opts, block, target, line) {
|
|
|
555
739
|
format: typeof block.attrs["format-data"] === "string" ? block.attrs["format-data"] : inferDataFormat(target),
|
|
556
740
|
header: block.attrs["header"] === undefined ? true : block.attrs["header"],
|
|
557
741
|
};
|
|
742
|
+
// A chart reading a `;`-delimited export needs the same delimiter override a
|
|
743
|
+
// table does; the table rules validate it (§6).
|
|
744
|
+
const delim = block.attrs["delim"];
|
|
745
|
+
if (delim !== undefined)
|
|
746
|
+
attrs["delim"] = delim;
|
|
558
747
|
const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
|
|
559
748
|
for (const d of diagnostics)
|
|
560
749
|
ctx.diags.push({ ...d, line });
|
|
@@ -720,6 +909,12 @@ function validateProjections(children, ctx, opts) {
|
|
|
720
909
|
function relJoinPath(base, target) {
|
|
721
910
|
if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
|
|
722
911
|
return target;
|
|
912
|
+
// A POSIX-absolute base must stay absolute. The segment loop below drops empty
|
|
913
|
+
// segments, and the leading "" of "/tmp/x" IS the root — dropping it silently
|
|
914
|
+
// turned `/tmp/x/part.geml` into the relative `tmp/x/part.geml`, which then
|
|
915
|
+
// resolved against the wrong directory. Windows never showed it: a `C:\…` base
|
|
916
|
+
// has no "/", so relDirPath returns "" and the early return above takes over.
|
|
917
|
+
const rooted = base.startsWith("/");
|
|
723
918
|
const out = [];
|
|
724
919
|
for (const s of (base + "/" + target).split("/")) {
|
|
725
920
|
if (s === "" || s === ".")
|
|
@@ -729,12 +924,117 @@ function relJoinPath(base, target) {
|
|
|
729
924
|
else
|
|
730
925
|
out.push(s);
|
|
731
926
|
}
|
|
732
|
-
return out.join("/");
|
|
927
|
+
return (rooted ? "/" : "") + out.join("/");
|
|
733
928
|
}
|
|
734
929
|
function relDirPath(p) {
|
|
735
930
|
const i = p.lastIndexOf("/");
|
|
736
931
|
return i < 0 ? "" : p.slice(0, i);
|
|
737
932
|
}
|
|
933
|
+
// A chain that cannot reach an entity block. Carries the diagnostic code it
|
|
934
|
+
// corresponds to (§3) so the message can name it without inventing a new one.
|
|
935
|
+
class ViewError extends Error {
|
|
936
|
+
code;
|
|
937
|
+
constructor(code, message) {
|
|
938
|
+
super(message);
|
|
939
|
+
this.code = code;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
|
|
943
|
+
// content, so without a confinement root a document could name any path on the
|
|
944
|
+
// machine. And never a URL — `geml get` is a read command that agents and
|
|
945
|
+
// editors call constantly, so letting content steer it at the network would turn
|
|
946
|
+
// it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
|
|
947
|
+
function readConfined(rel, root) {
|
|
948
|
+
if (!/\.geml$/i.test(rel)) {
|
|
949
|
+
throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
|
|
950
|
+
}
|
|
951
|
+
const base = resolvePath(root);
|
|
952
|
+
const abs = resolvePath(root, rel);
|
|
953
|
+
if (abs !== base && !abs.startsWith(base + sep)) {
|
|
954
|
+
throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
return readFileSync(abs, "utf8");
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// One hop: read the target document and select what the fragment names. Several
|
|
964
|
+
// units come back when the fragment names a section (§4.3).
|
|
965
|
+
function oneHop(file, src, root) {
|
|
966
|
+
const hash = src.indexOf("#");
|
|
967
|
+
const docPath = hash < 0 ? src : src.slice(0, hash);
|
|
968
|
+
const frag = hash < 0 ? undefined : src.slice(hash + 1);
|
|
969
|
+
// Check the scheme on what the DOCUMENT wrote, before composition: a URL can
|
|
970
|
+
// only arrive through `src=`, never from joining relative paths — and testing
|
|
971
|
+
// the composed path instead would read a Windows drive letter (`C:/…`) as a
|
|
972
|
+
// scheme and refuse every absolute path, which is exactly what the MCP layer
|
|
973
|
+
// hands the CLI.
|
|
974
|
+
if (schemeOf(docPath) !== null) {
|
|
975
|
+
throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
|
|
976
|
+
}
|
|
977
|
+
const rel = relJoinPath(relDirPath(file), docPath);
|
|
978
|
+
const text = readConfined(rel, root);
|
|
979
|
+
if (frag === undefined) {
|
|
980
|
+
// `src=other.geml`: the frame looks onto the WHOLE document. Every block
|
|
981
|
+
// comes from the same target, so the resolution base stays uniform — unlike
|
|
982
|
+
// a host-side section selector, where splicing would mix two documents.
|
|
983
|
+
// `meta` is frontmatter, not content (render.ts's selectEmbed).
|
|
984
|
+
//
|
|
985
|
+
// Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
|
|
986
|
+
// every addressed unit would emit the blocks inside a section twice.
|
|
987
|
+
const every = addressedUnits(text).map((a) => a.unit);
|
|
988
|
+
const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
|
|
989
|
+
&& (o.span.start < u.span.start || o.span.end > u.span.end)));
|
|
990
|
+
return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
|
|
991
|
+
}
|
|
992
|
+
const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
|
|
993
|
+
return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
|
|
994
|
+
}
|
|
995
|
+
// Provenance is stated relative to the confinement root, not as the path the
|
|
996
|
+
// walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
|
|
997
|
+
// so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
|
|
998
|
+
// server's layout, and not a path any caller could pass back in.
|
|
999
|
+
function shownPath(rel, root) {
|
|
1000
|
+
const r = relative(root, rel).replace(/\\/g, "/");
|
|
1001
|
+
return r === "" ? rel : r;
|
|
1002
|
+
}
|
|
1003
|
+
function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
|
|
1004
|
+
const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
|
|
1005
|
+
if (src === undefined)
|
|
1006
|
+
return [{ doc: file, text: source, unit, all: [], from: "" }];
|
|
1007
|
+
// The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
|
|
1008
|
+
// cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
|
|
1009
|
+
// not expanded — `--view` may not: stopping here means what we are holding is
|
|
1010
|
+
// still a frame, and returning it would break the contract silently.
|
|
1011
|
+
if (depth >= EMBED_DEPTH_LIMIT) {
|
|
1012
|
+
throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
|
|
1013
|
+
}
|
|
1014
|
+
const hop = oneHop(file, src, root);
|
|
1015
|
+
// Same key shape as the check's cycle detector: a document plus what was
|
|
1016
|
+
// selected in it.
|
|
1017
|
+
const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
|
|
1018
|
+
if (seen.has(key)) {
|
|
1019
|
+
throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
|
|
1020
|
+
}
|
|
1021
|
+
const nextSeen = new Set(seen).add(key);
|
|
1022
|
+
// Per-unit application, recursively: what a frame looks onto may itself be a
|
|
1023
|
+
// frame, and a section may hold a mix (§4.3).
|
|
1024
|
+
return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
|
|
1025
|
+
// An inner identity step has no provenance of its own, so carry this hop's:
|
|
1026
|
+
// `from` must always name where the bytes actually came from.
|
|
1027
|
+
.map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
|
|
1028
|
+
}
|
|
1029
|
+
// The `src=` of an embed unit, read off its head line: a Unit carries the span,
|
|
1030
|
+
// not parsed attributes.
|
|
1031
|
+
function embedSrcOf(source, unit) {
|
|
1032
|
+
const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, true, false));
|
|
1033
|
+
if (!braces)
|
|
1034
|
+
return undefined;
|
|
1035
|
+
const v = parseAttrs(braces[0]).attrs["src"];
|
|
1036
|
+
return typeof v === "string" ? v : undefined;
|
|
1037
|
+
}
|
|
738
1038
|
function gatherEmbeds(source) {
|
|
739
1039
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
|
|
740
1040
|
scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
|
|
@@ -751,6 +1051,11 @@ function tableFromDocument(source, id) {
|
|
|
751
1051
|
const found = ctx.tables?.get(id);
|
|
752
1052
|
if (found !== undefined)
|
|
753
1053
|
return found;
|
|
1054
|
+
// GEP-0005: a remote `data` block is the other chart-source form; its value
|
|
1055
|
+
// is projected by the CALLER (recordsToTable needs the chart's attributes).
|
|
1056
|
+
const dv = ctx.dataValues?.get(id);
|
|
1057
|
+
if (dv !== undefined)
|
|
1058
|
+
return { records: dv };
|
|
754
1059
|
const anyBlock = (function find(bs) {
|
|
755
1060
|
for (const b of bs) {
|
|
756
1061
|
if ((b.kind === "block" || b.kind === "heading") && b.id === id)
|
|
@@ -847,7 +1152,11 @@ function resolveTableSources(ctx, opts) {
|
|
|
847
1152
|
err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
|
|
848
1153
|
continue;
|
|
849
1154
|
}
|
|
850
|
-
|
|
1155
|
+
// A table's `src=` names a TABLE. A `data` block is a chart-source form
|
|
1156
|
+
// (§7.1, GEP-0005), not a table-source form — the column algebra a
|
|
1157
|
+
// borrowing table implies (compute/summary against named columns) has
|
|
1158
|
+
// no defined meaning over a value tree.
|
|
1159
|
+
if (remote === "not-a-table" || "records" in remote) {
|
|
851
1160
|
err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
|
|
852
1161
|
continue;
|
|
853
1162
|
}
|
|
@@ -923,6 +1232,213 @@ function validateRefs(ctx, opts) {
|
|
|
923
1232
|
}
|
|
924
1233
|
}
|
|
925
1234
|
}
|
|
1235
|
+
// `src=` on a `code` block: the route to the code the block shows,
|
|
1236
|
+
// `<path>[#L<start>[-<end>]]` (1-based, inclusive). The code-graph runtime has
|
|
1237
|
+
// always fetched and sliced this at render time; resolving it here is what
|
|
1238
|
+
// turns a range that no longer exists — the source moved or shrank — from a
|
|
1239
|
+
// silently empty panel into a build error. There is no extension gate (code is
|
|
1240
|
+
// any language); the safety rule is the resolver's confinement to the document
|
|
1241
|
+
// tree, widened only by `--root`.
|
|
1242
|
+
const SOURCE_RANGE = /^L(\d+)(?:-(\d+))?$/;
|
|
1243
|
+
// One route syntax for the two types whose `src=` fragment position is free —
|
|
1244
|
+
// `code` and `data`. (A table's is already taken: `src=doc.geml#id` names a
|
|
1245
|
+
// block.) `<path>[#L<start>[-<end>]]`, 1-based and inclusive; `to === 0` means
|
|
1246
|
+
// "through end of file". Returns null after reporting, so callers just skip.
|
|
1247
|
+
function parseSourceRoute(target, kind, line, ctx) {
|
|
1248
|
+
const hash = target.indexOf("#");
|
|
1249
|
+
const path = hash < 0 ? target : target.slice(0, hash);
|
|
1250
|
+
const frag = hash < 0 ? "" : target.slice(hash + 1);
|
|
1251
|
+
if (frag === "")
|
|
1252
|
+
return { path, from: 1, to: 0 };
|
|
1253
|
+
const m = SOURCE_RANGE.exec(frag);
|
|
1254
|
+
if (!m) {
|
|
1255
|
+
ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: unrecognised fragment (expected \`#L<start>\` or \`#L<start>-<end>\`)`, line });
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
const from = Number(m[1]);
|
|
1259
|
+
const to = m[2] === undefined ? from : Number(m[2]);
|
|
1260
|
+
if (from < 1 || to < from) {
|
|
1261
|
+
ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: line range is empty or starts before line 1`, line });
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
return { path, from, to };
|
|
1265
|
+
}
|
|
1266
|
+
// Slice a resolved file to a route's range, or report that the range no longer
|
|
1267
|
+
// exists — the signal a stale reference exists at all.
|
|
1268
|
+
function sliceSourceRange(text, route, target, kind, line, ctx) {
|
|
1269
|
+
const all = normalizeSource(text).split("\n");
|
|
1270
|
+
// A trailing newline yields a final empty element; it is not a line.
|
|
1271
|
+
if (all.length > 0 && all[all.length - 1] === "")
|
|
1272
|
+
all.pop();
|
|
1273
|
+
if (route.to > all.length) {
|
|
1274
|
+
ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: the file has ${all.length} line(s), so lines ${route.from}-${route.to} no longer exist — the range is stale`, line });
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
1277
|
+
return all.slice(route.from - 1, route.to === 0 ? all.length : route.to);
|
|
1278
|
+
}
|
|
1279
|
+
function resolveCodeSources(ctx, opts) {
|
|
1280
|
+
for (const { block, line, target } of ctx.codeSources ?? []) {
|
|
1281
|
+
const scheme = schemeOf(target);
|
|
1282
|
+
// A remote source is fetched by the RENDERER (§9.4), as for a table.
|
|
1283
|
+
if (scheme === "http" || scheme === "https")
|
|
1284
|
+
continue;
|
|
1285
|
+
if (scheme !== null) {
|
|
1286
|
+
ctx.diags.push({ severity: "error", code: "bad-code-source", message: `code source \`${target}\` names a disallowed URL scheme`, line });
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const route = parseSourceRoute(target, "code", line, ctx);
|
|
1290
|
+
if (route === null)
|
|
1291
|
+
continue;
|
|
1292
|
+
const { path, from, to } = route;
|
|
1293
|
+
if (!opts.resolveDoc) {
|
|
1294
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `code source \`${target}\` not checked (no document resolver)`, line });
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const text = opts.resolveDoc(path);
|
|
1298
|
+
if (text === null) {
|
|
1299
|
+
// A WARNING, not an error, and the code/value model split is the reason:
|
|
1300
|
+
// a value that cannot be loaded is a promise the document failed to keep
|
|
1301
|
+
// (an error — see `unresolvable-data-source`), while a code region that
|
|
1302
|
+
// cannot be reached right now is still a code region at a location. A
|
|
1303
|
+
// generated code graph read away from its sources — published on its own,
|
|
1304
|
+
// or describing another checkout — must stay valid, exactly as the
|
|
1305
|
+
// render-time runtime degrades to showing the path.
|
|
1306
|
+
ctx.diags.push({ severity: "warning", code: "unresolvable-code-source", message: `cannot resolve code source \`${path}\` — not checked`, line });
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const slice = sliceSourceRange(text, { from, to }, target, "code", line, ctx);
|
|
1310
|
+
if (slice === null)
|
|
1311
|
+
continue;
|
|
1312
|
+
const hasBody = (block.raw ?? []).some((l) => l.trim() !== "");
|
|
1313
|
+
if (!hasBody) {
|
|
1314
|
+
block.raw = slice;
|
|
1315
|
+
}
|
|
1316
|
+
else if ((block.raw ?? []).join("\n") !== slice.join("\n")) {
|
|
1317
|
+
// A body alongside `src=` is a cached snapshot, kept for offline reading.
|
|
1318
|
+
// Silence would let the two drift — the very thing the route prevents.
|
|
1319
|
+
ctx.diags.push({ severity: "warning", code: "stale-code-snapshot", message: `code block body differs from its source \`${target}\` — the body is a snapshot and is now out of date`, line });
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
// GEP-0005: `src=` on a `data` block names external content — the same
|
|
1324
|
+
// external-source discipline tables have (§6, §9.4): an http(s) source is
|
|
1325
|
+
// fetched by the RENDERER, never the parser (the block defers, and so does a
|
|
1326
|
+
// chart over it); any other scheme is refused; the file must look like data
|
|
1327
|
+
// (`.json`/`.jsonl`); a missing resolver leaves it unchecked with a warning.
|
|
1328
|
+
function resolveDataSources(ctx, opts) {
|
|
1329
|
+
for (const { block, line, target } of ctx.dataSources ?? []) {
|
|
1330
|
+
const defer = () => { if (block.id !== undefined)
|
|
1331
|
+
(ctx.dataSrcPending ??= new Set()).add(block.id); };
|
|
1332
|
+
const scheme = schemeOf(target);
|
|
1333
|
+
if (scheme === "http" || scheme === "https") {
|
|
1334
|
+
defer();
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
if (scheme !== null) {
|
|
1338
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `data source \`${target}\` names a disallowed URL scheme`, line });
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
// The route shares `code`'s syntax (§3.2): a line range MAY narrow the file.
|
|
1342
|
+
const route = parseSourceRoute(target, "data", line, ctx);
|
|
1343
|
+
if (route === null)
|
|
1344
|
+
continue;
|
|
1345
|
+
const { path } = route;
|
|
1346
|
+
// A data source is data — the same shape rule table sources enforce, so
|
|
1347
|
+
// the loader cannot be pointed at a `.env` or a private key.
|
|
1348
|
+
if (!/\.(json|jsonl)$/i.test(path)) {
|
|
1349
|
+
ctx.diags.push({ severity: "error", code: "bad-data-source", message: `data source \`${path}\` is not a \`.json\`/\`.jsonl\` data file`, line });
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
// Explicit format= wins; otherwise the (already-gated) extension names it.
|
|
1353
|
+
const fmtAttr = block.attrs["format"];
|
|
1354
|
+
const fmt = typeof fmtAttr === "string" ? fmtAttr : /\.jsonl$/i.test(path) ? "jsonl" : "json";
|
|
1355
|
+
// A range narrows the file to lines; what those lines mean is then the
|
|
1356
|
+
// format's business, unchanged. Slicing a `jsonl` log is the obvious use;
|
|
1357
|
+
// slicing a `json` file works whenever the slice is itself a value, and
|
|
1358
|
+
// when it is not, the ordinary data-parse error already names the line.
|
|
1359
|
+
// No extra rule.
|
|
1360
|
+
if (!opts.resolveDoc) {
|
|
1361
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `data source \`${target}\` not checked (no document resolver)`, line });
|
|
1362
|
+
defer();
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
const text = opts.resolveDoc(path);
|
|
1366
|
+
if (text === null) {
|
|
1367
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `cannot resolve data source \`${path}\``, line });
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const lines = sliceSourceRange(text, route, target, "data", line, ctx);
|
|
1371
|
+
if (lines === null)
|
|
1372
|
+
continue;
|
|
1373
|
+
const parsed = parseDataBody(fmt, lines, line);
|
|
1374
|
+
for (const d of parsed.diags)
|
|
1375
|
+
ctx.diags.push(d);
|
|
1376
|
+
if (parsed.value !== undefined) {
|
|
1377
|
+
block.value = parsed.value;
|
|
1378
|
+
if (block.id !== undefined && !ctx.dataValues?.has(block.id)) {
|
|
1379
|
+
(ctx.dataValues ??= new Map()).set(block.id, parsed.value);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
// GEP-0005: a chart's `data=` may target a `data` block whose value is a
|
|
1385
|
+
// RECORD ARRAY — a non-empty array of objects. Keys project to columns in
|
|
1386
|
+
// first-seen order; every column the chart actually references (x/y/size/
|
|
1387
|
+
// series) must be present with a SCALAR value in every record, and a
|
|
1388
|
+
// violation is an error naming the first offending record. Columns the chart
|
|
1389
|
+
// does not reference may hold anything (nested values project as compact
|
|
1390
|
+
// JSON text). The projection feeds the unchanged table machinery.
|
|
1391
|
+
function recordsToTable(value, attrs, line, ctx) {
|
|
1392
|
+
const fail = (msg) => {
|
|
1393
|
+
ctx.diags.push({ severity: "error", code: "chart-data-not-records", message: `geml-chart: ${msg}`, line });
|
|
1394
|
+
return null;
|
|
1395
|
+
};
|
|
1396
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
1397
|
+
return fail("data target is not a non-empty record array");
|
|
1398
|
+
const columns = [];
|
|
1399
|
+
for (let i = 0; i < value.length; i++) {
|
|
1400
|
+
const r = value[i];
|
|
1401
|
+
if (r === null || typeof r !== "object" || Array.isArray(r))
|
|
1402
|
+
return fail(`record ${i + 1} is not an object`);
|
|
1403
|
+
for (const k of Object.keys(r))
|
|
1404
|
+
if (!columns.includes(k))
|
|
1405
|
+
columns.push(k);
|
|
1406
|
+
}
|
|
1407
|
+
// Only the channels this chart TYPE reads are "referenced" (§7.1): a stray
|
|
1408
|
+
// size= on a bar chart is buildChart's chart-unused-channel WARNING, and the
|
|
1409
|
+
// projection must not turn it into an error a table source would not raise.
|
|
1410
|
+
// An unknown/missing type validates x/y only; buildChart reports the type.
|
|
1411
|
+
const typeAttr = String(attrs["type"] ?? "");
|
|
1412
|
+
const uses = USES[typeAttr] ?? new Set(["x", "y"]);
|
|
1413
|
+
const channels = [];
|
|
1414
|
+
for (const c of ["x", "y", "size", "series"]) {
|
|
1415
|
+
if (!uses.has(c))
|
|
1416
|
+
continue;
|
|
1417
|
+
const v = attrs[c];
|
|
1418
|
+
if (typeof v === "string")
|
|
1419
|
+
for (const name of v.split(",").map((s) => s.trim()).filter(Boolean))
|
|
1420
|
+
channels.push(name);
|
|
1421
|
+
}
|
|
1422
|
+
for (const col of channels) {
|
|
1423
|
+
for (let i = 0; i < value.length; i++) {
|
|
1424
|
+
const v = value[i][col];
|
|
1425
|
+
if (v === undefined || v === null || typeof v === "object") {
|
|
1426
|
+
return fail(`column \`${col}\` is missing or non-scalar in record ${i + 1}`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
const rows = value.map((r) => columns.map((c) => {
|
|
1431
|
+
const v = r[c];
|
|
1432
|
+
const text = v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
1433
|
+
// Data, not prose: cells carry plain-text inlines, never inline-parsed —
|
|
1434
|
+
// the same treatment `format=csv` cells get (a `*` in a value is a `*`).
|
|
1435
|
+
const cell = { text, inlines: text === "" ? [] : [{ type: "text", value: text }] };
|
|
1436
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
1437
|
+
cell.value = v;
|
|
1438
|
+
return cell;
|
|
1439
|
+
}));
|
|
1440
|
+
return { header: true, columns, align: columns.map(() => undefined), rows };
|
|
1441
|
+
}
|
|
926
1442
|
// §7: resolve every geml-chart against its referenced table. Runs after the
|
|
927
1443
|
// scan so that `data=#id` may point at a table defined anywhere in the doc.
|
|
928
1444
|
function resolveCharts(ctx, opts) {
|
|
@@ -942,6 +1458,21 @@ function resolveCharts(ctx, opts) {
|
|
|
942
1458
|
let table;
|
|
943
1459
|
if (docPath === "") {
|
|
944
1460
|
table = ctx.tables?.get(id);
|
|
1461
|
+
if (!table && ctx.dataValues?.has(id)) {
|
|
1462
|
+
// GEP-0005: the target is a `data` block. A RECORD ARRAY projects to
|
|
1463
|
+
// the table model (keys -> columns) and feeds the unchanged chart
|
|
1464
|
+
// machinery, so column checks and rendering stay single-sourced.
|
|
1465
|
+
const projected = recordsToTable(ctx.dataValues.get(id), block.attrs, line, ctx);
|
|
1466
|
+
if (projected === null)
|
|
1467
|
+
continue; // reported by the projection
|
|
1468
|
+
table = projected;
|
|
1469
|
+
}
|
|
1470
|
+
if (!table && ctx.dataSrcPending?.has(id)) {
|
|
1471
|
+
// GEP-0005: the target is a `data` block whose `src=` is render-time
|
|
1472
|
+
// (http, or no resolver) — defer exactly like a src table with no
|
|
1473
|
+
// columns; the renderer checks it when the data actually arrives.
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
945
1476
|
if (!table) {
|
|
946
1477
|
// A chart is a view of a table, and a data file is one of the three ways
|
|
947
1478
|
// §6 lets a table name its content. So `data=rows.csv` desugars: it is an
|
|
@@ -955,8 +1486,36 @@ function resolveCharts(ctx, opts) {
|
|
|
955
1486
|
continue; // already reported by the table rules
|
|
956
1487
|
table = sugar;
|
|
957
1488
|
}
|
|
1489
|
+
else if (hash < 0 && /\.(json|jsonl)$/i.test(id) && schemeOf(id) === null) {
|
|
1490
|
+
// GEP-0005 sugar, the json/jsonl twin of the csv path: an anonymous
|
|
1491
|
+
// LOCAL data source projected through the record-array rules. A
|
|
1492
|
+
// remote URL needs a NAMED `data` block with `src=` — its fetch is
|
|
1493
|
+
// render-time, and an anonymous source has no block to defer on.
|
|
1494
|
+
if (!opts.resolveDoc) {
|
|
1495
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${id}\` not checked (no document resolver)`, line });
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
const text = opts.resolveDoc(id);
|
|
1499
|
+
if (text === null) {
|
|
1500
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `geml-chart: cannot resolve data source \`${id}\``, line });
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
const parsed = parseDataBody(/\.jsonl$/i.test(id) ? "jsonl" : "json", normalizeSource(text).split("\n"), line);
|
|
1504
|
+
for (const d of parsed.diags)
|
|
1505
|
+
ctx.diags.push(d);
|
|
1506
|
+
if (parsed.value === undefined)
|
|
1507
|
+
continue;
|
|
1508
|
+
const projected = recordsToTable(parsed.value, block.attrs, line, ctx);
|
|
1509
|
+
if (projected === null)
|
|
1510
|
+
continue; // reported by the projection
|
|
1511
|
+
table = projected;
|
|
1512
|
+
}
|
|
1513
|
+
else if (hash < 0 && /\.(json|jsonl)$/i.test(id)) {
|
|
1514
|
+
ctx.diags.push({ severity: "error", code: "bad-data-source", message: `geml-chart: \`data=${id}\`: a remote json/jsonl source needs a named \`data\` block with \`src=\``, line });
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
958
1517
|
else if (hash < 0 && /\.[a-z0-9]+$/i.test(id)) {
|
|
959
|
-
ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\` data file, and not a \`#id\` naming a table`, line });
|
|
1518
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\`/\`.json\`/\`.jsonl\` data file, and not a \`#id\` naming a table or data block`, line });
|
|
960
1519
|
continue;
|
|
961
1520
|
}
|
|
962
1521
|
else {
|
|
@@ -984,10 +1543,18 @@ function resolveCharts(ctx, opts) {
|
|
|
984
1543
|
continue;
|
|
985
1544
|
}
|
|
986
1545
|
if (remote === "not-a-table") {
|
|
987
|
-
ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is
|
|
1546
|
+
ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is neither a table nor a data block`, line });
|
|
988
1547
|
continue;
|
|
989
1548
|
}
|
|
990
|
-
|
|
1549
|
+
if ("records" in remote) {
|
|
1550
|
+
const projected = recordsToTable(remote.records, block.attrs, line, ctx);
|
|
1551
|
+
if (projected === null)
|
|
1552
|
+
continue; // reported by the projection
|
|
1553
|
+
table = projected;
|
|
1554
|
+
}
|
|
1555
|
+
else {
|
|
1556
|
+
table = remote;
|
|
1557
|
+
}
|
|
991
1558
|
}
|
|
992
1559
|
if (table.src !== undefined && table.columns.length === 0) {
|
|
993
1560
|
// §6: the table names a source whose data did not arrive at build time — a
|
|
@@ -1012,6 +1579,8 @@ export function parse(source, opts = {}) {
|
|
|
1012
1579
|
// Table sources first: a chart reads the build-time model of the table it
|
|
1013
1580
|
// charts, so that model has to be filled before charts are resolved.
|
|
1014
1581
|
resolveTableSources(ctx, opts);
|
|
1582
|
+
resolveDataSources(ctx, opts);
|
|
1583
|
+
resolveCodeSources(ctx, opts);
|
|
1015
1584
|
resolveCharts(ctx, opts);
|
|
1016
1585
|
validateRefs(ctx, opts);
|
|
1017
1586
|
detectTransclusionCycles(ctx, opts);
|
|
@@ -1319,6 +1888,8 @@ Usage:
|
|
|
1319
1888
|
--to <output>: json | html | md | geml
|
|
1320
1889
|
--to md -> Markdown (lossy)
|
|
1321
1890
|
--to html -> self-contained HTML
|
|
1891
|
+
--to html --fragment -> body-only markup, no page shell
|
|
1892
|
+
(embed in your own layout; assets via pageAssets)
|
|
1322
1893
|
--to geml -> canonical re-format
|
|
1323
1894
|
--to json -> document-model JSON (default)
|
|
1324
1895
|
--from <input>: geml | md | json (overrides extension; html is output-only)
|
|
@@ -1351,6 +1922,10 @@ Usage:
|
|
|
1351
1922
|
set/add/delete/rename/revert; every write is validated before it
|
|
1352
1923
|
reaches disk. A code graph under --root adds four read-only
|
|
1353
1924
|
geml_codemap_* tools to the same server)
|
|
1925
|
+
geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
|
|
1926
|
+
(authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
|
|
1927
|
+
MCP server registered at user scope; touches no settings.json,
|
|
1928
|
+
installs no hooks; idempotent — re-run to update)
|
|
1354
1929
|
geml --help | --version [--json]
|
|
1355
1930
|
|
|
1356
1931
|
Use '-' as the file to read from stdin.
|
|
@@ -1364,7 +1939,7 @@ Exit codes:
|
|
|
1364
1939
|
// One-line usage for each subcommand — the single source for both the error
|
|
1365
1940
|
// shown on misuse and the `<cmd> --help` text.
|
|
1366
1941
|
const SUBHELP = {
|
|
1367
|
-
get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; without a selector: list every addressable block with its shortest unique address, --json = array)",
|
|
1942
|
+
get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--view [--root <dir>]] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; --view = read THROUGH an `embed` to the entity block it stands for, following a chain to its end (the identity on any other block, and on a section selector — it never splices two documents' bytes together); provenance goes to stderr as `view: <sel> -> <doc>[#<id>]`; read-only, `set` refuses it; chain reads are confined to --root (default: the document's own directory) and never fetched over the network; without a selector: list every addressable block with its shortest unique address, --json = array)",
|
|
1368
1943
|
set: "usage: geml set <file.geml|-> <selector> [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body; guarded splice, refused if it breaks the doc; writing through an @<hex> address prints the new address on stderr)",
|
|
1369
1944
|
add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
|
|
1370
1945
|
delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
|
|
@@ -1408,6 +1983,19 @@ const SUBHELP = {
|
|
|
1408
1983
|
|
|
1409
1984
|
Register with a client:
|
|
1410
1985
|
claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
|
|
1986
|
+
skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp]
|
|
1987
|
+
|
|
1988
|
+
One command, three things, all user-global — so any Claude Code session can
|
|
1989
|
+
author, validate, and blockwise-edit GEML:
|
|
1990
|
+
1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
|
|
1991
|
+
2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
|
|
1992
|
+
3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
|
|
1993
|
+
Touches no settings.json and installs no hooks. Idempotent — re-run after an
|
|
1994
|
+
upgrade to refresh the skill text alongside the CLI it teaches.
|
|
1995
|
+
|
|
1996
|
+
--dest <dir> install the skill under <dir> instead of ~/.claude/skills
|
|
1997
|
+
--no-global skip the global npm install
|
|
1998
|
+
--no-mcp skip the MCP server registration`,
|
|
1411
1999
|
};
|
|
1412
2000
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
1413
2001
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
@@ -1486,7 +2074,19 @@ function resolverFor(file, root) {
|
|
|
1486
2074
|
return null;
|
|
1487
2075
|
// References resolve FROM the document's own directory; the gates below
|
|
1488
2076
|
// confine them to the (possibly widened) base.
|
|
1489
|
-
|
|
2077
|
+
let targetAbs = resolvePath(dirAbs, d);
|
|
2078
|
+
// A SOURCE route (`code`/`data` `src=`) may instead be written relative to
|
|
2079
|
+
// the resolution root — that is how the code-graph profile writes them
|
|
2080
|
+
// (`geml-parser/src/attrs.ts` from a document two levels down). So when
|
|
2081
|
+
// the document-relative path does not exist and a root was named, try the
|
|
2082
|
+
// root as the base. Only a widened `--root` can enable this, and both
|
|
2083
|
+
// confinement gates below still apply, so it cannot reach further than a
|
|
2084
|
+
// document-relative reference already could.
|
|
2085
|
+
if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
|
|
2086
|
+
const fromBase = resolvePath(baseAbs, d);
|
|
2087
|
+
if (existsSync(fromBase))
|
|
2088
|
+
targetAbs = fromBase;
|
|
2089
|
+
}
|
|
1490
2090
|
// Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
|
|
1491
2091
|
// before touching the filesystem.
|
|
1492
2092
|
if (outside(baseAbs, targetAbs))
|
|
@@ -1556,18 +2156,6 @@ function historyError(e, file, historyPath) {
|
|
|
1556
2156
|
}
|
|
1557
2157
|
return err?.message ?? String(e);
|
|
1558
2158
|
}
|
|
1559
|
-
// The three verbs the four-verb collapse removed (design §2/§6). They are HARD
|
|
1560
|
-
// deletions, not aliases — the same call this repo already made for `geml
|
|
1561
|
-
// codemap mcp` and `geml mcp --workspace`: one word, one meaning, and a stale
|
|
1562
|
-
// spelling that keeps working is a second vocabulary the docs and an agent's
|
|
1563
|
-
// memory then both carry. So each names its replacement rather than falling into
|
|
1564
|
-
// `unknown history subcommand`, which would leave the caller guessing which of
|
|
1565
|
-
// four verbs took over.
|
|
1566
|
-
const RETIRED_HISTORY = {
|
|
1567
|
-
commit: "geml history commit was renamed: use `geml history save <file.geml> [-m msg]` (same behaviour, except that a file identical to the tip is now a no-op instead of an empty revision).",
|
|
1568
|
-
log: "geml history log was removed: use `geml history get <file.geml>` — no revision selector lists every revision, newest first, with the same copy-pasteable first column.",
|
|
1569
|
-
show: "geml history show was removed: use `geml history get <file.geml> <rev>` — a revision selector prints that revision's full text (`--json` wraps it as {id, text}).",
|
|
1570
|
-
};
|
|
1571
2159
|
// Subcommand, file and revision, read positionally around the options —
|
|
1572
2160
|
// `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
|
|
1573
2161
|
// indexing read `--history` itself as the file.
|
|
@@ -1594,8 +2182,6 @@ function runHistory(args) {
|
|
|
1594
2182
|
const [sub, file, rev, ...extra] = historyPositionals(args);
|
|
1595
2183
|
if (!sub || !file)
|
|
1596
2184
|
fail(SUBHELP.history);
|
|
1597
|
-
if (RETIRED_HISTORY[sub])
|
|
1598
|
-
fail(RETIRED_HISTORY[sub]);
|
|
1599
2185
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
1600
2186
|
const json = args.includes("--json");
|
|
1601
2187
|
try {
|
|
@@ -1717,6 +2303,13 @@ function runTransform(argv) {
|
|
|
1717
2303
|
const out = flag(argv, "-o") ?? flag(argv, "--out");
|
|
1718
2304
|
const fromRaw = flag(argv, "--from");
|
|
1719
2305
|
const toRaw = flag(argv, "--to");
|
|
2306
|
+
// `--to html --fragment`: body-only markup for embedding in an existing
|
|
2307
|
+
// layout (library parity: RenderOptions.fragment). Consumed here so it can
|
|
2308
|
+
// be rejected on any other target — a discarded flag is a silent lie.
|
|
2309
|
+
const fragIdx = argv.indexOf("--fragment");
|
|
2310
|
+
const fragment = fragIdx >= 0;
|
|
2311
|
+
if (fragment)
|
|
2312
|
+
argv.splice(fragIdx, 1);
|
|
1720
2313
|
// Same `--root` as `check`, and for the same reason: cross-document resolution is
|
|
1721
2314
|
// fail-closed at the document's own directory, so a reference that climbs out of
|
|
1722
2315
|
// it needs the tree's root named. Without this the transform silently ignored the
|
|
@@ -1764,6 +2357,8 @@ function runTransform(argv) {
|
|
|
1764
2357
|
else {
|
|
1765
2358
|
outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
|
|
1766
2359
|
}
|
|
2360
|
+
if (fragment && outFmt !== "html")
|
|
2361
|
+
fail("--fragment only applies to --to html", 2);
|
|
1767
2362
|
const src = readInput(file);
|
|
1768
2363
|
// md -> geml is a direct projection, not a parse/serialize round-trip: emit
|
|
1769
2364
|
// the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
|
|
@@ -1800,6 +2395,7 @@ function runTransform(argv) {
|
|
|
1800
2395
|
case "html":
|
|
1801
2396
|
output = renderHtml(doc, {
|
|
1802
2397
|
source: file === "-" ? "stdin" : basename(file),
|
|
2398
|
+
fragment,
|
|
1803
2399
|
// geml-code-graph embeds load + parse sibling codemap docs on demand.
|
|
1804
2400
|
loadDoc: resolverFor(file, root),
|
|
1805
2401
|
parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
|
|
@@ -2148,7 +2744,8 @@ function runGet(args) {
|
|
|
2148
2744
|
const json = args.includes("--json");
|
|
2149
2745
|
const headOnly = args.includes("--head");
|
|
2150
2746
|
const bodyOnly = args.includes("--body");
|
|
2151
|
-
const
|
|
2747
|
+
const view = args.includes("--view");
|
|
2748
|
+
const [file, rawSel] = positionals(args, ["--root"]);
|
|
2152
2749
|
if (!file)
|
|
2153
2750
|
fail(SUBHELP.get);
|
|
2154
2751
|
if (headOnly && bodyOnly)
|
|
@@ -2171,16 +2768,72 @@ function runGet(args) {
|
|
|
2171
2768
|
return;
|
|
2172
2769
|
}
|
|
2173
2770
|
const { units, all } = selectUnits(source, file, rawSel, where);
|
|
2771
|
+
// The chain is composed with `/` — relJoinPath's rule, and `src=` values are
|
|
2772
|
+
// always `/`-separated — so normalize the PLATFORM path at this boundary. On
|
|
2773
|
+
// Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
|
|
2774
|
+
// tell, and a relative `src=` resolves against the wrong base.
|
|
2775
|
+
const startDoc = where.replace(/\\/g, "/");
|
|
2776
|
+
const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
|
|
2174
2777
|
if (json) {
|
|
2175
2778
|
// §7: N matches yield N model nodes. The old `{kind:"blocks",
|
|
2176
2779
|
// matches:[{lines}]}` coordinate envelope is gone — it answered "where are
|
|
2177
2780
|
// they" when the question is "what are they" (§9 change 2).
|
|
2178
|
-
|
|
2179
|
-
|
|
2781
|
+
let nodes;
|
|
2782
|
+
try {
|
|
2783
|
+
nodes = units.flatMap((u) => {
|
|
2784
|
+
if (!view)
|
|
2785
|
+
return [unitNode(source, file, u, all)];
|
|
2786
|
+
return viewResolve(source, startDoc, u, viewRoot).map((res) => {
|
|
2787
|
+
const node = unitNode(res.text, res.doc, res.unit, res.all);
|
|
2788
|
+
// Provenance is mandatory (§4): the node's references and relative
|
|
2789
|
+
// paths resolve against ITS document, not the one asked about. A
|
|
2790
|
+
// whole-document target has no `#`, so it carries `doc` alone.
|
|
2791
|
+
if (res.from !== "") {
|
|
2792
|
+
const h = res.from.lastIndexOf("#");
|
|
2793
|
+
node["from"] = h < 0 ? { doc: res.from }
|
|
2794
|
+
: { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
|
|
2795
|
+
}
|
|
2796
|
+
return node;
|
|
2797
|
+
});
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
catch (e) {
|
|
2801
|
+
if (e instanceof ViewError)
|
|
2802
|
+
fail(e.message, 1);
|
|
2803
|
+
throw e;
|
|
2804
|
+
}
|
|
2805
|
+
console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
|
|
2180
2806
|
return;
|
|
2181
2807
|
}
|
|
2182
2808
|
if (units.length > 1)
|
|
2183
2809
|
reportMatches(units[0].type ?? "", units);
|
|
2810
|
+
if (view) {
|
|
2811
|
+
// All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
|
|
2812
|
+
// chain that breaks halfway cannot leave a partial read on stdout for a
|
|
2813
|
+
// caller that ignores the exit code. Partial scenery is not scenery.
|
|
2814
|
+
const out = [];
|
|
2815
|
+
const notes = [];
|
|
2816
|
+
try {
|
|
2817
|
+
for (const u of units) {
|
|
2818
|
+
for (const res of viewResolve(source, startDoc, u, viewRoot)) {
|
|
2819
|
+
if (res.from !== "")
|
|
2820
|
+
notes.push(`view: ${rawSel} -> ${res.from}`);
|
|
2821
|
+
out.push(sliceUnit(res.text, res.unit.span, headOnly, bodyOnly));
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
catch (e) {
|
|
2826
|
+
// A chain that cannot reach an entity block is a failed READ, reported the
|
|
2827
|
+
// way `get` reports a selector that matches nothing: one line, exit 1.
|
|
2828
|
+
if (e instanceof ViewError)
|
|
2829
|
+
fail(e.message, 1);
|
|
2830
|
+
throw e;
|
|
2831
|
+
}
|
|
2832
|
+
for (const n of notes)
|
|
2833
|
+
console.error(n);
|
|
2834
|
+
process.stdout.write(out.join(""));
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2184
2837
|
for (const u of units)
|
|
2185
2838
|
process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
|
|
2186
2839
|
}
|
|
@@ -2208,6 +2861,12 @@ function runSet(args) {
|
|
|
2208
2861
|
const bodyOnly = args.includes("--body");
|
|
2209
2862
|
if (headOnly && bodyOnly)
|
|
2210
2863
|
fail("--head and --body are mutually exclusive", 2);
|
|
2864
|
+
// `--view` reads THROUGH an embed (see runGet). Writing through one would mean
|
|
2865
|
+
// one `set` silently editing a different file, so it is refused rather than
|
|
2866
|
+
// ignored — and the message has to point the way, not just say no.
|
|
2867
|
+
if (args.includes("--view")) {
|
|
2868
|
+
fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
|
|
2869
|
+
}
|
|
2211
2870
|
const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
|
|
2212
2871
|
if (!file)
|
|
2213
2872
|
fail(SUBHELP.set);
|
|
@@ -2934,6 +3593,105 @@ function runMcp(args) {
|
|
|
2934
3593
|
const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
|
|
2935
3594
|
process.exit(r.status ?? 1);
|
|
2936
3595
|
}
|
|
3596
|
+
// geml skill install: one command that makes GEML usable everywhere for a
|
|
3597
|
+
// Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
|
|
3598
|
+
// the CLI on the global PATH, and the MCP server registered at user scope.
|
|
3599
|
+
// Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
|
|
3600
|
+
// sidecars. Idempotent, so re-running after an upgrade refreshes everything.
|
|
3601
|
+
function runSkill(args) {
|
|
3602
|
+
const sub = args[0];
|
|
3603
|
+
if (sub !== "install")
|
|
3604
|
+
fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
|
|
3605
|
+
const rest = args.slice(1);
|
|
3606
|
+
const flag = (name) => {
|
|
3607
|
+
const i = rest.indexOf(name);
|
|
3608
|
+
if (i >= 0)
|
|
3609
|
+
rest.splice(i, 1);
|
|
3610
|
+
return i >= 0;
|
|
3611
|
+
};
|
|
3612
|
+
const opt = (name) => {
|
|
3613
|
+
const i = rest.indexOf(name);
|
|
3614
|
+
if (i < 0)
|
|
3615
|
+
return undefined;
|
|
3616
|
+
const v = rest[i + 1];
|
|
3617
|
+
if (!v)
|
|
3618
|
+
fail(`${name} needs a value.\n${SUBHELP.skill}`);
|
|
3619
|
+
rest.splice(i, 2);
|
|
3620
|
+
return v;
|
|
3621
|
+
};
|
|
3622
|
+
const noGlobal = flag("--no-global");
|
|
3623
|
+
const noMcp = flag("--no-mcp");
|
|
3624
|
+
const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
|
|
3625
|
+
if (rest.length)
|
|
3626
|
+
fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
|
|
3627
|
+
// The skill ships inside the npm package, next to dist/ — the installed
|
|
3628
|
+
// skill text always matches the CLI version it teaches.
|
|
3629
|
+
const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
|
|
3630
|
+
if (!existsSync(join(src, "SKILL.md")))
|
|
3631
|
+
fail(`bundled skill not found at ${src} (broken install?)`, 1);
|
|
3632
|
+
const target = join(dest, "geml");
|
|
3633
|
+
const copied = [];
|
|
3634
|
+
const copyTree = (from, to) => {
|
|
3635
|
+
mkdirSync(to, { recursive: true });
|
|
3636
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
3637
|
+
// Never ship a history sidecar — skill and config docs carry none.
|
|
3638
|
+
if (e.name.endsWith(".gemlhistory"))
|
|
3639
|
+
continue;
|
|
3640
|
+
const f = join(from, e.name);
|
|
3641
|
+
const t = join(to, e.name);
|
|
3642
|
+
if (e.isDirectory())
|
|
3643
|
+
copyTree(f, t);
|
|
3644
|
+
else {
|
|
3645
|
+
copyFileSync(f, t);
|
|
3646
|
+
copied.push(relative(dest, t));
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
};
|
|
3650
|
+
try {
|
|
3651
|
+
copyTree(src, target);
|
|
3652
|
+
}
|
|
3653
|
+
catch (e) {
|
|
3654
|
+
// A clean one-liner, never a raw stack: --dest may name a file, a
|
|
3655
|
+
// read-only tree, or a path whose ancestor is not a directory.
|
|
3656
|
+
fail(`cannot install skill to ${target}: ${e instanceof Error ? e.message : String(e)}`, 1);
|
|
3657
|
+
}
|
|
3658
|
+
console.log(`skill installed -> ${target} (${copied.join(", ")})`);
|
|
3659
|
+
// Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
|
|
3660
|
+
// below is a fixed literal, so shell:true adds no injection surface.
|
|
3661
|
+
const sh = process.platform === "win32";
|
|
3662
|
+
const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
|
|
3663
|
+
if (!noGlobal) {
|
|
3664
|
+
const have = run("geml", ["--version"]);
|
|
3665
|
+
if (have.status === 0) {
|
|
3666
|
+
console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
|
|
3667
|
+
}
|
|
3668
|
+
else {
|
|
3669
|
+
console.log("cli installing @geml/geml globally (npm i -g)...");
|
|
3670
|
+
const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
|
|
3671
|
+
if (r.status !== 0)
|
|
3672
|
+
console.error("cli global install failed — install later with: npm i -g @geml/geml");
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
if (!noMcp) {
|
|
3676
|
+
const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
|
|
3677
|
+
const claude = run("claude", ["--version"]);
|
|
3678
|
+
if (claude.status !== 0) {
|
|
3679
|
+
console.log(`mcp claude CLI not found — register later with: ${REG}`);
|
|
3680
|
+
}
|
|
3681
|
+
else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
|
|
3682
|
+
console.log("mcp server 'geml' already registered");
|
|
3683
|
+
}
|
|
3684
|
+
else {
|
|
3685
|
+
const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
|
|
3686
|
+
if (r.status === 0)
|
|
3687
|
+
console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
|
|
3688
|
+
else
|
|
3689
|
+
console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
console.log("done — new Claude Code sessions pick up the skill.");
|
|
3693
|
+
process.exit(0);
|
|
3694
|
+
}
|
|
2937
3695
|
// npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
|
|
2938
3696
|
// CLI" by resolving argv[1] to its real path, not by its spelling.
|
|
2939
3697
|
const entry = (() => {
|
|
@@ -3006,6 +3764,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
|
|
|
3006
3764
|
else if (cmd === "mcp") {
|
|
3007
3765
|
runMcp(argv.slice(1));
|
|
3008
3766
|
}
|
|
3767
|
+
else if (cmd === "skill") {
|
|
3768
|
+
runSkill(argv.slice(1));
|
|
3769
|
+
}
|
|
3009
3770
|
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
3010
3771
|
// A bare word that is neither a known command nor a path is almost always
|
|
3011
3772
|
// a mistyped command — say so, don't try to read it as a file. (The
|