@geml/geml 1.3.2 → 1.4.3
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/README.md +91 -14
- package/codemap/build.mjs +31 -1
- package/codemap/cross-stack.mjs +303 -0
- package/codemap/emit.mjs +48 -0
- package/codemap/normalize.mjs +4 -1
- package/codemap/verify.mjs +11 -6
- package/dist/block-edit.d.ts +1 -0
- package/dist/block-edit.js +112 -0
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +2 -5
- package/dist/geml.js +904 -213
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +528 -0
- package/dist/render.d.ts +2 -2
- package/dist/render.js +126 -43
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -63
package/dist/geml.js
CHANGED
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
// `meta` data block, ATX headings, lists and paragraphs, the attribute object
|
|
6
6
|
// with §4 value typing, and a document-model JSON serialization.
|
|
7
7
|
//
|
|
8
|
-
// M2: inline parsing of
|
|
9
|
-
// media embeds, links, auto-references, footnotes) and build-time
|
|
10
|
-
// validation (§8 — unique ids, resolvable internal/cross-document
|
|
11
|
-
|
|
8
|
+
// M2: inline parsing of unfenced blocks (§5 — emphasis/strong/strike, code,
|
|
9
|
+
// math, media embeds, links, auto-references, footnotes) and build-time
|
|
10
|
+
// reference validation (§8 — unique ids, resolvable internal/cross-document
|
|
11
|
+
// references).
|
|
12
|
+
import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
|
|
12
13
|
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import { spawnSync } from "node:child_process";
|
|
15
16
|
import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
16
17
|
import { renderHtml } from "./render-html.js";
|
|
18
|
+
import { normalizeBlockId } from "./block-edit.js";
|
|
19
|
+
import { normalizeSource } from "./diagnostics.js";
|
|
17
20
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
18
21
|
import { META_REF_SRC, parseInline } from "./inline.js";
|
|
19
22
|
import { parseTable } from "./table.js";
|
|
@@ -25,6 +28,17 @@ export { mdToGeml } from "./from-md.js";
|
|
|
25
28
|
export { renderHtml } from "./render-html.js";
|
|
26
29
|
export { serialize } from "./serialize.js";
|
|
27
30
|
export { gemlToMd } from "./to-md.js";
|
|
31
|
+
// A block id is any non-whitespace run (§4), so it may contain regex
|
|
32
|
+
// metacharacters. Every place that builds a RegExp from an id MUST run it
|
|
33
|
+
// through this first, or a crafted id (`#a(`, `#(x+x+)+y`) turns a labeled-close
|
|
34
|
+
// or reference match into an uncaught `SyntaxError` or a ReDoS on the main
|
|
35
|
+
// parse path (SEC: document-controlled RegExp injection).
|
|
36
|
+
function reLit(s) {
|
|
37
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
}
|
|
39
|
+
// Re-exported from ./diagnostics.js so that `Diagnostic` stays importable from
|
|
40
|
+
// the package root. The catalogue of codes lives there (spec Appendix A).
|
|
41
|
+
export { SEVERITY } from "./diagnostics.js";
|
|
28
42
|
// Type registry: which body mode each typed block uses. Unknown types are a
|
|
29
43
|
// warning and fall back to `raw` (forward compatibility, §3/§8).
|
|
30
44
|
const REGISTRY = {
|
|
@@ -118,7 +132,7 @@ function interpolate(text, line, ctx) {
|
|
|
118
132
|
if (ctx.meta.has(key))
|
|
119
133
|
out += ctx.meta.get(key);
|
|
120
134
|
else {
|
|
121
|
-
ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
|
|
135
|
+
ctx.diags.push({ severity: "error", code: "unknown-metadata-reference", message: `unknown metadata reference \`{{${key}}}\``, line });
|
|
122
136
|
out += m[0];
|
|
123
137
|
}
|
|
124
138
|
i = META_REF.lastIndex;
|
|
@@ -133,7 +147,7 @@ function interpolate(text, line, ctx) {
|
|
|
133
147
|
// Register a block id, flagging duplicates as errors (§4: ids unique per doc).
|
|
134
148
|
function registerId(ctx, id, line) {
|
|
135
149
|
if (ctx.ids.has(id)) {
|
|
136
|
-
ctx.diags.push({ severity: "error", message: `duplicate id \`#${id}\` (first defined at line ${ctx.ids.get(id)})`, line });
|
|
150
|
+
ctx.diags.push({ severity: "error", code: "duplicate-id", message: `duplicate id \`#${id}\` (first defined at line ${ctx.ids.get(id)})`, line });
|
|
137
151
|
}
|
|
138
152
|
else {
|
|
139
153
|
ctx.ids.set(id, line);
|
|
@@ -203,7 +217,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
203
217
|
// rather than building a model that overflows the renderer (DoS). One
|
|
204
218
|
// diagnostic per over-deep list; content is preserved, just flattened.
|
|
205
219
|
if (!tooDeep) {
|
|
206
|
-
ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
|
|
220
|
+
ctx.diags.push({ severity: "error", code: "list-nesting-too-deep", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
|
|
207
221
|
tooDeep = true;
|
|
208
222
|
}
|
|
209
223
|
cur = top.list;
|
|
@@ -276,7 +290,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
276
290
|
// of any length ≥ 3 followed by the block's id). The labeled close is a
|
|
277
291
|
// *local* close: it can't be gotten wrong by miscounting `=`, so it is the
|
|
278
292
|
// safe way to nest (§3).
|
|
279
|
-
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${attrs.id}[ \\t]*$`) : null;
|
|
293
|
+
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
|
|
280
294
|
const body = [];
|
|
281
295
|
let j = i + 1;
|
|
282
296
|
let closed = false;
|
|
@@ -289,11 +303,11 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
289
303
|
}
|
|
290
304
|
if (!closed) {
|
|
291
305
|
const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
|
|
292
|
-
diags.push({ severity: "error", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
|
|
306
|
+
diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
|
|
293
307
|
}
|
|
294
308
|
let mode = REGISTRY[type];
|
|
295
309
|
if (mode === undefined) {
|
|
296
|
-
diags.push({ severity: "warning", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
|
|
310
|
+
diags.push({ severity: "warning", code: "unknown-block-type", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
|
|
297
311
|
mode = "raw";
|
|
298
312
|
}
|
|
299
313
|
const block = {
|
|
@@ -317,7 +331,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
317
331
|
// Refuse to recurse past the cap: emit a diagnostic and keep the body
|
|
318
332
|
// as raw so the parser returns cleanly instead of overflowing the
|
|
319
333
|
// call stack on a pathologically nested document (DoS).
|
|
320
|
-
diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
|
|
334
|
+
diags.push({ severity: "error", code: "block-nesting-too-deep", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
|
|
321
335
|
block.raw = body;
|
|
322
336
|
}
|
|
323
337
|
else {
|
|
@@ -347,7 +361,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
347
361
|
// §7: native chart — resolved in a second pass (data=#id may be
|
|
348
362
|
// defined later in the document).
|
|
349
363
|
if (body.length > 0 && body.some((l) => l.trim() !== "")) {
|
|
350
|
-
diags.push({ severity: "warning", message: "geml-chart body is ignored; the chart spec lives in attributes", line: openLineNo });
|
|
364
|
+
diags.push({ severity: "warning", code: "ignored-diagram-body", message: "geml-chart body is ignored; the chart spec lives in attributes", line: openLineNo });
|
|
351
365
|
}
|
|
352
366
|
(ctx.charts ??= []).push({ block, line: openLineNo });
|
|
353
367
|
}
|
|
@@ -357,18 +371,18 @@ function scanBlocks(lines, base, ctx, depth = 0) {
|
|
|
357
371
|
// ("view config travels with the data"). Body is empty.
|
|
358
372
|
const src = attrs.attrs["src"];
|
|
359
373
|
if (typeof src !== "string" || src === "") {
|
|
360
|
-
diags.push({ severity: "warning", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
|
|
374
|
+
diags.push({ severity: "warning", code: "code-graph-missing-src", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
|
|
361
375
|
}
|
|
362
376
|
else if (ctx.resolveDoc && ctx.resolveDoc(src) === null) {
|
|
363
|
-
diags.push({ severity: "warning", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
|
|
377
|
+
diags.push({ severity: "warning", code: "code-graph-unresolvable-document", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
|
|
364
378
|
}
|
|
365
379
|
if (body.length > 0 && body.some((l) => l.trim() !== "")) {
|
|
366
|
-
diags.push({ severity: "warning", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
|
|
380
|
+
diags.push({ severity: "warning", code: "ignored-diagram-body", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
|
|
367
381
|
}
|
|
368
382
|
}
|
|
369
383
|
else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
|
|
370
384
|
// §7: warn on a diagram format with no registered renderer.
|
|
371
|
-
diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
385
|
+
diags.push({ severity: "warning", code: "unknown-diagram-format", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
372
386
|
}
|
|
373
387
|
}
|
|
374
388
|
}
|
|
@@ -436,7 +450,7 @@ function parseData(lines) {
|
|
|
436
450
|
// resolving `other.geml#id` references.
|
|
437
451
|
function gatherIds(source) {
|
|
438
452
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
|
|
439
|
-
scanBlocks(source
|
|
453
|
+
scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
|
|
440
454
|
return new Set(ctx.ids.keys());
|
|
441
455
|
}
|
|
442
456
|
// Pre-scan for `=== meta` blocks (at any fence depth) and merge their
|
|
@@ -468,14 +482,14 @@ function validateRefs(ctx, opts) {
|
|
|
468
482
|
if (!ref.doc)
|
|
469
483
|
continue;
|
|
470
484
|
if (!opts.resolveDoc) {
|
|
471
|
-
ctx.diags.push({ severity: "warning", message: `cross-document reference \`${ref.doc}${ref.anchor ? "#" + ref.anchor : ""}\` not checked (no document resolver)`, line: ref.line });
|
|
485
|
+
ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `cross-document reference \`${ref.doc}${ref.anchor ? "#" + ref.anchor : ""}\` not checked (no document resolver)`, line: ref.line });
|
|
472
486
|
continue;
|
|
473
487
|
}
|
|
474
488
|
let ids = docIds.get(ref.doc);
|
|
475
489
|
if (ids === undefined) {
|
|
476
490
|
const src = opts.resolveDoc(ref.doc);
|
|
477
491
|
if (src === null) {
|
|
478
|
-
ctx.diags.push({ severity: "error", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
|
|
492
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
|
|
479
493
|
docIds.set(ref.doc, new Set());
|
|
480
494
|
continue;
|
|
481
495
|
}
|
|
@@ -483,14 +497,16 @@ function validateRefs(ctx, opts) {
|
|
|
483
497
|
docIds.set(ref.doc, ids);
|
|
484
498
|
}
|
|
485
499
|
if (ref.anchor !== undefined && !ids.has(ref.anchor)) {
|
|
486
|
-
ctx.diags.push({ severity: "error", message: `unresolved reference \`${ref.doc}#${ref.anchor}\``, line: ref.line });
|
|
500
|
+
ctx.diags.push({ severity: "error", code: "unresolved-cross-document-reference", message: `unresolved reference \`${ref.doc}#${ref.anchor}\``, line: ref.line });
|
|
487
501
|
}
|
|
488
502
|
continue;
|
|
489
503
|
}
|
|
490
504
|
// internal, autoref, footnote — anchor must be a known id in this document.
|
|
491
505
|
if (ref.anchor !== undefined && !ctx.ids.has(ref.anchor)) {
|
|
492
|
-
const
|
|
493
|
-
|
|
506
|
+
const footnote = ref.kind === "footnote";
|
|
507
|
+
const what = footnote ? `footnote \`[^${ref.anchor}]\`` : `reference \`#${ref.anchor}\``;
|
|
508
|
+
const code = footnote ? "unresolved-footnote" : "unresolved-reference";
|
|
509
|
+
ctx.diags.push({ severity: "error", code, message: `unresolved ${what}`, line: ref.line });
|
|
494
510
|
}
|
|
495
511
|
}
|
|
496
512
|
}
|
|
@@ -501,13 +517,15 @@ function resolveCharts(ctx) {
|
|
|
501
517
|
const ref = typeof block.attrs["data"] === "string" ? block.attrs["data"] : "";
|
|
502
518
|
const id = ref.replace(/^#/, "");
|
|
503
519
|
if (id === "") {
|
|
504
|
-
ctx.diags.push({ severity: "error", message: "geml-chart: missing `data=#id`", line });
|
|
520
|
+
ctx.diags.push({ severity: "error", code: "chart-missing-data", message: "geml-chart: missing `data=#id`", line });
|
|
505
521
|
continue;
|
|
506
522
|
}
|
|
507
523
|
const table = ctx.tables?.get(id);
|
|
508
524
|
if (!table) {
|
|
509
|
-
const
|
|
510
|
-
|
|
525
|
+
const known = ctx.ids.has(id);
|
|
526
|
+
const what = known ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
|
|
527
|
+
const code = known ? "chart-data-not-a-table" : "unresolved-reference";
|
|
528
|
+
ctx.diags.push({ severity: "error", code, message: `geml-chart: ${what}`, line });
|
|
511
529
|
continue;
|
|
512
530
|
}
|
|
513
531
|
if (table.src !== undefined) {
|
|
@@ -524,7 +542,7 @@ function resolveCharts(ctx) {
|
|
|
524
542
|
}
|
|
525
543
|
}
|
|
526
544
|
export function parse(source, opts = {}) {
|
|
527
|
-
const lines = source
|
|
545
|
+
const lines = normalizeSource(source).split("\n");
|
|
528
546
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
|
|
529
547
|
const children = scanBlocks(lines, 0, ctx);
|
|
530
548
|
resolveCharts(ctx);
|
|
@@ -548,7 +566,7 @@ function idOfHeading(braces, text, line, ctx) {
|
|
|
548
566
|
function fenceClose(lines, i, open) {
|
|
549
567
|
const openLen = open[1].length;
|
|
550
568
|
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
551
|
-
const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
|
|
569
|
+
const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(id)}[ \\t]*$`) : null;
|
|
552
570
|
for (let j = i + 1; j < lines.length; j++) {
|
|
553
571
|
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
|
|
554
572
|
return { end: j + 1, closed: true };
|
|
@@ -635,7 +653,7 @@ function collectSpans(lines, base, out, ctx, depth = 0) {
|
|
|
635
653
|
// with the physical lines produced by splitLines(source).
|
|
636
654
|
export function blockSpans(source) {
|
|
637
655
|
const out = new Map();
|
|
638
|
-
const lines = source
|
|
656
|
+
const lines = normalizeSource(source).split("\n");
|
|
639
657
|
// Inert context: heading auto-ids slug the interpolated text (parser parity);
|
|
640
658
|
// its diagnostics are discarded — the span scan never reports.
|
|
641
659
|
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
@@ -719,52 +737,86 @@ function parseStamp(s) {
|
|
|
719
737
|
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
720
738
|
}
|
|
721
739
|
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
722
|
-
const PARSER_VERSION = "1.3
|
|
723
|
-
const USAGE = `geml — GEML reference CLI
|
|
724
|
-
|
|
725
|
-
Usage:
|
|
726
|
-
geml <file.geml|->
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
geml
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
740
|
+
const PARSER_VERSION = "1.4.3"; // reference implementation; keep in sync with package.json
|
|
741
|
+
const USAGE = `geml — GEML reference CLI
|
|
742
|
+
|
|
743
|
+
Usage:
|
|
744
|
+
geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
|
|
745
|
+
--to <output>: json | html | md | geml
|
|
746
|
+
--to md -> Markdown (lossy)
|
|
747
|
+
--to html -> self-contained HTML
|
|
748
|
+
--to geml -> canonical re-format
|
|
749
|
+
--to json -> document-model JSON (default)
|
|
750
|
+
--from <input>: geml | md | json (overrides extension; html is output-only)
|
|
751
|
+
geml notes.md -> GEML (md inferred from extension)
|
|
752
|
+
geml model.json --to geml -> GEML (round-trips a prior --to json)
|
|
753
|
+
geml - --from md read Markdown on stdin
|
|
754
|
+
geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
|
|
755
|
+
(a heading id = its whole section; --head = head line;
|
|
756
|
+
--json = model node). Without #id: list all addressable
|
|
757
|
+
ids (--json = array).
|
|
758
|
+
geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
759
|
+
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
760
|
+
default = whole block · --head = head line · --body = body)
|
|
761
|
+
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
762
|
+
(1+ blocks and/or prose; content keeps its own ids, a clash is refused)
|
|
763
|
+
geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
|
|
764
|
+
(a missing id is skipped; a dangling reference is a warning, not a refusal)
|
|
765
|
+
geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
|
|
766
|
+
geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
|
|
767
|
+
(sel: 0 | -N | id-prefix | changed; default -1)
|
|
768
|
+
geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
|
|
769
|
+
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
770
|
+
geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
|
|
771
|
+
geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
|
|
772
|
+
geml mcp --workspace <dir> [--no-history] serve document CRUD over MCP (stdio)
|
|
773
|
+
(9 tools: list/read/check/history + write/add/delete/rename/revert;
|
|
774
|
+
every write is validated before it reaches disk)
|
|
775
|
+
geml --help | --version [--json]
|
|
776
|
+
|
|
777
|
+
Use '-' as the file to read from stdin.
|
|
778
|
+
Mutations (set/add/delete/rename) write the whole updated document in place for a
|
|
779
|
+
file, or to stdout for '-' input; -o redirects it (-o - = stdout).
|
|
780
|
+
Exit codes:
|
|
781
|
+
0 ok
|
|
782
|
+
1 document/operation error
|
|
783
|
+
2 command usage error.
|
|
746
784
|
`;
|
|
747
785
|
// One-line usage for each subcommand — the single source for both the error
|
|
748
786
|
// shown on misuse and the `<cmd> --help` text.
|
|
749
787
|
const SUBHELP = {
|
|
750
|
-
get: "usage: geml get <file.geml|-> #id [--json] [--head] (a heading id = its whole section
|
|
751
|
-
set: "usage: geml set <file.geml|-> #id [--
|
|
788
|
+
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)",
|
|
789
|
+
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)",
|
|
790
|
+
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)",
|
|
791
|
+
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)",
|
|
792
|
+
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)",
|
|
752
793
|
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
753
|
-
|
|
754
|
-
convert: "usage: geml convert <file.md|-> [-o out.geml]",
|
|
755
|
-
export: "usage: geml export <file.geml|-> [-o out.md]",
|
|
756
|
-
fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
|
|
757
|
-
revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--head] (sel: -N | latest | id-prefix; default -1)",
|
|
794
|
+
revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
|
|
758
795
|
history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
|
|
759
|
-
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)
|
|
760
|
-
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]]
|
|
761
|
-
geml codemap verify [dir] geml check + profile reference checks
|
|
762
|
-
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
763
|
-
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
|
|
764
|
-
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
765
|
-
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
766
|
-
geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
|
|
796
|
+
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)
|
|
797
|
+
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]]
|
|
798
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
799
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
800
|
+
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
|
|
801
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
802
|
+
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
803
|
+
geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
|
|
767
804
|
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
805
|
+
mcp: `usage: geml mcp --workspace <dir> [--no-history]
|
|
806
|
+
|
|
807
|
+
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
808
|
+
Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
|
|
809
|
+
geml_write_block · geml_add_block · geml_delete_block
|
|
810
|
+
geml_rename_id · geml_revert_block
|
|
811
|
+
|
|
812
|
+
--workspace <dir> REQUIRED. Root holding the .geml documents. Every path a
|
|
813
|
+
client names is confined here; a client cannot widen it.
|
|
814
|
+
--no-history Skip the .gemlhistory commit taken before each write
|
|
815
|
+
(default: commit, so geml_revert_block always has a
|
|
816
|
+
revision to undo to).
|
|
817
|
+
|
|
818
|
+
Register with a client:
|
|
819
|
+
claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs`,
|
|
768
820
|
};
|
|
769
821
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
770
822
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
@@ -779,6 +831,19 @@ function fail(msg, code = 2) {
|
|
|
779
831
|
console.error(`error: ${msg}`);
|
|
780
832
|
process.exit(code);
|
|
781
833
|
}
|
|
834
|
+
// Refuse a mutation whose RESULT would be broken (the pre-write check every
|
|
835
|
+
// mutation runs). Prose mode is the long-standing wording: the first error,
|
|
836
|
+
// phrased by the call site. `--json` additionally carries the FULL diagnostic
|
|
837
|
+
// list with the stable codes of spec Appendix A, so a programmatic caller —
|
|
838
|
+
// `geml mcp` above all — reports what actually broke instead of re-parsing
|
|
839
|
+
// English out of stderr.
|
|
840
|
+
function refuseBroken(prose, errs) {
|
|
841
|
+
if (jsonMode) {
|
|
842
|
+
console.error(JSON.stringify({ error: prose, code: 1, diagnostics: errs }));
|
|
843
|
+
process.exit(1);
|
|
844
|
+
}
|
|
845
|
+
fail(prose, 1);
|
|
846
|
+
}
|
|
782
847
|
// Read a file, or stdin when the path is "-". On failure emit a clean error.
|
|
783
848
|
function readInput(file) {
|
|
784
849
|
try {
|
|
@@ -942,10 +1007,10 @@ function runHistory(args) {
|
|
|
942
1007
|
console.log(`restored ${file} to ${rev}`);
|
|
943
1008
|
}
|
|
944
1009
|
else if (sub === "log") {
|
|
945
|
-
// Newest-first, with the `--
|
|
946
|
-
// (`
|
|
1010
|
+
// Newest-first, with the `--rev` selector for each row in the first column
|
|
1011
|
+
// (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
|
|
947
1012
|
for (const r of listRevisions(historyPath)) {
|
|
948
|
-
const sel = r.current ? "
|
|
1013
|
+
const sel = r.current ? "0" : `-${r.offset}`;
|
|
949
1014
|
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
950
1015
|
}
|
|
951
1016
|
}
|
|
@@ -957,39 +1022,98 @@ function runHistory(args) {
|
|
|
957
1022
|
fail(historyError(e, file, historyPath));
|
|
958
1023
|
}
|
|
959
1024
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const
|
|
1025
|
+
function runTransform(argv) {
|
|
1026
|
+
const out = flag(argv, "-o") ?? flag(argv, "--out");
|
|
1027
|
+
const fromRaw = flag(argv, "--from");
|
|
1028
|
+
const toRaw = flag(argv, "--to");
|
|
1029
|
+
const [file] = positionals(argv, ["-o", "--out", "--from", "--to"]);
|
|
963
1030
|
if (!file)
|
|
964
|
-
fail(
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1031
|
+
fail("no input file (use '-' to read from stdin)", 2);
|
|
1032
|
+
// A bare `--to`/`--from` (no following value) is a mistyped flag, not a
|
|
1033
|
+
// silent fall-through to the default — flag() would return undefined and we
|
|
1034
|
+
// must not quietly ignore it.
|
|
1035
|
+
if (argv.includes("--from") && fromRaw === undefined)
|
|
1036
|
+
fail("--from needs a format (geml | md | json)", 2);
|
|
1037
|
+
if (argv.includes("--to") && toRaw === undefined)
|
|
1038
|
+
fail("--to needs a format (json | html | md | geml)", 2);
|
|
1039
|
+
// Input format: an explicit --from wins (for any input, file or stdin), else
|
|
1040
|
+
// the file extension, else GEML (covers .geml, unknown extensions, and stdin).
|
|
1041
|
+
let inFmt;
|
|
1042
|
+
if (fromRaw !== undefined) {
|
|
1043
|
+
if (fromRaw !== "geml" && fromRaw !== "md" && fromRaw !== "json") {
|
|
1044
|
+
fail(`--from: unknown input format '${fromRaw}' (want geml | md | json)`, 2);
|
|
1045
|
+
}
|
|
1046
|
+
inFmt = fromRaw;
|
|
1047
|
+
}
|
|
1048
|
+
else if (/\.(md|markdown)$/i.test(file)) {
|
|
1049
|
+
inFmt = "md";
|
|
1050
|
+
}
|
|
1051
|
+
else if (/\.json$/i.test(file)) {
|
|
1052
|
+
inFmt = "json";
|
|
972
1053
|
}
|
|
973
1054
|
else {
|
|
974
|
-
|
|
1055
|
+
inFmt = "geml";
|
|
975
1056
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (!file)
|
|
984
|
-
fail(SUBHELP.export);
|
|
985
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
986
|
-
const { md, notes } = gemlToMd(doc);
|
|
987
|
-
if (out) {
|
|
988
|
-
writeFileSync(out, md);
|
|
989
|
-
console.error(`wrote ${out}`);
|
|
1057
|
+
// Output format: an explicit --to wins, else md input -> geml, geml -> json.
|
|
1058
|
+
let outFmt;
|
|
1059
|
+
if (toRaw !== undefined) {
|
|
1060
|
+
if (toRaw !== "json" && toRaw !== "html" && toRaw !== "md" && toRaw !== "geml") {
|
|
1061
|
+
fail(`--to: unknown output format '${toRaw}' (want json | html | md | geml)`, 2);
|
|
1062
|
+
}
|
|
1063
|
+
outFmt = toRaw;
|
|
990
1064
|
}
|
|
991
|
-
else
|
|
992
|
-
|
|
1065
|
+
else {
|
|
1066
|
+
outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
|
|
1067
|
+
}
|
|
1068
|
+
const src = readInput(file);
|
|
1069
|
+
// md -> geml is a direct projection, not a parse/serialize round-trip: emit
|
|
1070
|
+
// the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
|
|
1071
|
+
if (inFmt === "md" && outFmt === "geml") {
|
|
1072
|
+
const { geml, notes } = mdToGeml(src);
|
|
1073
|
+
writeOut(geml, out);
|
|
1074
|
+
for (const n of notes)
|
|
1075
|
+
console.error(`note: ${n}`);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
// Otherwise load a document — a md input is converted to GEML first — and
|
|
1079
|
+
// project it to the target.
|
|
1080
|
+
let notes = [];
|
|
1081
|
+
let doc;
|
|
1082
|
+
if (inFmt === "json") {
|
|
1083
|
+
doc = loadModelJson(src, file); // the inverse of `--to json`
|
|
1084
|
+
}
|
|
1085
|
+
else if (inFmt === "md") {
|
|
1086
|
+
const conv = mdToGeml(src);
|
|
1087
|
+
notes = conv.notes;
|
|
1088
|
+
doc = parse(conv.geml, { resolveDoc: resolverFor(file) });
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
doc = parse(src, { resolveDoc: resolverFor(file) });
|
|
1092
|
+
}
|
|
1093
|
+
let output;
|
|
1094
|
+
switch (outFmt) {
|
|
1095
|
+
case "json":
|
|
1096
|
+
output = JSON.stringify(doc, null, 2) + "\n"; // == the former bare parse
|
|
1097
|
+
break;
|
|
1098
|
+
case "geml":
|
|
1099
|
+
output = serialize(doc); // == the former `fmt`
|
|
1100
|
+
break;
|
|
1101
|
+
case "html":
|
|
1102
|
+
output = renderHtml(doc, {
|
|
1103
|
+
source: file === "-" ? "stdin" : basename(file),
|
|
1104
|
+
// geml-code-graph embeds load + parse sibling codemap docs on demand.
|
|
1105
|
+
loadDoc: resolverFor(file),
|
|
1106
|
+
parseDoc: (s) => parse(s),
|
|
1107
|
+
});
|
|
1108
|
+
break;
|
|
1109
|
+
case "md": {
|
|
1110
|
+
const r = gemlToMd(doc); // == the former `export`
|
|
1111
|
+
notes = notes.concat(r.notes);
|
|
1112
|
+
output = r.md;
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
writeOut(output, out);
|
|
993
1117
|
for (const n of notes)
|
|
994
1118
|
console.error(`note: ${n}`);
|
|
995
1119
|
for (const d of doc.diagnostics)
|
|
@@ -997,55 +1121,57 @@ function runExport(args) {
|
|
|
997
1121
|
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
998
1122
|
process.exit(1);
|
|
999
1123
|
}
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
1010
|
-
const html = renderHtml(doc, {
|
|
1011
|
-
source: file === "-" ? "stdin" : basename(file),
|
|
1012
|
-
// geml-code-graph embeds load + parse sibling codemap documents on demand.
|
|
1013
|
-
loadDoc: resolverFor(file),
|
|
1014
|
-
parseDoc: (s) => parse(s),
|
|
1015
|
-
});
|
|
1016
|
-
if (out) {
|
|
1017
|
-
writeFileSync(out, html);
|
|
1018
|
-
console.error(`wrote ${out}`);
|
|
1124
|
+
// Load a document-model JSON (the exact output of `--to json`) back into a
|
|
1125
|
+
// Document, so `--from json --to geml` is the inverse of a prior `--to json`.
|
|
1126
|
+
// The model is trusted as-is — no re-parse — so a clean round-trip is byte-stable
|
|
1127
|
+
// with `--to geml`. Anything that is not a document model is refused, and any
|
|
1128
|
+
// carried diagnostics are preserved (so a broken doc's JSON stays flagged).
|
|
1129
|
+
function loadModelJson(src, file) {
|
|
1130
|
+
let obj;
|
|
1131
|
+
try {
|
|
1132
|
+
obj = JSON.parse(src);
|
|
1019
1133
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
if (
|
|
1025
|
-
|
|
1134
|
+
catch (e) {
|
|
1135
|
+
fail(`--from json: ${file === "-" ? "stdin" : file} is not valid JSON (${e.message})`, 1);
|
|
1136
|
+
}
|
|
1137
|
+
const d = obj;
|
|
1138
|
+
if (!d || typeof d !== "object" || d.kind !== "document" || !Array.isArray(d.children)) {
|
|
1139
|
+
fail(`--from json: not a GEML document-model JSON (expected {"kind":"document","children":[…]})`, 1);
|
|
1140
|
+
}
|
|
1141
|
+
const doc = d;
|
|
1142
|
+
if (!Array.isArray(doc.diagnostics))
|
|
1143
|
+
doc.diagnostics = [];
|
|
1144
|
+
return doc;
|
|
1026
1145
|
}
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
// pretty-printer whose output parses back to the same model (round-trip stable).
|
|
1030
|
-
function runFmt(args) {
|
|
1031
|
-
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1032
|
-
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
|
|
1033
|
-
if (!file)
|
|
1034
|
-
fail(SUBHELP.fmt);
|
|
1035
|
-
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
1036
|
-
const text = serialize(doc);
|
|
1146
|
+
// Write to `-o out` (with a `wrote` note on stderr) or to stdout.
|
|
1147
|
+
function writeOut(text, out) {
|
|
1037
1148
|
if (out) {
|
|
1038
1149
|
writeFileSync(out, text);
|
|
1039
1150
|
console.error(`wrote ${out}`);
|
|
1040
1151
|
}
|
|
1041
1152
|
else
|
|
1042
1153
|
process.stdout.write(text);
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1154
|
+
}
|
|
1155
|
+
// Output-target rule shared by the MUTATION verbs (set, and — soon — add,
|
|
1156
|
+
// delete, rename, revert): a real file input with no `-o` is edited IN PLACE
|
|
1157
|
+
// (it's the obvious target, and it's what lets an agent chain edits without
|
|
1158
|
+
// re-reading a path back out of stdout); stdin (`file === "-"`) has no such
|
|
1159
|
+
// target, so it falls back to stdout. `-o` always wins when given: `-o -`
|
|
1160
|
+
// explicitly requests stdout (even for a file input), `-o <path>` writes
|
|
1161
|
+
// there. Every write announces itself with `wrote <path>` on stderr; stdout
|
|
1162
|
+
// stays reserved for the document bytes so it's still pipeable.
|
|
1163
|
+
function resolveOutTarget(file, oFlag) {
|
|
1164
|
+
const toFile = (path) => ({
|
|
1165
|
+
write(text) { writeFileSync(path, text); console.error(`wrote ${path}`); },
|
|
1166
|
+
});
|
|
1167
|
+
const toStdout = { write(text) { process.stdout.write(text); } };
|
|
1168
|
+
if (oFlag === "-")
|
|
1169
|
+
return toStdout;
|
|
1170
|
+
if (oFlag !== undefined)
|
|
1171
|
+
return toFile(oFlag);
|
|
1172
|
+
if (file === "-")
|
|
1173
|
+
return toStdout;
|
|
1174
|
+
return toFile(file);
|
|
1049
1175
|
}
|
|
1050
1176
|
// Positional args (a file, an id) are the non-flag tokens that aren't the value
|
|
1051
1177
|
// of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
|
|
@@ -1069,6 +1195,47 @@ function positionals(args, valued) {
|
|
|
1069
1195
|
}
|
|
1070
1196
|
return out;
|
|
1071
1197
|
}
|
|
1198
|
+
// `geml get <file>` with no id: list every addressable id — the document's
|
|
1199
|
+
// table of contents. Default output is one id per line with its kind (and, for
|
|
1200
|
+
// a heading, its level and text); `--json` is a machine-readable array so an
|
|
1201
|
+
// agent can pick its next `get #id` target. Ids are listed in document order
|
|
1202
|
+
// (the registration order parse() records), covering the same set `get #id`
|
|
1203
|
+
// resolves against: typed blocks, headings, and footnote definitions.
|
|
1204
|
+
function listIds(source, file, json) {
|
|
1205
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1206
|
+
const rows = doc.ids.map((id) => {
|
|
1207
|
+
const site = findBlockSite(doc.children, id);
|
|
1208
|
+
const b = site?.siblings[site.index];
|
|
1209
|
+
if (b?.kind === "heading")
|
|
1210
|
+
return { id, kind: "heading", level: b.level, text: b.text };
|
|
1211
|
+
if (b?.kind === "block") {
|
|
1212
|
+
const row = { id, kind: b.type };
|
|
1213
|
+
if (b.classes.includes("footnote"))
|
|
1214
|
+
row.footnote = true; // §5.2 footnote definition
|
|
1215
|
+
return row;
|
|
1216
|
+
}
|
|
1217
|
+
return { id, kind: b?.kind ?? "unknown" };
|
|
1218
|
+
});
|
|
1219
|
+
if (json) {
|
|
1220
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (rows.length === 0) {
|
|
1224
|
+
console.error(`no addressable ids in ${file === "-" ? "stdin" : file}`);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
// Align the id and kind columns; append a heading's level+text or a footnote flag.
|
|
1228
|
+
const idW = Math.max(...rows.map((r) => r.id.length + 1));
|
|
1229
|
+
const kindW = Math.max(...rows.map((r) => r.kind.length));
|
|
1230
|
+
for (const r of rows) {
|
|
1231
|
+
let line = `#${r.id}`.padEnd(idW + 1) + " " + r.kind.padEnd(kindW);
|
|
1232
|
+
if (r.kind === "heading")
|
|
1233
|
+
line += ` h${r.level} ${r.text}`;
|
|
1234
|
+
else if (r.footnote)
|
|
1235
|
+
line += " footnote";
|
|
1236
|
+
console.log(line.replace(/\s+$/, ""));
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1072
1239
|
// `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
|
|
1073
1240
|
// without loading the rest of the document into context. Default output is the
|
|
1074
1241
|
// block's exact source bytes: a typed block's full `=== … ===` span, a
|
|
@@ -1081,8 +1248,14 @@ function runGet(args) {
|
|
|
1081
1248
|
const json = args.includes("--json");
|
|
1082
1249
|
const headOnly = args.includes("--head");
|
|
1083
1250
|
const [file, rawId] = positionals(args, []);
|
|
1084
|
-
if (!file
|
|
1251
|
+
if (!file)
|
|
1085
1252
|
fail(SUBHELP.get);
|
|
1253
|
+
// No id: list every addressable id — the document's "table of contents", so
|
|
1254
|
+
// an agent can discover what `get #id` can target without pulling the model.
|
|
1255
|
+
if (!rawId) {
|
|
1256
|
+
listIds(readInput(file), file, json);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1086
1259
|
const id = rawId.replace(/^#/, "");
|
|
1087
1260
|
const source = readInput(file);
|
|
1088
1261
|
if (json) {
|
|
@@ -1118,42 +1291,407 @@ function runGet(args) {
|
|
|
1118
1291
|
const span = headOnly ? narrowToHead(found) : found;
|
|
1119
1292
|
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
1120
1293
|
}
|
|
1121
|
-
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1294
|
+
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
1295
|
+
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
1296
|
+
// replace ONE existing block, addressed by #id, with new content, preserving
|
|
1297
|
+
// every other byte. Two content CHANNELS × three MODES:
|
|
1298
|
+
//
|
|
1299
|
+
// channels · `--in F[#src]` extracts a BLOCK from GEML file F (F is always
|
|
1300
|
+
// read as GEML — extension ignored, no md conversion): `--in F`
|
|
1301
|
+
// takes the block whose id == the target #id; `--in F#src` takes
|
|
1302
|
+
// #src. stdin (default, or `--in -`) is raw bytes.
|
|
1303
|
+
// modes · default replaces the WHOLE block, `--head` only the head line,
|
|
1304
|
+
// `--body` only the body. Default and `--head` NORMALIZE the
|
|
1305
|
+
// content's id to #id (its source id is irrelevant); `--body`
|
|
1306
|
+
// keeps the target's head verbatim, so #id is preserved naturally.
|
|
1307
|
+
//
|
|
1308
|
+
// Output follows resolveOutTarget (file -> in place, stdin -> stdout, `-o`/`-o -`
|
|
1309
|
+
// override) and every splice is guarded — re-parsed and rejected if it broke
|
|
1310
|
+
// the doc, so `set` never writes a corrupt file.
|
|
1126
1311
|
function runSet(args) {
|
|
1127
1312
|
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1128
|
-
const from = flag(args, "--
|
|
1313
|
+
const from = flag(args, "--in");
|
|
1129
1314
|
const headOnly = args.includes("--head");
|
|
1130
|
-
const
|
|
1131
|
-
if (
|
|
1315
|
+
const bodyOnly = args.includes("--body");
|
|
1316
|
+
if (headOnly && bodyOnly)
|
|
1317
|
+
fail("--head and --body are mutually exclusive", 2);
|
|
1318
|
+
const [file, rawId] = positionals(args, ["-o", "--out", "--in"]);
|
|
1319
|
+
if (!file)
|
|
1132
1320
|
fail(SUBHELP.set);
|
|
1321
|
+
// No id: there is no block to replace. Point the way to discovery, not a bare
|
|
1322
|
+
// usage line — `geml get <file>` lists every id `set` can target.
|
|
1323
|
+
if (!rawId)
|
|
1324
|
+
fail(`no #id given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable ids`, 2);
|
|
1133
1325
|
const id = rawId.replace(/^#/, "");
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
|
|
1137
|
-
|
|
1326
|
+
// The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
|
|
1327
|
+
// a block from a file. Document and content can't BOTH be stdin: reject that
|
|
1328
|
+
// up front, before consuming stdin, so the document read below is unambiguous.
|
|
1329
|
+
const rawChannel = from === undefined || from === "-";
|
|
1330
|
+
if (file === "-" && rawChannel) {
|
|
1331
|
+
fail("reading the document from stdin needs --in for the new content", 2);
|
|
1138
1332
|
}
|
|
1139
1333
|
const source = readInput(file);
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1334
|
+
if (bodyOnly) {
|
|
1335
|
+
runSetBody(source, id, from, rawChannel, file, out);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
// default / --head: content is a whole block (default) or a bare head line.
|
|
1339
|
+
let content;
|
|
1340
|
+
if (rawChannel) {
|
|
1341
|
+
content = readInput("-");
|
|
1342
|
+
if (content === "")
|
|
1343
|
+
fail(NO_CONTENT, 1);
|
|
1344
|
+
// Default mode wants exactly ONE block. Pure prose has no head to carry the
|
|
1345
|
+
// id (steer to --body); multiple blocks are `add`'s job. --head takes a
|
|
1346
|
+
// lone head line, so it skips the whole-block shape check.
|
|
1347
|
+
if (!headOnly) {
|
|
1348
|
+
const shape = contentShape(content);
|
|
1349
|
+
if (shape === "empty")
|
|
1350
|
+
fail(NO_CONTENT, 1);
|
|
1351
|
+
if (shape === "prose")
|
|
1352
|
+
fail(`content is prose, not a block — use --body to set the body of #${id}`, 1);
|
|
1353
|
+
if (shape === "multi")
|
|
1354
|
+
fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
|
|
1355
|
+
}
|
|
1144
1356
|
}
|
|
1145
1357
|
else {
|
|
1146
|
-
|
|
1147
|
-
if (replacement === "")
|
|
1148
|
-
fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
|
|
1358
|
+
content = extractBlock(from, id, headOnly ? "head" : "whole");
|
|
1149
1359
|
}
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1360
|
+
const normalized = normalizeBlockId(content, id);
|
|
1361
|
+
const updated = spliceBlock(source, id, normalized, file, headOnly);
|
|
1362
|
+
resolveOutTarget(file, out).write(updated);
|
|
1363
|
+
}
|
|
1364
|
+
// `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
|
|
1365
|
+
// for a typed block, its close fence. Assembles head + new body + close and
|
|
1366
|
+
// reuses the guarded spliceBlock — the head carries #id, so the id survives
|
|
1367
|
+
// with no normalization needed.
|
|
1368
|
+
function runSetBody(source, id, from, rawChannel, file, out) {
|
|
1369
|
+
const found = blockSpans(source).get(id);
|
|
1370
|
+
if (!found)
|
|
1371
|
+
fail(`no block with id \`${id}\``, 1);
|
|
1372
|
+
const lines = splitLines(source);
|
|
1373
|
+
const headLine = lines[found.start] ?? "";
|
|
1374
|
+
const headText = stripEol(headLine);
|
|
1375
|
+
// A typed block keeps its closing fence; a heading section has none.
|
|
1376
|
+
let closeLine = null;
|
|
1377
|
+
const open = FENCE_OPEN.exec(headText);
|
|
1378
|
+
if (open) {
|
|
1379
|
+
const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
1380
|
+
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1381
|
+
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1382
|
+
if (isCloseFence(lastText, open[1].length) || labeled)
|
|
1383
|
+
closeLine = lines[found.end - 1] ?? "";
|
|
1384
|
+
}
|
|
1385
|
+
let body;
|
|
1386
|
+
if (rawChannel) {
|
|
1387
|
+
body = readInput("-");
|
|
1388
|
+
if (body === "")
|
|
1389
|
+
fail(NO_CONTENT, 1);
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
body = extractBlock(from, id, "body");
|
|
1154
1393
|
}
|
|
1394
|
+
let head = headLine;
|
|
1395
|
+
if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
|
|
1396
|
+
head += "\n";
|
|
1397
|
+
let b = body.replace(/\r\n?/g, "\n");
|
|
1398
|
+
if (closeLine !== null && b !== "" && !b.endsWith("\n"))
|
|
1399
|
+
b += "\n";
|
|
1400
|
+
const replacement = closeLine !== null ? head + b + closeLine : head + b;
|
|
1401
|
+
// A typed block (closeLine !== null) must stay ONE block: enforce the
|
|
1402
|
+
// block-count invariant so a `===` fence in the raw body can't close it early
|
|
1403
|
+
// and inject siblings (SEC F2). A heading section body has no close fence and
|
|
1404
|
+
// may legitimately contain blocks, so it is not count-guarded.
|
|
1405
|
+
const updated = spliceBlock(source, id, replacement, file, false, closeLine !== null);
|
|
1406
|
+
resolveOutTarget(file, out).write(updated);
|
|
1407
|
+
}
|
|
1408
|
+
// `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
|
|
1409
|
+
// — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
|
|
1410
|
+
// `add` names no target id, so content keeps its OWN ids (no normalization); an
|
|
1411
|
+
// id colliding with the document (or duplicated within the fragment) makes the
|
|
1412
|
+
// re-parse fail and nothing is written. Bare prose is a valid fragment.
|
|
1413
|
+
function runAdd(args) {
|
|
1414
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1415
|
+
const from = flag(args, "--in");
|
|
1416
|
+
const before = flag(args, "--before");
|
|
1417
|
+
const after = flag(args, "--after");
|
|
1418
|
+
const append = args.includes("--append");
|
|
1419
|
+
const posCount = (append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0);
|
|
1420
|
+
if (posCount !== 1)
|
|
1421
|
+
fail("add needs exactly one position: --append | --before #id | --after #id", 2);
|
|
1422
|
+
const [file] = positionals(args, ["-o", "--out", "--in", "--before", "--after"]);
|
|
1423
|
+
if (!file)
|
|
1424
|
+
fail(SUBHELP.add);
|
|
1425
|
+
const rawChannel = from === undefined || from === "-";
|
|
1426
|
+
if (file === "-" && rawChannel)
|
|
1427
|
+
fail("reading the document from stdin needs --in for the new content", 2);
|
|
1428
|
+
const source = readInput(file);
|
|
1429
|
+
// Content: --in F#src -> block #src; --in F -> all of F (a multi-block
|
|
1430
|
+
// fragment is fine here); stdin -> raw. No id-normalization: add keeps ids.
|
|
1431
|
+
let content;
|
|
1432
|
+
if (rawChannel)
|
|
1433
|
+
content = readInput("-");
|
|
1434
|
+
else if (from.includes("#"))
|
|
1435
|
+
content = extractBlock(from, "", "whole");
|
|
1155
1436
|
else
|
|
1156
|
-
|
|
1437
|
+
content = readInput(from);
|
|
1438
|
+
if (content.trim() === "")
|
|
1439
|
+
fail("no content to add (use --in FILE or pipe it on stdin)", 1);
|
|
1440
|
+
// Resolve the physical-line insertion point.
|
|
1441
|
+
const lines = splitLines(source);
|
|
1442
|
+
let at;
|
|
1443
|
+
if (append) {
|
|
1444
|
+
at = lines.length;
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
const anchorId = (before ?? after).replace(/^#/, "");
|
|
1448
|
+
const span = blockSpans(source).get(anchorId);
|
|
1449
|
+
if (!span)
|
|
1450
|
+
fail(`no block with id \`${anchorId}\` in ${file === "-" ? "stdin" : file}`, 1);
|
|
1451
|
+
at = before !== undefined ? span.start : span.end;
|
|
1452
|
+
}
|
|
1453
|
+
const updated = insertFragment(source, lines, at, content, file);
|
|
1454
|
+
resolveOutTarget(file, out).write(updated);
|
|
1455
|
+
}
|
|
1456
|
+
// Splice `fragment` into `source` at physical-line index `at` (splitLines
|
|
1457
|
+
// coords), separating it from adjacent content with a single blank line so
|
|
1458
|
+
// blocks don't fuse, then GUARD: the re-parse must be error-free (a colliding
|
|
1459
|
+
// or duplicate id surfaces as an error diagnostic) and no pre-existing id may
|
|
1460
|
+
// vanish. Returns the updated text; on any violation fail()s and writes nothing.
|
|
1461
|
+
function insertFragment(source, lines, at, fragment, file) {
|
|
1462
|
+
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
1463
|
+
const before = lines.slice(0, at);
|
|
1464
|
+
const after = lines.slice(at);
|
|
1465
|
+
// The preceding line must end in a newline so the fragment starts on its own.
|
|
1466
|
+
if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
|
|
1467
|
+
before[before.length - 1] += "\n";
|
|
1468
|
+
}
|
|
1469
|
+
let frag = fragment.replace(/\r\n?/g, "\n");
|
|
1470
|
+
if (!frag.endsWith("\n"))
|
|
1471
|
+
frag += "\n";
|
|
1472
|
+
// A single blank separator on each side that has adjacent content and isn't
|
|
1473
|
+
// already blank — keeps a following head / preceding block from fusing.
|
|
1474
|
+
const blank = (s) => stripEol(s).trim() === "";
|
|
1475
|
+
const sepBefore = before.length && !blank(before[before.length - 1]) ? "\n" : "";
|
|
1476
|
+
const sepAfter = after.length && !blank(after[0]) ? "\n" : "";
|
|
1477
|
+
const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
|
|
1478
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1479
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1480
|
+
if (errs.length) {
|
|
1481
|
+
const first = errs[0];
|
|
1482
|
+
refuseBroken(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
1483
|
+
}
|
|
1484
|
+
const now = new Set(reparsed.ids);
|
|
1485
|
+
const dropped = beforeIds.find((x) => !now.has(x));
|
|
1486
|
+
if (dropped !== undefined)
|
|
1487
|
+
fail(`adding the content would drop block \`#${dropped}\`; not written`, 1);
|
|
1488
|
+
return updated;
|
|
1489
|
+
}
|
|
1490
|
+
// `geml delete <file|-> #id [#id2 …] [-o]` — remove one or more blocks. A
|
|
1491
|
+
// missing id is SKIPPED with a note (declarative "ensure absent", not an
|
|
1492
|
+
// error). Unlike set/add, delete's write is LENIENT: removing a complete block
|
|
1493
|
+
// can't break the parse structurally, but it may leave a reference dangling —
|
|
1494
|
+
// that is a WARNING, never a refusal (delete is reversible via revert + history,
|
|
1495
|
+
// and `geml check` still flags the dangling ref afterward). Contained/overlapping
|
|
1496
|
+
// spans (a nested block inside a deleted heading section) are handled by deleting
|
|
1497
|
+
// the UNION of target lines, so a line is never spliced twice.
|
|
1498
|
+
function runDelete(args) {
|
|
1499
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1500
|
+
const pos = positionals(args, ["-o", "--out"]);
|
|
1501
|
+
const file = pos[0];
|
|
1502
|
+
if (!file)
|
|
1503
|
+
fail(SUBHELP.delete);
|
|
1504
|
+
const ids = pos.slice(1).map((s) => s.replace(/^#/, ""));
|
|
1505
|
+
if (ids.length === 0)
|
|
1506
|
+
fail("delete needs at least one #id (run 'geml get <file>' to list ids)", 2);
|
|
1507
|
+
const source = readInput(file);
|
|
1508
|
+
const spans = blockSpans(source);
|
|
1509
|
+
const toDelete = new Set();
|
|
1510
|
+
let found = 0;
|
|
1511
|
+
for (const id of ids) {
|
|
1512
|
+
const span = spans.get(id);
|
|
1513
|
+
if (!span) {
|
|
1514
|
+
console.error(`skipped #${id}: no such block`);
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
found++;
|
|
1518
|
+
for (let i = span.start; i < span.end; i++)
|
|
1519
|
+
toDelete.add(i);
|
|
1520
|
+
}
|
|
1521
|
+
if (found === 0) {
|
|
1522
|
+
resolveOutTarget(file, out).write(source);
|
|
1523
|
+
return;
|
|
1524
|
+
} // nothing to remove
|
|
1525
|
+
const updated = splitLines(source).filter((_, i) => !toDelete.has(i)).join("");
|
|
1526
|
+
// Lenient guard: surface any resulting error diagnostic (a reference now
|
|
1527
|
+
// dangling) as a WARNING, but write regardless.
|
|
1528
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1529
|
+
for (const d of reparsed.diagnostics.filter((x) => x.severity === "error")) {
|
|
1530
|
+
console.error(`warning: ${d.message} (line ${d.line}) — left dangling by delete; run 'geml check' to see it as an error`);
|
|
1531
|
+
}
|
|
1532
|
+
resolveOutTarget(file, out).write(updated);
|
|
1533
|
+
}
|
|
1534
|
+
// `geml rename <file|-> #old #new [-o]` — the one verb that reaches OUTSIDE a
|
|
1535
|
+
// block: it rewrites #old's declaration AND every reference to it. #new must be
|
|
1536
|
+
// free; the guarded re-parse refuses anything that would break the doc.
|
|
1537
|
+
function runRename(args) {
|
|
1538
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1539
|
+
const [file, rawOld, rawNew] = positionals(args, ["-o", "--out"]);
|
|
1540
|
+
if (!file || !rawOld || !rawNew)
|
|
1541
|
+
fail(SUBHELP.rename);
|
|
1542
|
+
const oldId = rawOld.replace(/^#/, "");
|
|
1543
|
+
const newId = rawNew.replace(/^#/, "");
|
|
1544
|
+
if (oldId === newId)
|
|
1545
|
+
fail("#old and #new are the same id — nothing to rename", 2);
|
|
1546
|
+
const source = readInput(file);
|
|
1547
|
+
const before = parse(source, { resolveDoc: resolverFor(file) });
|
|
1548
|
+
if (!before.ids.includes(oldId))
|
|
1549
|
+
fail(`no block with id \`${oldId}\``, 1);
|
|
1550
|
+
if (before.ids.includes(newId))
|
|
1551
|
+
fail(`id \`${newId}\` already exists; not written`, 1);
|
|
1552
|
+
// Renaming an id that has recorded history breaks the revert-lineage for it
|
|
1553
|
+
// (revert keys by id and can't follow #old -> #new across the boundary). Warn
|
|
1554
|
+
// so the user knows a later `revert #new` won't reach pre-rename revisions.
|
|
1555
|
+
if (file !== "-") {
|
|
1556
|
+
const hp = historyPathFor(file);
|
|
1557
|
+
if (existsSync(hp)) {
|
|
1558
|
+
try {
|
|
1559
|
+
if (blockSpans(resolveContent(hp, "0").text).has(oldId)) {
|
|
1560
|
+
console.error(`warning: #${oldId} has history; revert across this rename is not tracked — see docs`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
catch { /* unreadable/empty history: no warning */ }
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
const updated = rewriteId(source, oldId, newId, file);
|
|
1567
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1568
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1569
|
+
if (errs.length) {
|
|
1570
|
+
const e = errs[0];
|
|
1571
|
+
refuseBroken(`rename would break the document: ${e.message} (line ${e.line}); not written`, errs);
|
|
1572
|
+
}
|
|
1573
|
+
if (!reparsed.ids.includes(newId))
|
|
1574
|
+
fail(`rename did not produce #${newId}; not written`, 1);
|
|
1575
|
+
if (reparsed.ids.includes(oldId))
|
|
1576
|
+
fail(`#${oldId} still present after rename; not written`, 1);
|
|
1577
|
+
// Every OTHER id must be untouched. The `#old` match boundary treats a char
|
|
1578
|
+
// outside [A-Za-z0-9_-] as an id terminator, but ids may contain e.g. `.`
|
|
1579
|
+
// (`#foo.bar`), so renaming `#foo` could silently rewrite the *different* id
|
|
1580
|
+
// `#foo.bar` -> `#baz.bar`. Reject when the set of ids other than the rename
|
|
1581
|
+
// pair changed at all (SEC/correctness: collateral id corruption).
|
|
1582
|
+
const othersBefore = before.ids.filter((id) => id !== oldId).sort().join("\n");
|
|
1583
|
+
const othersAfter = reparsed.ids.filter((id) => id !== newId).sort().join("\n");
|
|
1584
|
+
if (othersBefore !== othersAfter) {
|
|
1585
|
+
fail(`rename would also change other ids sharing the \`${oldId}\` prefix (e.g. \`#${oldId}…\`); not written`, 1);
|
|
1586
|
+
}
|
|
1587
|
+
resolveOutTarget(file, out).write(updated);
|
|
1588
|
+
}
|
|
1589
|
+
// Rewrite id `old` -> `new` everywhere it is a declaration or reference, id-
|
|
1590
|
+
// boundary-safe: `#old` is replaced only when NOT followed by an id char, so a
|
|
1591
|
+
// longer id like `#old2` / `#old-x` is untouched. Covers the declaration
|
|
1592
|
+
// (`{#old …}`, labeled close `=== #old`), block references (`[[#old]]`,
|
|
1593
|
+
// `[t](#old)`, chart `data=#old`) and footnotes (`[^old]`). RAW / data block
|
|
1594
|
+
// BODIES (code/diagram/math/table/meta) are skipped — a `#old` there is literal
|
|
1595
|
+
// text, not a reference. (Known residual: id-less raw bodies and inline
|
|
1596
|
+
// code/math spans in flow content — see design §8.)
|
|
1597
|
+
function rewriteId(source, oldId, newId, file) {
|
|
1598
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1599
|
+
const spans = blockSpans(source);
|
|
1600
|
+
const protectedLines = new Set();
|
|
1601
|
+
for (const b of doc.children) {
|
|
1602
|
+
if (b.kind === "block" && (b.mode === "raw" || b.mode === "data") && b.id) {
|
|
1603
|
+
const span = spans.get(b.id);
|
|
1604
|
+
if (span) {
|
|
1605
|
+
const br = bodyRange(source, span);
|
|
1606
|
+
for (let i = br.start; i < br.end; i++)
|
|
1607
|
+
protectedLines.add(i);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
const esc = reLit(oldId);
|
|
1612
|
+
const hashRe = new RegExp(`#${esc}(?![A-Za-z0-9_-])`, "g");
|
|
1613
|
+
const fnRe = new RegExp(`(\\[\\^)${esc}(?![A-Za-z0-9_-])`, "g");
|
|
1614
|
+
const lines = splitLines(source);
|
|
1615
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1616
|
+
if (protectedLines.has(i))
|
|
1617
|
+
continue;
|
|
1618
|
+
lines[i] = lines[i].replace(hashRe, `#${newId}`).replace(fnRe, `$1${newId}`);
|
|
1619
|
+
}
|
|
1620
|
+
return lines.join("");
|
|
1621
|
+
}
|
|
1622
|
+
// Extract one block from a GEML file for `--in`. `spec` is `F` (block whose id
|
|
1623
|
+
// == the target) or `F#src` (block #src) — the last `#` splits path from id, so
|
|
1624
|
+
// a `#` inside the path is tolerated; F is read as GEML regardless of extension
|
|
1625
|
+
// (blockSpans + splitLines, no parse — same slice `geml get` prints). `part`
|
|
1626
|
+
// selects the whole span, its head line, or its body. A missing file or absent
|
|
1627
|
+
// id is an operation error (exit 1); the caller writes nothing.
|
|
1628
|
+
function extractBlock(spec, targetId, part) {
|
|
1629
|
+
const hash = spec.lastIndexOf("#");
|
|
1630
|
+
const fragFile = hash >= 0 ? spec.slice(0, hash) : spec;
|
|
1631
|
+
const fragId = hash >= 0 ? spec.slice(hash + 1).replace(/^#/, "") : targetId;
|
|
1632
|
+
let text;
|
|
1633
|
+
try {
|
|
1634
|
+
text = readFileSync(fragFile, "utf8");
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
fail(`cannot read ${fragFile}`, 1);
|
|
1638
|
+
}
|
|
1639
|
+
const span = blockSpans(text).get(fragId);
|
|
1640
|
+
if (!span)
|
|
1641
|
+
fail(`no block with id \`${fragId}\` in ${fragFile}`, 1);
|
|
1642
|
+
const lines = splitLines(text);
|
|
1643
|
+
if (part === "head")
|
|
1644
|
+
return lines.slice(span.start, span.start + 1).join("");
|
|
1645
|
+
if (part === "body") {
|
|
1646
|
+
const b = bodyRange(text, span);
|
|
1647
|
+
return lines.slice(b.start, b.end).join("");
|
|
1648
|
+
}
|
|
1649
|
+
return lines.slice(span.start, span.end).join("");
|
|
1650
|
+
}
|
|
1651
|
+
// Strip a single trailing terminator (`\r\n`, `\r`, or `\n`) from one line.
|
|
1652
|
+
function stripEol(line) {
|
|
1653
|
+
return line.replace(/(\r\n|\r|\n)$/, "");
|
|
1654
|
+
}
|
|
1655
|
+
// The body sub-range of a block span: [head+1, close) for a closed typed block,
|
|
1656
|
+
// otherwise [head+1, end) — a heading section (no close fence) or an
|
|
1657
|
+
// unterminated block whose span already runs to end-of-scope.
|
|
1658
|
+
function bodyRange(text, span) {
|
|
1659
|
+
const lines = splitLines(text);
|
|
1660
|
+
const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
|
|
1661
|
+
if (open) {
|
|
1662
|
+
const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
|
|
1663
|
+
const bid = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
1664
|
+
const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
|
|
1665
|
+
const closed = isCloseFence(lastText, open[1].length) || labeled;
|
|
1666
|
+
return { start: span.start + 1, end: closed ? span.end - 1 : span.end };
|
|
1667
|
+
}
|
|
1668
|
+
return { start: span.start + 1, end: span.end };
|
|
1669
|
+
}
|
|
1670
|
+
// The shape of default-mode stdin content, section-aware: a heading OWNS its
|
|
1671
|
+
// section (`# H …blocks…` is ONE unit, not many), matching sectionEnd/blockSpans.
|
|
1672
|
+
// Used to reject pure prose (-> --body) and multi-block content (-> add) before
|
|
1673
|
+
// the splice — extraction via --in is inherently one block and skips this.
|
|
1674
|
+
function contentShape(content) {
|
|
1675
|
+
const bs = parse(content).children;
|
|
1676
|
+
let blockUnits = 0, proseUnits = 0, i = 0;
|
|
1677
|
+
while (i < bs.length) {
|
|
1678
|
+
const b = bs[i];
|
|
1679
|
+
if (b.kind === "heading") {
|
|
1680
|
+
i = sectionEndIndex(bs, i);
|
|
1681
|
+
blockUnits++;
|
|
1682
|
+
}
|
|
1683
|
+
else if (b.kind === "block") {
|
|
1684
|
+
i++;
|
|
1685
|
+
blockUnits++;
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
i++;
|
|
1689
|
+
proseUnits++;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (blockUnits === 0)
|
|
1693
|
+
return proseUnits === 0 ? "empty" : "prose";
|
|
1694
|
+
return blockUnits + proseUnits === 1 ? "single" : "multi";
|
|
1157
1695
|
}
|
|
1158
1696
|
// Replace block #id's source span in `source` with `replacement`, preserving
|
|
1159
1697
|
// every other byte, and GUARD the result: the re-parse must be error-free, #id
|
|
@@ -1161,11 +1699,12 @@ function runSet(args) {
|
|
|
1161
1699
|
// can silently swallow a neighbour). Returns the updated document text; on any
|
|
1162
1700
|
// violation it calls fail() and never returns a corrupt document. Shared by
|
|
1163
1701
|
// `set` and `revert`.
|
|
1164
|
-
function spliceBlock(source, id, replacement, file, headOnly = false) {
|
|
1702
|
+
function spliceBlock(source, id, replacement, file, headOnly = false, guardCount = false) {
|
|
1165
1703
|
const found = blockSpans(source).get(id);
|
|
1166
1704
|
if (!found)
|
|
1167
1705
|
fail(`no block with id \`${id}\``, 1);
|
|
1168
|
-
const
|
|
1706
|
+
const beforeDoc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1707
|
+
const beforeIds = beforeDoc.ids;
|
|
1169
1708
|
// Keep the bytes before and after the target span exactly; give the new block
|
|
1170
1709
|
// a single trailing newline so the following block still starts on its own
|
|
1171
1710
|
// line (unless it is the file's last line, which may legitimately lack one).
|
|
@@ -1191,7 +1730,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false) {
|
|
|
1191
1730
|
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1192
1731
|
if (errs.length) {
|
|
1193
1732
|
const first = errs[0];
|
|
1194
|
-
|
|
1733
|
+
refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
1195
1734
|
}
|
|
1196
1735
|
const now = new Set(reparsed.ids);
|
|
1197
1736
|
if (!now.has(id))
|
|
@@ -1200,22 +1739,46 @@ function spliceBlock(source, id, replacement, file, headOnly = false) {
|
|
|
1200
1739
|
if (dropped !== undefined) {
|
|
1201
1740
|
fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
|
|
1202
1741
|
}
|
|
1742
|
+
// For a typed block with a close fence, the body is opaque and swapping it
|
|
1743
|
+
// keeps exactly ONE block. A raw `--body` can embed a `===` fence of the
|
|
1744
|
+
// block's length that closes the target early and turns the remainder — plus
|
|
1745
|
+
// the close line we re-appended — into NEW sibling blocks, including an id-less
|
|
1746
|
+
// `=== meta` that redefines document metadata (the dropped-id check above
|
|
1747
|
+
// cannot see an id-less injection). Guarded callers refuse any count change.
|
|
1748
|
+
// (Not enforced for heading sections / whole-block set, whose replacement may
|
|
1749
|
+
// legitimately span several top-level blocks.)
|
|
1750
|
+
if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
|
|
1751
|
+
fail(`replacement changes the block count (a fence in the body closed #${id} early and injected sibling block(s)?); not written`, 1);
|
|
1752
|
+
}
|
|
1203
1753
|
return updated;
|
|
1204
1754
|
}
|
|
1205
|
-
// `geml revert <file.geml> #id [--
|
|
1755
|
+
// `geml revert <file.geml> #id [--rev <sel>] [--dry-run] [-o out] [--history PATH]`
|
|
1206
1756
|
// Restore ONE block to a past revision's version — a targeted, guarded splice
|
|
1207
|
-
// that leaves the rest of the document untouched. <sel> (default `-1`):
|
|
1208
|
-
// revisions back
|
|
1209
|
-
// skips revisions
|
|
1210
|
-
// *distinct* version. `--dry-run` prints what would be spliced in,
|
|
1211
|
-
// nothing. Writes in place by default (revert is a mutation); `-o` redirects.
|
|
1757
|
+
// that leaves the rest of the document untouched. <sel> (default `-1`): `0` (the
|
|
1758
|
+
// tip), `-N` (N revisions back), an id prefix/suffix, or `changed` — a content
|
|
1759
|
+
// selector that skips revisions which never touched the block, landing on its
|
|
1760
|
+
// previous *distinct* version. `--dry-run` prints what would be spliced in,
|
|
1761
|
+
// writing nothing. Writes in place by default (revert is a mutation); `-o` redirects.
|
|
1212
1762
|
function runRevert(args) {
|
|
1213
|
-
const changed = args.includes("--changed");
|
|
1214
1763
|
const dryRun = args.includes("--dry-run");
|
|
1215
1764
|
const headOnly = args.includes("--head");
|
|
1216
1765
|
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1217
|
-
const to = flag(args, "--
|
|
1218
|
-
|
|
1766
|
+
const to = flag(args, "--rev") ?? "-1";
|
|
1767
|
+
// `--rev changed` is a CONTENT selector, not a position: skip commits that
|
|
1768
|
+
// never touched this block, landing on its previous *distinct* version. It is
|
|
1769
|
+
// just a `--rev` value, so it cannot conflict with a positional `-N`.
|
|
1770
|
+
const changed = to === "changed";
|
|
1771
|
+
// The former standalone `--changed` flag is now this value; refuse the old
|
|
1772
|
+
// spelling loudly rather than silently ignoring it (and reverting to -1).
|
|
1773
|
+
if (args.includes("--changed"))
|
|
1774
|
+
fail("--changed is now `--rev changed`", 2);
|
|
1775
|
+
const before = flag(args, "--before");
|
|
1776
|
+
const after = flag(args, "--after");
|
|
1777
|
+
const append = args.includes("--append");
|
|
1778
|
+
if ((append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0) > 1) {
|
|
1779
|
+
fail("revert takes at most one position: --append | --before #id | --after #id", 2);
|
|
1780
|
+
}
|
|
1781
|
+
const [file, rawId] = positionals(args, ["--rev", "--history", "-o", "--out", "--before", "--after"]);
|
|
1219
1782
|
if (!file || !rawId)
|
|
1220
1783
|
fail(SUBHELP.revert);
|
|
1221
1784
|
if (file === "-")
|
|
@@ -1223,13 +1786,13 @@ function runRevert(args) {
|
|
|
1223
1786
|
const id = rawId.replace(/^#/, "");
|
|
1224
1787
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
1225
1788
|
const source = readInput(file);
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
// Extract
|
|
1232
|
-
//
|
|
1789
|
+
const curFull = blockSpans(source).get(id); // undefined => absent now
|
|
1790
|
+
const curBlock = curFull === undefined ? undefined : (() => {
|
|
1791
|
+
const span = headOnly ? narrowToHead(curFull) : curFull;
|
|
1792
|
+
return splitLines(source).slice(span.start, span.end).join("");
|
|
1793
|
+
})();
|
|
1794
|
+
// Extract #id's block from a reconstructed revision (undefined => absent
|
|
1795
|
+
// there). Under `--head`, extract only the head line.
|
|
1233
1796
|
const pick = (text) => {
|
|
1234
1797
|
const s = blockSpans(text).get(id);
|
|
1235
1798
|
if (!s)
|
|
@@ -1241,7 +1804,7 @@ function runRevert(args) {
|
|
|
1241
1804
|
const target = (() => {
|
|
1242
1805
|
try {
|
|
1243
1806
|
if (changed) {
|
|
1244
|
-
const found = firstChangedContent(historyPath, curBlock, pick);
|
|
1807
|
+
const found = firstChangedContent(historyPath, curBlock ?? "", pick);
|
|
1245
1808
|
if (!found)
|
|
1246
1809
|
fail(`no earlier revision changes \`${id}\``, 1);
|
|
1247
1810
|
return found;
|
|
@@ -1252,22 +1815,142 @@ function runRevert(args) {
|
|
|
1252
1815
|
fail(historyError(e, file, historyPath), 1);
|
|
1253
1816
|
}
|
|
1254
1817
|
})();
|
|
1255
|
-
const oldBlock = pick(target.text);
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1818
|
+
const oldBlock = pick(target.text); // undefined => absent at R
|
|
1819
|
+
// Common write path (bespoke message; -o path redirects; -o - -> stdout).
|
|
1820
|
+
const emit = (updated, verb) => {
|
|
1821
|
+
const dest = out ?? file;
|
|
1822
|
+
if (dest === "-")
|
|
1823
|
+
process.stdout.write(updated);
|
|
1824
|
+
else
|
|
1825
|
+
writeFileSync(dest, updated);
|
|
1826
|
+
console.error(`${verb}${dest === file ? "" : dest === "-" ? " -> stdout" : ` -> ${dest}`}`);
|
|
1827
|
+
};
|
|
1828
|
+
// Reconcile #id between now and revision R across the four presence cells.
|
|
1829
|
+
if (curBlock === undefined && oldBlock === undefined) {
|
|
1830
|
+
fail(`\`${id}\` exists in neither the document nor ${target.id} (try --rev changed)`, 1);
|
|
1831
|
+
}
|
|
1832
|
+
// both present -> SPLICE (undo set)
|
|
1833
|
+
if (curBlock !== undefined && oldBlock !== undefined) {
|
|
1834
|
+
if (oldBlock === curBlock) {
|
|
1835
|
+
console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
|
|
1836
|
+
// A no-op still has to PRODUCE the document when an output destination was
|
|
1837
|
+
// asked for: `-o` means "write the result somewhere", and the result of a
|
|
1838
|
+
// no-op revert is the unchanged document. Returning silently here left
|
|
1839
|
+
// `-o -` consumers with exit 0 and empty stdout, which reads as "success,
|
|
1840
|
+
// and the document is now empty".
|
|
1841
|
+
if (out !== undefined)
|
|
1842
|
+
emit(source, `#${id} unchanged`);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (dryRun) {
|
|
1846
|
+
console.error(`would revert #${id} to ${target.id}:`);
|
|
1847
|
+
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
emit(spliceBlock(source, id, oldBlock, file, headOnly), `reverted #${id} to ${target.id}`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
// --head is only meaningful for the splice cell (it can't resurrect or remove).
|
|
1854
|
+
if (headOnly) {
|
|
1855
|
+
fail("--head only applies when the block exists in both the document and the target revision", 2);
|
|
1856
|
+
}
|
|
1857
|
+
// absent now, present at R -> RESURRECT (undo delete)
|
|
1858
|
+
if (curBlock === undefined && oldBlock !== undefined) {
|
|
1859
|
+
// Guard: if the block we'd resurrect is the same (modulo id) as one already
|
|
1860
|
+
// present under a different id, #id was likely renamed away — resurrecting
|
|
1861
|
+
// would duplicate it. Point at `rename` instead of writing.
|
|
1862
|
+
const cmpKey = normalizeBlockId(oldBlock, "__cmp__");
|
|
1863
|
+
for (const [cid, cs] of blockSpans(source)) {
|
|
1864
|
+
if (cid === id)
|
|
1865
|
+
continue;
|
|
1866
|
+
const csrc = splitLines(source).slice(cs.start, cs.end).join("");
|
|
1867
|
+
if (normalizeBlockId(csrc, "__cmp__") === cmpKey) {
|
|
1868
|
+
fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
|
|
1872
|
+
if (dryRun) {
|
|
1873
|
+
console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
|
|
1874
|
+
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (warn)
|
|
1878
|
+
console.error(`warning: anchors for #${id} are gone; appended at end`);
|
|
1879
|
+
emit(insertFragment(source, splitLines(source), at, oldBlock, file), `resurrected #${id} from ${target.id} at ${where}`);
|
|
1260
1880
|
return;
|
|
1261
1881
|
}
|
|
1882
|
+
// present now, absent at R -> REMOVE (undo add)
|
|
1883
|
+
// Guard: if the block we'd remove is the same (modulo id) as one present at R
|
|
1884
|
+
// under a different id, #id was likely renamed IN — removing would delete a
|
|
1885
|
+
// renamed block. Point at `rename` instead (the dangerous direction).
|
|
1886
|
+
{
|
|
1887
|
+
const cmpKey = normalizeBlockId(curBlock, "__cmp__");
|
|
1888
|
+
for (const [rid, rs] of blockSpans(target.text)) {
|
|
1889
|
+
if (rid === id)
|
|
1890
|
+
continue;
|
|
1891
|
+
const rsrc = splitLines(target.text).slice(rs.start, rs.end).join("");
|
|
1892
|
+
if (normalizeBlockId(rsrc, "__cmp__") === cmpKey) {
|
|
1893
|
+
fail(`#${id} looks renamed from #${rid}; revert would delete it — use 'rename #${id} #${rid}'`, 1);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1262
1897
|
if (dryRun) {
|
|
1263
|
-
console.error(`would
|
|
1264
|
-
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1898
|
+
console.error(`would remove #${id} (absent at ${target.id})`);
|
|
1265
1899
|
return;
|
|
1266
1900
|
}
|
|
1267
|
-
const
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1901
|
+
const span = curFull;
|
|
1902
|
+
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
1903
|
+
const updated = splitLines(source).filter((_, i) => i < span.start || i >= span.end).join("");
|
|
1904
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1905
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1906
|
+
if (errs.length) {
|
|
1907
|
+
const first = errs[0];
|
|
1908
|
+
refuseBroken(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, errs);
|
|
1909
|
+
}
|
|
1910
|
+
const now = new Set(reparsed.ids);
|
|
1911
|
+
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
1912
|
+
if (dropped !== undefined)
|
|
1913
|
+
fail(`removing #${id} would drop block \`#${dropped}\`; not written`, 1);
|
|
1914
|
+
emit(updated, `removed #${id} (absent at ${target.id})`);
|
|
1915
|
+
}
|
|
1916
|
+
// Choose the physical-line insertion point for a resurrected block. Explicit
|
|
1917
|
+
// --append/--before/--after win; otherwise infer from the block's neighbours in
|
|
1918
|
+
// revision R: the nearest id BEFORE it that still exists now (insert after it),
|
|
1919
|
+
// else the nearest id AFTER it that still exists (insert before it), else append
|
|
1920
|
+
// at end (warn=true). The deleted block's own former descendants are absent now
|
|
1921
|
+
// too, so they are naturally skipped as anchors.
|
|
1922
|
+
function resurrectPosition(source, revText, id, before, after, append, file) {
|
|
1923
|
+
const lines = splitLines(source);
|
|
1924
|
+
const here = blockSpans(source);
|
|
1925
|
+
if (append)
|
|
1926
|
+
return { at: lines.length, where: "end", warn: false };
|
|
1927
|
+
if (before !== undefined) {
|
|
1928
|
+
const a = before.replace(/^#/, "");
|
|
1929
|
+
const s = here.get(a);
|
|
1930
|
+
if (!s)
|
|
1931
|
+
fail(`no block with id \`${a}\` in ${file}`, 1);
|
|
1932
|
+
return { at: s.start, where: `before #${a}`, warn: false };
|
|
1933
|
+
}
|
|
1934
|
+
if (after !== undefined) {
|
|
1935
|
+
const a = after.replace(/^#/, "");
|
|
1936
|
+
const s = here.get(a);
|
|
1937
|
+
if (!s)
|
|
1938
|
+
fail(`no block with id \`${a}\` in ${file}`, 1);
|
|
1939
|
+
return { at: s.end, where: `after #${a}`, warn: false };
|
|
1940
|
+
}
|
|
1941
|
+
const revIds = [...blockSpans(revText).keys()];
|
|
1942
|
+
const idx = revIds.indexOf(id);
|
|
1943
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
1944
|
+
const s = here.get(revIds[i]);
|
|
1945
|
+
if (s)
|
|
1946
|
+
return { at: s.end, where: `after #${revIds[i]}`, warn: false };
|
|
1947
|
+
}
|
|
1948
|
+
for (let i = idx + 1; i < revIds.length; i++) {
|
|
1949
|
+
const s = here.get(revIds[i]);
|
|
1950
|
+
if (s)
|
|
1951
|
+
return { at: s.start, where: `before #${revIds[i]}`, warn: false };
|
|
1952
|
+
}
|
|
1953
|
+
return { at: lines.length, where: "end", warn: true };
|
|
1271
1954
|
}
|
|
1272
1955
|
// geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
|
|
1273
1956
|
// package's codemap/ directory (they are argv-driven programs, some
|
|
@@ -1291,6 +1974,15 @@ function runCodemap(args) {
|
|
|
1291
1974
|
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
1292
1975
|
process.exit(r.status ?? 1);
|
|
1293
1976
|
}
|
|
1977
|
+
// geml mcp: the document-CRUD MCP server. Runs as a child's MAIN module for the
|
|
1978
|
+
// same reason `codemap mcp` does — it owns stdin/stdout for the whole session
|
|
1979
|
+
// (the stdio transport), and dispatching by spawn keeps this module free of a
|
|
1980
|
+
// runtime import cycle (mcp.js imports the parser from here).
|
|
1981
|
+
function runMcp(args) {
|
|
1982
|
+
const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
|
|
1983
|
+
const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
|
|
1984
|
+
process.exit(r.status ?? 1);
|
|
1985
|
+
}
|
|
1294
1986
|
// npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
|
|
1295
1987
|
// CLI" by resolving argv[1] to its real path, not by its spelling.
|
|
1296
1988
|
const entry = (() => {
|
|
@@ -1339,40 +2031,39 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
|
|
|
1339
2031
|
else if (cmd === "set") {
|
|
1340
2032
|
runSet(argv.slice(1));
|
|
1341
2033
|
}
|
|
2034
|
+
else if (cmd === "add") {
|
|
2035
|
+
runAdd(argv.slice(1));
|
|
2036
|
+
}
|
|
2037
|
+
else if (cmd === "delete") {
|
|
2038
|
+
runDelete(argv.slice(1));
|
|
2039
|
+
}
|
|
2040
|
+
else if (cmd === "rename") {
|
|
2041
|
+
runRename(argv.slice(1));
|
|
2042
|
+
}
|
|
1342
2043
|
else if (cmd === "revert") {
|
|
1343
2044
|
runRevert(argv.slice(1));
|
|
1344
2045
|
}
|
|
1345
2046
|
else if (cmd === "history") {
|
|
1346
2047
|
runHistory(argv.slice(1));
|
|
1347
2048
|
}
|
|
1348
|
-
else if (cmd === "convert") {
|
|
1349
|
-
runConvert(argv.slice(1));
|
|
1350
|
-
}
|
|
1351
|
-
else if (cmd === "export") {
|
|
1352
|
-
runExport(argv.slice(1));
|
|
1353
|
-
}
|
|
1354
|
-
else if (cmd === "render") {
|
|
1355
|
-
runRender(argv.slice(1));
|
|
1356
|
-
}
|
|
1357
|
-
else if (cmd === "fmt") {
|
|
1358
|
-
runFmt(argv.slice(1));
|
|
1359
|
-
}
|
|
1360
2049
|
else if (cmd === "check") {
|
|
1361
2050
|
runCheck(argv.slice(1));
|
|
1362
2051
|
}
|
|
1363
2052
|
else if (cmd === "codemap") {
|
|
1364
2053
|
runCodemap(argv.slice(1));
|
|
1365
2054
|
}
|
|
2055
|
+
else if (cmd === "mcp") {
|
|
2056
|
+
runMcp(argv.slice(1));
|
|
2057
|
+
}
|
|
1366
2058
|
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
1367
2059
|
// A bare word that is neither a known command nor a path is almost always
|
|
1368
|
-
// a mistyped command — say so, don't try to read it as a file.
|
|
2060
|
+
// a mistyped command — say so, don't try to read it as a file. (The
|
|
2061
|
+
// reclaimed verbs render/export/fmt/convert land here too.)
|
|
1369
2062
|
fail(`unknown command '${cmd}'. Run 'geml --help'.`);
|
|
1370
2063
|
}
|
|
1371
2064
|
else {
|
|
1372
|
-
//
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
1376
|
-
process.exit(1);
|
|
2065
|
+
// A file (or stdin via '-') is the transform entry: `--to`/`--from`/`-o`,
|
|
2066
|
+
// default `--to json`. The single door for every format conversion.
|
|
2067
|
+
runTransform(argv);
|
|
1377
2068
|
}
|
|
1378
2069
|
}
|