@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/dist/blocks.js ADDED
@@ -0,0 +1,329 @@
1
+ // Line-oriented block parser: a faithful port of rust/parser/src/blocks.rs.
2
+ // Where this file and that file disagree, one of them has a conformance bug.
3
+ import { isName, nameLen, parseAttrs, utf8Len } from "./attrs.js";
4
+ import { parseInlines } from "./inlines.js";
5
+ export const MAX_LIST_DEPTH = 16;
6
+ export const MAX_QUOTE_DEPTH = 16;
7
+ export const MAX_DIRECTIVE_DEPTH = 8;
8
+ export const MAX_TARGET_BYTES = 4096;
9
+ function peek(cur) {
10
+ return cur.lines[cur.pos];
11
+ }
12
+ export function parseBlocks(body) {
13
+ const lines = body.split("\n");
14
+ // A final newline terminates the last line rather than creating an empty
15
+ // one (otherwise every trailing newline leaks a blank into fences).
16
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
17
+ lines.pop();
18
+ }
19
+ const cur = { lines, pos: 0 };
20
+ return parseContainer(cur, { dirDepth: 0, listDepth: 0, quoteDepth: 0 }, null);
21
+ }
22
+ /**
23
+ * Parse blocks until the container ends. `openDir` is the name of the
24
+ * directive whose body this is (null at document level and inside
25
+ * blockquotes / list items, which are fresh containers).
26
+ */
27
+ function parseContainer(cur, ctx, openDir) {
28
+ const out = [];
29
+ for (;;) {
30
+ const line = peek(cur);
31
+ if (line === undefined) {
32
+ break;
33
+ }
34
+ if (isBlank(line)) {
35
+ cur.pos += 1;
36
+ continue;
37
+ }
38
+ // Directive open / close / error lines.
39
+ if (line.startsWith(":::")) {
40
+ cur.pos += 1;
41
+ const dir = classifyDirective(line);
42
+ if (dir.kind === "close") {
43
+ if (openDir !== null && dir.name === null) {
44
+ return out;
45
+ }
46
+ if (openDir !== null && dir.name === openDir) {
47
+ return out;
48
+ }
49
+ out.push(invalid(openDir !== null ? "mismatched_close" : "stray_close"));
50
+ }
51
+ else if (dir.kind === "open") {
52
+ if (ctx.dirDepth >= MAX_DIRECTIVE_DEPTH) {
53
+ out.push(invalid("depth_exceeded"));
54
+ continue;
55
+ }
56
+ const attrs = parseAttrs(dir.attrsSrc);
57
+ if (!attrs.ok) {
58
+ out.push(invalid(attrs.reason));
59
+ }
60
+ else {
61
+ const children = dir.leaf
62
+ ? []
63
+ : parseContainer(cur, { ...ctx, dirDepth: ctx.dirDepth + 1 }, dir.name);
64
+ out.push({ type: "directive", name: dir.name, attrs: attrs.attrs, children });
65
+ }
66
+ }
67
+ else {
68
+ out.push(invalid(dir.reason));
69
+ }
70
+ continue;
71
+ }
72
+ // Comment block: consecutive `%%` lines, raw content.
73
+ if (line.startsWith("%%")) {
74
+ const texts = [];
75
+ for (let l = peek(cur); l !== undefined && l.startsWith("%%"); l = peek(cur)) {
76
+ const rest = l.slice(2);
77
+ texts.push(rest.startsWith(" ") ? rest.slice(1) : rest);
78
+ cur.pos += 1;
79
+ }
80
+ out.push({ type: "comment", text: texts.join("\n") });
81
+ continue;
82
+ }
83
+ const heading = headingLine(line);
84
+ if (heading !== null) {
85
+ cur.pos += 1;
86
+ out.push({
87
+ type: "heading",
88
+ level: heading.level,
89
+ children: parseInlines(trimSpaceTab(heading.content)),
90
+ });
91
+ continue;
92
+ }
93
+ const fence = fenceOpen(line);
94
+ if (fence !== null) {
95
+ cur.pos += 1;
96
+ const content = [];
97
+ for (let l = peek(cur); l !== undefined; l = peek(cur)) {
98
+ cur.pos += 1;
99
+ if (fenceClose(l, fence.len)) {
100
+ break;
101
+ }
102
+ content.push(l);
103
+ }
104
+ const text = content.join("\n");
105
+ out.push(fence.info === null
106
+ ? { type: "code_block", text }
107
+ : { type: "code_block", info: fence.info, text });
108
+ continue;
109
+ }
110
+ if (line.startsWith(">") && ctx.quoteDepth < MAX_QUOTE_DEPTH) {
111
+ const innerLines = [];
112
+ for (let l = peek(cur); l !== undefined && l.startsWith(">"); l = peek(cur)) {
113
+ const rest = l.slice(1);
114
+ innerLines.push(rest.startsWith(" ") ? rest.slice(1) : rest);
115
+ cur.pos += 1;
116
+ }
117
+ const sub = { lines: innerLines, pos: 0 };
118
+ out.push({
119
+ type: "blockquote",
120
+ children: parseContainer(sub, { ...ctx, quoteDepth: ctx.quoteDepth + 1 }, null),
121
+ });
122
+ continue;
123
+ }
124
+ if (line.replace(/[ \t]+$/, "") === "---") {
125
+ cur.pos += 1;
126
+ out.push({ type: "thematic_break" });
127
+ continue;
128
+ }
129
+ if (ctx.listDepth < MAX_LIST_DEPTH) {
130
+ const m = marker(line);
131
+ if (m !== null) {
132
+ out.push(parseList(cur, ctx, m));
133
+ continue;
134
+ }
135
+ }
136
+ // Paragraph: plain lines until blank / container end / block start.
137
+ const plines = [line];
138
+ cur.pos += 1;
139
+ for (let l = peek(cur); l !== undefined; l = peek(cur)) {
140
+ if (isBlank(l) || isBlockStart(l, ctx)) {
141
+ break;
142
+ }
143
+ plines.push(l);
144
+ cur.pos += 1;
145
+ }
146
+ if (plines.length === 1) {
147
+ const target = turbolink(plines[0]);
148
+ if (target !== null) {
149
+ out.push({ type: "turbolink", target });
150
+ continue;
151
+ }
152
+ }
153
+ out.push({ type: "paragraph", children: parseInlines(plines.join("\n")) });
154
+ }
155
+ return out;
156
+ }
157
+ function invalid(reason) {
158
+ return { type: "invalid_directive", reason, children: [] };
159
+ }
160
+ function isBlank(line) {
161
+ return /^[ \t]*$/.test(line);
162
+ }
163
+ function indentOf(line) {
164
+ let i = 0;
165
+ while (i < line.length && line[i] === " ") {
166
+ i += 1;
167
+ }
168
+ return i;
169
+ }
170
+ function trimSpaceTab(s) {
171
+ return s.replace(/^[ \t]+|[ \t]+$/g, "");
172
+ }
173
+ /** Would this line start a non-paragraph block in this context? (Used both
174
+ * by the dispatcher and to end paragraphs: any block construct interrupts.) */
175
+ function isBlockStart(line, ctx) {
176
+ return (line.startsWith(":::") ||
177
+ line.startsWith("%%") ||
178
+ headingLine(line) !== null ||
179
+ fenceOpen(line) !== null ||
180
+ (line.startsWith(">") && ctx.quoteDepth < MAX_QUOTE_DEPTH) ||
181
+ line.replace(/[ \t]+$/, "") === "---" ||
182
+ (ctx.listDepth < MAX_LIST_DEPTH && marker(line) !== null));
183
+ }
184
+ function headingLine(line) {
185
+ let n = 0;
186
+ while (n < line.length && line[n] === "#") {
187
+ n += 1;
188
+ }
189
+ if (n >= 1 && n <= 8 && line[n] === " ") {
190
+ return { level: n, content: line.slice(n + 1) };
191
+ }
192
+ return null;
193
+ }
194
+ function fenceOpen(line) {
195
+ let n = 0;
196
+ while (n < line.length && line[n] === "`") {
197
+ n += 1;
198
+ }
199
+ if (n < 3) {
200
+ return null;
201
+ }
202
+ const info = trimSpaceTab(line.slice(n));
203
+ return { len: n, info: info === "" ? null : info };
204
+ }
205
+ function fenceClose(line, openLen) {
206
+ const t = line.replace(/[ \t]+$/, "");
207
+ return t.length >= openLen && /^`+$/.test(t);
208
+ }
209
+ function marker(line) {
210
+ const indent = indentOf(line);
211
+ const rest = line.slice(indent);
212
+ if (rest.length >= 2 && "-*+".includes(rest[0]) && rest[1] === " ") {
213
+ return { indent, ordered: false, contentIdx: indent + 2 };
214
+ }
215
+ const m = /^([0-9]+)\. /.exec(rest);
216
+ if (m !== null) {
217
+ return { indent, ordered: true, contentIdx: indent + m[1].length + 2 };
218
+ }
219
+ return null;
220
+ }
221
+ /** One list of one kind. A same-column marker of the other kind ends this
222
+ * list (the dispatcher immediately starts the next one). */
223
+ function parseList(cur, ctx, first) {
224
+ const ordered = first.ordered;
225
+ const items = [];
226
+ let buf = [peek(cur).slice(first.contentIdx)];
227
+ cur.pos += 1;
228
+ for (;;) {
229
+ const line = peek(cur);
230
+ if (line === undefined) {
231
+ break;
232
+ }
233
+ if (isBlank(line)) {
234
+ // The list continues past blanks only into indented content or a
235
+ // same-kind column-0/1 marker; anything else ends it here.
236
+ let j = cur.pos + 1;
237
+ while (j < cur.lines.length && isBlank(cur.lines[j])) {
238
+ j += 1;
239
+ }
240
+ const next = cur.lines[j];
241
+ let continues = false;
242
+ if (next !== undefined) {
243
+ if (indentOf(next) >= 2) {
244
+ continues = true;
245
+ }
246
+ else {
247
+ const m = marker(next);
248
+ continues = m !== null && m.ordered === ordered && m.indent < 2;
249
+ }
250
+ }
251
+ if (!continues) {
252
+ break;
253
+ }
254
+ buf.push("");
255
+ cur.pos += 1;
256
+ continue;
257
+ }
258
+ if (indentOf(line) >= 2) {
259
+ // Content (or a deeper marker) inside the current item; strip the
260
+ // content column. Off-grid extra spaces ride along (floor rule).
261
+ buf.push(line.slice(2));
262
+ cur.pos += 1;
263
+ continue;
264
+ }
265
+ // Column 0 or 1 (floors to 0).
266
+ const m = marker(line);
267
+ if (m !== null && m.ordered === ordered) {
268
+ items.push(finishItem(buf, ctx));
269
+ buf = [line.slice(m.contentIdx)];
270
+ cur.pos += 1;
271
+ continue;
272
+ }
273
+ break; // column-0 block, or a kind switch
274
+ }
275
+ items.push(finishItem(buf, ctx));
276
+ return { type: "list", ordered, children: items };
277
+ }
278
+ function finishItem(buf, ctx) {
279
+ const sub = { lines: buf, pos: 0 };
280
+ return {
281
+ type: "list_item",
282
+ children: parseContainer(sub, { ...ctx, listDepth: ctx.listDepth + 1 }, null),
283
+ };
284
+ }
285
+ function classifyDirective(line) {
286
+ const rest = line.slice(3).replace(/[ \t]+$/, "");
287
+ if (rest === "") {
288
+ return { kind: "close", name: null };
289
+ }
290
+ if (rest.startsWith(" ") || rest.startsWith("\t")) {
291
+ // `::: name` - a named close.
292
+ const name = trimSpaceTab(rest);
293
+ return isName(name)
294
+ ? { kind: "close", name }
295
+ : { kind: "bad", reason: "bad_name" };
296
+ }
297
+ // Open line. The leaf closer token is stripped before attribute parsing.
298
+ const leaf = rest.endsWith(":::");
299
+ const body = leaf ? rest.slice(0, -3) : rest;
300
+ const nlen = nameLen(body);
301
+ if (nlen === 0) {
302
+ return { kind: "bad", reason: "bad_name" };
303
+ }
304
+ const after = body.slice(nlen);
305
+ if (!(after === "" || after.startsWith(" ") || after.startsWith("\t"))) {
306
+ return { kind: "bad", reason: "bad_name" };
307
+ }
308
+ return { kind: "open", name: body.slice(0, nlen), attrsSrc: after, leaf };
309
+ }
310
+ /**
311
+ * A paragraph that is exactly one authority-form absolute URI is a
312
+ * turbolink. Every bare word is a valid relative URI reference, so the
313
+ * sugar demands `scheme://`; everything else uses `:::turbolink`.
314
+ */
315
+ function turbolink(line) {
316
+ const t = trimSpaceTab(line);
317
+ if (t === "" || utf8Len(t) > MAX_TARGET_BYTES) {
318
+ return null;
319
+ }
320
+ if (t.includes(" ") || t.includes("\t")) {
321
+ return null;
322
+ }
323
+ const m = /^[A-Za-z][A-Za-z0-9+.-]*/.exec(t);
324
+ if (m === null) {
325
+ return null;
326
+ }
327
+ const after = t.slice(m[0].length);
328
+ return after.startsWith("://") && after.length > 3 ? t : null;
329
+ }
@@ -0,0 +1,7 @@
1
+ import type { Node } from "./ast.ts";
2
+ export type { Attrs, Node, Reason } from "./ast.ts";
3
+ export declare class UnsupportedVersionError extends Error {
4
+ readonly version: number;
5
+ constructor(version: number);
6
+ }
7
+ export declare function parse(input: string): Node;
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ // Reference parser for the Marquee markup language (TypeScript).
2
+ //
3
+ // Parse is total: any byte sequence yields a document. The one refusal is an
4
+ // unknown dialect version (SPEC.md, "Conformance"), surfaced as a thrown
5
+ // error rather than a guessed parse.
6
+ import { parseBlocks } from "./blocks.js";
7
+ export class UnsupportedVersionError extends Error {
8
+ version;
9
+ constructor(version) {
10
+ super(`unsupported marquee version ${version}`);
11
+ this.name = "UnsupportedVersionError";
12
+ this.version = version;
13
+ }
14
+ }
15
+ const U64_MAX = 2n ** 64n - 1n;
16
+ export function parse(input) {
17
+ // Front-door normalization: \r\n and \r become \n before anything else.
18
+ const normalized = input.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
19
+ const [version, body] = stripVersion(normalized);
20
+ if (version !== 0) {
21
+ throw new UnsupportedVersionError(version);
22
+ }
23
+ return { type: "document", version, children: parseBlocks(body) };
24
+ }
25
+ /** The in-band version declaration: line 1, exactly `#!marquee <integer>`.
26
+ * The default is version 0, forever. */
27
+ function stripVersion(s) {
28
+ const nl = s.indexOf("\n");
29
+ const first = nl === -1 ? s : s.slice(0, nl);
30
+ const m = /^#!marquee ([0-9]+)$/.exec(first);
31
+ // Digits past u64 are not a version (mirrors the Rust parse failing):
32
+ // the line is prose.
33
+ if (m !== null && BigInt(m[1]) <= U64_MAX) {
34
+ const body = s.slice(first.length);
35
+ return [Number(m[1]), body.startsWith("\n") ? body.slice(1) : body];
36
+ }
37
+ return [0, s];
38
+ }
@@ -0,0 +1,4 @@
1
+ import type { Node } from "./ast.ts";
2
+ export declare const MAX_INLINE_DEPTH = 16;
3
+ export declare const MAX_EMOJI_SLUG_BYTES = 64;
4
+ export declare function parseInlines(text: string): Node[];