@geml/geml 1.1.1 → 1.4.2
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 +155 -79
- package/codemap/adapters/crg.mjs +120 -109
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -229
- package/codemap/browser-stub.mjs +29 -24
- package/codemap/build.mjs +609 -354
- package/codemap/cross-stack.mjs +303 -0
- package/codemap/detect.mjs +399 -185
- package/codemap/emit.mjs +480 -361
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -143
- package/codemap/normalize.mjs +0 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -160
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -378
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +148 -126
- package/dist/block-edit.d.ts +1 -0
- package/dist/block-edit.js +112 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +2 -1
- package/dist/geml.js +1103 -233
- package/dist/history.js +102 -62
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +33 -3
- package/dist/render.js +558 -283
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +4 -0
- package/package.json +62 -58
package/dist/geml.js
CHANGED
|
@@ -8,23 +8,32 @@
|
|
|
8
8
|
// M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
|
|
9
9
|
// media embeds, links, auto-references, footnotes) and build-time reference
|
|
10
10
|
// validation (§8 — unique ids, resolvable internal/cross-document references).
|
|
11
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
11
|
+
import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
15
15
|
import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
16
|
-
import { renderHtml } from "./render.js";
|
|
16
|
+
import { renderHtml } from "./render-html.js";
|
|
17
|
+
import { normalizeBlockId } from "./block-edit.js";
|
|
17
18
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
18
|
-
import { parseInline } from "./inline.js";
|
|
19
|
+
import { META_REF_SRC, parseInline } from "./inline.js";
|
|
19
20
|
import { parseTable } from "./table.js";
|
|
20
21
|
import { buildChart } from "./chart.js";
|
|
21
22
|
import { mdToGeml } from "./from-md.js";
|
|
22
23
|
import { serialize } from "./serialize.js";
|
|
23
24
|
import { gemlToMd } from "./to-md.js";
|
|
24
25
|
export { mdToGeml } from "./from-md.js";
|
|
25
|
-
export { renderHtml } from "./render.js";
|
|
26
|
+
export { renderHtml } from "./render-html.js";
|
|
26
27
|
export { serialize } from "./serialize.js";
|
|
27
28
|
export { gemlToMd } from "./to-md.js";
|
|
29
|
+
// A block id is any non-whitespace run (§4), so it may contain regex
|
|
30
|
+
// metacharacters. Every place that builds a RegExp from an id MUST run it
|
|
31
|
+
// through this first, or a crafted id (`#a(`, `#(x+x+)+y`) turns a labeled-close
|
|
32
|
+
// or reference match into an uncaught `SyntaxError` or a ReDoS on the main
|
|
33
|
+
// parse path (SEC: document-controlled RegExp injection).
|
|
34
|
+
function reLit(s) {
|
|
35
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
28
37
|
// Type registry: which body mode each typed block uses. Unknown types are a
|
|
29
38
|
// warning and fall back to `raw` (forward compatibility, §3/§8).
|
|
30
39
|
const REGISTRY = {
|
|
@@ -34,6 +43,7 @@ const REGISTRY = {
|
|
|
34
43
|
table: "raw", // structured table parsing lands in M3
|
|
35
44
|
output: "raw", // captured result of a code block (stored, never executed)
|
|
36
45
|
note: "flow",
|
|
46
|
+
text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
|
|
37
47
|
meta: "data",
|
|
38
48
|
};
|
|
39
49
|
// §7: built-in diagram renderer registry. Unknown formats are a warning (the
|
|
@@ -45,6 +55,11 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
|
|
|
45
55
|
const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
|
|
46
56
|
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
|
|
47
57
|
const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
58
|
+
// Maximum block/list nesting depth the recursive-descent scanner will build
|
|
59
|
+
// before emitting a diagnostic instead of recursing further. Guards parse()
|
|
60
|
+
// (scanBlocks / parseList) and, in step, the renderer against a deeply nested
|
|
61
|
+
// document overflowing the call stack (DoS). 256 is far past any real document.
|
|
62
|
+
const MAX_NESTING = 256;
|
|
48
63
|
function isCloseFence(line, openLen) {
|
|
49
64
|
const t = line.replace(/\s+$/, "");
|
|
50
65
|
return /^=+$/.test(t) && t.length === openLen;
|
|
@@ -62,15 +77,67 @@ function slug(text) {
|
|
|
62
77
|
// ---------------------------------------------------------------------------
|
|
63
78
|
// §4: substitute `{{key}}` in flow text with the matching `=== meta` value.
|
|
64
79
|
// An unknown key is a build error (single-source-of-truth, fail loudly).
|
|
80
|
+
// The scan mirrors the §5.3(1) verbatim atoms: a `{{key}}` inside a code span
|
|
81
|
+
// or inline math is left untouched (so GEML prose can document this very
|
|
82
|
+
// syntax), and a backslash-escaped character can neither open a span nor a
|
|
83
|
+
// `{{…}}` reference — `\{{key}}` renders as the literal text `{{key}}`.
|
|
84
|
+
const META_REF = new RegExp(META_REF_SRC, "y");
|
|
65
85
|
function interpolate(text, line, ctx) {
|
|
66
86
|
if (!text.includes("{{"))
|
|
67
87
|
return text;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
let out = "";
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < text.length) {
|
|
91
|
+
const c = text[i];
|
|
92
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
93
|
+
out += c + text[i + 1];
|
|
94
|
+
i += 2;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (c === "`") {
|
|
98
|
+
let n = 0;
|
|
99
|
+
while (text[i + n] === "`")
|
|
100
|
+
n++;
|
|
101
|
+
const close = text.indexOf("`".repeat(n), i + n);
|
|
102
|
+
if (close >= 0) {
|
|
103
|
+
out += text.slice(i, close + n);
|
|
104
|
+
i = close + n;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
out += text.slice(i, i + n); // unclosed run: literal, keep scanning
|
|
108
|
+
i += n;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === "$") {
|
|
112
|
+
const close = text.indexOf("$", i + 1);
|
|
113
|
+
if (close > i + 1) {
|
|
114
|
+
out += text.slice(i, close + 1);
|
|
115
|
+
i = close + 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
out += c;
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === "{" && text[i + 1] === "{") {
|
|
123
|
+
META_REF.lastIndex = i;
|
|
124
|
+
const m = META_REF.exec(text);
|
|
125
|
+
if (m) {
|
|
126
|
+
const key = m[1];
|
|
127
|
+
if (ctx.meta.has(key))
|
|
128
|
+
out += ctx.meta.get(key);
|
|
129
|
+
else {
|
|
130
|
+
ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
|
|
131
|
+
out += m[0];
|
|
132
|
+
}
|
|
133
|
+
i = META_REF.lastIndex;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
out += c;
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
74
141
|
}
|
|
75
142
|
// Register a block id, flagging duplicates as errors (§4: ids unique per doc).
|
|
76
143
|
function registerId(ctx, id, line) {
|
|
@@ -122,6 +189,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
122
189
|
const root = mkList(matchMarker(lines[i]));
|
|
123
190
|
const stack = [{ list: root, indent: matchMarker(lines[i]).indent }];
|
|
124
191
|
let prevBlank = false;
|
|
192
|
+
let tooDeep = false;
|
|
125
193
|
while (i < lines.length) {
|
|
126
194
|
if (lines[i].trim() === "") {
|
|
127
195
|
prevBlank = true;
|
|
@@ -139,9 +207,21 @@ function parseList(lines, i, base, ctx) {
|
|
|
139
207
|
const parent = top.list.items[top.list.items.length - 1];
|
|
140
208
|
if (!parent)
|
|
141
209
|
break; // deeper indent with no parent item: defensive stop
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
210
|
+
if (stack.length >= MAX_NESTING) {
|
|
211
|
+
// Refuse to nest deeper than the cap: keep the item at the current level
|
|
212
|
+
// rather than building a model that overflows the renderer (DoS). One
|
|
213
|
+
// diagnostic per over-deep list; content is preserved, just flattened.
|
|
214
|
+
if (!tooDeep) {
|
|
215
|
+
ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
|
|
216
|
+
tooDeep = true;
|
|
217
|
+
}
|
|
218
|
+
cur = top.list;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
cur = mkList(mk);
|
|
222
|
+
(parent.children ??= []).push(cur);
|
|
223
|
+
stack.push({ list: cur, indent: mk.indent });
|
|
224
|
+
}
|
|
145
225
|
}
|
|
146
226
|
else {
|
|
147
227
|
// §5: a change of marker type (bullet ↔ ordered) at the same level ends
|
|
@@ -159,7 +239,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
159
239
|
}
|
|
160
240
|
return { block: root, next: i };
|
|
161
241
|
}
|
|
162
|
-
function scanBlocks(lines, base, ctx) {
|
|
242
|
+
function scanBlocks(lines, base, ctx, depth = 0) {
|
|
163
243
|
const blocks = [];
|
|
164
244
|
const diags = ctx.diags;
|
|
165
245
|
let i = 0;
|
|
@@ -205,7 +285,7 @@ function scanBlocks(lines, base, ctx) {
|
|
|
205
285
|
// of any length ≥ 3 followed by the block's id). The labeled close is a
|
|
206
286
|
// *local* close: it can't be gotten wrong by miscounting `=`, so it is the
|
|
207
287
|
// safe way to nest (§3).
|
|
208
|
-
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${attrs.id}[ \\t]*$`) : null;
|
|
288
|
+
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
|
|
209
289
|
const body = [];
|
|
210
290
|
let j = i + 1;
|
|
211
291
|
let closed = false;
|
|
@@ -242,7 +322,16 @@ function scanBlocks(lines, base, ctx) {
|
|
|
242
322
|
ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
|
|
243
323
|
}
|
|
244
324
|
if (mode === "flow") {
|
|
245
|
-
|
|
325
|
+
if (depth >= MAX_NESTING) {
|
|
326
|
+
// Refuse to recurse past the cap: emit a diagnostic and keep the body
|
|
327
|
+
// as raw so the parser returns cleanly instead of overflowing the
|
|
328
|
+
// call stack on a pathologically nested document (DoS).
|
|
329
|
+
diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
|
|
330
|
+
block.raw = body;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
block.children = scanBlocks(body, base + i + 1, ctx, depth + 1);
|
|
334
|
+
}
|
|
246
335
|
}
|
|
247
336
|
else if (mode === "data") {
|
|
248
337
|
block.data = parseData(body);
|
|
@@ -453,8 +542,46 @@ export function parse(source, opts = {}) {
|
|
|
453
542
|
}
|
|
454
543
|
// The id that a fence/heading line defines, matching how scanBlocks derives it
|
|
455
544
|
// (parseAttrs for the attribute object; heading text slug when no explicit id).
|
|
456
|
-
|
|
457
|
-
|
|
545
|
+
// The slug MUST come from the INTERPOLATED text — scanBlocks slugs after
|
|
546
|
+
// interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
|
|
547
|
+
// the raw text here would create a phantom id the parser never registered and
|
|
548
|
+
// make the real one unaddressable. `ctx` is an inert context carrying the
|
|
549
|
+
// document's meta (diagnostics are discarded — spans never report).
|
|
550
|
+
function idOfHeading(braces, text, line, ctx) {
|
|
551
|
+
return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
|
|
552
|
+
}
|
|
553
|
+
// The matching close of the fence opened at lines[i] (equal-length run, or the
|
|
554
|
+
// labeled `=== #id` close when the block carries an id): the index just past
|
|
555
|
+
// the close line, and whether one was found — an unterminated block runs to
|
|
556
|
+
// the end of the scope.
|
|
557
|
+
function fenceClose(lines, i, open) {
|
|
558
|
+
const openLen = open[1].length;
|
|
559
|
+
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
560
|
+
const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(id)}[ \\t]*$`) : null;
|
|
561
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
562
|
+
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
|
|
563
|
+
return { end: j + 1, closed: true };
|
|
564
|
+
}
|
|
565
|
+
return { end: lines.length, closed: false };
|
|
566
|
+
}
|
|
567
|
+
// A heading's span covers its whole SECTION: the heading line through the line
|
|
568
|
+
// just before the next heading of same-or-higher level (fewer-or-equal `#`) in
|
|
569
|
+
// the current scope, or end-of-scope. Fenced blocks are skipped whole — a `#`
|
|
570
|
+
// line inside a `=== code` body is content, never a boundary.
|
|
571
|
+
function sectionEnd(lines, i, level) {
|
|
572
|
+
let j = i + 1;
|
|
573
|
+
while (j < lines.length) {
|
|
574
|
+
const open = FENCE_OPEN.exec(lines[j]);
|
|
575
|
+
if (open) {
|
|
576
|
+
j = fenceClose(lines, j, open).end;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const h = HEADING.exec(lines[j]);
|
|
580
|
+
if (h && h[1].length <= level)
|
|
581
|
+
return j;
|
|
582
|
+
j++;
|
|
583
|
+
}
|
|
584
|
+
return lines.length;
|
|
458
585
|
}
|
|
459
586
|
// Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
|
|
460
587
|
// or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
|
|
@@ -462,7 +589,7 @@ function idOfHeading(braces, text) {
|
|
|
462
589
|
// First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
|
|
463
590
|
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
464
591
|
// absolute line offset of this slice within the whole document.
|
|
465
|
-
function collectSpans(lines, base, out) {
|
|
592
|
+
function collectSpans(lines, base, out, ctx, depth = 0) {
|
|
466
593
|
const add = (id, start, end) => {
|
|
467
594
|
if (!out.has(id))
|
|
468
595
|
out.set(id, { start, end });
|
|
@@ -486,33 +613,27 @@ function collectSpans(lines, base, out) {
|
|
|
486
613
|
} // hidden line: no id
|
|
487
614
|
const open = FENCE_OPEN.exec(line);
|
|
488
615
|
if (open) {
|
|
489
|
-
const openLen = open[1].length;
|
|
490
616
|
const type = open[2];
|
|
491
617
|
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
492
|
-
const
|
|
493
|
-
let j = i + 1;
|
|
494
|
-
let closed = false;
|
|
495
|
-
for (; j < lines.length; j++) {
|
|
496
|
-
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
|
|
497
|
-
closed = true;
|
|
498
|
-
break;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
const end = closed ? j + 1 : j;
|
|
618
|
+
const { end, closed } = fenceClose(lines, i, open);
|
|
502
619
|
if (id !== undefined)
|
|
503
620
|
add(id, base + i, base + end);
|
|
504
621
|
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
505
622
|
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
506
623
|
// the parser's contract.
|
|
507
|
-
if ((REGISTRY[type] ?? "raw") === "flow") {
|
|
508
|
-
collectSpans(lines.slice(i + 1, closed ?
|
|
624
|
+
if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
|
|
625
|
+
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
|
|
509
626
|
}
|
|
510
627
|
i = end;
|
|
511
628
|
continue;
|
|
512
629
|
}
|
|
513
630
|
const h = HEADING.exec(line);
|
|
514
631
|
if (h) {
|
|
515
|
-
|
|
632
|
+
// Section span (heading through its prose and nested blocks). The walk
|
|
633
|
+
// still advances one line at a time so every nested id inside the
|
|
634
|
+
// section registers its own span — spans intentionally OVERLAP: #sec
|
|
635
|
+
// contains #code, and each remains addressable on its own.
|
|
636
|
+
add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
|
|
516
637
|
i++;
|
|
517
638
|
continue;
|
|
518
639
|
}
|
|
@@ -523,29 +644,49 @@ function collectSpans(lines, base, out) {
|
|
|
523
644
|
// with the physical lines produced by splitLines(source).
|
|
524
645
|
export function blockSpans(source) {
|
|
525
646
|
const out = new Map();
|
|
526
|
-
|
|
647
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
648
|
+
// Inert context: heading auto-ids slug the interpolated text (parser parity);
|
|
649
|
+
// its diagnostics are discarded — the span scan never reports.
|
|
650
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
651
|
+
collectSpans(lines, 0, out, ctx);
|
|
527
652
|
return out;
|
|
528
653
|
}
|
|
529
654
|
// Split into physical lines while *keeping* each line's terminator, so
|
|
530
655
|
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
656
|
+
// A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
|
|
657
|
+
// the span scan's `\r\n?` -> `\n` normalization sees, so span indices always
|
|
658
|
+
// address the same lines the parser counted.
|
|
531
659
|
function splitLines(source) {
|
|
532
|
-
return source.split(/(?<=\n)/);
|
|
660
|
+
return source.split(/(?<=\n|\r(?!\n))/);
|
|
661
|
+
}
|
|
662
|
+
// `--head`: narrow any id's span to its HEAD line — the single declaring line
|
|
663
|
+
// (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
|
|
664
|
+
// `[^id]:` line). The head is by construction the FIRST line of the span, so
|
|
665
|
+
// the narrowing is parse-free and needs no type check. Main use: `set --head`
|
|
666
|
+
// edits a block's attributes (caption/compute/lang/…) without re-sending its
|
|
667
|
+
// body, or renames a heading without rewriting its section.
|
|
668
|
+
function narrowToHead(span) {
|
|
669
|
+
return { start: span.start, end: span.start + 1 };
|
|
533
670
|
}
|
|
534
671
|
// Depth-first search for the document-model node carrying `id`, descending into
|
|
535
672
|
// flow-block children (and list-item children) so a nested id is found too.
|
|
536
|
-
|
|
537
|
-
|
|
673
|
+
// Returns the containing sibling array and index, not just the node: the model
|
|
674
|
+
// is FLAT — a heading does not own its section; the section's prose and blocks
|
|
675
|
+
// are its FOLLOWING SIBLINGS — so a section consumer needs the array.
|
|
676
|
+
function findBlockSite(blocks, id) {
|
|
677
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
678
|
+
const b = blocks[i];
|
|
538
679
|
if ((b.kind === "heading" || b.kind === "block") && b.id === id)
|
|
539
|
-
return
|
|
680
|
+
return { siblings: blocks, index: i };
|
|
540
681
|
if (b.kind === "block" && b.children) {
|
|
541
|
-
const hit =
|
|
682
|
+
const hit = findBlockSite(b.children, id);
|
|
542
683
|
if (hit)
|
|
543
684
|
return hit;
|
|
544
685
|
}
|
|
545
686
|
if (b.kind === "list") {
|
|
546
687
|
for (const it of b.items) {
|
|
547
688
|
if (it.children) {
|
|
548
|
-
const hit =
|
|
689
|
+
const hit = findBlockSite(it.children, id);
|
|
549
690
|
if (hit)
|
|
550
691
|
return hit;
|
|
551
692
|
}
|
|
@@ -554,6 +695,21 @@ function findBlockById(blocks, id) {
|
|
|
554
695
|
}
|
|
555
696
|
return undefined;
|
|
556
697
|
}
|
|
698
|
+
// Model-side section boundary: within one sibling array, the section opened by
|
|
699
|
+
// the heading at index k runs to the next sibling heading of same-or-higher
|
|
700
|
+
// level, or the array end. This is the SAME rule sectionEnd() applies to raw
|
|
701
|
+
// source lines (where skipping fenced bodies makes "next heading" well-defined)
|
|
702
|
+
// — the two sides must stay in lockstep; the get-set suite pins their parity
|
|
703
|
+
// (ids covered by the raw slice == ids covered by the --json envelope).
|
|
704
|
+
function sectionEndIndex(siblings, k) {
|
|
705
|
+
const level = siblings[k].level;
|
|
706
|
+
for (let m = k + 1; m < siblings.length; m++) {
|
|
707
|
+
const b = siblings[m];
|
|
708
|
+
if (b.kind === "heading" && b.level <= level)
|
|
709
|
+
return m;
|
|
710
|
+
}
|
|
711
|
+
return siblings.length;
|
|
712
|
+
}
|
|
557
713
|
// ---------------------------------------------------------------------------
|
|
558
714
|
// CLI
|
|
559
715
|
// ---------------------------------------------------------------------------
|
|
@@ -572,45 +728,69 @@ function parseStamp(s) {
|
|
|
572
728
|
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
573
729
|
}
|
|
574
730
|
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
575
|
-
const PARSER_VERSION = "1.
|
|
576
|
-
const USAGE = `geml — GEML reference CLI
|
|
577
|
-
|
|
578
|
-
Usage:
|
|
579
|
-
geml <file.geml|->
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
geml
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
731
|
+
const PARSER_VERSION = "1.4.2"; // reference implementation; keep in sync with package.json
|
|
732
|
+
const USAGE = `geml — GEML reference CLI
|
|
733
|
+
|
|
734
|
+
Usage:
|
|
735
|
+
geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
|
|
736
|
+
<fmt>: json | html | md | geml
|
|
737
|
+
--to md -> Markdown (lossy)
|
|
738
|
+
--to html -> self-contained HTML
|
|
739
|
+
--to geml -> canonical re-format
|
|
740
|
+
--to json -> document-model JSON (default)
|
|
741
|
+
A Markdown input converts the other way:
|
|
742
|
+
geml notes.md -> GEML
|
|
743
|
+
--from overrides the input format (any input):
|
|
744
|
+
geml notes.txt --from md treat as Markdown
|
|
745
|
+
geml - --from md read Markdown on stdin
|
|
746
|
+
geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
|
|
747
|
+
(a heading id = its whole section; --head = head line;
|
|
748
|
+
--json = model node). Without #id: list all addressable
|
|
749
|
+
ids (--json = array).
|
|
750
|
+
geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
751
|
+
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
752
|
+
default = whole block · --head = head line · --body = body)
|
|
753
|
+
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
754
|
+
(1+ blocks and/or prose; content keeps its own ids, a clash is refused)
|
|
755
|
+
geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
|
|
756
|
+
(a missing id is skipped; a dangling reference is a warning, not a refusal)
|
|
757
|
+
geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
|
|
758
|
+
geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
|
|
759
|
+
(sel: -N | latest | id-prefix; default -1)
|
|
760
|
+
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
761
|
+
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
762
|
+
geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
|
|
763
|
+
geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
|
|
764
|
+
geml --help | --version [--json]
|
|
765
|
+
|
|
766
|
+
Use '-' as the file to read from stdin.
|
|
767
|
+
Mutations (set/add/delete/rename) write the whole updated document in place for a
|
|
768
|
+
file, or to stdout for '-' input; -o redirects it (-o - = stdout).
|
|
769
|
+
Exit codes:
|
|
770
|
+
0 ok
|
|
771
|
+
1 document/operation error
|
|
772
|
+
2 command usage error.
|
|
773
|
+
`;
|
|
594
774
|
// One-line usage for each subcommand — the single source for both the error
|
|
595
775
|
// shown on misuse and the `<cmd> --help` text.
|
|
596
776
|
const SUBHELP = {
|
|
597
|
-
get: "usage: geml get <file.geml|-> #id [--json]",
|
|
598
|
-
set: "usage: geml set <file.geml|-> #id [--
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] (sel: -N | latest | id-prefix; default -1)",
|
|
777
|
+
get: "usage: geml get <file.geml|-> [#id] [--json] [--head] (with #id: that block, a heading id = its whole section, --head = its head line; without #id: list every addressable id, --json = array)",
|
|
778
|
+
set: "usage: geml set <file.geml|-> #id [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (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 to #id — --body = body; guarded splice, refused if it breaks the doc)",
|
|
779
|
+
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)",
|
|
780
|
+
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)",
|
|
781
|
+
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)",
|
|
782
|
+
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
783
|
+
revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--changed] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: -N | latest | id-prefix; default -1)",
|
|
605
784
|
history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
|
|
606
|
-
codemap: `usage: geml codemap build --root <repo> # auto-detect languages, run the indexer(s), and merge into one codemap
|
|
607
|
-
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]]
|
|
608
|
-
geml codemap verify [dir] geml check + profile reference checks
|
|
609
|
-
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
610
|
-
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
|
|
611
|
-
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
612
|
-
geml codemap
|
|
613
|
-
|
|
785
|
+
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)
|
|
786
|
+
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]]
|
|
787
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
788
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
789
|
+
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
|
|
790
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
791
|
+
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
792
|
+
geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
|
|
793
|
+
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
614
794
|
};
|
|
615
795
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
616
796
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
@@ -634,12 +814,66 @@ function readInput(file) {
|
|
|
634
814
|
fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
|
|
635
815
|
}
|
|
636
816
|
}
|
|
637
|
-
// A cross-document resolver rooted at the input's directory (cwd for stdin)
|
|
638
|
-
|
|
639
|
-
|
|
817
|
+
// A cross-document resolver rooted at the input's directory (cwd for stdin),
|
|
818
|
+
// CONFINED to that directory's subtree. A reference that resolves outside the
|
|
819
|
+
// base — via a `..` escape, an absolute path, or (on Windows) a different drive
|
|
820
|
+
// — is refused (returns null, i.e. an unresolvable ref) so a crafted document
|
|
821
|
+
// cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
|
|
822
|
+
//
|
|
823
|
+
// A purely LEXICAL check is not enough: a symlink that sits lexically inside the
|
|
824
|
+
// subtree but points to `../../outside.geml` passes `path.relative` yet reads an
|
|
825
|
+
// external target. So after the cheap lexical gate we resolve BOTH the base and
|
|
826
|
+
// the target through `realpathSync` (following every symlink component) and
|
|
827
|
+
// re-check that the REAL target still lies within the REAL base subtree before
|
|
828
|
+
// reading. A target that does not exist makes `realpathSync` throw — handled as
|
|
829
|
+
// an ordinary unresolvable ref (null), never a crash.
|
|
830
|
+
//
|
|
831
|
+
// `root` (CLI `--root`, an explicit per-invocation user grant — never
|
|
832
|
+
// document-controlled) widens the confinement base from the input's own
|
|
833
|
+
// directory to an ancestor the user names, so repo-relative `../` references
|
|
834
|
+
// between sibling directories can be checked. It moves WHERE the boundary
|
|
835
|
+
// stands, never whether it is enforced: both gates below run against the
|
|
836
|
+
// widened base, so escapes past the root are refused exactly as above. The
|
|
837
|
+
// viewer/web surfaces never pass a root — their boundary is unchanged.
|
|
838
|
+
function resolverFor(file, root) {
|
|
839
|
+
const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
|
|
840
|
+
const baseAbs = root === undefined ? dirAbs : resolvePath(root);
|
|
841
|
+
// Canonicalise the base once. If the base itself cannot be realpath'd, no
|
|
842
|
+
// cross-doc ref can be safely confined — resolve nothing.
|
|
843
|
+
let realBase = null;
|
|
844
|
+
try {
|
|
845
|
+
realBase = realpathSync(baseAbs);
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
realBase = null;
|
|
849
|
+
}
|
|
850
|
+
const outside = (from, to) => {
|
|
851
|
+
const rel = relative(from, to);
|
|
852
|
+
return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
|
|
853
|
+
};
|
|
640
854
|
return (d) => {
|
|
855
|
+
if (realBase === null)
|
|
856
|
+
return null;
|
|
857
|
+
// References resolve FROM the document's own directory; the gates below
|
|
858
|
+
// confine them to the (possibly widened) base.
|
|
859
|
+
const targetAbs = resolvePath(dirAbs, d);
|
|
860
|
+
// Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
|
|
861
|
+
// before touching the filesystem.
|
|
862
|
+
if (outside(baseAbs, targetAbs))
|
|
863
|
+
return null;
|
|
864
|
+
// Real (symlink-resolved) gate: a symlink pointing out of the subtree
|
|
865
|
+
// resolves to a real path outside `realBase` and is refused here.
|
|
866
|
+
let realTarget;
|
|
867
|
+
try {
|
|
868
|
+
realTarget = realpathSync(targetAbs);
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
if (outside(realBase, realTarget))
|
|
874
|
+
return null;
|
|
641
875
|
try {
|
|
642
|
-
return readFileSync(
|
|
876
|
+
return readFileSync(realTarget, "utf8");
|
|
643
877
|
}
|
|
644
878
|
catch {
|
|
645
879
|
return null;
|
|
@@ -650,10 +884,22 @@ function resolverFor(file) {
|
|
|
650
884
|
// dump (cheap for agents). `--json` prints the diagnostics array for machines.
|
|
651
885
|
function runCheck(args) {
|
|
652
886
|
const json = args.includes("--json");
|
|
653
|
-
const
|
|
887
|
+
const root = flag(args, "--root");
|
|
888
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
|
|
654
889
|
if (!file)
|
|
655
890
|
fail(SUBHELP.check);
|
|
656
|
-
|
|
891
|
+
// A mistyped --root must be a usage error (exit 2), not a wall of misleading
|
|
892
|
+
// "cannot resolve document" errors from a resolver confined to nothing.
|
|
893
|
+
if (root !== undefined) {
|
|
894
|
+
let isDir = false;
|
|
895
|
+
try {
|
|
896
|
+
isDir = statSync(root).isDirectory();
|
|
897
|
+
}
|
|
898
|
+
catch { /* missing -> not a dir */ }
|
|
899
|
+
if (!isDir)
|
|
900
|
+
fail(`--root ${root} is not a directory`);
|
|
901
|
+
}
|
|
902
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
|
|
657
903
|
if (json) {
|
|
658
904
|
console.log(JSON.stringify(doc.diagnostics, null, 2));
|
|
659
905
|
}
|
|
@@ -737,39 +983,92 @@ function runHistory(args) {
|
|
|
737
983
|
fail(historyError(e, file, historyPath));
|
|
738
984
|
}
|
|
739
985
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
986
|
+
function runTransform(argv) {
|
|
987
|
+
const out = flag(argv, "-o") ?? flag(argv, "--out");
|
|
988
|
+
const fromRaw = flag(argv, "--from");
|
|
989
|
+
const toRaw = flag(argv, "--to");
|
|
990
|
+
const [file] = positionals(argv, ["-o", "--out", "--from", "--to"]);
|
|
743
991
|
if (!file)
|
|
744
|
-
fail(
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
992
|
+
fail("no input file (use '-' to read from stdin)", 2);
|
|
993
|
+
// A bare `--to`/`--from` (no following value) is a mistyped flag, not a
|
|
994
|
+
// silent fall-through to the default — flag() would return undefined and we
|
|
995
|
+
// must not quietly ignore it.
|
|
996
|
+
if (argv.includes("--from") && fromRaw === undefined)
|
|
997
|
+
fail("--from needs a format (geml | md)", 2);
|
|
998
|
+
if (argv.includes("--to") && toRaw === undefined)
|
|
999
|
+
fail("--to needs a format (json | html | md | geml)", 2);
|
|
1000
|
+
// Input format: an explicit --from wins (for any input, file or stdin), else
|
|
1001
|
+
// the file extension, else GEML (covers .geml, unknown extensions, and stdin).
|
|
1002
|
+
let inFmt;
|
|
1003
|
+
if (fromRaw !== undefined) {
|
|
1004
|
+
if (fromRaw !== "geml" && fromRaw !== "md") {
|
|
1005
|
+
fail(`--from: unknown input format '${fromRaw}' (want geml | md)`, 2);
|
|
1006
|
+
}
|
|
1007
|
+
inFmt = fromRaw;
|
|
1008
|
+
}
|
|
1009
|
+
else if (/\.(md|markdown)$/i.test(file)) {
|
|
1010
|
+
inFmt = "md";
|
|
752
1011
|
}
|
|
753
1012
|
else {
|
|
754
|
-
|
|
1013
|
+
inFmt = "geml";
|
|
755
1014
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
if (!file)
|
|
764
|
-
fail(SUBHELP.export);
|
|
765
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
766
|
-
const { md, notes } = gemlToMd(doc);
|
|
767
|
-
if (out) {
|
|
768
|
-
writeFileSync(out, md);
|
|
769
|
-
console.error(`wrote ${out}`);
|
|
1015
|
+
// Output format: an explicit --to wins, else md input -> geml, geml -> json.
|
|
1016
|
+
let outFmt;
|
|
1017
|
+
if (toRaw !== undefined) {
|
|
1018
|
+
if (toRaw !== "json" && toRaw !== "html" && toRaw !== "md" && toRaw !== "geml") {
|
|
1019
|
+
fail(`--to: unknown output format '${toRaw}' (want json | html | md | geml)`, 2);
|
|
1020
|
+
}
|
|
1021
|
+
outFmt = toRaw;
|
|
770
1022
|
}
|
|
771
|
-
else
|
|
772
|
-
|
|
1023
|
+
else {
|
|
1024
|
+
outFmt = inFmt === "md" ? "geml" : "json";
|
|
1025
|
+
}
|
|
1026
|
+
const src = readInput(file);
|
|
1027
|
+
// md -> geml is a direct projection, not a parse/serialize round-trip: emit
|
|
1028
|
+
// the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
|
|
1029
|
+
if (inFmt === "md" && outFmt === "geml") {
|
|
1030
|
+
const { geml, notes } = mdToGeml(src);
|
|
1031
|
+
writeOut(geml, out);
|
|
1032
|
+
for (const n of notes)
|
|
1033
|
+
console.error(`note: ${n}`);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// Otherwise load a document — a md input is converted to GEML first — and
|
|
1037
|
+
// project it to the target.
|
|
1038
|
+
let notes = [];
|
|
1039
|
+
let doc;
|
|
1040
|
+
if (inFmt === "md") {
|
|
1041
|
+
const conv = mdToGeml(src);
|
|
1042
|
+
notes = conv.notes;
|
|
1043
|
+
doc = parse(conv.geml, { resolveDoc: resolverFor(file) });
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
doc = parse(src, { resolveDoc: resolverFor(file) });
|
|
1047
|
+
}
|
|
1048
|
+
let output;
|
|
1049
|
+
switch (outFmt) {
|
|
1050
|
+
case "json":
|
|
1051
|
+
output = JSON.stringify(doc, null, 2) + "\n"; // == the former bare parse
|
|
1052
|
+
break;
|
|
1053
|
+
case "geml":
|
|
1054
|
+
output = serialize(doc); // == the former `fmt`
|
|
1055
|
+
break;
|
|
1056
|
+
case "html":
|
|
1057
|
+
output = renderHtml(doc, {
|
|
1058
|
+
source: file === "-" ? "stdin" : basename(file),
|
|
1059
|
+
// geml-code-graph embeds load + parse sibling codemap docs on demand.
|
|
1060
|
+
loadDoc: resolverFor(file),
|
|
1061
|
+
parseDoc: (s) => parse(s),
|
|
1062
|
+
});
|
|
1063
|
+
break;
|
|
1064
|
+
case "md": {
|
|
1065
|
+
const r = gemlToMd(doc); // == the former `export`
|
|
1066
|
+
notes = notes.concat(r.notes);
|
|
1067
|
+
output = r.md;
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
writeOut(output, out);
|
|
773
1072
|
for (const n of notes)
|
|
774
1073
|
console.error(`note: ${n}`);
|
|
775
1074
|
for (const d of doc.diagnostics)
|
|
@@ -777,55 +1076,35 @@ function runExport(args) {
|
|
|
777
1076
|
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
778
1077
|
process.exit(1);
|
|
779
1078
|
}
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
// are diagnostics (a viewer should still show what it can), but exits non-zero
|
|
783
|
-
// on any error so CI and agents get a hard signal.
|
|
784
|
-
function runRender(args) {
|
|
785
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
786
|
-
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
|
|
787
|
-
if (!file)
|
|
788
|
-
fail(SUBHELP.render);
|
|
789
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
790
|
-
const html = renderHtml(doc, {
|
|
791
|
-
source: file === "-" ? "stdin" : basename(file),
|
|
792
|
-
// geml-code-graph embeds load + parse sibling codemap documents on demand.
|
|
793
|
-
loadDoc: resolverFor(file),
|
|
794
|
-
parseDoc: (s) => parse(s),
|
|
795
|
-
});
|
|
796
|
-
if (out) {
|
|
797
|
-
writeFileSync(out, html);
|
|
798
|
-
console.error(`wrote ${out}`);
|
|
799
|
-
}
|
|
800
|
-
else
|
|
801
|
-
process.stdout.write(html);
|
|
802
|
-
for (const d of doc.diagnostics)
|
|
803
|
-
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
804
|
-
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
805
|
-
process.exit(1);
|
|
806
|
-
}
|
|
807
|
-
// `geml fmt <file.geml> [-o out.geml]` — re-serialize the document model into
|
|
808
|
-
// canonical GEML. Because `serialize` is the inverse of `parse`, `fmt` is a
|
|
809
|
-
// pretty-printer whose output parses back to the same model (round-trip stable).
|
|
810
|
-
function runFmt(args) {
|
|
811
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
812
|
-
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
|
|
813
|
-
if (!file)
|
|
814
|
-
fail(SUBHELP.fmt);
|
|
815
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
816
|
-
const text = serialize(doc);
|
|
1079
|
+
// Write to `-o out` (with a `wrote` note on stderr) or to stdout.
|
|
1080
|
+
function writeOut(text, out) {
|
|
817
1081
|
if (out) {
|
|
818
1082
|
writeFileSync(out, text);
|
|
819
1083
|
console.error(`wrote ${out}`);
|
|
820
1084
|
}
|
|
821
1085
|
else
|
|
822
1086
|
process.stdout.write(text);
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1087
|
+
}
|
|
1088
|
+
// Output-target rule shared by the MUTATION verbs (set, and — soon — add,
|
|
1089
|
+
// delete, rename, revert): a real file input with no `-o` is edited IN PLACE
|
|
1090
|
+
// (it's the obvious target, and it's what lets an agent chain edits without
|
|
1091
|
+
// re-reading a path back out of stdout); stdin (`file === "-"`) has no such
|
|
1092
|
+
// target, so it falls back to stdout. `-o` always wins when given: `-o -`
|
|
1093
|
+
// explicitly requests stdout (even for a file input), `-o <path>` writes
|
|
1094
|
+
// there. Every write announces itself with `wrote <path>` on stderr; stdout
|
|
1095
|
+
// stays reserved for the document bytes so it's still pipeable.
|
|
1096
|
+
function resolveOutTarget(file, oFlag) {
|
|
1097
|
+
const toFile = (path) => ({
|
|
1098
|
+
write(text) { writeFileSync(path, text); console.error(`wrote ${path}`); },
|
|
1099
|
+
});
|
|
1100
|
+
const toStdout = { write(text) { process.stdout.write(text); } };
|
|
1101
|
+
if (oFlag === "-")
|
|
1102
|
+
return toStdout;
|
|
1103
|
+
if (oFlag !== undefined)
|
|
1104
|
+
return toFile(oFlag);
|
|
1105
|
+
if (file === "-")
|
|
1106
|
+
return toStdout;
|
|
1107
|
+
return toFile(file);
|
|
829
1108
|
}
|
|
830
1109
|
// Positional args (a file, an id) are the non-flag tokens that aren't the value
|
|
831
1110
|
// of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
|
|
@@ -849,69 +1128,503 @@ function positionals(args, valued) {
|
|
|
849
1128
|
}
|
|
850
1129
|
return out;
|
|
851
1130
|
}
|
|
1131
|
+
// `geml get <file>` with no id: list every addressable id — the document's
|
|
1132
|
+
// table of contents. Default output is one id per line with its kind (and, for
|
|
1133
|
+
// a heading, its level and text); `--json` is a machine-readable array so an
|
|
1134
|
+
// agent can pick its next `get #id` target. Ids are listed in document order
|
|
1135
|
+
// (the registration order parse() records), covering the same set `get #id`
|
|
1136
|
+
// resolves against: typed blocks, headings, and footnote definitions.
|
|
1137
|
+
function listIds(source, file, json) {
|
|
1138
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1139
|
+
const rows = doc.ids.map((id) => {
|
|
1140
|
+
const site = findBlockSite(doc.children, id);
|
|
1141
|
+
const b = site?.siblings[site.index];
|
|
1142
|
+
if (b?.kind === "heading")
|
|
1143
|
+
return { id, kind: "heading", level: b.level, text: b.text };
|
|
1144
|
+
if (b?.kind === "block") {
|
|
1145
|
+
const row = { id, kind: b.type };
|
|
1146
|
+
if (b.classes.includes("footnote"))
|
|
1147
|
+
row.footnote = true; // §5.2 footnote definition
|
|
1148
|
+
return row;
|
|
1149
|
+
}
|
|
1150
|
+
return { id, kind: b?.kind ?? "unknown" };
|
|
1151
|
+
});
|
|
1152
|
+
if (json) {
|
|
1153
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (rows.length === 0) {
|
|
1157
|
+
console.error(`no addressable ids in ${file === "-" ? "stdin" : file}`);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
// Align the id and kind columns; append a heading's level+text or a footnote flag.
|
|
1161
|
+
const idW = Math.max(...rows.map((r) => r.id.length + 1));
|
|
1162
|
+
const kindW = Math.max(...rows.map((r) => r.kind.length));
|
|
1163
|
+
for (const r of rows) {
|
|
1164
|
+
let line = `#${r.id}`.padEnd(idW + 1) + " " + r.kind.padEnd(kindW);
|
|
1165
|
+
if (r.kind === "heading")
|
|
1166
|
+
line += ` h${r.level} ${r.text}`;
|
|
1167
|
+
else if (r.footnote)
|
|
1168
|
+
line += " footnote";
|
|
1169
|
+
console.log(line.replace(/\s+$/, ""));
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
852
1172
|
// `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
|
|
853
1173
|
// without loading the rest of the document into context. Default output is the
|
|
854
|
-
// block's exact source bytes
|
|
855
|
-
// a heading
|
|
1174
|
+
// block's exact source bytes: a typed block's full `=== … ===` span, a
|
|
1175
|
+
// footnote's line, or — for a heading — its whole SECTION (heading line through
|
|
1176
|
+
// the line before the next same-or-higher heading). `--json` covers the same
|
|
1177
|
+
// content: a block/footnote id prints its document-model node; a heading id
|
|
1178
|
+
// prints a section envelope `{kind:"section", id, level, blocks:[heading,
|
|
1179
|
+
// …siblings up to the boundary]}`.
|
|
856
1180
|
function runGet(args) {
|
|
857
1181
|
const json = args.includes("--json");
|
|
1182
|
+
const headOnly = args.includes("--head");
|
|
858
1183
|
const [file, rawId] = positionals(args, []);
|
|
859
|
-
if (!file
|
|
1184
|
+
if (!file)
|
|
860
1185
|
fail(SUBHELP.get);
|
|
1186
|
+
// No id: list every addressable id — the document's "table of contents", so
|
|
1187
|
+
// an agent can discover what `get #id` can target without pulling the model.
|
|
1188
|
+
if (!rawId) {
|
|
1189
|
+
listIds(readInput(file), file, json);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
861
1192
|
const id = rawId.replace(/^#/, "");
|
|
862
1193
|
const source = readInput(file);
|
|
863
1194
|
if (json) {
|
|
864
|
-
// The model node — same
|
|
865
|
-
// to resolve the tree (and nested-block ids), but only the
|
|
1195
|
+
// The model node(s) — same shapes `geml <file>` emits. Parsing is needed
|
|
1196
|
+
// to resolve the tree (and nested-block ids), but only the target prints.
|
|
866
1197
|
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
867
|
-
const
|
|
868
|
-
if (!
|
|
1198
|
+
const site = findBlockSite(doc.children, id);
|
|
1199
|
+
if (!site)
|
|
869
1200
|
fail(`no block with id \`${id}\``, 1);
|
|
1201
|
+
const block = site.siblings[site.index];
|
|
1202
|
+
// `--head` on a heading suppresses the section envelope (the lone heading
|
|
1203
|
+
// node IS the head). On a block/footnote id there is nothing finer than
|
|
1204
|
+
// the single node — the model has no sub-node for "just the fence line" —
|
|
1205
|
+
// so --head refines only the RAW output there.
|
|
1206
|
+
if (block.kind === "heading" && !headOnly) {
|
|
1207
|
+
// A heading id addresses its SECTION, so `--json` covers the same
|
|
1208
|
+
// content as the raw span: a self-describing envelope whose blocks[0]
|
|
1209
|
+
// is the heading node followed by its siblings up to the boundary.
|
|
1210
|
+
// `kind: "section"` lets a consumer branch — a block/footnote id still
|
|
1211
|
+
// yields the single model node (the model itself stays flat).
|
|
1212
|
+
const end = sectionEndIndex(site.siblings, site.index);
|
|
1213
|
+
console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
870
1216
|
console.log(JSON.stringify(block, null, 2));
|
|
871
1217
|
return;
|
|
872
1218
|
}
|
|
873
1219
|
// Raw: slice the source span byte-for-byte. No parse required, so `get` still
|
|
874
1220
|
// returns the exact bytes even if the document has diagnostics elsewhere.
|
|
875
|
-
const
|
|
876
|
-
if (!
|
|
1221
|
+
const found = blockSpans(source).get(id);
|
|
1222
|
+
if (!found)
|
|
877
1223
|
fail(`no block with id \`${id}\``, 1);
|
|
1224
|
+
const span = headOnly ? narrowToHead(found) : found;
|
|
878
1225
|
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
879
1226
|
}
|
|
880
|
-
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
1227
|
+
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
1228
|
+
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
1229
|
+
// replace ONE existing block, addressed by #id, with new content, preserving
|
|
1230
|
+
// every other byte. Two content CHANNELS × three MODES:
|
|
1231
|
+
//
|
|
1232
|
+
// channels · `--in F[#src]` extracts a BLOCK from GEML file F (F is always
|
|
1233
|
+
// read as GEML — extension ignored, no md conversion): `--in F`
|
|
1234
|
+
// takes the block whose id == the target #id; `--in F#src` takes
|
|
1235
|
+
// #src. stdin (default, or `--in -`) is raw bytes.
|
|
1236
|
+
// modes · default replaces the WHOLE block, `--head` only the head line,
|
|
1237
|
+
// `--body` only the body. Default and `--head` NORMALIZE the
|
|
1238
|
+
// content's id to #id (its source id is irrelevant); `--body`
|
|
1239
|
+
// keeps the target's head verbatim, so #id is preserved naturally.
|
|
1240
|
+
//
|
|
1241
|
+
// Output follows resolveOutTarget (file -> in place, stdin -> stdout, `-o`/`-o -`
|
|
1242
|
+
// override) and every splice is guarded — re-parsed and rejected if it broke
|
|
1243
|
+
// the doc, so `set` never writes a corrupt file.
|
|
885
1244
|
function runSet(args) {
|
|
886
1245
|
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
887
|
-
const from = flag(args, "--
|
|
888
|
-
const
|
|
889
|
-
|
|
1246
|
+
const from = flag(args, "--in");
|
|
1247
|
+
const headOnly = args.includes("--head");
|
|
1248
|
+
const bodyOnly = args.includes("--body");
|
|
1249
|
+
if (headOnly && bodyOnly)
|
|
1250
|
+
fail("--head and --body are mutually exclusive", 2);
|
|
1251
|
+
const [file, rawId] = positionals(args, ["-o", "--out", "--in"]);
|
|
1252
|
+
if (!file)
|
|
890
1253
|
fail(SUBHELP.set);
|
|
1254
|
+
// No id: there is no block to replace. Point the way to discovery, not a bare
|
|
1255
|
+
// usage line — `geml get <file>` lists every id `set` can target.
|
|
1256
|
+
if (!rawId)
|
|
1257
|
+
fail(`no #id given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable ids`, 2);
|
|
891
1258
|
const id = rawId.replace(/^#/, "");
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
|
|
895
|
-
|
|
1259
|
+
// The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
|
|
1260
|
+
// a block from a file. Document and content can't BOTH be stdin: reject that
|
|
1261
|
+
// up front, before consuming stdin, so the document read below is unambiguous.
|
|
1262
|
+
const rawChannel = from === undefined || from === "-";
|
|
1263
|
+
if (file === "-" && rawChannel) {
|
|
1264
|
+
fail("reading the document from stdin needs --in for the new content", 2);
|
|
896
1265
|
}
|
|
897
1266
|
const source = readInput(file);
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1267
|
+
if (bodyOnly) {
|
|
1268
|
+
runSetBody(source, id, from, rawChannel, file, out);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
// default / --head: content is a whole block (default) or a bare head line.
|
|
1272
|
+
let content;
|
|
1273
|
+
if (rawChannel) {
|
|
1274
|
+
content = readInput("-");
|
|
1275
|
+
if (content === "")
|
|
1276
|
+
fail(NO_CONTENT, 1);
|
|
1277
|
+
// Default mode wants exactly ONE block. Pure prose has no head to carry the
|
|
1278
|
+
// id (steer to --body); multiple blocks are `add`'s job. --head takes a
|
|
1279
|
+
// lone head line, so it skips the whole-block shape check.
|
|
1280
|
+
if (!headOnly) {
|
|
1281
|
+
const shape = contentShape(content);
|
|
1282
|
+
if (shape === "empty")
|
|
1283
|
+
fail(NO_CONTENT, 1);
|
|
1284
|
+
if (shape === "prose")
|
|
1285
|
+
fail(`content is prose, not a block — use --body to set the body of #${id}`, 1);
|
|
1286
|
+
if (shape === "multi")
|
|
1287
|
+
fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
|
|
1288
|
+
}
|
|
902
1289
|
}
|
|
903
1290
|
else {
|
|
904
|
-
|
|
905
|
-
if (replacement === "")
|
|
906
|
-
fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
|
|
1291
|
+
content = extractBlock(from, id, headOnly ? "head" : "whole");
|
|
907
1292
|
}
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1293
|
+
const normalized = normalizeBlockId(content, id);
|
|
1294
|
+
const updated = spliceBlock(source, id, normalized, file, headOnly);
|
|
1295
|
+
resolveOutTarget(file, out).write(updated);
|
|
1296
|
+
}
|
|
1297
|
+
// `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
|
|
1298
|
+
// for a typed block, its close fence. Assembles head + new body + close and
|
|
1299
|
+
// reuses the guarded spliceBlock — the head carries #id, so the id survives
|
|
1300
|
+
// with no normalization needed.
|
|
1301
|
+
function runSetBody(source, id, from, rawChannel, file, out) {
|
|
1302
|
+
const found = blockSpans(source).get(id);
|
|
1303
|
+
if (!found)
|
|
1304
|
+
fail(`no block with id \`${id}\``, 1);
|
|
1305
|
+
const lines = splitLines(source);
|
|
1306
|
+
const headLine = lines[found.start] ?? "";
|
|
1307
|
+
const headText = stripEol(headLine);
|
|
1308
|
+
// A typed block keeps its closing fence; a heading section has none.
|
|
1309
|
+
let closeLine = null;
|
|
1310
|
+
const open = FENCE_OPEN.exec(headText);
|
|
1311
|
+
if (open) {
|
|
1312
|
+
const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
1313
|
+
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1314
|
+
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1315
|
+
if (isCloseFence(lastText, open[1].length) || labeled)
|
|
1316
|
+
closeLine = lines[found.end - 1] ?? "";
|
|
1317
|
+
}
|
|
1318
|
+
let body;
|
|
1319
|
+
if (rawChannel) {
|
|
1320
|
+
body = readInput("-");
|
|
1321
|
+
if (body === "")
|
|
1322
|
+
fail(NO_CONTENT, 1);
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
body = extractBlock(from, id, "body");
|
|
912
1326
|
}
|
|
1327
|
+
let head = headLine;
|
|
1328
|
+
if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
|
|
1329
|
+
head += "\n";
|
|
1330
|
+
let b = body.replace(/\r\n?/g, "\n");
|
|
1331
|
+
if (closeLine !== null && b !== "" && !b.endsWith("\n"))
|
|
1332
|
+
b += "\n";
|
|
1333
|
+
const replacement = closeLine !== null ? head + b + closeLine : head + b;
|
|
1334
|
+
// A typed block (closeLine !== null) must stay ONE block: enforce the
|
|
1335
|
+
// block-count invariant so a `===` fence in the raw body can't close it early
|
|
1336
|
+
// and inject siblings (SEC F2). A heading section body has no close fence and
|
|
1337
|
+
// may legitimately contain blocks, so it is not count-guarded.
|
|
1338
|
+
const updated = spliceBlock(source, id, replacement, file, false, closeLine !== null);
|
|
1339
|
+
resolveOutTarget(file, out).write(updated);
|
|
1340
|
+
}
|
|
1341
|
+
// `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
|
|
1342
|
+
// — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
|
|
1343
|
+
// `add` names no target id, so content keeps its OWN ids (no normalization); an
|
|
1344
|
+
// id colliding with the document (or duplicated within the fragment) makes the
|
|
1345
|
+
// re-parse fail and nothing is written. Bare prose is a valid fragment.
|
|
1346
|
+
function runAdd(args) {
|
|
1347
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1348
|
+
const from = flag(args, "--in");
|
|
1349
|
+
const before = flag(args, "--before");
|
|
1350
|
+
const after = flag(args, "--after");
|
|
1351
|
+
const append = args.includes("--append");
|
|
1352
|
+
const posCount = (append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0);
|
|
1353
|
+
if (posCount !== 1)
|
|
1354
|
+
fail("add needs exactly one position: --append | --before #id | --after #id", 2);
|
|
1355
|
+
const [file] = positionals(args, ["-o", "--out", "--in", "--before", "--after"]);
|
|
1356
|
+
if (!file)
|
|
1357
|
+
fail(SUBHELP.add);
|
|
1358
|
+
const rawChannel = from === undefined || from === "-";
|
|
1359
|
+
if (file === "-" && rawChannel)
|
|
1360
|
+
fail("reading the document from stdin needs --in for the new content", 2);
|
|
1361
|
+
const source = readInput(file);
|
|
1362
|
+
// Content: --in F#src -> block #src; --in F -> all of F (a multi-block
|
|
1363
|
+
// fragment is fine here); stdin -> raw. No id-normalization: add keeps ids.
|
|
1364
|
+
let content;
|
|
1365
|
+
if (rawChannel)
|
|
1366
|
+
content = readInput("-");
|
|
1367
|
+
else if (from.includes("#"))
|
|
1368
|
+
content = extractBlock(from, "", "whole");
|
|
913
1369
|
else
|
|
914
|
-
|
|
1370
|
+
content = readInput(from);
|
|
1371
|
+
if (content.trim() === "")
|
|
1372
|
+
fail("no content to add (use --in FILE or pipe it on stdin)", 1);
|
|
1373
|
+
// Resolve the physical-line insertion point.
|
|
1374
|
+
const lines = splitLines(source);
|
|
1375
|
+
let at;
|
|
1376
|
+
if (append) {
|
|
1377
|
+
at = lines.length;
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
const anchorId = (before ?? after).replace(/^#/, "");
|
|
1381
|
+
const span = blockSpans(source).get(anchorId);
|
|
1382
|
+
if (!span)
|
|
1383
|
+
fail(`no block with id \`${anchorId}\` in ${file === "-" ? "stdin" : file}`, 1);
|
|
1384
|
+
at = before !== undefined ? span.start : span.end;
|
|
1385
|
+
}
|
|
1386
|
+
const updated = insertFragment(source, lines, at, content, file);
|
|
1387
|
+
resolveOutTarget(file, out).write(updated);
|
|
1388
|
+
}
|
|
1389
|
+
// Splice `fragment` into `source` at physical-line index `at` (splitLines
|
|
1390
|
+
// coords), separating it from adjacent content with a single blank line so
|
|
1391
|
+
// blocks don't fuse, then GUARD: the re-parse must be error-free (a colliding
|
|
1392
|
+
// or duplicate id surfaces as an error diagnostic) and no pre-existing id may
|
|
1393
|
+
// vanish. Returns the updated text; on any violation fail()s and writes nothing.
|
|
1394
|
+
function insertFragment(source, lines, at, fragment, file) {
|
|
1395
|
+
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
1396
|
+
const before = lines.slice(0, at);
|
|
1397
|
+
const after = lines.slice(at);
|
|
1398
|
+
// The preceding line must end in a newline so the fragment starts on its own.
|
|
1399
|
+
if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
|
|
1400
|
+
before[before.length - 1] += "\n";
|
|
1401
|
+
}
|
|
1402
|
+
let frag = fragment.replace(/\r\n?/g, "\n");
|
|
1403
|
+
if (!frag.endsWith("\n"))
|
|
1404
|
+
frag += "\n";
|
|
1405
|
+
// A single blank separator on each side that has adjacent content and isn't
|
|
1406
|
+
// already blank — keeps a following head / preceding block from fusing.
|
|
1407
|
+
const blank = (s) => stripEol(s).trim() === "";
|
|
1408
|
+
const sepBefore = before.length && !blank(before[before.length - 1]) ? "\n" : "";
|
|
1409
|
+
const sepAfter = after.length && !blank(after[0]) ? "\n" : "";
|
|
1410
|
+
const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
|
|
1411
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1412
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1413
|
+
if (errs.length) {
|
|
1414
|
+
const first = errs[0];
|
|
1415
|
+
fail(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, 1);
|
|
1416
|
+
}
|
|
1417
|
+
const now = new Set(reparsed.ids);
|
|
1418
|
+
const dropped = beforeIds.find((x) => !now.has(x));
|
|
1419
|
+
if (dropped !== undefined)
|
|
1420
|
+
fail(`adding the content would drop block \`#${dropped}\`; not written`, 1);
|
|
1421
|
+
return updated;
|
|
1422
|
+
}
|
|
1423
|
+
// `geml delete <file|-> #id [#id2 …] [-o]` — remove one or more blocks. A
|
|
1424
|
+
// missing id is SKIPPED with a note (declarative "ensure absent", not an
|
|
1425
|
+
// error). Unlike set/add, delete's write is LENIENT: removing a complete block
|
|
1426
|
+
// can't break the parse structurally, but it may leave a reference dangling —
|
|
1427
|
+
// that is a WARNING, never a refusal (delete is reversible via revert + history,
|
|
1428
|
+
// and `geml check` still flags the dangling ref afterward). Contained/overlapping
|
|
1429
|
+
// spans (a nested block inside a deleted heading section) are handled by deleting
|
|
1430
|
+
// the UNION of target lines, so a line is never spliced twice.
|
|
1431
|
+
function runDelete(args) {
|
|
1432
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1433
|
+
const pos = positionals(args, ["-o", "--out"]);
|
|
1434
|
+
const file = pos[0];
|
|
1435
|
+
if (!file)
|
|
1436
|
+
fail(SUBHELP.delete);
|
|
1437
|
+
const ids = pos.slice(1).map((s) => s.replace(/^#/, ""));
|
|
1438
|
+
if (ids.length === 0)
|
|
1439
|
+
fail("delete needs at least one #id (run 'geml get <file>' to list ids)", 2);
|
|
1440
|
+
const source = readInput(file);
|
|
1441
|
+
const spans = blockSpans(source);
|
|
1442
|
+
const toDelete = new Set();
|
|
1443
|
+
let found = 0;
|
|
1444
|
+
for (const id of ids) {
|
|
1445
|
+
const span = spans.get(id);
|
|
1446
|
+
if (!span) {
|
|
1447
|
+
console.error(`skipped #${id}: no such block`);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
found++;
|
|
1451
|
+
for (let i = span.start; i < span.end; i++)
|
|
1452
|
+
toDelete.add(i);
|
|
1453
|
+
}
|
|
1454
|
+
if (found === 0) {
|
|
1455
|
+
resolveOutTarget(file, out).write(source);
|
|
1456
|
+
return;
|
|
1457
|
+
} // nothing to remove
|
|
1458
|
+
const updated = splitLines(source).filter((_, i) => !toDelete.has(i)).join("");
|
|
1459
|
+
// Lenient guard: surface any resulting error diagnostic (a reference now
|
|
1460
|
+
// dangling) as a WARNING, but write regardless.
|
|
1461
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1462
|
+
for (const d of reparsed.diagnostics.filter((x) => x.severity === "error")) {
|
|
1463
|
+
console.error(`warning: ${d.message} (line ${d.line}) — left dangling by delete; run 'geml check' to see it as an error`);
|
|
1464
|
+
}
|
|
1465
|
+
resolveOutTarget(file, out).write(updated);
|
|
1466
|
+
}
|
|
1467
|
+
// `geml rename <file|-> #old #new [-o]` — the one verb that reaches OUTSIDE a
|
|
1468
|
+
// block: it rewrites #old's declaration AND every reference to it. #new must be
|
|
1469
|
+
// free; the guarded re-parse refuses anything that would break the doc.
|
|
1470
|
+
function runRename(args) {
|
|
1471
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1472
|
+
const [file, rawOld, rawNew] = positionals(args, ["-o", "--out"]);
|
|
1473
|
+
if (!file || !rawOld || !rawNew)
|
|
1474
|
+
fail(SUBHELP.rename);
|
|
1475
|
+
const oldId = rawOld.replace(/^#/, "");
|
|
1476
|
+
const newId = rawNew.replace(/^#/, "");
|
|
1477
|
+
if (oldId === newId)
|
|
1478
|
+
fail("#old and #new are the same id — nothing to rename", 2);
|
|
1479
|
+
const source = readInput(file);
|
|
1480
|
+
const before = parse(source, { resolveDoc: resolverFor(file) });
|
|
1481
|
+
if (!before.ids.includes(oldId))
|
|
1482
|
+
fail(`no block with id \`${oldId}\``, 1);
|
|
1483
|
+
if (before.ids.includes(newId))
|
|
1484
|
+
fail(`id \`${newId}\` already exists; not written`, 1);
|
|
1485
|
+
// Renaming an id that has recorded history breaks the revert-lineage for it
|
|
1486
|
+
// (revert keys by id and can't follow #old -> #new across the boundary). Warn
|
|
1487
|
+
// so the user knows a later `revert #new` won't reach pre-rename revisions.
|
|
1488
|
+
if (file !== "-") {
|
|
1489
|
+
const hp = historyPathFor(file);
|
|
1490
|
+
if (existsSync(hp)) {
|
|
1491
|
+
try {
|
|
1492
|
+
if (blockSpans(resolveContent(hp, "latest").text).has(oldId)) {
|
|
1493
|
+
console.error(`warning: #${oldId} has history; revert across this rename is not tracked — see docs`);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
catch { /* unreadable/empty history: no warning */ }
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const updated = rewriteId(source, oldId, newId, file);
|
|
1500
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1501
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1502
|
+
if (errs.length) {
|
|
1503
|
+
const e = errs[0];
|
|
1504
|
+
fail(`rename would break the document: ${e.message} (line ${e.line}); not written`, 1);
|
|
1505
|
+
}
|
|
1506
|
+
if (!reparsed.ids.includes(newId))
|
|
1507
|
+
fail(`rename did not produce #${newId}; not written`, 1);
|
|
1508
|
+
if (reparsed.ids.includes(oldId))
|
|
1509
|
+
fail(`#${oldId} still present after rename; not written`, 1);
|
|
1510
|
+
// Every OTHER id must be untouched. The `#old` match boundary treats a char
|
|
1511
|
+
// outside [A-Za-z0-9_-] as an id terminator, but ids may contain e.g. `.`
|
|
1512
|
+
// (`#foo.bar`), so renaming `#foo` could silently rewrite the *different* id
|
|
1513
|
+
// `#foo.bar` -> `#baz.bar`. Reject when the set of ids other than the rename
|
|
1514
|
+
// pair changed at all (SEC/correctness: collateral id corruption).
|
|
1515
|
+
const othersBefore = before.ids.filter((id) => id !== oldId).sort().join("\n");
|
|
1516
|
+
const othersAfter = reparsed.ids.filter((id) => id !== newId).sort().join("\n");
|
|
1517
|
+
if (othersBefore !== othersAfter) {
|
|
1518
|
+
fail(`rename would also change other ids sharing the \`${oldId}\` prefix (e.g. \`#${oldId}…\`); not written`, 1);
|
|
1519
|
+
}
|
|
1520
|
+
resolveOutTarget(file, out).write(updated);
|
|
1521
|
+
}
|
|
1522
|
+
// Rewrite id `old` -> `new` everywhere it is a declaration or reference, id-
|
|
1523
|
+
// boundary-safe: `#old` is replaced only when NOT followed by an id char, so a
|
|
1524
|
+
// longer id like `#old2` / `#old-x` is untouched. Covers the declaration
|
|
1525
|
+
// (`{#old …}`, labeled close `=== #old`), block references (`[[#old]]`,
|
|
1526
|
+
// `[t](#old)`, chart `data=#old`) and footnotes (`[^old]`). RAW / data block
|
|
1527
|
+
// BODIES (code/diagram/math/table/meta) are skipped — a `#old` there is literal
|
|
1528
|
+
// text, not a reference. (Known residual: id-less raw bodies and inline
|
|
1529
|
+
// code/math spans in flow content — see design §8.)
|
|
1530
|
+
function rewriteId(source, oldId, newId, file) {
|
|
1531
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1532
|
+
const spans = blockSpans(source);
|
|
1533
|
+
const protectedLines = new Set();
|
|
1534
|
+
for (const b of doc.children) {
|
|
1535
|
+
if (b.kind === "block" && (b.mode === "raw" || b.mode === "data") && b.id) {
|
|
1536
|
+
const span = spans.get(b.id);
|
|
1537
|
+
if (span) {
|
|
1538
|
+
const br = bodyRange(source, span);
|
|
1539
|
+
for (let i = br.start; i < br.end; i++)
|
|
1540
|
+
protectedLines.add(i);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const esc = reLit(oldId);
|
|
1545
|
+
const hashRe = new RegExp(`#${esc}(?![A-Za-z0-9_-])`, "g");
|
|
1546
|
+
const fnRe = new RegExp(`(\\[\\^)${esc}(?![A-Za-z0-9_-])`, "g");
|
|
1547
|
+
const lines = splitLines(source);
|
|
1548
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1549
|
+
if (protectedLines.has(i))
|
|
1550
|
+
continue;
|
|
1551
|
+
lines[i] = lines[i].replace(hashRe, `#${newId}`).replace(fnRe, `$1${newId}`);
|
|
1552
|
+
}
|
|
1553
|
+
return lines.join("");
|
|
1554
|
+
}
|
|
1555
|
+
// Extract one block from a GEML file for `--in`. `spec` is `F` (block whose id
|
|
1556
|
+
// == the target) or `F#src` (block #src) — the last `#` splits path from id, so
|
|
1557
|
+
// a `#` inside the path is tolerated; F is read as GEML regardless of extension
|
|
1558
|
+
// (blockSpans + splitLines, no parse — same slice `geml get` prints). `part`
|
|
1559
|
+
// selects the whole span, its head line, or its body. A missing file or absent
|
|
1560
|
+
// id is an operation error (exit 1); the caller writes nothing.
|
|
1561
|
+
function extractBlock(spec, targetId, part) {
|
|
1562
|
+
const hash = spec.lastIndexOf("#");
|
|
1563
|
+
const fragFile = hash >= 0 ? spec.slice(0, hash) : spec;
|
|
1564
|
+
const fragId = hash >= 0 ? spec.slice(hash + 1).replace(/^#/, "") : targetId;
|
|
1565
|
+
let text;
|
|
1566
|
+
try {
|
|
1567
|
+
text = readFileSync(fragFile, "utf8");
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
fail(`cannot read ${fragFile}`, 1);
|
|
1571
|
+
}
|
|
1572
|
+
const span = blockSpans(text).get(fragId);
|
|
1573
|
+
if (!span)
|
|
1574
|
+
fail(`no block with id \`${fragId}\` in ${fragFile}`, 1);
|
|
1575
|
+
const lines = splitLines(text);
|
|
1576
|
+
if (part === "head")
|
|
1577
|
+
return lines.slice(span.start, span.start + 1).join("");
|
|
1578
|
+
if (part === "body") {
|
|
1579
|
+
const b = bodyRange(text, span);
|
|
1580
|
+
return lines.slice(b.start, b.end).join("");
|
|
1581
|
+
}
|
|
1582
|
+
return lines.slice(span.start, span.end).join("");
|
|
1583
|
+
}
|
|
1584
|
+
// Strip a single trailing terminator (`\r\n`, `\r`, or `\n`) from one line.
|
|
1585
|
+
function stripEol(line) {
|
|
1586
|
+
return line.replace(/(\r\n|\r|\n)$/, "");
|
|
1587
|
+
}
|
|
1588
|
+
// The body sub-range of a block span: [head+1, close) for a closed typed block,
|
|
1589
|
+
// otherwise [head+1, end) — a heading section (no close fence) or an
|
|
1590
|
+
// unterminated block whose span already runs to end-of-scope.
|
|
1591
|
+
function bodyRange(text, span) {
|
|
1592
|
+
const lines = splitLines(text);
|
|
1593
|
+
const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
|
|
1594
|
+
if (open) {
|
|
1595
|
+
const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
1596
|
+
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1597
|
+
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1598
|
+
const closed = isCloseFence(lastText, open[1].length) || labeled;
|
|
1599
|
+
return { start: span.start + 1, end: closed ? span.end - 1 : span.end };
|
|
1600
|
+
}
|
|
1601
|
+
return { start: span.start + 1, end: span.end };
|
|
1602
|
+
}
|
|
1603
|
+
// The shape of default-mode stdin content, section-aware: a heading OWNS its
|
|
1604
|
+
// section (`# H …blocks…` is ONE unit, not many), matching sectionEnd/blockSpans.
|
|
1605
|
+
// Used to reject pure prose (-> --body) and multi-block content (-> add) before
|
|
1606
|
+
// the splice — extraction via --in is inherently one block and skips this.
|
|
1607
|
+
function contentShape(content) {
|
|
1608
|
+
const bs = parse(content).children;
|
|
1609
|
+
let blockUnits = 0, proseUnits = 0, i = 0;
|
|
1610
|
+
while (i < bs.length) {
|
|
1611
|
+
const b = bs[i];
|
|
1612
|
+
if (b.kind === "heading") {
|
|
1613
|
+
i = sectionEndIndex(bs, i);
|
|
1614
|
+
blockUnits++;
|
|
1615
|
+
}
|
|
1616
|
+
else if (b.kind === "block") {
|
|
1617
|
+
i++;
|
|
1618
|
+
blockUnits++;
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
i++;
|
|
1622
|
+
proseUnits++;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (blockUnits === 0)
|
|
1626
|
+
return proseUnits === 0 ? "empty" : "prose";
|
|
1627
|
+
return blockUnits + proseUnits === 1 ? "single" : "multi";
|
|
915
1628
|
}
|
|
916
1629
|
// Replace block #id's source span in `source` with `replacement`, preserving
|
|
917
1630
|
// every other byte, and GUARD the result: the re-parse must be error-free, #id
|
|
@@ -919,15 +1632,22 @@ function runSet(args) {
|
|
|
919
1632
|
// can silently swallow a neighbour). Returns the updated document text; on any
|
|
920
1633
|
// violation it calls fail() and never returns a corrupt document. Shared by
|
|
921
1634
|
// `set` and `revert`.
|
|
922
|
-
function spliceBlock(source, id, replacement, file) {
|
|
923
|
-
const
|
|
924
|
-
if (!
|
|
1635
|
+
function spliceBlock(source, id, replacement, file, headOnly = false, guardCount = false) {
|
|
1636
|
+
const found = blockSpans(source).get(id);
|
|
1637
|
+
if (!found)
|
|
925
1638
|
fail(`no block with id \`${id}\``, 1);
|
|
926
|
-
const
|
|
1639
|
+
const beforeDoc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1640
|
+
const beforeIds = beforeDoc.ids;
|
|
927
1641
|
// Keep the bytes before and after the target span exactly; give the new block
|
|
928
1642
|
// a single trailing newline so the following block still starts on its own
|
|
929
1643
|
// line (unless it is the file's last line, which may legitimately lack one).
|
|
930
1644
|
const orig = splitLines(source);
|
|
1645
|
+
// `--head`: splice only the id's head line; everything below stays
|
|
1646
|
+
// byte-identical. The guard below still applies — the replacement must
|
|
1647
|
+
// re-declare `{#id}` and, for a typed block, keep the fence pairing intact
|
|
1648
|
+
// (an opening line that no longer matches the untouched close fence breaks
|
|
1649
|
+
// the re-parse), or the splice is refused.
|
|
1650
|
+
const span = headOnly ? narrowToHead(found) : found;
|
|
931
1651
|
const before = orig.slice(0, span.start);
|
|
932
1652
|
const after = orig.slice(span.end);
|
|
933
1653
|
let inject = replacement.replace(/\r\n?/g, "\n");
|
|
@@ -952,9 +1672,20 @@ function spliceBlock(source, id, replacement, file) {
|
|
|
952
1672
|
if (dropped !== undefined) {
|
|
953
1673
|
fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
|
|
954
1674
|
}
|
|
1675
|
+
// For a typed block with a close fence, the body is opaque and swapping it
|
|
1676
|
+
// keeps exactly ONE block. A raw `--body` can embed a `===` fence of the
|
|
1677
|
+
// block's length that closes the target early and turns the remainder — plus
|
|
1678
|
+
// the close line we re-appended — into NEW sibling blocks, including an id-less
|
|
1679
|
+
// `=== meta` that redefines document metadata (the dropped-id check above
|
|
1680
|
+
// cannot see an id-less injection). Guarded callers refuse any count change.
|
|
1681
|
+
// (Not enforced for heading sections / whole-block set, whose replacement may
|
|
1682
|
+
// legitimately span several top-level blocks.)
|
|
1683
|
+
if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
|
|
1684
|
+
fail(`replacement changes the block count (a fence in the body closed #${id} early and injected sibling block(s)?); not written`, 1);
|
|
1685
|
+
}
|
|
955
1686
|
return updated;
|
|
956
1687
|
}
|
|
957
|
-
// `geml revert <file.geml> #id [--
|
|
1688
|
+
// `geml revert <file.geml> #id [--rev <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
|
|
958
1689
|
// Restore ONE block to a past revision's version — a targeted, guarded splice
|
|
959
1690
|
// that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
|
|
960
1691
|
// revisions back from current), `latest`, or an id prefix/suffix. `--changed`
|
|
@@ -964,9 +1695,16 @@ function spliceBlock(source, id, replacement, file) {
|
|
|
964
1695
|
function runRevert(args) {
|
|
965
1696
|
const changed = args.includes("--changed");
|
|
966
1697
|
const dryRun = args.includes("--dry-run");
|
|
1698
|
+
const headOnly = args.includes("--head");
|
|
967
1699
|
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
968
|
-
const to = flag(args, "--
|
|
969
|
-
const
|
|
1700
|
+
const to = flag(args, "--rev") ?? "-1";
|
|
1701
|
+
const before = flag(args, "--before");
|
|
1702
|
+
const after = flag(args, "--after");
|
|
1703
|
+
const append = args.includes("--append");
|
|
1704
|
+
if ((append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0) > 1) {
|
|
1705
|
+
fail("revert takes at most one position: --append | --before #id | --after #id", 2);
|
|
1706
|
+
}
|
|
1707
|
+
const [file, rawId] = positionals(args, ["--rev", "--history", "-o", "--out", "--before", "--after"]);
|
|
970
1708
|
if (!file || !rawId)
|
|
971
1709
|
fail(SUBHELP.revert);
|
|
972
1710
|
if (file === "-")
|
|
@@ -974,21 +1712,25 @@ function runRevert(args) {
|
|
|
974
1712
|
const id = rawId.replace(/^#/, "");
|
|
975
1713
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
976
1714
|
const source = readInput(file);
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
// block
|
|
1715
|
+
const curFull = blockSpans(source).get(id); // undefined => absent now
|
|
1716
|
+
const curBlock = curFull === undefined ? undefined : (() => {
|
|
1717
|
+
const span = headOnly ? narrowToHead(curFull) : curFull;
|
|
1718
|
+
return splitLines(source).slice(span.start, span.end).join("");
|
|
1719
|
+
})();
|
|
1720
|
+
// Extract #id's block from a reconstructed revision (undefined => absent
|
|
1721
|
+
// there). Under `--head`, extract only the head line.
|
|
983
1722
|
const pick = (text) => {
|
|
984
1723
|
const s = blockSpans(text).get(id);
|
|
985
|
-
|
|
1724
|
+
if (!s)
|
|
1725
|
+
return undefined;
|
|
1726
|
+
const span = headOnly ? narrowToHead(s) : s;
|
|
1727
|
+
return splitLines(text).slice(span.start, span.end).join("");
|
|
986
1728
|
};
|
|
987
1729
|
// Resolve the source revision, formatting any history-layer error cleanly.
|
|
988
1730
|
const target = (() => {
|
|
989
1731
|
try {
|
|
990
1732
|
if (changed) {
|
|
991
|
-
const found = firstChangedContent(historyPath, curBlock, pick);
|
|
1733
|
+
const found = firstChangedContent(historyPath, curBlock ?? "", pick);
|
|
992
1734
|
if (!found)
|
|
993
1735
|
fail(`no earlier revision changes \`${id}\``, 1);
|
|
994
1736
|
return found;
|
|
@@ -999,22 +1741,135 @@ function runRevert(args) {
|
|
|
999
1741
|
fail(historyError(e, file, historyPath), 1);
|
|
1000
1742
|
}
|
|
1001
1743
|
})();
|
|
1002
|
-
const oldBlock = pick(target.text);
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1744
|
+
const oldBlock = pick(target.text); // undefined => absent at R
|
|
1745
|
+
// Common write path (bespoke message; -o path redirects; -o - -> stdout).
|
|
1746
|
+
const emit = (updated, verb) => {
|
|
1747
|
+
const dest = out ?? file;
|
|
1748
|
+
if (dest === "-")
|
|
1749
|
+
process.stdout.write(updated);
|
|
1750
|
+
else
|
|
1751
|
+
writeFileSync(dest, updated);
|
|
1752
|
+
console.error(`${verb}${dest === file ? "" : dest === "-" ? " -> stdout" : ` -> ${dest}`}`);
|
|
1753
|
+
};
|
|
1754
|
+
// Reconcile #id between now and revision R across the four presence cells.
|
|
1755
|
+
if (curBlock === undefined && oldBlock === undefined) {
|
|
1756
|
+
fail(`\`${id}\` exists in neither the document nor ${target.id} (try --changed)`, 1);
|
|
1757
|
+
}
|
|
1758
|
+
// both present -> SPLICE (undo set)
|
|
1759
|
+
if (curBlock !== undefined && oldBlock !== undefined) {
|
|
1760
|
+
if (oldBlock === curBlock) {
|
|
1761
|
+
console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --changed)"}`);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
if (dryRun) {
|
|
1765
|
+
console.error(`would revert #${id} to ${target.id}:`);
|
|
1766
|
+
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
emit(spliceBlock(source, id, oldBlock, file, headOnly), `reverted #${id} to ${target.id}`);
|
|
1007
1770
|
return;
|
|
1008
1771
|
}
|
|
1772
|
+
// --head is only meaningful for the splice cell (it can't resurrect or remove).
|
|
1773
|
+
if (headOnly) {
|
|
1774
|
+
fail("--head only applies when the block exists in both the document and the target revision", 2);
|
|
1775
|
+
}
|
|
1776
|
+
// absent now, present at R -> RESURRECT (undo delete)
|
|
1777
|
+
if (curBlock === undefined && oldBlock !== undefined) {
|
|
1778
|
+
// Guard: if the block we'd resurrect is the same (modulo id) as one already
|
|
1779
|
+
// present under a different id, #id was likely renamed away — resurrecting
|
|
1780
|
+
// would duplicate it. Point at `rename` instead of writing.
|
|
1781
|
+
const cmpKey = normalizeBlockId(oldBlock, "__cmp__");
|
|
1782
|
+
for (const [cid, cs] of blockSpans(source)) {
|
|
1783
|
+
if (cid === id)
|
|
1784
|
+
continue;
|
|
1785
|
+
const csrc = splitLines(source).slice(cs.start, cs.end).join("");
|
|
1786
|
+
if (normalizeBlockId(csrc, "__cmp__") === cmpKey) {
|
|
1787
|
+
fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
|
|
1791
|
+
if (dryRun) {
|
|
1792
|
+
console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
|
|
1793
|
+
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
if (warn)
|
|
1797
|
+
console.error(`warning: anchors for #${id} are gone; appended at end`);
|
|
1798
|
+
emit(insertFragment(source, splitLines(source), at, oldBlock, file), `resurrected #${id} from ${target.id} at ${where}`);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
// present now, absent at R -> REMOVE (undo add)
|
|
1802
|
+
// Guard: if the block we'd remove is the same (modulo id) as one present at R
|
|
1803
|
+
// under a different id, #id was likely renamed IN — removing would delete a
|
|
1804
|
+
// renamed block. Point at `rename` instead (the dangerous direction).
|
|
1805
|
+
{
|
|
1806
|
+
const cmpKey = normalizeBlockId(curBlock, "__cmp__");
|
|
1807
|
+
for (const [rid, rs] of blockSpans(target.text)) {
|
|
1808
|
+
if (rid === id)
|
|
1809
|
+
continue;
|
|
1810
|
+
const rsrc = splitLines(target.text).slice(rs.start, rs.end).join("");
|
|
1811
|
+
if (normalizeBlockId(rsrc, "__cmp__") === cmpKey) {
|
|
1812
|
+
fail(`#${id} looks renamed from #${rid}; revert would delete it — use 'rename #${id} #${rid}'`, 1);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1009
1816
|
if (dryRun) {
|
|
1010
|
-
console.error(`would
|
|
1011
|
-
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1817
|
+
console.error(`would remove #${id} (absent at ${target.id})`);
|
|
1012
1818
|
return;
|
|
1013
1819
|
}
|
|
1014
|
-
const
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1820
|
+
const span = curFull;
|
|
1821
|
+
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
1822
|
+
const updated = splitLines(source).filter((_, i) => i < span.start || i >= span.end).join("");
|
|
1823
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1824
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1825
|
+
if (errs.length) {
|
|
1826
|
+
const first = errs[0];
|
|
1827
|
+
fail(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, 1);
|
|
1828
|
+
}
|
|
1829
|
+
const now = new Set(reparsed.ids);
|
|
1830
|
+
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
1831
|
+
if (dropped !== undefined)
|
|
1832
|
+
fail(`removing #${id} would drop block \`#${dropped}\`; not written`, 1);
|
|
1833
|
+
emit(updated, `removed #${id} (absent at ${target.id})`);
|
|
1834
|
+
}
|
|
1835
|
+
// Choose the physical-line insertion point for a resurrected block. Explicit
|
|
1836
|
+
// --append/--before/--after win; otherwise infer from the block's neighbours in
|
|
1837
|
+
// revision R: the nearest id BEFORE it that still exists now (insert after it),
|
|
1838
|
+
// else the nearest id AFTER it that still exists (insert before it), else append
|
|
1839
|
+
// at end (warn=true). The deleted block's own former descendants are absent now
|
|
1840
|
+
// too, so they are naturally skipped as anchors.
|
|
1841
|
+
function resurrectPosition(source, revText, id, before, after, append, file) {
|
|
1842
|
+
const lines = splitLines(source);
|
|
1843
|
+
const here = blockSpans(source);
|
|
1844
|
+
if (append)
|
|
1845
|
+
return { at: lines.length, where: "end", warn: false };
|
|
1846
|
+
if (before !== undefined) {
|
|
1847
|
+
const a = before.replace(/^#/, "");
|
|
1848
|
+
const s = here.get(a);
|
|
1849
|
+
if (!s)
|
|
1850
|
+
fail(`no block with id \`${a}\` in ${file}`, 1);
|
|
1851
|
+
return { at: s.start, where: `before #${a}`, warn: false };
|
|
1852
|
+
}
|
|
1853
|
+
if (after !== undefined) {
|
|
1854
|
+
const a = after.replace(/^#/, "");
|
|
1855
|
+
const s = here.get(a);
|
|
1856
|
+
if (!s)
|
|
1857
|
+
fail(`no block with id \`${a}\` in ${file}`, 1);
|
|
1858
|
+
return { at: s.end, where: `after #${a}`, warn: false };
|
|
1859
|
+
}
|
|
1860
|
+
const revIds = [...blockSpans(revText).keys()];
|
|
1861
|
+
const idx = revIds.indexOf(id);
|
|
1862
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
1863
|
+
const s = here.get(revIds[i]);
|
|
1864
|
+
if (s)
|
|
1865
|
+
return { at: s.end, where: `after #${revIds[i]}`, warn: false };
|
|
1866
|
+
}
|
|
1867
|
+
for (let i = idx + 1; i < revIds.length; i++) {
|
|
1868
|
+
const s = here.get(revIds[i]);
|
|
1869
|
+
if (s)
|
|
1870
|
+
return { at: s.start, where: `before #${revIds[i]}`, warn: false };
|
|
1871
|
+
}
|
|
1872
|
+
return { at: lines.length, where: "end", warn: true };
|
|
1018
1873
|
}
|
|
1019
1874
|
// geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
|
|
1020
1875
|
// package's codemap/ directory (they are argv-driven programs, some
|
|
@@ -1027,6 +1882,7 @@ function runCodemap(args) {
|
|
|
1027
1882
|
render: "render-all.mjs",
|
|
1028
1883
|
serve: "serve.mjs",
|
|
1029
1884
|
refresh: "refresh.mjs",
|
|
1885
|
+
find: "find.mjs",
|
|
1030
1886
|
mcp: "mcp-server.mjs",
|
|
1031
1887
|
};
|
|
1032
1888
|
const sub = args[0] ?? "";
|
|
@@ -1037,10 +1893,28 @@ function runCodemap(args) {
|
|
|
1037
1893
|
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
1038
1894
|
process.exit(r.status ?? 1);
|
|
1039
1895
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1896
|
+
// npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
|
|
1897
|
+
// CLI" by resolving argv[1] to its real path, not by its spelling.
|
|
1898
|
+
const entry = (() => {
|
|
1899
|
+
const argv1 = process.argv[1];
|
|
1900
|
+
if (!argv1)
|
|
1901
|
+
return "";
|
|
1902
|
+
try {
|
|
1903
|
+
return realpathSync(argv1);
|
|
1904
|
+
}
|
|
1905
|
+
catch {
|
|
1906
|
+
return argv1;
|
|
1907
|
+
}
|
|
1908
|
+
})();
|
|
1909
|
+
// `entry` must be non-empty: in a browser bundle both sides degenerate to ""
|
|
1910
|
+
// (esbuild defines process.argv=[] and import.meta.url="", and the node-stub's
|
|
1911
|
+
// fileURLToPath is String()), which would run the CLI at import time and crash
|
|
1912
|
+
// the page. A real CLI invocation always has argv[1].
|
|
1913
|
+
if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.ts"))) {
|
|
1042
1914
|
const argv = process.argv.slice(2);
|
|
1043
|
-
|
|
1915
|
+
// The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
|
|
1916
|
+
// command from the directory name — accept those spellings as `codemap`.
|
|
1917
|
+
const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
|
|
1044
1918
|
jsonMode = argv.includes("--json");
|
|
1045
1919
|
const rest = argv.slice(1);
|
|
1046
1920
|
if (cmd === "--help" || cmd === "-h") {
|
|
@@ -1067,24 +1941,21 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
|
1067
1941
|
else if (cmd === "set") {
|
|
1068
1942
|
runSet(argv.slice(1));
|
|
1069
1943
|
}
|
|
1944
|
+
else if (cmd === "add") {
|
|
1945
|
+
runAdd(argv.slice(1));
|
|
1946
|
+
}
|
|
1947
|
+
else if (cmd === "delete") {
|
|
1948
|
+
runDelete(argv.slice(1));
|
|
1949
|
+
}
|
|
1950
|
+
else if (cmd === "rename") {
|
|
1951
|
+
runRename(argv.slice(1));
|
|
1952
|
+
}
|
|
1070
1953
|
else if (cmd === "revert") {
|
|
1071
1954
|
runRevert(argv.slice(1));
|
|
1072
1955
|
}
|
|
1073
1956
|
else if (cmd === "history") {
|
|
1074
1957
|
runHistory(argv.slice(1));
|
|
1075
1958
|
}
|
|
1076
|
-
else if (cmd === "convert") {
|
|
1077
|
-
runConvert(argv.slice(1));
|
|
1078
|
-
}
|
|
1079
|
-
else if (cmd === "export") {
|
|
1080
|
-
runExport(argv.slice(1));
|
|
1081
|
-
}
|
|
1082
|
-
else if (cmd === "render") {
|
|
1083
|
-
runRender(argv.slice(1));
|
|
1084
|
-
}
|
|
1085
|
-
else if (cmd === "fmt") {
|
|
1086
|
-
runFmt(argv.slice(1));
|
|
1087
|
-
}
|
|
1088
1959
|
else if (cmd === "check") {
|
|
1089
1960
|
runCheck(argv.slice(1));
|
|
1090
1961
|
}
|
|
@@ -1093,14 +1964,13 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
|
1093
1964
|
}
|
|
1094
1965
|
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
1095
1966
|
// A bare word that is neither a known command nor a path is almost always
|
|
1096
|
-
// a mistyped command — say so, don't try to read it as a file.
|
|
1967
|
+
// a mistyped command — say so, don't try to read it as a file. (The
|
|
1968
|
+
// reclaimed verbs render/export/fmt/convert land here too.)
|
|
1097
1969
|
fail(`unknown command '${cmd}'. Run 'geml --help'.`);
|
|
1098
1970
|
}
|
|
1099
1971
|
else {
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
1104
|
-
process.exit(1);
|
|
1972
|
+
// A file (or stdin via '-') is the transform entry: `--to`/`--from`/`-o`,
|
|
1973
|
+
// default `--to json`. The single door for every format conversion.
|
|
1974
|
+
runTransform(argv);
|
|
1105
1975
|
}
|
|
1106
1976
|
}
|