@geml/geml 1.1.1 → 1.3.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 +41 -11
- package/codemap/adapters/crg.mjs +11 -0
- package/codemap/adapters/scip.mjs +481 -52
- package/codemap/browser-stub.mjs +5 -0
- package/codemap/build.mjs +270 -45
- package/codemap/detect.mjs +230 -16
- package/codemap/emit.mjs +78 -7
- package/codemap/entries.mjs +129 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/mcp-server.mjs +44 -15
- package/codemap/normalize.mjs +0 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +158 -8
- package/codemap/serve.mjs +428 -228
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +20 -3
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +2 -1
- package/dist/geml.js +369 -97
- package/dist/history.js +102 -62
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +32 -2
- package/dist/render.js +308 -116
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +4 -0
- package/package.json +63 -58
package/dist/geml.js
CHANGED
|
@@ -8,21 +8,21 @@
|
|
|
8
8
|
// M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
|
|
9
9
|
// media embeds, links, auto-references, footnotes) and build-time reference
|
|
10
10
|
// validation (§8 — unique ids, resolvable internal/cross-document references).
|
|
11
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
11
|
+
import { readFileSync, writeFileSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
15
15
|
import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
16
|
-
import { renderHtml } from "./render.js";
|
|
16
|
+
import { renderHtml } from "./render-html.js";
|
|
17
17
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
18
|
-
import { parseInline } from "./inline.js";
|
|
18
|
+
import { META_REF_SRC, parseInline } from "./inline.js";
|
|
19
19
|
import { parseTable } from "./table.js";
|
|
20
20
|
import { buildChart } from "./chart.js";
|
|
21
21
|
import { mdToGeml } from "./from-md.js";
|
|
22
22
|
import { serialize } from "./serialize.js";
|
|
23
23
|
import { gemlToMd } from "./to-md.js";
|
|
24
24
|
export { mdToGeml } from "./from-md.js";
|
|
25
|
-
export { renderHtml } from "./render.js";
|
|
25
|
+
export { renderHtml } from "./render-html.js";
|
|
26
26
|
export { serialize } from "./serialize.js";
|
|
27
27
|
export { gemlToMd } from "./to-md.js";
|
|
28
28
|
// Type registry: which body mode each typed block uses. Unknown types are a
|
|
@@ -34,6 +34,7 @@ const REGISTRY = {
|
|
|
34
34
|
table: "raw", // structured table parsing lands in M3
|
|
35
35
|
output: "raw", // captured result of a code block (stored, never executed)
|
|
36
36
|
note: "flow",
|
|
37
|
+
text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
|
|
37
38
|
meta: "data",
|
|
38
39
|
};
|
|
39
40
|
// §7: built-in diagram renderer registry. Unknown formats are a warning (the
|
|
@@ -45,6 +46,11 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
|
|
|
45
46
|
const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
|
|
46
47
|
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
|
|
47
48
|
const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
49
|
+
// Maximum block/list nesting depth the recursive-descent scanner will build
|
|
50
|
+
// before emitting a diagnostic instead of recursing further. Guards parse()
|
|
51
|
+
// (scanBlocks / parseList) and, in step, the renderer against a deeply nested
|
|
52
|
+
// document overflowing the call stack (DoS). 256 is far past any real document.
|
|
53
|
+
const MAX_NESTING = 256;
|
|
48
54
|
function isCloseFence(line, openLen) {
|
|
49
55
|
const t = line.replace(/\s+$/, "");
|
|
50
56
|
return /^=+$/.test(t) && t.length === openLen;
|
|
@@ -62,15 +68,67 @@ function slug(text) {
|
|
|
62
68
|
// ---------------------------------------------------------------------------
|
|
63
69
|
// §4: substitute `{{key}}` in flow text with the matching `=== meta` value.
|
|
64
70
|
// An unknown key is a build error (single-source-of-truth, fail loudly).
|
|
71
|
+
// The scan mirrors the §5.3(1) verbatim atoms: a `{{key}}` inside a code span
|
|
72
|
+
// or inline math is left untouched (so GEML prose can document this very
|
|
73
|
+
// syntax), and a backslash-escaped character can neither open a span nor a
|
|
74
|
+
// `{{…}}` reference — `\{{key}}` renders as the literal text `{{key}}`.
|
|
75
|
+
const META_REF = new RegExp(META_REF_SRC, "y");
|
|
65
76
|
function interpolate(text, line, ctx) {
|
|
66
77
|
if (!text.includes("{{"))
|
|
67
78
|
return text;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
let out = "";
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < text.length) {
|
|
82
|
+
const c = text[i];
|
|
83
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
84
|
+
out += c + text[i + 1];
|
|
85
|
+
i += 2;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === "`") {
|
|
89
|
+
let n = 0;
|
|
90
|
+
while (text[i + n] === "`")
|
|
91
|
+
n++;
|
|
92
|
+
const close = text.indexOf("`".repeat(n), i + n);
|
|
93
|
+
if (close >= 0) {
|
|
94
|
+
out += text.slice(i, close + n);
|
|
95
|
+
i = close + n;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
out += text.slice(i, i + n); // unclosed run: literal, keep scanning
|
|
99
|
+
i += n;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (c === "$") {
|
|
103
|
+
const close = text.indexOf("$", i + 1);
|
|
104
|
+
if (close > i + 1) {
|
|
105
|
+
out += text.slice(i, close + 1);
|
|
106
|
+
i = close + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out += c;
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === "{" && text[i + 1] === "{") {
|
|
114
|
+
META_REF.lastIndex = i;
|
|
115
|
+
const m = META_REF.exec(text);
|
|
116
|
+
if (m) {
|
|
117
|
+
const key = m[1];
|
|
118
|
+
if (ctx.meta.has(key))
|
|
119
|
+
out += ctx.meta.get(key);
|
|
120
|
+
else {
|
|
121
|
+
ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
|
|
122
|
+
out += m[0];
|
|
123
|
+
}
|
|
124
|
+
i = META_REF.lastIndex;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
out += c;
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
74
132
|
}
|
|
75
133
|
// Register a block id, flagging duplicates as errors (§4: ids unique per doc).
|
|
76
134
|
function registerId(ctx, id, line) {
|
|
@@ -122,6 +180,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
122
180
|
const root = mkList(matchMarker(lines[i]));
|
|
123
181
|
const stack = [{ list: root, indent: matchMarker(lines[i]).indent }];
|
|
124
182
|
let prevBlank = false;
|
|
183
|
+
let tooDeep = false;
|
|
125
184
|
while (i < lines.length) {
|
|
126
185
|
if (lines[i].trim() === "") {
|
|
127
186
|
prevBlank = true;
|
|
@@ -139,9 +198,21 @@ function parseList(lines, i, base, ctx) {
|
|
|
139
198
|
const parent = top.list.items[top.list.items.length - 1];
|
|
140
199
|
if (!parent)
|
|
141
200
|
break; // deeper indent with no parent item: defensive stop
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
201
|
+
if (stack.length >= MAX_NESTING) {
|
|
202
|
+
// Refuse to nest deeper than the cap: keep the item at the current level
|
|
203
|
+
// rather than building a model that overflows the renderer (DoS). One
|
|
204
|
+
// diagnostic per over-deep list; content is preserved, just flattened.
|
|
205
|
+
if (!tooDeep) {
|
|
206
|
+
ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
|
|
207
|
+
tooDeep = true;
|
|
208
|
+
}
|
|
209
|
+
cur = top.list;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
cur = mkList(mk);
|
|
213
|
+
(parent.children ??= []).push(cur);
|
|
214
|
+
stack.push({ list: cur, indent: mk.indent });
|
|
215
|
+
}
|
|
145
216
|
}
|
|
146
217
|
else {
|
|
147
218
|
// §5: a change of marker type (bullet ↔ ordered) at the same level ends
|
|
@@ -159,7 +230,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
159
230
|
}
|
|
160
231
|
return { block: root, next: i };
|
|
161
232
|
}
|
|
162
|
-
function scanBlocks(lines, base, ctx) {
|
|
233
|
+
function scanBlocks(lines, base, ctx, depth = 0) {
|
|
163
234
|
const blocks = [];
|
|
164
235
|
const diags = ctx.diags;
|
|
165
236
|
let i = 0;
|
|
@@ -242,7 +313,16 @@ function scanBlocks(lines, base, ctx) {
|
|
|
242
313
|
ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
|
|
243
314
|
}
|
|
244
315
|
if (mode === "flow") {
|
|
245
|
-
|
|
316
|
+
if (depth >= MAX_NESTING) {
|
|
317
|
+
// Refuse to recurse past the cap: emit a diagnostic and keep the body
|
|
318
|
+
// as raw so the parser returns cleanly instead of overflowing the
|
|
319
|
+
// call stack on a pathologically nested document (DoS).
|
|
320
|
+
diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
|
|
321
|
+
block.raw = body;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
block.children = scanBlocks(body, base + i + 1, ctx, depth + 1);
|
|
325
|
+
}
|
|
246
326
|
}
|
|
247
327
|
else if (mode === "data") {
|
|
248
328
|
block.data = parseData(body);
|
|
@@ -453,8 +533,46 @@ export function parse(source, opts = {}) {
|
|
|
453
533
|
}
|
|
454
534
|
// The id that a fence/heading line defines, matching how scanBlocks derives it
|
|
455
535
|
// (parseAttrs for the attribute object; heading text slug when no explicit id).
|
|
456
|
-
|
|
457
|
-
|
|
536
|
+
// The slug MUST come from the INTERPOLATED text — scanBlocks slugs after
|
|
537
|
+
// interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
|
|
538
|
+
// the raw text here would create a phantom id the parser never registered and
|
|
539
|
+
// make the real one unaddressable. `ctx` is an inert context carrying the
|
|
540
|
+
// document's meta (diagnostics are discarded — spans never report).
|
|
541
|
+
function idOfHeading(braces, text, line, ctx) {
|
|
542
|
+
return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
|
|
543
|
+
}
|
|
544
|
+
// The matching close of the fence opened at lines[i] (equal-length run, or the
|
|
545
|
+
// labeled `=== #id` close when the block carries an id): the index just past
|
|
546
|
+
// the close line, and whether one was found — an unterminated block runs to
|
|
547
|
+
// the end of the scope.
|
|
548
|
+
function fenceClose(lines, i, open) {
|
|
549
|
+
const openLen = open[1].length;
|
|
550
|
+
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
551
|
+
const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
|
|
552
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
553
|
+
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
|
|
554
|
+
return { end: j + 1, closed: true };
|
|
555
|
+
}
|
|
556
|
+
return { end: lines.length, closed: false };
|
|
557
|
+
}
|
|
558
|
+
// A heading's span covers its whole SECTION: the heading line through the line
|
|
559
|
+
// just before the next heading of same-or-higher level (fewer-or-equal `#`) in
|
|
560
|
+
// the current scope, or end-of-scope. Fenced blocks are skipped whole — a `#`
|
|
561
|
+
// line inside a `=== code` body is content, never a boundary.
|
|
562
|
+
function sectionEnd(lines, i, level) {
|
|
563
|
+
let j = i + 1;
|
|
564
|
+
while (j < lines.length) {
|
|
565
|
+
const open = FENCE_OPEN.exec(lines[j]);
|
|
566
|
+
if (open) {
|
|
567
|
+
j = fenceClose(lines, j, open).end;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const h = HEADING.exec(lines[j]);
|
|
571
|
+
if (h && h[1].length <= level)
|
|
572
|
+
return j;
|
|
573
|
+
j++;
|
|
574
|
+
}
|
|
575
|
+
return lines.length;
|
|
458
576
|
}
|
|
459
577
|
// Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
|
|
460
578
|
// or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
|
|
@@ -462,7 +580,7 @@ function idOfHeading(braces, text) {
|
|
|
462
580
|
// First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
|
|
463
581
|
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
464
582
|
// absolute line offset of this slice within the whole document.
|
|
465
|
-
function collectSpans(lines, base, out) {
|
|
583
|
+
function collectSpans(lines, base, out, ctx, depth = 0) {
|
|
466
584
|
const add = (id, start, end) => {
|
|
467
585
|
if (!out.has(id))
|
|
468
586
|
out.set(id, { start, end });
|
|
@@ -486,33 +604,27 @@ function collectSpans(lines, base, out) {
|
|
|
486
604
|
} // hidden line: no id
|
|
487
605
|
const open = FENCE_OPEN.exec(line);
|
|
488
606
|
if (open) {
|
|
489
|
-
const openLen = open[1].length;
|
|
490
607
|
const type = open[2];
|
|
491
608
|
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
492
|
-
const
|
|
493
|
-
let j = i + 1;
|
|
494
|
-
let closed = false;
|
|
495
|
-
for (; j < lines.length; j++) {
|
|
496
|
-
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
|
|
497
|
-
closed = true;
|
|
498
|
-
break;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
const end = closed ? j + 1 : j;
|
|
609
|
+
const { end, closed } = fenceClose(lines, i, open);
|
|
502
610
|
if (id !== undefined)
|
|
503
611
|
add(id, base + i, base + end);
|
|
504
612
|
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
505
613
|
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
506
614
|
// the parser's contract.
|
|
507
|
-
if ((REGISTRY[type] ?? "raw") === "flow") {
|
|
508
|
-
collectSpans(lines.slice(i + 1, closed ?
|
|
615
|
+
if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
|
|
616
|
+
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
|
|
509
617
|
}
|
|
510
618
|
i = end;
|
|
511
619
|
continue;
|
|
512
620
|
}
|
|
513
621
|
const h = HEADING.exec(line);
|
|
514
622
|
if (h) {
|
|
515
|
-
|
|
623
|
+
// Section span (heading through its prose and nested blocks). The walk
|
|
624
|
+
// still advances one line at a time so every nested id inside the
|
|
625
|
+
// section registers its own span — spans intentionally OVERLAP: #sec
|
|
626
|
+
// contains #code, and each remains addressable on its own.
|
|
627
|
+
add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
|
|
516
628
|
i++;
|
|
517
629
|
continue;
|
|
518
630
|
}
|
|
@@ -523,29 +635,49 @@ function collectSpans(lines, base, out) {
|
|
|
523
635
|
// with the physical lines produced by splitLines(source).
|
|
524
636
|
export function blockSpans(source) {
|
|
525
637
|
const out = new Map();
|
|
526
|
-
|
|
638
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
639
|
+
// Inert context: heading auto-ids slug the interpolated text (parser parity);
|
|
640
|
+
// its diagnostics are discarded — the span scan never reports.
|
|
641
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
642
|
+
collectSpans(lines, 0, out, ctx);
|
|
527
643
|
return out;
|
|
528
644
|
}
|
|
529
645
|
// Split into physical lines while *keeping* each line's terminator, so
|
|
530
646
|
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
647
|
+
// A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
|
|
648
|
+
// the span scan's `\r\n?` -> `\n` normalization sees, so span indices always
|
|
649
|
+
// address the same lines the parser counted.
|
|
531
650
|
function splitLines(source) {
|
|
532
|
-
return source.split(/(?<=\n)/);
|
|
651
|
+
return source.split(/(?<=\n|\r(?!\n))/);
|
|
652
|
+
}
|
|
653
|
+
// `--head`: narrow any id's span to its HEAD line — the single declaring line
|
|
654
|
+
// (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
|
|
655
|
+
// `[^id]:` line). The head is by construction the FIRST line of the span, so
|
|
656
|
+
// the narrowing is parse-free and needs no type check. Main use: `set --head`
|
|
657
|
+
// edits a block's attributes (caption/compute/lang/…) without re-sending its
|
|
658
|
+
// body, or renames a heading without rewriting its section.
|
|
659
|
+
function narrowToHead(span) {
|
|
660
|
+
return { start: span.start, end: span.start + 1 };
|
|
533
661
|
}
|
|
534
662
|
// Depth-first search for the document-model node carrying `id`, descending into
|
|
535
663
|
// flow-block children (and list-item children) so a nested id is found too.
|
|
536
|
-
|
|
537
|
-
|
|
664
|
+
// Returns the containing sibling array and index, not just the node: the model
|
|
665
|
+
// is FLAT — a heading does not own its section; the section's prose and blocks
|
|
666
|
+
// are its FOLLOWING SIBLINGS — so a section consumer needs the array.
|
|
667
|
+
function findBlockSite(blocks, id) {
|
|
668
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
669
|
+
const b = blocks[i];
|
|
538
670
|
if ((b.kind === "heading" || b.kind === "block") && b.id === id)
|
|
539
|
-
return
|
|
671
|
+
return { siblings: blocks, index: i };
|
|
540
672
|
if (b.kind === "block" && b.children) {
|
|
541
|
-
const hit =
|
|
673
|
+
const hit = findBlockSite(b.children, id);
|
|
542
674
|
if (hit)
|
|
543
675
|
return hit;
|
|
544
676
|
}
|
|
545
677
|
if (b.kind === "list") {
|
|
546
678
|
for (const it of b.items) {
|
|
547
679
|
if (it.children) {
|
|
548
|
-
const hit =
|
|
680
|
+
const hit = findBlockSite(it.children, id);
|
|
549
681
|
if (hit)
|
|
550
682
|
return hit;
|
|
551
683
|
}
|
|
@@ -554,6 +686,21 @@ function findBlockById(blocks, id) {
|
|
|
554
686
|
}
|
|
555
687
|
return undefined;
|
|
556
688
|
}
|
|
689
|
+
// Model-side section boundary: within one sibling array, the section opened by
|
|
690
|
+
// the heading at index k runs to the next sibling heading of same-or-higher
|
|
691
|
+
// level, or the array end. This is the SAME rule sectionEnd() applies to raw
|
|
692
|
+
// source lines (where skipping fenced bodies makes "next heading" well-defined)
|
|
693
|
+
// — the two sides must stay in lockstep; the get-set suite pins their parity
|
|
694
|
+
// (ids covered by the raw slice == ids covered by the --json envelope).
|
|
695
|
+
function sectionEndIndex(siblings, k) {
|
|
696
|
+
const level = siblings[k].level;
|
|
697
|
+
for (let m = k + 1; m < siblings.length; m++) {
|
|
698
|
+
const b = siblings[m];
|
|
699
|
+
if (b.kind === "heading" && b.level <= level)
|
|
700
|
+
return m;
|
|
701
|
+
}
|
|
702
|
+
return siblings.length;
|
|
703
|
+
}
|
|
557
704
|
// ---------------------------------------------------------------------------
|
|
558
705
|
// CLI
|
|
559
706
|
// ---------------------------------------------------------------------------
|
|
@@ -572,45 +719,52 @@ function parseStamp(s) {
|
|
|
572
719
|
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
573
720
|
}
|
|
574
721
|
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
575
|
-
const PARSER_VERSION = "1.
|
|
576
|
-
const USAGE = `geml — GEML reference CLI
|
|
577
|
-
|
|
578
|
-
Usage:
|
|
579
|
-
geml <file.geml|-> parse -> document-model JSON (stdout)
|
|
580
|
-
geml get <file.geml|-> #id [--json]
|
|
581
|
-
|
|
582
|
-
geml
|
|
583
|
-
geml
|
|
584
|
-
geml
|
|
585
|
-
|
|
586
|
-
geml
|
|
587
|
-
geml
|
|
588
|
-
geml
|
|
589
|
-
geml
|
|
590
|
-
geml
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
722
|
+
const PARSER_VERSION = "1.3.2"; // reference implementation; keep in sync with package.json
|
|
723
|
+
const USAGE = `geml — GEML reference CLI
|
|
724
|
+
|
|
725
|
+
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
|
|
732
|
+
(--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> [...]
|
|
738
|
+
geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
|
|
739
|
+
geml --help | --version [--json]
|
|
740
|
+
|
|
741
|
+
Use '-' as the file to read from stdin.
|
|
742
|
+
Exit codes:
|
|
743
|
+
0 ok
|
|
744
|
+
1 document/operation error
|
|
745
|
+
2 command usage error.
|
|
746
|
+
`;
|
|
594
747
|
// One-line usage for each subcommand — the single source for both the error
|
|
595
748
|
// shown on misuse and the `<cmd> --help` text.
|
|
596
749
|
const SUBHELP = {
|
|
597
|
-
get: "usage: geml get <file.geml|-> #id [--json]",
|
|
598
|
-
set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml]",
|
|
599
|
-
check: "usage: geml check <file.geml|-> [--json]",
|
|
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]",
|
|
752
|
+
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
600
753
|
render: "usage: geml render <file.geml|-> [-o out.html]",
|
|
601
754
|
convert: "usage: geml convert <file.md|-> [-o out.geml]",
|
|
602
755
|
export: "usage: geml export <file.geml|-> [-o out.md]",
|
|
603
756
|
fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
|
|
604
|
-
revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] (sel: -N | latest | id-prefix; default -1)",
|
|
757
|
+
revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--head] (sel: -N | latest | id-prefix; default -1)",
|
|
605
758
|
history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
|
|
606
|
-
codemap: `usage: geml codemap build --root <repo> # auto-detect languages, run the indexer(s), and merge into one codemap
|
|
607
|
-
geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ --root <repo> [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
|
|
608
|
-
geml codemap verify [dir] geml check + profile reference checks
|
|
609
|
-
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
610
|
-
geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
|
|
611
|
-
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
612
|
-
geml codemap
|
|
613
|
-
|
|
759
|
+
codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
|
|
760
|
+
geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
|
|
761
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
762
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
763
|
+
geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
|
|
764
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
765
|
+
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
766
|
+
geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
|
|
767
|
+
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
614
768
|
};
|
|
615
769
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
616
770
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
@@ -634,12 +788,66 @@ function readInput(file) {
|
|
|
634
788
|
fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
|
|
635
789
|
}
|
|
636
790
|
}
|
|
637
|
-
// A cross-document resolver rooted at the input's directory (cwd for stdin)
|
|
638
|
-
|
|
639
|
-
|
|
791
|
+
// A cross-document resolver rooted at the input's directory (cwd for stdin),
|
|
792
|
+
// CONFINED to that directory's subtree. A reference that resolves outside the
|
|
793
|
+
// base — via a `..` escape, an absolute path, or (on Windows) a different drive
|
|
794
|
+
// — is refused (returns null, i.e. an unresolvable ref) so a crafted document
|
|
795
|
+
// cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
|
|
796
|
+
//
|
|
797
|
+
// A purely LEXICAL check is not enough: a symlink that sits lexically inside the
|
|
798
|
+
// subtree but points to `../../outside.geml` passes `path.relative` yet reads an
|
|
799
|
+
// external target. So after the cheap lexical gate we resolve BOTH the base and
|
|
800
|
+
// the target through `realpathSync` (following every symlink component) and
|
|
801
|
+
// re-check that the REAL target still lies within the REAL base subtree before
|
|
802
|
+
// reading. A target that does not exist makes `realpathSync` throw — handled as
|
|
803
|
+
// an ordinary unresolvable ref (null), never a crash.
|
|
804
|
+
//
|
|
805
|
+
// `root` (CLI `--root`, an explicit per-invocation user grant — never
|
|
806
|
+
// document-controlled) widens the confinement base from the input's own
|
|
807
|
+
// directory to an ancestor the user names, so repo-relative `../` references
|
|
808
|
+
// between sibling directories can be checked. It moves WHERE the boundary
|
|
809
|
+
// stands, never whether it is enforced: both gates below run against the
|
|
810
|
+
// widened base, so escapes past the root are refused exactly as above. The
|
|
811
|
+
// viewer/web surfaces never pass a root — their boundary is unchanged.
|
|
812
|
+
function resolverFor(file, root) {
|
|
813
|
+
const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
|
|
814
|
+
const baseAbs = root === undefined ? dirAbs : resolvePath(root);
|
|
815
|
+
// Canonicalise the base once. If the base itself cannot be realpath'd, no
|
|
816
|
+
// cross-doc ref can be safely confined — resolve nothing.
|
|
817
|
+
let realBase = null;
|
|
818
|
+
try {
|
|
819
|
+
realBase = realpathSync(baseAbs);
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
realBase = null;
|
|
823
|
+
}
|
|
824
|
+
const outside = (from, to) => {
|
|
825
|
+
const rel = relative(from, to);
|
|
826
|
+
return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
|
|
827
|
+
};
|
|
640
828
|
return (d) => {
|
|
829
|
+
if (realBase === null)
|
|
830
|
+
return null;
|
|
831
|
+
// References resolve FROM the document's own directory; the gates below
|
|
832
|
+
// confine them to the (possibly widened) base.
|
|
833
|
+
const targetAbs = resolvePath(dirAbs, d);
|
|
834
|
+
// Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
|
|
835
|
+
// before touching the filesystem.
|
|
836
|
+
if (outside(baseAbs, targetAbs))
|
|
837
|
+
return null;
|
|
838
|
+
// Real (symlink-resolved) gate: a symlink pointing out of the subtree
|
|
839
|
+
// resolves to a real path outside `realBase` and is refused here.
|
|
840
|
+
let realTarget;
|
|
641
841
|
try {
|
|
642
|
-
|
|
842
|
+
realTarget = realpathSync(targetAbs);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
if (outside(realBase, realTarget))
|
|
848
|
+
return null;
|
|
849
|
+
try {
|
|
850
|
+
return readFileSync(realTarget, "utf8");
|
|
643
851
|
}
|
|
644
852
|
catch {
|
|
645
853
|
return null;
|
|
@@ -650,10 +858,22 @@ function resolverFor(file) {
|
|
|
650
858
|
// dump (cheap for agents). `--json` prints the diagnostics array for machines.
|
|
651
859
|
function runCheck(args) {
|
|
652
860
|
const json = args.includes("--json");
|
|
653
|
-
const
|
|
861
|
+
const root = flag(args, "--root");
|
|
862
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
|
|
654
863
|
if (!file)
|
|
655
864
|
fail(SUBHELP.check);
|
|
656
|
-
|
|
865
|
+
// A mistyped --root must be a usage error (exit 2), not a wall of misleading
|
|
866
|
+
// "cannot resolve document" errors from a resolver confined to nothing.
|
|
867
|
+
if (root !== undefined) {
|
|
868
|
+
let isDir = false;
|
|
869
|
+
try {
|
|
870
|
+
isDir = statSync(root).isDirectory();
|
|
871
|
+
}
|
|
872
|
+
catch { /* missing -> not a dir */ }
|
|
873
|
+
if (!isDir)
|
|
874
|
+
fail(`--root ${root} is not a directory`);
|
|
875
|
+
}
|
|
876
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
|
|
657
877
|
if (json) {
|
|
658
878
|
console.log(JSON.stringify(doc.diagnostics, null, 2));
|
|
659
879
|
}
|
|
@@ -851,30 +1071,51 @@ function positionals(args, valued) {
|
|
|
851
1071
|
}
|
|
852
1072
|
// `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
|
|
853
1073
|
// without loading the rest of the document into context. Default output is the
|
|
854
|
-
// block's exact source bytes
|
|
855
|
-
// a heading
|
|
1074
|
+
// block's exact source bytes: a typed block's full `=== … ===` span, a
|
|
1075
|
+
// footnote's line, or — for a heading — its whole SECTION (heading line through
|
|
1076
|
+
// the line before the next same-or-higher heading). `--json` covers the same
|
|
1077
|
+
// content: a block/footnote id prints its document-model node; a heading id
|
|
1078
|
+
// prints a section envelope `{kind:"section", id, level, blocks:[heading,
|
|
1079
|
+
// …siblings up to the boundary]}`.
|
|
856
1080
|
function runGet(args) {
|
|
857
1081
|
const json = args.includes("--json");
|
|
1082
|
+
const headOnly = args.includes("--head");
|
|
858
1083
|
const [file, rawId] = positionals(args, []);
|
|
859
1084
|
if (!file || !rawId)
|
|
860
1085
|
fail(SUBHELP.get);
|
|
861
1086
|
const id = rawId.replace(/^#/, "");
|
|
862
1087
|
const source = readInput(file);
|
|
863
1088
|
if (json) {
|
|
864
|
-
// The model node — same
|
|
865
|
-
// to resolve the tree (and nested-block ids), but only the
|
|
1089
|
+
// The model node(s) — same shapes `geml <file>` emits. Parsing is needed
|
|
1090
|
+
// to resolve the tree (and nested-block ids), but only the target prints.
|
|
866
1091
|
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
867
|
-
const
|
|
868
|
-
if (!
|
|
1092
|
+
const site = findBlockSite(doc.children, id);
|
|
1093
|
+
if (!site)
|
|
869
1094
|
fail(`no block with id \`${id}\``, 1);
|
|
1095
|
+
const block = site.siblings[site.index];
|
|
1096
|
+
// `--head` on a heading suppresses the section envelope (the lone heading
|
|
1097
|
+
// node IS the head). On a block/footnote id there is nothing finer than
|
|
1098
|
+
// the single node — the model has no sub-node for "just the fence line" —
|
|
1099
|
+
// so --head refines only the RAW output there.
|
|
1100
|
+
if (block.kind === "heading" && !headOnly) {
|
|
1101
|
+
// A heading id addresses its SECTION, so `--json` covers the same
|
|
1102
|
+
// content as the raw span: a self-describing envelope whose blocks[0]
|
|
1103
|
+
// is the heading node followed by its siblings up to the boundary.
|
|
1104
|
+
// `kind: "section"` lets a consumer branch — a block/footnote id still
|
|
1105
|
+
// yields the single model node (the model itself stays flat).
|
|
1106
|
+
const end = sectionEndIndex(site.siblings, site.index);
|
|
1107
|
+
console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
870
1110
|
console.log(JSON.stringify(block, null, 2));
|
|
871
1111
|
return;
|
|
872
1112
|
}
|
|
873
1113
|
// Raw: slice the source span byte-for-byte. No parse required, so `get` still
|
|
874
1114
|
// returns the exact bytes even if the document has diagnostics elsewhere.
|
|
875
|
-
const
|
|
876
|
-
if (!
|
|
1115
|
+
const found = blockSpans(source).get(id);
|
|
1116
|
+
if (!found)
|
|
877
1117
|
fail(`no block with id \`${id}\``, 1);
|
|
1118
|
+
const span = headOnly ? narrowToHead(found) : found;
|
|
878
1119
|
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
879
1120
|
}
|
|
880
1121
|
// `geml set <file.geml|-> #id [--from FILE] [-o out]` — replace ONLY that
|
|
@@ -885,6 +1126,7 @@ function runGet(args) {
|
|
|
885
1126
|
function runSet(args) {
|
|
886
1127
|
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
887
1128
|
const from = flag(args, "--from");
|
|
1129
|
+
const headOnly = args.includes("--head");
|
|
888
1130
|
const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
|
|
889
1131
|
if (!file || !rawId)
|
|
890
1132
|
fail(SUBHELP.set);
|
|
@@ -905,7 +1147,7 @@ function runSet(args) {
|
|
|
905
1147
|
if (replacement === "")
|
|
906
1148
|
fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
|
|
907
1149
|
}
|
|
908
|
-
const updated = spliceBlock(source, id, replacement, file);
|
|
1150
|
+
const updated = spliceBlock(source, id, replacement, file, headOnly);
|
|
909
1151
|
if (out) {
|
|
910
1152
|
writeFileSync(out, updated);
|
|
911
1153
|
console.error(`wrote ${out}`);
|
|
@@ -919,15 +1161,21 @@ function runSet(args) {
|
|
|
919
1161
|
// can silently swallow a neighbour). Returns the updated document text; on any
|
|
920
1162
|
// violation it calls fail() and never returns a corrupt document. Shared by
|
|
921
1163
|
// `set` and `revert`.
|
|
922
|
-
function spliceBlock(source, id, replacement, file) {
|
|
923
|
-
const
|
|
924
|
-
if (!
|
|
1164
|
+
function spliceBlock(source, id, replacement, file, headOnly = false) {
|
|
1165
|
+
const found = blockSpans(source).get(id);
|
|
1166
|
+
if (!found)
|
|
925
1167
|
fail(`no block with id \`${id}\``, 1);
|
|
926
1168
|
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
927
1169
|
// Keep the bytes before and after the target span exactly; give the new block
|
|
928
1170
|
// a single trailing newline so the following block still starts on its own
|
|
929
1171
|
// line (unless it is the file's last line, which may legitimately lack one).
|
|
930
1172
|
const orig = splitLines(source);
|
|
1173
|
+
// `--head`: splice only the id's head line; everything below stays
|
|
1174
|
+
// byte-identical. The guard below still applies — the replacement must
|
|
1175
|
+
// re-declare `{#id}` and, for a typed block, keep the fence pairing intact
|
|
1176
|
+
// (an opening line that no longer matches the untouched close fence breaks
|
|
1177
|
+
// the re-parse), or the splice is refused.
|
|
1178
|
+
const span = headOnly ? narrowToHead(found) : found;
|
|
931
1179
|
const before = orig.slice(0, span.start);
|
|
932
1180
|
const after = orig.slice(span.end);
|
|
933
1181
|
let inject = replacement.replace(/\r\n?/g, "\n");
|
|
@@ -964,6 +1212,7 @@ function spliceBlock(source, id, replacement, file) {
|
|
|
964
1212
|
function runRevert(args) {
|
|
965
1213
|
const changed = args.includes("--changed");
|
|
966
1214
|
const dryRun = args.includes("--dry-run");
|
|
1215
|
+
const headOnly = args.includes("--head");
|
|
967
1216
|
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
968
1217
|
const to = flag(args, "--to") ?? "-1";
|
|
969
1218
|
const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
|
|
@@ -974,15 +1223,19 @@ function runRevert(args) {
|
|
|
974
1223
|
const id = rawId.replace(/^#/, "");
|
|
975
1224
|
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
976
1225
|
const source = readInput(file);
|
|
977
|
-
const
|
|
978
|
-
if (!
|
|
1226
|
+
const found = blockSpans(source).get(id);
|
|
1227
|
+
if (!found)
|
|
979
1228
|
fail(`no block with id \`${id}\` in ${file}`, 1);
|
|
1229
|
+
const curSpan = headOnly ? narrowToHead(found) : found;
|
|
980
1230
|
const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
|
|
981
1231
|
// Extract block #id's source from a reconstructed revision (undefined if the
|
|
982
|
-
// block did not exist there).
|
|
1232
|
+
// block did not exist there). Under `--head`, extract only the head line.
|
|
983
1233
|
const pick = (text) => {
|
|
984
1234
|
const s = blockSpans(text).get(id);
|
|
985
|
-
|
|
1235
|
+
if (!s)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
const span = headOnly ? narrowToHead(s) : s;
|
|
1238
|
+
return splitLines(text).slice(span.start, span.end).join("");
|
|
986
1239
|
};
|
|
987
1240
|
// Resolve the source revision, formatting any history-layer error cleanly.
|
|
988
1241
|
const target = (() => {
|
|
@@ -1011,7 +1264,7 @@ function runRevert(args) {
|
|
|
1011
1264
|
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1012
1265
|
return;
|
|
1013
1266
|
}
|
|
1014
|
-
const updated = spliceBlock(source, id, oldBlock, file);
|
|
1267
|
+
const updated = spliceBlock(source, id, oldBlock, file, headOnly);
|
|
1015
1268
|
const dest = out ?? file;
|
|
1016
1269
|
writeFileSync(dest, updated);
|
|
1017
1270
|
console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
|
|
@@ -1027,6 +1280,7 @@ function runCodemap(args) {
|
|
|
1027
1280
|
render: "render-all.mjs",
|
|
1028
1281
|
serve: "serve.mjs",
|
|
1029
1282
|
refresh: "refresh.mjs",
|
|
1283
|
+
find: "find.mjs",
|
|
1030
1284
|
mcp: "mcp-server.mjs",
|
|
1031
1285
|
};
|
|
1032
1286
|
const sub = args[0] ?? "";
|
|
@@ -1037,10 +1291,28 @@ function runCodemap(args) {
|
|
|
1037
1291
|
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
1038
1292
|
process.exit(r.status ?? 1);
|
|
1039
1293
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1294
|
+
// npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
|
|
1295
|
+
// CLI" by resolving argv[1] to its real path, not by its spelling.
|
|
1296
|
+
const entry = (() => {
|
|
1297
|
+
const argv1 = process.argv[1];
|
|
1298
|
+
if (!argv1)
|
|
1299
|
+
return "";
|
|
1300
|
+
try {
|
|
1301
|
+
return realpathSync(argv1);
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
return argv1;
|
|
1305
|
+
}
|
|
1306
|
+
})();
|
|
1307
|
+
// `entry` must be non-empty: in a browser bundle both sides degenerate to ""
|
|
1308
|
+
// (esbuild defines process.argv=[] and import.meta.url="", and the node-stub's
|
|
1309
|
+
// fileURLToPath is String()), which would run the CLI at import time and crash
|
|
1310
|
+
// the page. A real CLI invocation always has argv[1].
|
|
1311
|
+
if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.ts"))) {
|
|
1042
1312
|
const argv = process.argv.slice(2);
|
|
1043
|
-
|
|
1313
|
+
// The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
|
|
1314
|
+
// command from the directory name — accept those spellings as `codemap`.
|
|
1315
|
+
const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
|
|
1044
1316
|
jsonMode = argv.includes("--json");
|
|
1045
1317
|
const rest = argv.slice(1);
|
|
1046
1318
|
if (cmd === "--help" || cmd === "-h") {
|