apexfile 1.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.
@@ -0,0 +1,518 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ApexDoc Tokenizer
5
+ * Converts raw .apx source text into a flat array of tokens.
6
+ * Each token = { type, value, props, line, col }
7
+ */
8
+
9
+ const T = require('./tokens');
10
+
11
+ // Top-level sections (%%meta, %%style, %%body)
12
+ const TOP_SECTIONS = new Set(['meta', 'style', 'body']);
13
+
14
+ // Blocks that self-close (no %%end needed when written as %%name {props}%%end inline)
15
+ const SELF_CLOSE_BLOCKS = new Set([
16
+ 'chart', 'image', 'video', 'audio', 'clock', 'countdown', 'divider',
17
+ 'spacer', 'progress', 'qr', 'map', 'lottie', 'icon', 'rating',
18
+ 'back-to-top', 'breadcrumbs', 'sidenav', 'toc', 'progress-bar',
19
+ 'theme', 'theme-switcher', 'stat', 'signature', 'feed',
20
+ 'slider', 'toggle', 'unit',
21
+ ]);
22
+
23
+ class Tokenizer {
24
+ constructor(source) {
25
+ this.source = source;
26
+ this.pos = 0;
27
+ this.line = 1;
28
+ this.col = 1;
29
+ this.tokens = [];
30
+ }
31
+
32
+ // ── Main Entry ─────────────────────────────────────────────────
33
+
34
+ tokenize() {
35
+ const lines = this.source.split('\n');
36
+
37
+ for (let i = 0; i < lines.length; i++) {
38
+ this.line = i + 1;
39
+ const raw = lines[i];
40
+ const line = raw.trimEnd();
41
+
42
+ // Empty line
43
+ if (line.trim() === '') {
44
+ this.push(T.NEWLINE, '', {}, i + 1);
45
+ continue;
46
+ }
47
+
48
+ // ── Block directives %%name {props} ──────────────────────
49
+ if (line.trimStart().startsWith('%%')) {
50
+ this._tokenizeBlock(line.trim(), i + 1);
51
+ continue;
52
+ }
53
+
54
+ // ── Context-dependent: inside %%meta or %%style ──────────
55
+ // MUST come before @ check so @theme/@import inside %%style
56
+ // are handled by the style tokenizer, not the logic tokenizer.
57
+ const ctx = this._currentContext();
58
+
59
+ if (ctx === 'meta') {
60
+ this._tokenizeMetaField(line, i + 1);
61
+ continue;
62
+ }
63
+
64
+ if (ctx === 'style') {
65
+ this._tokenizeStyleLine(line, i + 1);
66
+ continue;
67
+ }
68
+
69
+ // ── Logic directives @set / @if / @each / @end ───────────
70
+ if (line.trimStart().startsWith('@')) {
71
+ this._tokenizeLogic(line.trim(), i + 1);
72
+ continue;
73
+ }
74
+
75
+ // ── Body content ─────────────────────────────────────────
76
+ this._tokenizeBodyLine(line, i + 1);
77
+ }
78
+
79
+ this.push(T.EOF, '', {}, this.line);
80
+ return this.tokens;
81
+ }
82
+
83
+ // ── Block Tokenizer ────────────────────────────────────────────
84
+
85
+ _tokenizeBlock(line, lineNum) {
86
+ // %%end — closes current block or section
87
+ if (line === '%%end') {
88
+ const ctx = this._currentContext();
89
+ if (TOP_SECTIONS.has(ctx)) {
90
+ this.push(T.SECTION_CLOSE, ctx, {}, lineNum);
91
+ } else {
92
+ this.push(T.BLOCK_CLOSE, ctx, {}, lineNum);
93
+ }
94
+ return;
95
+ }
96
+
97
+ // %%name {props}%%end — self-closing inline
98
+ const selfCloseMatch = line.match(/^%%([a-z0-9-]+)(?:\s*(\{[^}]*\}))?%%end$/i);
99
+ if (selfCloseMatch) {
100
+ const name = selfCloseMatch[1].toLowerCase();
101
+ const props = selfCloseMatch[2] ? this._parseProps(selfCloseMatch[2]) : {};
102
+ this.push(T.BLOCK_SELF_CLOSE, name, props, lineNum);
103
+ return;
104
+ }
105
+
106
+ // %%name {props} or %%name
107
+ const blockMatch = line.match(/^%%([a-z0-9-]+)(?:\s+(\{[\s\S]*\}))?$/i);
108
+ if (blockMatch) {
109
+ const name = blockMatch[1].toLowerCase();
110
+ const props = blockMatch[2] ? this._parseProps(blockMatch[2]) : {};
111
+
112
+ if (TOP_SECTIONS.has(name)) {
113
+ this.push(T.SECTION_OPEN, name, props, lineNum);
114
+ } else {
115
+ this.push(T.BLOCK_OPEN, name, props, lineNum);
116
+ }
117
+ return;
118
+ }
119
+
120
+ // Fallback — treat as text
121
+ this.push(T.TEXT, line, {}, lineNum);
122
+ }
123
+
124
+ // ── Logic Tokenizer ────────────────────────────────────────────
125
+
126
+ _tokenizeLogic(line, lineNum) {
127
+ // @set name = value
128
+ const setMatch = line.match(/^@set\s+(\w+)\s*=\s*(.+)$/);
129
+ if (setMatch) {
130
+ this.push(T.SET, setMatch[1], { value: setMatch[2].trim() }, lineNum);
131
+ return;
132
+ }
133
+
134
+ // @if condition
135
+ const ifMatch = line.match(/^@if\s+(.+)$/);
136
+ if (ifMatch) {
137
+ this.push(T.IF, ifMatch[1].trim(), {}, lineNum);
138
+ return;
139
+ }
140
+
141
+ // @elseif condition
142
+ const elseifMatch = line.match(/^@elseif\s+(.+)$/);
143
+ if (elseifMatch) {
144
+ this.push(T.ELSEIF, elseifMatch[1].trim(), {}, lineNum);
145
+ return;
146
+ }
147
+
148
+ // @else
149
+ if (line === '@else') {
150
+ this.push(T.ELSE, '', {}, lineNum);
151
+ return;
152
+ }
153
+
154
+ // @end
155
+ if (line === '@end') {
156
+ this.push(T.END_LOGIC, '', {}, lineNum);
157
+ return;
158
+ }
159
+
160
+ // @each item in list
161
+ const eachMatch = line.match(/^@each\s+(\w+)\s+in\s+(.+)$/);
162
+ if (eachMatch) {
163
+ this.push(T.EACH, eachMatch[1], { source: eachMatch[2].trim() }, lineNum);
164
+ return;
165
+ }
166
+
167
+ // @keyframe name { ... } (single-line — multi-line handled in parser)
168
+ const kfMatch = line.match(/^@keyframe\s+(\w+)/);
169
+ if (kfMatch) {
170
+ this.push(T.KEYFRAME, kfMatch[1], {}, lineNum);
171
+ return;
172
+ }
173
+
174
+ // Unknown @ directive — treat as text
175
+ this.push(T.TEXT, line, {}, lineNum);
176
+ }
177
+
178
+ // ── Meta Field Tokenizer ───────────────────────────────────────
179
+
180
+ _tokenizeMetaField(line, lineNum) {
181
+ // key: value or key: "value"
182
+ const fieldMatch = line.match(/^\s*([\w-]+)\s*:\s*(.*)$/);
183
+ if (fieldMatch) {
184
+ const key = fieldMatch[1].trim();
185
+ const value = this._parseMetaValue(fieldMatch[2].trim());
186
+ this.push(T.META_FIELD, key, { value }, lineNum);
187
+ }
188
+ }
189
+
190
+ // ── Style Line Tokenizer ───────────────────────────────────────
191
+
192
+ _tokenizeStyleLine(line, lineNum) {
193
+ const trimmed = line.trim();
194
+
195
+ // @import / @media / @breakpoint (without a block {)
196
+ if (trimmed.startsWith('@') && !trimmed.includes('{')) {
197
+ this.push(T.STYLE_DIRECTIVE, trimmed, {}, lineNum);
198
+ return;
199
+ }
200
+
201
+ // @theme name { / @media (...) { / @breakpoint name {
202
+ if (trimmed.startsWith('@') && trimmed.endsWith('{')) {
203
+ const directiveMatch = trimmed.match(/^(@\w+)\s+([^{]+)\s*\{$/);
204
+ if (directiveMatch) {
205
+ this.push(T.STYLE_BLOCK_OPEN, directiveMatch[1], { name: directiveMatch[2].trim() }, lineNum);
206
+ return;
207
+ }
208
+ }
209
+
210
+ // Closing brace of a style block
211
+ if (trimmed === '}') {
212
+ this.push(T.STYLE_BLOCK_CLOSE, '', {}, lineNum);
213
+ return;
214
+ }
215
+
216
+ // --variable: value
217
+ const varMatch = trimmed.match(/^(--[\w-]+)\s*:\s*(.+)$/);
218
+ if (varMatch) {
219
+ this.push(T.STYLE_VAR, varMatch[1], { value: varMatch[2].trim() }, lineNum);
220
+ return;
221
+ }
222
+
223
+ // Regular CSS property inside a style block
224
+ const propMatch = trimmed.match(/^([\w-]+)\s*:\s*(.+)$/);
225
+ if (propMatch) {
226
+ this.push(T.STYLE_VAR, propMatch[1], { value: propMatch[2].trim() }, lineNum);
227
+ }
228
+ }
229
+
230
+ // ── Body Line Tokenizer ────────────────────────────────────────
231
+
232
+ _tokenizeBodyLine(line, lineNum) {
233
+ // Heading # ## ### ####
234
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
235
+ if (headingMatch) {
236
+ const level = headingMatch[1].length;
237
+ const rest = headingMatch[2];
238
+ // Extract inline props from {props} at end of heading
239
+ const { text, props } = this._extractInlineProps(rest);
240
+ this.push(T.HEADING, text, { level, ...props }, lineNum);
241
+ return;
242
+ }
243
+
244
+ // Horizontal rule
245
+ if (/^(---|===|~~~)$/.test(line.trim())) {
246
+ this.push(T.HR, line.trim(), {}, lineNum);
247
+ return;
248
+ }
249
+
250
+ // Blockquote > text
251
+ if (line.trimStart().startsWith('>')) {
252
+ const level = (line.match(/^(>+)/)?.[1] || '>').length;
253
+ const content = line.replace(/^>+\s?/, '');
254
+ this.push(T.BLOCKQUOTE, content, { level }, lineNum);
255
+ return;
256
+ }
257
+
258
+ // Unordered list item
259
+ const ulMatch = line.match(/^(\s*)-\s+(.+)$/);
260
+ if (ulMatch) {
261
+ const indent = ulMatch[1].length;
262
+ const content = ulMatch[2];
263
+ // Task list - [x] / - [ ] / - [~]
264
+ const taskMatch = content.match(/^\[([x ~])\]\s+(.+)$/i);
265
+ if (taskMatch) {
266
+ const state = taskMatch[1] === 'x' ? 'done' : taskMatch[1] === '~' ? 'progress' : 'todo';
267
+ this.push(T.LIST_ITEM_TASK, taskMatch[2], { indent, state }, lineNum);
268
+ } else {
269
+ this.push(T.LIST_ITEM, content, { indent }, lineNum);
270
+ }
271
+ return;
272
+ }
273
+
274
+ // Ordered list item 1. text
275
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
276
+ if (olMatch) {
277
+ const indent = olMatch[1].length;
278
+ const num = parseInt(olMatch[2], 10);
279
+ this.push(T.LIST_ITEM_ORDERED, olMatch[3], { indent, num }, lineNum);
280
+ return;
281
+ }
282
+
283
+ // Footnote definition [^1]: content
284
+ const fnDefMatch = line.match(/^\[\^(\w+)\]:\s+(.+)$/);
285
+ if (fnDefMatch) {
286
+ this.push(T.FOOTNOTE_DEF, fnDefMatch[1], { content: fnDefMatch[2] }, lineNum);
287
+ return;
288
+ }
289
+
290
+ // Regular paragraph — tokenize inline syntax
291
+ const inlineTokens = this._tokenizeInline(line, lineNum);
292
+ for (const tok of inlineTokens) {
293
+ this.tokens.push(tok);
294
+ }
295
+ }
296
+
297
+ // ── Inline Tokenizer ───────────────────────────────────────────
298
+
299
+ _tokenizeInline(text, lineNum) {
300
+ const tokens = [];
301
+ let remaining = text;
302
+ let buffer = '';
303
+
304
+ const flush = () => {
305
+ if (buffer.length > 0) {
306
+ tokens.push(this._tok(T.TEXT, buffer, {}, lineNum));
307
+ buffer = '';
308
+ }
309
+ };
310
+
311
+ const patterns = [
312
+ // Math inline $...$
313
+ { re: /^\$([^$]+)\$/, type: T.MATH_INLINE, val: m => m[1] },
314
+
315
+ // Inline styled [text]{props}
316
+ { re: /^\[([^\]]+)\]\{([^}]*)\}/, type: T.INLINE_STYLED,
317
+ val: m => m[1], extra: m => ({ props: this._parseInlineProps(m[2]) }) },
318
+
319
+ // Link [text](url)
320
+ { re: /^\[([^\]]+)\]\(([^)]+)\)/, type: T.LINK,
321
+ val: m => m[1], extra: m => ({ href: m[2] }) },
322
+
323
+ // Footnote ref [^1]
324
+ { re: /^\[\^(\w+)\]/, type: T.FOOTNOTE_REF, val: m => m[1] },
325
+
326
+ // Bold **text**
327
+ { re: /^\*\*([^*]+)\*\*/, type: T.BOLD, val: m => m[1] },
328
+
329
+ // Italic _text_
330
+ { re: /^_([^_]+)_/, type: T.ITALIC, val: m => m[1] },
331
+
332
+ // Underline __text__
333
+ { re: /^__([^_]+)__/, type: T.UNDERLINE, val: m => m[1] },
334
+
335
+ // Strikethrough ~~text~~
336
+ { re: /^~~([^~]+)~~/, type: T.STRIKETHROUGH, val: m => m[1] },
337
+
338
+ // Superscript ^^text^^
339
+ { re: /^\^\^([^^]+)\^\^/, type: T.SUPERSCRIPT, val: m => m[1] },
340
+
341
+ // Subscript ,,text,,
342
+ { re: /^,,([^,]+),,/, type: T.SUBSCRIPT, val: m => m[1] },
343
+
344
+ // Code inline `text`
345
+ { re: /^`([^`]+)`/, type: T.CODE_INLINE, val: m => m[1] },
346
+
347
+ // Highlight ==text==
348
+ { re: /^==([^=]+)==/, type: T.HIGHLIGHT, val: m => m[1] },
349
+
350
+ // Expression {expr}
351
+ { re: /^\{([^}]+)\}/, type: T.EXPRESSION, val: m => m[1] },
352
+ ];
353
+
354
+ while (remaining.length > 0) {
355
+ let matched = false;
356
+
357
+ for (const pat of patterns) {
358
+ const m = remaining.match(pat.re);
359
+ if (m) {
360
+ flush();
361
+ const extra = pat.extra ? pat.extra(m) : {};
362
+ tokens.push(this._tok(pat.type, pat.val(m), extra, lineNum));
363
+ remaining = remaining.slice(m[0].length);
364
+ matched = true;
365
+ break;
366
+ }
367
+ }
368
+
369
+ if (!matched) {
370
+ buffer += remaining[0];
371
+ remaining = remaining.slice(1);
372
+ }
373
+ }
374
+
375
+ flush();
376
+ return tokens;
377
+ }
378
+
379
+ // ── Helpers ────────────────────────────────────────────────────
380
+
381
+ /**
382
+ * Parse a props string like {type: bar, data: sales, animated: true}
383
+ * into a plain object.
384
+ */
385
+ _parseProps(str) {
386
+ const result = {};
387
+ // Remove outer braces
388
+ const inner = str.replace(/^\{|\}$/g, '').trim();
389
+ if (!inner) return result;
390
+
391
+ // Split on commas that are NOT inside quotes or brackets
392
+ const parts = this._splitProps(inner);
393
+
394
+ for (const part of parts) {
395
+ const colonIdx = part.indexOf(':');
396
+ if (colonIdx === -1) continue;
397
+ const key = part.slice(0, colonIdx).trim();
398
+ const value = part.slice(colonIdx + 1).trim();
399
+ result[key] = this._parseMetaValue(value);
400
+ }
401
+
402
+ return result;
403
+ }
404
+
405
+ _parseInlineProps(str) {
406
+ return this._parseProps(`{${str}}`);
407
+ }
408
+
409
+ /**
410
+ * Split a props string on commas, respecting nested brackets and quotes.
411
+ */
412
+ _splitProps(str) {
413
+ const parts = [];
414
+ let depth = 0;
415
+ let inQuote = false;
416
+ let quoteChar = '';
417
+ let current = '';
418
+
419
+ for (let i = 0; i < str.length; i++) {
420
+ const ch = str[i];
421
+
422
+ if (!inQuote && (ch === '"' || ch === "'")) {
423
+ inQuote = true;
424
+ quoteChar = ch;
425
+ current += ch;
426
+ } else if (inQuote && ch === quoteChar) {
427
+ inQuote = false;
428
+ current += ch;
429
+ } else if (!inQuote && (ch === '(' || ch === '[' || ch === '{')) {
430
+ depth++;
431
+ current += ch;
432
+ } else if (!inQuote && (ch === ')' || ch === ']' || ch === '}')) {
433
+ depth--;
434
+ current += ch;
435
+ } else if (!inQuote && depth === 0 && ch === ',') {
436
+ parts.push(current.trim());
437
+ current = '';
438
+ } else {
439
+ current += ch;
440
+ }
441
+ }
442
+
443
+ if (current.trim()) parts.push(current.trim());
444
+ return parts;
445
+ }
446
+
447
+ /**
448
+ * Parse a meta value — handles strings, booleans, numbers, arrays
449
+ */
450
+ _parseMetaValue(raw) {
451
+ if (!raw) return '';
452
+
453
+ // String with quotes
454
+ if ((raw.startsWith('"') && raw.endsWith('"')) ||
455
+ (raw.startsWith("'") && raw.endsWith("'"))) {
456
+ return raw.slice(1, -1);
457
+ }
458
+
459
+ // Array [a, b, c]
460
+ if (raw.startsWith('[') && raw.endsWith(']')) {
461
+ const inner = raw.slice(1, -1);
462
+ return this._splitProps(inner).map(v => this._parseMetaValue(v.trim()));
463
+ }
464
+
465
+ // Boolean
466
+ if (raw === 'true') return true;
467
+ if (raw === 'false') return false;
468
+
469
+ // Null / undefined
470
+ if (raw === 'null' || raw === 'never' || raw === '') return null;
471
+
472
+ // Number
473
+ if (!isNaN(raw) && raw !== '') return parseFloat(raw);
474
+
475
+ // Semver / date / plain string
476
+ return raw;
477
+ }
478
+
479
+ /**
480
+ * Extract {props} from end of a heading line
481
+ * "My Heading {color: red}" → { text: "My Heading", props: { color: "red" } }
482
+ */
483
+ _extractInlineProps(text) {
484
+ const match = text.match(/^(.*?)\s*(\{[^}]*\})\s*$/);
485
+ if (match) {
486
+ return {
487
+ text: match[1].trim(),
488
+ props: this._parseProps(match[2]),
489
+ };
490
+ }
491
+ return { text, props: {} };
492
+ }
493
+
494
+ /**
495
+ * Returns the current open context (meta/style/body/blockname)
496
+ */
497
+ _currentContext() {
498
+ // Walk backwards through tokens to find last open section/block
499
+ for (let i = this.tokens.length - 1; i >= 0; i--) {
500
+ const tok = this.tokens[i];
501
+ if (tok.type === T.SECTION_OPEN) return tok.value;
502
+ if (tok.type === T.SECTION_CLOSE) return 'root';
503
+ if (tok.type === T.BLOCK_OPEN) return tok.value;
504
+ if (tok.type === T.BLOCK_CLOSE) continue;
505
+ }
506
+ return 'root';
507
+ }
508
+
509
+ _tok(type, value, props, line) {
510
+ return { type, value, props: props || {}, line };
511
+ }
512
+
513
+ push(type, value, props, line) {
514
+ this.tokens.push(this._tok(type, value, props, line));
515
+ }
516
+ }
517
+
518
+ module.exports = Tokenizer;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ApexDoc Token Types
3
+ * Every piece of an .apx file becomes one of these tokens
4
+ */
5
+
6
+ const T = {
7
+ // ── Document Structure ──────────────────────────────────────────
8
+ SECTION_OPEN: 'SECTION_OPEN', // %%meta %%style %%body
9
+ SECTION_CLOSE: 'SECTION_CLOSE', // %%end (closing a top-level section)
10
+ BLOCK_OPEN: 'BLOCK_OPEN', // %%box {props}
11
+ BLOCK_CLOSE: 'BLOCK_CLOSE', // %%end (closing a block)
12
+ BLOCK_SELF_CLOSE: 'BLOCK_SELF_CLOSE', // %%chart {props}%%end
13
+
14
+ // ── Headings ────────────────────────────────────────────────────
15
+ HEADING: 'HEADING', // # ## ### ####
16
+
17
+ // ── Inline Text ─────────────────────────────────────────────────
18
+ TEXT: 'TEXT', // plain text
19
+ BOLD: 'BOLD', // **text**
20
+ ITALIC: 'ITALIC', // _text_
21
+ UNDERLINE: 'UNDERLINE', // __text__
22
+ STRIKETHROUGH: 'STRIKETHROUGH', // ~~text~~
23
+ SUPERSCRIPT: 'SUPERSCRIPT', // ^^text^^
24
+ SUBSCRIPT: 'SUBSCRIPT', // ,,text,,
25
+ CODE_INLINE: 'CODE_INLINE', // `text`
26
+ HIGHLIGHT: 'HIGHLIGHT', // ==text==
27
+ INLINE_STYLED: 'INLINE_STYLED', // [text]{props}
28
+ LINK: 'LINK', // [text](url)
29
+
30
+ // ── Math ────────────────────────────────────────────────────────
31
+ MATH_INLINE: 'MATH_INLINE', // $...$
32
+ MATH_BLOCK: 'MATH_BLOCK', // %%math...%%end
33
+
34
+ // ── Lists ───────────────────────────────────────────────────────
35
+ LIST_ITEM: 'LIST_ITEM', // - item
36
+ LIST_ITEM_ORDERED: 'LIST_ITEM_ORDERED', // 1. item
37
+ LIST_ITEM_TASK: 'LIST_ITEM_TASK', // - [x] / - [ ] / - [~]
38
+
39
+ // ── Logic ───────────────────────────────────────────────────────
40
+ SET: 'SET', // @set x = value
41
+ IF: 'IF', // @if condition
42
+ ELSEIF: 'ELSEIF', // @elseif condition
43
+ ELSE: 'ELSE', // @else
44
+ END_LOGIC: 'END_LOGIC', // @end
45
+ EACH: 'EACH', // @each item in list
46
+ KEYFRAME: 'KEYFRAME', // @keyframe name { }
47
+
48
+ // ── Variables & Expressions ─────────────────────────────────────
49
+ EXPRESSION: 'EXPRESSION', // {expr} {name} {fn()}
50
+
51
+ // ── Metadata ────────────────────────────────────────────────────
52
+ META_FIELD: 'META_FIELD', // key: value inside %%meta
53
+
54
+ // ── Style ───────────────────────────────────────────────────────
55
+ STYLE_VAR: 'STYLE_VAR', // --primary: #00f5d4
56
+ STYLE_DIRECTIVE: 'STYLE_DIRECTIVE', // @theme / @import / @media / @breakpoint
57
+ STYLE_BLOCK_OPEN: 'STYLE_BLOCK_OPEN', // @theme name {
58
+ STYLE_BLOCK_CLOSE: 'STYLE_BLOCK_CLOSE', // }
59
+
60
+ // ── Blockquote ──────────────────────────────────────────────────
61
+ BLOCKQUOTE: 'BLOCKQUOTE', // > text
62
+
63
+ // ── Horizontal Rule ─────────────────────────────────────────────
64
+ HR: 'HR', // --- === ~~~
65
+
66
+ // ── Footnote ────────────────────────────────────────────────────
67
+ FOOTNOTE_REF: 'FOOTNOTE_REF', // [^1]
68
+ FOOTNOTE_DEF: 'FOOTNOTE_DEF', // [^1]: content
69
+
70
+ // ── Special ─────────────────────────────────────────────────────
71
+ NEWLINE: 'NEWLINE',
72
+ EOF: 'EOF',
73
+ };
74
+
75
+ module.exports = T;