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