@geml/geml 1.7.2 → 1.7.4
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 +24 -3
- package/codemap/build.mjs +6 -0
- package/codemap/emit.mjs +31 -1
- package/codemap/render-all.mjs +14 -1
- package/dist/cli.js +146 -10
- package/dist/geml.js +25 -0
- package/dist/mcp.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +13 -1
- package/skill/references/authoring.geml +6 -1
package/README.md
CHANGED
|
@@ -24,7 +24,8 @@ print("hi")
|
|
|
24
24
|
- **Addressable** — every block can be named: an `#id`, or a content address for
|
|
25
25
|
the ones nobody named; `geml get` / `geml set '<selector>'`
|
|
26
26
|
read or patch one section without re-emitting the whole file (on this repo's
|
|
27
|
-
own spec, ~**
|
|
27
|
+
own spec, ~**120× less context** than shipping the whole document — the block
|
|
28
|
+
is ~590 chars whatever the document grows to).
|
|
28
29
|
- **Verifiable** — references are checked at build time (a dangling `#id` is an
|
|
29
30
|
error, not a silent dead link), and the parser emits a document-model JSON
|
|
30
31
|
with a `diagnostics` array, so agents and CI get a structured pass/fail signal.
|
|
@@ -101,13 +102,15 @@ geml find "text" doc.geml|dir # search block CONTENT -> file<TAB>address;
|
|
|
101
102
|
geml get doc.geml ['<selector>'] # list addressable blocks, or print what the selector matches
|
|
102
103
|
geml get doc.geml '#sec' --intro # a section cuts three ways: --head | --intro | --body
|
|
103
104
|
geml set doc.geml '<selector>' [--head|--intro|--body] [--in F[#src]] # replace ONE block's content
|
|
105
|
+
geml replace doc.geml OLD NEW [--within '<selector>'] # EXPERIMENTAL: literal swap, checked and reported
|
|
104
106
|
geml add doc.geml (--append|--before #id|--after #id) [--in F[#src]] # insert a fragment
|
|
105
107
|
geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks
|
|
106
108
|
geml rename doc.geml '#old' '#new' # rename an id + every reference to it
|
|
107
109
|
geml revert doc.geml '#id' [--rev -1] # undo a block: splice / resurrect / remove
|
|
108
110
|
geml check doc.geml [--root <dir>] # validate only: diagnostics + exit code (--json for the array)
|
|
109
111
|
geml history <save|get|restore|verify> doc.geml [...] # .gemlhistory version sidecar (get = list revisions, or print one)
|
|
110
|
-
geml codemap <build|verify|render|serve|refresh|find
|
|
112
|
+
geml codemap <build|verify|render|serve|refresh|find> # your codebase's call graph as GEML docs
|
|
113
|
+
geml mcp --root <dir> [--graph <dir>] # serve documents (+ the code graph) over MCP
|
|
111
114
|
geml --help | --version # --version --json prints {"parser","spec"}
|
|
112
115
|
```
|
|
113
116
|
|
|
@@ -138,6 +141,15 @@ byte-identical — and `--intro` is how a section's opening is edited without
|
|
|
138
141
|
pulling its subsections into context. A block has no intro; asking for one is a
|
|
139
142
|
usage error rather than a quiet fall back to the body.
|
|
140
143
|
|
|
144
|
+
`replace` is the cheap path when the exact old text is already known and nothing
|
|
145
|
+
needs reading — a version string in six places, a term renamed. It is the one
|
|
146
|
+
operation where GEML can beat `sed` outright rather than imitate it: the same
|
|
147
|
+
two short strings, but the result is re-parsed before it lands, the blocks it
|
|
148
|
+
touched are named back to you, and it is in `.gemlhistory` to revert. It swaps a
|
|
149
|
+
LITERAL, never a pattern, and refuses a swap that would rename an id — that is
|
|
150
|
+
`geml rename`, which fixes the references too. **It is EXPERIMENTAL and may be
|
|
151
|
+
withdrawn**; build nothing on it that cannot change.
|
|
152
|
+
|
|
141
153
|
A write is refused when it would break the document, never merely because it
|
|
142
154
|
removes something. A replacement that drops blocks is carried out and the
|
|
143
155
|
dropped blocks are named on stderr — unnamed ones counted, references left
|
|
@@ -234,9 +246,13 @@ Add to your `claude_desktop_config.json`:
|
|
|
234
246
|
### Claude Code / CLI Clients
|
|
235
247
|
Run the following command to add the server:
|
|
236
248
|
```sh
|
|
237
|
-
|
|
249
|
+
claude mcp add geml -- npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
|
|
238
250
|
```
|
|
239
251
|
|
|
252
|
+
With a code graph under `--root` (`geml codemap build`), the same server also
|
|
253
|
+
serves four read-only `geml_codemap_*` tools. Every tool and option:
|
|
254
|
+
[`docs/mcp-guide.md`](https://github.com/geml-spec/geml/blob/main/docs/mcp-guide.md).
|
|
255
|
+
|
|
240
256
|
## Library
|
|
241
257
|
|
|
242
258
|
```js
|
|
@@ -259,6 +275,11 @@ Full normative spec, history-sidecar spec, and format comparison live in the
|
|
|
259
275
|
[repository](https://github.com/geml-spec/geml). The spec is itself
|
|
260
276
|
written in GEML (`GEML-spec.geml`) and parsed clean on every test run.
|
|
261
277
|
|
|
278
|
+
What changed between releases:
|
|
279
|
+
[`CHANGELOG.md`](https://github.com/geml-spec/geml/blob/main/CHANGELOG.md).
|
|
280
|
+
The parser and the specification version independently — `geml --version --json`
|
|
281
|
+
prints both.
|
|
282
|
+
|
|
262
283
|
## License
|
|
263
284
|
|
|
264
285
|
MIT.
|
package/codemap/build.mjs
CHANGED
|
@@ -513,6 +513,12 @@ console.error(
|
|
|
513
513
|
+ `${stats.containers} containers (${stats.written} of ${stats.docs} files written), `
|
|
514
514
|
+ `${(stats.bytes / 1048576).toFixed(2)} MB -> ${outDir}`,
|
|
515
515
|
);
|
|
516
|
+
// A build that deletes files says which ones. Silence here is how the orphans
|
|
517
|
+
// accumulated in the first place — name them, so a rename that drops a whole
|
|
518
|
+
// naming scheme is visible in the log rather than three renamings later.
|
|
519
|
+
if (stats.pruned?.length) {
|
|
520
|
+
console.error(` pruned ${stats.pruned.length} document(s) no longer produced: ${stats.pruned.join(", ")}`);
|
|
521
|
+
}
|
|
516
522
|
|
|
517
523
|
// --history: snapshot every changed document into its .gemlhistory sidecar —
|
|
518
524
|
// the graph's own architectural history (geml history get / revert per node).
|
package/codemap/emit.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Emission is deterministic: stable sort orders everywhere; a file is only
|
|
14
14
|
// written when its bytes changed (mtime = "what a change touched").
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
17
17
|
import { dirname, join, posix } from "node:path";
|
|
18
18
|
import { buildNormalizer } from "./normalize.mjs";
|
|
19
19
|
|
|
@@ -461,10 +461,40 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
|
|
|
461
461
|
if (!existsSync(p) || readFileSync(p, "utf8") !== content) writeFileSync(p, content);
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
// ---- prune documents this build no longer produces ----
|
|
465
|
+
// A container that stops yielding symbols simply gets no document; without
|
|
466
|
+
// this, the one written by an earlier build stays behind describing code that
|
|
467
|
+
// is gone. Those orphans are not inert: `geml check` reads their `src=` line
|
|
468
|
+
// ranges and fails the build-time reference check, which is how nine of them
|
|
469
|
+
// — across two renamings of the naming scheme — went unnoticed until one
|
|
470
|
+
// orphan's source file happened to SHRINK past its recorded line numbers.
|
|
471
|
+
//
|
|
472
|
+
// `allDocs` is the authoritative set: every .geml this run emitted, written
|
|
473
|
+
// or byte-identical. Anything else at the top level of outDir is an orphan.
|
|
474
|
+
// Two guards keep this from eating a file it does not own: only the top level
|
|
475
|
+
// is scanned (never _index/, _build/, or any subtree), and a candidate must
|
|
476
|
+
// carry the generated-document marker `resolution-default` in its head — a
|
|
477
|
+
// hand-placed .geml parked in the directory is left alone.
|
|
478
|
+
const pruned = [];
|
|
479
|
+
const keep = new Set(allDocs);
|
|
480
|
+
let present = [];
|
|
481
|
+
try { present = readdirSync(outDir); } catch { present = []; }
|
|
482
|
+
for (const f of present) {
|
|
483
|
+
if (!f.endsWith(".geml") || keep.has(f)) continue;
|
|
484
|
+
const p = join(outDir, f);
|
|
485
|
+
try {
|
|
486
|
+
if (!statSync(p).isFile()) continue;
|
|
487
|
+
if (!/^===\s*meta\b[\s\S]*?\bresolution-default\s*=/.test(readFileSync(p, "utf8").slice(0, 2000))) continue;
|
|
488
|
+
unlinkSync(p);
|
|
489
|
+
pruned.push(f);
|
|
490
|
+
} catch { /* unreadable or already gone — not this build's problem */ }
|
|
491
|
+
}
|
|
492
|
+
|
|
464
493
|
return {
|
|
465
494
|
...stats,
|
|
466
495
|
allDocs,
|
|
467
496
|
writtenDocs,
|
|
497
|
+
pruned,
|
|
468
498
|
containers: containers.size,
|
|
469
499
|
symbols: symbols.length,
|
|
470
500
|
methods: methods.length,
|
package/codemap/render-all.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// graph area (nested frame), so the whole map is browsable offline — this is
|
|
9
9
|
// the "copy the folder to someone" mode. For a live view that never goes
|
|
10
10
|
// stale, use `geml codemap serve` instead.
|
|
11
|
-
import { readdirSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import { readdirSync, readFileSync, writeFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
12
12
|
import { join, basename, sep, resolve as resolvePath } from "node:path";
|
|
13
13
|
import { parse, renderHtml } from "../dist/geml.js";
|
|
14
14
|
|
|
@@ -73,5 +73,18 @@ for (const f of files) {
|
|
|
73
73
|
console.error(`render: ${f}: ${e.message}`);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
// Each tool prunes what it owns: build removes the documents it no longer
|
|
77
|
+
// produces, and this removes the pages whose document is gone. An orphan page
|
|
78
|
+
// is worse than a stale one — it is unreachable from index.html yet still
|
|
79
|
+
// served, so a copied folder ships a page describing deleted code with no way
|
|
80
|
+
// to notice. Only a `<base>.html` whose `<base>.geml` is absent qualifies, so
|
|
81
|
+
// nothing that has a document behind it is ever touched.
|
|
82
|
+
const prunedPages = [];
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
if (!f.endsWith(".html")) continue;
|
|
85
|
+
if (files.includes(f.replace(/\.html$/, ".geml"))) continue;
|
|
86
|
+
try { unlinkSync(join(dir, f)); prunedPages.push(f); } catch { /* already gone */ }
|
|
87
|
+
}
|
|
76
88
|
console.error(`rendered ${n} page(s) -> ${dir}${failed.length ? `; FAILED: ${failed.join(", ")}` : ""}`);
|
|
89
|
+
if (prunedPages.length) console.error(` pruned ${prunedPages.length} orphan page(s): ${prunedPages.join(", ")}`);
|
|
77
90
|
process.exit(failed.length ? 1 : 0);
|
package/dist/cli.js
CHANGED
|
@@ -147,7 +147,8 @@ Usage:
|
|
|
147
147
|
(call this first — its addresses are what every verb below takes)
|
|
148
148
|
geml find <pattern> [<file|dir> …] [--json] [--case] [--head] search block content -> file#address
|
|
149
149
|
(an address, not a line number, so a hit pastes into get/set;
|
|
150
|
-
a
|
|
150
|
+
a named file is searched whatever its extension, a dir walks
|
|
151
|
+
*.geml only; exit 1 when nothing matched)
|
|
151
152
|
geml get <file.geml|-> [#id] [--json] [--head|--intro|--body] with #id: print that block
|
|
152
153
|
(a heading id = its whole section; --head = head line;
|
|
153
154
|
--json = model node). Without #id: list all addressable
|
|
@@ -156,6 +157,7 @@ Usage:
|
|
|
156
157
|
lines, which is how a grep hit or a stack trace becomes
|
|
157
158
|
an address.
|
|
158
159
|
geml set <file.geml|-> #id [--head|--intro|--body] [--in f[#src]|-] [-o f] replace ONE block by id
|
|
160
|
+
geml replace <file.geml|-> <old> <new> [--within <selector>] [-o f] EXPERIMENTAL: swap a literal string, checked and reported
|
|
159
161
|
(--in F takes F's block #id, F#src takes #src, else stdin raw;
|
|
160
162
|
default = whole block · --head = head line · --body = body)
|
|
161
163
|
geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
|
|
@@ -173,7 +175,7 @@ Usage:
|
|
|
173
175
|
and re-hash the whole chain)
|
|
174
176
|
geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
|
|
175
177
|
geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
|
|
176
|
-
(
|
|
178
|
+
(11 tools, each geml_ + its CLI command path: list/find/get/check/history/to +
|
|
177
179
|
set/add/delete/rename/revert; every write is validated before it
|
|
178
180
|
reaches disk. A code graph under --root adds four read-only
|
|
179
181
|
geml_codemap_* tools to the same server)
|
|
@@ -200,7 +202,8 @@ const SUBHELP = {
|
|
|
200
202
|
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)",
|
|
201
203
|
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)",
|
|
202
204
|
list: "usage: geml list <file.geml|-> [--json] (list every addressable block with its shortest unique address, its kind and its line range — the same listing `geml get <file>` prints with no selector, under the name the MCP surface already uses. Call it FIRST: the addresses it prints are what get/set/add/delete/rename/revert all take)",
|
|
203
|
-
find: "usage: geml find <pattern> [<file
|
|
205
|
+
find: "usage: geml find <pattern> [<file|dir> …] [--json] [--case] [--head] (search block CONTENT and print `<file>TAB<address>` per hit — an address, never a line number, so a hit is `geml get <file> '<address>'` with no editing. The address is the INNERMOST block holding the match, never its enclosing section, and a block is reported once however many lines in it matched. Substring, case-insensitive unless --case; a file you NAME is searched whatever its extension, including Markdown, while a directory is walked for *.geml only; no path = the current directory; --head adds the matching line as a third column. Exit 1 when nothing matched, so `if geml find …` works in a script)",
|
|
206
|
+
replace: "usage: geml replace <file.geml|-> <old> <new> [--within <selector>] [-o out.geml] (EXPERIMENTAL — this verb MAY BE WITHDRAWN in a later release; it is here to find out whether an addressed, checked replacement earns its place beside `sed`, and if it does not, it goes. Build nothing on it you cannot change, and say so in a discussion if it is doing real work for you. Swaps a LITERAL string — never a pattern, that is what `sed` is for and where the footguns are. Without --within the whole document; with it, only inside the blocks that selector matches, and unlike `set` it may match several: `--within '=== table'` means every table. What this buys over `sed -i`, at the same cost of two short strings and nothing read: the result is re-parsed and refused if it would break the document, the blocks it touched are NAMED on stderr, and the write lands in .gemlhistory where `revert` can undo it. An id is not text — a replacement that would rename one is refused and points at `geml rename`, which fixes every reference too. Exit 1 when nothing matched, so `if geml replace …` works in a script)",
|
|
204
207
|
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
205
208
|
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)",
|
|
206
209
|
history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
|
|
@@ -637,9 +640,11 @@ function runTransform(argv) {
|
|
|
637
640
|
const root = flag(argv, "--root");
|
|
638
641
|
if (argv.includes("--root") && root === undefined)
|
|
639
642
|
fail("--root needs a directory", 2);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
+
// Dispatch only lands here when argv[0] is `-` or carries a path character,
|
|
644
|
+
// and `positionals` keeps both — so there is always a file. A guard for the
|
|
645
|
+
// empty case would read as a possibility that does not exist; a caller who
|
|
646
|
+
// writes `geml --to md` is told `unknown command '--to'` at the door.
|
|
647
|
+
const file = positionals(argv, ["-o", "--out", "--from", "--to", "--root"])[0];
|
|
643
648
|
// A bare `--to`/`--from` (no following value) is a mistyped flag, not a
|
|
644
649
|
// silent fall-through to the default — flag() would return undefined and we
|
|
645
650
|
// must not quietly ignore it.
|
|
@@ -1096,7 +1101,14 @@ function runList(args) {
|
|
|
1096
1101
|
// platforms — a listing that reorders between machines is a listing nobody can
|
|
1097
1102
|
// diff. Hidden directories and `node_modules` are skipped: a search verb that
|
|
1098
1103
|
// dredges up vendored copies trains people to stop reading its output.
|
|
1099
|
-
|
|
1104
|
+
// `explicit` marks a path the caller NAMED, as opposed to one this walk found.
|
|
1105
|
+
// A named file is searched whatever it is called: `get` and `list` already read
|
|
1106
|
+
// a `.md` this way, and having only `find` refuse meant
|
|
1107
|
+
// `geml find GEML README.md` exited 1 against a file holding forty-four
|
|
1108
|
+
// matches — a search that answers "no" about a file you pointed straight at.
|
|
1109
|
+
// The `.geml` filter belongs to the DIRECTORY walk, where taking every file
|
|
1110
|
+
// would drag the whole source tree through the parser.
|
|
1111
|
+
function gemlFilesUnder(path, out, explicit = false) {
|
|
1100
1112
|
let dir = false;
|
|
1101
1113
|
try {
|
|
1102
1114
|
dir = statSync(path).isDirectory();
|
|
@@ -1105,7 +1117,7 @@ function gemlFilesUnder(path, out) {
|
|
|
1105
1117
|
return;
|
|
1106
1118
|
}
|
|
1107
1119
|
if (!dir) {
|
|
1108
|
-
if (path.endsWith(".geml"))
|
|
1120
|
+
if (explicit || path.endsWith(".geml"))
|
|
1109
1121
|
out.push(path);
|
|
1110
1122
|
return;
|
|
1111
1123
|
}
|
|
@@ -1132,8 +1144,9 @@ function runFind(args) {
|
|
|
1132
1144
|
const json = args.includes("--json");
|
|
1133
1145
|
const needle = sensitive ? pattern : pattern.toLowerCase();
|
|
1134
1146
|
const files = [];
|
|
1135
|
-
|
|
1136
|
-
|
|
1147
|
+
const named = pos.slice(1);
|
|
1148
|
+
for (const p of named.length ? named : ["."])
|
|
1149
|
+
gemlFilesUnder(p, files, named.length > 0);
|
|
1137
1150
|
const hits = [];
|
|
1138
1151
|
for (const f of files) {
|
|
1139
1152
|
let source;
|
|
@@ -1309,6 +1322,126 @@ function runGet(args) {
|
|
|
1309
1322
|
for (const u of units)
|
|
1310
1323
|
process.stdout.write(sliceUnit(source, u.span, part));
|
|
1311
1324
|
}
|
|
1325
|
+
// `geml replace <file> <old> <new> [--within <selector>]` — swap a literal
|
|
1326
|
+
// string, everywhere or inside named blocks, without reading the document.
|
|
1327
|
+
//
|
|
1328
|
+
// This is the one operation where GEML can beat `sed` outright rather than
|
|
1329
|
+
// imitate it. The cost is the same — two short strings out, nothing read in —
|
|
1330
|
+
// and three things come back that `sed -i` cannot give: the write is re-parsed
|
|
1331
|
+
// and refused if it would break the document, the blocks it touched are named,
|
|
1332
|
+
// and it lands in `.gemlhistory` where `revert` can undo it. Measured on a real
|
|
1333
|
+
// day of editing, ten of fourteen changes were bulk blind replacement done with
|
|
1334
|
+
// the original commands; every one of those was an edit that escaped all three.
|
|
1335
|
+
//
|
|
1336
|
+
// LITERAL, never a pattern. Regular expressions are where `sed` is genuinely
|
|
1337
|
+
// better and where the footguns live, and the moment this grows them it stops
|
|
1338
|
+
// being "GEML, addressed" and becomes a worse `sed`.
|
|
1339
|
+
function runReplace(args) {
|
|
1340
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1341
|
+
const within = flag(args, "--within");
|
|
1342
|
+
const [file, oldText, newText] = positionals(args, ["-o", "--out", "--within"]);
|
|
1343
|
+
if (!file || oldText === undefined || newText === undefined)
|
|
1344
|
+
fail(SUBHELP.replace);
|
|
1345
|
+
if (oldText === "")
|
|
1346
|
+
fail("the text to replace is empty — that would match everywhere", 2);
|
|
1347
|
+
const source = readInput(file);
|
|
1348
|
+
const where = file === "-" ? "stdin" : file;
|
|
1349
|
+
const all = addressedUnits(source);
|
|
1350
|
+
// Scope: the whole document, or every block a selector matches. Several
|
|
1351
|
+
// matches are fine here — `replace … --within '=== table'` meaning "in all
|
|
1352
|
+
// the tables" is the useful reading, and unlike `set` there is no ambiguity
|
|
1353
|
+
// about which one receives the write.
|
|
1354
|
+
const lines = splitLines(source);
|
|
1355
|
+
const lineStart = [];
|
|
1356
|
+
{
|
|
1357
|
+
let at = 0;
|
|
1358
|
+
for (const l of lines) {
|
|
1359
|
+
lineStart.push(at);
|
|
1360
|
+
at += l.length;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
let scopes;
|
|
1364
|
+
if (within === undefined) {
|
|
1365
|
+
scopes = [{ from: 0, to: source.length }];
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
// `selectUnits` already refuses a selector that matches nothing, with the
|
|
1369
|
+
// message the other verbs give, so there is no empty case to handle here.
|
|
1370
|
+
const { units } = selectUnits(source, file, within, where);
|
|
1371
|
+
scopes = units.map((u) => ({
|
|
1372
|
+
from: lineStart[u.span.start],
|
|
1373
|
+
to: u.span.end >= lineStart.length ? source.length : lineStart[u.span.end],
|
|
1374
|
+
}));
|
|
1375
|
+
}
|
|
1376
|
+
// Find every occurrence inside the scopes, right to left, so replacing one
|
|
1377
|
+
// cannot move the ones not yet done.
|
|
1378
|
+
const hits = [];
|
|
1379
|
+
for (const s of scopes) {
|
|
1380
|
+
let at = source.indexOf(oldText, s.from);
|
|
1381
|
+
while (at !== -1 && at + oldText.length <= s.to) {
|
|
1382
|
+
hits.push(at);
|
|
1383
|
+
at = source.indexOf(oldText, at + oldText.length);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
hits.sort((a, b) => a - b);
|
|
1387
|
+
if (hits.length === 0) {
|
|
1388
|
+
// Exit 1 like `find`, so `if geml replace …` means what it looks like.
|
|
1389
|
+
fail(`\`${oldText}\` does not occur in ${within === undefined ? where : `\`${within}\` of ${where}`} — nothing written`, 1);
|
|
1390
|
+
}
|
|
1391
|
+
let updated = source;
|
|
1392
|
+
for (const at of [...hits].reverse()) {
|
|
1393
|
+
updated = updated.slice(0, at) + newText + updated.slice(at + oldText.length);
|
|
1394
|
+
}
|
|
1395
|
+
// Which blocks were touched — the report has to speak in addresses, or this
|
|
1396
|
+
// is just `sed` with a longer name.
|
|
1397
|
+
const lineOf = (off) => {
|
|
1398
|
+
let lo = 0, hi = lineStart.length - 1;
|
|
1399
|
+
while (lo < hi) {
|
|
1400
|
+
const mid = (lo + hi + 1) >> 1;
|
|
1401
|
+
if (lineStart[mid] <= off)
|
|
1402
|
+
lo = mid;
|
|
1403
|
+
else
|
|
1404
|
+
hi = mid - 1;
|
|
1405
|
+
}
|
|
1406
|
+
return lo;
|
|
1407
|
+
};
|
|
1408
|
+
const touched = new Set();
|
|
1409
|
+
for (const at of hits) {
|
|
1410
|
+
const ln = lineOf(at);
|
|
1411
|
+
let best;
|
|
1412
|
+
for (const a of all) {
|
|
1413
|
+
if (a.unit.span.start <= ln && ln < a.unit.span.end) {
|
|
1414
|
+
if (!best || (a.unit.span.end - a.unit.span.start) < (best.unit.span.end - best.unit.span.start))
|
|
1415
|
+
best = a;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (best)
|
|
1419
|
+
touched.add(shortestAddress(best, all));
|
|
1420
|
+
}
|
|
1421
|
+
// An id is not text to be swapped: changing one silently cuts every reference
|
|
1422
|
+
// to it, which is precisely what `rename` exists to do properly.
|
|
1423
|
+
const before = parse(source, { ...docOpts(file) });
|
|
1424
|
+
const after = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
|
|
1425
|
+
const goneIds = before.ids.filter((x) => !new Set(after.ids).has(x));
|
|
1426
|
+
const newIds = after.ids.filter((x) => !new Set(before.ids).has(x));
|
|
1427
|
+
if (goneIds.length && newIds.length) {
|
|
1428
|
+
fail(`that would rename \`#${goneIds[0]}\` to \`#${newIds[0]}\` — an id is not text: use \`geml rename ${where} '#${goneIds[0]}' '#${newIds[0]}'\`, which fixes every reference too. Nothing written`, 2);
|
|
1429
|
+
}
|
|
1430
|
+
const errs = after.diagnostics.filter((d) => d.severity === "error");
|
|
1431
|
+
if (errs.length) {
|
|
1432
|
+
refuseBroken(`the replacement would break the document: ${errs[0].message} (line ${errs[0].line}); nothing written`, errs);
|
|
1433
|
+
}
|
|
1434
|
+
// Blocks the replacement removed follow `set`'s rule: carried out, and named.
|
|
1435
|
+
const droppedAnon = Math.max(0, countBlockUnits(source) - countBlockUnits(updated) - goneIds.length);
|
|
1436
|
+
if (goneIds.length || droppedAnon) {
|
|
1437
|
+
const named = goneIds.map((x) => `\`#${x}\``).join(", ");
|
|
1438
|
+
const anon = droppedAnon ? `${droppedAnon} unnamed block${droppedAnon > 1 ? "s" : ""}` : "";
|
|
1439
|
+
console.error(`dropped ${[named, anon].filter(Boolean).join(" and ")} — run 'geml revert' to put them back`);
|
|
1440
|
+
}
|
|
1441
|
+
resolveOutTarget(file, out).write(updated);
|
|
1442
|
+
const list = [...touched].join(", ");
|
|
1443
|
+
console.error(`replaced ${hits.length} occurrence${hits.length > 1 ? "s" : ""}${list ? ` in ${list}` : ""}`);
|
|
1444
|
+
}
|
|
1312
1445
|
const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
|
|
1313
1446
|
// `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
|
|
1314
1447
|
// replace ONE existing block, addressed by #id, with new content, preserving
|
|
@@ -2392,6 +2525,9 @@ const entry = (() => {
|
|
|
2392
2525
|
else if (cmd === "set") {
|
|
2393
2526
|
runSet(argv.slice(1));
|
|
2394
2527
|
}
|
|
2528
|
+
else if (cmd === "replace") {
|
|
2529
|
+
runReplace(argv.slice(1));
|
|
2530
|
+
}
|
|
2395
2531
|
else if (cmd === "add") {
|
|
2396
2532
|
runAdd(argv.slice(1));
|
|
2397
2533
|
}
|
package/dist/geml.js
CHANGED
|
@@ -1137,6 +1137,31 @@ function validateRefs(ctx, opts) {
|
|
|
1137
1137
|
if (ref.kind === "cross") {
|
|
1138
1138
|
if (!ref.doc)
|
|
1139
1139
|
continue;
|
|
1140
|
+
// WHAT `#frag` MEANS IS THE TARGET FORMAT'S BUSINESS, and GEML only
|
|
1141
|
+
// defines it for GEML. In `page.html#sec` the fragment is an element id;
|
|
1142
|
+
// in `notes.md#sec` it is a forge's heading slug or an `<a id>`. Reading
|
|
1143
|
+
// either with GEML's own rules got both directions wrong: it accepted
|
|
1144
|
+
// `{#brace}` that no forge resolves, refused `<a id="x">` and slug
|
|
1145
|
+
// anchors that every forge does, and — this is the part that makes the
|
|
1146
|
+
// check untrustworthy rather than merely strict — passed by ACCIDENT
|
|
1147
|
+
// whenever the name happened to appear anywhere in the target, which is
|
|
1148
|
+
// how this repo's own `../GEML-spec.md#appendix-a-diagnostic-catalogue`
|
|
1149
|
+
// was green: that string is in a LINK there, not a definition.
|
|
1150
|
+
//
|
|
1151
|
+
// So the document must still resolve — a link to a file that is not
|
|
1152
|
+
// there is broken whatever its format — and the fragment is left to the
|
|
1153
|
+
// format that owns it. Same lesson as directories: do not judge another
|
|
1154
|
+
// convention by GEML's rules; a check that guesses teaches people to
|
|
1155
|
+
// ignore it.
|
|
1156
|
+
const gemlTarget = /\.geml$/i.test(ref.doc);
|
|
1157
|
+
if (!gemlTarget && ref.anchor !== undefined && opts.resolveDoc) {
|
|
1158
|
+
// The document still has to be there — a link to a missing file is
|
|
1159
|
+
// broken whatever its format — but nothing here reads its fragment.
|
|
1160
|
+
if (opts.resolveDoc(ref.doc) === null && !opts.docExists?.(ref.doc)) {
|
|
1161
|
+
ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1140
1165
|
if (!opts.resolveDoc) {
|
|
1141
1166
|
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 });
|
|
1142
1167
|
continue;
|
package/dist/mcp.js
CHANGED
|
@@ -275,7 +275,7 @@ export const TOOLS = [
|
|
|
275
275
|
},
|
|
276
276
|
{
|
|
277
277
|
name: "geml_find",
|
|
278
|
-
description: "Search block CONTENT across the served documents and get back ADDRESSES, one row of `<file>\\t<address>` per hit. This is the other half of geml_list: `list` says what a document contains, `find` says which block holds the words you are looking for — and it answers with an address that pastes straight into geml_get or geml_set, never a line number that the next edit invalidates. The address is the innermost block holding the match, and a block that matches on many lines is reported once. Substring, case-insensitive unless `case` is true. Omit `path` to search every `*.geml` under the server root, or give a file or directory to narrow it. No match is not an error: the result is empty.",
|
|
278
|
+
description: "Search block CONTENT across the served documents and get back ADDRESSES, one row of `<file>\\t<address>` per hit. This is the other half of geml_list: `list` says what a document contains, `find` says which block holds the words you are looking for — and it answers with an address that pastes straight into geml_get or geml_set, never a line number that the next edit invalidates. The address is the innermost block holding the match, and a block that matches on many lines is reported once. Substring, case-insensitive unless `case` is true. Omit `path` to search every `*.geml` under the server root, or give a file or directory to narrow it — a file you name is searched whatever its extension, Markdown included, while a directory walks `*.geml` only. No match is not an error: the result is empty.",
|
|
279
279
|
inputSchema: {
|
|
280
280
|
type: "object",
|
|
281
281
|
properties: {
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -58,9 +58,12 @@ already; none is ever created for you. `--dry-run` shows what it would do.
|
|
|
58
58
|
|
|
59
59
|
```sh
|
|
60
60
|
geml list file.geml # CALL THIS FIRST — every block, its address, kind, lines
|
|
61
|
-
geml find "text" file
|
|
61
|
+
geml find "text" file|dir # search block CONTENT -> file<TAB>address (exit 1 = no hit)
|
|
62
|
+
# a NAMED file is searched whatever its extension (.md too);
|
|
63
|
+
# a directory walks *.geml only
|
|
62
64
|
geml get file.geml '#id' # read ONE block (a heading id = its whole section)
|
|
63
65
|
geml set file.geml '#id' --in f # replace ONE block (re-parsed; never writes a broken doc)
|
|
66
|
+
geml replace file.geml OLD NEW # EXPERIMENTAL literal swap; --within '#id' to narrow
|
|
64
67
|
geml history save file.geml -m "…" # snapshot to .gemlhistory after each meaningful edit
|
|
65
68
|
geml revert file.geml '#id' # roll ONE block back (--rev -2 | changed | <rev-id>)
|
|
66
69
|
```
|
|
@@ -78,6 +81,15 @@ under it, so it always contains the intro). `--intro` is how you edit a
|
|
|
78
81
|
section's opening without pulling its subsections into context, and setting an
|
|
79
82
|
empty one writes an opening where the section had none.
|
|
80
83
|
|
|
84
|
+
When the exact old text is already known and nothing needs reading — a version
|
|
85
|
+
string in six places, a renamed term — `geml replace` is the cheap path, and the
|
|
86
|
+
one to prefer over dropping to `sed`: same two short strings, but the result is
|
|
87
|
+
re-parsed before it lands, the blocks it touched are named back to you, and it
|
|
88
|
+
is in `.gemlhistory` to revert. It swaps a LITERAL, never a pattern, and refuses
|
|
89
|
+
a swap that would rename an id (use `geml rename`, which fixes the references
|
|
90
|
+
too). **It is EXPERIMENTAL and may be withdrawn** — reach for it, but do not
|
|
91
|
+
build anything on it that cannot change.
|
|
92
|
+
|
|
81
93
|
A write is refused when it would break the document, never merely because it
|
|
82
94
|
removes something: a replacement that drops blocks is carried out and NAMED on
|
|
83
95
|
stderr — unnamed blocks included — with `geml revert` as the way back. Read,
|
|
@@ -215,7 +215,10 @@ All commands accept `-` to read from stdin.
|
|
|
215
215
|
=== code {#cli-verbs lang=sh}
|
|
216
216
|
geml file.geml # document-model JSON (default --to json)
|
|
217
217
|
geml list file.geml # CALL FIRST: every block, its address, kind, line range
|
|
218
|
-
geml find "text" file
|
|
218
|
+
geml find "text" file|dir # search block CONTENT -> file<TAB>address; exit 1 = no hit
|
|
219
|
+
# a NAMED file is searched whatever its extension — `list`,
|
|
220
|
+
# `get` and `find` all read Markdown, so this addresses a
|
|
221
|
+
# plain README without converting it; a DIRECTORY walks *.geml
|
|
219
222
|
geml get file.geml # same listing as `list` (the no-selector default)
|
|
220
223
|
geml get file.geml '#id' # print ONE block (raw span; --json = model node)
|
|
221
224
|
geml get file.geml '=== note' # every block of a type; '@a3f9c1d2' = a block with no #id
|
|
@@ -223,6 +226,8 @@ geml get file.geml 'L27-58' # position: the smallest block holding tho
|
|
|
223
226
|
geml get file.geml '#sec' --intro # a section cut three ways: --head | --intro | --body
|
|
224
227
|
geml set file.geml '#id' --in f # replace ONE block (guarded: re-parsed, never writes broken)
|
|
225
228
|
geml set file.geml '#sec' --intro # replace just the opening; the subsections stay put
|
|
229
|
+
geml replace file.geml OLD NEW # EXPERIMENTAL, may be withdrawn: literal swap, checked and
|
|
230
|
+
# reported; --within '#id' or '=== type' narrows the scope
|
|
226
231
|
geml add file.geml --after '#id' --in f # insert a fragment (keeps its own ids)
|
|
227
232
|
geml delete file.geml '#id' ['#id2'] # remove one or more blocks
|
|
228
233
|
geml rename file.geml '#old' '#new' # rename an id AND every reference to it
|