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/LICENSE +21 -0
- package/README.md +33 -0
- package/configs/atdoc-setting.json +30 -0
- package/dist/Adapters.d.ts +18 -0
- package/dist/Adapters.d.ts.map +1 -0
- package/dist/Adapters.js +484 -0
- package/dist/Adapters.js.map +1 -0
- package/dist/Lexer.d.ts +23 -0
- package/dist/Lexer.d.ts.map +1 -0
- package/dist/Lexer.js +292 -0
- package/dist/Lexer.js.map +1 -0
- package/dist/Parser.d.ts +136 -0
- package/dist/Parser.d.ts.map +1 -0
- package/dist/Parser.js +797 -0
- package/dist/Parser.js.map +1 -0
- package/dist/Serializer.d.ts +97 -0
- package/dist/Serializer.d.ts.map +1 -0
- package/dist/Serializer.js +753 -0
- package/dist/Serializer.js.map +1 -0
- package/dist/editor/monarch.d.ts +17 -0
- package/dist/editor/monarch.d.ts.map +1 -0
- package/dist/editor/monarch.js +163 -0
- package/dist/editor/monarch.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +104 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +265 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/docs/en/Block-Syntax-Specification.md +992 -0
- package/docs/en/Inline-Syntax-Specification.md +915 -0
- package/docs/en/README.md +235 -0
- package/package.json +36 -0
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
|