@geml/geml 1.0.0
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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/attrs.d.ts +9 -0
- package/dist/attrs.js +64 -0
- package/dist/chart.d.ts +28 -0
- package/dist/chart.js +128 -0
- package/dist/from-md.d.ts +5 -0
- package/dist/from-md.js +242 -0
- package/dist/geml.d.ts +70 -0
- package/dist/geml.js +730 -0
- package/dist/history.d.ts +55 -0
- package/dist/history.js +507 -0
- package/dist/inline.d.ts +52 -0
- package/dist/inline.js +418 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +508 -0
- package/dist/serialize.d.ts +2 -0
- package/dist/serialize.js +187 -0
- package/dist/table.d.ts +32 -0
- package/dist/table.js +469 -0
- package/dist/to-md.d.ts +5 -0
- package/dist/to-md.js +213 -0
- package/package.json +54 -0
package/dist/geml.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GEML reference parser — Milestones 1 & 2: block scanner + inline content.
|
|
3
|
+
//
|
|
4
|
+
// M1: typed-block fences (equal-length close + longer-fence nesting), the
|
|
5
|
+
// `meta` data block, ATX headings, lists and paragraphs, the attribute object
|
|
6
|
+
// with §4 value typing, and a document-model JSON serialization.
|
|
7
|
+
//
|
|
8
|
+
// M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
|
|
9
|
+
// media embeds, links, auto-references, footnotes) and build-time reference
|
|
10
|
+
// validation (§8 — unique ids, resolvable internal/cross-document references).
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, resolve as resolvePath } from "node:path";
|
|
13
|
+
import { commit, restore, verify } from "./history.js";
|
|
14
|
+
import { renderHtml } from "./render.js";
|
|
15
|
+
import { coerce, parseAttrs } from "./attrs.js";
|
|
16
|
+
import { parseInline } from "./inline.js";
|
|
17
|
+
import { parseTable } from "./table.js";
|
|
18
|
+
import { buildChart } from "./chart.js";
|
|
19
|
+
import { mdToGeml } from "./from-md.js";
|
|
20
|
+
import { serialize } from "./serialize.js";
|
|
21
|
+
import { gemlToMd } from "./to-md.js";
|
|
22
|
+
export { mdToGeml } from "./from-md.js";
|
|
23
|
+
export { renderHtml } from "./render.js";
|
|
24
|
+
export { serialize } from "./serialize.js";
|
|
25
|
+
export { gemlToMd } from "./to-md.js";
|
|
26
|
+
// Type registry: which body mode each typed block uses. Unknown types are a
|
|
27
|
+
// warning and fall back to `raw` (forward compatibility, §3/§8).
|
|
28
|
+
const REGISTRY = {
|
|
29
|
+
code: "raw",
|
|
30
|
+
diagram: "raw",
|
|
31
|
+
math: "raw",
|
|
32
|
+
table: "raw", // structured table parsing lands in M3
|
|
33
|
+
output: "raw", // captured result of a code block (stored, never executed)
|
|
34
|
+
note: "flow",
|
|
35
|
+
aside: "flow",
|
|
36
|
+
meta: "data",
|
|
37
|
+
};
|
|
38
|
+
// §7: built-in diagram renderer registry. Unknown formats are a warning (the
|
|
39
|
+
// processor keeps the body raw rather than interpreting it).
|
|
40
|
+
const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart"]);
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Lexical helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
|
|
45
|
+
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
|
|
46
|
+
const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
47
|
+
function isCloseFence(line, openLen) {
|
|
48
|
+
const t = line.replace(/\s+$/, "");
|
|
49
|
+
return /^=+$/.test(t) && t.length === openLen;
|
|
50
|
+
}
|
|
51
|
+
function slug(text) {
|
|
52
|
+
return text
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/`[^`]*`/g, "")
|
|
55
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/\s+/g, "-");
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Block scanner
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// §4: substitute `{{key}}` in flow text with the matching `=== meta` value.
|
|
63
|
+
// An unknown key is a build error (single-source-of-truth, fail loudly).
|
|
64
|
+
function interpolate(text, line, ctx) {
|
|
65
|
+
if (!text.includes("{{"))
|
|
66
|
+
return text;
|
|
67
|
+
return text.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g, (full, key) => {
|
|
68
|
+
if (ctx.meta.has(key))
|
|
69
|
+
return ctx.meta.get(key);
|
|
70
|
+
ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
|
|
71
|
+
return full;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// Register a block id, flagging duplicates as errors (§4: ids unique per doc).
|
|
75
|
+
function registerId(ctx, id, line) {
|
|
76
|
+
if (ctx.ids.has(id)) {
|
|
77
|
+
ctx.diags.push({ severity: "error", message: `duplicate id \`#${id}\` (first defined at line ${ctx.ids.get(id)})`, line });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
ctx.ids.set(id, line);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// §5: a list marker — `-`/`*` (unordered) or `N.` (ordered) — capturing the
|
|
84
|
+
// leading indent (in spaces; a tab counts as one) and the item content. Nesting
|
|
85
|
+
// is decided by that indent.
|
|
86
|
+
const MARKER = /^([ \t]*)(?:[-*]|(\d+)\.)[ \t]+(.*)$/;
|
|
87
|
+
function matchMarker(line) {
|
|
88
|
+
const m = MARKER.exec(line);
|
|
89
|
+
if (!m)
|
|
90
|
+
return null;
|
|
91
|
+
const ordered = m[2] !== undefined;
|
|
92
|
+
const mk = { indent: m[1].length, ordered, rest: m[3] };
|
|
93
|
+
if (ordered)
|
|
94
|
+
mk.start = parseInt(m[2], 10);
|
|
95
|
+
return mk;
|
|
96
|
+
}
|
|
97
|
+
function makeListItem(mk, lineNo, ctx) {
|
|
98
|
+
let text = interpolate(mk.rest, lineNo, ctx);
|
|
99
|
+
// Task list item: a leading `[ ]` (open) or `[x]`/`[X]` (done) marker.
|
|
100
|
+
const task = /^\[([ xX])\](?:[ \t]+(.*))?$/.exec(text);
|
|
101
|
+
const item = { text, inlines: [] };
|
|
102
|
+
if (task) {
|
|
103
|
+
item.checked = task[1] !== " ";
|
|
104
|
+
text = task[2] ?? "";
|
|
105
|
+
item.text = text;
|
|
106
|
+
}
|
|
107
|
+
item.inlines = parseInline(text, lineNo, ctx);
|
|
108
|
+
return item;
|
|
109
|
+
}
|
|
110
|
+
// §5: parse one list, nesting sub-lists by indentation. A list is a run of marker
|
|
111
|
+
// lines; a deeper indent opens a sub-list under the preceding item, a shallower
|
|
112
|
+
// indent closes back to an outer list, a blank line between siblings makes the
|
|
113
|
+
// list *loose*, and any non-marker line ends the list.
|
|
114
|
+
function parseList(lines, i, base, ctx) {
|
|
115
|
+
const mkList = (m) => {
|
|
116
|
+
const l = { kind: "list", ordered: m.ordered, items: [] };
|
|
117
|
+
if (m.ordered && m.start !== undefined)
|
|
118
|
+
l.start = m.start;
|
|
119
|
+
return l;
|
|
120
|
+
};
|
|
121
|
+
const root = mkList(matchMarker(lines[i]));
|
|
122
|
+
const stack = [{ list: root, indent: matchMarker(lines[i]).indent }];
|
|
123
|
+
let prevBlank = false;
|
|
124
|
+
while (i < lines.length) {
|
|
125
|
+
if (lines[i].trim() === "") {
|
|
126
|
+
prevBlank = true;
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const mk = matchMarker(lines[i]);
|
|
131
|
+
if (!mk)
|
|
132
|
+
break; // a non-marker line ends the list
|
|
133
|
+
while (stack.length > 1 && mk.indent < stack[stack.length - 1].indent)
|
|
134
|
+
stack.pop();
|
|
135
|
+
const top = stack[stack.length - 1];
|
|
136
|
+
let cur;
|
|
137
|
+
if (mk.indent > top.indent) {
|
|
138
|
+
const parent = top.list.items[top.list.items.length - 1];
|
|
139
|
+
if (!parent)
|
|
140
|
+
break; // deeper indent with no parent item: defensive stop
|
|
141
|
+
cur = mkList(mk);
|
|
142
|
+
(parent.children ??= []).push(cur);
|
|
143
|
+
stack.push({ list: cur, indent: mk.indent });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
cur = top.list;
|
|
147
|
+
}
|
|
148
|
+
if (prevBlank && cur.items.length > 0)
|
|
149
|
+
cur.loose = true;
|
|
150
|
+
cur.items.push(makeListItem(mk, base + i + 1, ctx));
|
|
151
|
+
prevBlank = false;
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
return { block: root, next: i };
|
|
155
|
+
}
|
|
156
|
+
function scanBlocks(lines, base, ctx) {
|
|
157
|
+
const blocks = [];
|
|
158
|
+
const diags = ctx.diags;
|
|
159
|
+
let i = 0;
|
|
160
|
+
while (i < lines.length) {
|
|
161
|
+
const line = lines[i];
|
|
162
|
+
if (line.trim() === "") {
|
|
163
|
+
i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// A `%%` line is hidden: kept in the model (tools can find it), never
|
|
167
|
+
// rendered, and not inline-parsed (so a scratch note can't break the build).
|
|
168
|
+
const hid = /^[ \t]*%%[ \t]?(.*)$/.exec(line);
|
|
169
|
+
if (hid) {
|
|
170
|
+
blocks.push({ kind: "hidden", text: hid[1] });
|
|
171
|
+
i++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// §5.2: a Markdown-style footnote definition `[^id]: text` defines the
|
|
175
|
+
// target a `[^id]` reference points at — recorded as a note block with that
|
|
176
|
+
// id, so the reference resolves. (A model that reaches for Markdown
|
|
177
|
+
// footnotes by habit then "just works" instead of leaving a dangling ref.)
|
|
178
|
+
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
179
|
+
if (fndef) {
|
|
180
|
+
const id = fndef[1].trim();
|
|
181
|
+
const lineNo = base + i + 1;
|
|
182
|
+
registerId(ctx, id, lineNo);
|
|
183
|
+
const text = interpolate(fndef[2], lineNo, ctx);
|
|
184
|
+
blocks.push({
|
|
185
|
+
kind: "block", type: "note", mode: "flow", id, classes: ["footnote"], attrs: {},
|
|
186
|
+
children: [{ kind: "paragraph", text, inlines: parseInline(text, lineNo, ctx) }],
|
|
187
|
+
});
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const open = FENCE_OPEN.exec(line);
|
|
192
|
+
if (open) {
|
|
193
|
+
const openLen = open[1].length;
|
|
194
|
+
const type = open[2];
|
|
195
|
+
const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
|
|
196
|
+
const openLineNo = base + i + 1;
|
|
197
|
+
// Collect the body. A block closes on a bare fence of exactly the opening
|
|
198
|
+
// length, OR — when it has an id — on a labeled fence `=== #id` (a `=` run
|
|
199
|
+
// of any length ≥ 3 followed by the block's id). The labeled close is a
|
|
200
|
+
// *local* close: it can't be gotten wrong by miscounting `=`, so it is the
|
|
201
|
+
// safe way to nest (§3).
|
|
202
|
+
const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${attrs.id}[ \\t]*$`) : null;
|
|
203
|
+
const body = [];
|
|
204
|
+
let j = i + 1;
|
|
205
|
+
let closed = false;
|
|
206
|
+
for (; j < lines.length; j++) {
|
|
207
|
+
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
|
|
208
|
+
closed = true;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
body.push(lines[j]);
|
|
212
|
+
}
|
|
213
|
+
if (!closed) {
|
|
214
|
+
const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
|
|
215
|
+
diags.push({ severity: "error", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
|
|
216
|
+
}
|
|
217
|
+
let mode = REGISTRY[type];
|
|
218
|
+
if (mode === undefined) {
|
|
219
|
+
diags.push({ severity: "warning", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
|
|
220
|
+
mode = "raw";
|
|
221
|
+
}
|
|
222
|
+
const block = {
|
|
223
|
+
kind: "block", type, mode, classes: attrs.classes, attrs: attrs.attrs,
|
|
224
|
+
};
|
|
225
|
+
if (attrs.id !== undefined) {
|
|
226
|
+
block.id = attrs.id;
|
|
227
|
+
registerId(ctx, attrs.id, openLineNo);
|
|
228
|
+
}
|
|
229
|
+
if (attrs.attrs["hidden"] === true)
|
|
230
|
+
block.hidden = true; // §4: not rendered, still in model
|
|
231
|
+
// §3: an `output` block stores a code block's captured result; `of=#id`
|
|
232
|
+
// (when present) binds it to that block and is checked like any reference.
|
|
233
|
+
if (type === "output" && typeof attrs.attrs["of"] === "string") {
|
|
234
|
+
const of = attrs.attrs["of"];
|
|
235
|
+
if (of.startsWith("#"))
|
|
236
|
+
ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
|
|
237
|
+
}
|
|
238
|
+
if (mode === "flow") {
|
|
239
|
+
block.children = scanBlocks(body, base + i + 1, ctx);
|
|
240
|
+
}
|
|
241
|
+
else if (mode === "data") {
|
|
242
|
+
block.data = parseData(body);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
block.raw = body;
|
|
246
|
+
if (type === "table") {
|
|
247
|
+
// §6: parse the raw body (visual or csv/tsv) into one table model.
|
|
248
|
+
const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
|
|
249
|
+
block.table = model;
|
|
250
|
+
for (const d of diagnostics)
|
|
251
|
+
diags.push({ ...d, line: openLineNo });
|
|
252
|
+
// First definition wins, matching ctx.ids (a duplicate id is already
|
|
253
|
+
// reported as an error by registerId).
|
|
254
|
+
if (block.id !== undefined && !ctx.tables?.has(block.id)) {
|
|
255
|
+
(ctx.tables ??= new Map()).set(block.id, model);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (type === "diagram") {
|
|
259
|
+
const fmt = attrs.attrs["format"];
|
|
260
|
+
if (fmt === "geml-chart") {
|
|
261
|
+
// §7: native chart — resolved in a second pass (data=#id may be
|
|
262
|
+
// defined later in the document).
|
|
263
|
+
if (body.length > 0 && body.some((l) => l.trim() !== "")) {
|
|
264
|
+
diags.push({ severity: "warning", message: "geml-chart body is ignored; the chart spec lives in attributes", line: openLineNo });
|
|
265
|
+
}
|
|
266
|
+
(ctx.charts ??= []).push({ block, line: openLineNo });
|
|
267
|
+
}
|
|
268
|
+
else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
|
|
269
|
+
// §7: warn on a diagram format with no registered renderer.
|
|
270
|
+
diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
blocks.push(block);
|
|
275
|
+
i = closed ? j + 1 : j;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const h = HEADING.exec(line);
|
|
279
|
+
if (h) {
|
|
280
|
+
const lineNo = base + i + 1;
|
|
281
|
+
const level = h[1].length;
|
|
282
|
+
const a = h[3] ? parseAttrs(h[3]) : { classes: [], attrs: {} };
|
|
283
|
+
const text = interpolate(h[2], lineNo, ctx);
|
|
284
|
+
const id = a.id ?? slug(text);
|
|
285
|
+
registerId(ctx, id, lineNo);
|
|
286
|
+
const block = {
|
|
287
|
+
kind: "heading", level, text, inlines: parseInline(text, lineNo, ctx), id, classes: a.classes, attrs: a.attrs,
|
|
288
|
+
};
|
|
289
|
+
if (a.attrs["hidden"] === true)
|
|
290
|
+
block.hidden = true;
|
|
291
|
+
blocks.push(block);
|
|
292
|
+
i++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (LIST_ITEM.test(line)) {
|
|
296
|
+
const { block, next } = parseList(lines, i, base, ctx);
|
|
297
|
+
blocks.push(block);
|
|
298
|
+
i = next;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// Paragraph: consecutive non-blank lines that start no other construct.
|
|
302
|
+
const paraStart = base + i + 1;
|
|
303
|
+
const para = [];
|
|
304
|
+
while (i < lines.length &&
|
|
305
|
+
lines[i].trim() !== "" &&
|
|
306
|
+
!/^[ \t]*%%/.test(lines[i]) &&
|
|
307
|
+
!FENCE_OPEN.test(lines[i]) &&
|
|
308
|
+
!HEADING.test(lines[i]) &&
|
|
309
|
+
!LIST_ITEM.test(lines[i])) {
|
|
310
|
+
para.push(lines[i]);
|
|
311
|
+
i++;
|
|
312
|
+
}
|
|
313
|
+
const text = interpolate(para.join("\n"), paraStart, ctx);
|
|
314
|
+
blocks.push({ kind: "paragraph", text, inlines: parseInline(text, paraStart, ctx) });
|
|
315
|
+
}
|
|
316
|
+
return blocks;
|
|
317
|
+
}
|
|
318
|
+
// Parse `key = val` lines of a `data`-mode block (e.g. meta), §4 value typing.
|
|
319
|
+
function parseData(lines) {
|
|
320
|
+
const out = {};
|
|
321
|
+
for (const raw of lines) {
|
|
322
|
+
if (raw.trim() === "")
|
|
323
|
+
continue;
|
|
324
|
+
const eq = raw.indexOf("=");
|
|
325
|
+
if (eq <= 0)
|
|
326
|
+
continue;
|
|
327
|
+
out[raw.slice(0, eq).trim()] = coerce(raw.slice(eq + 1));
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Public API
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Collect the block ids of a (cross-document) source, without validation, for
|
|
335
|
+
// resolving `other.geml#id` references.
|
|
336
|
+
function gatherIds(source) {
|
|
337
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
|
|
338
|
+
scanBlocks(source.replace(/\r\n?/g, "\n").split("\n"), 0, ctx);
|
|
339
|
+
return new Set(ctx.ids.keys());
|
|
340
|
+
}
|
|
341
|
+
// Pre-scan for `=== meta` blocks (at any fence depth) and merge their
|
|
342
|
+
// `key=val` lines, so `{{key}}` interpolation can resolve forward references.
|
|
343
|
+
function collectMeta(lines) {
|
|
344
|
+
const meta = new Map();
|
|
345
|
+
for (let i = 0; i < lines.length; i++) {
|
|
346
|
+
const open = FENCE_OPEN.exec(lines[i]);
|
|
347
|
+
if (!open || open[2] !== "meta")
|
|
348
|
+
continue;
|
|
349
|
+
const len = open[1].length;
|
|
350
|
+
const body = [];
|
|
351
|
+
let j = i + 1;
|
|
352
|
+
for (; j < lines.length && !isCloseFence(lines[j], len); j++)
|
|
353
|
+
body.push(lines[j]);
|
|
354
|
+
for (const [k, v] of Object.entries(parseData(body)))
|
|
355
|
+
meta.set(k, String(v));
|
|
356
|
+
i = j;
|
|
357
|
+
}
|
|
358
|
+
return meta;
|
|
359
|
+
}
|
|
360
|
+
// §8: resolve every discovered reference. Internal/autoref/footnote anchors
|
|
361
|
+
// must exist in this document; cross-document anchors must resolve in the
|
|
362
|
+
// target file when a `resolveDoc` hook is supplied (else reported as unchecked).
|
|
363
|
+
function validateRefs(ctx, opts) {
|
|
364
|
+
const docIds = new Map(); // memoized cross-doc id sets
|
|
365
|
+
for (const ref of ctx.refs) {
|
|
366
|
+
if (ref.kind === "cross") {
|
|
367
|
+
if (!ref.doc)
|
|
368
|
+
continue;
|
|
369
|
+
if (!opts.resolveDoc) {
|
|
370
|
+
ctx.diags.push({ severity: "warning", message: `cross-document reference \`${ref.doc}${ref.anchor ? "#" + ref.anchor : ""}\` not checked (no document resolver)`, line: ref.line });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
let ids = docIds.get(ref.doc);
|
|
374
|
+
if (ids === undefined) {
|
|
375
|
+
const src = opts.resolveDoc(ref.doc);
|
|
376
|
+
if (src === null) {
|
|
377
|
+
ctx.diags.push({ severity: "error", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
|
|
378
|
+
docIds.set(ref.doc, new Set());
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
ids = gatherIds(src);
|
|
382
|
+
docIds.set(ref.doc, ids);
|
|
383
|
+
}
|
|
384
|
+
if (ref.anchor !== undefined && !ids.has(ref.anchor)) {
|
|
385
|
+
ctx.diags.push({ severity: "error", message: `unresolved reference \`${ref.doc}#${ref.anchor}\``, line: ref.line });
|
|
386
|
+
}
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
// internal, autoref, footnote — anchor must be a known id in this document.
|
|
390
|
+
if (ref.anchor !== undefined && !ctx.ids.has(ref.anchor)) {
|
|
391
|
+
const what = ref.kind === "footnote" ? `footnote \`[^${ref.anchor}]\`` : `reference \`#${ref.anchor}\``;
|
|
392
|
+
ctx.diags.push({ severity: "error", message: `unresolved ${what}`, line: ref.line });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// §7: resolve every geml-chart against its referenced table. Runs after the
|
|
397
|
+
// scan so that `data=#id` may point at a table defined anywhere in the doc.
|
|
398
|
+
function resolveCharts(ctx) {
|
|
399
|
+
for (const { block, line } of ctx.charts ?? []) {
|
|
400
|
+
const ref = typeof block.attrs["data"] === "string" ? block.attrs["data"] : "";
|
|
401
|
+
const id = ref.replace(/^#/, "");
|
|
402
|
+
if (id === "") {
|
|
403
|
+
ctx.diags.push({ severity: "error", message: "geml-chart: missing `data=#id`", line });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const table = ctx.tables?.get(id);
|
|
407
|
+
if (!table) {
|
|
408
|
+
const what = ctx.ids.has(id) ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
|
|
409
|
+
ctx.diags.push({ severity: "error", message: `geml-chart: ${what}`, line });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (table.src !== undefined) {
|
|
413
|
+
// §6: the table's data is external (src=), loaded at render time. The
|
|
414
|
+
// chart is therefore resolved at render time too — its column references
|
|
415
|
+
// are checked there, not here — so skip build-time chart resolution.
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const { model, diagnostics } = buildChart(block.attrs, table);
|
|
419
|
+
if (model)
|
|
420
|
+
block.chart = model;
|
|
421
|
+
for (const d of diagnostics)
|
|
422
|
+
ctx.diags.push({ ...d, line });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
export function parse(source, opts = {}) {
|
|
426
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
427
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
428
|
+
const children = scanBlocks(lines, 0, ctx);
|
|
429
|
+
resolveCharts(ctx);
|
|
430
|
+
validateRefs(ctx, opts);
|
|
431
|
+
return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
|
|
432
|
+
}
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// CLI
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
function flag(args, name) {
|
|
437
|
+
const i = args.indexOf(name);
|
|
438
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
439
|
+
}
|
|
440
|
+
function historyPathFor(geml) {
|
|
441
|
+
return geml.replace(/\.geml$/, "") + ".gemlhistory";
|
|
442
|
+
}
|
|
443
|
+
function parseStamp(s) {
|
|
444
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(s);
|
|
445
|
+
if (!m)
|
|
446
|
+
throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
|
|
447
|
+
const [, y, mo, d, h, mi, se] = m;
|
|
448
|
+
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
449
|
+
}
|
|
450
|
+
const VERSION = "1.0-draft"; // GEML spec version this CLI targets
|
|
451
|
+
const PARSER_VERSION = "1.0.0"; // reference implementation; keep in sync with package.json
|
|
452
|
+
const USAGE = `geml — GEML reference CLI
|
|
453
|
+
|
|
454
|
+
Usage:
|
|
455
|
+
geml <file.geml|-> parse -> document-model JSON (stdout)
|
|
456
|
+
geml check <file.geml|-> [--json] validate only: diagnostics + exit code
|
|
457
|
+
geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
|
|
458
|
+
geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
|
|
459
|
+
geml convert <file.md|-> [-o out.geml] Markdown -> GEML
|
|
460
|
+
geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
|
|
461
|
+
geml history <commit|verify|show|restore> <file.geml> [...]
|
|
462
|
+
geml --help | --version [--json]
|
|
463
|
+
|
|
464
|
+
Use '-' as the file to read from stdin.
|
|
465
|
+
Exit codes: 0 ok · 1 document/operation error · 2 usage error.`;
|
|
466
|
+
// One-line usage for each subcommand — the single source for both the error
|
|
467
|
+
// shown on misuse and the `<cmd> --help` text.
|
|
468
|
+
const SUBHELP = {
|
|
469
|
+
check: "usage: geml check <file.geml|-> [--json]",
|
|
470
|
+
render: "usage: geml render <file.geml|-> [-o out.html]",
|
|
471
|
+
convert: "usage: geml convert <file.md|-> [-o out.geml]",
|
|
472
|
+
export: "usage: geml export <file.geml|-> [-o out.md]",
|
|
473
|
+
fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
|
|
474
|
+
history: "usage: geml history <commit|verify|show|restore> <file.geml> [...]",
|
|
475
|
+
};
|
|
476
|
+
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
477
|
+
// envelope so an agent that standardizes on --json never has to parse text.
|
|
478
|
+
let jsonMode = false;
|
|
479
|
+
// Clean one-line error + non-zero exit — never a raw Node stack trace.
|
|
480
|
+
function fail(msg) {
|
|
481
|
+
if (jsonMode)
|
|
482
|
+
console.error(JSON.stringify({ error: msg, code: 2 }));
|
|
483
|
+
else
|
|
484
|
+
console.error(`error: ${msg}`);
|
|
485
|
+
process.exit(2);
|
|
486
|
+
}
|
|
487
|
+
// Read a file, or stdin when the path is "-". On failure emit a clean error.
|
|
488
|
+
function readInput(file) {
|
|
489
|
+
try {
|
|
490
|
+
return readFileSync(file === "-" ? 0 : file, "utf8");
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// A cross-document resolver rooted at the input's directory (cwd for stdin).
|
|
497
|
+
function resolverFor(file) {
|
|
498
|
+
const baseDir = file === "-" ? "." : dirname(file);
|
|
499
|
+
return (d) => {
|
|
500
|
+
try {
|
|
501
|
+
return readFileSync(resolvePath(baseDir, d), "utf8");
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
// `geml check <file>` — validate only: diagnostics + exit code, no document
|
|
509
|
+
// dump (cheap for agents). `--json` prints the diagnostics array for machines.
|
|
510
|
+
function runCheck(args) {
|
|
511
|
+
const json = args.includes("--json");
|
|
512
|
+
const file = args.find((a) => a === "-" || !a.startsWith("-"));
|
|
513
|
+
if (!file)
|
|
514
|
+
fail(SUBHELP.check);
|
|
515
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
516
|
+
if (json) {
|
|
517
|
+
console.log(JSON.stringify(doc.diagnostics, null, 2));
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
for (const d of doc.diagnostics)
|
|
521
|
+
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
522
|
+
const errs = doc.diagnostics.filter((d) => d.severity === "error").length;
|
|
523
|
+
const warns = doc.diagnostics.filter((d) => d.severity === "warning").length;
|
|
524
|
+
console.error(errs || warns ? `${errs} error(s), ${warns} warning(s)` : "ok: no diagnostics");
|
|
525
|
+
}
|
|
526
|
+
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
// Map a thrown error from the history layer to a clean one-line message —
|
|
530
|
+
// never a raw node:fs stack trace, and without leaking the absolute path the
|
|
531
|
+
// runtime resolved (we report the relative path the user actually passed).
|
|
532
|
+
function historyError(e, file, historyPath) {
|
|
533
|
+
const err = e;
|
|
534
|
+
if (err?.code === "ENOENT") {
|
|
535
|
+
const p = err.path ?? "";
|
|
536
|
+
if (p.endsWith(basename(historyPath)))
|
|
537
|
+
return `cannot read history ${historyPath}`;
|
|
538
|
+
return `cannot read ${file}`;
|
|
539
|
+
}
|
|
540
|
+
return err?.message ?? String(e);
|
|
541
|
+
}
|
|
542
|
+
function runHistory(args) {
|
|
543
|
+
const sub = args[0];
|
|
544
|
+
const file = args[1];
|
|
545
|
+
if (!sub || !file)
|
|
546
|
+
fail(SUBHELP.history);
|
|
547
|
+
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
548
|
+
try {
|
|
549
|
+
if (sub === "commit") {
|
|
550
|
+
const at = flag(args, "--at");
|
|
551
|
+
const r = commit({
|
|
552
|
+
gemlPath: file,
|
|
553
|
+
historyPath,
|
|
554
|
+
summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
|
|
555
|
+
author: flag(args, "--author"),
|
|
556
|
+
at: at ? parseStamp(at) : undefined,
|
|
557
|
+
});
|
|
558
|
+
console.log(`committed ${r.id}`);
|
|
559
|
+
}
|
|
560
|
+
else if (sub === "verify") {
|
|
561
|
+
const res = verify(historyPath, file);
|
|
562
|
+
for (const e of res.errors)
|
|
563
|
+
console.error(`error: ${e}`);
|
|
564
|
+
for (const w of res.warnings)
|
|
565
|
+
console.error(`warning: ${w}`);
|
|
566
|
+
console.log(`verify: ${res.ok ? "OK" : "FAILED"} (${res.checked} revisions reconstructed & hashed)`);
|
|
567
|
+
if (!res.ok)
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
else if (sub === "show") {
|
|
571
|
+
const rev = args[2];
|
|
572
|
+
if (!rev)
|
|
573
|
+
fail("usage: geml history show <file.geml> <revision>");
|
|
574
|
+
process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
|
|
575
|
+
}
|
|
576
|
+
else if (sub === "restore") {
|
|
577
|
+
const rev = args[2];
|
|
578
|
+
if (!rev)
|
|
579
|
+
fail("usage: geml history restore <file.geml> <revision> [--force]");
|
|
580
|
+
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
581
|
+
console.log(`restored ${file} to ${rev}`);
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch (e) {
|
|
588
|
+
fail(historyError(e, file, historyPath));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// `geml convert <file.md|-> [-o out.geml]` — Markdown -> GEML.
|
|
592
|
+
function runConvert(args) {
|
|
593
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== flag(args, "-o")));
|
|
594
|
+
if (!file)
|
|
595
|
+
fail(SUBHELP.convert);
|
|
596
|
+
const { geml, notes } = mdToGeml(readInput(file));
|
|
597
|
+
for (const n of notes)
|
|
598
|
+
console.error(`note: ${n}`);
|
|
599
|
+
const outPath = flag(args, "-o") ?? flag(args, "--out");
|
|
600
|
+
if (outPath) {
|
|
601
|
+
writeFileSync(outPath, geml);
|
|
602
|
+
console.error(`wrote ${outPath}`);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
process.stdout.write(geml);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// `geml export <file.geml|-> [-o out.md]` — GEML -> Markdown (lossy). Writes
|
|
609
|
+
// the output even with diagnostics, prints any lossy-projection notes, and
|
|
610
|
+
// exits non-zero on a parse error — same contract as render.
|
|
611
|
+
function runExport(args) {
|
|
612
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
613
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
|
|
614
|
+
if (!file)
|
|
615
|
+
fail(SUBHELP.export);
|
|
616
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
617
|
+
const { md, notes } = gemlToMd(doc);
|
|
618
|
+
if (out) {
|
|
619
|
+
writeFileSync(out, md);
|
|
620
|
+
console.error(`wrote ${out}`);
|
|
621
|
+
}
|
|
622
|
+
else
|
|
623
|
+
process.stdout.write(md);
|
|
624
|
+
for (const n of notes)
|
|
625
|
+
console.error(`note: ${n}`);
|
|
626
|
+
for (const d of doc.diagnostics)
|
|
627
|
+
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
628
|
+
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
// `geml render <file.geml> [-o out.html]` — GEML -> one self-contained,
|
|
632
|
+
// interactive HTML artifact (the P0 runtime). Writes the file even when there
|
|
633
|
+
// are diagnostics (a viewer should still show what it can), but exits non-zero
|
|
634
|
+
// on any error so CI and agents get a hard signal.
|
|
635
|
+
function runRender(args) {
|
|
636
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
637
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
|
|
638
|
+
if (!file)
|
|
639
|
+
fail(SUBHELP.render);
|
|
640
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
641
|
+
const html = renderHtml(doc, { source: file === "-" ? "stdin" : basename(file) });
|
|
642
|
+
if (out) {
|
|
643
|
+
writeFileSync(out, html);
|
|
644
|
+
console.error(`wrote ${out}`);
|
|
645
|
+
}
|
|
646
|
+
else
|
|
647
|
+
process.stdout.write(html);
|
|
648
|
+
for (const d of doc.diagnostics)
|
|
649
|
+
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
650
|
+
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
653
|
+
// `geml fmt <file.geml> [-o out.geml]` — re-serialize the document model into
|
|
654
|
+
// canonical GEML. Because `serialize` is the inverse of `parse`, `fmt` is a
|
|
655
|
+
// pretty-printer whose output parses back to the same model (round-trip stable).
|
|
656
|
+
function runFmt(args) {
|
|
657
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
658
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== out));
|
|
659
|
+
if (!file)
|
|
660
|
+
fail(SUBHELP.fmt);
|
|
661
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
662
|
+
const text = serialize(doc);
|
|
663
|
+
if (out) {
|
|
664
|
+
writeFileSync(out, text);
|
|
665
|
+
console.error(`wrote ${out}`);
|
|
666
|
+
}
|
|
667
|
+
else
|
|
668
|
+
process.stdout.write(text);
|
|
669
|
+
// A broken document must not be reported as a clean format. Surface the
|
|
670
|
+
// diagnostics and exit non-zero, matching parse/render/check.
|
|
671
|
+
for (const d of doc.diagnostics)
|
|
672
|
+
console.error(`${d.severity}: ${d.message} (line ${d.line})`);
|
|
673
|
+
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
const entry = process.argv[1] ?? "";
|
|
677
|
+
if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
678
|
+
const argv = process.argv.slice(2);
|
|
679
|
+
const cmd = argv[0];
|
|
680
|
+
jsonMode = argv.includes("--json");
|
|
681
|
+
const rest = argv.slice(1);
|
|
682
|
+
if (cmd === "--help" || cmd === "-h") {
|
|
683
|
+
console.log(USAGE);
|
|
684
|
+
}
|
|
685
|
+
else if (cmd === "--version" || cmd === "-V") {
|
|
686
|
+
if (jsonMode)
|
|
687
|
+
console.log(JSON.stringify({ parser: PARSER_VERSION, spec: VERSION }));
|
|
688
|
+
else
|
|
689
|
+
console.log(`geml ${PARSER_VERSION} (GEML spec ${VERSION})`);
|
|
690
|
+
}
|
|
691
|
+
else if (cmd === undefined) {
|
|
692
|
+
console.error(USAGE);
|
|
693
|
+
process.exit(2);
|
|
694
|
+
}
|
|
695
|
+
else if (SUBHELP[cmd] && (rest.includes("--help") || rest.includes("-h"))) {
|
|
696
|
+
// `geml <cmd> --help` is a help request, not a usage error: usage to
|
|
697
|
+
// stdout, exit 0 — never the `error:`-prefixed exit-2 path.
|
|
698
|
+
console.log(SUBHELP[cmd]);
|
|
699
|
+
}
|
|
700
|
+
else if (cmd === "history") {
|
|
701
|
+
runHistory(argv.slice(1));
|
|
702
|
+
}
|
|
703
|
+
else if (cmd === "convert") {
|
|
704
|
+
runConvert(argv.slice(1));
|
|
705
|
+
}
|
|
706
|
+
else if (cmd === "export") {
|
|
707
|
+
runExport(argv.slice(1));
|
|
708
|
+
}
|
|
709
|
+
else if (cmd === "render") {
|
|
710
|
+
runRender(argv.slice(1));
|
|
711
|
+
}
|
|
712
|
+
else if (cmd === "fmt") {
|
|
713
|
+
runFmt(argv.slice(1));
|
|
714
|
+
}
|
|
715
|
+
else if (cmd === "check") {
|
|
716
|
+
runCheck(argv.slice(1));
|
|
717
|
+
}
|
|
718
|
+
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
719
|
+
// A bare word that is neither a known command nor a path is almost always
|
|
720
|
+
// a mistyped command — say so, don't try to read it as a file.
|
|
721
|
+
fail(`unknown command '${cmd}'. Run 'geml --help'.`);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
// Default: parse a file (or stdin via '-') to the document-model JSON.
|
|
725
|
+
const doc = parse(readInput(cmd), { resolveDoc: resolverFor(cmd) });
|
|
726
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
727
|
+
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
}
|