@cube-drone/marquee-parser 0.3.0 → 0.5.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/README.md CHANGED
@@ -15,6 +15,27 @@ const doc = parse("# hello *world*\n");
15
15
  version (`UnsupportedVersionError`). Malformed constructs become `invalid_directive` nodes
16
16
  with spec'd reasons; confusing inline input degrades to literal text. Nothing is ever eaten.
17
17
 
18
+ **For editor tooling** there's `parseWithPositions(source)`, returning
19
+ `{ doc, spans, source }`: the same AST byte-for-byte (positions live in a `WeakMap`
20
+ side-table, never on the nodes, so serialization and the wire contract are untouched), with
21
+ each node mapped to its `[start, end)` extent in **UTF-16 code units** over the *normalized*
22
+ source (`\r\n` → `\n`; the returned `source` is the string offsets refer to — hand it, not
23
+ your raw input, to anything consuming the spans). A span covers its construct
24
+ opener-through-closer, markers included; the interiors of canonicalized text (merged
25
+ literals, resolved escapes) are covered but not subdivided. Built for CodeMirror
26
+ decorations; deliberately outside the cross-implementation conformance corpus (see SPEC.md,
27
+ "Source positions").
28
+
29
+ Three sharp edges, all consequences of "a span is a *source* extent":
30
+
31
+ - `slice(span) !== node.value` wherever the source is denser than the text — `a \*b\* c`
32
+ slices nine characters for a seven-character text node.
33
+ - A construct that crosses a line inside a container **contains that container's prefix**:
34
+ in `> *a\n> b*`, the emphasis span slices `*a\n> b*`, `> ` and all. Range *marks* are fine;
35
+ a *replace* decoration over such a range would swallow the quote marker, so split
36
+ multi-line ranges per line before replacing.
37
+ - Blank lines between blocks belong to no node: coverage has gaps, by design.
38
+
18
39
  You probably want [`@cube-drone/marquee-markup`](https://www.npmjs.com/package/@cube-drone/marquee-markup)
19
40
  (batteries included: parse + render + CLI) unless you're building a renderer or tool of your
20
41
  own — in which case the AST contract is specified in
package/dist/blocks.d.ts CHANGED
@@ -3,4 +3,13 @@ export declare const MAX_LIST_DEPTH = 16;
3
3
  export declare const MAX_QUOTE_DEPTH = 16;
4
4
  export declare const MAX_DIRECTIVE_DEPTH = 8;
5
5
  export declare const MAX_TARGET_BYTES = 4096;
6
- export declare function parseBlocks(body: string): Node[];
6
+ /** A node's source extent: [start, end) in UTF-16 code units over the
7
+ * front-door-normalized source (shebang line included in the count). */
8
+ export interface Span {
9
+ start: number;
10
+ end: number;
11
+ }
12
+ export interface PosTracker {
13
+ spans: WeakMap<Node, Span>;
14
+ }
15
+ export declare function parseBlocks(body: string, T?: PosTracker | null, baseOffset?: number): Node[];
package/dist/blocks.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // Line-oriented block parser: a faithful port of rust/parser/src/blocks.rs.
2
2
  // Where this file and that file disagree, one of them has a conformance bug.
3
+ //
4
+ // Positions (SPEC.md, "Source positions"): when a PosTracker rides along,
5
+ // every node gets a [start, end) span - UTF-16 offsets into the normalized
6
+ // source - recorded in the side-table. The AST itself never changes; the
7
+ // default path pays nothing.
3
8
  import { isName, nameLen, parseAttrs, utf8Len } from "./attrs.js";
4
9
  import { parseInlines } from "./inlines.js";
5
10
  export const MAX_LIST_DEPTH = 16;
@@ -9,22 +14,68 @@ export const MAX_TARGET_BYTES = 4096;
9
14
  function peek(cur) {
10
15
  return cur.lines[cur.pos];
11
16
  }
12
- export function parseBlocks(body) {
17
+ export function parseBlocks(body, T = null, baseOffset = 0) {
13
18
  const lines = body.split("\n");
19
+ let starts = null;
20
+ if (T !== null) {
21
+ starts = [];
22
+ let off = baseOffset;
23
+ for (const line of lines) {
24
+ starts.push(off);
25
+ off += line.length + 1;
26
+ }
27
+ }
14
28
  // A final newline terminates the last line rather than creating an empty
15
29
  // one (otherwise every trailing newline leaks a blank into fences).
16
30
  if (lines.length > 0 && lines[lines.length - 1] === "") {
17
31
  lines.pop();
32
+ starts?.pop();
18
33
  }
19
- const cur = { lines, pos: 0 };
20
- return parseContainer(cur, { dirDepth: 0, listDepth: 0, quoteDepth: 0 }, null);
34
+ const cur = { lines, starts, pos: 0 };
35
+ return parseContainer(cur, { dirDepth: 0, listDepth: 0, quoteDepth: 0 }, null, T);
36
+ }
37
+ function lineStartOf(cur, i) {
38
+ return cur.starts[i];
39
+ }
40
+ function lineEndOf(cur, i) {
41
+ return cur.starts[i] + cur.lines[i].length;
42
+ }
43
+ function mark(T, cur, node, startLine, endLine) {
44
+ if (T !== null && cur.starts !== null) {
45
+ T.spans.set(node, { start: lineStartOf(cur, startLine), end: lineEndOf(cur, endLine) });
46
+ }
47
+ return node;
48
+ }
49
+ /** Build the logical inline text (segments joined by real source newlines)
50
+ * plus the code-point -> source-offset map the inline parser records from. */
51
+ function inlineCtx(T, segs) {
52
+ const text = segs.map((s) => s.text).join("\n");
53
+ if (T === null) {
54
+ return { text, P: null };
55
+ }
56
+ const map = [];
57
+ let end = 0;
58
+ for (let s = 0; s < segs.length; s += 1) {
59
+ if (s > 0) {
60
+ map.push(end); // the joining "\n" is the previous line's real newline
61
+ }
62
+ const seg = segs[s];
63
+ let off = seg.start;
64
+ for (const ch of seg.text) {
65
+ map.push(off);
66
+ off += ch.length;
67
+ }
68
+ end = off;
69
+ }
70
+ map.push(end);
71
+ return { text, P: { map, spans: T.spans } };
21
72
  }
22
73
  /**
23
74
  * Parse blocks until the container ends. `openDir` is the name of the
24
75
  * directive whose body this is (null at document level and inside
25
76
  * blockquotes / list items, which are fresh containers).
26
77
  */
27
- function parseContainer(cur, ctx, openDir) {
78
+ function parseContainer(cur, ctx, openDir, T) {
28
79
  const out = [];
29
80
  for (;;) {
30
81
  const line = peek(cur);
@@ -37,6 +88,7 @@ function parseContainer(cur, ctx, openDir) {
37
88
  }
38
89
  // Directive open / close / error lines.
39
90
  if (line.startsWith(":::")) {
91
+ const li = cur.pos;
40
92
  cur.pos += 1;
41
93
  const dir = classifyDirective(line);
42
94
  if (dir.kind === "close") {
@@ -46,52 +98,66 @@ function parseContainer(cur, ctx, openDir) {
46
98
  if (openDir !== null && dir.name === openDir) {
47
99
  return out;
48
100
  }
49
- out.push(invalid(openDir !== null ? "mismatched_close" : "stray_close"));
101
+ out.push(mark(T, cur, invalid(openDir !== null ? "mismatched_close" : "stray_close"), li, li));
50
102
  }
51
103
  else if (dir.kind === "open") {
52
104
  if (ctx.dirDepth >= MAX_DIRECTIVE_DEPTH) {
53
- out.push(invalid("depth_exceeded"));
105
+ out.push(mark(T, cur, invalid("depth_exceeded"), li, li));
54
106
  continue;
55
107
  }
56
108
  const attrs = parseAttrs(dir.attrsSrc);
57
109
  if (!attrs.ok) {
58
- out.push(invalid(attrs.reason));
110
+ out.push(mark(T, cur, invalid(attrs.reason), li, li));
59
111
  }
60
112
  else {
61
113
  const children = dir.leaf
62
114
  ? []
63
- : parseContainer(cur, { ...ctx, dirDepth: ctx.dirDepth + 1 }, dir.name);
64
- out.push({ type: "directive", name: dir.name, attrs: attrs.attrs, children });
115
+ : parseContainer(cur, { ...ctx, dirDepth: ctx.dirDepth + 1 }, dir.name, T);
116
+ // A container's close line (or EOF auto-close) has been consumed
117
+ // by the child parse: the span runs to the last consumed line.
118
+ const endLi = dir.leaf ? li : Math.max(li, cur.pos - 1);
119
+ out.push(mark(T, cur, { type: "directive", name: dir.name, attrs: attrs.attrs, children }, li, endLi));
65
120
  }
66
121
  }
67
122
  else {
68
- out.push(invalid(dir.reason));
123
+ out.push(mark(T, cur, invalid(dir.reason), li, li));
69
124
  }
70
125
  continue;
71
126
  }
72
127
  // Comment block: consecutive `%%` lines, raw content.
73
128
  if (line.startsWith("%%")) {
129
+ const li = cur.pos;
74
130
  const texts = [];
75
131
  for (let l = peek(cur); l !== undefined && l.startsWith("%%"); l = peek(cur)) {
76
132
  const rest = l.slice(2);
77
133
  texts.push(rest.startsWith(" ") ? rest.slice(1) : rest);
78
134
  cur.pos += 1;
79
135
  }
80
- out.push({ type: "comment", text: texts.join("\n") });
136
+ out.push(mark(T, cur, { type: "comment", text: texts.join("\n") }, li, cur.pos - 1));
81
137
  continue;
82
138
  }
83
139
  const heading = headingLine(line);
84
140
  if (heading !== null) {
141
+ const li = cur.pos;
85
142
  cur.pos += 1;
86
- out.push({
87
- type: "heading",
88
- level: heading.level,
89
- children: parseInlines(trimSpaceTab(heading.content)),
90
- });
143
+ const content = trimSpaceTab(heading.content);
144
+ let P = null;
145
+ let text = content;
146
+ if (T !== null && cur.starts !== null) {
147
+ // Content offset: past the #s and their space, plus leading trim
148
+ // (all ASCII, so char counts are UTF-16 counts).
149
+ const lead = heading.content.length - heading.content.replace(/^[ \t]+/, "").length;
150
+ const start = lineStartOf(cur, li) + heading.level + 1 + lead;
151
+ const built = inlineCtx(T, [{ text: content, start }]);
152
+ text = built.text;
153
+ P = built.P;
154
+ }
155
+ out.push(mark(T, cur, { type: "heading", level: heading.level, children: parseInlines(text, P) }, li, li));
91
156
  continue;
92
157
  }
93
158
  const fence = fenceOpen(line);
94
159
  if (fence !== null) {
160
+ const li = cur.pos;
95
161
  cur.pos += 1;
96
162
  const content = [];
97
163
  for (let l = peek(cur); l !== undefined; l = peek(cur)) {
@@ -102,38 +168,44 @@ function parseContainer(cur, ctx, openDir) {
102
168
  content.push(l);
103
169
  }
104
170
  const text = content.join("\n");
105
- out.push(fence.info === null
171
+ out.push(mark(T, cur, fence.info === null
106
172
  ? { type: "code_block", text }
107
- : { type: "code_block", info: fence.info, text });
173
+ : { type: "code_block", info: fence.info, text }, li, cur.pos - 1));
108
174
  continue;
109
175
  }
110
176
  if (line.startsWith(">") && ctx.quoteDepth < MAX_QUOTE_DEPTH) {
177
+ const li = cur.pos;
111
178
  const innerLines = [];
179
+ const innerStarts = T !== null && cur.starts !== null ? [] : null;
112
180
  for (let l = peek(cur); l !== undefined && l.startsWith(">"); l = peek(cur)) {
113
181
  const rest = l.slice(1);
114
- innerLines.push(rest.startsWith(" ") ? rest.slice(1) : rest);
182
+ const stripped = rest.startsWith(" ") ? 2 : 1;
183
+ innerLines.push(l.slice(stripped));
184
+ innerStarts?.push(lineStartOf(cur, cur.pos) + stripped);
115
185
  cur.pos += 1;
116
186
  }
117
- const sub = { lines: innerLines, pos: 0 };
118
- out.push({
187
+ const sub = { lines: innerLines, starts: innerStarts, pos: 0 };
188
+ out.push(mark(T, cur, {
119
189
  type: "blockquote",
120
- children: parseContainer(sub, { ...ctx, quoteDepth: ctx.quoteDepth + 1 }, null),
121
- });
190
+ children: parseContainer(sub, { ...ctx, quoteDepth: ctx.quoteDepth + 1 }, null, T),
191
+ }, li, cur.pos - 1));
122
192
  continue;
123
193
  }
124
194
  if (line.replace(/[ \t]+$/, "") === "---") {
195
+ const li = cur.pos;
125
196
  cur.pos += 1;
126
- out.push({ type: "thematic_break" });
197
+ out.push(mark(T, cur, { type: "thematic_break" }, li, li));
127
198
  continue;
128
199
  }
129
200
  if (ctx.listDepth < MAX_LIST_DEPTH) {
130
201
  const m = marker(line);
131
202
  if (m !== null) {
132
- out.push(parseList(cur, ctx, m));
203
+ out.push(parseList(cur, ctx, m, T));
133
204
  continue;
134
205
  }
135
206
  }
136
207
  // Paragraph: plain lines until blank / container end / block start.
208
+ const li = cur.pos;
137
209
  const plines = [line];
138
210
  cur.pos += 1;
139
211
  for (let l = peek(cur); l !== undefined; l = peek(cur)) {
@@ -146,11 +218,19 @@ function parseContainer(cur, ctx, openDir) {
146
218
  if (plines.length === 1) {
147
219
  const target = turbolink(plines[0]);
148
220
  if (target !== null) {
149
- out.push({ type: "turbolink", target });
221
+ out.push(mark(T, cur, { type: "turbolink", target }, li, li));
150
222
  continue;
151
223
  }
152
224
  }
153
- out.push({ type: "paragraph", children: parseInlines(plines.join("\n")) });
225
+ let P = null;
226
+ let text = plines.join("\n");
227
+ if (T !== null && cur.starts !== null) {
228
+ const segs = plines.map((t, idx) => ({ text: t, start: lineStartOf(cur, li + idx) }));
229
+ const built = inlineCtx(T, segs);
230
+ text = built.text;
231
+ P = built.P;
232
+ }
233
+ out.push(mark(T, cur, { type: "paragraph", children: parseInlines(text, P) }, li, cur.pos - 1));
154
234
  }
155
235
  return out;
156
236
  }
@@ -220,11 +300,18 @@ function marker(line) {
220
300
  }
221
301
  /** One list of one kind. A same-column marker of the other kind ends this
222
302
  * list (the dispatcher immediately starts the next one). */
223
- function parseList(cur, ctx, first) {
303
+ function parseList(cur, ctx, first, T) {
304
+ const tracking = T !== null && cur.starts !== null;
224
305
  const ordered = first.ordered;
225
306
  const items = [];
307
+ const listLi = cur.pos;
308
+ let itemStart = tracking ? lineStartOf(cur, cur.pos) + first.indent : 0;
226
309
  let buf = [peek(cur).slice(first.contentIdx)];
310
+ let bufStarts = tracking ? [lineStartOf(cur, cur.pos) + first.contentIdx] : null;
227
311
  cur.pos += 1;
312
+ const finish = () => {
313
+ items.push(finishItem(buf, bufStarts, ctx, T, itemStart));
314
+ };
228
315
  for (;;) {
229
316
  const line = peek(cur);
230
317
  if (line === undefined) {
@@ -252,6 +339,7 @@ function parseList(cur, ctx, first) {
252
339
  break;
253
340
  }
254
341
  buf.push("");
342
+ bufStarts?.push(tracking ? lineStartOf(cur, cur.pos) : 0);
255
343
  cur.pos += 1;
256
344
  continue;
257
345
  }
@@ -259,28 +347,46 @@ function parseList(cur, ctx, first) {
259
347
  // Content (or a deeper marker) inside the current item; strip the
260
348
  // content column. Off-grid extra spaces ride along (floor rule).
261
349
  buf.push(line.slice(2));
350
+ bufStarts?.push(tracking ? lineStartOf(cur, cur.pos) + 2 : 0);
262
351
  cur.pos += 1;
263
352
  continue;
264
353
  }
265
354
  // Column 0 or 1 (floors to 0).
266
355
  const m = marker(line);
267
356
  if (m !== null && m.ordered === ordered) {
268
- items.push(finishItem(buf, ctx));
357
+ finish();
358
+ itemStart = tracking ? lineStartOf(cur, cur.pos) + m.indent : 0;
269
359
  buf = [line.slice(m.contentIdx)];
360
+ bufStarts = tracking ? [lineStartOf(cur, cur.pos) + m.contentIdx] : null;
270
361
  cur.pos += 1;
271
362
  continue;
272
363
  }
273
364
  break; // column-0 block, or a kind switch
274
365
  }
275
- items.push(finishItem(buf, ctx));
276
- return { type: "list", ordered, children: items };
366
+ finish();
367
+ const list = { type: "list", ordered, children: items };
368
+ if (tracking) {
369
+ T.spans.set(list, {
370
+ start: lineStartOf(cur, listLi) + first.indent,
371
+ end: lineEndOf(cur, cur.pos - 1),
372
+ });
373
+ }
374
+ return list;
277
375
  }
278
- function finishItem(buf, ctx) {
279
- const sub = { lines: buf, pos: 0 };
280
- return {
376
+ function finishItem(buf, bufStarts, ctx, T, itemStart) {
377
+ const sub = { lines: buf, starts: bufStarts, pos: 0 };
378
+ const item = {
281
379
  type: "list_item",
282
- children: parseContainer(sub, { ...ctx, listDepth: ctx.listDepth + 1 }, null),
380
+ children: parseContainer(sub, { ...ctx, listDepth: ctx.listDepth + 1 }, null, T),
283
381
  };
382
+ if (T !== null && bufStarts !== null) {
383
+ let end = itemStart;
384
+ for (let i = 0; i < buf.length; i += 1) {
385
+ end = Math.max(end, bufStarts[i] + buf[i].length);
386
+ }
387
+ T.spans.set(item, { start: itemStart, end });
388
+ }
389
+ return item;
284
390
  }
285
391
  function classifyDirective(line) {
286
392
  const rest = line.slice(3).replace(/[ \t]+$/, "");
package/dist/index.d.ts CHANGED
@@ -1,7 +1,27 @@
1
+ import { type Span } from "./blocks.ts";
1
2
  import type { Node } from "./ast.ts";
2
3
  export type { Attrs, Node, Reason } from "./ast.ts";
4
+ export type { Span } from "./blocks.ts";
3
5
  export declare class UnsupportedVersionError extends Error {
4
6
  readonly version: number;
5
7
  constructor(version: number);
6
8
  }
7
9
  export declare function parse(input: string): Node;
10
+ /** parse() plus source positions - the editor-tooling extra, deliberately
11
+ * OUTSIDE the AST wire contract (SPEC.md, "Source positions"):
12
+ *
13
+ * - `doc` is byte-identical to what parse() returns: nodes carry no
14
+ * position fields, so serialization, vectors, and goldens are untouched.
15
+ * - `spans` maps each node object to its [start, end) source extent, in
16
+ * UTF-16 code units (what JavaScript strings and CodeMirror speak) over
17
+ * `source` - the front-door-NORMALIZED input (\r\n -> \n; the shebang
18
+ * line, when present, is included in the offset space).
19
+ * - Node-level extents are the guarantee (a span covers its opener through
20
+ * its closer); the interiors of canonicalized text (merged literals,
21
+ * resolved escapes) are covered but not subdivided.
22
+ */
23
+ export declare function parseWithPositions(input: string): {
24
+ doc: Node;
25
+ spans: WeakMap<Node, Span>;
26
+ source: string;
27
+ };
package/dist/index.js CHANGED
@@ -22,6 +22,34 @@ export function parse(input) {
22
22
  }
23
23
  return { type: "document", version, children: parseBlocks(body) };
24
24
  }
25
+ /** parse() plus source positions - the editor-tooling extra, deliberately
26
+ * OUTSIDE the AST wire contract (SPEC.md, "Source positions"):
27
+ *
28
+ * - `doc` is byte-identical to what parse() returns: nodes carry no
29
+ * position fields, so serialization, vectors, and goldens are untouched.
30
+ * - `spans` maps each node object to its [start, end) source extent, in
31
+ * UTF-16 code units (what JavaScript strings and CodeMirror speak) over
32
+ * `source` - the front-door-NORMALIZED input (\r\n -> \n; the shebang
33
+ * line, when present, is included in the offset space).
34
+ * - Node-level extents are the guarantee (a span covers its opener through
35
+ * its closer); the interiors of canonicalized text (merged literals,
36
+ * resolved escapes) are covered but not subdivided.
37
+ */
38
+ export function parseWithPositions(input) {
39
+ const normalized = input.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
40
+ const [version, body] = stripVersion(normalized);
41
+ if (version !== 0) {
42
+ throw new UnsupportedVersionError(version);
43
+ }
44
+ const tracker = { spans: new WeakMap() };
45
+ const doc = {
46
+ type: "document",
47
+ version,
48
+ children: parseBlocks(body, tracker, normalized.length - body.length),
49
+ };
50
+ tracker.spans.set(doc, { start: 0, end: normalized.length });
51
+ return { doc, spans: tracker.spans, source: normalized };
52
+ }
25
53
  /** The in-band version declaration: line 1, exactly `#!marquee <integer>`.
26
54
  * The default is version 0, forever. */
27
55
  function stripVersion(s) {
package/dist/inlines.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import type { Node } from "./ast.ts";
2
+ import { type Span } from "./blocks.ts";
2
3
  export declare const MAX_INLINE_DEPTH = 16;
3
4
  export declare const MAX_EMOJI_SLUG_BYTES = 64;
4
- export declare function parseInlines(text: string): Node[];
5
+ /** Positions context: map[cpIndex] = source UTF-16 offset of that code
6
+ * point in the logical text (one extra entry for the end). */
7
+ export interface PosCtx {
8
+ map: number[];
9
+ spans: WeakMap<Node, Span>;
10
+ }
11
+ export declare function parseInlines(text: string, P?: PosCtx | null): Node[];