@geml/geml 1.3.2 → 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/dist/geml.js CHANGED
@@ -8,12 +8,13 @@
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, realpathSync, statSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
12
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
16
  import { renderHtml } from "./render-html.js";
17
+ import { normalizeBlockId } from "./block-edit.js";
17
18
  import { coerce, parseAttrs } from "./attrs.js";
18
19
  import { META_REF_SRC, parseInline } from "./inline.js";
19
20
  import { parseTable } from "./table.js";
@@ -25,6 +26,14 @@ export { mdToGeml } from "./from-md.js";
25
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 = {
@@ -276,7 +285,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
276
285
  // of any length ≥ 3 followed by the block's id). The labeled close is a
277
286
  // *local* close: it can't be gotten wrong by miscounting `=`, so it is the
278
287
  // safe way to nest (§3).
279
- 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;
280
289
  const body = [];
281
290
  let j = i + 1;
282
291
  let closed = false;
@@ -548,7 +557,7 @@ function idOfHeading(braces, text, line, ctx) {
548
557
  function fenceClose(lines, i, open) {
549
558
  const openLen = open[1].length;
550
559
  const id = open[3] ? parseAttrs(open[3]).id : undefined;
551
- const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
560
+ const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(id)}[ \\t]*$`) : null;
552
561
  for (let j = i + 1; j < lines.length; j++) {
553
562
  if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
554
563
  return { end: j + 1, closed: true };
@@ -719,27 +728,45 @@ function parseStamp(s) {
719
728
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
720
729
  }
721
730
  const VERSION = "1.0"; // GEML spec version this CLI targets
722
- const PARSER_VERSION = "1.3.2"; // reference implementation; keep in sync with package.json
731
+ const PARSER_VERSION = "1.4.2"; // reference implementation; keep in sync with package.json
723
732
  const USAGE = `geml — GEML reference CLI
724
733
 
725
734
  Usage:
726
- geml <file.geml|-> parse -> document-model JSON (stdout)
727
- geml get <file.geml|-> #id [--json][--head] print ONE block by id (a heading id = its section;
728
- --head narrows any id to its head line; --json = model node)
729
- geml set <file.geml|-> #id [--from f][-o f][--head] replace ONE block by id (new content: --from/stdin)
730
- geml revert <file.geml> #id [--to <sel>][--head] restore ONE block to a past revision (sel: -N|latest|id)
731
- geml check <file.geml|-> [--root d][--json] validate only: diagnostics + exit code
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
732
761
  (--root widens cross-doc refs to dir d, e.g. the repo root)
733
- geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
734
- geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
735
- geml convert <file.md|-> [-o out.geml] Markdown -> GEML
736
- geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
737
- geml history <commit|verify|show|restore|log> <file.geml> [...]
762
+ geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
738
763
  geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
739
764
  geml --help | --version [--json]
740
765
 
741
766
  Use '-' as the file to read from stdin.
742
- Exit codes:
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:
743
770
  0 ok
744
771
  1 document/operation error
745
772
  2 command usage error.
@@ -747,14 +774,13 @@ Exit codes:
747
774
  // One-line usage for each subcommand — the single source for both the error
748
775
  // shown on misuse and the `<cmd> --help` text.
749
776
  const SUBHELP = {
750
- get: "usage: geml get <file.geml|-> #id [--json] [--head] (a heading id = its whole section; --head narrows any id to its head line)",
751
- set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml] [--head]",
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)",
752
782
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
753
- render: "usage: geml render <file.geml|-> [-o out.html]",
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)",
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)",
758
784
  history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
759
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)
760
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]]
@@ -957,39 +983,92 @@ function runHistory(args) {
957
983
  fail(historyError(e, file, historyPath));
958
984
  }
959
985
  }
960
- // `geml convert <file.md|-> [-o out.geml]` — Markdown -> GEML.
961
- function runConvert(args) {
962
- const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== flag(args, "-o")));
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"]);
963
991
  if (!file)
964
- fail(SUBHELP.convert);
965
- const { geml, notes } = mdToGeml(readInput(file));
966
- for (const n of notes)
967
- console.error(`note: ${n}`);
968
- const outPath = flag(args, "-o") ?? flag(args, "--out");
969
- if (outPath) {
970
- writeFileSync(outPath, geml);
971
- console.error(`wrote ${outPath}`);
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";
972
1011
  }
973
1012
  else {
974
- process.stdout.write(geml);
1013
+ inFmt = "geml";
975
1014
  }
976
- }
977
- // `geml export <file.geml|-> [-o out.md]` — GEML -> Markdown (lossy). Writes
978
- // the output even with diagnostics, prints any lossy-projection notes, and
979
- // exits non-zero on a parse error same contract as render.
980
- function runExport(args) {
981
- const out = flag(args, "-o") ?? flag(args, "--out");
982
- const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
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}`);
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;
990
1022
  }
991
- else
992
- process.stdout.write(md);
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);
993
1072
  for (const n of notes)
994
1073
  console.error(`note: ${n}`);
995
1074
  for (const d of doc.diagnostics)
@@ -997,55 +1076,35 @@ function runExport(args) {
997
1076
  if (doc.diagnostics.some((d) => d.severity === "error"))
998
1077
  process.exit(1);
999
1078
  }
1000
- // `geml render <file.geml> [-o out.html]` GEML -> one self-contained,
1001
- // interactive HTML artifact (the P0 runtime). Writes the file even when there
1002
- // are diagnostics (a viewer should still show what it can), but exits non-zero
1003
- // on any error so CI and agents get a hard signal.
1004
- function runRender(args) {
1005
- const out = flag(args, "-o") ?? flag(args, "--out");
1006
- const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
1007
- if (!file)
1008
- fail(SUBHELP.render);
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}`);
1019
- }
1020
- else
1021
- process.stdout.write(html);
1022
- for (const d of doc.diagnostics)
1023
- console.error(`${d.severity}: ${d.message} (line ${d.line})`);
1024
- if (doc.diagnostics.some((d) => d.severity === "error"))
1025
- process.exit(1);
1026
- }
1027
- // `geml fmt <file.geml> [-o out.geml]` — re-serialize the document model into
1028
- // canonical GEML. Because `serialize` is the inverse of `parse`, `fmt` is a
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);
1079
+ // Write to `-o out` (with a `wrote` note on stderr) or to stdout.
1080
+ function writeOut(text, out) {
1037
1081
  if (out) {
1038
1082
  writeFileSync(out, text);
1039
1083
  console.error(`wrote ${out}`);
1040
1084
  }
1041
1085
  else
1042
1086
  process.stdout.write(text);
1043
- // A broken document must not be reported as a clean format. Surface the
1044
- // diagnostics and exit non-zero, matching parse/render/check.
1045
- for (const d of doc.diagnostics)
1046
- console.error(`${d.severity}: ${d.message} (line ${d.line})`);
1047
- if (doc.diagnostics.some((d) => d.severity === "error"))
1048
- process.exit(1);
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);
1049
1108
  }
1050
1109
  // Positional args (a file, an id) are the non-flag tokens that aren't the value
1051
1110
  // of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
@@ -1069,6 +1128,47 @@ function positionals(args, valued) {
1069
1128
  }
1070
1129
  return out;
1071
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
+ }
1072
1172
  // `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
1073
1173
  // without loading the rest of the document into context. Default output is the
1074
1174
  // block's exact source bytes: a typed block's full `=== … ===` span, a
@@ -1081,8 +1181,14 @@ function runGet(args) {
1081
1181
  const json = args.includes("--json");
1082
1182
  const headOnly = args.includes("--head");
1083
1183
  const [file, rawId] = positionals(args, []);
1084
- if (!file || !rawId)
1184
+ if (!file)
1085
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
+ }
1086
1192
  const id = rawId.replace(/^#/, "");
1087
1193
  const source = readInput(file);
1088
1194
  if (json) {
@@ -1118,42 +1224,407 @@ function runGet(args) {
1118
1224
  const span = headOnly ? narrowToHead(found) : found;
1119
1225
  process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1120
1226
  }
1121
- // `geml set <file.geml|-> #id [--from FILE] [-o out]` replace ONLY that
1122
- // block's source span with new content (from --from or stdin), preserving every
1123
- // other byte. Prints the full updated document, or writes in place with -o. The
1124
- // splice is re-parsed and rejected if it broke the doc: `set` never writes a
1125
- // corrupt file.
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.
1126
1244
  function runSet(args) {
1127
1245
  const out = flag(args, "-o") ?? flag(args, "--out");
1128
- const from = flag(args, "--from");
1246
+ const from = flag(args, "--in");
1129
1247
  const headOnly = args.includes("--head");
1130
- const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
1131
- if (!file || !rawId)
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)
1132
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);
1133
1258
  const id = rawId.replace(/^#/, "");
1134
- // Both the document and the replacement can't come from stdin. Reject that up
1135
- // front before consuming stdin so the document read below is unambiguous.
1136
- if (file === "-" && from === undefined) {
1137
- fail("reading the document from stdin needs --from for the new content", 2);
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);
1138
1265
  }
1139
1266
  const source = readInput(file);
1140
- // New content: an explicit --from file, else stdin.
1141
- let replacement;
1142
- if (from !== undefined) {
1143
- replacement = readInput(from);
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
+ }
1144
1289
  }
1145
1290
  else {
1146
- replacement = readInput("-");
1147
- if (replacement === "")
1148
- fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
1291
+ content = extractBlock(from, id, headOnly ? "head" : "whole");
1149
1292
  }
1150
- const updated = spliceBlock(source, id, replacement, file, headOnly);
1151
- if (out) {
1152
- writeFileSync(out, updated);
1153
- console.error(`wrote ${out}`);
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");
1154
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");
1155
1369
  else
1156
- process.stdout.write(updated);
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";
1157
1628
  }
1158
1629
  // Replace block #id's source span in `source` with `replacement`, preserving
1159
1630
  // every other byte, and GUARD the result: the re-parse must be error-free, #id
@@ -1161,11 +1632,12 @@ function runSet(args) {
1161
1632
  // can silently swallow a neighbour). Returns the updated document text; on any
1162
1633
  // violation it calls fail() and never returns a corrupt document. Shared by
1163
1634
  // `set` and `revert`.
1164
- function spliceBlock(source, id, replacement, file, headOnly = false) {
1635
+ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount = false) {
1165
1636
  const found = blockSpans(source).get(id);
1166
1637
  if (!found)
1167
1638
  fail(`no block with id \`${id}\``, 1);
1168
- const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
1639
+ const beforeDoc = parse(source, { resolveDoc: resolverFor(file) });
1640
+ const beforeIds = beforeDoc.ids;
1169
1641
  // Keep the bytes before and after the target span exactly; give the new block
1170
1642
  // a single trailing newline so the following block still starts on its own
1171
1643
  // line (unless it is the file's last line, which may legitimately lack one).
@@ -1200,9 +1672,20 @@ function spliceBlock(source, id, replacement, file, headOnly = false) {
1200
1672
  if (dropped !== undefined) {
1201
1673
  fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
1202
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
+ }
1203
1686
  return updated;
1204
1687
  }
1205
- // `geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
1688
+ // `geml revert <file.geml> #id [--rev <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
1206
1689
  // Restore ONE block to a past revision's version — a targeted, guarded splice
1207
1690
  // that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
1208
1691
  // revisions back from current), `latest`, or an id prefix/suffix. `--changed`
@@ -1214,8 +1697,14 @@ function runRevert(args) {
1214
1697
  const dryRun = args.includes("--dry-run");
1215
1698
  const headOnly = args.includes("--head");
1216
1699
  const out = flag(args, "-o") ?? flag(args, "--out");
1217
- const to = flag(args, "--to") ?? "-1";
1218
- const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
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"]);
1219
1708
  if (!file || !rawId)
1220
1709
  fail(SUBHELP.revert);
1221
1710
  if (file === "-")
@@ -1223,13 +1712,13 @@ function runRevert(args) {
1223
1712
  const id = rawId.replace(/^#/, "");
1224
1713
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1225
1714
  const source = readInput(file);
1226
- const found = blockSpans(source).get(id);
1227
- if (!found)
1228
- fail(`no block with id \`${id}\` in ${file}`, 1);
1229
- const curSpan = headOnly ? narrowToHead(found) : found;
1230
- const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
1231
- // Extract block #id's source from a reconstructed revision (undefined if the
1232
- // block did not exist there). Under `--head`, extract only the head line.
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.
1233
1722
  const pick = (text) => {
1234
1723
  const s = blockSpans(text).get(id);
1235
1724
  if (!s)
@@ -1241,7 +1730,7 @@ function runRevert(args) {
1241
1730
  const target = (() => {
1242
1731
  try {
1243
1732
  if (changed) {
1244
- const found = firstChangedContent(historyPath, curBlock, pick);
1733
+ const found = firstChangedContent(historyPath, curBlock ?? "", pick);
1245
1734
  if (!found)
1246
1735
  fail(`no earlier revision changes \`${id}\``, 1);
1247
1736
  return found;
@@ -1252,22 +1741,135 @@ function runRevert(args) {
1252
1741
  fail(historyError(e, file, historyPath), 1);
1253
1742
  }
1254
1743
  })();
1255
- const oldBlock = pick(target.text);
1256
- if (oldBlock === undefined)
1257
- fail(`block \`${id}\` does not exist at revision ${target.id}`, 1);
1258
- if (oldBlock === curBlock) {
1259
- console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --to -2, or --changed)"}`);
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}`);
1260
1770
  return;
1261
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
+ }
1262
1816
  if (dryRun) {
1263
- console.error(`would revert #${id} to ${target.id}:`);
1264
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1817
+ console.error(`would remove #${id} (absent at ${target.id})`);
1265
1818
  return;
1266
1819
  }
1267
- const updated = spliceBlock(source, id, oldBlock, file, headOnly);
1268
- const dest = out ?? file;
1269
- writeFileSync(dest, updated);
1270
- console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
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 };
1271
1873
  }
1272
1874
  // geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
1273
1875
  // package's codemap/ directory (they are argv-driven programs, some
@@ -1339,24 +1941,21 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
1339
1941
  else if (cmd === "set") {
1340
1942
  runSet(argv.slice(1));
1341
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
+ }
1342
1953
  else if (cmd === "revert") {
1343
1954
  runRevert(argv.slice(1));
1344
1955
  }
1345
1956
  else if (cmd === "history") {
1346
1957
  runHistory(argv.slice(1));
1347
1958
  }
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
1959
  else if (cmd === "check") {
1361
1960
  runCheck(argv.slice(1));
1362
1961
  }
@@ -1365,14 +1964,13 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
1365
1964
  }
1366
1965
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
1367
1966
  // 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.
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.)
1369
1969
  fail(`unknown command '${cmd}'. Run 'geml --help'.`);
1370
1970
  }
1371
1971
  else {
1372
- // Default: parse a file (or stdin via '-') to the document-model JSON.
1373
- const doc = parse(readInput(cmd), { resolveDoc: resolverFor(cmd) });
1374
- console.log(JSON.stringify(doc, null, 2));
1375
- if (doc.diagnostics.some((d) => d.severity === "error"))
1376
- 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);
1377
1975
  }
1378
1976
  }