@nml-lang/compiler-ts 2.2.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/src/parser.ts ADDED
@@ -0,0 +1,1108 @@
1
+ /**
2
+ * NML Parser
3
+ * Ports nml_parse.py's build_ast, _expand_components_pass,
4
+ * _inject_slot, and related helpers to TypeScript.
5
+ *
6
+ * Every ASTNode carries loc: { line, column } sourced from the lexer.
7
+ */
8
+
9
+ import { tokenize, NMLLexerError, type SourceLocation } from "./lexer.js";
10
+ import { createHash } from "crypto";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Public types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface SourceLocation2 extends SourceLocation {}
17
+
18
+ export interface ASTNode {
19
+ element: string;
20
+ attributes: Record<string, string | boolean | string[]>;
21
+ content: string;
22
+ children: ASTNode[];
23
+ multiline_trigger: boolean;
24
+ multiline_content: string[];
25
+ loc: SourceLocation;
26
+ /** Internal: node-level context override (props) */
27
+ __context__?: Record<string, unknown>;
28
+ /** Set by postProcessConditionalsPass on @if nodes: the else-branch children */
29
+ elseBranch?: ASTNode[];
30
+ }
31
+
32
+ export type ComponentMap = Record<string, ASTNode[]>;
33
+ export type GlobalStyles = Record<string, string>;
34
+
35
+ export class NMLParserError extends Error {
36
+ loc: SourceLocation;
37
+ constructor(message: string, loc: SourceLocation = { line: 0, column: 0 }) {
38
+ super(message);
39
+ this.name = "NMLParserError";
40
+ this.loc = loc;
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Constants mirrored from nml_parse.py
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const INDENT_WIDTH = 4;
49
+
50
+ const VOID_ELEMENTS = new Set([
51
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
52
+ "link", "meta", "param", "source", "track", "wbr",
53
+ ]);
54
+
55
+ const BOOLEAN_ATTRIBUTES = new Set([
56
+ "allowfullscreen", "async", "autofocus", "autoplay", "checked",
57
+ "controls", "crossorigin", "default", "defer", "disabled",
58
+ "formnovalidate", "hidden", "ismap", "loop", "multiple",
59
+ "muted", "nomodule", "novalidate", "open", "readonly",
60
+ "required", "reversed", "selected",
61
+ ]);
62
+
63
+ const WHITELISTED_ATTRS = new Set([
64
+ "id", "name", "type", "href", "src", "action", "method",
65
+ "placeholder", "value", "for", "rel", "charset", "content",
66
+ "lang", "dir", "tabindex", "role", "target", "download",
67
+ "enctype", "accept", "autocomplete", "min", "max", "step",
68
+ "pattern", "rows", "cols", "colspan", "rowspan", "scope",
69
+ "headers", "width", "height", "alt", "title", "loading",
70
+ "decoding", "fetchpriority", "integrity", "crossorigin",
71
+ "nonce", "referrerpolicy", "sandbox", "srcdoc", "srclang",
72
+ "poster", "preload", "autoplay", "loop", "muted", "controls", "playsinline",
73
+ ]);
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Line parser (equivalent to parse_line in Python)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export function parseLine(rawContent: string, loc: SourceLocation): ASTNode {
80
+ const line = rawContent.trim();
81
+
82
+ // Content pipe
83
+ if (line.startsWith("|")) {
84
+ return makeNode("__text__", {}, line.slice(1).trim(), [], loc);
85
+ }
86
+
87
+ // Comments (should not reach here normally, handled by lexer)
88
+ if (line.startsWith("//")) {
89
+ return makeNode("__comment__", {}, "", [], loc);
90
+ }
91
+
92
+ // Determine the element name (everything before the first unquoted dot)
93
+ let element = "";
94
+ let rest = "";
95
+
96
+ if (line.startsWith("@")) {
97
+ // Could be @define.Name, @slot, @slot.name, @style:, @ComponentName...
98
+ const spaceIdx = line.indexOf(" ");
99
+ const candidate = spaceIdx === -1 ? line : line.slice(0, spaceIdx);
100
+
101
+ if (candidate.startsWith("@define.")) {
102
+ element = "@define";
103
+ const name = candidate.slice(8);
104
+ return makeNode("@define", { class: name }, "", [], loc);
105
+ }
106
+
107
+ if (candidate === "@slot" || candidate.startsWith("@slot.")) {
108
+ const slotName = candidate.startsWith("@slot.") ? candidate.slice(6) : undefined;
109
+ const attrs: Record<string, string | boolean | string[]> = {};
110
+ if (slotName) attrs["name"] = slotName;
111
+ return makeNode("@slot", attrs, "", [], loc);
112
+ }
113
+
114
+ if (candidate === "@style:" || candidate === "@style") {
115
+ const node = makeNode("@style", {}, "", [], loc);
116
+ node.multiline_trigger = candidate.endsWith(":");
117
+ return node;
118
+ }
119
+
120
+ if (candidate === "@include" || candidate.startsWith("@include(")) {
121
+ // @include("path/to/file.nml")
122
+ // @include("path/to/file.nml", { key: "value" })
123
+ const afterAt = line.slice(line.indexOf("("));
124
+ return parseIncludeDirective(afterAt, loc);
125
+ }
126
+
127
+ // @each(items as item)
128
+ if (candidate === "@each" || candidate.startsWith("@each(")) {
129
+ const parenContent = extractParenContent(line);
130
+ const asMatch = parenContent?.match(/^([\w.]+)\s+as\s+([\w]+)$/);
131
+ if (!asMatch) {
132
+ throw new NMLParserError(
133
+ `@each syntax error: expected '@each(items as item)', got '${line}'`,
134
+ loc
135
+ );
136
+ }
137
+ return makeNode("@each", { items: asMatch[1], as: asMatch[2] }, "", [], loc);
138
+ }
139
+
140
+ if (candidate === "@endeach") {
141
+ return makeNode("@endeach", {}, "", [], loc);
142
+ }
143
+
144
+ // @if(condition)
145
+ if (candidate === "@if" || candidate.startsWith("@if(")) {
146
+ const condition = extractParenContent(line) ?? "";
147
+ return makeNode("@if", { condition }, "", [], loc);
148
+ }
149
+
150
+ if (candidate === "@else") {
151
+ return makeNode("@else", {}, "", [], loc);
152
+ }
153
+
154
+ if (candidate === "@endif") {
155
+ return makeNode("@endif", {}, "", [], loc);
156
+ }
157
+
158
+ // @ComponentName possibly with attributes
159
+ const dotIdx = findFirstUnquotedDot(candidate);
160
+ if (dotIdx === -1) {
161
+ element = candidate;
162
+ rest = spaceIdx !== -1 ? line.slice(spaceIdx + 1) : "";
163
+ } else {
164
+ element = candidate.slice(0, dotIdx);
165
+ rest = candidate.slice(dotIdx) + (spaceIdx !== -1 ? line.slice(spaceIdx) : "");
166
+ }
167
+ } else {
168
+ // Normal element: leading dot means "div" (or strip leading dot)
169
+ let workLine = line.startsWith(".") ? "div" + line : line;
170
+
171
+ // Strip trailing ':' from multiline trigger lines when parsing
172
+ if (workLine.trimEnd().endsWith(":")) {
173
+ workLine = workLine.trimEnd().slice(0, -1).trimEnd();
174
+ }
175
+
176
+ const dotIdx = findFirstUnquotedDot(workLine);
177
+ if (dotIdx === -1) {
178
+ element = workLine;
179
+ rest = "";
180
+ } else {
181
+ element = workLine.slice(0, dotIdx);
182
+ rest = workLine.slice(dotIdx);
183
+ }
184
+ }
185
+
186
+ // Parse attribute chain from `rest`
187
+ const { attributes, content } = parseAttributeChain(rest, loc);
188
+
189
+ const node = makeNode(element, attributes, content, [], loc);
190
+ // Set multiline_trigger if the original line ended with ':'
191
+ if (!line.startsWith("@") && line.trimEnd().endsWith(":")) {
192
+ node.multiline_trigger = true;
193
+ }
194
+ return node;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Attribute chain parser
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function parseAttributeChain(
202
+ chain: string,
203
+ loc: SourceLocation
204
+ ): { attributes: Record<string, string | boolean | string[]>; content: string } {
205
+ const attributes: Record<string, string | boolean | string[]> = {};
206
+ let content = "";
207
+ let i = 0;
208
+
209
+ while (i < chain.length) {
210
+ if (chain[i] !== ".") {
211
+ i++;
212
+ continue;
213
+ }
214
+ i++; // skip dot
215
+
216
+ // Read the attribute name (up to '(' or next unquoted '.' )
217
+ let attrName = "";
218
+ while (i < chain.length && chain[i] !== "(" && chain[i] !== ".") {
219
+ attrName += chain[i];
220
+ i++;
221
+ }
222
+
223
+ if (!attrName) continue;
224
+
225
+ // Handle on:* events -> translate to native event handler name
226
+ if (attrName.startsWith("on:")) {
227
+ attrName = "on" + attrName.slice(3);
228
+ }
229
+
230
+ // Normalize hx:* → hx-* and x:* → x-* (HTMX/Alpine colon sugar)
231
+ // LLMs frequently emit colons instead of dashes; the compiler absorbs both forms.
232
+ if (attrName.startsWith("hx:")) {
233
+ attrName = "hx-" + attrName.slice(3);
234
+ } else if (/^x:[a-z]/.test(attrName)) {
235
+ attrName = "x-" + attrName.slice(2);
236
+ }
237
+
238
+ if (i < chain.length && chain[i] === "(") {
239
+ // Read the parenthesised value(s)
240
+ i++; // skip '('
241
+ const args = readParenArgs(chain, i);
242
+ i = args.end + 1; // skip ')'
243
+
244
+ if (attrName === "class") {
245
+ // .class("val") replaces class entirely
246
+ // Last arg may be content if multiple args
247
+ if (args.values.length === 1) {
248
+ attributes["class"] = args.values[0];
249
+ } else if (args.values.length >= 2) {
250
+ attributes["class"] = args.values[0];
251
+ content = args.values[args.values.length - 1];
252
+ }
253
+ } else {
254
+ // General attribute — last value is content if multiple
255
+ if (args.values.length === 1) {
256
+ // Single arg: it IS the attribute value
257
+ // UNLESS attrName matches element name (h1("text") shorthand)
258
+ attributes[attrName] = args.values[0];
259
+ } else if (args.values.length === 0) {
260
+ attributes[attrName] = "";
261
+ } else {
262
+ // Multiple args: first is attr value, last is content
263
+ attributes[attrName] = args.values[0];
264
+ content = args.values[args.values.length - 1];
265
+ }
266
+ }
267
+ } else {
268
+ // Boolean or bare class shorthand (e.g., .text-xl, .disabled, .!text-2xl)
269
+ if (BOOLEAN_ATTRIBUTES.has(attrName)) {
270
+ attributes[attrName] = true;
271
+ } else if (!attrName.includes(":") && !WHITELISTED_ATTRS.has(attrName) && !attrName.startsWith("data-") && !attrName.startsWith("aria-") && !attrName.startsWith("hx-") && !attrName.startsWith("x-")) {
272
+ // Bare word that's not a known HTML attr: treat as additional class
273
+ const existing = attributes["class"];
274
+ if (existing === undefined) {
275
+ attributes["class"] = [attrName];
276
+ } else if (Array.isArray(existing)) {
277
+ existing.push(attrName);
278
+ } else {
279
+ attributes["class"] = existing + " " + attrName;
280
+ }
281
+ } else {
282
+ // Could be data-*, aria-*, hx-*, x-* etc
283
+ attributes[attrName] = true;
284
+ }
285
+ }
286
+ }
287
+
288
+ return { attributes, content };
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Helpers for element-level content shorthand e.g. h1("Hello")
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * The Python parser supports `h1("Hello")` as shorthand for content.
297
+ * In NML the element line is like `h1.class("blue", "Hello")` or `h1("Hello")`.
298
+ * We need to detect when the "attribute name" equals the element name
299
+ * and treat the value as content instead.
300
+ */
301
+ export function parseLineRaw(rawLine: string, loc: SourceLocation): ASTNode {
302
+ // Handle the content shorthand BEFORE the attribute chain parser
303
+ // by checking whether the FIRST parenthesised group uses the element name.
304
+ const line = rawLine.trim();
305
+
306
+ if (line.startsWith("|")) {
307
+ return makeNode("__text__", {}, line.slice(1).trim(), [], loc);
308
+ }
309
+ if (line.startsWith("//")) {
310
+ return makeNode("__comment__", {}, "", [], loc);
311
+ }
312
+
313
+ // Detect multiline trigger before stripping
314
+ const isMultiline = !line.startsWith("@") && line.trimEnd().endsWith(":");
315
+
316
+ // Strip multiline trigger colon from the end for attribute parsing
317
+ const workLine = isMultiline
318
+ ? line.trimEnd().slice(0, -1).trimEnd()
319
+ : line;
320
+
321
+ // Find element name
322
+ let element = "";
323
+ let chainStart = 0;
324
+
325
+ if (workLine.startsWith("@") || line.startsWith("@")) {
326
+ // Pass the original line so parseLine can detect '@style:'
327
+ return parseLine(line, loc);
328
+ }
329
+
330
+ const adjusted = workLine.startsWith(".") ? "div" + workLine : workLine;
331
+ const firstParen = adjusted.indexOf("(");
332
+ const firstDot = findFirstUnquotedDot(adjusted);
333
+
334
+ if (firstParen !== -1 && (firstDot === -1 || firstParen < firstDot)) {
335
+ // Content shorthand: `h1("Hello")` or `h1.class("x", "Hello")`
336
+ element = adjusted.slice(0, firstParen);
337
+ const rest = adjusted.slice(firstParen);
338
+ // Read the content from the first parens
339
+ const args = readParenArgs(rest, 1);
340
+ const afterFirst = rest.slice(args.end + 1);
341
+ let content = args.values[args.values.length - 1] ?? "";
342
+
343
+ // Parse remaining attribute chain after the first paren group
344
+ const { attributes } = parseAttributeChain(afterFirst, loc);
345
+
346
+ // If multiple values in first group, first is attr of same name? No —
347
+ // shorthand is: element("content") or element.attr("v", "content")
348
+ // When the first group immediately follows the element (no dot), it's content.
349
+ if (args.values.length > 1) {
350
+ content = args.values[args.values.length - 1];
351
+ }
352
+
353
+ const node = makeNode(element, attributes, content, [], loc);
354
+ if (isMultiline) node.multiline_trigger = true;
355
+ return node;
356
+ }
357
+
358
+ if (firstDot === -1) {
359
+ element = adjusted;
360
+ chainStart = adjusted.length;
361
+ } else {
362
+ element = adjusted.slice(0, firstDot);
363
+ chainStart = firstDot;
364
+ }
365
+
366
+ const chain = adjusted.slice(chainStart);
367
+ const { attributes, content } = parseAttributeChain(chain, loc);
368
+ const node = makeNode(element, attributes, content, [], loc);
369
+ if (isMultiline) node.multiline_trigger = true;
370
+ return node;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Build AST (equivalent to build_ast in Python)
375
+ // ---------------------------------------------------------------------------
376
+
377
+ export function buildAst(
378
+ source: string,
379
+ options: {
380
+ components?: ComponentMap;
381
+ globalStyles?: GlobalStyles;
382
+ } = {}
383
+ ): ASTNode[] {
384
+ const components = options.components ?? {};
385
+ const globalStyles = options.globalStyles ?? {};
386
+
387
+ const lines = source.split("\n");
388
+ const root: ASTNode = makeNode("__root__", {}, "", [], { line: 0, column: 0 });
389
+ const stack: Array<[ASTNode, number]> = [[root, -1]];
390
+
391
+ let lineIdx = 0;
392
+
393
+ while (lineIdx < lines.length) {
394
+ const rawLine = lines[lineIdx];
395
+ const lineNum = lineIdx + 1;
396
+ lineIdx++;
397
+
398
+ const stripped = rawLine.trimEnd();
399
+
400
+ // Skip blank lines
401
+ if (stripped.trim() === "") continue;
402
+
403
+ // Skip comments
404
+ if (stripped.trimStart().startsWith("//")) continue;
405
+
406
+ // Validate indentation
407
+ if (stripped.startsWith("\t")) {
408
+ throw new NMLParserError(
409
+ `Indentation error on line ${lineNum}: Please use 4 spaces for indentation, not tabs.`,
410
+ { line: lineNum, column: 0 }
411
+ );
412
+ }
413
+
414
+ const leadingSpaces = stripped.length - stripped.trimStart().length;
415
+ if (leadingSpaces % INDENT_WIDTH !== 0) {
416
+ throw new NMLParserError(
417
+ `Indentation error on line ${lineNum}: Non-standard indentation. Use 4 spaces per level.`,
418
+ { line: lineNum, column: 0 }
419
+ );
420
+ }
421
+
422
+ const level = leadingSpaces / INDENT_WIDTH;
423
+ const content = stripped.trimStart();
424
+ const loc: SourceLocation = { line: lineNum, column: leadingSpaces };
425
+
426
+ // Parse the line into an AST node
427
+ const newNode = parseLineRaw(content, loc);
428
+
429
+ // Skip comment nodes
430
+ if (newNode.element === "__comment__") continue;
431
+
432
+ // Validate indentation depth
433
+ const currentLevel = stack[stack.length - 1][1];
434
+ if (level > currentLevel + 1) {
435
+ throw new NMLParserError(
436
+ `Indentation error on line ${lineNum}: Incorrect indentation level (too deep).`,
437
+ { line: lineNum, column: leadingSpaces }
438
+ );
439
+ }
440
+
441
+ // Pop stack to find correct parent
442
+ while (level <= stack[stack.length - 1][1]) {
443
+ stack.pop();
444
+ }
445
+
446
+ const parentNode = stack[stack.length - 1][0];
447
+
448
+ // Handle content pipe nodes
449
+ if (newNode.element === "__text__") {
450
+ const parentLevel = stack[stack.length - 1][1];
451
+ if (level !== parentLevel + 1 || parentLevel === -1) {
452
+ throw new NMLParserError(
453
+ `Indentation error on line ${lineNum}: Content pipe '|' must be indented one level deeper than its parent element.`,
454
+ { line: lineNum, column: leadingSpaces }
455
+ );
456
+ }
457
+ parentNode.children.push(newNode);
458
+ continue;
459
+ }
460
+
461
+ // Handle multiline blocks (element ends with ':')
462
+ if (isMultilineTrigger(content)) {
463
+ newNode.multiline_trigger = true;
464
+ const blockStartSpaces = (level + 1) * INDENT_WIDTH;
465
+
466
+ while (lineIdx < lines.length) {
467
+ const mlRaw = lines[lineIdx];
468
+ const mlLine = lineIdx + 1;
469
+ const mlStripped = mlRaw.trimEnd();
470
+
471
+ if (mlStripped.trim() === "") {
472
+ newNode.multiline_content.push("");
473
+ lineIdx++;
474
+ continue;
475
+ }
476
+
477
+ const mlSpaces = mlStripped.length - mlStripped.trimStart().length;
478
+
479
+ if (mlSpaces < blockStartSpaces) break;
480
+
481
+ if (mlSpaces % INDENT_WIDTH !== 0) {
482
+ throw new NMLParserError(
483
+ `Indentation error on line ${mlLine}: Non-standard indentation. Use 4 spaces per level.`,
484
+ { line: mlLine, column: 0 }
485
+ );
486
+ }
487
+
488
+ const relativeIndent = " ".repeat(mlSpaces - blockStartSpaces);
489
+ newNode.multiline_content.push(relativeIndent + mlStripped.trimStart());
490
+ lineIdx++;
491
+ }
492
+
493
+ parentNode.children.push(newNode);
494
+ // Multiline nodes can't have children in the tree — don't push to stack
495
+ continue;
496
+ }
497
+
498
+ parentNode.children.push(newNode);
499
+ stack.push([newNode, level]);
500
+ }
501
+
502
+ // Component expansion pass
503
+ root.children = expandComponentsPass(root.children, components, globalStyles);
504
+ // Conditionals post-process pass: group @if/@else/@endif siblings
505
+ return postProcessConditionalsPass(root.children);
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Component expansion pass
510
+ // ---------------------------------------------------------------------------
511
+
512
+ function expandComponentsPass(
513
+ nodes: ASTNode[],
514
+ components: ComponentMap,
515
+ globalStyles: GlobalStyles
516
+ ): ASTNode[] {
517
+ const expanded: ASTNode[] = [];
518
+
519
+ for (const node of nodes) {
520
+ const element = node.element;
521
+
522
+ // Process @define blocks
523
+ if (element === "@define") {
524
+ const componentName = node.attributes["class"] as string;
525
+ if (componentName) {
526
+ // Extract @style block
527
+ let styleRaw = "";
528
+ for (const child of node.children) {
529
+ if (child.element === "@style") {
530
+ styleRaw = child.multiline_content.join("\n");
531
+ break;
532
+ }
533
+ }
534
+
535
+ const toHash = `${componentName}|${styleRaw}`;
536
+ const digest = createHash("sha1").update(toHash).digest("hex").slice(0, 6);
537
+ const scopeId = `nml-c-${digest}`;
538
+
539
+ const { componentAst, scopedCss } = extractScopedStyle(node.children, scopeId);
540
+
541
+ if (scopedCss) {
542
+ globalStyles[scopeId] = scopedCss;
543
+ const rootNode = findComponentRootNode(componentAst);
544
+ if (rootNode) {
545
+ rootNode.attributes[scopeId] = true;
546
+ }
547
+ }
548
+
549
+ components[componentName] = componentAst;
550
+ }
551
+ continue; // @define never added to output AST
552
+ }
553
+
554
+ // Process @ComponentName calls
555
+ if (element.startsWith("@") && element !== "@define" && element !== "@slot" && element !== "@style" && element !== "@include" && element !== "@each" && element !== "@endeach" && element !== "@if" && element !== "@else" && element !== "@endif") {
556
+ const componentName = element.slice(1);
557
+
558
+ if (!(componentName in components)) {
559
+ throw new NMLParserError(
560
+ `Undefined component: '@${componentName}' not found.`,
561
+ node.loc
562
+ );
563
+ }
564
+
565
+ const templateAst = deepClone(components[componentName]);
566
+
567
+ // Collect slot content from call site
568
+ const defaultChildren: ASTNode[] = [];
569
+ const namedSlots: Record<string, ASTNode[]> = {};
570
+
571
+ for (const child of node.children) {
572
+ if (child.element === "@slot") {
573
+ const slotName = child.attributes["name"] as string | undefined;
574
+ const expandedChildren = expandComponentsPass(child.children, components, globalStyles);
575
+ if (slotName) {
576
+ if (namedSlots[slotName]) {
577
+ namedSlots[slotName].push(...expandedChildren);
578
+ } else {
579
+ namedSlots[slotName] = expandedChildren;
580
+ }
581
+ }
582
+ // Named slot children go to named slots only
583
+ } else {
584
+ // Default slot
585
+ const expandedList = expandComponentsPass([child], components, globalStyles);
586
+ defaultChildren.push(...expandedList);
587
+ }
588
+ }
589
+
590
+ const slots: Record<string | symbol, ASTNode[]> = {
591
+ [Symbol.for("default")]: defaultChildren,
592
+ ...namedSlots,
593
+ };
594
+
595
+ const injected = injectSlot(templateAst, slots);
596
+ let resolvedAst = injected;
597
+ if (resolvedAst.length > 0) {
598
+ resolvedAst = expandComponentsPass(resolvedAst, components, globalStyles);
599
+ }
600
+
601
+ if (resolvedAst.length > 0) {
602
+ const baseNode = resolvedAst[0];
603
+ const callAttrs = node.attributes;
604
+
605
+ // Merge call-site attributes
606
+ const mergeAttrs: Record<string, string | boolean | string[]> = {};
607
+ const propsAttrs: Record<string, string | boolean | string[]> = {};
608
+
609
+ for (const [k, v] of Object.entries(callAttrs)) {
610
+ if (isMergeableAttr(k)) {
611
+ mergeAttrs[k] = v;
612
+ } else {
613
+ propsAttrs[k] = v;
614
+ }
615
+ }
616
+
617
+ baseNode.attributes = mergeAttributes(baseNode.attributes, mergeAttrs);
618
+
619
+ if (Object.keys(propsAttrs).length > 0) {
620
+ const propDict: Record<string, string> = {};
621
+ for (const [k, v] of Object.entries(propsAttrs)) {
622
+ propDict[k] = String(v);
623
+ }
624
+ baseNode.__context__ = { prop: propDict };
625
+ }
626
+ }
627
+
628
+ expanded.push(...resolvedAst);
629
+ continue;
630
+ }
631
+
632
+ // Regular node — recurse into children
633
+ if (node.children.length > 0) {
634
+ node.children = expandComponentsPass(node.children, components, globalStyles);
635
+ }
636
+ expanded.push(node);
637
+ }
638
+
639
+ return expanded;
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // Slot injection
644
+ // ---------------------------------------------------------------------------
645
+
646
+ function injectSlot(
647
+ templateAst: ASTNode[],
648
+ slots: Record<string | symbol, ASTNode[]>
649
+ ): ASTNode[] {
650
+ const result: ASTNode[] = [];
651
+
652
+ for (const node of templateAst) {
653
+ if (node.element === "@slot") {
654
+ const slotName = node.attributes["name"] as string | undefined;
655
+ const key = slotName ?? Symbol.for("default");
656
+ const provided = slots[key as string] ?? (typeof key === "symbol" ? slots[Symbol.for("default")] : undefined);
657
+
658
+ if (provided && provided.length > 0) {
659
+ result.push(...provided);
660
+ } else if (node.children.length > 0) {
661
+ // Fallback content
662
+ result.push(...node.children);
663
+ }
664
+ continue;
665
+ }
666
+
667
+ if (node.children.length > 0) {
668
+ node.children = injectSlot(node.children, slots);
669
+ }
670
+ result.push(node);
671
+ }
672
+
673
+ return result;
674
+ }
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // Scoped style extraction
678
+ // ---------------------------------------------------------------------------
679
+
680
+ function extractScopedStyle(
681
+ children: ASTNode[],
682
+ scopeId: string
683
+ ): { componentAst: ASTNode[]; scopedCss: string } {
684
+ const componentAst: ASTNode[] = [];
685
+ let scopedCss = "";
686
+
687
+ for (const child of children) {
688
+ if (child.element === "@style") {
689
+ const raw = child.multiline_content.join("\n");
690
+ scopedCss = scopeStyleBlock(raw, scopeId);
691
+ } else {
692
+ componentAst.push(child);
693
+ }
694
+ }
695
+
696
+ return { componentAst, scopedCss };
697
+ }
698
+
699
+ function scopeStyleBlock(css: string, scopeId: string): string {
700
+ // Add [scopeId] attribute selector to each CSS rule selector
701
+ return css.replace(/\.([\w-]+)(\s*(?::[\w-]+)*)\s*\{/g, (match, className, pseudo) => {
702
+ return `.${className}[${scopeId}]${pseudo} {`;
703
+ });
704
+ }
705
+
706
+ export function findComponentRootNode(ast: ASTNode[]): ASTNode | null {
707
+ for (const node of ast) {
708
+ if (node.element !== "@define" && node.element !== "@slot" && node.element !== "@style" && !node.element.startsWith("//")) {
709
+ return node;
710
+ }
711
+ }
712
+ return null;
713
+ }
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // Attribute helpers
717
+ // ---------------------------------------------------------------------------
718
+
719
+ function isMergeableAttr(key: string): boolean {
720
+ if (key === "class") return true;
721
+ if (BOOLEAN_ATTRIBUTES.has(key)) return true;
722
+ if (WHITELISTED_ATTRS.has(key)) return true;
723
+ if (key.startsWith("data-")) return true;
724
+ if (key.startsWith("aria-")) return true;
725
+ if (key.startsWith("on")) return true; // onclick, etc.
726
+ return false;
727
+ }
728
+
729
+ function mergeAttributes(
730
+ base: Record<string, string | boolean | string[]>,
731
+ overrides: Record<string, string | boolean | string[]>
732
+ ): Record<string, string | boolean | string[]> {
733
+ const result = { ...base };
734
+
735
+ for (const [key, value] of Object.entries(overrides)) {
736
+ if (key === "class") {
737
+ const baseClass = result["class"];
738
+ if (Array.isArray(value)) {
739
+ // Dot-chain classes: append
740
+ const baseStr = Array.isArray(baseClass) ? baseClass.join(" ") : (baseClass as string) ?? "";
741
+ result["class"] = (baseStr + " " + value.join(" ")).trim();
742
+ } else if (typeof value === "string") {
743
+ // .class("val"): replace
744
+ result["class"] = value;
745
+ }
746
+ } else {
747
+ result[key] = value;
748
+ }
749
+ }
750
+
751
+ return result;
752
+ }
753
+
754
+ // ---------------------------------------------------------------------------
755
+ // Variable rendering (equivalent to _render_variables in Python)
756
+ // ---------------------------------------------------------------------------
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // isTruthy — Pythonic UI-optimized truthiness
760
+ // ---------------------------------------------------------------------------
761
+
762
+ export function isTruthy(val: unknown): boolean {
763
+ if (val === null || val === undefined) return false;
764
+ if (val === 0 || val === "") return false;
765
+ if (Array.isArray(val)) return val.length > 0;
766
+ if (typeof val === "object") return Object.keys(val as object).length > 0;
767
+ return Boolean(val);
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Built-in filters
772
+ // ---------------------------------------------------------------------------
773
+
774
+ type FilterFn = (val: unknown, arg?: string) => string;
775
+
776
+ const BUILTIN_FILTERS: Record<string, FilterFn> = {
777
+ uppercase: (val) => String(val ?? "").toUpperCase(),
778
+ lowercase: (val) => String(val ?? "").toLowerCase(),
779
+ trim: (val) => String(val ?? "").trim(),
780
+ json: (val) => JSON.stringify(val),
781
+ default: (val, arg) => (isTruthy(val) ? String(val) : (arg ?? "")),
782
+ };
783
+
784
+ function applyFilter(filterName: string, filterArg: string | undefined, val: unknown, context: Record<string, unknown>): string | null {
785
+ // 1. Check built-ins
786
+ const builtin = BUILTIN_FILTERS[filterName];
787
+ if (builtin) return builtin(val, filterArg);
788
+ // 2. Check user-defined fn in context
789
+ const userFn = context[filterName];
790
+ if (typeof userFn === "function") return String((userFn as (v: unknown, a?: string) => unknown)(val, filterArg));
791
+ // 3. Unknown → empty string
792
+ return "";
793
+ }
794
+
795
+ // ---------------------------------------------------------------------------
796
+ // Variable rendering (equivalent to _render_variables in Python)
797
+ // ---------------------------------------------------------------------------
798
+
799
+ export function renderVariables(
800
+ template: string,
801
+ context: Record<string, unknown>
802
+ ): string {
803
+ // Matches: {{ varPath }}, {{ varPath|raw }}, {{ varPath|filter }}, {{ varPath|filter("arg") }}
804
+ return template.replace(/\{\{\s*([\w.]+?)(?:\|(raw|[\w]+(?:\([^)]*\))?))?\s*\}\}/g, (match, key, filterExpr) => {
805
+ const value = resolvePath(key, context);
806
+
807
+ // No filter — existing behaviour
808
+ if (!filterExpr) {
809
+ if (value === undefined) return match;
810
+ return escapeHtml(String(value));
811
+ }
812
+
813
+ // |raw bypass
814
+ if (filterExpr === "raw") {
815
+ if (value === undefined) return match;
816
+ return String(value);
817
+ }
818
+
819
+ // Filter expression: filterName or filterName("arg")
820
+ const filterMatch = filterExpr.match(/^([\w]+)(?:\(([^)]*)\))?$/);
821
+ if (!filterMatch) {
822
+ if (value === undefined) return match;
823
+ return escapeHtml(String(value));
824
+ }
825
+
826
+ const filterName = filterMatch[1];
827
+ const rawArg = filterMatch[2]; // may be undefined or '"N/A"' etc.
828
+ const filterArg = rawArg !== undefined
829
+ ? rawArg.trim().replace(/^["']|["']$/g, "")
830
+ : undefined;
831
+
832
+ const result = applyFilter(filterName, filterArg, value, context);
833
+ // json filter: raw output (not HTML-escaped)
834
+ if (filterName === "json") return result ?? "";
835
+ return escapeHtml(result ?? "");
836
+ });
837
+ }
838
+
839
+ function resolvePath(path: string, context: Record<string, unknown>): unknown {
840
+ const parts = path.split(".");
841
+ let current: unknown = context;
842
+ for (const part of parts) {
843
+ if (current === null || current === undefined) return undefined;
844
+ current = (current as Record<string, unknown>)[part];
845
+ }
846
+ return current;
847
+ }
848
+
849
+ function escapeHtml(str: string): string {
850
+ return str
851
+ .replace(/&/g, "&amp;")
852
+ .replace(/</g, "&lt;")
853
+ .replace(/>/g, "&gt;")
854
+ .replace(/"/g, "&quot;")
855
+ .replace(/'/g, "&#39;");
856
+ }
857
+
858
+ // ---------------------------------------------------------------------------
859
+ // Utility helpers
860
+ // ---------------------------------------------------------------------------
861
+
862
+ function makeNode(
863
+ element: string,
864
+ attributes: Record<string, string | boolean | string[]>,
865
+ content: string,
866
+ children: ASTNode[],
867
+ loc: SourceLocation
868
+ ): ASTNode {
869
+ return {
870
+ element,
871
+ attributes,
872
+ content,
873
+ children,
874
+ multiline_trigger: false,
875
+ multiline_content: [],
876
+ loc,
877
+ };
878
+ }
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // @include directive parser
882
+ // ---------------------------------------------------------------------------
883
+
884
+ /**
885
+ * Parse @include("path") or @include("path", { key: "value" })
886
+ * Stores:
887
+ * attributes.file — the relative path string
888
+ * attributes.overrides — JSON-encoded override object (if provided)
889
+ */
890
+ function parseIncludeDirective(afterAt: string, loc: SourceLocation): ASTNode {
891
+ // afterAt looks like: ("path/to/file.nml") or ("path", { k: "v" })
892
+ const openParen = afterAt.indexOf("(");
893
+ if (openParen === -1) {
894
+ throw new NMLParserError("@include requires a file path argument: @include(\"path.nml\")", loc);
895
+ }
896
+
897
+ // Extract everything inside the outer parens
898
+ const args = readParenArgs(afterAt, openParen + 1);
899
+
900
+ if (args.values.length === 0) {
901
+ throw new NMLParserError("@include requires a file path argument", loc);
902
+ }
903
+
904
+ const file = args.values[0];
905
+ if (!file) {
906
+ throw new NMLParserError("@include file path cannot be empty", loc);
907
+ }
908
+ if (file.startsWith("/")) {
909
+ throw new NMLParserError("@include paths must be relative, not absolute", loc);
910
+ }
911
+
912
+ // Second arg (if any) is a JSON-like override object string
913
+ // We store it raw as a string and parse it at render time
914
+ const overridesRaw = args.values.length >= 2 ? args.values[1] : "";
915
+
916
+ return makeNode("@include", { file, overrides: overridesRaw }, "", [], loc);
917
+ }
918
+
919
+ function deepClone<T>(obj: T): T {
920
+ return JSON.parse(JSON.stringify(obj)) as T;
921
+ }
922
+
923
+ /**
924
+ * Extract the content between the outermost parentheses of a directive line.
925
+ * e.g. "@each(items as item)" → "items as item"
926
+ * "@if(user.isAdmin)" → "user.isAdmin"
927
+ */
928
+ function extractParenContent(line: string): string | undefined {
929
+ const open = line.indexOf("(");
930
+ if (open === -1) return undefined;
931
+ const close = line.lastIndexOf(")");
932
+ if (close === -1 || close <= open) return undefined;
933
+ return line.slice(open + 1, close).trim();
934
+ }
935
+
936
+ // ---------------------------------------------------------------------------
937
+ // Post-process pass: group @if/@else/@endif and strip @endeach markers
938
+ // ---------------------------------------------------------------------------
939
+
940
+ /**
941
+ * Because NML is indentation-based, @else / @endif appear as SIBLINGS of @if
942
+ * in the parent array (not as children). This pass:
943
+ * - Finds @if nodes at index i
944
+ * - Looks ahead for @else at i+1 → moves its children into node.elseBranch
945
+ * - Removes the @else and @endif siblings
946
+ * - Removes @endeach siblings (children already captured by indentation)
947
+ * - Recurses into every node's children array
948
+ */
949
+ export function postProcessConditionalsPass(nodes: ASTNode[]): ASTNode[] {
950
+ const result: ASTNode[] = [];
951
+ let i = 0;
952
+
953
+ while (i < nodes.length) {
954
+ const node = nodes[i];
955
+
956
+ if (node.element === "@if") {
957
+ // Recurse into then-branch children first
958
+ node.children = postProcessConditionalsPass(node.children);
959
+
960
+ let j = i + 1;
961
+
962
+ // Consume optional @else sibling
963
+ if (j < nodes.length && nodes[j].element === "@else") {
964
+ node.elseBranch = postProcessConditionalsPass(nodes[j].children);
965
+ j++;
966
+ }
967
+
968
+ // Consume required @endif sibling — throw if missing
969
+ if (j < nodes.length && nodes[j].element === "@endif") {
970
+ j++;
971
+ } else {
972
+ throw new NMLParserError(
973
+ `Missing @endif for @if on line ${node.loc.line}.`,
974
+ node.loc
975
+ );
976
+ }
977
+
978
+ result.push(node);
979
+ i = j;
980
+ continue;
981
+ }
982
+
983
+ if (node.element === "@each") {
984
+ // Recurse into loop body children
985
+ node.children = postProcessConditionalsPass(node.children);
986
+
987
+ let j = i + 1;
988
+ // Consume @endeach sibling — throw if missing
989
+ if (j < nodes.length && nodes[j].element === "@endeach") {
990
+ j++;
991
+ } else {
992
+ throw new NMLParserError(
993
+ `Missing @endeach for @each on line ${node.loc.line}.`,
994
+ node.loc
995
+ );
996
+ }
997
+
998
+ result.push(node);
999
+ i = j;
1000
+ continue;
1001
+ }
1002
+
1003
+ // For all other nodes, recurse into children
1004
+ if (node.children.length > 0) {
1005
+ node.children = postProcessConditionalsPass(node.children);
1006
+ }
1007
+ if (node.elseBranch && node.elseBranch.length > 0) {
1008
+ node.elseBranch = postProcessConditionalsPass(node.elseBranch);
1009
+ }
1010
+
1011
+ result.push(node);
1012
+ i++;
1013
+ }
1014
+
1015
+ return result;
1016
+ }
1017
+
1018
+ function isMultilineTrigger(content: string): boolean {
1019
+ const trimmed = content.trimEnd();
1020
+ let inQuote = false;
1021
+ let quoteChar = "";
1022
+ for (let i = 0; i < trimmed.length; i++) {
1023
+ const ch = trimmed[i];
1024
+ if (inQuote) {
1025
+ if (ch === quoteChar) inQuote = false;
1026
+ } else {
1027
+ if (ch === '"' || ch === "'") {
1028
+ inQuote = true;
1029
+ quoteChar = ch;
1030
+ }
1031
+ }
1032
+ }
1033
+ return !inQuote && trimmed.endsWith(":");
1034
+ }
1035
+
1036
+ function findFirstUnquotedDot(s: string): number {
1037
+ let inQuote = false;
1038
+ let quoteChar = "";
1039
+ for (let i = 0; i < s.length; i++) {
1040
+ const ch = s[i];
1041
+ if (inQuote) {
1042
+ if (ch === quoteChar) inQuote = false;
1043
+ } else {
1044
+ if (ch === '"' || ch === "'") {
1045
+ inQuote = true;
1046
+ quoteChar = ch;
1047
+ } else if (ch === ".") {
1048
+ return i;
1049
+ }
1050
+ }
1051
+ }
1052
+ return -1;
1053
+ }
1054
+
1055
+ interface ParenResult {
1056
+ values: string[];
1057
+ end: number;
1058
+ }
1059
+
1060
+ function readParenArgs(s: string, startIdx: number): ParenResult {
1061
+ // s[startIdx-1] is '(' — read comma-separated quoted strings until ')'
1062
+ const values: string[] = [];
1063
+ let i = startIdx;
1064
+ let depth = 1;
1065
+ let current = "";
1066
+ let inQuote = false;
1067
+ let quoteChar = "";
1068
+
1069
+ while (i < s.length && depth > 0) {
1070
+ const ch = s[i];
1071
+
1072
+ if (inQuote) {
1073
+ if (ch === quoteChar) {
1074
+ inQuote = false;
1075
+ // Don't add the closing quote
1076
+ } else {
1077
+ current += ch;
1078
+ }
1079
+ } else {
1080
+ if (ch === '"' || ch === "'") {
1081
+ inQuote = true;
1082
+ quoteChar = ch;
1083
+ // Opening quote: don't add to current
1084
+ } else if (ch === "(") {
1085
+ depth++;
1086
+ current += ch;
1087
+ } else if (ch === ")") {
1088
+ depth--;
1089
+ if (depth === 0) {
1090
+ if (current.trim() !== "" || values.length > 0) {
1091
+ values.push(current.trim());
1092
+ }
1093
+ break;
1094
+ } else {
1095
+ current += ch;
1096
+ }
1097
+ } else if (ch === "," && depth === 1) {
1098
+ values.push(current.trim());
1099
+ current = "";
1100
+ } else {
1101
+ current += ch;
1102
+ }
1103
+ }
1104
+ i++;
1105
+ }
1106
+
1107
+ return { values, end: i };
1108
+ }