atdoc-core 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/Parser.js ADDED
@@ -0,0 +1,797 @@
1
+ // Parser — recursive-descent, driven entirely by registry.ts's NodeDef table.
2
+ //
3
+ // Editor Mode (Inline Syntax Specification §11 Parser Recovery Strategy): this
4
+ // parser never aborts the whole document over one malformed construct. Every
5
+ // recoverable problem — an unclosed bracket, a missing required paren, a node
6
+ // in the wrong context, a non-digit @fn, an unsupported node in a table cell —
7
+ // is recorded in `diagnostics` (surfaced by the editor as a red squiggly, see
8
+ // MonacoCodeEditor.tsx's updateDiagnostics()) and the parser recovers with a
9
+ // sensible best-effort AST instead of throwing. `DocSyntaxError` is reserved
10
+ // for genuine internal invariant violations (a NODE token for an unregistered
11
+ // name — the Lexer should never emit one), not for anything a user can type.
12
+ import { DocSyntaxError } from './types';
13
+ import { getNodeDef, isCellAllowedNode, deriveParenFields } from './registry';
14
+ /** Content modes the Lexer scans opaquely into a single RAW token (@raw, @code, @mermaid, @kbd, @fn). */
15
+ function isRawFamilyContent(content) {
16
+ return content === 'raw' || content === 'raw-escaped' || content === 'key' || content === 'integer';
17
+ }
18
+ // Known @color/@bordered {styles} tokens (Inline Syntax Specification §7) —
19
+ // the same seven named colors @mark's {styles} accepts, plus the hex pattern
20
+ // — used only for the editor diagnostic below. Adapters.ts owns the actual
21
+ // color *resolution* logic; this list exists purely to flag typos in the Playground.
22
+ const KNOWN_COLOR_TOKENS = new Set(['yellow', 'red', 'green', 'blue', 'orange', 'purple', 'gray']);
23
+ const HEX_STYLE_TOKEN = /^#[0-9a-fA-F]{6}$/;
24
+ export class DocParser {
25
+ tokens;
26
+ cursor = 0;
27
+ /** Non-fatal issues collected during parsing (e.g. an unclosed bracket, a node used in the wrong context). */
28
+ diagnostics = [];
29
+ constructor(tokens) {
30
+ this.tokens = tokens;
31
+ }
32
+ /** Records a non-fatal issue without aborting the parse — see the file-level Editor Mode comment. Severity defaults to 'error' (omitted on the diagnostic itself, preserving the historical shape). */
33
+ diagnose(start, end, message, severity) {
34
+ const d = { start, end, message };
35
+ if (severity)
36
+ d.severity = severity;
37
+ this.diagnostics.push(d);
38
+ }
39
+ /** Best-effort position for a diagnostic when there's no specific token to blame (e.g. end-of-input). */
40
+ cursorPos() {
41
+ const prev = this.tokens[this.cursor - 1];
42
+ if (prev)
43
+ return prev.end;
44
+ const cur = this.tokens[this.cursor];
45
+ return cur ? cur.start : 0;
46
+ }
47
+ parse() {
48
+ const ast = [];
49
+ while (this.cursor < this.tokens.length) {
50
+ const cur = this.tokens[this.cursor];
51
+ if (cur.type === 'TEXT' && cur.value.trim() === '') {
52
+ this.cursor++;
53
+ continue;
54
+ }
55
+ if (cur.type === 'NODE' && this.isTopLevelBlock(cur.value)) {
56
+ const start = cur.start;
57
+ const node = this.parseNode(undefined);
58
+ if (node) {
59
+ node.start = start;
60
+ ast.push(node);
61
+ }
62
+ continue;
63
+ }
64
+ // Leniency: stray inline content at document top level gets wrapped in
65
+ // an implicit @p, the same forgiving behavior the previous prototype had.
66
+ const implicitStart = cur.start;
67
+ const p = this.parseImplicitParagraph();
68
+ p.start = implicitStart;
69
+ const hasContent = p.content.some(c => typeof c !== 'string' || c.trim() !== '');
70
+ if (hasContent)
71
+ ast.push(p);
72
+ }
73
+ return ast;
74
+ }
75
+ isTopLevelBlock(name) {
76
+ const nodeDef = getNodeDef(name);
77
+ return !!nodeDef && (nodeDef.kind === 'block' || nodeDef.kind === 'meta') && !nodeDef.restrictedTo;
78
+ }
79
+ parseImplicitParagraph() {
80
+ const content = [];
81
+ while (this.cursor < this.tokens.length) {
82
+ const cur = this.tokens[this.cursor];
83
+ if (cur.type === 'NODE' && this.isTopLevelBlock(cur.value))
84
+ break;
85
+ if (cur.type === 'NODE') {
86
+ const child = this.parseNode('paragraph');
87
+ if (child)
88
+ content.push(child);
89
+ continue;
90
+ }
91
+ if (cur.type === 'TEXT') {
92
+ content.push(cur.value);
93
+ this.cursor++;
94
+ continue;
95
+ }
96
+ if (cur.type === 'SLOT_OPEN') {
97
+ content.push('[');
98
+ this.cursor++;
99
+ continue;
100
+ }
101
+ if (cur.type === 'SLOT_CLOSE') {
102
+ content.push(']');
103
+ this.cursor++;
104
+ continue;
105
+ }
106
+ this.cursor++;
107
+ }
108
+ return { type: 'paragraph', content };
109
+ }
110
+ /**
111
+ * Parses one node starting at the current NODE token.
112
+ * `parentType` is the immediate containing node's type (or undefined at
113
+ * document root) — used to enforce `restrictedTo` (Widget-Blocks.md §3,
114
+ * Structural-Blocks.md §5 Table: @tab/@cols/@data are only valid inside
115
+ * their specific parent). A violation is a diagnostic, not a throw — the
116
+ * node still parses normally, just flagged as misplaced (Editor Mode).
117
+ */
118
+ parseNode(parentType) {
119
+ const token = this.tokens[this.cursor];
120
+ if (!token || token.type !== 'NODE')
121
+ return null;
122
+ const name = token.value;
123
+ const nodeDef = getNodeDef(name);
124
+ if (!nodeDef) {
125
+ // Genuine internal invariant violation, not a recoverable user error —
126
+ // the registry-aware Lexer should never emit a NODE token it doesn't
127
+ // recognize itself.
128
+ throw new DocSyntaxError(`Internal error: "@${name}" reached the Parser but isn't registered — the Lexer should never have emitted a NODE token for it.`);
129
+ }
130
+ if (nodeDef.restrictedTo && nodeDef.restrictedTo !== parentType) {
131
+ const where = parentType ? `inside \`@${parentType}\`` : 'at the document root';
132
+ this.diagnose(token.start, token.end, `\`@${name}\` may only appear directly inside \`@${nodeDef.restrictedTo}\` — found ${where}.`);
133
+ }
134
+ this.cursor++; // consume NODE
135
+ // node.type is always the canonical registry name — @h/@p/@b/@i/@u
136
+ // resolve transparently via registry.ts's alias table (getNodeDef), so
137
+ // the AST never distinguishes how the author spelled the command.
138
+ const node = { type: nodeDef.name, content: [] };
139
+ if (this.tokens[this.cursor]?.type === 'PAREN') {
140
+ node.paren = this.tokens[this.cursor].value;
141
+ this.cursor++;
142
+ }
143
+ // A `(paren)` swapped after `{styles}` (e.g. `@img{radius-8}(src=...)[...]`)
144
+ // is recovered further down (see the "must come before {styles}" block
145
+ // below) — peek past a leading STYLES token for it here so a
146
+ // required-paren node (@img) doesn't also report a misleading "requires
147
+ // a parenthesized X" (the paren IS there, just in the wrong slot).
148
+ const parenIsSwappedAfterStyles = node.paren === undefined
149
+ && this.tokens[this.cursor]?.type === 'STYLES' && this.tokens[this.cursor + 1]?.type === 'PAREN';
150
+ if (nodeDef.paren === 'required' && node.paren === undefined && !parenIsSwappedAfterStyles) {
151
+ this.diagnose(token.start, token.end, `\`@${name}\` requires a parenthesized ${nodeDef.parenRole ?? 'value'} — e.g. \`@${name}(...)\`.`);
152
+ }
153
+ // @color's "(hex)" paren syntax was retired in favor of sharing @mark's
154
+ // "{styles}" slot — flag the old form instead of silently discarding it.
155
+ if (nodeDef.name === 'color' && node.paren !== undefined) {
156
+ this.diagnose(token.start, token.end, `\`@color\` no longer accepts a parenthesized value — use \`@color{${node.paren}}\` instead of \`@color(${node.paren})\`.`);
157
+ }
158
+ Object.assign(node, deriveParenFields(nodeDef.parenRole, node.paren));
159
+ // @color and @bordered both take a single color-swatch value (hex or
160
+ // named token), not a comma-separated token list like @mark — so they
161
+ // share `node.color` instead of the generic split-into-array handling
162
+ // below. @bordered applies that same value as a border instead of a
163
+ // foreground color (see Adapters.ts).
164
+ const isColorSwatch = nodeDef.name === 'color' || nodeDef.name === 'bordered';
165
+ if (this.tokens[this.cursor]?.type === 'STYLES') {
166
+ const stylesTok = this.tokens[this.cursor];
167
+ const raw = stylesTok.value;
168
+ // Only the nodes whose grammar actually defines a "{styles}" slot get
169
+ // one (registry.ts's `styles` StyleSet). The Lexer tokenizes "{...}"
170
+ // after *any* node name generically, so without this the slot would be
171
+ // silently swallowed and dropped on e.g. `@heading(1){radius-12}[...]`,
172
+ // leaving the author to wonder why their styles did nothing. Flagged
173
+ // but still consumed, per the file-level Editor Mode comment.
174
+ if (!nodeDef.styles) {
175
+ // Underline just the "{" rather than the whole token: a slot the
176
+ // author is still typing has no "}" yet, so the Lexer's token runs to
177
+ // end-of-document and would otherwise paint the rest of the file red.
178
+ this.diagnose(stylesTok.start, stylesTok.start + 1, `\`@${name}\` has no \`{styles}\` slot.`);
179
+ }
180
+ if (isColorSwatch) {
181
+ node.color = raw.trim();
182
+ this.diagnoseUnknownColorValue(nodeDef.name, node.color, raw, stylesTok.start);
183
+ }
184
+ else {
185
+ node.styles = raw.split(',').map(s => s.trim()).filter(Boolean);
186
+ }
187
+ this.cursor++;
188
+ }
189
+ else if (isColorSwatch) {
190
+ // No {styles} slot at all — grammatically optional, but flagged all
191
+ // the same, so authors can choose an explicit foreground or border color
192
+ // instead of relying on the renderer's fallback appearance.
193
+ this.diagnose(token.start, token.end, `\`@${nodeDef.name}\` has no {styles} value — add \`{#hex}\` or \`{colorname}\` to pick an explicit color.`);
194
+ }
195
+ // A `(paren)` written after `{styles}` (e.g. `@card{radius-12}(Title)[...]`,
196
+ // `@img{radius-8}(src=...)[...]`) is a common ordering mistake — the EBNF
197
+ // requires [paren] before [styles] (Block Syntax Specification §5/§6/§7).
198
+ // Flag it with a specific diagnostic and still apply it (best-effort
199
+ // recovery, per the file-level Editor Mode comment) rather than leaving
200
+ // the stray PAREN token to desync the content-slot check right after,
201
+ // which would otherwise also fire a second, more confusing diagnostic.
202
+ if (nodeDef.paren !== 'none' && node.paren === undefined && this.tokens[this.cursor]?.type === 'PAREN') {
203
+ const parenTok = this.tokens[this.cursor];
204
+ this.diagnose(parenTok.start, parenTok.end, `\`@${name}\`'s parenthesized ${nodeDef.parenRole ?? 'value'} must come before \`{styles}\`, not after — write \`@${name}(...) {...} [...]\`.`);
205
+ node.paren = parenTok.value;
206
+ this.cursor++;
207
+ Object.assign(node, deriveParenFields(nodeDef.parenRole, node.paren));
208
+ }
209
+ return this.parseContentByMode(node, nodeDef);
210
+ }
211
+ parseContentByMode(node, nodeDef) {
212
+ switch (nodeDef.content) {
213
+ case 'none':
214
+ return node;
215
+ case 'raw':
216
+ case 'raw-escaped':
217
+ case 'key':
218
+ case 'integer': {
219
+ const t = this.tokens[this.cursor];
220
+ if (!t || t.type !== 'RAW') {
221
+ this.diagnose(this.cursorPos(), this.cursorPos(), `\`@${node.type}\` expects a content slot \`[...]\` immediately after it.`);
222
+ return node;
223
+ }
224
+ node.raw = t.value;
225
+ this.cursor++;
226
+ if (nodeDef.content === 'raw-escaped')
227
+ this.diagnoseRawEscapes(t);
228
+ if (nodeDef.content === 'integer') {
229
+ if (!/^[0-9]+$/.test(node.raw)) {
230
+ this.diagnose(t.start, t.end, `\`@${node.type}[...]\` must contain only digits — got \`${node.raw}\` (Inline Syntax Specification §4: fn = "@fn", "[", integer, "]").`);
231
+ }
232
+ else {
233
+ node.number = parseInt(node.raw, 10);
234
+ }
235
+ }
236
+ return node;
237
+ }
238
+ case 'comma-list': {
239
+ if (!this.trySlotOpen(node.type))
240
+ return node;
241
+ const cells = this.parseInlineCellList(node.type);
242
+ this.closeSlot(node.type);
243
+ // Unlike @data rows, empty columns (trailing comma, "@cols[]") are dropped
244
+ // rather than kept — there's no fixed column count to stay aligned with.
245
+ node.columns = cells.filter(cell => cell.length > 0);
246
+ return node;
247
+ }
248
+ case 'rows': {
249
+ if (!this.trySlotOpen(node.type))
250
+ return node;
251
+ node.rows = this.parseDataRows();
252
+ this.closeSlot(node.type);
253
+ return node;
254
+ }
255
+ case 'table': {
256
+ if (!this.trySlotOpen(node.type))
257
+ return node;
258
+ node.columns = [];
259
+ node.rows = [];
260
+ this.skipWhitespaceText();
261
+ const colsTok = this.tokens[this.cursor];
262
+ if (!colsTok || colsTok.type !== 'NODE' || colsTok.value !== 'cols') {
263
+ this.diagnose(this.cursorPos(), this.cursorPos(), '`@table` requires `@cols` as its first child (Block Syntax Specification §5 Table).');
264
+ this.skipToMatchingSlotClose();
265
+ return node;
266
+ }
267
+ const colsNode = this.parseNode(node.type);
268
+ this.skipWhitespaceText();
269
+ const dataTok = this.tokens[this.cursor];
270
+ if (!dataTok || dataTok.type !== 'NODE' || dataTok.value !== 'data') {
271
+ this.diagnose(this.cursorPos(), this.cursorPos(), '`@table` requires `@data` as its second child, immediately after `@cols` (Block Syntax Specification §5 Table).');
272
+ node.columns = colsNode.columns ?? [];
273
+ this.skipToMatchingSlotClose();
274
+ return node;
275
+ }
276
+ const dataNode = this.parseNode(node.type);
277
+ this.skipWhitespaceText();
278
+ this.closeSlot(node.type);
279
+ node.columns = colsNode.columns ?? [];
280
+ node.rows = dataNode.rows ?? [];
281
+ return node;
282
+ }
283
+ case 'tabs': {
284
+ if (!this.trySlotOpen(node.type))
285
+ return node;
286
+ const tabs = [];
287
+ // eslint-disable-next-line no-constant-condition
288
+ while (true) {
289
+ this.skipWhitespaceText();
290
+ const t = this.tokens[this.cursor];
291
+ if (!t) {
292
+ this.diagnose(this.cursorPos(), this.cursorPos(), '`@tabs[...]` is missing its closing `]`.');
293
+ break;
294
+ }
295
+ if (t.type === 'SLOT_CLOSE') {
296
+ this.cursor++;
297
+ break;
298
+ }
299
+ if (t.type !== 'NODE' || t.value !== 'tab') {
300
+ const found = t.type === 'NODE' ? `@${t.value}` : t.value;
301
+ this.diagnose(t.start, t.end, `\`@tabs\` only accepts \`@tab\` children — found \`${found}\` (Block Syntax Specification §8 Tabs).`);
302
+ // Drop the offending child (fully consumed, so the cursor stays in
303
+ // sync) and keep collecting the remaining valid @tab children.
304
+ if (t.type === 'NODE')
305
+ this.parseNode(node.type);
306
+ else
307
+ this.cursor++;
308
+ continue;
309
+ }
310
+ tabs.push(this.parseNode(node.type));
311
+ }
312
+ node.tabs = tabs;
313
+ return node;
314
+ }
315
+ case 'meta': {
316
+ if (!this.trySlotOpen(node.type))
317
+ return node;
318
+ const raw = this.collectRawText(node.type);
319
+ this.closeSlot(node.type);
320
+ node.meta = {};
321
+ raw.split('\n').forEach(line => {
322
+ const m = line.match(/^\s*([a-zA-Z0-9_-]+)\s*=\s*(.*?)\s*$/);
323
+ if (m)
324
+ node.meta[m[1]] = m[2];
325
+ });
326
+ return node;
327
+ }
328
+ case 'generic':
329
+ default: {
330
+ if (!this.trySlotOpen(node.type))
331
+ return node;
332
+ const rawContent = this.parseSlotContent(node.type);
333
+ // @list's content is still registry 'generic' (any inline/nested node is
334
+ // legal inside it), but its top-level shape is special: each "- "/"N. "
335
+ // prefixed line is its own item. Post-process rather than adding a new
336
+ // registry content mode, since the recursive inline parsing above is
337
+ // identical either way.
338
+ node.content = node.type === 'list' ? this.buildListItems(rawContent) : rawContent;
339
+ this.closeSlot(node.type);
340
+ return node;
341
+ }
342
+ }
343
+ }
344
+ /**
345
+ * Restructures @list's flat inline content into `list-item` nodes (Block
346
+ * Syntax Specification §5 List). A line is anything between "\n" boundaries
347
+ * inside the content's string runs; a DocASTNode segment stays attached to
348
+ * whichever line it falls on.
349
+ *
350
+ * - Every non-blank line is its own item — a leading "- " is optional and
351
+ * stripped when present, purely for backward compatibility with the old
352
+ * dash-required style; it was never required to make something an item.
353
+ * - A leading "N. " / "N)" is also optional; when present it's stripped and
354
+ * kept as `marker` (only meaningful for @list(ordered), see Adapters.ts,
355
+ * letting the numbering jump/resume via <li value>).
356
+ * - A line that's nothing but a single nested `@list[...]` (plus surrounding
357
+ * whitespace) isn't a new item — it's folded into the previous item's
358
+ * content as that item's sub-list.
359
+ * - Blank lines are ignored.
360
+ */
361
+ buildListItems(content) {
362
+ const lines = [[]];
363
+ for (const seg of content) {
364
+ if (typeof seg !== 'string') {
365
+ lines[lines.length - 1].push(seg);
366
+ continue;
367
+ }
368
+ const parts = seg.split('\n');
369
+ lines[lines.length - 1].push(parts[0]);
370
+ for (let k = 1; k < parts.length; k++)
371
+ lines.push([parts[k]]);
372
+ }
373
+ const items = [];
374
+ const DASH_RE = /^[ \t]*-[ \t]+([\s\S]*)$/;
375
+ const NUM_RE = /^[ \t]*(\d+)[.)][ \t]+([\s\S]*)$/;
376
+ for (const line of lines) {
377
+ const nodeSegs = line.filter((s) => typeof s !== 'string');
378
+ const textSegs = line.filter((s) => typeof s === 'string');
379
+ const isBlank = nodeSegs.length === 0 && textSegs.every(t => t.trim() === '');
380
+ if (isBlank)
381
+ continue;
382
+ const isSoleNestedList = nodeSegs.length === 1 && nodeSegs[0].type === 'list' && textSegs.every(t => t.trim() === '');
383
+ if (isSoleNestedList && items.length > 0) {
384
+ items[items.length - 1].content.push(nodeSegs[0]);
385
+ continue;
386
+ }
387
+ const rest = line.slice();
388
+ let marker;
389
+ const first = rest[0];
390
+ if (typeof first === 'string') {
391
+ const dashMatch = first.match(DASH_RE);
392
+ const numMatch = !dashMatch ? first.match(NUM_RE) : null;
393
+ if (dashMatch) {
394
+ rest[0] = dashMatch[1];
395
+ }
396
+ else if (numMatch) {
397
+ marker = parseInt(numMatch[1], 10);
398
+ rest[0] = numMatch[2];
399
+ }
400
+ if (rest[0] === '')
401
+ rest.shift();
402
+ }
403
+ const item = { type: 'list-item', content: rest };
404
+ if (marker !== undefined)
405
+ item.marker = marker;
406
+ items.push(item);
407
+ }
408
+ return items;
409
+ }
410
+ /** Depth-aware: a literal, unpaired "[" typed as plain text (e.g. "array[0]") stays transparent instead of prematurely closing the slot. */
411
+ parseSlotContent(parentType) {
412
+ const content = [];
413
+ let depth = 0;
414
+ while (this.cursor < this.tokens.length) {
415
+ const cur = this.tokens[this.cursor];
416
+ if (cur.type === 'SLOT_CLOSE' && depth === 0)
417
+ break;
418
+ if (cur.type === 'NODE') {
419
+ const child = this.parseNode(parentType);
420
+ if (child)
421
+ content.push(child);
422
+ continue;
423
+ }
424
+ if (cur.type === 'SLOT_OPEN') {
425
+ depth++;
426
+ content.push('[');
427
+ this.cursor++;
428
+ continue;
429
+ }
430
+ if (cur.type === 'SLOT_CLOSE') {
431
+ depth--;
432
+ content.push(']');
433
+ this.cursor++;
434
+ continue;
435
+ }
436
+ if (cur.type === 'TEXT') {
437
+ content.push(cur.value);
438
+ this.cursor++;
439
+ continue;
440
+ }
441
+ // PAREN / STYLES / RAW should never surface here — they're always
442
+ // consumed inline by parseNode right after their own NODE token.
443
+ this.cursor++;
444
+ }
445
+ return content;
446
+ }
447
+ /**
448
+ * Escape-awareness feedback for @raw (Inline Syntax Specification §9) — the
449
+ * "advance notice" tier of Editor Mode. Never blocks: the content parses
450
+ * exactly as the escape rules say either way.
451
+ *
452
+ * Two tiers:
453
+ *
454
+ * - warning — the node shows swallow symptoms: it never found its closing
455
+ * "]" at all, or an escape sat at the very end of a line the content then
456
+ * ran past. The near-certain cause is an escape that consumed the "]" the
457
+ * author meant as the node's end (`@raw[@mark[hello@@]]`-style, or a
458
+ * trailing "@" fusing with the closer). Scoped to escape-at-line-end
459
+ * rather than "any newline in the content" so a deliberate multi-line
460
+ * @raw with mid-line escapes (e.g. Markdown import preserving an
461
+ * unbalanced code block) stays at the info tier.
462
+ *
463
+ * - info — every other consumed escape gets a quiet heads-up that it *is*
464
+ * an escape (a literal bracket that neither ends the node nor counts
465
+ * toward depth). This is what catches the cases no heuristic can: an
466
+ * accidental `@]` that swallows the rest of its own line still parses
467
+ * "successfully", and only the author knows it wasn't meant — the note
468
+ * tells them what the Parser did with what they wrote.
469
+ */
470
+ diagnoseRawEscapes(t) {
471
+ const escapes = t.escapes;
472
+ if (!escapes || escapes.length === 0)
473
+ return;
474
+ // Swallow symptoms, tightly scoped so a *deliberate* multi-line @raw with
475
+ // mid-line escapes stays at the info tier: either the node never closed at
476
+ // all, or an escape sits at the very end of a line the content then ran
477
+ // past — the signature of "@mark[hello@]"-style escapes that consumed the
478
+ // "]" the author meant as the node's end.
479
+ const eolEscapes = escapes.filter(e => e.atLineEnd);
480
+ const symptomatic = t.closed === false || eolEscapes.length > 0;
481
+ if (symptomatic) {
482
+ const culprit = t.closed === false
483
+ ? escapes[escapes.length - 1]
484
+ : eolEscapes[eolEscapes.length - 1];
485
+ const symptom = t.closed === false
486
+ ? 'the node never finds its closing "]"'
487
+ : 'the raw content runs past the end of this line';
488
+ this.diagnose(culprit.start, culprit.end, `This \`${culprit.seq}\` is read as @raw's escape, so it does not end the node — and ${symptom}, `
489
+ + `which usually means it consumed the "]" that was meant as the end. Balanced brackets need no escape inside @raw; `
490
+ + `escape only unpaired ones (Inline Syntax Specification §9).`, 'warning');
491
+ return;
492
+ }
493
+ for (const esc of escapes) {
494
+ const decoded = esc.seq === '@@]' ? '@]' : esc.seq === '@@[' ? '@[' : esc.seq === '@]' ? ']' : '[';
495
+ this.diagnose(esc.start, esc.end, `\`${esc.seq}\` is @raw's escape for a literal "${decoded}" — it neither ends the node nor counts toward bracket depth `
496
+ + `(Inline Syntax Specification §9).`, 'info');
497
+ }
498
+ }
499
+ /**
500
+ * @color/@bordered's {styles} value is semantically validated here purely
501
+ * for editor feedback (Inline Syntax Specification §7 leaves token *meaning*
502
+ * to the Renderer — an unrecognized value already falls back gracefully at
503
+ * render time — but it's almost always a typo, or a missing value, the
504
+ * author would want flagged, not a silent no-op). An empty `{}` counts as
505
+ * an invalid value here too, same as a real unrecognized token — it's
506
+ * flagged right alongside the case where {styles} is missing entirely (see
507
+ * the call site above).
508
+ */
509
+ diagnoseUnknownColorValue(nodeName, token, raw, stylesStart) {
510
+ if (token && (KNOWN_COLOR_TOKENS.has(token) || HEX_STYLE_TOKEN.test(token)))
511
+ return;
512
+ const leadingWs = raw.length - raw.trimStart().length;
513
+ const tokenStart = stylesStart + 1 + leadingWs; // +1 skips the "{" itself
514
+ const message = token
515
+ ? `Unrecognized value "${token}" inside @${nodeName}'s {styles} — not a known named color or hex value.`
516
+ : `\`@${nodeName}\`'s {styles} is empty — add \`{#hex}\` or \`{colorname}\` to pick an explicit color.`;
517
+ this.diagnose(tokenStart, tokenStart + token.length, message);
518
+ }
519
+ /**
520
+ * Like parseSlotContent, but for content modes that only ever hold plain text
521
+ * (currently just @meta's key=value lines). "@@" already resolves to a literal
522
+ * "@" at the Lexer level (Inline Spec §2 step 1), so it never reaches here as a
523
+ * NODE token. Void nodes (@n, content:'none') and raw-family nodes (@raw/@code/
524
+ * @kbd/..., content:'raw'/'raw-escaped'/'key'/'integer') get a narrow carve-out
525
+ * since they can't recursively contain more of the very commands this slot
526
+ * forbids. Every other @command is still invalid here, but instead of throwing
527
+ * and losing the whole document, it gets fully consumed (to keep the cursor in
528
+ * sync), silently dropped from the output, and recorded in `diagnostics` — the
529
+ * editor renders that as a squiggly + hover, the rendered doc simply doesn't
530
+ * contain it.
531
+ */
532
+ collectRawText(ownerName) {
533
+ let buf = '';
534
+ let depth = 0;
535
+ while (this.cursor < this.tokens.length) {
536
+ const cur = this.tokens[this.cursor];
537
+ if (cur.type === 'SLOT_CLOSE' && depth === 0)
538
+ break;
539
+ if (cur.type === 'NODE') {
540
+ const nodeDef = getNodeDef(cur.value);
541
+ if (nodeDef?.content === 'none') {
542
+ buf += '\n'; // @n — line break marker; renderer turns it into <br>
543
+ this.cursor++;
544
+ continue;
545
+ }
546
+ // Only @raw itself (content mode 'raw-escaped', unique to @raw) is part of
547
+ // @meta's grammar — NOT the rest of the raw family (@code/@mermaid/@kbd/@fn).
548
+ // isRawFamilyContent() below is deliberately not used here: it also matches
549
+ // 'raw'/'key'/'integer', which would silently fold @code/@mermaid/@kbd/@fn's
550
+ // raw text into a metadata value with no diagnostic, contradicting the
551
+ // "only plain text, @n, and @raw" message a few lines down.
552
+ if (nodeDef?.content === 'raw-escaped') {
553
+ this.cursor++; // consume NODE
554
+ const rawTok = this.tokens[this.cursor];
555
+ if (!rawTok || rawTok.type !== 'RAW') {
556
+ this.diagnose(this.cursorPos(), this.cursorPos(), `\`@${cur.value}\` expects a content slot \`[...]\` immediately after it.`);
557
+ continue;
558
+ }
559
+ buf += rawTok.value;
560
+ this.cursor++;
561
+ continue;
562
+ }
563
+ // Anything else isn't part of this slot's grammar. Rather than aborting the
564
+ // whole document over one bad node, consume its full subtree (so the cursor
565
+ // stays in sync), drop it from the output, and surface it as an editor
566
+ // diagnostic instead.
567
+ const name = cur.value;
568
+ const start = cur.start;
569
+ this.parseNode(ownerName);
570
+ const end = this.tokens[this.cursor - 1]?.end ?? cur.end;
571
+ this.diagnose(start, end, `Unsupported node "@${name}" inside @${ownerName} — only plain text, @n, and @raw are allowed here.`);
572
+ continue; // nothing appended to buf — the node is dropped entirely
573
+ }
574
+ if (cur.type === 'SLOT_OPEN') {
575
+ depth++;
576
+ buf += '[';
577
+ this.cursor++;
578
+ continue;
579
+ }
580
+ if (cur.type === 'SLOT_CLOSE') {
581
+ depth--;
582
+ buf += ']';
583
+ this.cursor++;
584
+ continue;
585
+ }
586
+ buf += cur.value;
587
+ this.cursor++;
588
+ }
589
+ return buf;
590
+ }
591
+ /**
592
+ * Trims leading/trailing whitespace-only string chunks off a cell's inline
593
+ * content array — the array equivalent of `str.trim()` for a mixed text/node list.
594
+ */
595
+ trimCellEdges(cell) {
596
+ const out = cell.slice();
597
+ while (out.length && typeof out[0] === 'string') {
598
+ const t = out[0].replace(/^\s+/, '');
599
+ if (t === '') {
600
+ out.shift();
601
+ continue;
602
+ }
603
+ out[0] = t;
604
+ break;
605
+ }
606
+ while (out.length && typeof out[out.length - 1] === 'string') {
607
+ const t = out[out.length - 1].replace(/\s+$/, '');
608
+ if (t === '') {
609
+ out.pop();
610
+ continue;
611
+ }
612
+ out[out.length - 1] = t;
613
+ break;
614
+ }
615
+ return out;
616
+ }
617
+ /**
618
+ * Parses @cols/@data content as comma-separated cells, each cell holding inline
619
+ * content (text plus a curated set of formatting nodes — @n, @raw/@code/@kbd/...,
620
+ * and whatever registry.ts's isCellAllowedNode() lets through, e.g. @bold/@mark/
621
+ * @link). Anything else is unsupported here: fully consumed (cursor stays in
622
+ * sync), dropped from the output, and recorded as a diagnostic — same policy as
623
+ * collectRawText, just producing structured cells instead of a flat string.
624
+ *
625
+ * Commas only split cells at this slot's own depth — a comma inside a nested
626
+ * node's own "[...]" (e.g. `@bold[a,b]`) stays literal, since that TEXT token
627
+ * is emitted while `depth > 0`.
628
+ */
629
+ parseInlineCellList(ownerName) {
630
+ const cells = [[]];
631
+ let depth = 0;
632
+ const currentCell = () => cells[cells.length - 1];
633
+ const pushText = (s) => { if (s !== '')
634
+ currentCell().push(s); };
635
+ while (this.cursor < this.tokens.length) {
636
+ const cur = this.tokens[this.cursor];
637
+ if (cur.type === 'SLOT_CLOSE' && depth === 0)
638
+ break;
639
+ if (cur.type === 'TEXT') {
640
+ if (depth === 0 && cur.value.includes(',')) {
641
+ const parts = cur.value.split(',');
642
+ pushText(parts[0]);
643
+ for (let k = 1; k < parts.length; k++) {
644
+ cells.push([]);
645
+ pushText(parts[k]);
646
+ }
647
+ }
648
+ else {
649
+ pushText(cur.value);
650
+ }
651
+ this.cursor++;
652
+ continue;
653
+ }
654
+ if (cur.type === 'SLOT_OPEN') {
655
+ depth++;
656
+ pushText('[');
657
+ this.cursor++;
658
+ continue;
659
+ }
660
+ if (cur.type === 'SLOT_CLOSE') {
661
+ depth--;
662
+ pushText(']');
663
+ this.cursor++;
664
+ continue;
665
+ }
666
+ if (cur.type === 'NODE') {
667
+ const nodeDef = getNodeDef(cur.value);
668
+ if (nodeDef?.content === 'none') {
669
+ pushText('\n'); // @n — line break marker; renderer turns it into <br>
670
+ this.cursor++;
671
+ continue;
672
+ }
673
+ // Checked before the raw-family carve-out below: @fn is content:'integer'
674
+ // (raw-family) but needs the real-node path so it renders as its actual
675
+ // `<sup><a>` back-link instead of being dumped as bare digit text.
676
+ if (nodeDef && isCellAllowedNode(nodeDef.name)) {
677
+ const child = this.parseNode(ownerName);
678
+ if (child)
679
+ currentCell().push(child);
680
+ continue;
681
+ }
682
+ if (isRawFamilyContent(nodeDef?.content)) {
683
+ this.cursor++; // consume NODE
684
+ // An optional "(...)" (e.g. @code's language tag) can sit between the
685
+ // NODE and its RAW content — consume and discard it, same as every
686
+ // other raw-family node's value gets flattened to plain text here.
687
+ // Without this, `@code(js)[...]` would find a PAREN token where it
688
+ // expects RAW and misreport "expects a content slot".
689
+ if (this.tokens[this.cursor]?.type === 'PAREN')
690
+ this.cursor++;
691
+ const rawTok = this.tokens[this.cursor];
692
+ if (!rawTok || rawTok.type !== 'RAW') {
693
+ this.diagnose(this.cursorPos(), this.cursorPos(), `\`@${cur.value}\` expects a content slot \`[...]\` immediately after it.`);
694
+ continue;
695
+ }
696
+ pushText(rawTok.value);
697
+ this.cursor++;
698
+ continue;
699
+ }
700
+ // Structural/disallowed node (e.g. @card, @table, @details) — consume its
701
+ // full subtree, drop it, and surface a diagnostic instead of throwing.
702
+ const name = cur.value;
703
+ const start = cur.start;
704
+ this.parseNode(ownerName);
705
+ const end = this.tokens[this.cursor - 1]?.end ?? cur.end;
706
+ this.diagnose(start, end, `Unsupported node "@${name}" inside @${ownerName} — only plain text and inline formatting (@bold, @italic, @mark, @n, @raw, ...) are allowed here.`);
707
+ continue;
708
+ }
709
+ // PAREN / STYLES / RAW should never surface loose here — always consumed
710
+ // inline by parseNode/the raw-family branch right after their own NODE token.
711
+ this.cursor++;
712
+ }
713
+ return cells.map((cell) => this.trimCellEdges(cell));
714
+ }
715
+ parseDataRows() {
716
+ const rows = [];
717
+ // eslint-disable-next-line no-constant-condition
718
+ while (true) {
719
+ this.skipWhitespaceText();
720
+ const t = this.tokens[this.cursor];
721
+ if (!t) {
722
+ this.diagnose(this.cursorPos(), this.cursorPos(), '`@data[...]` is missing its closing `]`.');
723
+ break;
724
+ }
725
+ if (t.type === 'SLOT_CLOSE')
726
+ break; // caller (closeSlot) consumes it
727
+ if (t.type !== 'SLOT_OPEN') {
728
+ this.diagnose(t.start, t.end, 'Each row inside `@data[...]` must start with `[` (Block Syntax Specification §5 Table).');
729
+ this.cursor++; // drop the stray token and keep looking for the next real row
730
+ continue;
731
+ }
732
+ this.cursor++; // consume the row's own "["
733
+ // Unlike @cols, empty cells are kept (not filtered) — a row's cell count
734
+ // must stay aligned with the table's column count.
735
+ const cells = this.parseInlineCellList('data row');
736
+ this.closeSlot('data row');
737
+ rows.push(cells);
738
+ }
739
+ return rows;
740
+ }
741
+ skipWhitespaceText() {
742
+ while (this.tokens[this.cursor]?.type === 'TEXT' && this.tokens[this.cursor].value.trim() === '') {
743
+ this.cursor++;
744
+ }
745
+ }
746
+ /**
747
+ * Scans forward tracking SLOT_OPEN/SLOT_CLOSE depth until the bracket that
748
+ * was already opened by the caller (depth starts at 1, representing that
749
+ * "[") finds its match — used to abandon a structurally malformed construct
750
+ * (e.g. `@table` missing `@cols`/`@data`) without losing cursor sync with
751
+ * the rest of the document. Runs out cleanly at end-of-input.
752
+ */
753
+ skipToMatchingSlotClose() {
754
+ let depth = 1;
755
+ while (this.cursor < this.tokens.length) {
756
+ const t = this.tokens[this.cursor];
757
+ if (t.type === 'SLOT_OPEN')
758
+ depth++;
759
+ if (t.type === 'SLOT_CLOSE') {
760
+ depth--;
761
+ if (depth === 0) {
762
+ this.cursor++;
763
+ return;
764
+ }
765
+ }
766
+ this.cursor++;
767
+ }
768
+ }
769
+ /** Like the old expectSlotOpen, but returns success instead of throwing — a missing "[" is a diagnostic, and the node is left with empty/default content. */
770
+ trySlotOpen(ownerName) {
771
+ const t = this.tokens[this.cursor];
772
+ if (!t || t.type !== 'SLOT_OPEN') {
773
+ this.diagnose(this.cursorPos(), this.cursorPos(), `\`@${ownerName}\` expects a content slot \`[...]\` immediately after it.`);
774
+ return false;
775
+ }
776
+ this.cursor++;
777
+ return true;
778
+ }
779
+ /**
780
+ * Like the old expectSlotClose, but records a diagnostic instead of
781
+ * throwing when the closing "]" is missing. Every content-collecting loop
782
+ * that calls this (parseSlotContent, parseInlineCellList, parseDataRows,
783
+ * the @table/@tabs child loops) only stops without consuming a SLOT_CLOSE
784
+ * when the token stream itself has run out — so reaching here without one
785
+ * always means end-of-input, i.e. an unclosed bracket. Editor Mode treats
786
+ * that as "auto-close at EOF" rather than aborting the whole document.
787
+ */
788
+ closeSlot(ownerName) {
789
+ const t = this.tokens[this.cursor];
790
+ if (!t || t.type !== 'SLOT_CLOSE') {
791
+ this.diagnose(this.cursorPos(), this.cursorPos(), `\`@${ownerName}\` is missing its closing \`]\` (unexpected end of input).`);
792
+ return;
793
+ }
794
+ this.cursor++;
795
+ }
796
+ }
797
+ //# sourceMappingURL=Parser.js.map