@geml/geml 1.7.0 → 1.7.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/README.md +20 -4
- package/codemap/build.mjs +20 -6
- package/codemap/exclude.mjs +18 -14
- package/codemap/refresh.mjs +4 -1
- package/codemap/verify.mjs +4 -1
- package/dist/block-edit.js +15 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2433 -0
- package/dist/from-md.js +7 -2
- package/dist/geml.d.ts +32 -0
- package/dist/geml.js +179 -2039
- package/dist/history.js +7 -3
- package/dist/mcp.js +46 -8
- package/dist/render-html.d.ts +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +215 -17
- package/dist/selector.d.ts +7 -0
- package/dist/selector.js +41 -1
- package/dist/to-md.js +24 -2
- package/package.json +67 -67
- package/skill/SKILL.md +25 -0
- package/skill/references/authoring.geml +6 -1
package/dist/selector.js
CHANGED
|
@@ -20,8 +20,15 @@ import { createHash } from "node:crypto";
|
|
|
20
20
|
export function sha8(text) {
|
|
21
21
|
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex").slice(0, 8);
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// Each optional part carries its OWN trailing whitespace. Written flat —
|
|
24
|
+
// `…[ \t]*(@…)?[ \t]*(\{.*\})?[ \t]*$` — three runs competed for the same tabs
|
|
25
|
+
// and the engine tried every way to divide them: `=== note` plus 8k tabs and
|
|
26
|
+
// one stray byte took 84 SECONDS. Nested, each absent part leaves exactly one
|
|
27
|
+
// run and the match is immediate. Same language: 150k random strings, identical
|
|
28
|
+
// groups. (Mirrors geml.ts's FENCE_OPEN, which had the same shape.)
|
|
29
|
+
const FENCE_SEL = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(?:(@[0-9a-fA-F]{1,}(?:~\d+)?)[ \t]*)?(?:(\{.*\})[ \t]*)?$/;
|
|
24
30
|
const BARE_AT = /^@([0-9a-fA-F]+)(?:~(\d+))?$/;
|
|
31
|
+
const BARE_LINE = /^[Ll](\d+)(?:-(\d+))?$/;
|
|
25
32
|
// Parse selector TEXT. Never touches a document: every form is decided by
|
|
26
33
|
// lexis alone, which is also what keeps the two selector namespaces on
|
|
27
34
|
// `history get <file> <rev> <selector>` from overlapping (history design §10.2).
|
|
@@ -53,6 +60,19 @@ export function parseSelector(raw, attrsIdOf) {
|
|
|
53
60
|
}
|
|
54
61
|
return { form: "type", type };
|
|
55
62
|
}
|
|
63
|
+
// Checked before the id fallthrough, so a bare `L27` is a position. An id
|
|
64
|
+
// that really is spelled `L27` keeps its explicit key form, `#L27` — the
|
|
65
|
+
// same "short form for the common case, key form always available" rule
|
|
66
|
+
// `@<hex>` already relies on.
|
|
67
|
+
const line = s.match(BARE_LINE);
|
|
68
|
+
if (line) {
|
|
69
|
+
const from = Number(line[1]);
|
|
70
|
+
const to = line[2] !== undefined ? Number(line[2]) : from;
|
|
71
|
+
// Reject rather than clamp: `L0` and `L10-5` are typos, and a selector that
|
|
72
|
+
// silently means something else is worse than one that refuses.
|
|
73
|
+
if (from >= 1 && to >= from)
|
|
74
|
+
return { form: "line", from, to };
|
|
75
|
+
}
|
|
56
76
|
// Anything else is an id or a pasted heading line; the caller resolves it.
|
|
57
77
|
return { form: "id", raw: s };
|
|
58
78
|
}
|
|
@@ -104,6 +124,26 @@ export function matchContent(sel, all) {
|
|
|
104
124
|
export function discoveryHint(where) {
|
|
105
125
|
return ` — run \`geml get ${where}\` to list every addressable block`;
|
|
106
126
|
}
|
|
127
|
+
// Match a position selector: the SMALLEST unit that fully contains the range.
|
|
128
|
+
// "Smallest" is what makes this ≤1 match instead of N. Spans nest — a heading's
|
|
129
|
+
// span covers its whole section, so line 30 of the comparison doc sits inside
|
|
130
|
+
// both `#capability-matrix` (L25-65) and `#caps` (L27-58) — and returning both
|
|
131
|
+
// would emit the inner block twice, once alone and once inside its section.
|
|
132
|
+
// The innermost is also the answer the question actually wants: a line number
|
|
133
|
+
// arrived from grep or a stack trace, and the caller means "the thing I have to
|
|
134
|
+
// edit", which is never the enclosing chapter.
|
|
135
|
+
export function matchLine(sel, all) {
|
|
136
|
+
let best;
|
|
137
|
+
for (const a of all) {
|
|
138
|
+
const start = a.unit.span.start + 1; // spans are 0-based; selectors are 1-based, as the listing prints them
|
|
139
|
+
const end = a.unit.span.end;
|
|
140
|
+
if (start > sel.from || end < sel.to)
|
|
141
|
+
continue; // must FULLY contain the range
|
|
142
|
+
if (best === undefined || end - start < best.span.end - (best.span.start + 1))
|
|
143
|
+
best = a.unit;
|
|
144
|
+
}
|
|
145
|
+
return best;
|
|
146
|
+
}
|
|
107
147
|
// Match a type filter: every block of that type in document order. Blocks that
|
|
108
148
|
// carry an id are INCLUDED — the selector says nothing about ids, so filtering
|
|
109
149
|
// by whether one is present would be a rule nobody wrote down (§2).
|
package/dist/to-md.js
CHANGED
|
@@ -48,10 +48,32 @@ function inline(n) {
|
|
|
48
48
|
function seq(ns) {
|
|
49
49
|
return ns.map(inline).join("");
|
|
50
50
|
}
|
|
51
|
+
// Escape a `|` so GFM keeps it inside the cell instead of splitting the row.
|
|
52
|
+
// GFM resolves backslash escapes in a row BEFORE it splits on `|`, so a
|
|
53
|
+
// backslash run sitting right in front of our escape would eat it: a code span
|
|
54
|
+
// holding `a\|b` became `a\\|b`, which reads as a literal backslash followed by
|
|
55
|
+
// an UNescaped pipe — a spurious cell break. Double any such run first, then
|
|
56
|
+
// escape the pipe. Runs already produced by escText (`\\` for a literal
|
|
57
|
+
// backslash) survive this unchanged, so pre-rendered Markdown stays intact.
|
|
58
|
+
//
|
|
59
|
+
// The backslash run is matched as `\\+\|?` — one ATOMIC token, run and pipe
|
|
60
|
+
// together — not as `(\\*)\|`. The latter is quadratic: on a cell holding a
|
|
61
|
+
// long run of backslashes and no pipe, the engine matches the run from every
|
|
62
|
+
// index in it and fails at the required `|` each time. Here the greedy `\\+`
|
|
63
|
+
// takes the whole run in one match and the trailing `\|?` is optional, so
|
|
64
|
+
// nothing backtracks and each character is visited once.
|
|
65
|
+
function escPipe(s) {
|
|
66
|
+
return s.replace(/\\+\|?|\|/g, (m) => {
|
|
67
|
+
if (m.charAt(m.length - 1) !== "|")
|
|
68
|
+
return m; // a run with no pipe after it
|
|
69
|
+
const bs = m.slice(0, -1); // the run that would otherwise eat our escape
|
|
70
|
+
return bs + bs + "\\|";
|
|
71
|
+
});
|
|
72
|
+
}
|
|
51
73
|
// Inline text for a table cell: render inlines, then neutralise the two bytes
|
|
52
74
|
// that would break a GFM cell.
|
|
53
75
|
function cellText(c) {
|
|
54
|
-
return seq(c.inlines)
|
|
76
|
+
return escPipe(seq(c.inlines)).replace(/\n/g, " ");
|
|
55
77
|
}
|
|
56
78
|
// ---------------------------------------------------------------------------
|
|
57
79
|
// Tables
|
|
@@ -72,7 +94,7 @@ function tableToMd(t, notes) {
|
|
|
72
94
|
const lines = [];
|
|
73
95
|
if (t.caption)
|
|
74
96
|
lines.push(`*${t.caption}*`, "");
|
|
75
|
-
lines.push(`| ${cols.map(
|
|
97
|
+
lines.push(`| ${cols.map(escPipe).join(" | ")} |`);
|
|
76
98
|
lines.push(`| ${cols.map((_, i) => sep(t.align[i])).join(" | ")} |`);
|
|
77
99
|
const pad = (cells) => {
|
|
78
100
|
while (cells.length < cols.length)
|
package/package.json
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@geml/geml",
|
|
3
|
-
"version": "1.7.
|
|
4
|
-
"mcpName": "io.github.geml-spec/geml",
|
|
5
|
-
"publishConfig": {
|
|
6
|
-
"access": "public"
|
|
7
|
-
},
|
|
8
|
-
"description": "CLI and parser for GEML, a plain-text document format where every block has an id — so an AI patches one block instead of rewriting the file; edits, reference checks and rollbacks are all per block. Ships an MCP server.",
|
|
9
|
-
"type": "module",
|
|
10
|
-
"bin": {
|
|
11
|
-
"geml": "dist/
|
|
12
|
-
},
|
|
13
|
-
"main": "dist/geml.js",
|
|
14
|
-
"types": "dist/geml.d.ts",
|
|
15
|
-
"files": [
|
|
16
|
-
"dist",
|
|
17
|
-
"codemap",
|
|
18
|
-
"skill",
|
|
19
|
-
"README.md",
|
|
20
|
-
"LICENSE"
|
|
21
|
-
],
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=22"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"geml",
|
|
27
|
-
"general expressive markup language",
|
|
28
|
-
"mcp",
|
|
29
|
-
"mcp-server",
|
|
30
|
-
"markup",
|
|
31
|
-
"markdown",
|
|
32
|
-
"parser",
|
|
33
|
-
"cli",
|
|
34
|
-
"document",
|
|
35
|
-
"typed-block",
|
|
36
|
-
"ai",
|
|
37
|
-
"agent",
|
|
38
|
-
"llm",
|
|
39
|
-
"addressable",
|
|
40
|
-
"versioning",
|
|
41
|
-
"docs",
|
|
42
|
-
"code-graph"
|
|
43
|
-
],
|
|
44
|
-
"repository": {
|
|
45
|
-
"type": "git",
|
|
46
|
-
"url": "git+https://github.com/geml-spec/geml.git",
|
|
47
|
-
"directory": "geml-parser"
|
|
48
|
-
},
|
|
49
|
-
"homepage": "https://github.com/geml-spec/geml#readme",
|
|
50
|
-
"bugs": {
|
|
51
|
-
"url": "https://github.com/geml-spec/geml/issues"
|
|
52
|
-
},
|
|
53
|
-
"scripts": {
|
|
54
|
-
"build": "tsc",
|
|
55
|
-
"test": "tsc && node test/all.mjs",
|
|
56
|
-
"parse": "node dist/geml.js",
|
|
57
|
-
"coverage": "tsc && c8 --all --include=dist/**/*.js --include=codemap/**/*.mjs --reporter=text --reporter=text-summary node test/all.mjs",
|
|
58
|
-
"coverage:check": "tsc && c8 --all --include=dist/**/*.js --include=codemap/**/*.mjs --check-coverage --lines 95 --statements 95 --functions 95 --branches 95 node test/all.mjs",
|
|
59
|
-
"prepublishOnly": "npm run build"
|
|
60
|
-
},
|
|
61
|
-
"license": "MIT",
|
|
62
|
-
"devDependencies": {
|
|
63
|
-
"@types/node": "^22.19.21",
|
|
64
|
-
"c8": "^10.1.3",
|
|
65
|
-
"typescript": "^5.9.3"
|
|
66
|
-
}
|
|
67
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@geml/geml",
|
|
3
|
+
"version": "1.7.2",
|
|
4
|
+
"mcpName": "io.github.geml-spec/geml",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "CLI and parser for GEML, a plain-text document format where every block has an id — so an AI patches one block instead of rewriting the file; edits, reference checks and rollbacks are all per block. Ships an MCP server.",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"geml": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "dist/geml.js",
|
|
14
|
+
"types": "dist/geml.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"codemap",
|
|
18
|
+
"skill",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"geml",
|
|
27
|
+
"general expressive markup language",
|
|
28
|
+
"mcp",
|
|
29
|
+
"mcp-server",
|
|
30
|
+
"markup",
|
|
31
|
+
"markdown",
|
|
32
|
+
"parser",
|
|
33
|
+
"cli",
|
|
34
|
+
"document",
|
|
35
|
+
"typed-block",
|
|
36
|
+
"ai",
|
|
37
|
+
"agent",
|
|
38
|
+
"llm",
|
|
39
|
+
"addressable",
|
|
40
|
+
"versioning",
|
|
41
|
+
"docs",
|
|
42
|
+
"code-graph"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/geml-spec/geml.git",
|
|
47
|
+
"directory": "geml-parser"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/geml-spec/geml#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/geml-spec/geml/issues"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc",
|
|
55
|
+
"test": "tsc && node test/all.mjs",
|
|
56
|
+
"parse": "node dist/geml.js",
|
|
57
|
+
"coverage": "tsc && c8 --all --include=dist/**/*.js --include=codemap/**/*.mjs --reporter=text --reporter=text-summary node test/all.mjs",
|
|
58
|
+
"coverage:check": "tsc && c8 --all --include=dist/**/*.js --include=codemap/**/*.mjs --check-coverage --lines 95 --statements 95 --functions 95 --branches 95 node test/all.mjs",
|
|
59
|
+
"prepublishOnly": "npm run build"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "^22.19.21",
|
|
64
|
+
"c8": "^10.1.3",
|
|
65
|
+
"typescript": "^5.9.3"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -50,15 +50,40 @@ Inside the geml-spec repo prefer the local build:
|
|
|
50
50
|
`node geml-parser/dist/geml.js <args>`. If no parser is reachable, follow the
|
|
51
51
|
golden rules and validate once it is.
|
|
52
52
|
|
|
53
|
+
`geml skill install` sets all of this up user-global, and installs this text
|
|
54
|
+
into whatever other agent tools it detects — a tool's directory has to be there
|
|
55
|
+
already; none is ever created for you. `--dry-run` shows what it would do.
|
|
56
|
+
|
|
53
57
|
## Work blockwise (agent editing)
|
|
54
58
|
|
|
55
59
|
```sh
|
|
60
|
+
geml list file.geml # CALL THIS FIRST — every block, its address, kind, lines
|
|
61
|
+
geml find "text" file.geml|dir # search block CONTENT -> file<TAB>address (exit 1 = no hit)
|
|
56
62
|
geml get file.geml '#id' # read ONE block (a heading id = its whole section)
|
|
57
63
|
geml set file.geml '#id' --in f # replace ONE block (re-parsed; never writes a broken doc)
|
|
58
64
|
geml history save file.geml -m "…" # snapshot to .gemlhistory after each meaningful edit
|
|
59
65
|
geml revert file.geml '#id' # roll ONE block back (--rev -2 | changed | <rev-id>)
|
|
60
66
|
```
|
|
61
67
|
|
|
68
|
+
Address a block, never a line range: `#id` · `'## Heading'` (its whole section)
|
|
69
|
+
· `'=== type'` · `@<hex>` (no id) · `L27` or `L27-58` (the smallest block holding
|
|
70
|
+
those lines — how a line number from an editor, a linter or a diff hunk becomes
|
|
71
|
+
an address). `list` and `find` print addresses that paste straight into the
|
|
72
|
+
others, so neither `grep` nor a line count is needed to locate anything.
|
|
73
|
+
|
|
74
|
+
Any section can be cut three ways, on `get` and `set` alike: `--head` (the
|
|
75
|
+
heading line), `--intro` (what it says before its first subheading — empty when
|
|
76
|
+
one follows immediately, the whole body when none does), `--body` (everything
|
|
77
|
+
under it, so it always contains the intro). `--intro` is how you edit a
|
|
78
|
+
section's opening without pulling its subsections into context, and setting an
|
|
79
|
+
empty one writes an opening where the section had none.
|
|
80
|
+
|
|
81
|
+
A write is refused when it would break the document, never merely because it
|
|
82
|
+
removes something: a replacement that drops blocks is carried out and NAMED on
|
|
83
|
+
stderr — unnamed blocks included — with `geml revert` as the way back. Read,
|
|
84
|
+
edit, write back, and nothing is dropped, because `get` handed those blocks to
|
|
85
|
+
you. Send content that omits them only when removing them is the point.
|
|
86
|
+
|
|
62
87
|
## Full reference — pull ONE section, not the whole file
|
|
63
88
|
|
|
64
89
|
`references/authoring.geml` (under this skill's base directory) holds the
|
|
@@ -214,10 +214,15 @@ All commands accept `-` to read from stdin.
|
|
|
214
214
|
|
|
215
215
|
=== code {#cli-verbs lang=sh}
|
|
216
216
|
geml file.geml # document-model JSON (default --to json)
|
|
217
|
-
geml
|
|
217
|
+
geml list file.geml # CALL FIRST: every block, its address, kind, line range
|
|
218
|
+
geml find "text" file.geml|dir # search block CONTENT -> file<TAB>address; exit 1 = no hit
|
|
219
|
+
geml get file.geml # same listing as `list` (the no-selector default)
|
|
218
220
|
geml get file.geml '#id' # print ONE block (raw span; --json = model node)
|
|
219
221
|
geml get file.geml '=== note' # every block of a type; '@a3f9c1d2' = a block with no #id
|
|
222
|
+
geml get file.geml 'L27-58' # position: the smallest block holding those lines
|
|
223
|
+
geml get file.geml '#sec' --intro # a section cut three ways: --head | --intro | --body
|
|
220
224
|
geml set file.geml '#id' --in f # replace ONE block (guarded: re-parsed, never writes broken)
|
|
225
|
+
geml set file.geml '#sec' --intro # replace just the opening; the subsections stay put
|
|
221
226
|
geml add file.geml --after '#id' --in f # insert a fragment (keeps its own ids)
|
|
222
227
|
geml delete file.geml '#id' ['#id2'] # remove one or more blocks
|
|
223
228
|
geml rename file.geml '#old' '#new' # rename an id AND every reference to it
|