@geml/geml 1.1.1 → 1.4.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.
@@ -0,0 +1,112 @@
1
+ // Structural id-rewriting for `geml set`. `set #id` names the block to edit,
2
+ // so the content spliced in must ADOPT that id — whatever id it declared (or
3
+ // none). This module performs that rewrite parse-aware (per head form) rather
4
+ // than by blind byte replacement, touching ONLY the id: type, classes,
5
+ // attributes, body and the fence pairing all ride along unchanged.
6
+ //
7
+ // Deliberately self-contained — it imports only the shared attribute parser,
8
+ // never geml.ts: geml.ts's module body runs the CLI on import, so a back-import
9
+ // would fire the whole command line just by loading this helper.
10
+ import { parseAttrs } from "./attrs.js";
11
+ // The two head forms, spelled to MIRROR geml.ts's FENCE_OPEN / HEADING (same
12
+ // language) but with the id-bearing brace tail split out so the id can be
13
+ // rewritten while every other byte is copied verbatim:
14
+ // FENCE_HEAD g1 = `=== type` g2 = ws g3 = `{…}`? g4 = trailing ws
15
+ // HEAD_HEAD g1 = `## text` g2 = ws g3 = `{…}`? g4 = trailing ws
16
+ const FENCE_HEAD = /^(={3,}[ \t]+[A-Za-z][A-Za-z0-9_-]*)([ \t]*)(\{.*\})?([ \t]*)$/;
17
+ const HEAD_HEAD = /^(#{1,6}[ \t]+.*?)([ \t]*)(\{[^}]*\})?([ \t]*)$/;
18
+ // Split into physical lines while keeping each line's terminator, so join("")
19
+ // is byte-exact — the same boundaries geml.ts's splitLines() uses. A line ends
20
+ // at `\n`, `\r\n`, or a lone `\r`.
21
+ function splitLines(source) {
22
+ return source.split(/(?<=\n|\r(?!\n))/);
23
+ }
24
+ // Strip a single trailing terminator from one physical line.
25
+ function stripEnding(line) {
26
+ return line.replace(/(\r\n|\r|\n)$/, "");
27
+ }
28
+ // Rewrite the id inside a `{…}` attribute block to `#newId`, keeping the braces
29
+ // and every other class/attr byte. If no id is present, insert `#newId` as the
30
+ // first token. The id token sits at a token boundary (`{` or whitespace) and
31
+ // never inside a quoted value, so the anchored match can't disturb a value like
32
+ // `caption="#x"`.
33
+ function rewriteBraces(braces, newId) {
34
+ if (parseAttrs(braces).id !== undefined) {
35
+ return braces.replace(/([{\s])#[^\s}]+/, `$1#${newId}`);
36
+ }
37
+ const inner = braces.slice(1, -1).replace(/^[ \t]*/, "");
38
+ return `{#${newId}${inner.length ? " " + inner : ""}}`;
39
+ }
40
+ // Rewrite a HEAD line's id declaration to `#newId`. Handles both head forms and
41
+ // all id states: existing brace id, brace attrs without an id, and no braces at
42
+ // all (append `{#newId}`). A line that is neither form is returned unchanged.
43
+ function rewriteHead(head, newId) {
44
+ const rebuild = (m) => {
45
+ const lead = m[1], ws = m[2] ?? "", braces = m[3], trail = m[4] ?? "";
46
+ if (braces)
47
+ return lead + ws + rewriteBraces(braces, newId) + trail;
48
+ return `${lead} {#${newId}}${ws}${trail}`;
49
+ };
50
+ const f = FENCE_HEAD.exec(head);
51
+ if (f)
52
+ return rebuild(f);
53
+ const h = HEAD_HEAD.exec(head);
54
+ if (h)
55
+ return rebuild(h);
56
+ return head;
57
+ }
58
+ // Locate the block's HEAD: the first non-blank, non-`%%` line that opens a fence
59
+ // or a heading. Returns its line index, or -1 when the content has no head
60
+ // (pure prose, or a structural line that is not a head) — the caller decides
61
+ // what that means.
62
+ function findHead(lines) {
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const t = stripEnding(lines[i]);
65
+ if (t.trim() === "" || /^[ \t]*%%/.test(t))
66
+ continue;
67
+ if (FENCE_HEAD.test(t) || HEAD_HEAD.test(t))
68
+ return i;
69
+ return -1; // the first structural line isn't a head: no addressable block
70
+ }
71
+ return -1;
72
+ }
73
+ // Rewrite the HEAD id of the first block in `blockSrc` to `newId`, across every
74
+ // head form:
75
+ // • fence attrs `{#x …}` -> `{#newId …}` (other classes/attrs kept)
76
+ // • fence with attrs but no id, or no braces -> gains `{#newId}`
77
+ // • labeled close `=== #x` -> `=== #newId` (renamed to match the open)
78
+ // • heading `## T {#x}` -> `## T {#newId}`
79
+ // • heading auto-slug (no braces) -> `## T {#newId}` appended
80
+ // Only the id changes; type / classes / attrs / body / fence length are byte-
81
+ // preserved, as are line terminators. Content with no head is returned as-is.
82
+ export function normalizeBlockId(blockSrc, newId) {
83
+ const lines = splitLines(blockSrc);
84
+ const hi = findHead(lines);
85
+ if (hi < 0)
86
+ return blockSrc;
87
+ const headText = stripEnding(lines[hi]);
88
+ const headTerm = lines[hi].slice(headText.length);
89
+ lines[hi] = rewriteHead(headText, newId) + headTerm;
90
+ // For a fence carrying an id, a labeled close `=== #oldId` names that id and
91
+ // must be renamed too — otherwise the open declares #newId while the close
92
+ // still labels #oldId and the block no longer parses. The FIRST close wins
93
+ // (plain equal-length OR labeled), matching geml.ts's fenceClose scan; a
94
+ // plain close needs no rewrite.
95
+ const f = FENCE_HEAD.exec(headText);
96
+ const oldId = f && f[3] ? parseAttrs(f[3]).id : undefined;
97
+ if (f && oldId !== undefined) {
98
+ const openLen = /^=+/.exec(f[1])[0].length;
99
+ for (let j = hi + 1; j < lines.length; j++) {
100
+ const ct = stripEnding(lines[j]);
101
+ const trimmed = ct.replace(/[ \t]+$/, "");
102
+ if (/^=+$/.test(trimmed) && trimmed.length === openLen)
103
+ break; // plain close: done
104
+ const cm = /^(={3,}[ \t]+#)([^\s}]+)([ \t]*)$/.exec(ct);
105
+ if (cm && cm[2] === oldId) {
106
+ lines[j] = cm[1] + newId + cm[3] + lines[j].slice(ct.length);
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ return lines.join("");
112
+ }
package/dist/from-md.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // thematic break (---/***) -> dropped (§1: not part of GEML)
15
15
  //
16
16
  // Anything else (ATX headings, lists, paragraphs) is already valid GEML.
17
+ import { META_REF_SRC } from "./inline.js";
17
18
  // Pick a fence length longer than any run of `=` that appears alone on a body
18
19
  // line (leading indentation included), so the close fence stays unambiguous
19
20
  // (§3 equal-length close rule) and the body's own `=` fences nest safely.
@@ -68,6 +69,63 @@ function autolinks(s) {
68
69
  .replace(/<mailto:([^>\s]+)>/g, "[$1](mailto:$1)"))
69
70
  .join("");
70
71
  }
72
+ // A literal `{{name}}` in Markdown prose is plain text (Markdown has no
73
+ // metadata interpolation), but converted GEML flow text would read it as a §4
74
+ // reference — an unknown key fails `geml check`, and a key that happens to
75
+ // exist in the generated `=== meta` block substitutes silently. Escape it to
76
+ // `\{{name}}`, skipping the spans GEML interpolation itself leaves verbatim
77
+ // (inline code, inline math) and already-escaped characters.
78
+ const META_REF_Y = new RegExp(META_REF_SRC, "y");
79
+ function escMetaRefs(s) {
80
+ if (!s.includes("{{"))
81
+ return s;
82
+ let out = "";
83
+ let i = 0;
84
+ while (i < s.length) {
85
+ const c = s[i];
86
+ if (c === "\\" && i + 1 < s.length) {
87
+ out += s.slice(i, i + 2);
88
+ i += 2;
89
+ continue;
90
+ }
91
+ if (c === "`") {
92
+ let n = 0;
93
+ while (s[i + n] === "`")
94
+ n++;
95
+ const close = s.indexOf("`".repeat(n), i + n);
96
+ if (close >= 0) {
97
+ out += s.slice(i, close + n);
98
+ i = close + n;
99
+ continue;
100
+ }
101
+ out += s.slice(i, i + n);
102
+ i += n;
103
+ continue;
104
+ }
105
+ if (c === "$") {
106
+ const close = s.indexOf("$", i + 1);
107
+ if (close > i + 1) {
108
+ out += s.slice(i, close + 1);
109
+ i = close + 1;
110
+ continue;
111
+ }
112
+ out += c;
113
+ i++;
114
+ continue;
115
+ }
116
+ if (c === "{" && s[i + 1] === "{") {
117
+ META_REF_Y.lastIndex = i;
118
+ if (META_REF_Y.test(s)) {
119
+ out += "\\{";
120
+ i++;
121
+ continue;
122
+ }
123
+ }
124
+ out += c;
125
+ i++;
126
+ }
127
+ return out;
128
+ }
71
129
  // GitHub-style heading anchor: drop code backticks (keep content), lowercase,
72
130
  // strip punctuation except `-`/`_`, collapse whitespace to hyphens. Used to keep
73
131
  // converted headings' ids in sync with Markdown TOC links.
@@ -151,12 +209,12 @@ export function mdToGeml(source) {
151
209
  if (line.trim() !== "" && !THEMATIC.test(line) && i + 1 < lines.length) {
152
210
  const nxt = lines[i + 1];
153
211
  if (SETEXT_UL.test(nxt)) {
154
- out.push(`# ${line.trim()}`);
212
+ out.push(`# ${escMetaRefs(line.trim())}`);
155
213
  i += 2;
156
214
  continue;
157
215
  }
158
216
  if (SETEXT_DASH.test(nxt)) {
159
- out.push(`## ${line.trim()}`);
217
+ out.push(`## ${escMetaRefs(line.trim())}`);
160
218
  i += 2;
161
219
  continue;
162
220
  }
@@ -183,7 +241,7 @@ export function mdToGeml(source) {
183
241
  else
184
242
  break;
185
243
  }
186
- emitBlock(out, "note", `{#${fn[1].trim()}}`, body.map(autolinks), ids);
244
+ emitBlock(out, "note", `{#${fn[1].trim()}}`, body.map((l) => escMetaRefs(autolinks(l))), ids);
187
245
  i = j;
188
246
  continue;
189
247
  }
@@ -191,7 +249,7 @@ export function mdToGeml(source) {
191
249
  if (/^\s*>/.test(line)) {
192
250
  const body = [];
193
251
  while (i < lines.length && /^\s*>/.test(lines[i])) {
194
- body.push(lines[i].replace(/^\s*>\s?/, ""));
252
+ body.push(escMetaRefs(lines[i].replace(/^\s*>\s?/, "")));
195
253
  i++;
196
254
  }
197
255
  emitBlock(out, "note", "", body, ids);
@@ -220,13 +278,14 @@ export function mdToGeml(source) {
220
278
  if (atx && atx[2].includes("`") && !/\{[^}]*\}\s*$/.test(atx[2])) {
221
279
  const id = githubSlug(atx[2]);
222
280
  if (id) {
223
- out.push(`${atx[1]} ${atx[2]} {#${id}}`);
281
+ out.push(`${atx[1]} ${escMetaRefs(atx[2])} {#${id}}`);
224
282
  i++;
225
283
  continue;
226
284
  }
227
285
  }
228
- // Inline pass: rewrite autolinks to GEML links (outside code spans).
229
- const text = autolinks(line);
286
+ // Inline pass: rewrite autolinks to GEML links, escape literal `{{name}}`
287
+ // (both outside code spans).
288
+ const text = escMetaRefs(autolinks(line));
230
289
  // Raw HTML note — ignore `<…>` that sits inside an inline code span.
231
290
  if (/<[a-zA-Z/]/.test(text.replace(/`[^`]*`/g, ""))) {
232
291
  notes.push(`raw HTML kept as text at line ${i + 1}: ${line.trim().slice(0, 40)}`);
package/dist/geml.d.ts CHANGED
@@ -7,7 +7,8 @@ export { type Value } from "./attrs.js";
7
7
  export { type Inline } from "./inline.js";
8
8
  export { type TableModel } from "./table.js";
9
9
  export { mdToGeml, type ConvertResult } from "./from-md.js";
10
- export { renderHtml, type RenderOptions } from "./render.js";
10
+ export { renderHtml } from "./render-html.js";
11
+ export { type RenderOptions } from "./render.js";
11
12
  export { serialize } from "./serialize.js";
12
13
  export { gemlToMd } from "./to-md.js";
13
14
  export type BodyMode = "raw" | "flow" | "data";