@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/dist/inline.js ADDED
@@ -0,0 +1,418 @@
1
+ // GEML reference parser — Milestone 2: inline content (§5).
2
+ //
3
+ // Parses the inline grammar of flow blocks (paragraphs, headings, list items):
4
+ // escapes, code spans, inline math, images, links, auto-references, footnote
5
+ // references, then emphasis/strong/strike — in the §5.3 priority order. Every
6
+ // internal/cross-document reference is reported to a `RefSink` so the document
7
+ // layer can resolve and validate it at build time (§8).
8
+ import { parseAttrs } from "./attrs.js";
9
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i; // http:, https:, mailto:, …
10
+ // §5.1: when `as` is omitted, infer the media kind from the source extension.
11
+ const VIDEO_EXT = /\.(mp4|webm|mov|m4v|ogv|mkv)(?:[?#].*)?$/i;
12
+ const AUDIO_EXT = /\.(mp3|wav|ogg|oga|m4a|flac|aac|opus)(?:[?#].*)?$/i;
13
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?)(?:[?#].*)?$/i;
14
+ function inferAs(src) {
15
+ if (VIDEO_EXT.test(src))
16
+ return "video";
17
+ if (AUDIO_EXT.test(src))
18
+ return "audio";
19
+ if (IMAGE_EXT.test(src))
20
+ return "image";
21
+ return undefined;
22
+ }
23
+ // Classify a link/image destination into {href|doc, anchor}.
24
+ function classifyDest(dest) {
25
+ const d = dest.trim();
26
+ if (SCHEME.test(d))
27
+ return { href: d };
28
+ const hash = d.indexOf("#");
29
+ if (hash === 0)
30
+ return { anchor: d.slice(1) };
31
+ if (hash > 0)
32
+ return { doc: d.slice(0, hash), anchor: d.slice(hash + 1) };
33
+ if (d)
34
+ return { doc: d };
35
+ return {};
36
+ }
37
+ // Read a balanced `(...)` starting at s[i]==='('. Returns content and index
38
+ // just past the closing ')', or null if unbalanced.
39
+ function readParen(s, i) {
40
+ if (s[i] !== "(")
41
+ return null;
42
+ let depth = 0;
43
+ for (let j = i; j < s.length; j++) {
44
+ const c = s[j];
45
+ if (c === "(")
46
+ depth++;
47
+ else if (c === ")") {
48
+ depth--;
49
+ if (depth === 0)
50
+ return { content: s.slice(i + 1, j), end: j + 1 };
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ // Read a balanced `[...]` starting at s[i]==='['. Returns content and index
56
+ // just past the closing ']', or null if unbalanced.
57
+ function readBracket(s, i) {
58
+ if (s[i] !== "[")
59
+ return null;
60
+ let depth = 0;
61
+ for (let j = i; j < s.length; j++) {
62
+ const c = s[j];
63
+ if (c === "[")
64
+ depth++;
65
+ else if (c === "]") {
66
+ depth--;
67
+ if (depth === 0)
68
+ return { content: s.slice(i + 1, j), end: j + 1 };
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ // Optional `{…}` attribute object immediately following a construct.
74
+ function readAttrs(s, i) {
75
+ if (s[i] !== "{")
76
+ return null;
77
+ const close = s.indexOf("}", i);
78
+ if (close < 0)
79
+ return null;
80
+ return { attrs: parseAttrs(s.slice(i, close + 1)), end: close + 1 };
81
+ }
82
+ // Phase A: pull out high-priority atoms (escapes, code, math, media, links,
83
+ // auto-refs, footnotes, hard breaks). Everything else is left as text runs for
84
+ // phase B (emphasis). Children of links are fully re-parsed.
85
+ function scanAtoms(s, line, sink) {
86
+ const out = [];
87
+ let buf = "";
88
+ const flush = () => { if (buf) {
89
+ out.push(buf);
90
+ buf = "";
91
+ } };
92
+ let i = 0;
93
+ while (i < s.length) {
94
+ const c = s[i];
95
+ // §5.3(1): backslash escape / hard break.
96
+ if (c === "\\") {
97
+ const next = s[i + 1];
98
+ if (next === undefined || next === "\n") { // line-final backslash
99
+ flush();
100
+ out.push({ type: "break" });
101
+ i += next === undefined ? 1 : 2;
102
+ continue;
103
+ }
104
+ if (/[!-/:-@[-`{-~]/.test(next)) {
105
+ // ASCII punctuation -> literal, emitted as its own text atom so phase B
106
+ // (emphasis) cannot mistake an escaped `*`/`~` for a delimiter (§5.3(1)).
107
+ flush();
108
+ out.push({ type: "text", value: next });
109
+ i += 2;
110
+ continue;
111
+ }
112
+ buf += c;
113
+ i++;
114
+ continue;
115
+ }
116
+ // §5.3(1): code span — matched by run length, content kept raw.
117
+ if (c === "`") {
118
+ let n = 0;
119
+ while (s[i + n] === "`")
120
+ n++;
121
+ const fence = "`".repeat(n);
122
+ const close = s.indexOf(fence, i + n);
123
+ if (close >= 0) {
124
+ flush();
125
+ out.push({ type: "code", value: s.slice(i + n, close) });
126
+ i = close + n;
127
+ continue;
128
+ }
129
+ buf += fence;
130
+ i += n;
131
+ continue;
132
+ }
133
+ // §5.3(1): inline math $…$ (raw).
134
+ if (c === "$") {
135
+ const close = s.indexOf("$", i + 1);
136
+ if (close > i + 1) {
137
+ flush();
138
+ out.push({ type: "math", value: s.slice(i + 1, close) });
139
+ i = close + 1;
140
+ continue;
141
+ }
142
+ buf += c;
143
+ i++;
144
+ continue;
145
+ }
146
+ // §5.3(2): image ![alt](src){…}.
147
+ if (c === "!" && s[i + 1] === "[") {
148
+ const label = readBracket(s, i + 1);
149
+ const paren = label ? readParen(s, label.end) : null;
150
+ if (label && paren) {
151
+ const a = readAttrs(s, paren.end);
152
+ const attrObj = a ? a.attrs : { classes: [], attrs: {} };
153
+ const node = {
154
+ type: "image", alt: label.content, src: paren.content.trim(), attrs: attrObj.attrs,
155
+ };
156
+ const as = attrObj.attrs["as"];
157
+ if (typeof as === "string")
158
+ node.as = as;
159
+ else {
160
+ const inf = inferAs(node.src);
161
+ if (inf)
162
+ node.as = inf;
163
+ }
164
+ flush();
165
+ out.push(node);
166
+ i = a ? a.end : paren.end;
167
+ continue;
168
+ }
169
+ }
170
+ // §5.3(2): auto-reference [[#id]].
171
+ if (c === "[" && s[i + 1] === "[") {
172
+ const inner = readBracket(s, i + 1); // inner [...] after the first [
173
+ if (inner && s[inner.end] === "]") {
174
+ const target = inner.content.trim();
175
+ const { doc, anchor } = classifyDest(target);
176
+ if (anchor) {
177
+ flush();
178
+ const node = { type: "autoref", anchor };
179
+ if (doc)
180
+ node.doc = doc;
181
+ out.push(node);
182
+ sink.refs.push({ kind: doc ? "cross" : "autoref", doc, anchor, line });
183
+ i = inner.end + 1;
184
+ continue;
185
+ }
186
+ }
187
+ }
188
+ // §5.3(2): footnote reference [^id].
189
+ if (c === "[" && s[i + 1] === "^") {
190
+ const br = readBracket(s, i);
191
+ if (br && br.content.startsWith("^")) {
192
+ const ref = br.content.slice(1).trim();
193
+ flush();
194
+ out.push({ type: "footnote", ref });
195
+ sink.refs.push({ kind: "footnote", anchor: ref, line });
196
+ i = br.end;
197
+ continue;
198
+ }
199
+ }
200
+ // §5.3(2): link [text](dest){…}.
201
+ if (c === "[") {
202
+ const label = readBracket(s, i);
203
+ const paren = label ? readParen(s, label.end) : null;
204
+ if (label && paren) {
205
+ const a = readAttrs(s, paren.end);
206
+ const attrObj = a ? a.attrs : { classes: [], attrs: {} };
207
+ const dest = classifyDest(paren.content);
208
+ const node = {
209
+ type: "link",
210
+ children: parseInline(label.content, line, sink),
211
+ attrs: attrObj.attrs,
212
+ };
213
+ if (dest.href)
214
+ node.href = dest.href;
215
+ if (dest.doc)
216
+ node.doc = dest.doc;
217
+ if (dest.anchor)
218
+ node.anchor = dest.anchor;
219
+ if (dest.anchor || dest.doc) {
220
+ sink.refs.push({ kind: dest.doc ? "cross" : "internal", doc: dest.doc, anchor: dest.anchor, line });
221
+ }
222
+ flush();
223
+ out.push(node);
224
+ i = a ? a.end : paren.end;
225
+ continue;
226
+ }
227
+ }
228
+ buf += c;
229
+ i++;
230
+ }
231
+ flush();
232
+ return out;
233
+ }
234
+ // Phase B: emphasis / strong / strikethrough on a plain text run (§5.3).
235
+ //
236
+ // A maximal run of `*` is an emphasis delimiter (one `*` -> emphasis, two ->
237
+ // strong, longer runs pair greedily); a maximal run of two or more `~` is a
238
+ // strikethrough delimiter (a lone `~` is literal). Whether a run may *open*
239
+ // and/or *close* is fixed by flanking: it must hug a non-space character, and on
240
+ // the side facing a punctuation character it must also have whitespace or
241
+ // punctuation on the far side (the CommonMark left/right-flanking rule). Runs are
242
+ // then paired by a single left-to-right stack scan with the rule of three, so
243
+ // nested and adjacent delimiters resolve to exactly one tree — no leftmost-regex
244
+ // guesswork. Delimiters pair only *within* one text run: they never reach across
245
+ // a code span, inline math, a link or image (atoms from phase A), or a block
246
+ // boundary. Any delimiter left unpaired is literal text.
247
+ const ASCII_PUNCT = /[!-\/:-@\[-`{-~]/;
248
+ const isPunct = (c) => c !== undefined && ASCII_PUNCT.test(c);
249
+ const isWS = (c) => c === undefined || /\s/.test(c);
250
+ // Left/right-flanking for a delimiter run, given the chars on either side.
251
+ function flank(before, after) {
252
+ const bWS = isWS(before), aWS = isWS(after), bP = isPunct(before), aP = isPunct(after);
253
+ return { open: !aWS && (!aP || bWS || bP), close: !bWS && (!bP || aWS || aP) };
254
+ }
255
+ // Split a text run into a doubly-linked list of text and delimiter-run nodes.
256
+ function tokenizeRuns(s) {
257
+ let head = null, tail = null;
258
+ const push = (node) => { node.prev = tail; if (tail)
259
+ tail.next = node;
260
+ else
261
+ head = node; tail = node; };
262
+ let i = 0;
263
+ while (i < s.length) {
264
+ const c = s[i];
265
+ if (c === "*" || c === "~") {
266
+ let j = i;
267
+ while (s[j] === c)
268
+ j++;
269
+ const n = j - i;
270
+ if (c === "~" && n < 2)
271
+ push({ t: "text", v: "~", prev: null, next: null });
272
+ else {
273
+ const f = flank(i > 0 ? s[i - 1] : undefined, j < s.length ? s[j] : undefined);
274
+ push({ t: "delim", ch: c, n, open: f.open, close: f.close, prev: null, next: null });
275
+ }
276
+ i = j;
277
+ }
278
+ else {
279
+ let j = i;
280
+ while (j < s.length && s[j] !== "*" && s[j] !== "~")
281
+ j++;
282
+ push({ t: "text", v: s.slice(i, j), prev: null, next: null });
283
+ i = j;
284
+ }
285
+ }
286
+ return head;
287
+ }
288
+ const nextDelim = (n) => { for (; n; n = n.next)
289
+ if (n.t === "delim")
290
+ return n; return null; };
291
+ const prevDelim = (n) => { for (; n; n = n.prev)
292
+ if (n.t === "delim")
293
+ return n; return null; };
294
+ // Rule of three: when either side can also play the other role, a combined
295
+ // length that is a multiple of three is only allowed if both lengths are.
296
+ function rule3(o, c) {
297
+ if (o.close || c.open)
298
+ return (o.n + c.n) % 3 !== 0 || (o.n % 3 === 0 && c.n % 3 === 0);
299
+ return true;
300
+ }
301
+ function unlink(node, head) {
302
+ if (node.prev)
303
+ node.prev.next = node.next;
304
+ else
305
+ head = node.next;
306
+ if (node.next)
307
+ node.next.prev = node.prev;
308
+ return head;
309
+ }
310
+ // The CommonMark emphasis algorithm over the delimiter list: scan closers left
311
+ // to right, pair each with the nearest eligible opener, wrap the span, and bound
312
+ // future searches with `bottom` so the scan stays linear and deterministic.
313
+ function processEmphasis(head) {
314
+ const bottom = new Map();
315
+ let closer = nextDelim(head);
316
+ while (closer) {
317
+ if (closer.t !== "delim" || !closer.close) {
318
+ closer = nextDelim(closer.next);
319
+ continue;
320
+ }
321
+ const ch = closer.ch;
322
+ const key = `${ch}${closer.open ? 1 : 0}${closer.n % 3}`;
323
+ const stop = bottom.has(key) ? bottom.get(key) : null;
324
+ let opener = prevDelim(closer.prev);
325
+ let found = null;
326
+ while (opener && opener !== stop) {
327
+ if (opener.t === "delim" && opener.open && opener.ch === ch && rule3(opener, closer)) {
328
+ found = opener;
329
+ break;
330
+ }
331
+ opener = prevDelim(opener.prev);
332
+ }
333
+ if (found) {
334
+ const use = ch === "~" ? 2 : (found.n >= 2 && closer.n >= 2 ? 2 : 1);
335
+ const kind = ch === "~" ? "strike" : use === 2 ? "strong" : "emph";
336
+ // Gather and detach the nodes strictly between opener and closer.
337
+ let kidsHead = null, kidsTail = null;
338
+ for (let p = found.next; p && p !== closer;) {
339
+ const q = p.next;
340
+ p.prev = kidsTail;
341
+ p.next = null;
342
+ if (kidsTail)
343
+ kidsTail.next = p;
344
+ else
345
+ kidsHead = p;
346
+ kidsTail = p;
347
+ p = q;
348
+ }
349
+ const wrap = { t: "wrap", kind, kids: kidsHead, prev: found, next: closer };
350
+ found.next = wrap;
351
+ closer.prev = wrap;
352
+ found.n -= use;
353
+ closer.n -= use;
354
+ if (found.n === 0)
355
+ head = unlink(found, head);
356
+ if (closer.n === 0) {
357
+ const after = closer.next;
358
+ head = unlink(closer, head);
359
+ closer = nextDelim(after);
360
+ }
361
+ // else: keep the same closer (it still has delimiter characters left).
362
+ }
363
+ else {
364
+ bottom.set(key, closer.prev);
365
+ closer = nextDelim(closer.next);
366
+ }
367
+ }
368
+ return head;
369
+ }
370
+ // Linked list of (possibly nested) nodes -> Inline[]; unpaired delimiters and
371
+ // empty text vanish into literal text, with adjacent text runs merged.
372
+ function finalize(head) {
373
+ const out = [];
374
+ const pushText = (v) => {
375
+ const last = out[out.length - 1];
376
+ if (last && last.type === "text")
377
+ last.value += v;
378
+ else if (v)
379
+ out.push({ type: "text", value: v });
380
+ };
381
+ for (let n = head; n; n = n.next) {
382
+ if (n.t === "text")
383
+ pushText(n.v);
384
+ else if (n.t === "delim")
385
+ pushText(n.ch.repeat(n.n));
386
+ else
387
+ out.push({ type: n.kind, children: finalize(n.kids) });
388
+ }
389
+ return out;
390
+ }
391
+ function emphasize(text) {
392
+ const head = tokenizeRuns(text);
393
+ return head ? finalize(processEmphasis(head)) : [];
394
+ }
395
+ // Coalesce adjacent literal text nodes (e.g. an escaped `*` atom sitting between
396
+ // two text runs) so the inline sequence is canonical.
397
+ function mergeText(ns) {
398
+ const out = [];
399
+ for (const n of ns) {
400
+ const last = out[out.length - 1];
401
+ if (n.type === "text" && last && last.type === "text")
402
+ last.value += n.value;
403
+ else
404
+ out.push(n);
405
+ }
406
+ return out;
407
+ }
408
+ export function parseInline(s, line, sink) {
409
+ const atoms = scanAtoms(s, line, sink);
410
+ const out = [];
411
+ for (const a of atoms) {
412
+ if (typeof a === "string")
413
+ out.push(...emphasize(a));
414
+ else
415
+ out.push(a);
416
+ }
417
+ return mergeText(out);
418
+ }
@@ -0,0 +1,6 @@
1
+ import { type Document } from "./geml.js";
2
+ export interface RenderOptions {
3
+ title?: string;
4
+ source?: string;
5
+ }
6
+ export declare function renderHtml(doc: Document, opts?: RenderOptions): string;