@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/src/inlines.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
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
|
+
|
|
9
|
+
import type { Attrs, Node } from "./ast.ts";
|
|
10
|
+
import { isName, nameLen, parseAttrs, parseValue, utf8Len } from "./attrs.ts";
|
|
11
|
+
import { MAX_TARGET_BYTES } from "./blocks.ts";
|
|
12
|
+
|
|
13
|
+
export const MAX_INLINE_DEPTH = 16;
|
|
14
|
+
export const MAX_EMOJI_SLUG_BYTES = 64;
|
|
15
|
+
|
|
16
|
+
type DelimKind = "em" | "strong" | "strike";
|
|
17
|
+
|
|
18
|
+
interface Delim {
|
|
19
|
+
kind: DelimKind;
|
|
20
|
+
idx: number;
|
|
21
|
+
raw: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Frame {
|
|
25
|
+
/** Raw span opener text (`[color=red]`), for reverting if never closed. */
|
|
26
|
+
openerRaw: string;
|
|
27
|
+
name: string;
|
|
28
|
+
attrs: Attrs;
|
|
29
|
+
children: Node[];
|
|
30
|
+
delims: Delim[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rootFrame(): Frame {
|
|
34
|
+
return { openerRaw: "", name: "", attrs: {}, children: [], delims: [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ASCII_PUNCT = new Set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~");
|
|
38
|
+
|
|
39
|
+
/** Grammar whitespace is ASCII only (SPEC.md, front-door normalization):
|
|
40
|
+
* Unicode spaces are content, not structure. */
|
|
41
|
+
function isWs(c: string): boolean {
|
|
42
|
+
return c === " " || c === "\t" || c === "\n";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSlugChar(c: string): boolean {
|
|
46
|
+
return /^[a-z0-9_+-]$/.test(c);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseInlines(text: string): Node[] {
|
|
50
|
+
return parseInlinesAt(text, 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `base` is the inline depth already spent by enclosing link text: spans,
|
|
54
|
+
* delimiters, and link nesting share the one <= 16 cap (a per-construct cap
|
|
55
|
+
* would let composition multiply past it). */
|
|
56
|
+
function parseInlinesAt(text: string, base: number): Node[] {
|
|
57
|
+
const chars = Array.from(text);
|
|
58
|
+
const frames: Frame[] = [rootFrame()];
|
|
59
|
+
let i = 0;
|
|
60
|
+
|
|
61
|
+
while (i < chars.length) {
|
|
62
|
+
const c = chars[i]!;
|
|
63
|
+
if (c === "\\") {
|
|
64
|
+
const n = chars[i + 1];
|
|
65
|
+
if (n === "\n") {
|
|
66
|
+
top(frames).children.push({ type: "hard_break" });
|
|
67
|
+
i += 2;
|
|
68
|
+
} else if (n !== undefined && ASCII_PUNCT.has(n)) {
|
|
69
|
+
pushStr(frames, n);
|
|
70
|
+
i += 2;
|
|
71
|
+
} else {
|
|
72
|
+
pushStr(frames, "\\");
|
|
73
|
+
i += 1;
|
|
74
|
+
}
|
|
75
|
+
} else if (c === "`") {
|
|
76
|
+
const n = runLen(chars, i, "`");
|
|
77
|
+
const k = findBacktickCloser(chars, i + n, n);
|
|
78
|
+
if (k !== null) {
|
|
79
|
+
top(frames).children.push({ type: "code_span", text: chars.slice(i + n, k).join("") });
|
|
80
|
+
i = k + n;
|
|
81
|
+
} else {
|
|
82
|
+
pushStr(frames, "`".repeat(n));
|
|
83
|
+
i += n;
|
|
84
|
+
}
|
|
85
|
+
} else if (c === "[") {
|
|
86
|
+
i = bracket(chars, i, false, frames, base);
|
|
87
|
+
} else if (c === "!" && chars[i + 1] === "[") {
|
|
88
|
+
i = bracket(chars, i + 1, true, frames, base);
|
|
89
|
+
} else if (c === ":") {
|
|
90
|
+
let k = i + 1;
|
|
91
|
+
while (k < chars.length && isSlugChar(chars[k]!)) {
|
|
92
|
+
k += 1;
|
|
93
|
+
}
|
|
94
|
+
const slugLen = k - (i + 1);
|
|
95
|
+
if (slugLen >= 1 && slugLen <= MAX_EMOJI_SLUG_BYTES && chars[k] === ":") {
|
|
96
|
+
top(frames).children.push({ type: "emoji", slug: chars.slice(i + 1, k).join("") });
|
|
97
|
+
i = k + 1;
|
|
98
|
+
} else {
|
|
99
|
+
pushStr(frames, ":");
|
|
100
|
+
i += 1;
|
|
101
|
+
}
|
|
102
|
+
} else if (c === "*") {
|
|
103
|
+
const n = runLen(chars, i, "*");
|
|
104
|
+
if (n === 1) {
|
|
105
|
+
i = delimiter(chars, i, n, "em", "*", frames, base);
|
|
106
|
+
} else if (n === 2) {
|
|
107
|
+
i = delimiter(chars, i, n, "strong", "**", frames, base);
|
|
108
|
+
} else {
|
|
109
|
+
pushStr(frames, "*".repeat(n));
|
|
110
|
+
i += n;
|
|
111
|
+
}
|
|
112
|
+
} else if (c === "~") {
|
|
113
|
+
const n = runLen(chars, i, "~");
|
|
114
|
+
if (n === 2) {
|
|
115
|
+
i = delimiter(chars, i, n, "strike", "~~", frames, base);
|
|
116
|
+
} else {
|
|
117
|
+
pushStr(frames, "~".repeat(n));
|
|
118
|
+
i += n;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
pushStr(frames, c);
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Container end: unclosed spans and delimiters revert to literal text.
|
|
127
|
+
while (frames.length > 1) {
|
|
128
|
+
const frame = frames.pop()!;
|
|
129
|
+
top(frames).children.push(...revertFrame(frame));
|
|
130
|
+
}
|
|
131
|
+
const root = frames.pop()!;
|
|
132
|
+
return normalize(revertDelims(root.children, root.delims));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function top(frames: Frame[]): Frame {
|
|
136
|
+
return frames[frames.length - 1]!;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pushStr(frames: Frame[], s: string): void {
|
|
140
|
+
const frame = top(frames);
|
|
141
|
+
// An open delimiter sits (invisibly, until it closes) at its recorded
|
|
142
|
+
// index: text on its far side must not merge into text before it.
|
|
143
|
+
const barrier = frame.delims.length > 0 ? frame.delims[frame.delims.length - 1]!.idx : 0;
|
|
144
|
+
const children = frame.children;
|
|
145
|
+
if (children.length > barrier) {
|
|
146
|
+
const last = children[children.length - 1]!;
|
|
147
|
+
if (last.type === "text") {
|
|
148
|
+
last.value += s;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
children.push({ type: "text", value: s });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function runLen(chars: string[], i: number, c: string): number {
|
|
156
|
+
let n = 0;
|
|
157
|
+
while (i + n < chars.length && chars[i + n] === c) {
|
|
158
|
+
n += 1;
|
|
159
|
+
}
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function findBacktickCloser(chars: string[], from: number, n: number): number | null {
|
|
164
|
+
let k = from;
|
|
165
|
+
while (k < chars.length) {
|
|
166
|
+
if (chars[k] === "`") {
|
|
167
|
+
const m = runLen(chars, k, "`");
|
|
168
|
+
if (m === n) {
|
|
169
|
+
return k;
|
|
170
|
+
}
|
|
171
|
+
k += m;
|
|
172
|
+
} else {
|
|
173
|
+
k += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function totalDepth(frames: Frame[]): number {
|
|
180
|
+
let depth = frames.length - 1;
|
|
181
|
+
for (const f of frames) {
|
|
182
|
+
depth += f.delims.length;
|
|
183
|
+
}
|
|
184
|
+
return depth;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function delimiter(
|
|
188
|
+
chars: string[],
|
|
189
|
+
i: number,
|
|
190
|
+
n: number,
|
|
191
|
+
kind: DelimKind,
|
|
192
|
+
raw: string,
|
|
193
|
+
frames: Frame[],
|
|
194
|
+
base: number,
|
|
195
|
+
): number {
|
|
196
|
+
const canClose = i > 0 && !isWs(chars[i - 1]!);
|
|
197
|
+
const next = chars[i + n];
|
|
198
|
+
const canOpen = next !== undefined && !isWs(next);
|
|
199
|
+
const deep = base + totalDepth(frames) >= MAX_INLINE_DEPTH;
|
|
200
|
+
const frame = top(frames);
|
|
201
|
+
const innermost = frame.delims[frame.delims.length - 1];
|
|
202
|
+
if (canClose && innermost !== undefined && innermost.kind === kind) {
|
|
203
|
+
frame.delims.pop();
|
|
204
|
+
const inner = normalize(frame.children.splice(innermost.idx));
|
|
205
|
+
frame.children.push(
|
|
206
|
+
kind === "em"
|
|
207
|
+
? { type: "emphasis", children: inner }
|
|
208
|
+
: kind === "strong"
|
|
209
|
+
? { type: "strong", children: inner }
|
|
210
|
+
: { type: "strikethrough", children: inner },
|
|
211
|
+
);
|
|
212
|
+
} else if (canOpen && !deep) {
|
|
213
|
+
frame.delims.push({ kind, idx: frame.children.length, raw });
|
|
214
|
+
} else {
|
|
215
|
+
pushStr(frames, raw);
|
|
216
|
+
}
|
|
217
|
+
return i + n;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Handle a bracket construct starting at `chars[open]` (which is `[`).
|
|
221
|
+
* `embed` means a `!` sits just before it. Returns the new position. */
|
|
222
|
+
function bracket(
|
|
223
|
+
chars: string[],
|
|
224
|
+
open: number,
|
|
225
|
+
embed: boolean,
|
|
226
|
+
frames: Frame[],
|
|
227
|
+
base: number,
|
|
228
|
+
): number {
|
|
229
|
+
const bang = embed ? open - 1 : open;
|
|
230
|
+
const fallback = (): number => {
|
|
231
|
+
pushStr(frames, chars[bang]!);
|
|
232
|
+
return bang + 1;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Find the matching `]` (balanced, escape-aware).
|
|
236
|
+
let depth = 1;
|
|
237
|
+
let k = open + 1;
|
|
238
|
+
while (k < chars.length) {
|
|
239
|
+
const c = chars[k]!;
|
|
240
|
+
if (c === "\\") {
|
|
241
|
+
k += 1;
|
|
242
|
+
} else if (c === "[") {
|
|
243
|
+
depth += 1;
|
|
244
|
+
} else if (c === "]") {
|
|
245
|
+
depth -= 1;
|
|
246
|
+
if (depth === 0) {
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
k += 1;
|
|
251
|
+
}
|
|
252
|
+
if (k >= chars.length) {
|
|
253
|
+
return fallback();
|
|
254
|
+
}
|
|
255
|
+
const interior = chars.slice(open + 1, k).join("");
|
|
256
|
+
|
|
257
|
+
// Link / embed: `](` with a lexable target.
|
|
258
|
+
if (chars[k + 1] === "(") {
|
|
259
|
+
// A link's text is one nesting level (embeds don't recurse: alt is a
|
|
260
|
+
// plain string). Over the shared inline cap, fall back to literal.
|
|
261
|
+
if (!embed && base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
|
|
262
|
+
return fallback();
|
|
263
|
+
}
|
|
264
|
+
const lexed = lexTarget(chars, k + 2);
|
|
265
|
+
if (lexed !== null) {
|
|
266
|
+
const depth = base + totalDepth(frames) + 1;
|
|
267
|
+
top(frames).children.push(
|
|
268
|
+
embed
|
|
269
|
+
? { type: "embed", target: lexed.target, alt: resolveEscapes(interior) }
|
|
270
|
+
: { type: "link", target: lexed.target, children: parseInlinesAt(interior, depth) },
|
|
271
|
+
);
|
|
272
|
+
return lexed.end;
|
|
273
|
+
}
|
|
274
|
+
return fallback();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Span closer: `[/name]` must name the innermost open span.
|
|
278
|
+
if (interior.startsWith("/")) {
|
|
279
|
+
const name = interior.slice(1);
|
|
280
|
+
if (isName(name)) {
|
|
281
|
+
if (embed) {
|
|
282
|
+
pushStr(frames, "!"); // the ! belongs to links/embeds, not spans
|
|
283
|
+
}
|
|
284
|
+
if (frames.length > 1 && top(frames).name === name) {
|
|
285
|
+
const frame = frames.pop()!;
|
|
286
|
+
const children = normalize(revertDelims(frame.children, frame.delims));
|
|
287
|
+
top(frames).children.push({ type: "span", name: frame.name, attrs: frame.attrs, children });
|
|
288
|
+
} else {
|
|
289
|
+
// Well-formed but mismatched or orphan: the characters back.
|
|
290
|
+
pushStr(frames, `[/${name}]`);
|
|
291
|
+
}
|
|
292
|
+
return k + 1;
|
|
293
|
+
}
|
|
294
|
+
return fallback();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Span opener: `[name ...]`, with the BBCode default-parameter idiom
|
|
298
|
+
// (`[color=red]` puts `color=red` in the span's own attrs).
|
|
299
|
+
const opener = parseSpanOpener(interior);
|
|
300
|
+
if (opener !== null) {
|
|
301
|
+
if (embed) {
|
|
302
|
+
pushStr(frames, "!"); // the ! belongs to links/embeds, not spans
|
|
303
|
+
}
|
|
304
|
+
if (base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
|
|
305
|
+
pushStr(frames, chars.slice(open, k + 1).join(""));
|
|
306
|
+
} else {
|
|
307
|
+
frames.push({
|
|
308
|
+
openerRaw: chars.slice(open, k + 1).join(""),
|
|
309
|
+
name: opener.name,
|
|
310
|
+
attrs: opener.attrs,
|
|
311
|
+
children: [],
|
|
312
|
+
delims: [],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return k + 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return fallback();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parseSpanOpener(interior: string): { name: string; attrs: Attrs } | null {
|
|
322
|
+
const nlen = nameLen(interior);
|
|
323
|
+
if (nlen === 0) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const name = interior.slice(0, nlen);
|
|
327
|
+
const pairs = new Map<string, string>();
|
|
328
|
+
let rest = interior.slice(nlen);
|
|
329
|
+
if (rest.startsWith("=")) {
|
|
330
|
+
const v = parseValue(rest.slice(1));
|
|
331
|
+
if (!v.ok) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (!(v.rest === "" || v.rest.startsWith(" ") || v.rest.startsWith("\t"))) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
pairs.set(name, v.value);
|
|
338
|
+
rest = v.rest;
|
|
339
|
+
} else if (!(rest === "" || rest.startsWith(" ") || rest.startsWith("\t"))) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const parsed = parseAttrs(rest);
|
|
343
|
+
if (!parsed.ok) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
for (const [key, value] of Object.entries(parsed.attrs)) {
|
|
347
|
+
if (!pairs.has(key)) {
|
|
348
|
+
pairs.set(key, value);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { name, attrs: Object.fromEntries(pairs) };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Lex a link/embed target from `chars[from]`: no whitespace, balanced
|
|
355
|
+
* parens, an unbalanced `)` ends it. Returns the position after `)`. */
|
|
356
|
+
function lexTarget(chars: string[], from: number): { target: string; end: number } | null {
|
|
357
|
+
let depth = 0;
|
|
358
|
+
let k = from;
|
|
359
|
+
while (k < chars.length) {
|
|
360
|
+
const c = chars[k]!;
|
|
361
|
+
if (c === ")" && depth === 0) {
|
|
362
|
+
const target = chars.slice(from, k).join("");
|
|
363
|
+
if (utf8Len(target) > MAX_TARGET_BYTES) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
return { target, end: k + 1 };
|
|
367
|
+
}
|
|
368
|
+
if (c === ")") {
|
|
369
|
+
depth -= 1;
|
|
370
|
+
} else if (c === "(") {
|
|
371
|
+
depth += 1;
|
|
372
|
+
} else if (isWs(c)) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
k += 1;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resolveEscapes(s: string): string {
|
|
381
|
+
let out = "";
|
|
382
|
+
const chars = Array.from(s);
|
|
383
|
+
let i = 0;
|
|
384
|
+
while (i < chars.length) {
|
|
385
|
+
const c = chars[i]!;
|
|
386
|
+
if (c === "\\") {
|
|
387
|
+
const n = chars[i + 1];
|
|
388
|
+
if (n !== undefined && ASCII_PUNCT.has(n)) {
|
|
389
|
+
out += n;
|
|
390
|
+
i += 2;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
out += "\\";
|
|
394
|
+
if (n !== undefined) {
|
|
395
|
+
out += n;
|
|
396
|
+
i += 2;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
i += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
out += c;
|
|
403
|
+
i += 1;
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** An unclosed span reverts: its opener text, then its children, flattened
|
|
409
|
+
* into the parent (its own unmatched delimiters reverting first). */
|
|
410
|
+
function revertFrame(frame: Frame): Node[] {
|
|
411
|
+
const out: Node[] = [{ type: "text", value: frame.openerRaw }];
|
|
412
|
+
out.push(...revertDelims(frame.children, frame.delims));
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function revertDelims(children: Node[], delims: Delim[]): Node[] {
|
|
417
|
+
for (let d = delims.length - 1; d >= 0; d -= 1) {
|
|
418
|
+
children.splice(delims[d]!.idx, 0, { type: "text", value: delims[d]!.raw });
|
|
419
|
+
}
|
|
420
|
+
return children;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Canonical text: adjacent literals merge, empty text nodes vanish. */
|
|
424
|
+
function normalize(children: Node[]): Node[] {
|
|
425
|
+
const out: Node[] = [];
|
|
426
|
+
for (const node of children) {
|
|
427
|
+
if (node.type === "text") {
|
|
428
|
+
if (node.value === "") {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const last = out[out.length - 1];
|
|
432
|
+
if (last !== undefined && last.type === "text") {
|
|
433
|
+
last.value += node.value;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
out.push(node);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|