@geml/geml 1.7.8 → 1.8.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/dist/geml.js CHANGED
@@ -18,7 +18,7 @@ import { readFileSync, realpathSync } from "node:fs";
18
18
  import { dirname, join, resolve as resolvePath } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { normalizeSource } from "./diagnostics.js";
21
- import { coerce, parseAttrs } from "./attrs.js";
21
+ import { coerce, oddNames, parseAttrs } from "./attrs.js";
22
22
  import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
23
23
  import { parseTable } from "./table.js";
24
24
  import { USES, buildChart } from "./chart.js";
@@ -91,17 +91,26 @@ function jsonErrorLine(e, text, openLineNo) {
91
91
  export { SEVERITY } from "./diagnostics.js";
92
92
  // Type registry: which body mode each typed block uses. Unknown types are a
93
93
  // warning and fall back to `raw` (forward compatibility, §3/§8).
94
- const REGISTRY = {
95
- code: "raw",
96
- diagram: "raw",
97
- math: "raw",
98
- table: "raw", // structured table parsing lands in M3
99
- data: "raw", // GEP-0005: value tree a format engine parses the raw body in a second stage
100
- embed: "raw", // block transclusion: `src=` points at the content, body unused
101
- note: "flow",
102
- text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
103
- meta: "data",
104
- };
94
+ //
95
+ // A Map, not an object, because the key is the type name off a fence head — i.e.
96
+ // document-controlled. Indexing a plain object with it answered for the whole
97
+ // prototype chain: `=== constructor` (also toString, valueOf, hasOwnProperty,
98
+ // isPrototypeOf, propertyIsEnumerable, toLocaleString) returned an inherited
99
+ // FUNCTION, which is not undefined, so the unknown-block-type warning never
100
+ // fired and a function reached the model's `mode` field a value the published
101
+ // `BodyMode` type says cannot occur, and one JSON.stringify silently drops.
102
+ // Every other document-keyed registry here is already a Set or a Map.
103
+ const REGISTRY = new Map([
104
+ ["code", "raw"],
105
+ ["diagram", "raw"],
106
+ ["math", "raw"],
107
+ ["table", "raw"], // structured table parsing lands in M3
108
+ ["data", "raw"], // GEP-0005: value tree — a format engine parses the raw body in a second stage
109
+ ["embed", "raw"], // block transclusion: `src=` points at the content, body unused
110
+ ["note", "flow"],
111
+ ["text", "flow"], // addressable prose container: an id/attrs for a run of flow, no callout chrome
112
+ ["meta", "data"],
113
+ ]);
105
114
  // §7: built-in diagram renderer registry. Unknown formats are a warning (the
106
115
  // processor keeps the body raw rather than interpreting it).
107
116
  const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart", "geml-code-graph"]);
@@ -133,6 +142,21 @@ export const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(?:(\{.*\
133
142
  // Together: the group runs to the end of the line and starts at the first `{`
134
143
  // after the last OTHER `}`. Returns the RegExpExecArray shape the call sites
135
144
  // already destructure.
145
+ // A name in the attribute object that is not a NAME (§4). A WARNING, not an
146
+ // error: it has always parsed, documents in the wild rely on the leniency, and
147
+ // what the author needs is to be told — `{#a & b}` gives the id `a` and two
148
+ // flags called `&` and `b`, which is a legal parse of something nobody wrote.
149
+ function reportOddNames(a, line, diags) {
150
+ for (const { kind, name } of oddNames(a)) {
151
+ diags.push({
152
+ severity: "warning",
153
+ code: "name-not-a-name",
154
+ message: `${kind} \`${name}\` is not a NAME (§4: letters, digits, \`-\`, \`_\`)`
155
+ + (kind === "flag" ? " — an attribute object is whitespace-separated, so a space in an id or class splits it into flags like this one" : ""),
156
+ line,
157
+ });
158
+ }
159
+ }
136
160
  const HEADING_HEAD = /^(#{1,6})[ \t]+/;
137
161
  function matchHeading(line) {
138
162
  const m = HEADING_HEAD.exec(line);
@@ -184,7 +208,7 @@ function slug(text) {
184
208
  return text
185
209
  .toLowerCase()
186
210
  .replace(/`[^`]*`/g, "")
187
- .replace(/[^\p{L}\p{N}\s-]/gu, "")
211
+ .replace(/[^\p{L}\p{N}\s\-_]/gu, "")
188
212
  .trim()
189
213
  .replace(/\s+/g, "-");
190
214
  }
@@ -405,6 +429,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
405
429
  const type = open[2];
406
430
  const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
407
431
  const openLineNo = base + i + 1;
432
+ reportOddNames(attrs, openLineNo, diags);
408
433
  // Collect the body. A block closes on the FIRST line that is a bare fence
409
434
  // of exactly the opening length, OR — when it has an id — a labeled fence
410
435
  // `=== #id` (a `=` run of any length ≥ 3 followed by the block's id). The
@@ -439,7 +464,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
439
464
  const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
440
465
  diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
441
466
  }
442
- let mode = REGISTRY[type];
467
+ let mode = REGISTRY.get(type);
443
468
  if (mode === undefined) {
444
469
  diags.push({ severity: "warning", code: "unknown-block-type", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
445
470
  mode = "raw";
@@ -663,6 +688,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
663
688
  const level = h[1].length;
664
689
  const rawText = h[2];
665
690
  const a = parseAttrs(h[3] ?? "");
691
+ reportOddNames(a, lineNo, diags);
666
692
  const text = interpolate(rawText, lineNo, ctx);
667
693
  const id = a.id ?? slug(rawText);
668
694
  registerId(ctx, id, lineNo);
@@ -1111,8 +1137,9 @@ function gatherIds(source) {
1111
1137
  }
1112
1138
  // Pre-scan for `=== meta` blocks (at any fence depth) and merge their
1113
1139
  // `key=val` lines, so `{{key}}` interpolation can resolve forward references.
1114
- function collectMeta(lines) {
1140
+ function collectMeta(lines, diags) {
1115
1141
  const meta = new Map();
1142
+ const firstLine = new Map(); // key → 1-based line of the defining fence
1116
1143
  for (let i = 0; i < lines.length; i++) {
1117
1144
  const open = FENCE_OPEN.exec(lines[i]);
1118
1145
  if (!open || open[2] !== "meta")
@@ -1122,8 +1149,17 @@ function collectMeta(lines) {
1122
1149
  let j = i + 1;
1123
1150
  for (; j < lines.length && !isCloseFence(lines[j], len); j++)
1124
1151
  body.push(lines[j]);
1125
- for (const [k, v] of Object.entries(parseData(body)))
1126
- meta.set(k, String(v));
1152
+ for (const [k, v] of Object.entries(parseData(body))) {
1153
+ if (meta.has(k)) {
1154
+ diags?.push({ severity: "warning", code: "duplicate-meta-key",
1155
+ message: `meta key \`${k}\` already defined at line ${firstLine.get(k)}; later definition at line ${i + 1} is ignored`,
1156
+ line: i + 1 });
1157
+ }
1158
+ else {
1159
+ meta.set(k, String(v));
1160
+ firstLine.set(k, i + 1);
1161
+ }
1162
+ }
1127
1163
  i = j;
1128
1164
  }
1129
1165
  return meta;
@@ -1283,13 +1319,14 @@ function resolveCodeSources(ctx, opts) {
1283
1319
  if (slice === null)
1284
1320
  continue;
1285
1321
  const hasBody = (block.raw ?? []).some((l) => l.trim() !== "");
1286
- if (!hasBody) {
1287
- block.raw = slice;
1322
+ if (hasBody) {
1323
+ // A code block must not carry both src= and an inline body (§3.3).
1324
+ ctx.diags.push({ severity: "error", code: "code-src-and-body",
1325
+ message: `code: carries both \`src=\` and an inline body; exactly one is permitted`,
1326
+ line });
1288
1327
  }
1289
- else if ((block.raw ?? []).join("\n") !== slice.join("\n")) {
1290
- // A body alongside `src=` is a cached snapshot, kept for offline reading.
1291
- // Silence would let the two drift — the very thing the route prevents.
1292
- ctx.diags.push({ severity: "warning", code: "stale-code-snapshot", message: `code block body differs from its source \`${target}\` — the body is a snapshot and is now out of date`, line });
1328
+ else {
1329
+ block.raw = slice;
1293
1330
  }
1294
1331
  }
1295
1332
  }
@@ -1547,7 +1584,8 @@ function resolveCharts(ctx, opts) {
1547
1584
  }
1548
1585
  export function parse(source, opts = {}) {
1549
1586
  const lines = normalizeSource(source).split("\n");
1550
- const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
1587
+ const diags = [];
1588
+ const ctx = { diags, ids: new Map(), refs: [], meta: collectMeta(lines, diags), resolveDoc: opts.resolveDoc };
1551
1589
  const children = scanBlocks(lines, 0, ctx);
1552
1590
  // Table sources first: a chart reads the build-time model of the table it
1553
1591
  // charts, so that model has to be filled before charts are resolved.
@@ -1659,7 +1697,7 @@ units) {
1659
1697
  // Only a flow body is scanned for nested blocks (raw/data bodies are
1660
1698
  // opaque), so an id inside a `code` body is *not* addressable — exactly
1661
1699
  // the parser's contract.
1662
- if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
1700
+ if ((REGISTRY.get(type) ?? "raw") === "flow" && depth < MAX_NESTING) {
1663
1701
  collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, units);
1664
1702
  }
1665
1703
  i = end;
package/dist/inline.d.ts CHANGED
@@ -70,4 +70,10 @@ export interface RefSink {
70
70
  export declare const META_REF_SRC = "\\{\\{\\s*([A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}";
71
71
  export declare function schemeOf(url: string): string | null;
72
72
  export declare function isSafeUrl(url: string, allowDataImage?: boolean): boolean;
73
- export declare function parseInline(s: string, line: number, sink: RefSink, depth?: number): Inline[];
73
+ interface Pairs {
74
+ br: Int32Array;
75
+ pa: Int32Array;
76
+ off: number;
77
+ }
78
+ export declare function parseInline(s: string, line: number, sink: RefSink, depth?: number, pairs?: Pairs): Inline[];
79
+ export {};