@cube-drone/marquee-parser 0.1.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 +373 -0
- package/README.md +22 -0
- package/dist/ast.d.ts +79 -0
- package/dist/ast.js +3 -0
- package/dist/attrs.d.ts +30 -0
- package/dist/attrs.js +99 -0
- package/dist/blocks.d.ts +6 -0
- package/dist/blocks.js +329 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/inlines.d.ts +4 -0
- package/dist/inlines.js +400 -0
- package/package.json +39 -0
- package/src/ast.ts +39 -0
- package/src/attrs.ts +114 -0
- package/src/blocks.ts +379 -0
- package/src/index.ts +47 -0
- package/src/inlines.ts +440 -0
package/dist/inlines.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// The inline algorithm (SPEC.md, "The inline algorithm"): a faithful port of
|
|
2
|
+
// rust/parser/src/inlines.rs. One left-to-right pass per container; escape >
|
|
3
|
+
// code span > bracket > emoji > delimiter run; top-of-stack delimiter
|
|
4
|
+
// matching; everything unmatched reverts to literal.
|
|
5
|
+
//
|
|
6
|
+
// Works on code points (Array.from), mirroring Rust's Vec<char>, so the two
|
|
7
|
+
// implementations scan identical units.
|
|
8
|
+
import { isName, nameLen, parseAttrs, parseValue, utf8Len } from "./attrs.js";
|
|
9
|
+
import { MAX_TARGET_BYTES } from "./blocks.js";
|
|
10
|
+
export const MAX_INLINE_DEPTH = 16;
|
|
11
|
+
export const MAX_EMOJI_SLUG_BYTES = 64;
|
|
12
|
+
function rootFrame() {
|
|
13
|
+
return { openerRaw: "", name: "", attrs: {}, children: [], delims: [] };
|
|
14
|
+
}
|
|
15
|
+
const ASCII_PUNCT = new Set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~");
|
|
16
|
+
/** Grammar whitespace is ASCII only (SPEC.md, front-door normalization):
|
|
17
|
+
* Unicode spaces are content, not structure. */
|
|
18
|
+
function isWs(c) {
|
|
19
|
+
return c === " " || c === "\t" || c === "\n";
|
|
20
|
+
}
|
|
21
|
+
function isSlugChar(c) {
|
|
22
|
+
return /^[a-z0-9_+-]$/.test(c);
|
|
23
|
+
}
|
|
24
|
+
export function parseInlines(text) {
|
|
25
|
+
return parseInlinesAt(text, 0);
|
|
26
|
+
}
|
|
27
|
+
/** `base` is the inline depth already spent by enclosing link text: spans,
|
|
28
|
+
* delimiters, and link nesting share the one <= 16 cap (a per-construct cap
|
|
29
|
+
* would let composition multiply past it). */
|
|
30
|
+
function parseInlinesAt(text, base) {
|
|
31
|
+
const chars = Array.from(text);
|
|
32
|
+
const frames = [rootFrame()];
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < chars.length) {
|
|
35
|
+
const c = chars[i];
|
|
36
|
+
if (c === "\\") {
|
|
37
|
+
const n = chars[i + 1];
|
|
38
|
+
if (n === "\n") {
|
|
39
|
+
top(frames).children.push({ type: "hard_break" });
|
|
40
|
+
i += 2;
|
|
41
|
+
}
|
|
42
|
+
else if (n !== undefined && ASCII_PUNCT.has(n)) {
|
|
43
|
+
pushStr(frames, n);
|
|
44
|
+
i += 2;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
pushStr(frames, "\\");
|
|
48
|
+
i += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (c === "`") {
|
|
52
|
+
const n = runLen(chars, i, "`");
|
|
53
|
+
const k = findBacktickCloser(chars, i + n, n);
|
|
54
|
+
if (k !== null) {
|
|
55
|
+
top(frames).children.push({ type: "code_span", text: chars.slice(i + n, k).join("") });
|
|
56
|
+
i = k + n;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
pushStr(frames, "`".repeat(n));
|
|
60
|
+
i += n;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (c === "[") {
|
|
64
|
+
i = bracket(chars, i, false, frames, base);
|
|
65
|
+
}
|
|
66
|
+
else if (c === "!" && chars[i + 1] === "[") {
|
|
67
|
+
i = bracket(chars, i + 1, true, frames, base);
|
|
68
|
+
}
|
|
69
|
+
else if (c === ":") {
|
|
70
|
+
let k = i + 1;
|
|
71
|
+
while (k < chars.length && isSlugChar(chars[k])) {
|
|
72
|
+
k += 1;
|
|
73
|
+
}
|
|
74
|
+
const slugLen = k - (i + 1);
|
|
75
|
+
if (slugLen >= 1 && slugLen <= MAX_EMOJI_SLUG_BYTES && chars[k] === ":") {
|
|
76
|
+
top(frames).children.push({ type: "emoji", slug: chars.slice(i + 1, k).join("") });
|
|
77
|
+
i = k + 1;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
pushStr(frames, ":");
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (c === "*") {
|
|
85
|
+
const n = runLen(chars, i, "*");
|
|
86
|
+
if (n === 1) {
|
|
87
|
+
i = delimiter(chars, i, n, "em", "*", frames, base);
|
|
88
|
+
}
|
|
89
|
+
else if (n === 2) {
|
|
90
|
+
i = delimiter(chars, i, n, "strong", "**", frames, base);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
pushStr(frames, "*".repeat(n));
|
|
94
|
+
i += n;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (c === "~") {
|
|
98
|
+
const n = runLen(chars, i, "~");
|
|
99
|
+
if (n === 2) {
|
|
100
|
+
i = delimiter(chars, i, n, "strike", "~~", frames, base);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
pushStr(frames, "~".repeat(n));
|
|
104
|
+
i += n;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
pushStr(frames, c);
|
|
109
|
+
i += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Container end: unclosed spans and delimiters revert to literal text.
|
|
113
|
+
while (frames.length > 1) {
|
|
114
|
+
const frame = frames.pop();
|
|
115
|
+
top(frames).children.push(...revertFrame(frame));
|
|
116
|
+
}
|
|
117
|
+
const root = frames.pop();
|
|
118
|
+
return normalize(revertDelims(root.children, root.delims));
|
|
119
|
+
}
|
|
120
|
+
function top(frames) {
|
|
121
|
+
return frames[frames.length - 1];
|
|
122
|
+
}
|
|
123
|
+
function pushStr(frames, s) {
|
|
124
|
+
const frame = top(frames);
|
|
125
|
+
// An open delimiter sits (invisibly, until it closes) at its recorded
|
|
126
|
+
// index: text on its far side must not merge into text before it.
|
|
127
|
+
const barrier = frame.delims.length > 0 ? frame.delims[frame.delims.length - 1].idx : 0;
|
|
128
|
+
const children = frame.children;
|
|
129
|
+
if (children.length > barrier) {
|
|
130
|
+
const last = children[children.length - 1];
|
|
131
|
+
if (last.type === "text") {
|
|
132
|
+
last.value += s;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
children.push({ type: "text", value: s });
|
|
137
|
+
}
|
|
138
|
+
function runLen(chars, i, c) {
|
|
139
|
+
let n = 0;
|
|
140
|
+
while (i + n < chars.length && chars[i + n] === c) {
|
|
141
|
+
n += 1;
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
function findBacktickCloser(chars, from, n) {
|
|
146
|
+
let k = from;
|
|
147
|
+
while (k < chars.length) {
|
|
148
|
+
if (chars[k] === "`") {
|
|
149
|
+
const m = runLen(chars, k, "`");
|
|
150
|
+
if (m === n) {
|
|
151
|
+
return k;
|
|
152
|
+
}
|
|
153
|
+
k += m;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
k += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
function totalDepth(frames) {
|
|
162
|
+
let depth = frames.length - 1;
|
|
163
|
+
for (const f of frames) {
|
|
164
|
+
depth += f.delims.length;
|
|
165
|
+
}
|
|
166
|
+
return depth;
|
|
167
|
+
}
|
|
168
|
+
function delimiter(chars, i, n, kind, raw, frames, base) {
|
|
169
|
+
const canClose = i > 0 && !isWs(chars[i - 1]);
|
|
170
|
+
const next = chars[i + n];
|
|
171
|
+
const canOpen = next !== undefined && !isWs(next);
|
|
172
|
+
const deep = base + totalDepth(frames) >= MAX_INLINE_DEPTH;
|
|
173
|
+
const frame = top(frames);
|
|
174
|
+
const innermost = frame.delims[frame.delims.length - 1];
|
|
175
|
+
if (canClose && innermost !== undefined && innermost.kind === kind) {
|
|
176
|
+
frame.delims.pop();
|
|
177
|
+
const inner = normalize(frame.children.splice(innermost.idx));
|
|
178
|
+
frame.children.push(kind === "em"
|
|
179
|
+
? { type: "emphasis", children: inner }
|
|
180
|
+
: kind === "strong"
|
|
181
|
+
? { type: "strong", children: inner }
|
|
182
|
+
: { type: "strikethrough", children: inner });
|
|
183
|
+
}
|
|
184
|
+
else if (canOpen && !deep) {
|
|
185
|
+
frame.delims.push({ kind, idx: frame.children.length, raw });
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
pushStr(frames, raw);
|
|
189
|
+
}
|
|
190
|
+
return i + n;
|
|
191
|
+
}
|
|
192
|
+
/** Handle a bracket construct starting at `chars[open]` (which is `[`).
|
|
193
|
+
* `embed` means a `!` sits just before it. Returns the new position. */
|
|
194
|
+
function bracket(chars, open, embed, frames, base) {
|
|
195
|
+
const bang = embed ? open - 1 : open;
|
|
196
|
+
const fallback = () => {
|
|
197
|
+
pushStr(frames, chars[bang]);
|
|
198
|
+
return bang + 1;
|
|
199
|
+
};
|
|
200
|
+
// Find the matching `]` (balanced, escape-aware).
|
|
201
|
+
let depth = 1;
|
|
202
|
+
let k = open + 1;
|
|
203
|
+
while (k < chars.length) {
|
|
204
|
+
const c = chars[k];
|
|
205
|
+
if (c === "\\") {
|
|
206
|
+
k += 1;
|
|
207
|
+
}
|
|
208
|
+
else if (c === "[") {
|
|
209
|
+
depth += 1;
|
|
210
|
+
}
|
|
211
|
+
else if (c === "]") {
|
|
212
|
+
depth -= 1;
|
|
213
|
+
if (depth === 0) {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
k += 1;
|
|
218
|
+
}
|
|
219
|
+
if (k >= chars.length) {
|
|
220
|
+
return fallback();
|
|
221
|
+
}
|
|
222
|
+
const interior = chars.slice(open + 1, k).join("");
|
|
223
|
+
// Link / embed: `](` with a lexable target.
|
|
224
|
+
if (chars[k + 1] === "(") {
|
|
225
|
+
// A link's text is one nesting level (embeds don't recurse: alt is a
|
|
226
|
+
// plain string). Over the shared inline cap, fall back to literal.
|
|
227
|
+
if (!embed && base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
|
|
228
|
+
return fallback();
|
|
229
|
+
}
|
|
230
|
+
const lexed = lexTarget(chars, k + 2);
|
|
231
|
+
if (lexed !== null) {
|
|
232
|
+
const depth = base + totalDepth(frames) + 1;
|
|
233
|
+
top(frames).children.push(embed
|
|
234
|
+
? { type: "embed", target: lexed.target, alt: resolveEscapes(interior) }
|
|
235
|
+
: { type: "link", target: lexed.target, children: parseInlinesAt(interior, depth) });
|
|
236
|
+
return lexed.end;
|
|
237
|
+
}
|
|
238
|
+
return fallback();
|
|
239
|
+
}
|
|
240
|
+
// Span closer: `[/name]` must name the innermost open span.
|
|
241
|
+
if (interior.startsWith("/")) {
|
|
242
|
+
const name = interior.slice(1);
|
|
243
|
+
if (isName(name)) {
|
|
244
|
+
if (embed) {
|
|
245
|
+
pushStr(frames, "!"); // the ! belongs to links/embeds, not spans
|
|
246
|
+
}
|
|
247
|
+
if (frames.length > 1 && top(frames).name === name) {
|
|
248
|
+
const frame = frames.pop();
|
|
249
|
+
const children = normalize(revertDelims(frame.children, frame.delims));
|
|
250
|
+
top(frames).children.push({ type: "span", name: frame.name, attrs: frame.attrs, children });
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// Well-formed but mismatched or orphan: the characters back.
|
|
254
|
+
pushStr(frames, `[/${name}]`);
|
|
255
|
+
}
|
|
256
|
+
return k + 1;
|
|
257
|
+
}
|
|
258
|
+
return fallback();
|
|
259
|
+
}
|
|
260
|
+
// Span opener: `[name ...]`, with the BBCode default-parameter idiom
|
|
261
|
+
// (`[color=red]` puts `color=red` in the span's own attrs).
|
|
262
|
+
const opener = parseSpanOpener(interior);
|
|
263
|
+
if (opener !== null) {
|
|
264
|
+
if (embed) {
|
|
265
|
+
pushStr(frames, "!"); // the ! belongs to links/embeds, not spans
|
|
266
|
+
}
|
|
267
|
+
if (base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
|
|
268
|
+
pushStr(frames, chars.slice(open, k + 1).join(""));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
frames.push({
|
|
272
|
+
openerRaw: chars.slice(open, k + 1).join(""),
|
|
273
|
+
name: opener.name,
|
|
274
|
+
attrs: opener.attrs,
|
|
275
|
+
children: [],
|
|
276
|
+
delims: [],
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return k + 1;
|
|
280
|
+
}
|
|
281
|
+
return fallback();
|
|
282
|
+
}
|
|
283
|
+
function parseSpanOpener(interior) {
|
|
284
|
+
const nlen = nameLen(interior);
|
|
285
|
+
if (nlen === 0) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const name = interior.slice(0, nlen);
|
|
289
|
+
const pairs = new Map();
|
|
290
|
+
let rest = interior.slice(nlen);
|
|
291
|
+
if (rest.startsWith("=")) {
|
|
292
|
+
const v = parseValue(rest.slice(1));
|
|
293
|
+
if (!v.ok) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (!(v.rest === "" || v.rest.startsWith(" ") || v.rest.startsWith("\t"))) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
pairs.set(name, v.value);
|
|
300
|
+
rest = v.rest;
|
|
301
|
+
}
|
|
302
|
+
else if (!(rest === "" || rest.startsWith(" ") || rest.startsWith("\t"))) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const parsed = parseAttrs(rest);
|
|
306
|
+
if (!parsed.ok) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
for (const [key, value] of Object.entries(parsed.attrs)) {
|
|
310
|
+
if (!pairs.has(key)) {
|
|
311
|
+
pairs.set(key, value);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { name, attrs: Object.fromEntries(pairs) };
|
|
315
|
+
}
|
|
316
|
+
/** Lex a link/embed target from `chars[from]`: no whitespace, balanced
|
|
317
|
+
* parens, an unbalanced `)` ends it. Returns the position after `)`. */
|
|
318
|
+
function lexTarget(chars, from) {
|
|
319
|
+
let depth = 0;
|
|
320
|
+
let k = from;
|
|
321
|
+
while (k < chars.length) {
|
|
322
|
+
const c = chars[k];
|
|
323
|
+
if (c === ")" && depth === 0) {
|
|
324
|
+
const target = chars.slice(from, k).join("");
|
|
325
|
+
if (utf8Len(target) > MAX_TARGET_BYTES) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return { target, end: k + 1 };
|
|
329
|
+
}
|
|
330
|
+
if (c === ")") {
|
|
331
|
+
depth -= 1;
|
|
332
|
+
}
|
|
333
|
+
else if (c === "(") {
|
|
334
|
+
depth += 1;
|
|
335
|
+
}
|
|
336
|
+
else if (isWs(c)) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
k += 1;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
function resolveEscapes(s) {
|
|
344
|
+
let out = "";
|
|
345
|
+
const chars = Array.from(s);
|
|
346
|
+
let i = 0;
|
|
347
|
+
while (i < chars.length) {
|
|
348
|
+
const c = chars[i];
|
|
349
|
+
if (c === "\\") {
|
|
350
|
+
const n = chars[i + 1];
|
|
351
|
+
if (n !== undefined && ASCII_PUNCT.has(n)) {
|
|
352
|
+
out += n;
|
|
353
|
+
i += 2;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
out += "\\";
|
|
357
|
+
if (n !== undefined) {
|
|
358
|
+
out += n;
|
|
359
|
+
i += 2;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
i += 1;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
out += c;
|
|
366
|
+
i += 1;
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
/** An unclosed span reverts: its opener text, then its children, flattened
|
|
371
|
+
* into the parent (its own unmatched delimiters reverting first). */
|
|
372
|
+
function revertFrame(frame) {
|
|
373
|
+
const out = [{ type: "text", value: frame.openerRaw }];
|
|
374
|
+
out.push(...revertDelims(frame.children, frame.delims));
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
function revertDelims(children, delims) {
|
|
378
|
+
for (let d = delims.length - 1; d >= 0; d -= 1) {
|
|
379
|
+
children.splice(delims[d].idx, 0, { type: "text", value: delims[d].raw });
|
|
380
|
+
}
|
|
381
|
+
return children;
|
|
382
|
+
}
|
|
383
|
+
/** Canonical text: adjacent literals merge, empty text nodes vanish. */
|
|
384
|
+
function normalize(children) {
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const node of children) {
|
|
387
|
+
if (node.type === "text") {
|
|
388
|
+
if (node.value === "") {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const last = out[out.length - 1];
|
|
392
|
+
if (last !== undefined && last.type === "text") {
|
|
393
|
+
last.value += node.value;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
out.push(node);
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cube-drone/marquee-parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reference parser for the Marquee markup language",
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/cube-drone/marqueemarkup.git",
|
|
9
|
+
"directory": "ts/parser"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.6.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"marquee-src": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --conditions=marquee-src --test test/*.test.ts",
|
|
31
|
+
"check": "tsc --noEmit",
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"prepack": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.0.0",
|
|
37
|
+
"typescript": "^5.9.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/ast.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The AST is the contract: these shapes mirror SPEC.md's node inventory
|
|
2
|
+
// exactly, and structural equality against the vectors is conformance.
|
|
3
|
+
|
|
4
|
+
export type Attrs = Record<string, string>;
|
|
5
|
+
|
|
6
|
+
/** The closed `invalid_directive` reason enum (SPEC.md, "The AST"). */
|
|
7
|
+
export type Reason =
|
|
8
|
+
| "bad_name"
|
|
9
|
+
| "bad_attribute"
|
|
10
|
+
| "attribute_too_long"
|
|
11
|
+
| "depth_exceeded"
|
|
12
|
+
| "mismatched_close"
|
|
13
|
+
| "stray_close";
|
|
14
|
+
|
|
15
|
+
export type Node =
|
|
16
|
+
// Blocks
|
|
17
|
+
| { type: "document"; version: number; children: Node[] }
|
|
18
|
+
| { type: "paragraph"; children: Node[] }
|
|
19
|
+
| { type: "heading"; level: number; children: Node[] }
|
|
20
|
+
| { type: "code_block"; info?: string; text: string }
|
|
21
|
+
| { type: "blockquote"; children: Node[] }
|
|
22
|
+
| { type: "list"; ordered: boolean; children: Node[] }
|
|
23
|
+
| { type: "list_item"; children: Node[] }
|
|
24
|
+
| { type: "thematic_break" }
|
|
25
|
+
| { type: "directive"; name: string; attrs: Attrs; children: Node[] }
|
|
26
|
+
| { type: "invalid_directive"; reason: Reason; children: Node[] }
|
|
27
|
+
| { type: "comment"; text: string }
|
|
28
|
+
// Inlines
|
|
29
|
+
| { type: "text"; value: string }
|
|
30
|
+
| { type: "emphasis"; children: Node[] }
|
|
31
|
+
| { type: "strong"; children: Node[] }
|
|
32
|
+
| { type: "strikethrough"; children: Node[] }
|
|
33
|
+
| { type: "code_span"; text: string }
|
|
34
|
+
| { type: "link"; target: string; children: Node[] }
|
|
35
|
+
| { type: "embed"; target: string; alt: string }
|
|
36
|
+
| { type: "turbolink"; target: string }
|
|
37
|
+
| { type: "span"; name: string; attrs: Attrs; children: Node[] }
|
|
38
|
+
| { type: "emoji"; slug: string }
|
|
39
|
+
| { type: "hard_break" };
|
package/src/attrs.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The attribute grammar (SPEC.md, "The attribute grammar"). Shared by
|
|
2
|
+
// directive open lines and span openers; only the failure mode differs
|
|
3
|
+
// (blocks get invalid_directive nodes, spans fall back to literal text).
|
|
4
|
+
|
|
5
|
+
import type { Attrs, Reason } from "./ast.ts";
|
|
6
|
+
|
|
7
|
+
export const MAX_ATTR_VALUE_BYTES = 2048;
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
/** Caps are byte counts (SPEC.md): measure UTF-8, not UTF-16 units. */
|
|
12
|
+
export function utf8Len(s: string): number {
|
|
13
|
+
return encoder.encode(s).length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isName(s: string): boolean {
|
|
17
|
+
return /^[a-z][a-z0-9_-]*$/.test(s);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Length of the leading name in `s`, or 0. (Names are ASCII, so this
|
|
21
|
+
* length is the same in UTF-16 units and bytes.) */
|
|
22
|
+
export function nameLen(s: string): number {
|
|
23
|
+
const m = /^[a-z][a-z0-9_-]*/.exec(s);
|
|
24
|
+
return m ? m[0].length : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ValueResult =
|
|
28
|
+
| { ok: true; value: string; rest: string }
|
|
29
|
+
| { ok: false; reason: Reason };
|
|
30
|
+
|
|
31
|
+
export type AttrsResult = { ok: true; attrs: Attrs } | { ok: false; reason: Reason };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a whitespace-separated `key=value` list. Duplicate keys resolve
|
|
35
|
+
* first-writer-wins. Any deviation from the grammar is an error.
|
|
36
|
+
*/
|
|
37
|
+
export function parseAttrs(src: string): AttrsResult {
|
|
38
|
+
// A Map sidesteps object-prototype traps (`__proto__` is a legal key);
|
|
39
|
+
// Object.fromEntries defines own properties, so the traps stay closed.
|
|
40
|
+
const attrs = new Map<string, string>();
|
|
41
|
+
let rest = src;
|
|
42
|
+
for (;;) {
|
|
43
|
+
rest = rest.replace(/^[ \t]+/, "");
|
|
44
|
+
if (rest === "") {
|
|
45
|
+
return { ok: true, attrs: Object.fromEntries(attrs) };
|
|
46
|
+
}
|
|
47
|
+
const klen = nameLen(rest);
|
|
48
|
+
if (klen === 0) {
|
|
49
|
+
return { ok: false, reason: "bad_attribute" };
|
|
50
|
+
}
|
|
51
|
+
const key = rest.slice(0, klen);
|
|
52
|
+
rest = rest.slice(klen);
|
|
53
|
+
if (!rest.startsWith("=")) {
|
|
54
|
+
return { ok: false, reason: "bad_attribute" };
|
|
55
|
+
}
|
|
56
|
+
const v = parseValue(rest.slice(1));
|
|
57
|
+
if (!v.ok) {
|
|
58
|
+
return { ok: false, reason: v.reason };
|
|
59
|
+
}
|
|
60
|
+
rest = v.rest;
|
|
61
|
+
// After a value: end of input or whitespace.
|
|
62
|
+
if (!(rest === "" || rest.startsWith(" ") || rest.startsWith("\t"))) {
|
|
63
|
+
return { ok: false, reason: "bad_attribute" };
|
|
64
|
+
}
|
|
65
|
+
if (!attrs.has(key)) {
|
|
66
|
+
attrs.set(key, v.value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse one attribute value at the start of `s`; returns (value, rest). */
|
|
72
|
+
export function parseValue(s: string): ValueResult {
|
|
73
|
+
if (s.startsWith('"')) {
|
|
74
|
+
let value = "";
|
|
75
|
+
let i = 1;
|
|
76
|
+
while (i < s.length) {
|
|
77
|
+
const c = s[i]!;
|
|
78
|
+
if (c === '"') {
|
|
79
|
+
if (utf8Len(value) > MAX_ATTR_VALUE_BYTES) {
|
|
80
|
+
return { ok: false, reason: "attribute_too_long" };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, value, rest: s.slice(i + 1) };
|
|
83
|
+
}
|
|
84
|
+
if (c === "\\") {
|
|
85
|
+
const n = s[i + 1];
|
|
86
|
+
if (n === '"' || n === "\\") {
|
|
87
|
+
value += n;
|
|
88
|
+
i += 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
return { ok: false, reason: "bad_attribute" };
|
|
92
|
+
}
|
|
93
|
+
value += c;
|
|
94
|
+
i += 1;
|
|
95
|
+
}
|
|
96
|
+
return { ok: false, reason: "bad_attribute" }; // unterminated quote
|
|
97
|
+
}
|
|
98
|
+
let end = 0;
|
|
99
|
+
while (end < s.length && s[end] !== " " && s[end] !== "\t" && s[end] !== '"') {
|
|
100
|
+
end += 1;
|
|
101
|
+
}
|
|
102
|
+
if (end === 0) {
|
|
103
|
+
return { ok: false, reason: "bad_attribute" }; // empty bare value
|
|
104
|
+
}
|
|
105
|
+
const rest = s.slice(end);
|
|
106
|
+
if (rest.startsWith('"')) {
|
|
107
|
+
return { ok: false, reason: "bad_attribute" }; // quote inside a bare value
|
|
108
|
+
}
|
|
109
|
+
const value = s.slice(0, end);
|
|
110
|
+
if (utf8Len(value) > MAX_ATTR_VALUE_BYTES) {
|
|
111
|
+
return { ok: false, reason: "attribute_too_long" };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, value, rest };
|
|
114
|
+
}
|