@kernlang/core 3.0.0 → 3.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 CHANGED
@@ -1,4 +1,250 @@
1
1
  let _parseWarnings = [];
2
+ // ── Tokenizer ────────────────────────────────────────────────────────────
3
+ function isIdentStart(ch) {
4
+ return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_';
5
+ }
6
+ function isIdentChar(ch) {
7
+ return isIdentStart(ch) || (ch >= '0' && ch <= '9') || ch === '-';
8
+ }
9
+ function isDigit(ch) {
10
+ return ch >= '0' && ch <= '9';
11
+ }
12
+ /** Character-by-character tokenizer for a single KERN line (after indent stripped). */
13
+ export function tokenizeLine(line) {
14
+ const tokens = [];
15
+ let i = 0;
16
+ while (i < line.length) {
17
+ const ch = line[i];
18
+ // Whitespace
19
+ if (ch === ' ' || ch === '\t') {
20
+ const start = i;
21
+ while (i < line.length && (line[i] === ' ' || line[i] === '\t'))
22
+ i++;
23
+ tokens.push({ kind: 'whitespace', value: line.slice(start, i), pos: start });
24
+ continue;
25
+ }
26
+ // Expression block {{ ... }}
27
+ if (ch === '{' && i + 1 < line.length && line[i + 1] === '{') {
28
+ const start = i;
29
+ i += 2;
30
+ let depth = 1;
31
+ while (i < line.length - 1 && depth > 0) {
32
+ if (line[i] === '{' && line[i + 1] === '{') {
33
+ depth++;
34
+ i += 2;
35
+ }
36
+ else if (line[i] === '}' && line[i + 1] === '}') {
37
+ depth--;
38
+ if (depth === 0)
39
+ break;
40
+ i += 2;
41
+ }
42
+ else
43
+ i++;
44
+ }
45
+ const inner = line.slice(start + 2, i).trim();
46
+ i += 2;
47
+ tokens.push({ kind: 'expr', value: inner, pos: start });
48
+ continue;
49
+ }
50
+ // Style block { ... } — find matching } respecting quotes
51
+ if (ch === '{') {
52
+ const start = i;
53
+ let inQuote = false;
54
+ let j = i + 1;
55
+ while (j < line.length) {
56
+ if (line[j] === '"')
57
+ inQuote = !inQuote;
58
+ if (!inQuote && line[j] === '}') {
59
+ j++;
60
+ break;
61
+ }
62
+ j++;
63
+ }
64
+ tokens.push({ kind: 'style', value: line.slice(start + 1, j - 1), pos: start });
65
+ i = j;
66
+ continue;
67
+ }
68
+ // Quoted string "..."
69
+ if (ch === '"') {
70
+ const start = i;
71
+ i++;
72
+ while (i < line.length && line[i] !== '"')
73
+ i++;
74
+ const inner = line.slice(start + 1, i);
75
+ i++;
76
+ tokens.push({ kind: 'quoted', value: inner, pos: start });
77
+ continue;
78
+ }
79
+ // Theme ref $name
80
+ if (ch === '$' && i + 1 < line.length && isIdentStart(line[i + 1])) {
81
+ const start = i;
82
+ i++;
83
+ while (i < line.length && isIdentChar(line[i]))
84
+ i++;
85
+ tokens.push({ kind: 'themeRef', value: line.slice(start + 1, i), pos: start });
86
+ continue;
87
+ }
88
+ // Equals
89
+ if (ch === '=') {
90
+ tokens.push({ kind: 'equals', value: '=', pos: i });
91
+ i++;
92
+ continue;
93
+ }
94
+ // Comma
95
+ if (ch === ',') {
96
+ tokens.push({ kind: 'comma', value: ',', pos: i });
97
+ i++;
98
+ continue;
99
+ }
100
+ // Slash-prefixed path: /something
101
+ if (ch === '/') {
102
+ const start = i;
103
+ while (i < line.length && line[i] !== ' ' && line[i] !== '\t' && line[i] !== '{' && line[i] !== '$')
104
+ i++;
105
+ tokens.push({ kind: 'slash', value: line.slice(start, i), pos: start });
106
+ continue;
107
+ }
108
+ // Number (pure digits)
109
+ if (isDigit(ch)) {
110
+ const start = i;
111
+ while (i < line.length && isDigit(line[i]))
112
+ i++;
113
+ tokens.push({ kind: 'number', value: line.slice(start, i), pos: start });
114
+ continue;
115
+ }
116
+ // Identifier: [A-Za-z_][A-Za-z0-9_-]*
117
+ // Handles evolved: prefix (evolved:keyword → strips prefix, returns keyword)
118
+ if (isIdentStart(ch)) {
119
+ const start = i;
120
+ while (i < line.length && isIdentChar(line[i]))
121
+ i++;
122
+ if (line[i] === ':' && line.slice(start, i) === 'evolved' && i + 1 < line.length && isIdentStart(line[i + 1])) {
123
+ i++;
124
+ const nameStart = i;
125
+ while (i < line.length && isIdentChar(line[i]))
126
+ i++;
127
+ tokens.push({ kind: 'identifier', value: line.slice(nameStart, i), pos: start });
128
+ }
129
+ else {
130
+ tokens.push({ kind: 'identifier', value: line.slice(start, i), pos: start });
131
+ }
132
+ continue;
133
+ }
134
+ // Unknown character
135
+ tokens.push({ kind: 'unknown', value: ch, pos: i });
136
+ i++;
137
+ }
138
+ return tokens;
139
+ }
140
+ // ── Token stream ─────────────────────────────────────────────────────────
141
+ // Opus: class-based cursor. Codex contribution: consumeAnyValue for evolved hints.
142
+ class TokenStream {
143
+ tokens;
144
+ idx = 0;
145
+ constructor(tokens) { this.tokens = tokens; }
146
+ peek() { return this.tokens[this.idx]; }
147
+ next() { return this.tokens[this.idx++]; }
148
+ done() { return this.idx >= this.tokens.length; }
149
+ position() { return this.idx; }
150
+ setPosition(pos) { this.idx = pos; }
151
+ skipWS() {
152
+ while (this.idx < this.tokens.length && this.tokens[this.idx].kind === 'whitespace')
153
+ this.idx++;
154
+ }
155
+ /** Try to consume an identifier. Returns its value or null. */
156
+ tryIdent() {
157
+ if (this.idx < this.tokens.length && this.tokens[this.idx].kind === 'identifier') {
158
+ return this.tokens[this.idx++].value;
159
+ }
160
+ return null;
161
+ }
162
+ /** Try to consume a number token. Returns its value or null. */
163
+ tryNumber() {
164
+ if (this.idx < this.tokens.length && this.tokens[this.idx].kind === 'number') {
165
+ return this.tokens[this.idx++].value;
166
+ }
167
+ return null;
168
+ }
169
+ /** Check if the next non-WS token is an identifier followed by '='. */
170
+ isKeyValue() {
171
+ let j = this.idx;
172
+ while (j < this.tokens.length && this.tokens[j].kind === 'whitespace')
173
+ j++;
174
+ if (j >= this.tokens.length || this.tokens[j].kind !== 'identifier')
175
+ return false;
176
+ return j + 1 < this.tokens.length && this.tokens[j + 1].kind === 'equals';
177
+ }
178
+ /** Check if any remaining token contains '='. */
179
+ hasEquals() {
180
+ for (let j = this.idx; j < this.tokens.length; j++) {
181
+ if (this.tokens[j].kind === 'equals')
182
+ return true;
183
+ }
184
+ return false;
185
+ }
186
+ /** Check if there are more non-whitespace tokens. */
187
+ hasMore() {
188
+ let j = this.idx;
189
+ while (j < this.tokens.length && this.tokens[j].kind === 'whitespace')
190
+ j++;
191
+ return j < this.tokens.length;
192
+ }
193
+ /** Get remaining raw text from current position (for fallback / params). */
194
+ remainingRaw(line) {
195
+ if (this.idx >= this.tokens.length)
196
+ return '';
197
+ const startPos = this.tokens[this.idx].pos;
198
+ this.idx = this.tokens.length;
199
+ return line.slice(startPos);
200
+ }
201
+ /** Consume any single non-whitespace token as a value (for evolved positional args). */
202
+ consumeAnyValue() {
203
+ this.skipWS();
204
+ const tok = this.peek();
205
+ if (!tok || tok.kind === 'whitespace')
206
+ return undefined;
207
+ return this.next();
208
+ }
209
+ }
210
+ // ── Prop parsing (extracted from Codex's parsePropToken pattern) ──────────
211
+ /** Map a value token to its JS representation. */
212
+ function tokenValue(tok) {
213
+ if (tok.kind === 'expr')
214
+ return { __expr: true, code: tok.value };
215
+ if (tok.kind === 'quoted')
216
+ return tok.value;
217
+ return tok.value;
218
+ }
219
+ /** Try to parse a key=value prop from the stream. Returns true if consumed. */
220
+ function parseProp(s, props) {
221
+ if (!s.isKeyValue())
222
+ return false;
223
+ s.skipWS();
224
+ const key = s.next().value; // identifier
225
+ s.next(); // =
226
+ const valTok = s.peek();
227
+ if (!valTok || valTok.kind === 'whitespace') {
228
+ props[key] = '';
229
+ return true;
230
+ }
231
+ // key={{expr}} or key="quoted"
232
+ if (valTok.kind === 'expr' || valTok.kind === 'quoted') {
233
+ props[key] = tokenValue(s.next());
234
+ return true;
235
+ }
236
+ // key=bareValue — collect tokens up to next WS/style/themeRef
237
+ let value = '';
238
+ while (!s.done()) {
239
+ const vt = s.peek();
240
+ if (vt.kind === 'whitespace' || vt.kind === 'style' || vt.kind === 'themeRef')
241
+ break;
242
+ value += vt.value;
243
+ s.next();
244
+ }
245
+ props[key] = value;
246
+ return true;
247
+ }
2
248
  const MULTILINE_BLOCK_TYPES = new Set(['logic', 'handler', 'cleanup', 'body']);
3
249
  const _parserHints = new Map();
4
250
  /** Register parser hints for an evolved node type. */
@@ -18,328 +264,198 @@ export function unregisterParserHints(keyword) {
18
264
  }
19
265
  /** Clear all parser hints (for test isolation). */
20
266
  export function clearParserHints() {
21
- // Remove evolved entries from MULTILINE_BLOCK_TYPES, keep core ones
22
267
  for (const [keyword, hints] of _parserHints) {
23
268
  if (hints.multilineBlock)
24
269
  MULTILINE_BLOCK_TYPES.delete(keyword);
25
270
  }
26
271
  _parserHints.clear();
27
272
  }
273
+ /** Consume a bare identifier into props if it's not a key=value pair. */
274
+ function consumeBareIdent(s, props, propName) {
275
+ s.skipWS();
276
+ if (s.isKeyValue())
277
+ return;
278
+ const id = s.tryIdent();
279
+ if (id)
280
+ props[propName] = id;
281
+ }
282
+ const KEYWORD_HANDLERS = new Map([
283
+ ['theme', (s, props) => {
284
+ consumeBareIdent(s, props, 'name');
285
+ }],
286
+ ['import', (s, props) => {
287
+ s.skipWS();
288
+ const pos = s.position();
289
+ const id = s.tryIdent();
290
+ if (id === 'default') {
291
+ if (!s.done() && s.peek()?.kind !== 'equals') {
292
+ props.default = true;
293
+ s.skipWS();
294
+ }
295
+ else if (s.peek()?.kind === 'equals') {
296
+ s.setPosition(pos);
297
+ return;
298
+ }
299
+ else {
300
+ props.default = true;
301
+ return;
302
+ }
303
+ }
304
+ else if (id) {
305
+ s.setPosition(pos);
306
+ }
307
+ if (!s.isKeyValue()) {
308
+ s.skipWS();
309
+ const name = s.tryIdent();
310
+ if (name)
311
+ props.name = name;
312
+ }
313
+ }],
314
+ ['route', (s, props) => {
315
+ s.skipWS();
316
+ const pos = s.position();
317
+ const verb = s.tryIdent();
318
+ if (verb && /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/i.test(verb)) {
319
+ props.method = verb.toLowerCase();
320
+ s.skipWS();
321
+ const tok = s.peek();
322
+ if (tok && tok.kind === 'slash') {
323
+ props.path = tok.value;
324
+ s.next();
325
+ }
326
+ }
327
+ else if (verb) {
328
+ s.setPosition(pos);
329
+ }
330
+ }],
331
+ ['params', (s, props, content) => {
332
+ s.skipWS();
333
+ const remaining = s.remainingRaw(content);
334
+ if (remaining.length > 0) {
335
+ const items = [];
336
+ const parts = remaining.split(',').map(p => p.trim()).filter(Boolean);
337
+ for (const part of parts) {
338
+ const m = part.match(/^([A-Za-z_]\w*):([A-Za-z_]\w*(?:\[\])?)(?:\s*=\s*(.+))?$/);
339
+ if (m) {
340
+ const item = { name: m[1], type: m[2] };
341
+ if (m[3] !== undefined)
342
+ item.default = m[3].trim();
343
+ items.push(item);
344
+ }
345
+ }
346
+ props.items = items;
347
+ }
348
+ }],
349
+ ['auth', (s, props) => { consumeBareIdent(s, props, 'mode'); }],
350
+ ['validate', (s, props) => { consumeBareIdent(s, props, 'schema'); }],
351
+ ['error', (s, props) => {
352
+ s.skipWS();
353
+ const num = s.tryNumber();
354
+ if (num) {
355
+ props.status = parseInt(num, 10);
356
+ s.skipWS();
357
+ const tok = s.peek();
358
+ if (tok && tok.kind === 'quoted') {
359
+ props.message = tok.value;
360
+ s.next();
361
+ }
362
+ }
363
+ }],
364
+ ['derive', (s, props) => { consumeBareIdent(s, props, 'name'); }],
365
+ ['guard', (s, props) => { consumeBareIdent(s, props, 'name'); }],
366
+ ['effect', (s, props) => { consumeBareIdent(s, props, 'name'); }],
367
+ ['strategy', (s, props) => { consumeBareIdent(s, props, 'name'); }],
368
+ ['trigger', (s, props) => { consumeBareIdent(s, props, 'kind'); }],
369
+ ['respond', (s, props) => {
370
+ s.skipWS();
371
+ const num = s.tryNumber();
372
+ if (num)
373
+ props.status = parseInt(num, 10);
374
+ }],
375
+ ['middleware', (s, props, content) => {
376
+ s.skipWS();
377
+ if (!s.hasMore())
378
+ return;
379
+ if (s.hasEquals())
380
+ return;
381
+ const remaining = s.remainingRaw(content).trim();
382
+ if (remaining.length > 0) {
383
+ const names = remaining.split(',').map(n => n.trim()).filter(Boolean);
384
+ if (names.length > 1) {
385
+ props.names = names;
386
+ }
387
+ else if (names.length === 1) {
388
+ props.name = names[0];
389
+ }
390
+ }
391
+ }],
392
+ ]);
393
+ // ── parseLine (token-based) ──────────────────────────────────────────────
28
394
  function parseLine(raw, lineNum) {
29
395
  if (raw.trim() === '')
30
396
  return null;
31
397
  const indent = raw.search(/\S/);
32
- let rest = raw.slice(indent);
398
+ const content = raw.slice(indent);
33
399
  const col = indent + 1;
34
- // Extract type — supports `evolved:keyword` namespace prefix as escape hatch
35
- const typeMatch = rest.match(/^(?:evolved:)?([A-Za-z_][A-Za-z0-9_-]*)/);
36
- if (!typeMatch)
400
+ const tokens = tokenizeLine(content);
401
+ const s = new TokenStream(tokens);
402
+ // First token must be an identifier (the node type)
403
+ const typeToken = s.tryIdent();
404
+ if (!typeToken)
37
405
  return null;
38
- const type = typeMatch[1];
39
- rest = rest.slice(typeMatch[0].length);
406
+ const type = typeToken;
40
407
  const props = {};
41
408
  const styles = {};
42
409
  const pseudoStyles = {};
43
410
  const themeRefs = [];
44
411
  // ── Evolved node parser hints (v4) ──────────────────────────────────
45
- // Check if this type has parser hints from graduated evolved nodes.
46
- // Must run BEFORE core special cases to allow evolved nodes to use
47
- // positional args, bare words, etc.
48
412
  const evolvedHints = _parserHints.get(type);
49
413
  if (evolvedHints) {
50
- // Positional args: "api-route GET /users" → props.method="GET", props.path="/users"
51
414
  if (evolvedHints.positionalArgs) {
52
415
  for (const argName of evolvedHints.positionalArgs) {
53
- rest = rest.replace(/^ +/, '');
54
- const argMatch = rest.match(/^(\S+)/);
55
- if (argMatch) {
56
- props[argName] = argMatch[1];
57
- rest = rest.slice(argMatch[0].length);
58
- }
416
+ const tok = s.consumeAnyValue();
417
+ if (tok)
418
+ props[argName] = tok.value;
59
419
  }
60
420
  }
61
- // Bare word: "auth-guard admin" → props.name="admin"
62
421
  if (evolvedHints.bareWord) {
63
- rest = rest.replace(/^ +/, '');
64
- const bwMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
65
- if (bwMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_-]*=/)) {
66
- props[evolvedHints.bareWord] = bwMatch[1];
67
- rest = rest.slice(bwMatch[0].length);
68
- }
69
- }
70
- }
71
- // Special: theme nodes have a bare name after the type: "theme bar {h:8}"
72
- if (type === 'theme') {
73
- rest = rest.replace(/^ +/, '');
74
- const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
75
- if (nameMatch) {
76
- props.name = nameMatch[1];
77
- rest = rest.slice(nameMatch[0].length);
78
- }
79
- }
80
- // Special: import nodes support bare words for name and optional "default" flag
81
- // Syntax: "import [default] <name> from=<path>"
82
- if (type === 'import') {
83
- rest = rest.replace(/^ +/, '');
84
- // Check for "default" keyword
85
- if (rest.startsWith('default')) {
86
- const afterDefault = rest.slice(7);
87
- if (afterDefault.length === 0 || afterDefault[0] === ' ') {
88
- props.default = true;
89
- rest = afterDefault.replace(/^ +/, '');
90
- }
91
- }
92
- // Capture the import name (bare word before from=)
93
- const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
94
- if (nameMatch && !rest.startsWith('from=')) {
95
- props.name = nameMatch[1];
96
- rest = rest.slice(nameMatch[0].length);
97
- }
98
- }
99
- // Special: route v3 positional syntax — "route GET /api/users"
100
- if (type === 'route') {
101
- rest = rest.replace(/^ +/, '');
102
- const verbMatch = rest.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i);
103
- if (verbMatch) {
104
- props.method = verbMatch[1].toLowerCase();
105
- rest = rest.slice(verbMatch[0].length);
106
- const pathMatch = rest.match(/^(\/\S*)/);
107
- if (pathMatch) {
108
- props.path = pathMatch[1];
109
- rest = rest.slice(pathMatch[0].length);
110
- }
111
- }
112
- }
113
- // Special: params — "params page:number = 1, limit:number = 20"
114
- if (type === 'params') {
115
- rest = rest.replace(/^ +/, '');
116
- if (rest.length > 0) {
117
- const items = [];
118
- const parts = rest.split(',').map(s => s.trim()).filter(Boolean);
119
- for (const part of parts) {
120
- const m = part.match(/^([A-Za-z_]\w*):([A-Za-z_]\w*(?:\[\])?)(?:\s*=\s*(.+))?$/);
121
- if (m) {
122
- const item = { name: m[1], type: m[2] };
123
- if (m[3] !== undefined)
124
- item.default = m[3].trim();
125
- items.push(item);
126
- }
127
- }
128
- props.items = items;
129
- rest = '';
130
- }
131
- }
132
- // Special: auth — "auth required" / "auth optional" / "auth bearer"
133
- if (type === 'auth') {
134
- rest = rest.replace(/^ +/, '');
135
- const modeMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
136
- if (modeMatch) {
137
- props.mode = modeMatch[1];
138
- rest = rest.slice(modeMatch[0].length);
139
- }
140
- }
141
- // Special: validate — "validate UserQuerySchema"
142
- if (type === 'validate') {
143
- rest = rest.replace(/^ +/, '');
144
- const schemaMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
145
- if (schemaMatch) {
146
- props.schema = schemaMatch[1];
147
- rest = rest.slice(schemaMatch[0].length);
148
- }
149
- }
150
- // Special: error with numeric status — "error 401 "Unauthorized""
151
- if (type === 'error') {
152
- rest = rest.replace(/^ +/, '');
153
- const statusMatch = rest.match(/^(\d{3})/);
154
- if (statusMatch) {
155
- props.status = parseInt(statusMatch[1], 10);
156
- rest = rest.slice(statusMatch[0].length).replace(/^ +/, '');
157
- if (rest.startsWith('"')) {
158
- const endQuote = rest.indexOf('"', 1);
159
- if (endQuote > 0) {
160
- props.message = rest.slice(1, endQuote);
161
- rest = rest.slice(endQuote + 1);
162
- }
163
- }
164
- }
165
- }
166
- // Special: derive with bare name — "derive user expr={{...}}"
167
- if (type === 'derive') {
168
- rest = rest.replace(/^ +/, '');
169
- const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
170
- // Only consume bare name if it's NOT a key=value pair (e.g., "derive name=foo" or "derive from=x")
171
- if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
172
- props.name = nameMatch[1];
173
- rest = rest.slice(nameMatch[0].length);
174
- }
175
- }
176
- // Special: guard with bare name — "guard exists expr={{...}} else=404"
177
- if (type === 'guard') {
178
- rest = rest.replace(/^ +/, '');
179
- const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
180
- if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
181
- props.name = nameMatch[1];
182
- rest = rest.slice(nameMatch[0].length);
183
- }
184
- }
185
- // Special: effect with bare name — "effect fetchUsers"
186
- if (type === 'effect') {
187
- rest = rest.replace(/^ +/, '');
188
- const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
189
- if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
190
- props.name = nameMatch[1];
191
- rest = rest.slice(nameMatch[0].length);
192
- }
193
- }
194
- // Special: strategy with bare name — "strategy read-through"
195
- if (type === 'strategy') {
196
- rest = rest.replace(/^ +/, '');
197
- const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
198
- if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_-]*=/)) {
199
- props.name = nameMatch[1];
200
- rest = rest.slice(nameMatch[0].length);
201
- }
202
- }
203
- // Special: trigger with bare type — "trigger db query=..."
204
- if (type === 'trigger') {
205
- rest = rest.replace(/^ +/, '');
206
- const typeMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
207
- if (typeMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
208
- props.kind = typeMatch[1];
209
- rest = rest.slice(typeMatch[0].length);
210
- }
211
- }
212
- // Special: respond with optional status — "respond 200 json=users" / "respond redirect=/login"
213
- if (type === 'respond') {
214
- rest = rest.replace(/^ +/, '');
215
- const statusMatch = rest.match(/^(\d{3})/);
216
- if (statusMatch) {
217
- props.status = parseInt(statusMatch[1], 10);
218
- rest = rest.slice(statusMatch[0].length);
219
- }
220
- }
221
- // Special: middleware bare word list — "middleware rateLimit, cors"
222
- if (type === 'middleware') {
223
- rest = rest.replace(/^ +/, '');
224
- if (rest.length > 0 && !rest.includes('=')) {
225
- const names = rest.split(',').map(s => s.trim()).filter(Boolean);
226
- if (names.length > 1) {
227
- props.names = names;
228
- }
229
- else if (names.length === 1) {
230
- props.name = names[0];
422
+ s.skipWS();
423
+ if (!s.isKeyValue()) {
424
+ const id = s.tryIdent();
425
+ if (id)
426
+ props[evolvedHints.bareWord] = id;
231
427
  }
232
- rest = '';
233
428
  }
234
429
  }
235
- // Parse the remainder: props, style blocks, theme refs
236
- while (rest.length > 0) {
237
- rest = rest.replace(/^ +/, '');
238
- if (rest.length === 0)
430
+ // ── Keyword-specific handling ──────────────────────────────────────
431
+ const handler = KEYWORD_HANDLERS.get(type);
432
+ if (handler)
433
+ handler(s, props, content);
434
+ // ── Generic prop/style/theme parsing ───────────────────────────────
435
+ while (!s.done()) {
436
+ s.skipWS();
437
+ if (s.done())
239
438
  break;
240
- // Style block — find matching } respecting quotes
241
- if (rest[0] === '{') {
242
- let close = -1;
243
- let inQuote = false;
244
- for (let j = 1; j < rest.length; j++) {
245
- if (rest[j] === '"')
246
- inQuote = !inQuote;
247
- if (!inQuote && rest[j] === '}') {
248
- close = j;
249
- break;
250
- }
251
- }
252
- if (close === -1)
253
- break;
254
- const block = rest.slice(1, close);
255
- parseStyleBlock(block, styles, pseudoStyles);
256
- rest = rest.slice(close + 1);
439
+ const tok = s.peek();
440
+ // Style block
441
+ if (tok.kind === 'style') {
442
+ parseStyleBlock(tok.value, styles, pseudoStyles);
443
+ s.next();
257
444
  continue;
258
445
  }
259
446
  // Theme ref
260
- if (rest[0] === '$') {
261
- const refMatch = rest.match(/^\$([A-Za-z_][A-Za-z0-9_-]*)/);
262
- if (refMatch) {
263
- themeRefs.push(refMatch[1]);
264
- rest = rest.slice(refMatch[0].length);
265
- continue;
266
- }
267
- }
268
- // Prop: key={{ expression }}
269
- const exprPropMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)=\{\{/);
270
- if (exprPropMatch) {
271
- const key = exprPropMatch[1];
272
- rest = rest.slice(exprPropMatch[0].length);
273
- // Find matching }}
274
- let depth = 1;
275
- let j = 0;
276
- for (; j < rest.length - 1; j++) {
277
- if (rest[j] === '{' && rest[j + 1] === '{') {
278
- depth++;
279
- j++;
280
- }
281
- else if (rest[j] === '}' && rest[j + 1] === '}') {
282
- depth--;
283
- j++;
284
- if (depth === 0)
285
- break;
286
- }
287
- }
288
- const expr = rest.slice(0, j - 1).trim();
289
- rest = rest.slice(j + 1);
290
- props[key] = { __expr: true, code: expr };
447
+ if (tok.kind === 'themeRef') {
448
+ themeRefs.push(tok.value);
449
+ s.next();
291
450
  continue;
292
451
  }
293
- // Prop: key=value or key="quoted value"
294
- const propMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)=/);
295
- if (propMatch) {
296
- const key = propMatch[1];
297
- rest = rest.slice(propMatch[0].length);
298
- let value;
299
- if (rest[0] === '"') {
300
- const endQuote = rest.indexOf('"', 1);
301
- value = rest.slice(1, endQuote);
302
- rest = rest.slice(endQuote + 1);
303
- }
304
- else if (rest.startsWith('{{')) {
305
- // Bare expression without key= prefix (e.g. value={{ x }})
306
- rest = rest.slice(2);
307
- let depth = 1;
308
- let j = 0;
309
- for (; j < rest.length - 1; j++) {
310
- if (rest[j] === '{' && rest[j + 1] === '{') {
311
- depth++;
312
- j++;
313
- }
314
- else if (rest[j] === '}' && rest[j + 1] === '}') {
315
- depth--;
316
- j++;
317
- if (depth === 0)
318
- break;
319
- }
320
- }
321
- const expr = rest.slice(0, j - 1).trim();
322
- rest = rest.slice(j + 1);
323
- props[key] = { __expr: true, code: expr };
324
- continue;
325
- }
326
- else {
327
- const endMatch = rest.match(/^[^\s{$]+/);
328
- value = endMatch ? endMatch[0] : '';
329
- rest = rest.slice(value.length);
330
- }
331
- props[key] = value;
332
- continue;
333
- }
334
- // Unknown token — collect as warning, skip to next whitespace
335
- const skipped = rest.match(/^\S+/);
336
- if (skipped) {
337
- const errCol = col + (raw.length - rest.length);
338
- _parseWarnings.push(`Unexpected token "${skipped[0]}" at line ${lineNum}:${errCol}`);
339
- rest = rest.slice(skipped[0].length);
452
+ // Key=value prop (extracted helper from Codex)
453
+ if (parseProp(s, props))
340
454
  continue;
341
- }
342
- break;
455
+ // Unknown token — skip with warning
456
+ const skipped = s.next();
457
+ const errCol = col + skipped.pos;
458
+ _parseWarnings.push(`Unexpected token "${skipped.value}" at line ${lineNum}:${errCol}`);
343
459
  }
344
460
  return {
345
461
  indent: indent / 2,
@@ -351,6 +467,7 @@ function parseLine(raw, lineNum) {
351
467
  loc: { line: lineNum, col },
352
468
  };
353
469
  }
470
+ // ── Style block parsing (unchanged) ──────────────────────────────────────
354
471
  function splitStylePairs(block) {
355
472
  const pairs = [];
356
473
  let current = '';
@@ -400,7 +517,6 @@ function parseStyleBlock(block, styles, pseudoStyles) {
400
517
  if (quotedKeyMatch) {
401
518
  const key = quotedKeyMatch[1];
402
519
  let value = quotedKeyMatch[2].trim();
403
- // Strip surrounding quotes from value if present
404
520
  if (value.startsWith('"') && value.endsWith('"')) {
405
521
  value = value.slice(1, -1);
406
522
  }
@@ -412,7 +528,6 @@ function parseStyleBlock(block, styles, pseudoStyles) {
412
528
  if (colonIdx > 0) {
413
529
  const key = pair.slice(0, colonIdx).trim();
414
530
  let value = pair.slice(colonIdx + 1).trim();
415
- // Strip surrounding quotes from value if present
416
531
  if (value.startsWith('"') && value.endsWith('"')) {
417
532
  value = value.slice(1, -1);
418
533
  }
@@ -421,8 +536,6 @@ function parseStyleBlock(block, styles, pseudoStyles) {
421
536
  }
422
537
  }
423
538
  function expandMinified(source) {
424
- // Detect minified S-expression format: node(child1,child2)
425
- // Convert to indented format for the standard parser
426
539
  if (!source.includes('(') || source.split('\n').length > 2)
427
540
  return source;
428
541
  const result = [];
@@ -483,7 +596,6 @@ function expandMinified(source) {
483
596
  export function getParseWarnings() { return [..._parseWarnings]; }
484
597
  export function parse(source) {
485
598
  _parseWarnings = [];
486
- // Handle minified S-expression format
487
599
  source = expandMinified(source);
488
600
  const lines = source.split('\n');
489
601
  const parsed = [];
@@ -495,7 +607,6 @@ export function parse(source) {
495
607
  const codeLines = [];
496
608
  const startLine = i + 1;
497
609
  const blockOpen = `${multilineType} <<<`;
498
- // Check if inline close on same line
499
610
  const afterOpen = trimmed.slice(blockOpen.length);
500
611
  if (afterOpen.includes('>>>')) {
501
612
  codeLines.push(afterOpen.split('>>>')[0]);
@@ -506,7 +617,6 @@ export function parse(source) {
506
617
  codeLines.push(lines[i]);
507
618
  i++;
508
619
  }
509
- // Capture text before >>> on closing line
510
620
  if (i < lines.length) {
511
621
  const closeLine = lines[i];
512
622
  const closeIdx = closeLine.indexOf('>>>');
@@ -547,13 +657,11 @@ export function parse(source) {
547
657
  node.props.themeRefs = p.themeRefs;
548
658
  return node;
549
659
  }
550
- // Build tree using indent levels
551
660
  const root = toNode(parsed[0]);
552
661
  const stack = [{ node: root, indent: parsed[0].indent }];
553
662
  for (let i = 1; i < parsed.length; i++) {
554
663
  const p = parsed[i];
555
664
  const node = toNode(p);
556
- // Pop stack until we find a parent at a lower indent level
557
665
  while (stack.length > 1 && stack[stack.length - 1].indent >= p.indent) {
558
666
  stack.pop();
559
667
  }