@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/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 {…}.
|
|
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
|
+
}
|