@collie-lang/compiler 1.2.0 → 1.3.1

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/index.cjs CHANGED
@@ -49,340 +49,39 @@ __export(index_exports, {
49
49
  });
50
50
  module.exports = __toCommonJS(index_exports);
51
51
 
52
- // src/codegen.ts
53
- function generateRenderModule(root, options) {
54
- const { prelude, propsType, propsDestructure, jsx, isTsx } = buildModuleParts(root, options);
55
- const parts = [...prelude, propsType];
56
- if (!isTsx) {
57
- parts.push(`/** @param {any} props */`);
58
- }
59
- const functionLines = [
60
- isTsx ? "export function render(props: any) {" : "export function render(props) {"
61
- ];
62
- if (propsDestructure) {
63
- functionLines.push(` ${propsDestructure}`);
64
- }
65
- functionLines.push(` return ${jsx};`, `}`);
66
- parts.push(functionLines.join("\n"));
67
- return parts.join("\n\n");
68
- }
69
- function buildModuleParts(root, options) {
70
- const { jsxRuntime, flavor } = options;
71
- const isTsx = flavor === "tsx";
72
- const aliasEnv = buildClassAliasEnvironment(root.classAliases);
73
- const jsx = renderRootChildren(root.children, aliasEnv);
74
- const propsDestructure = emitPropsDestructure(root.props);
75
- const prelude = [];
76
- if (root.clientComponent) {
77
- prelude.push(`"use client";`);
78
- }
79
- if (jsxRuntime === "classic" && templateUsesJsx(root)) {
80
- prelude.push(`import React from "react";`);
81
- }
82
- const propsType = emitPropsType(root.props, flavor);
83
- return { prelude, propsType, propsDestructure, jsx, isTsx };
84
- }
85
- function buildClassAliasEnvironment(decl) {
86
- const env = /* @__PURE__ */ new Map();
87
- if (!decl) {
88
- return env;
89
- }
90
- for (const alias of decl.aliases) {
91
- env.set(alias.name, alias.classes);
92
- }
93
- return env;
94
- }
95
- function renderRootChildren(children, aliasEnv) {
96
- return emitNodesExpression(children, aliasEnv, /* @__PURE__ */ new Set());
97
- }
98
- function templateUsesJsx(root) {
99
- if (root.children.length === 0) {
100
- return false;
101
- }
102
- if (root.children.length > 1) {
103
- return true;
104
- }
105
- return nodeUsesJsx(root.children[0]);
106
- }
107
- function nodeUsesJsx(node) {
108
- if (node.type === "Element" || node.type === "Text" || node.type === "Component") {
109
- return true;
110
- }
111
- if (node.type === "Expression" || node.type === "JSXPassthrough") {
112
- return false;
113
- }
114
- if (node.type === "Conditional") {
115
- return node.branches.some((branch) => branchUsesJsx(branch));
116
- }
117
- if (node.type === "For") {
118
- return node.body.some((child) => nodeUsesJsx(child));
119
- }
120
- return false;
121
- }
122
- function branchUsesJsx(branch) {
123
- if (!branch.body.length) {
124
- return false;
125
- }
126
- return branch.body.some((child) => nodeUsesJsx(child));
127
- }
128
- function emitNodeInJsx(node, aliasEnv, locals) {
129
- if (node.type === "Text") {
130
- return emitText(node, locals);
131
- }
132
- if (node.type === "Expression") {
133
- return `{${emitExpressionValue(node.value, locals)}}`;
134
- }
135
- if (node.type === "JSXPassthrough") {
136
- return `{${emitJsxExpression(node.expression, locals)}}`;
137
- }
138
- if (node.type === "Conditional") {
139
- return `{${emitConditionalExpression(node, aliasEnv, locals)}}`;
140
- }
141
- if (node.type === "For") {
142
- return `{${emitForExpression(node, aliasEnv, locals)}}`;
143
- }
144
- if (node.type === "Component") {
145
- return wrapWithGuard(emitComponent(node, aliasEnv, locals), node.guard, "jsx", locals);
146
- }
147
- return wrapWithGuard(emitElement(node, aliasEnv, locals), node.guard, "jsx", locals);
148
- }
149
- function emitElement(node, aliasEnv, locals) {
150
- const expanded = expandClasses(node.classes, aliasEnv);
151
- const classAttr = expanded.length ? ` className="${expanded.join(" ")}"` : "";
152
- const attrs = emitAttributes(node.attributes, aliasEnv, locals);
153
- const allAttrs = classAttr + attrs;
154
- const children = emitChildrenWithSpacing(node.children, aliasEnv, locals);
155
- if (children.length > 0) {
156
- return `<${node.name}${allAttrs}>${children}</${node.name}>`;
157
- } else {
158
- return `<${node.name}${allAttrs} />`;
159
- }
160
- }
161
- function emitComponent(node, aliasEnv, locals) {
162
- const attrs = emitAttributes(node.attributes, aliasEnv, locals);
163
- const slotProps = emitSlotProps(node, aliasEnv, locals);
164
- const allAttrs = `${attrs}${slotProps}`;
165
- const children = emitChildrenWithSpacing(node.children, aliasEnv, locals);
166
- if (children.length > 0) {
167
- return `<${node.name}${allAttrs}>${children}</${node.name}>`;
168
- } else {
169
- return `<${node.name}${allAttrs} />`;
170
- }
171
- }
172
- function emitChildrenWithSpacing(children, aliasEnv, locals) {
173
- if (children.length === 0) {
174
- return "";
175
- }
176
- const parts = [];
177
- for (let i = 0; i < children.length; i++) {
178
- const child = children[i];
179
- const emitted = emitNodeInJsx(child, aliasEnv, locals);
180
- parts.push(emitted);
181
- if (i < children.length - 1) {
182
- const nextChild = children[i + 1];
183
- const needsSpace = child.type === "Text" && (nextChild.type === "Element" || nextChild.type === "Component" || nextChild.type === "Expression" || nextChild.type === "JSXPassthrough");
184
- if (needsSpace) {
185
- parts.push(" ");
186
- }
187
- }
188
- }
189
- return parts.join("");
190
- }
191
- function emitAttributes(attributes, aliasEnv, locals) {
192
- if (attributes.length === 0) {
193
- return "";
194
- }
195
- return attributes.map((attr) => {
196
- if (attr.value === null) {
197
- return ` ${attr.name}`;
198
- }
199
- return ` ${attr.name}=${emitAttributeValue(attr.value, locals)}`;
200
- }).join("");
201
- }
202
- function emitSlotProps(node, aliasEnv, locals) {
203
- if (!node.slots || node.slots.length === 0) {
204
- return "";
205
- }
206
- return node.slots.map((slot) => {
207
- const expr = emitNodesExpression(slot.children, aliasEnv, locals);
208
- return ` ${slot.name}={${expr}}`;
209
- }).join("");
210
- }
211
- function wrapWithGuard(rendered, guard, context, locals) {
212
- if (!guard) {
213
- return rendered;
214
- }
215
- const condition = emitExpressionValue(guard, locals);
216
- const expression = `(${condition}) && ${rendered}`;
217
- return context === "jsx" ? `{${expression}}` : expression;
218
- }
219
- function emitForExpression(node, aliasEnv, locals) {
220
- const arrayExpr = emitExpressionValue(node.arrayExpr, locals);
221
- const nextLocals = new Set(locals);
222
- nextLocals.add(node.itemName);
223
- const body = emitNodesExpression(node.body, aliasEnv, nextLocals);
224
- return `(${arrayExpr} ?? []).map((${node.itemName}) => ${body})`;
225
- }
226
- function expandClasses(classes, aliasEnv) {
227
- const result = [];
228
- for (const cls of classes) {
229
- const match = cls.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
230
- if (!match) {
231
- result.push(cls);
232
- continue;
233
- }
234
- const aliasClasses = aliasEnv.get(match[1]);
235
- if (!aliasClasses) {
236
- continue;
52
+ // src/rewrite.ts
53
+ function createTemplateEnv(propsDecls) {
54
+ const propAliases = /* @__PURE__ */ new Map();
55
+ if (propsDecls) {
56
+ for (const decl of propsDecls) {
57
+ propAliases.set(decl.name, decl.kind);
237
58
  }
238
- result.push(...aliasClasses);
239
59
  }
240
- return result;
241
- }
242
- function emitExpressionValue(expression, locals) {
243
- return rewriteExpression(expression, locals);
60
+ return {
61
+ propAliases,
62
+ localsStack: []
63
+ };
244
64
  }
245
- function emitJsxExpression(expression, locals) {
246
- const trimmed = expression.trimStart();
247
- if (trimmed.startsWith("<")) {
248
- return rewriteJsxExpression(expression, locals);
249
- }
250
- return rewriteExpression(expression, locals);
65
+ function pushLocals(env, names) {
66
+ const locals = new Set(names);
67
+ env.localsStack.push(locals);
251
68
  }
252
- function emitAttributeValue(value, locals) {
253
- const trimmed = value.trim();
254
- if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
255
- return value;
256
- }
257
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
258
- const inner = trimmed.slice(1, -1);
259
- const rewritten = rewriteExpression(inner, locals);
260
- return `{${rewritten}}`;
261
- }
262
- return rewriteExpression(trimmed, locals);
69
+ function popLocals(env) {
70
+ env.localsStack.pop();
263
71
  }
264
- function emitText(node, locals) {
265
- if (!node.parts.length) {
266
- return "";
267
- }
268
- return node.parts.map((part) => {
269
- if (part.type === "text") {
270
- return escapeText(part.value);
72
+ function isLocal(env, name) {
73
+ for (let i = env.localsStack.length - 1; i >= 0; i--) {
74
+ if (env.localsStack[i].has(name)) {
75
+ return true;
271
76
  }
272
- return `{${emitExpressionValue(part.value, locals)}}`;
273
- }).join("");
274
- }
275
- function emitConditionalExpression(node, aliasEnv, locals) {
276
- if (!node.branches.length) {
277
- return "null";
278
- }
279
- const first = node.branches[0];
280
- if (node.branches.length === 1 && first.test) {
281
- const test = emitExpressionValue(first.test, locals);
282
- return `(${test}) && ${emitBranchExpression(first, aliasEnv, locals)}`;
283
- }
284
- const hasElse = node.branches[node.branches.length - 1].test === void 0;
285
- let fallback = hasElse ? emitBranchExpression(node.branches[node.branches.length - 1], aliasEnv, locals) : "null";
286
- const startIndex = hasElse ? node.branches.length - 2 : node.branches.length - 1;
287
- if (startIndex < 0) {
288
- return fallback;
289
- }
290
- for (let i = startIndex; i >= 0; i--) {
291
- const branch = node.branches[i];
292
- const test = branch.test ? emitExpressionValue(branch.test, locals) : "false";
293
- fallback = `(${test}) ? ${emitBranchExpression(branch, aliasEnv, locals)} : ${fallback}`;
294
- }
295
- return fallback;
296
- }
297
- function emitBranchExpression(branch, aliasEnv, locals) {
298
- return emitNodesExpression(branch.body, aliasEnv, locals);
299
- }
300
- function emitNodesExpression(children, aliasEnv, locals) {
301
- if (children.length === 0) {
302
- return "null";
303
- }
304
- if (children.length === 1) {
305
- return emitSingleNodeExpression(children[0], aliasEnv, locals);
306
- }
307
- return `<>${children.map((child) => emitNodeInJsx(child, aliasEnv, locals)).join("")}</>`;
308
- }
309
- function emitSingleNodeExpression(node, aliasEnv, locals) {
310
- if (node.type === "Expression") {
311
- return emitExpressionValue(node.value, locals);
312
- }
313
- if (node.type === "JSXPassthrough") {
314
- return emitJsxExpression(node.expression, locals);
315
- }
316
- if (node.type === "Conditional") {
317
- return emitConditionalExpression(node, aliasEnv, locals);
318
- }
319
- if (node.type === "For") {
320
- return emitForExpression(node, aliasEnv, locals);
321
- }
322
- if (node.type === "Element") {
323
- return wrapWithGuard(emitElement(node, aliasEnv, locals), node.guard, "expression", locals);
324
- }
325
- if (node.type === "Component") {
326
- return wrapWithGuard(emitComponent(node, aliasEnv, locals), node.guard, "expression", locals);
327
- }
328
- if (node.type === "Text") {
329
- return `<>${emitNodeInJsx(node, aliasEnv, locals)}</>`;
330
- }
331
- return emitNodeInJsx(node, aliasEnv, locals);
332
- }
333
- function emitPropsType(props, flavor) {
334
- if (flavor === "tsx") {
335
- return emitTsPropsType(props);
336
- }
337
- return emitJsDocPropsType(props);
338
- }
339
- function emitJsDocPropsType(props) {
340
- if (!props) {
341
- return "/** @typedef {any} Props */";
342
- }
343
- if (!props.fields.length) {
344
- return "/** @typedef {{}} Props */";
345
- }
346
- const fields = props.fields.map((field) => {
347
- const optional = field.optional ? "?" : "";
348
- return `${field.name}${optional}: ${field.typeText}`;
349
- }).join("; ");
350
- return `/** @typedef {{ ${fields} }} Props */`;
351
- }
352
- function emitTsPropsType(props) {
353
- if (!props || props.fields.length === 0) {
354
- return "export type Props = Record<string, never>;";
355
77
  }
356
- const lines = props.fields.map((field) => {
357
- const optional = field.optional ? "?" : "";
358
- return ` ${field.name}${optional}: ${field.typeText};`;
359
- });
360
- return ["export interface Props {", ...lines, "}"].join("\n");
78
+ return false;
361
79
  }
362
- function emitPropsDestructure(props) {
363
- if (!props || props.fields.length === 0) {
364
- return null;
80
+ function isPropAlias(env, name) {
81
+ if (isLocal(env, name)) {
82
+ return false;
365
83
  }
366
- const names = props.fields.map((field) => field.name);
367
- return `const { ${names.join(", ")} } = props ?? {};`;
368
- }
369
- function escapeText(value) {
370
- return value.replace(/[&<>{}]/g, (char) => {
371
- switch (char) {
372
- case "&":
373
- return "&amp;";
374
- case "<":
375
- return "&lt;";
376
- case ">":
377
- return "&gt;";
378
- case "{":
379
- return "&#123;";
380
- case "}":
381
- return "&#125;";
382
- default:
383
- return char;
384
- }
385
- });
84
+ return env.propAliases.has(name);
386
85
  }
387
86
  var IGNORED_IDENTIFIERS = /* @__PURE__ */ new Set([
388
87
  "null",
@@ -435,13 +134,15 @@ var RESERVED_KEYWORDS = /* @__PURE__ */ new Set([
435
134
  "with",
436
135
  "yield"
437
136
  ]);
438
- function emitIdentifier(name) {
439
- return `props?.${name}`;
440
- }
441
- function rewriteExpression(expression, locals) {
137
+ function rewriteExpression(expression, env) {
442
138
  let i = 0;
443
139
  let state = "code";
444
140
  let output = "";
141
+ const usedBare = /* @__PURE__ */ new Set();
142
+ const usedPropsDot = /* @__PURE__ */ new Set();
143
+ const callSitesBare = /* @__PURE__ */ new Set();
144
+ const callSitesPropsDot = /* @__PURE__ */ new Set();
145
+ const rewrittenAliases = /* @__PURE__ */ new Set();
445
146
  while (i < expression.length) {
446
147
  const ch = expression[i];
447
148
  if (state === "code") {
@@ -479,12 +180,37 @@ function rewriteExpression(expression, locals) {
479
180
  const prevNonSpace = findPreviousNonSpace(expression, start - 1);
480
181
  const nextNonSpace = findNextNonSpace(expression, i);
481
182
  const isMemberAccess = prevNonSpace === ".";
482
- const isObjectKey = nextNonSpace === ":";
483
- if (isMemberAccess || isObjectKey || locals.has(name) || shouldIgnoreIdentifier(name)) {
183
+ const isObjectKey = nextNonSpace === ":" && (prevNonSpace === "{" || prevNonSpace === ",");
184
+ const isCall = nextNonSpace === "(";
185
+ if (prevNonSpace === "." && start >= 2) {
186
+ const propsStart = findPreviousIdentifierStart(expression, start - 2);
187
+ if (propsStart !== null) {
188
+ const possibleProps = expression.slice(propsStart, start - 1).trim();
189
+ if (possibleProps === "props") {
190
+ usedPropsDot.add(name);
191
+ if (isCall) {
192
+ callSitesPropsDot.add(name);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ if (isMemberAccess || isObjectKey || isLocal(env, name) || shouldIgnoreIdentifier(name)) {
484
198
  output += name;
485
199
  continue;
486
200
  }
487
- output += emitIdentifier(name);
201
+ if (isPropAlias(env, name)) {
202
+ output += `props.${name}`;
203
+ rewrittenAliases.add(name);
204
+ if (isCall) {
205
+ callSitesBare.add(name);
206
+ }
207
+ continue;
208
+ }
209
+ usedBare.add(name);
210
+ if (isCall) {
211
+ callSitesBare.add(name);
212
+ }
213
+ output += name;
488
214
  continue;
489
215
  }
490
216
  output += ch;
@@ -556,11 +282,16 @@ function rewriteExpression(expression, locals) {
556
282
  continue;
557
283
  }
558
284
  }
559
- return output;
285
+ return { code: output, usedBare, usedPropsDot, callSitesBare, callSitesPropsDot, rewrittenAliases };
560
286
  }
561
- function rewriteJsxExpression(expression, locals) {
287
+ function rewriteJsxExpression(expression, env) {
562
288
  let output = "";
563
289
  let i = 0;
290
+ const usedBare = /* @__PURE__ */ new Set();
291
+ const usedPropsDot = /* @__PURE__ */ new Set();
292
+ const callSitesBare = /* @__PURE__ */ new Set();
293
+ const callSitesPropsDot = /* @__PURE__ */ new Set();
294
+ const rewrittenAliases = /* @__PURE__ */ new Set();
564
295
  while (i < expression.length) {
565
296
  const ch = expression[i];
566
297
  if (ch === "{") {
@@ -569,15 +300,20 @@ function rewriteJsxExpression(expression, locals) {
569
300
  output += expression.slice(i);
570
301
  break;
571
302
  }
572
- const rewritten = rewriteExpression(braceResult.content, locals);
573
- output += `{${rewritten}}`;
303
+ const result = rewriteExpression(braceResult.content, env);
304
+ output += `{${result.code}}`;
305
+ for (const name of result.usedBare) usedBare.add(name);
306
+ for (const name of result.usedPropsDot) usedPropsDot.add(name);
307
+ for (const name of result.callSitesBare) callSitesBare.add(name);
308
+ for (const name of result.callSitesPropsDot) callSitesPropsDot.add(name);
309
+ for (const name of result.rewrittenAliases) rewrittenAliases.add(name);
574
310
  i = braceResult.endIndex + 1;
575
311
  continue;
576
312
  }
577
313
  output += ch;
578
314
  i++;
579
315
  }
580
- return output;
316
+ return { code: output, usedBare, usedPropsDot, callSitesBare, callSitesPropsDot, rewrittenAliases };
581
317
  }
582
318
  function readBalancedBraces(source, startIndex) {
583
319
  let i = startIndex;
@@ -667,34 +403,385 @@ function readBalancedBraces(source, startIndex) {
667
403
  continue;
668
404
  }
669
405
  }
670
- return null;
406
+ return null;
407
+ }
408
+ function findPreviousNonSpace(text, index) {
409
+ for (let i = index; i >= 0; i--) {
410
+ const ch = text[i];
411
+ if (!/\s/.test(ch)) {
412
+ return ch;
413
+ }
414
+ }
415
+ return null;
416
+ }
417
+ function findNextNonSpace(text, index) {
418
+ for (let i = index; i < text.length; i++) {
419
+ const ch = text[i];
420
+ if (!/\s/.test(ch)) {
421
+ return ch;
422
+ }
423
+ }
424
+ return null;
425
+ }
426
+ function findPreviousIdentifierStart(text, index) {
427
+ let i = index;
428
+ while (i >= 0 && /\s/.test(text[i])) {
429
+ i--;
430
+ }
431
+ if (i < 0) return null;
432
+ if (!isIdentifierPart(text[i])) {
433
+ return null;
434
+ }
435
+ while (i > 0 && isIdentifierPart(text[i - 1])) {
436
+ i--;
437
+ }
438
+ return i;
439
+ }
440
+ function isIdentifierStart(ch) {
441
+ return /[A-Za-z_$]/.test(ch);
442
+ }
443
+ function isIdentifierPart(ch) {
444
+ return /[A-Za-z0-9_$]/.test(ch);
445
+ }
446
+ function shouldIgnoreIdentifier(name) {
447
+ return IGNORED_IDENTIFIERS.has(name) || RESERVED_KEYWORDS.has(name);
448
+ }
449
+
450
+ // src/codegen.ts
451
+ function generateRenderModule(root, options) {
452
+ const { prelude, propsType, propsDestructure, jsx, isTsx } = buildModuleParts(root, options);
453
+ const parts = [...prelude, propsType];
454
+ if (!isTsx) {
455
+ parts.push(`/** @param {any} props */`);
456
+ }
457
+ const functionLines = [
458
+ isTsx ? "export function render(props: any) {" : "export function render(props) {"
459
+ ];
460
+ if (propsDestructure) {
461
+ functionLines.push(` ${propsDestructure}`);
462
+ }
463
+ functionLines.push(` return ${jsx};`, `}`);
464
+ parts.push(functionLines.join("\n"));
465
+ return parts.join("\n\n");
466
+ }
467
+ function buildModuleParts(root, options) {
468
+ const { jsxRuntime, flavor } = options;
469
+ const isTsx = flavor === "tsx";
470
+ const aliasEnv = buildClassAliasEnvironment(root.classAliases);
471
+ const env = createTemplateEnv(root.propsDecls);
472
+ const jsx = renderRootChildren(root.children, aliasEnv, env);
473
+ const propsDestructure = emitPropsDestructure(root.props);
474
+ const prelude = [];
475
+ if (root.clientComponent) {
476
+ prelude.push(`"use client";`);
477
+ }
478
+ if (jsxRuntime === "classic" && templateUsesJsx(root)) {
479
+ prelude.push(`import React from "react";`);
480
+ }
481
+ const propsType = emitPropsType(root.props, flavor);
482
+ return { prelude, propsType, propsDestructure, jsx, isTsx };
483
+ }
484
+ function buildClassAliasEnvironment(decl) {
485
+ const env = /* @__PURE__ */ new Map();
486
+ if (!decl) {
487
+ return env;
488
+ }
489
+ for (const alias of decl.aliases) {
490
+ env.set(alias.name, alias.classes);
491
+ }
492
+ return env;
493
+ }
494
+ function renderRootChildren(children, aliasEnv, env) {
495
+ return emitNodesExpression(children, aliasEnv, env);
496
+ }
497
+ function templateUsesJsx(root) {
498
+ if (root.children.length === 0) {
499
+ return false;
500
+ }
501
+ if (root.children.length > 1) {
502
+ return true;
503
+ }
504
+ return nodeUsesJsx(root.children[0]);
505
+ }
506
+ function nodeUsesJsx(node) {
507
+ if (node.type === "Element" || node.type === "Text" || node.type === "Component") {
508
+ return true;
509
+ }
510
+ if (node.type === "Expression" || node.type === "JSXPassthrough") {
511
+ return false;
512
+ }
513
+ if (node.type === "Conditional") {
514
+ return node.branches.some((branch) => branchUsesJsx(branch));
515
+ }
516
+ if (node.type === "For") {
517
+ return node.body.some((child) => nodeUsesJsx(child));
518
+ }
519
+ return false;
520
+ }
521
+ function branchUsesJsx(branch) {
522
+ if (!branch.body.length) {
523
+ return false;
524
+ }
525
+ return branch.body.some((child) => nodeUsesJsx(child));
526
+ }
527
+ function emitNodeInJsx(node, aliasEnv, env) {
528
+ if (node.type === "Text") {
529
+ return emitText(node, env);
530
+ }
531
+ if (node.type === "Expression") {
532
+ return `{${emitExpressionValue(node.value, env)}}`;
533
+ }
534
+ if (node.type === "JSXPassthrough") {
535
+ return `{${emitJsxExpression(node.expression, env)}}`;
536
+ }
537
+ if (node.type === "Conditional") {
538
+ return `{${emitConditionalExpression(node, aliasEnv, env)}}`;
539
+ }
540
+ if (node.type === "For") {
541
+ return `{${emitForExpression(node, aliasEnv, env)}}`;
542
+ }
543
+ if (node.type === "Component") {
544
+ return wrapWithGuard(emitComponent(node, aliasEnv, env), node.guard, "jsx", env);
545
+ }
546
+ return wrapWithGuard(emitElement(node, aliasEnv, env), node.guard, "jsx", env);
547
+ }
548
+ function emitElement(node, aliasEnv, env) {
549
+ const expanded = expandClasses(node.classes, aliasEnv);
550
+ const classAttr = expanded.length ? ` className="${expanded.join(" ")}"` : "";
551
+ const attrs = emitAttributes(node.attributes, aliasEnv, env);
552
+ const allAttrs = classAttr + attrs;
553
+ const children = emitChildrenWithSpacing(node.children, aliasEnv, env);
554
+ if (children.length > 0) {
555
+ return `<${node.name}${allAttrs}>${children}</${node.name}>`;
556
+ } else {
557
+ return `<${node.name}${allAttrs} />`;
558
+ }
559
+ }
560
+ function emitComponent(node, aliasEnv, env) {
561
+ const attrs = emitAttributes(node.attributes, aliasEnv, env);
562
+ const slotProps = emitSlotProps(node, aliasEnv, env);
563
+ const allAttrs = `${attrs}${slotProps}`;
564
+ const children = emitChildrenWithSpacing(node.children, aliasEnv, env);
565
+ if (children.length > 0) {
566
+ return `<${node.name}${allAttrs}>${children}</${node.name}>`;
567
+ } else {
568
+ return `<${node.name}${allAttrs} />`;
569
+ }
570
+ }
571
+ function emitChildrenWithSpacing(children, aliasEnv, env) {
572
+ if (children.length === 0) {
573
+ return "";
574
+ }
575
+ const parts = [];
576
+ for (let i = 0; i < children.length; i++) {
577
+ const child = children[i];
578
+ const emitted = emitNodeInJsx(child, aliasEnv, env);
579
+ parts.push(emitted);
580
+ if (i < children.length - 1) {
581
+ const nextChild = children[i + 1];
582
+ const needsSpace = child.type === "Text" && (nextChild.type === "Element" || nextChild.type === "Component" || nextChild.type === "Expression" || nextChild.type === "JSXPassthrough");
583
+ if (needsSpace) {
584
+ parts.push(" ");
585
+ }
586
+ }
587
+ }
588
+ return parts.join("");
589
+ }
590
+ function emitAttributes(attributes, aliasEnv, env) {
591
+ if (attributes.length === 0) {
592
+ return "";
593
+ }
594
+ return attributes.map((attr) => {
595
+ if (attr.value === null) {
596
+ return ` ${attr.name}`;
597
+ }
598
+ return ` ${attr.name}=${emitAttributeValue(attr.value, env)}`;
599
+ }).join("");
600
+ }
601
+ function emitSlotProps(node, aliasEnv, env) {
602
+ if (!node.slots || node.slots.length === 0) {
603
+ return "";
604
+ }
605
+ return node.slots.map((slot) => {
606
+ const expr = emitNodesExpression(slot.children, aliasEnv, env);
607
+ return ` ${slot.name}={${expr}}`;
608
+ }).join("");
609
+ }
610
+ function wrapWithGuard(rendered, guard, context, env) {
611
+ if (!guard) {
612
+ return rendered;
613
+ }
614
+ const condition = emitExpressionValue(guard, env);
615
+ const expression = `(${condition}) && ${rendered}`;
616
+ return context === "jsx" ? `{${expression}}` : expression;
617
+ }
618
+ function emitForExpression(node, aliasEnv, env) {
619
+ const arrayExpr = emitExpressionValue(node.arrayExpr, env);
620
+ pushLocals(env, [node.itemName]);
621
+ const body = emitNodesExpression(node.body, aliasEnv, env);
622
+ popLocals(env);
623
+ return `(${arrayExpr} ?? []).map((${node.itemName}) => ${body})`;
624
+ }
625
+ function expandClasses(classes, aliasEnv) {
626
+ const result = [];
627
+ for (const cls of classes) {
628
+ const match = cls.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
629
+ if (!match) {
630
+ result.push(cls);
631
+ continue;
632
+ }
633
+ const aliasClasses = aliasEnv.get(match[1]);
634
+ if (!aliasClasses) {
635
+ continue;
636
+ }
637
+ result.push(...aliasClasses);
638
+ }
639
+ return result;
640
+ }
641
+ function emitExpressionValue(expression, env) {
642
+ return rewriteExpression(expression, env).code;
643
+ }
644
+ function emitJsxExpression(expression, env) {
645
+ const trimmed = expression.trimStart();
646
+ if (trimmed.startsWith("<")) {
647
+ return rewriteJsxExpression(expression, env).code;
648
+ }
649
+ return rewriteExpression(expression, env).code;
650
+ }
651
+ function emitAttributeValue(value, env) {
652
+ const trimmed = value.trim();
653
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
654
+ return value;
655
+ }
656
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
657
+ const inner = trimmed.slice(1, -1);
658
+ const rewritten = rewriteExpression(inner, env).code;
659
+ return `{${rewritten}}`;
660
+ }
661
+ return rewriteExpression(trimmed, env).code;
662
+ }
663
+ function emitText(node, env) {
664
+ if (!node.parts.length) {
665
+ return "";
666
+ }
667
+ return node.parts.map((part) => {
668
+ if (part.type === "text") {
669
+ return escapeText(part.value);
670
+ }
671
+ return `{${emitExpressionValue(part.value, env)}}`;
672
+ }).join("");
673
+ }
674
+ function emitConditionalExpression(node, aliasEnv, env) {
675
+ if (!node.branches.length) {
676
+ return "null";
677
+ }
678
+ const first = node.branches[0];
679
+ if (node.branches.length === 1 && first.test) {
680
+ const test = emitExpressionValue(first.test, env);
681
+ return `(${test}) && ${emitBranchExpression(first, aliasEnv, env)}`;
682
+ }
683
+ const hasElse = node.branches[node.branches.length - 1].test === void 0;
684
+ let fallback = hasElse ? emitBranchExpression(node.branches[node.branches.length - 1], aliasEnv, env) : "null";
685
+ const startIndex = hasElse ? node.branches.length - 2 : node.branches.length - 1;
686
+ if (startIndex < 0) {
687
+ return fallback;
688
+ }
689
+ for (let i = startIndex; i >= 0; i--) {
690
+ const branch = node.branches[i];
691
+ const test = branch.test ? emitExpressionValue(branch.test, env) : "false";
692
+ fallback = `(${test}) ? ${emitBranchExpression(branch, aliasEnv, env)} : ${fallback}`;
693
+ }
694
+ return fallback;
695
+ }
696
+ function emitBranchExpression(branch, aliasEnv, env) {
697
+ return emitNodesExpression(branch.body, aliasEnv, env);
698
+ }
699
+ function emitNodesExpression(children, aliasEnv, env) {
700
+ if (children.length === 0) {
701
+ return "null";
702
+ }
703
+ if (children.length === 1) {
704
+ return emitSingleNodeExpression(children[0], aliasEnv, env);
705
+ }
706
+ return `<>${children.map((child) => emitNodeInJsx(child, aliasEnv, env)).join("")}</>`;
671
707
  }
672
- function findPreviousNonSpace(text, index) {
673
- for (let i = index; i >= 0; i--) {
674
- const ch = text[i];
675
- if (!/\s/.test(ch)) {
676
- return ch;
677
- }
708
+ function emitSingleNodeExpression(node, aliasEnv, env) {
709
+ if (node.type === "Expression") {
710
+ return emitExpressionValue(node.value, env);
678
711
  }
679
- return null;
712
+ if (node.type === "JSXPassthrough") {
713
+ return emitJsxExpression(node.expression, env);
714
+ }
715
+ if (node.type === "Conditional") {
716
+ return emitConditionalExpression(node, aliasEnv, env);
717
+ }
718
+ if (node.type === "For") {
719
+ return emitForExpression(node, aliasEnv, env);
720
+ }
721
+ if (node.type === "Element") {
722
+ return wrapWithGuard(emitElement(node, aliasEnv, env), node.guard, "expression", env);
723
+ }
724
+ if (node.type === "Component") {
725
+ return wrapWithGuard(emitComponent(node, aliasEnv, env), node.guard, "expression", env);
726
+ }
727
+ if (node.type === "Text") {
728
+ return `<>${emitNodeInJsx(node, aliasEnv, env)}</>`;
729
+ }
730
+ return emitNodeInJsx(node, aliasEnv, env);
680
731
  }
681
- function findNextNonSpace(text, index) {
682
- for (let i = index; i < text.length; i++) {
683
- const ch = text[i];
684
- if (!/\s/.test(ch)) {
685
- return ch;
686
- }
732
+ function emitPropsType(props, flavor) {
733
+ if (flavor === "tsx") {
734
+ return emitTsPropsType(props);
687
735
  }
688
- return null;
736
+ return emitJsDocPropsType(props);
689
737
  }
690
- function isIdentifierStart(ch) {
691
- return /[A-Za-z_$]/.test(ch);
738
+ function emitJsDocPropsType(props) {
739
+ if (!props) {
740
+ return "/** @typedef {any} Props */";
741
+ }
742
+ if (!props.fields.length) {
743
+ return "/** @typedef {{}} Props */";
744
+ }
745
+ const fields = props.fields.map((field) => {
746
+ const optional = field.optional ? "?" : "";
747
+ return `${field.name}${optional}: ${field.typeText}`;
748
+ }).join("; ");
749
+ return `/** @typedef {{ ${fields} }} Props */`;
692
750
  }
693
- function isIdentifierPart(ch) {
694
- return /[A-Za-z0-9_$]/.test(ch);
751
+ function emitTsPropsType(props) {
752
+ if (!props || props.fields.length === 0) {
753
+ return "export type Props = Record<string, never>;";
754
+ }
755
+ const lines = props.fields.map((field) => {
756
+ const optional = field.optional ? "?" : "";
757
+ return ` ${field.name}${optional}: ${field.typeText};`;
758
+ });
759
+ return ["export interface Props {", ...lines, "}"].join("\n");
695
760
  }
696
- function shouldIgnoreIdentifier(name) {
697
- return IGNORED_IDENTIFIERS.has(name) || RESERVED_KEYWORDS.has(name);
761
+ function emitPropsDestructure(props) {
762
+ if (!props || props.fields.length === 0) {
763
+ return null;
764
+ }
765
+ const names = props.fields.map((field) => field.name);
766
+ return `const { ${names.join(", ")} } = props ?? {};`;
767
+ }
768
+ function escapeText(value) {
769
+ return value.replace(/[&<>{}]/g, (char) => {
770
+ switch (char) {
771
+ case "&":
772
+ return "&amp;";
773
+ case "<":
774
+ return "&lt;";
775
+ case ">":
776
+ return "&gt;";
777
+ case "{":
778
+ return "&#123;";
779
+ case "}":
780
+ return "&#125;";
781
+ default:
782
+ return char;
783
+ }
784
+ });
698
785
  }
699
786
 
700
787
  // src/html-codegen.ts
@@ -1490,6 +1577,185 @@ function offsetSpan(base, index, length) {
1490
1577
  }
1491
1578
  };
1492
1579
  }
1580
+ function enforcePropAliases(root) {
1581
+ if (!root.propsDecls || root.propsDecls.length === 0) {
1582
+ return [];
1583
+ }
1584
+ const diagnostics = [];
1585
+ const declaredProps = new Map(root.propsDecls.map((d) => [d.name, d]));
1586
+ const allUsage = collectTemplateUsage(root);
1587
+ for (const name of allUsage.usedBare) {
1588
+ if (!declaredProps.has(name) && !shouldIgnoreForDiagnostics(name)) {
1589
+ diagnostics.push({
1590
+ severity: "warning",
1591
+ code: "props.missingDeclaration",
1592
+ message: `Identifier "${name}" is used without "props." but is not declared in #props. Declare "${name}" in #props or use "props.${name}".`
1593
+ });
1594
+ }
1595
+ }
1596
+ for (const [name, decl] of declaredProps) {
1597
+ const usedAsBare = allUsage.usedBareAliases.has(name);
1598
+ const usedAsProps = allUsage.usedPropsDot.has(name);
1599
+ if (!usedAsBare && !usedAsProps) {
1600
+ diagnostics.push({
1601
+ severity: "warning",
1602
+ code: "props.unusedDeclaration",
1603
+ message: `Prop "${name}" is declared in #props but never used in this template.`,
1604
+ span: decl.span
1605
+ });
1606
+ }
1607
+ }
1608
+ for (const name of allUsage.usedPropsDot) {
1609
+ if (declaredProps.has(name)) {
1610
+ diagnostics.push({
1611
+ severity: "warning",
1612
+ code: "props.style.nonPreferred",
1613
+ message: `"props.${name}" is unnecessary because "${name}" is declared in #props. Use "{${name}}" instead.`
1614
+ });
1615
+ }
1616
+ }
1617
+ for (const [name, decl] of declaredProps) {
1618
+ const isCallable = decl.kind === "callable";
1619
+ const usedAsCall = allUsage.callSitesBare.has(name);
1620
+ const usedAsValue = allUsage.usedBareAliases.has(name) && !usedAsCall;
1621
+ if (isCallable && usedAsValue) {
1622
+ diagnostics.push({
1623
+ severity: "warning",
1624
+ code: "props.style.nonPreferred",
1625
+ message: `"${name}" is declared as callable in #props (${name}()) but used as a value.`
1626
+ });
1627
+ } else if (!isCallable && usedAsCall) {
1628
+ diagnostics.push({
1629
+ severity: "warning",
1630
+ code: "props.style.nonPreferred",
1631
+ message: `"${name}" is declared as a value in #props but used as a function call.`
1632
+ });
1633
+ }
1634
+ }
1635
+ return diagnostics;
1636
+ }
1637
+ function collectTemplateUsage(root) {
1638
+ const usage = {
1639
+ usedBare: /* @__PURE__ */ new Set(),
1640
+ usedBareAliases: /* @__PURE__ */ new Set(),
1641
+ usedPropsDot: /* @__PURE__ */ new Set(),
1642
+ callSitesBare: /* @__PURE__ */ new Set(),
1643
+ callSitesPropsDot: /* @__PURE__ */ new Set()
1644
+ };
1645
+ const env = createTemplateEnv(root.propsDecls);
1646
+ function mergeResult(result) {
1647
+ for (const name of result.usedBare) {
1648
+ usage.usedBare.add(name);
1649
+ }
1650
+ for (const name of result.rewrittenAliases) {
1651
+ usage.usedBareAliases.add(name);
1652
+ }
1653
+ for (const name of result.usedPropsDot) {
1654
+ usage.usedPropsDot.add(name);
1655
+ }
1656
+ for (const name of result.callSitesBare) {
1657
+ usage.callSitesBare.add(name);
1658
+ }
1659
+ for (const name of result.callSitesPropsDot) {
1660
+ usage.callSitesPropsDot.add(name);
1661
+ }
1662
+ }
1663
+ function analyzeExpression(expr) {
1664
+ if (!expr) return;
1665
+ const result = rewriteExpression(expr, env);
1666
+ mergeResult(result);
1667
+ }
1668
+ function analyzeJsxExpression(expr) {
1669
+ if (!expr) return;
1670
+ const result = rewriteJsxExpression(expr, env);
1671
+ mergeResult(result);
1672
+ }
1673
+ function walkNode(node) {
1674
+ switch (node.type) {
1675
+ case "Text":
1676
+ for (const part of node.parts) {
1677
+ if (part.type === "expr") {
1678
+ analyzeExpression(part.value);
1679
+ }
1680
+ }
1681
+ break;
1682
+ case "Expression":
1683
+ analyzeExpression(node.value);
1684
+ break;
1685
+ case "JSXPassthrough":
1686
+ analyzeJsxExpression(node.expression);
1687
+ break;
1688
+ case "Element":
1689
+ if (node.guard) {
1690
+ analyzeExpression(node.guard);
1691
+ }
1692
+ for (const attr of node.attributes) {
1693
+ if (attr.value) {
1694
+ analyzeAttributeValue(attr.value);
1695
+ }
1696
+ }
1697
+ for (const child of node.children) {
1698
+ walkNode(child);
1699
+ }
1700
+ break;
1701
+ case "Component":
1702
+ if (node.guard) {
1703
+ analyzeExpression(node.guard);
1704
+ }
1705
+ for (const attr of node.attributes) {
1706
+ if (attr.value) {
1707
+ analyzeAttributeValue(attr.value);
1708
+ }
1709
+ }
1710
+ if (node.slots) {
1711
+ for (const slot of node.slots) {
1712
+ for (const child of slot.children) {
1713
+ walkNode(child);
1714
+ }
1715
+ }
1716
+ }
1717
+ for (const child of node.children) {
1718
+ walkNode(child);
1719
+ }
1720
+ break;
1721
+ case "Conditional":
1722
+ for (const branch of node.branches) {
1723
+ if (branch.test) {
1724
+ analyzeExpression(branch.test);
1725
+ }
1726
+ for (const child of branch.body) {
1727
+ walkNode(child);
1728
+ }
1729
+ }
1730
+ break;
1731
+ case "For":
1732
+ analyzeExpression(node.arrayExpr);
1733
+ for (const child of node.body) {
1734
+ walkNode(child);
1735
+ }
1736
+ break;
1737
+ }
1738
+ }
1739
+ function analyzeAttributeValue(value) {
1740
+ const trimmed = value.trim();
1741
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
1742
+ return;
1743
+ }
1744
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
1745
+ const inner = trimmed.slice(1, -1);
1746
+ analyzeExpression(inner);
1747
+ } else {
1748
+ analyzeExpression(trimmed);
1749
+ }
1750
+ }
1751
+ for (const child of root.children) {
1752
+ walkNode(child);
1753
+ }
1754
+ return usage;
1755
+ }
1756
+ function shouldIgnoreForDiagnostics(name) {
1757
+ return IGNORED_IDENTIFIERS2.has(name) || RESERVED_KEYWORDS2.has(name);
1758
+ }
1493
1759
 
1494
1760
  // src/parser.ts
1495
1761
  var TEMPLATE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9._-]*$/;
@@ -1806,6 +2072,7 @@ function parseTemplateBlock(lines, lineOffsets, startIndex, endIndex, options) {
1806
2072
  );
1807
2073
  } else {
1808
2074
  root.props = { fields: [] };
2075
+ root.propsDecls = [];
1809
2076
  }
1810
2077
  if (level === 0) {
1811
2078
  propsBlockLevel = level;
@@ -1860,6 +2127,23 @@ function parseTemplateBlock(lines, lineOffsets, startIndex, endIndex, options) {
1860
2127
  );
1861
2128
  continue;
1862
2129
  }
2130
+ const decl = parsePropDecl(lineContent, lineNumber, indent + 1, lineOffset, diagnostics);
2131
+ if (decl && root.propsDecls) {
2132
+ const existing = root.propsDecls.find((d) => d.name === decl.name);
2133
+ if (existing) {
2134
+ pushDiag(
2135
+ diagnostics,
2136
+ "COLLIE106",
2137
+ `Duplicate prop declaration "${decl.name}".`,
2138
+ lineNumber,
2139
+ indent + 1,
2140
+ lineOffset,
2141
+ trimmed.length
2142
+ );
2143
+ } else {
2144
+ root.propsDecls.push(decl);
2145
+ }
2146
+ }
1863
2147
  continue;
1864
2148
  }
1865
2149
  if (classesBlockLevel !== null && level > classesBlockLevel) {
@@ -2272,6 +2556,7 @@ function parseTemplateBlock(lines, lineOffsets, startIndex, endIndex, options) {
2272
2556
  diagnostics.push(...enforceDialect(root, options.dialect));
2273
2557
  diagnostics.push(...enforceProps(root, options.dialect.props));
2274
2558
  }
2559
+ diagnostics.push(...enforcePropAliases(root));
2275
2560
  return { root, diagnostics };
2276
2561
  }
2277
2562
  function buildLineOffsets(lines) {
@@ -2322,7 +2607,7 @@ function parseConditionalHeader(kind, lineContent, lineNumber, column, lineOffse
2322
2607
  pushDiag(
2323
2608
  diagnostics,
2324
2609
  "COLLIE201",
2325
- kind === "if" ? "Invalid @if syntax. Use @if <condition>." : "Invalid @elseIf syntax. Use @elseIf <condition>.",
2610
+ kind === "if" ? "Invalid @if syntax. Use @if (condition)." : "Invalid @elseIf syntax. Use @elseIf (condition).",
2326
2611
  lineNumber,
2327
2612
  column,
2328
2613
  lineOffset,
@@ -2346,29 +2631,36 @@ function parseConditionalHeader(kind, lineContent, lineNumber, column, lineOffse
2346
2631
  }
2347
2632
  const remainderTrimmed = remainder.trimStart();
2348
2633
  const usesParens = remainderTrimmed.startsWith("(");
2634
+ if (!usesParens) {
2635
+ pushDiag(
2636
+ diagnostics,
2637
+ "COLLIE211",
2638
+ kind === "if" ? "@if requires parentheses: @if (condition)" : "@elseIf requires parentheses: @elseIf (condition)",
2639
+ lineNumber,
2640
+ column,
2641
+ lineOffset,
2642
+ trimmed.length || token.length
2643
+ );
2644
+ return null;
2645
+ }
2349
2646
  let testRaw = "";
2350
2647
  let remainderRaw = "";
2351
- if (usesParens) {
2352
- const openIndex = trimmed.indexOf("(", token.length);
2353
- const closeIndex = trimmed.lastIndexOf(")");
2354
- if (openIndex === -1 || closeIndex <= openIndex) {
2355
- pushDiag(
2356
- diagnostics,
2357
- "COLLIE201",
2358
- kind === "if" ? "Invalid @if syntax. Use @if <condition>." : "Invalid @elseIf syntax. Use @elseIf <condition>.",
2359
- lineNumber,
2360
- column,
2361
- lineOffset,
2362
- trimmed.length || token.length
2363
- );
2364
- return null;
2365
- }
2366
- testRaw = trimmed.slice(openIndex + 1, closeIndex);
2367
- remainderRaw = trimmed.slice(closeIndex + 1);
2368
- } else {
2369
- testRaw = remainderTrimmed;
2370
- remainderRaw = "";
2648
+ const openIndex = trimmed.indexOf("(", token.length);
2649
+ const closeIndex = trimmed.lastIndexOf(")");
2650
+ if (openIndex === -1 || closeIndex <= openIndex) {
2651
+ pushDiag(
2652
+ diagnostics,
2653
+ "COLLIE212",
2654
+ kind === "if" ? "Unclosed parentheses in @if ( ... )" : "Unclosed parentheses in @elseIf ( ... )",
2655
+ lineNumber,
2656
+ column,
2657
+ lineOffset,
2658
+ trimmed.length || token.length
2659
+ );
2660
+ return null;
2371
2661
  }
2662
+ testRaw = trimmed.slice(openIndex + 1, closeIndex);
2663
+ remainderRaw = trimmed.slice(closeIndex + 1);
2372
2664
  const test = testRaw.trim();
2373
2665
  if (!test) {
2374
2666
  pushDiag(
@@ -2417,7 +2709,20 @@ function parseElseHeader(lineContent, lineNumber, column, lineOffset, diagnostic
2417
2709
  const token = "@else";
2418
2710
  const tokenSpan = createSpan(lineNumber, column, token.length, lineOffset);
2419
2711
  const remainderRaw = match[1] ?? "";
2420
- const inlineBody = remainderRaw.trim();
2712
+ const remainderTrimmed = remainderRaw.trim();
2713
+ if (remainderTrimmed.startsWith("(")) {
2714
+ pushDiag(
2715
+ diagnostics,
2716
+ "COLLIE213",
2717
+ "@else does not accept a condition",
2718
+ lineNumber,
2719
+ column,
2720
+ lineOffset,
2721
+ trimmed.length || 4
2722
+ );
2723
+ return null;
2724
+ }
2725
+ const inlineBody = remainderTrimmed;
2421
2726
  const remainderOffset = trimmed.length - remainderRaw.length;
2422
2727
  const leadingWhitespace = remainderRaw.length - inlineBody.length;
2423
2728
  const inlineColumn = inlineBody.length > 0 ? column + remainderOffset + leadingWhitespace : void 0;
@@ -2431,12 +2736,12 @@ function parseElseHeader(lineContent, lineNumber, column, lineOffset, diagnostic
2431
2736
  }
2432
2737
  function parseForHeader(lineContent, lineNumber, column, lineOffset, diagnostics) {
2433
2738
  const trimmed = lineContent.trimEnd();
2434
- const match = trimmed.match(/^@for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.+)$/);
2435
- if (!match) {
2739
+ const token = "@for";
2740
+ if (!trimmed.startsWith(token)) {
2436
2741
  pushDiag(
2437
2742
  diagnostics,
2438
2743
  "COLLIE210",
2439
- "Invalid @for syntax. Use @for itemName in arrayExpr.",
2744
+ "Invalid @for syntax. Use @for (item in array).",
2440
2745
  lineNumber,
2441
2746
  column,
2442
2747
  lineOffset,
@@ -2444,15 +2749,55 @@ function parseForHeader(lineContent, lineNumber, column, lineOffset, diagnostics
2444
2749
  );
2445
2750
  return null;
2446
2751
  }
2447
- const token = "@for";
2448
2752
  const tokenSpan = createSpan(lineNumber, column, token.length, lineOffset);
2753
+ const remainder = trimmed.slice(token.length).trimStart();
2754
+ if (!remainder.startsWith("(")) {
2755
+ pushDiag(
2756
+ diagnostics,
2757
+ "COLLIE211",
2758
+ "@for requires parentheses: @for (item in array)",
2759
+ lineNumber,
2760
+ column,
2761
+ lineOffset,
2762
+ trimmed.length || 4
2763
+ );
2764
+ return null;
2765
+ }
2766
+ const openIndex = trimmed.indexOf("(", token.length);
2767
+ const closeIndex = trimmed.lastIndexOf(")");
2768
+ if (openIndex === -1 || closeIndex <= openIndex) {
2769
+ pushDiag(
2770
+ diagnostics,
2771
+ "COLLIE212",
2772
+ "Unclosed parentheses in @for ( ... )",
2773
+ lineNumber,
2774
+ column,
2775
+ lineOffset,
2776
+ trimmed.length || 4
2777
+ );
2778
+ return null;
2779
+ }
2780
+ const content = trimmed.slice(openIndex + 1, closeIndex).trim();
2781
+ const match = content.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.+)$/);
2782
+ if (!match) {
2783
+ pushDiag(
2784
+ diagnostics,
2785
+ "COLLIE210",
2786
+ "Invalid @for syntax. Use @for (item in array).",
2787
+ lineNumber,
2788
+ column,
2789
+ lineOffset,
2790
+ trimmed.length || 4
2791
+ );
2792
+ return null;
2793
+ }
2449
2794
  const itemName = match[1];
2450
2795
  const arrayExprRaw = match[2];
2451
2796
  if (!itemName || !arrayExprRaw) {
2452
2797
  pushDiag(
2453
2798
  diagnostics,
2454
2799
  "COLLIE210",
2455
- "Invalid @for syntax. Use @for itemName in arrayExpr.",
2800
+ "Invalid @for syntax. Use @for (item in array).",
2456
2801
  lineNumber,
2457
2802
  column,
2458
2803
  lineOffset,
@@ -2474,8 +2819,9 @@ function parseForHeader(lineContent, lineNumber, column, lineOffset, diagnostics
2474
2819
  return null;
2475
2820
  }
2476
2821
  const arrayExprLeadingWhitespace = arrayExprRaw.length - arrayExprRaw.trimStart().length;
2477
- const arrayExprStart = trimmed.length - arrayExprRaw.length;
2478
- const arrayExprColumn = column + arrayExprStart + arrayExprLeadingWhitespace;
2822
+ const contentStart = openIndex + 1;
2823
+ const arrayExprStartInContent = content.length - arrayExprRaw.length;
2824
+ const arrayExprColumn = column + contentStart + arrayExprStartInContent + arrayExprLeadingWhitespace;
2479
2825
  const arrayExprSpan = createSpan(lineNumber, arrayExprColumn, arrayExpr.length, lineOffset);
2480
2826
  return { itemName, arrayExpr, token, tokenSpan, arrayExprSpan };
2481
2827
  }
@@ -3370,6 +3716,53 @@ function parseAndAddAttribute(attrStr, attributes, diagnostics, lineNumber, colu
3370
3716
  }
3371
3717
  }
3372
3718
  }
3719
+ function parsePropDecl(line, lineNumber, column, lineOffset, diagnostics) {
3720
+ const trimmed = line.trim();
3721
+ if (trimmed.includes(":") || trimmed.includes("<") || trimmed.includes("?")) {
3722
+ pushDiag(
3723
+ diagnostics,
3724
+ "COLLIE104",
3725
+ 'Types are not supported in #props yet. Use "name" or "name()".',
3726
+ lineNumber,
3727
+ column,
3728
+ lineOffset,
3729
+ trimmed.length
3730
+ );
3731
+ return null;
3732
+ }
3733
+ const callableMatch = trimmed.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\(\)$/);
3734
+ if (callableMatch) {
3735
+ const name = callableMatch[1];
3736
+ const nameStart = line.indexOf(name);
3737
+ const nameColumn = column + nameStart;
3738
+ return {
3739
+ name,
3740
+ kind: "callable",
3741
+ span: createSpan(lineNumber, nameColumn, name.length, lineOffset)
3742
+ };
3743
+ }
3744
+ const valueMatch = trimmed.match(/^([A-Za-z_$][A-Za-z0-9_$]*)$/);
3745
+ if (valueMatch) {
3746
+ const name = valueMatch[1];
3747
+ const nameStart = line.indexOf(name);
3748
+ const nameColumn = column + nameStart;
3749
+ return {
3750
+ name,
3751
+ kind: "value",
3752
+ span: createSpan(lineNumber, nameColumn, name.length, lineOffset)
3753
+ };
3754
+ }
3755
+ pushDiag(
3756
+ diagnostics,
3757
+ "COLLIE105",
3758
+ 'Invalid #props declaration. Use "name" or "name()".',
3759
+ lineNumber,
3760
+ column,
3761
+ lineOffset,
3762
+ trimmed.length
3763
+ );
3764
+ return null;
3765
+ }
3373
3766
  function pushDiag(diagnostics, code, message, line, column, lineOffset, length = 1) {
3374
3767
  diagnostics.push({
3375
3768
  severity: "error",