@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GEML contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # @geml/geml
2
+
3
+ Reference parser, validator, renderer, and CLI for **GEML** — the General
4
+ Expressive Markup Language: a plain-text document format that stays legible to
5
+ people and reliable for machines. Every kind of structured content — code,
6
+ tables, diagrams, math, callouts, metadata — is carried on **one** primitive,
7
+ the typed block:
8
+
9
+ ```
10
+ === code {#hello lang=python}
11
+ print("hi")
12
+ ===
13
+ ```
14
+
15
+ References are checked at build time (a dangling `#id` is an error, not a silent
16
+ dead link), and the parser emits a document-model JSON with `diagnostics`, so
17
+ agents and CI get a structured pass/fail signal.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ npm install -g @geml/geml # global CLI — installs the `geml` command
23
+ # or, per project:
24
+ npm install @geml/geml # library + local bin
25
+ ```
26
+
27
+ Requires Node ≥ 18.
28
+
29
+ ## CLI
30
+
31
+ Every command reads a file path, or `-` for stdin. Exit codes: `0` ok ·
32
+ `1` document/operation error · `2` usage error.
33
+
34
+ ```sh
35
+ geml check file.geml # validate only: diagnostics + exit code
36
+ geml check --json file.geml # machine-readable: diagnostics array (or {"error":…} on IO failure)
37
+ geml file.geml # full document-model JSON
38
+ geml render file.geml -o out.html # one self-contained, interactive HTML file
39
+ geml export file.geml -o out.md # project to GitHub-Flavored Markdown (lossy; notes on stderr)
40
+ geml convert in.md -o out.geml # Markdown -> GEML
41
+ geml fmt file.geml # canonical re-format (idempotent)
42
+ geml history <commit|verify|show|restore> file.geml [...] # .gemlhistory sidecar
43
+ geml --help | --version # --version --json prints {"parser","spec"}
44
+ ```
45
+
46
+ The agent loop: write `.geml` → `geml check` → fix on non-zero → done.
47
+
48
+ ## Library
49
+
50
+ ```js
51
+ import { parse, serialize, renderHtml, gemlToMd, mdToGeml } from "@geml/geml";
52
+
53
+ const doc = parse(src); // { kind:"document", children, ids, diagnostics }
54
+ const ok = !doc.diagnostics.some(d => d.severity === "error");
55
+ const html = renderHtml(doc); // one self-contained HTML string
56
+ const md = gemlToMd(doc).md; // GitHub-Flavored Markdown (lossy)
57
+ const geml = mdToGeml(markdown).geml; // the inverse
58
+ const canonical = serialize(doc); // GEML text; parse(serialize(parse(x))) is stable
59
+ ```
60
+
61
+ `parse(src, { resolveDoc })` enables cross-document reference checking — pass a
62
+ function that returns another file's source by path (or `null`).
63
+
64
+ ## Documentation
65
+
66
+ Full normative spec, history-sidecar spec, and format comparison live in the
67
+ [repository](https://github.com/xiongjy2104/geml-spec). The spec is itself
68
+ written in GEML (`GEML-spec.geml`) and parsed clean on every test run.
69
+
70
+ ## License
71
+
72
+ MIT.
@@ -0,0 +1,9 @@
1
+ export type Value = string | number | boolean;
2
+ export interface Attrs {
3
+ id?: string;
4
+ classes: string[];
5
+ attrs: Record<string, Value>;
6
+ }
7
+ export declare function coerce(raw: string): Value;
8
+ export declare function tokenize(s: string): string[];
9
+ export declare function parseAttrs(src: string): Attrs;
package/dist/attrs.js ADDED
@@ -0,0 +1,64 @@
1
+ // Shared attribute-object and value typing (§4), used by the block scanner
2
+ // and the inline parser.
3
+ // §4 value typing: quoted -> string, true/false -> boolean, integer/float
4
+ // syntax -> number, any other bare word -> string. No arrays/dates/tables.
5
+ export function coerce(raw) {
6
+ const t = raw.trim();
7
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
8
+ return t.slice(1, -1); // quoted -> always string
9
+ }
10
+ if (t === "true")
11
+ return true;
12
+ if (t === "false")
13
+ return false;
14
+ if (/^[+-]?\d+$/.test(t))
15
+ return parseInt(t, 10);
16
+ if (/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(t) && /[.eE]/.test(t))
17
+ return parseFloat(t);
18
+ return t; // bare word -> string
19
+ }
20
+ // Split on whitespace while keeping double-quoted spans intact.
21
+ export function tokenize(s) {
22
+ const out = [];
23
+ let cur = "";
24
+ let inQuote = false;
25
+ for (const ch of s) {
26
+ if (ch === '"') {
27
+ inQuote = !inQuote;
28
+ cur += ch;
29
+ }
30
+ else if (!inQuote && /\s/.test(ch)) {
31
+ if (cur) {
32
+ out.push(cur);
33
+ cur = "";
34
+ }
35
+ }
36
+ else {
37
+ cur += ch;
38
+ }
39
+ }
40
+ if (cur)
41
+ out.push(cur);
42
+ return out;
43
+ }
44
+ // Parse `{#id .class key=val key2="a b"}` (braces included).
45
+ export function parseAttrs(src) {
46
+ const inner = src.trim().replace(/^\{/, "").replace(/\}$/, "");
47
+ const out = { classes: [], attrs: {} };
48
+ for (const tok of tokenize(inner)) {
49
+ if (tok.startsWith("#")) {
50
+ out.id = tok.slice(1);
51
+ }
52
+ else if (tok.startsWith(".")) {
53
+ out.classes.push(tok.slice(1));
54
+ }
55
+ else {
56
+ const eq = tok.indexOf("=");
57
+ if (eq > 0)
58
+ out.attrs[tok.slice(0, eq)] = coerce(tok.slice(eq + 1));
59
+ else
60
+ out.attrs[tok] = true; // bare word -> boolean flag (e.g. `hidden`)
61
+ }
62
+ }
63
+ return out;
64
+ }
@@ -0,0 +1,28 @@
1
+ import { type Value } from "./attrs.js";
2
+ import { type TableModel } from "./table.js";
3
+ export type ChartType = "bar" | "line" | "area" | "pie" | "scatter";
4
+ export type RowScope = "data" | "all" | "summary";
5
+ export interface ChartDataset {
6
+ categories: string[];
7
+ numbers: Record<string, number[]>;
8
+ seriesOf?: string[];
9
+ }
10
+ export interface ChartModel {
11
+ type: ChartType;
12
+ x: string;
13
+ y: string[];
14
+ series?: string;
15
+ size?: string;
16
+ rows: RowScope;
17
+ dataRef: string;
18
+ dataset: ChartDataset;
19
+ }
20
+ export interface ChartDiag {
21
+ severity: "error" | "warning";
22
+ message: string;
23
+ }
24
+ export interface ChartResult {
25
+ model: ChartModel | null;
26
+ diagnostics: ChartDiag[];
27
+ }
28
+ export declare function buildChart(attrs: Record<string, Value>, table: TableModel): ChartResult;
package/dist/chart.js ADDED
@@ -0,0 +1,128 @@
1
+ // GEML reference parser — chart-from-table (§7 geml-chart renderer).
2
+ //
3
+ // A `diagram {format=geml-chart data=#id ...}` binds to a table and is drawn
4
+ // from a closed set of encoding channels (x, y, series, size). `type` only
5
+ // changes how those channels are drawn; it never adds new attributes. The
6
+ // processor validates the attributes against the referenced table's model and
7
+ // normalizes the selected rows into a dataset for a renderer. See the design
8
+ // doc and §7.
9
+ const TYPES = new Set(["bar", "line", "area", "pie", "scatter"]);
10
+ // Channels each type can use; supplying any other is a warning (ignored).
11
+ const USES = {
12
+ bar: new Set(["x", "y", "series"]),
13
+ line: new Set(["x", "y", "series"]),
14
+ area: new Set(["x", "y", "series"]),
15
+ scatter: new Set(["x", "y", "series", "size"]),
16
+ pie: new Set(["x", "y"]),
17
+ };
18
+ function str(v) {
19
+ return v === undefined ? undefined : typeof v === "string" ? v : String(v);
20
+ }
21
+ export function buildChart(attrs, table) {
22
+ const diagnostics = [];
23
+ const err = (m) => diagnostics.push({ severity: "error", message: m });
24
+ const warn = (m) => diagnostics.push({ severity: "warning", message: m });
25
+ const fail = () => ({ model: null, diagnostics });
26
+ const typeRaw = str(attrs["type"]);
27
+ if (!typeRaw) {
28
+ err("chart: missing `type`");
29
+ return fail();
30
+ }
31
+ if (!TYPES.has(typeRaw)) {
32
+ err(`chart: unknown type \`${typeRaw}\` (supported: bar, line, area, pie, scatter; use format=vega-lite for others)`);
33
+ return fail();
34
+ }
35
+ const type = typeRaw;
36
+ // Validate rows scope up front so a bad value is reported even when a column
37
+ // name is also wrong.
38
+ const rowsAttr = (str(attrs["rows"]) ?? "data");
39
+ if (!["data", "all", "summary"].includes(rowsAttr)) {
40
+ err(`chart: unknown rows scope \`${rowsAttr}\` (data|all|summary)`);
41
+ return fail();
42
+ }
43
+ const x = str(attrs["x"]);
44
+ const yRaw = str(attrs["y"]);
45
+ if (!x)
46
+ err("chart: missing required channel `x`");
47
+ if (!yRaw)
48
+ err("chart: missing required channel `y`");
49
+ if (!x || !yRaw)
50
+ return fail();
51
+ let y = yRaw.split(",").map((s) => s.trim()).filter((s) => s !== "");
52
+ if (y.length === 0) {
53
+ err("chart: `y` lists no columns");
54
+ return fail();
55
+ }
56
+ // Wrong-channel warnings (channel present but unused by this type).
57
+ if (attrs["size"] !== undefined && !USES[type].has("size"))
58
+ warn(`chart: \`size\` is ignored for type \`${type}\``);
59
+ if (attrs["series"] !== undefined && !USES[type].has("series"))
60
+ warn(`chart: \`series\` is ignored for type \`${type}\``);
61
+ if (type === "pie" && y.length > 1) {
62
+ warn("chart: pie uses a single `y`; extra columns ignored");
63
+ y = [y[0]];
64
+ }
65
+ // Optional channels, only when used by this type.
66
+ const series = USES[type].has("series") ? str(attrs["series"]) : undefined;
67
+ const size = USES[type].has("size") ? str(attrs["size"]) : undefined;
68
+ // Resolve columns by header name (x, y, and any used optional channels).
69
+ const idx = (name) => table.columns.indexOf(name);
70
+ for (const name of [x, ...y, ...(series ? [series] : []), ...(size ? [size] : [])]) {
71
+ if (idx(name) < 0)
72
+ err(`chart: column \`${name}\` not found in table`);
73
+ }
74
+ if (diagnostics.some((d) => d.severity === "error"))
75
+ return fail();
76
+ // Select rows per scope.
77
+ let picked;
78
+ if (rowsAttr === "summary") {
79
+ if (!table.summary) {
80
+ err("chart: rows=summary but the table has no summary row");
81
+ return fail();
82
+ }
83
+ picked = [table.summary];
84
+ }
85
+ else if (rowsAttr === "all") {
86
+ if (!table.summary)
87
+ warn("chart: rows=all but the table has no summary row; using data rows");
88
+ picked = table.summary ? [...table.rows, table.summary] : table.rows;
89
+ }
90
+ else {
91
+ picked = table.rows;
92
+ }
93
+ // Normalize: x text + numeric y/size columns; series text. A non-empty
94
+ // non-numeric value in a numeric column is always an error; a row with an
95
+ // empty numeric cell is skipped (no data point).
96
+ const numCols = [...y, ...(size ? [size] : [])];
97
+ const xi = idx(x);
98
+ const si = series ? idx(series) : -1;
99
+ const numIs = numCols.map(idx);
100
+ const categories = [];
101
+ const numbers = {};
102
+ const seriesOf = [];
103
+ for (const c of numCols)
104
+ numbers[c] = [];
105
+ for (const row of picked) {
106
+ const cells = numIs.map((i) => row[i]);
107
+ if (cells.some((cell) => (cell?.text ?? "") !== "" && typeof cell?.value !== "number")) {
108
+ err("chart: non-numeric value in a y column");
109
+ return fail();
110
+ }
111
+ if (cells.some((cell) => (cell?.text ?? "") === ""))
112
+ continue;
113
+ categories.push(row[xi]?.text ?? "");
114
+ numIs.forEach((i, j) => numbers[numCols[j]].push(row[i].value));
115
+ if (series)
116
+ seriesOf.push(row[si]?.text ?? "");
117
+ }
118
+ const dataRef = (str(attrs["data"]) ?? "").replace(/^#/, "");
119
+ const dataset = { categories, numbers };
120
+ if (series)
121
+ dataset.seriesOf = seriesOf;
122
+ const model = { type, x, y, rows: rowsAttr, dataRef, dataset };
123
+ if (series)
124
+ model.series = series;
125
+ if (size)
126
+ model.size = size;
127
+ return { model, diagnostics };
128
+ }
@@ -0,0 +1,5 @@
1
+ export interface ConvertResult {
2
+ geml: string;
3
+ notes: string[];
4
+ }
5
+ export declare function mdToGeml(source: string): ConvertResult;
@@ -0,0 +1,242 @@
1
+ // GEML reference parser — Markdown → GEML conversion.
2
+ //
3
+ // Markdown's inline syntax (emphasis/strong/strike, code, links, images,
4
+ // footnotes) is already a subset of GEML's (§5), so inline text passes through
5
+ // unchanged. The work is mapping block constructs onto GEML's single typed-block
6
+ // primitive (§3):
7
+ //
8
+ // YAML frontmatter -> === meta (data)
9
+ // ``` fenced code -> === code {lang=…} (raw)
10
+ // $$ … $$ math block -> === math (raw)
11
+ // > blockquote -> === note (flow)
12
+ // GFM pipe table -> === table (visual body, §6)
13
+ // setext heading -> ATX heading (§1: GEML headings are ATX-only)
14
+ // thematic break (---/***) -> dropped (§1: not part of GEML)
15
+ //
16
+ // Anything else (ATX headings, lists, paragraphs) is already valid GEML.
17
+ // Pick a fence length longer than any run of `=` that appears alone on a body
18
+ // line (leading indentation included), so the close fence stays unambiguous
19
+ // (§3 equal-length close rule) and the body's own `=` fences nest safely.
20
+ function fenceFor(body) {
21
+ let max = 2;
22
+ for (const l of body) {
23
+ const m = /^\s*(=+)\s*$/.exec(l);
24
+ if (m)
25
+ max = Math.max(max, m[1].length);
26
+ }
27
+ return "=".repeat(Math.max(3, max + 1));
28
+ }
29
+ // Emit a typed block, auto-assigning a stable `#type-N` id (so converted blocks
30
+ // are referenceable) unless one was already supplied or the block is `meta`.
31
+ function emitBlock(out, type, attrs, body, ids) {
32
+ if (ids && type !== "meta" && !attrs.includes("#")) {
33
+ const n = (ids[type] = (ids[type] ?? 0) + 1);
34
+ const idAttr = `#${type}-${n}`;
35
+ attrs = attrs ? attrs.replace(/^\{/, `{${idAttr} `) : `{${idAttr}}`;
36
+ }
37
+ const fence = fenceFor(body);
38
+ out.push(attrs ? `${fence} ${type} ${attrs}` : `${fence} ${type}`);
39
+ out.push(...body);
40
+ out.push(fence);
41
+ }
42
+ // Fenced-code info strings that denote a diagram DSL (§7) rather than code.
43
+ const DIAGRAM_LANGS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml"]);
44
+ // `key: value` (YAML-ish) -> `key=value`, quoting values that need it.
45
+ function metaLine(line) {
46
+ const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
47
+ if (!m)
48
+ return null;
49
+ let v = m[2].trim();
50
+ if (v === "")
51
+ return `${m[1]}=""`;
52
+ // Strip existing YAML quotes; re-quote only when the bare value would be
53
+ // re-tokenized (contains whitespace or a quote).
54
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
55
+ v = v.slice(1, -1);
56
+ const bareSafe = /^[^\s"]+$/.test(v);
57
+ return bareSafe ? `${m[1]}=${v}` : `${m[1]}="${v.replace(/"/g, '\\"')}"`;
58
+ }
59
+ // Rewrite Markdown autolinks `<https://…>` / `<mailto:…>` into GEML links
60
+ // `[url](url)` (GEML has no autolink syntax). Inline code spans are left intact.
61
+ function autolinks(s) {
62
+ return s
63
+ .split(/(`[^`]*`)/)
64
+ .map((seg, i) => i % 2 === 1
65
+ ? seg
66
+ : seg
67
+ .replace(/<((?:https?|ftp):\/\/[^>\s]+)>/g, "[$1]($1)")
68
+ .replace(/<mailto:([^>\s]+)>/g, "[$1](mailto:$1)"))
69
+ .join("");
70
+ }
71
+ // GitHub-style heading anchor: drop code backticks (keep content), lowercase,
72
+ // strip punctuation except `-`/`_`, collapse whitespace to hyphens. Used to keep
73
+ // converted headings' ids in sync with Markdown TOC links.
74
+ function githubSlug(text) {
75
+ return text
76
+ .replace(/`/g, "")
77
+ .toLowerCase()
78
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
79
+ .trim()
80
+ .replace(/\s+/g, "-");
81
+ }
82
+ const FENCE = /^(\s*)(`{3,}|~{3,})(.*)$/;
83
+ const SETEXT_UL = /^=+\s*$/;
84
+ const SETEXT_DASH = /^-+\s*$/;
85
+ const THEMATIC = /^\s*([-*_])(\s*\1){2,}\s*$/;
86
+ const TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/;
87
+ export function mdToGeml(source) {
88
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
89
+ const out = [];
90
+ const notes = [];
91
+ const ids = {}; // per-type id counters for auto-ids
92
+ let i = 0;
93
+ // YAML frontmatter (must be the very first line).
94
+ if (lines[0] === "---") {
95
+ let j = 1;
96
+ const meta = [];
97
+ while (j < lines.length && lines[j] !== "---" && lines[j] !== "...") {
98
+ const ml = metaLine(lines[j]);
99
+ if (ml)
100
+ meta.push(ml);
101
+ else if (lines[j].trim() !== "")
102
+ notes.push(`frontmatter line not converted: ${lines[j]}`);
103
+ j++;
104
+ }
105
+ if (j < lines.length) { // closing marker found -> it was frontmatter
106
+ emitBlock(out, "meta", "", meta, ids);
107
+ out.push("");
108
+ i = j + 1;
109
+ }
110
+ }
111
+ while (i < lines.length) {
112
+ const line = lines[i];
113
+ // Fenced code block.
114
+ const f = FENCE.exec(line);
115
+ if (f) {
116
+ const marker = f[2];
117
+ const info = f[3].trim().split(/\s+/)[0] ?? "";
118
+ const body = [];
119
+ let j = i + 1;
120
+ for (; j < lines.length; j++) {
121
+ const raw = lines[j];
122
+ // CommonMark: a closing fence is indented at most 3 spaces, uses the same
123
+ // marker, and is at least as long as the opener. A more-indented run of
124
+ // the same character is content — this is what lets a document *show*
125
+ // nested ``` fences without ending the block early.
126
+ const indent = raw.length - raw.trimStart().length;
127
+ const c = raw.replace(/\s+$/, "").trimStart();
128
+ if (indent <= 3 && c.length >= marker.length && c[0] === marker[0] && /^[`~]+$/.test(c))
129
+ break;
130
+ body.push(raw);
131
+ }
132
+ if (DIAGRAM_LANGS.has(info))
133
+ emitBlock(out, "diagram", `{format=${info}}`, body, ids);
134
+ else
135
+ emitBlock(out, "code", info ? `{lang=${info}}` : "", body, ids);
136
+ i = j < lines.length ? j + 1 : j;
137
+ continue;
138
+ }
139
+ // Display math $$ … $$.
140
+ if (line.trim() === "$$") {
141
+ const body = [];
142
+ let j = i + 1;
143
+ for (; j < lines.length && lines[j].trim() !== "$$"; j++)
144
+ body.push(lines[j]);
145
+ emitBlock(out, "math", "", body, ids);
146
+ i = j < lines.length ? j + 1 : j;
147
+ continue;
148
+ }
149
+ // Setext heading: a text line followed by `===` or `---` underline. Checked
150
+ // before the thematic-break drop so dash underlines aren't lost.
151
+ if (line.trim() !== "" && !THEMATIC.test(line) && i + 1 < lines.length) {
152
+ const nxt = lines[i + 1];
153
+ if (SETEXT_UL.test(nxt)) {
154
+ out.push(`# ${line.trim()}`);
155
+ i += 2;
156
+ continue;
157
+ }
158
+ if (SETEXT_DASH.test(nxt)) {
159
+ out.push(`## ${line.trim()}`);
160
+ i += 2;
161
+ continue;
162
+ }
163
+ }
164
+ // Thematic break (---, ***, ___) -> dropped (not a GEML construct). Any
165
+ // dash underline has already been consumed by the setext check above.
166
+ if (THEMATIC.test(line)) {
167
+ notes.push(`dropped thematic break at line ${i + 1}`);
168
+ i++;
169
+ continue;
170
+ }
171
+ // Footnote definition `[^id]: body` -> a flow `=== note {#id}` block so the
172
+ // matching `[^id]` reference resolves at build time (§5.2). Continuation
173
+ // lines (indented) are folded into the body.
174
+ const fn = /^\[\^([^\]]+)\]:\s?(.*)$/.exec(line);
175
+ if (fn) {
176
+ const body = fn[2].trim() ? [fn[2].trim()] : [];
177
+ let j = i + 1;
178
+ for (; j < lines.length; j++) {
179
+ if (/^\s{2,}\S/.test(lines[j]))
180
+ body.push(lines[j].replace(/^\s+/, ""));
181
+ else if (lines[j].trim() === "")
182
+ break;
183
+ else
184
+ break;
185
+ }
186
+ emitBlock(out, "note", `{#${fn[1].trim()}}`, body.map(autolinks), ids);
187
+ i = j;
188
+ continue;
189
+ }
190
+ // Blockquote -> === note (flow). Strips one `>` level per line.
191
+ if (/^\s*>/.test(line)) {
192
+ const body = [];
193
+ while (i < lines.length && /^\s*>/.test(lines[i])) {
194
+ body.push(lines[i].replace(/^\s*>\s?/, ""));
195
+ i++;
196
+ }
197
+ emitBlock(out, "note", "", body, ids);
198
+ continue;
199
+ }
200
+ // GFM pipe table -> === table (visual body).
201
+ if (line.includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]) && line.trim() !== "") {
202
+ const body = [line];
203
+ let j = i + 1;
204
+ body.push(lines[j]); // separator
205
+ j++;
206
+ while (j < lines.length && lines[j].includes("|") && lines[j].trim() !== "") {
207
+ body.push(lines[j]);
208
+ j++;
209
+ }
210
+ emitBlock(out, "table", "", body, ids);
211
+ i = j;
212
+ continue;
213
+ }
214
+ // ATX heading: pin an explicit id when it contains inline code. GEML's slug
215
+ // rule (§4) drops code-span content, but a Markdown TOC was authored against
216
+ // GitHub-style anchors (which keep it), so headings like `## 3. \`.x\` 文档`
217
+ // would otherwise break their links. Pinning the GitHub slug keeps both sides
218
+ // in agreement.
219
+ const atx = /^(#{1,6})\s+(.*?)\s*$/.exec(line);
220
+ if (atx && atx[2].includes("`") && !/\{[^}]*\}\s*$/.test(atx[2])) {
221
+ const id = githubSlug(atx[2]);
222
+ if (id) {
223
+ out.push(`${atx[1]} ${atx[2]} {#${id}}`);
224
+ i++;
225
+ continue;
226
+ }
227
+ }
228
+ // Inline pass: rewrite autolinks to GEML links (outside code spans).
229
+ const text = autolinks(line);
230
+ // Raw HTML note — ignore `<…>` that sits inside an inline code span.
231
+ if (/<[a-zA-Z/]/.test(text.replace(/`[^`]*`/g, ""))) {
232
+ notes.push(`raw HTML kept as text at line ${i + 1}: ${line.trim().slice(0, 40)}`);
233
+ }
234
+ // Everything else (ATX headings, lists, paragraphs, blanks) is valid GEML.
235
+ out.push(text);
236
+ i++;
237
+ }
238
+ let geml = out.join("\n");
239
+ if (!geml.endsWith("\n"))
240
+ geml += "\n";
241
+ return { geml, notes };
242
+ }
package/dist/geml.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { type Value } from "./attrs.js";
3
+ import { type Inline } from "./inline.js";
4
+ import { type TableModel } from "./table.js";
5
+ import { type ChartModel } from "./chart.js";
6
+ export { type Value } from "./attrs.js";
7
+ export { type Inline } from "./inline.js";
8
+ export { type TableModel } from "./table.js";
9
+ export { mdToGeml, type ConvertResult } from "./from-md.js";
10
+ export { renderHtml, type RenderOptions } from "./render.js";
11
+ export { serialize } from "./serialize.js";
12
+ export { gemlToMd } from "./to-md.js";
13
+ export type BodyMode = "raw" | "flow" | "data";
14
+ export interface ListItem {
15
+ text: string;
16
+ inlines: Inline[];
17
+ checked?: boolean;
18
+ children?: Block[];
19
+ }
20
+ export type Block = {
21
+ kind: "heading";
22
+ level: number;
23
+ text: string;
24
+ inlines: Inline[];
25
+ id?: string;
26
+ classes: string[];
27
+ attrs: Record<string, Value>;
28
+ hidden?: boolean;
29
+ } | {
30
+ kind: "paragraph";
31
+ text: string;
32
+ inlines: Inline[];
33
+ } | {
34
+ kind: "list";
35
+ ordered: boolean;
36
+ start?: number;
37
+ loose?: boolean;
38
+ items: ListItem[];
39
+ } | {
40
+ kind: "hidden";
41
+ text: string;
42
+ } | {
43
+ kind: "block";
44
+ type: string;
45
+ mode: BodyMode;
46
+ id?: string;
47
+ classes: string[];
48
+ attrs: Record<string, Value>;
49
+ raw?: string[];
50
+ children?: Block[];
51
+ data?: Record<string, Value>;
52
+ table?: TableModel;
53
+ chart?: ChartModel;
54
+ hidden?: boolean;
55
+ };
56
+ export interface Diagnostic {
57
+ severity: "error" | "warning";
58
+ message: string;
59
+ line: number;
60
+ }
61
+ export interface Document {
62
+ kind: "document";
63
+ children: Block[];
64
+ ids: string[];
65
+ diagnostics: Diagnostic[];
66
+ }
67
+ export interface ParseOptions {
68
+ resolveDoc?: (doc: string) => string | null;
69
+ }
70
+ export declare function parse(source: string, opts?: ParseOptions): Document;