@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.js CHANGED
@@ -1,337 +1,36 @@
1
- // src/codegen.ts
2
- function generateRenderModule(root, options) {
3
- const { prelude, propsType, propsDestructure, jsx, isTsx } = buildModuleParts(root, options);
4
- const parts = [...prelude, propsType];
5
- if (!isTsx) {
6
- parts.push(`/** @param {any} props */`);
7
- }
8
- const functionLines = [
9
- isTsx ? "export function render(props: any) {" : "export function render(props) {"
10
- ];
11
- if (propsDestructure) {
12
- functionLines.push(` ${propsDestructure}`);
13
- }
14
- functionLines.push(` return ${jsx};`, `}`);
15
- parts.push(functionLines.join("\n"));
16
- return parts.join("\n\n");
17
- }
18
- function buildModuleParts(root, options) {
19
- const { jsxRuntime, flavor } = options;
20
- const isTsx = flavor === "tsx";
21
- const aliasEnv = buildClassAliasEnvironment(root.classAliases);
22
- const jsx = renderRootChildren(root.children, aliasEnv);
23
- const propsDestructure = emitPropsDestructure(root.props);
24
- const prelude = [];
25
- if (root.clientComponent) {
26
- prelude.push(`"use client";`);
27
- }
28
- if (jsxRuntime === "classic" && templateUsesJsx(root)) {
29
- prelude.push(`import React from "react";`);
30
- }
31
- const propsType = emitPropsType(root.props, flavor);
32
- return { prelude, propsType, propsDestructure, jsx, isTsx };
33
- }
34
- function buildClassAliasEnvironment(decl) {
35
- const env = /* @__PURE__ */ new Map();
36
- if (!decl) {
37
- return env;
38
- }
39
- for (const alias of decl.aliases) {
40
- env.set(alias.name, alias.classes);
41
- }
42
- return env;
43
- }
44
- function renderRootChildren(children, aliasEnv) {
45
- return emitNodesExpression(children, aliasEnv, /* @__PURE__ */ new Set());
46
- }
47
- function templateUsesJsx(root) {
48
- if (root.children.length === 0) {
49
- return false;
50
- }
51
- if (root.children.length > 1) {
52
- return true;
53
- }
54
- return nodeUsesJsx(root.children[0]);
55
- }
56
- function nodeUsesJsx(node) {
57
- if (node.type === "Element" || node.type === "Text" || node.type === "Component") {
58
- return true;
59
- }
60
- if (node.type === "Expression" || node.type === "JSXPassthrough") {
61
- return false;
62
- }
63
- if (node.type === "Conditional") {
64
- return node.branches.some((branch) => branchUsesJsx(branch));
65
- }
66
- if (node.type === "For") {
67
- return node.body.some((child) => nodeUsesJsx(child));
68
- }
69
- return false;
70
- }
71
- function branchUsesJsx(branch) {
72
- if (!branch.body.length) {
73
- return false;
74
- }
75
- return branch.body.some((child) => nodeUsesJsx(child));
76
- }
77
- function emitNodeInJsx(node, aliasEnv, locals) {
78
- if (node.type === "Text") {
79
- return emitText(node, locals);
80
- }
81
- if (node.type === "Expression") {
82
- return `{${emitExpressionValue(node.value, locals)}}`;
83
- }
84
- if (node.type === "JSXPassthrough") {
85
- return `{${emitJsxExpression(node.expression, locals)}}`;
86
- }
87
- if (node.type === "Conditional") {
88
- return `{${emitConditionalExpression(node, aliasEnv, locals)}}`;
89
- }
90
- if (node.type === "For") {
91
- return `{${emitForExpression(node, aliasEnv, locals)}}`;
92
- }
93
- if (node.type === "Component") {
94
- return wrapWithGuard(emitComponent(node, aliasEnv, locals), node.guard, "jsx", locals);
95
- }
96
- return wrapWithGuard(emitElement(node, aliasEnv, locals), node.guard, "jsx", locals);
97
- }
98
- function emitElement(node, aliasEnv, locals) {
99
- const expanded = expandClasses(node.classes, aliasEnv);
100
- const classAttr = expanded.length ? ` className="${expanded.join(" ")}"` : "";
101
- const attrs = emitAttributes(node.attributes, aliasEnv, locals);
102
- const allAttrs = classAttr + attrs;
103
- const children = emitChildrenWithSpacing(node.children, aliasEnv, locals);
104
- if (children.length > 0) {
105
- return `<${node.name}${allAttrs}>${children}</${node.name}>`;
106
- } else {
107
- return `<${node.name}${allAttrs} />`;
108
- }
109
- }
110
- function emitComponent(node, aliasEnv, locals) {
111
- const attrs = emitAttributes(node.attributes, aliasEnv, locals);
112
- const slotProps = emitSlotProps(node, aliasEnv, locals);
113
- const allAttrs = `${attrs}${slotProps}`;
114
- const children = emitChildrenWithSpacing(node.children, aliasEnv, locals);
115
- if (children.length > 0) {
116
- return `<${node.name}${allAttrs}>${children}</${node.name}>`;
117
- } else {
118
- return `<${node.name}${allAttrs} />`;
119
- }
120
- }
121
- function emitChildrenWithSpacing(children, aliasEnv, locals) {
122
- if (children.length === 0) {
123
- return "";
124
- }
125
- const parts = [];
126
- for (let i = 0; i < children.length; i++) {
127
- const child = children[i];
128
- const emitted = emitNodeInJsx(child, aliasEnv, locals);
129
- parts.push(emitted);
130
- if (i < children.length - 1) {
131
- const nextChild = children[i + 1];
132
- const needsSpace = child.type === "Text" && (nextChild.type === "Element" || nextChild.type === "Component" || nextChild.type === "Expression" || nextChild.type === "JSXPassthrough");
133
- if (needsSpace) {
134
- parts.push(" ");
135
- }
136
- }
137
- }
138
- return parts.join("");
139
- }
140
- function emitAttributes(attributes, aliasEnv, locals) {
141
- if (attributes.length === 0) {
142
- return "";
143
- }
144
- return attributes.map((attr) => {
145
- if (attr.value === null) {
146
- return ` ${attr.name}`;
147
- }
148
- return ` ${attr.name}=${emitAttributeValue(attr.value, locals)}`;
149
- }).join("");
150
- }
151
- function emitSlotProps(node, aliasEnv, locals) {
152
- if (!node.slots || node.slots.length === 0) {
153
- return "";
154
- }
155
- return node.slots.map((slot) => {
156
- const expr = emitNodesExpression(slot.children, aliasEnv, locals);
157
- return ` ${slot.name}={${expr}}`;
158
- }).join("");
159
- }
160
- function wrapWithGuard(rendered, guard, context, locals) {
161
- if (!guard) {
162
- return rendered;
163
- }
164
- const condition = emitExpressionValue(guard, locals);
165
- const expression = `(${condition}) && ${rendered}`;
166
- return context === "jsx" ? `{${expression}}` : expression;
167
- }
168
- function emitForExpression(node, aliasEnv, locals) {
169
- const arrayExpr = emitExpressionValue(node.arrayExpr, locals);
170
- const nextLocals = new Set(locals);
171
- nextLocals.add(node.itemName);
172
- const body = emitNodesExpression(node.body, aliasEnv, nextLocals);
173
- return `(${arrayExpr} ?? []).map((${node.itemName}) => ${body})`;
174
- }
175
- function expandClasses(classes, aliasEnv) {
176
- const result = [];
177
- for (const cls of classes) {
178
- const match = cls.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
179
- if (!match) {
180
- result.push(cls);
181
- continue;
182
- }
183
- const aliasClasses = aliasEnv.get(match[1]);
184
- if (!aliasClasses) {
185
- continue;
1
+ // src/rewrite.ts
2
+ function createTemplateEnv(propsDecls) {
3
+ const propAliases = /* @__PURE__ */ new Map();
4
+ if (propsDecls) {
5
+ for (const decl of propsDecls) {
6
+ propAliases.set(decl.name, decl.kind);
186
7
  }
187
- result.push(...aliasClasses);
188
8
  }
189
- return result;
190
- }
191
- function emitExpressionValue(expression, locals) {
192
- return rewriteExpression(expression, locals);
9
+ return {
10
+ propAliases,
11
+ localsStack: []
12
+ };
193
13
  }
194
- function emitJsxExpression(expression, locals) {
195
- const trimmed = expression.trimStart();
196
- if (trimmed.startsWith("<")) {
197
- return rewriteJsxExpression(expression, locals);
198
- }
199
- return rewriteExpression(expression, locals);
14
+ function pushLocals(env, names) {
15
+ const locals = new Set(names);
16
+ env.localsStack.push(locals);
200
17
  }
201
- function emitAttributeValue(value, locals) {
202
- const trimmed = value.trim();
203
- if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
204
- return value;
205
- }
206
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
207
- const inner = trimmed.slice(1, -1);
208
- const rewritten = rewriteExpression(inner, locals);
209
- return `{${rewritten}}`;
210
- }
211
- return rewriteExpression(trimmed, locals);
18
+ function popLocals(env) {
19
+ env.localsStack.pop();
212
20
  }
213
- function emitText(node, locals) {
214
- if (!node.parts.length) {
215
- return "";
216
- }
217
- return node.parts.map((part) => {
218
- if (part.type === "text") {
219
- return escapeText(part.value);
21
+ function isLocal(env, name) {
22
+ for (let i = env.localsStack.length - 1; i >= 0; i--) {
23
+ if (env.localsStack[i].has(name)) {
24
+ return true;
220
25
  }
221
- return `{${emitExpressionValue(part.value, locals)}}`;
222
- }).join("");
223
- }
224
- function emitConditionalExpression(node, aliasEnv, locals) {
225
- if (!node.branches.length) {
226
- return "null";
227
- }
228
- const first = node.branches[0];
229
- if (node.branches.length === 1 && first.test) {
230
- const test = emitExpressionValue(first.test, locals);
231
- return `(${test}) && ${emitBranchExpression(first, aliasEnv, locals)}`;
232
- }
233
- const hasElse = node.branches[node.branches.length - 1].test === void 0;
234
- let fallback = hasElse ? emitBranchExpression(node.branches[node.branches.length - 1], aliasEnv, locals) : "null";
235
- const startIndex = hasElse ? node.branches.length - 2 : node.branches.length - 1;
236
- if (startIndex < 0) {
237
- return fallback;
238
- }
239
- for (let i = startIndex; i >= 0; i--) {
240
- const branch = node.branches[i];
241
- const test = branch.test ? emitExpressionValue(branch.test, locals) : "false";
242
- fallback = `(${test}) ? ${emitBranchExpression(branch, aliasEnv, locals)} : ${fallback}`;
243
- }
244
- return fallback;
245
- }
246
- function emitBranchExpression(branch, aliasEnv, locals) {
247
- return emitNodesExpression(branch.body, aliasEnv, locals);
248
- }
249
- function emitNodesExpression(children, aliasEnv, locals) {
250
- if (children.length === 0) {
251
- return "null";
252
- }
253
- if (children.length === 1) {
254
- return emitSingleNodeExpression(children[0], aliasEnv, locals);
255
- }
256
- return `<>${children.map((child) => emitNodeInJsx(child, aliasEnv, locals)).join("")}</>`;
257
- }
258
- function emitSingleNodeExpression(node, aliasEnv, locals) {
259
- if (node.type === "Expression") {
260
- return emitExpressionValue(node.value, locals);
261
- }
262
- if (node.type === "JSXPassthrough") {
263
- return emitJsxExpression(node.expression, locals);
264
- }
265
- if (node.type === "Conditional") {
266
- return emitConditionalExpression(node, aliasEnv, locals);
267
- }
268
- if (node.type === "For") {
269
- return emitForExpression(node, aliasEnv, locals);
270
- }
271
- if (node.type === "Element") {
272
- return wrapWithGuard(emitElement(node, aliasEnv, locals), node.guard, "expression", locals);
273
- }
274
- if (node.type === "Component") {
275
- return wrapWithGuard(emitComponent(node, aliasEnv, locals), node.guard, "expression", locals);
276
- }
277
- if (node.type === "Text") {
278
- return `<>${emitNodeInJsx(node, aliasEnv, locals)}</>`;
279
- }
280
- return emitNodeInJsx(node, aliasEnv, locals);
281
- }
282
- function emitPropsType(props, flavor) {
283
- if (flavor === "tsx") {
284
- return emitTsPropsType(props);
285
- }
286
- return emitJsDocPropsType(props);
287
- }
288
- function emitJsDocPropsType(props) {
289
- if (!props) {
290
- return "/** @typedef {any} Props */";
291
- }
292
- if (!props.fields.length) {
293
- return "/** @typedef {{}} Props */";
294
- }
295
- const fields = props.fields.map((field) => {
296
- const optional = field.optional ? "?" : "";
297
- return `${field.name}${optional}: ${field.typeText}`;
298
- }).join("; ");
299
- return `/** @typedef {{ ${fields} }} Props */`;
300
- }
301
- function emitTsPropsType(props) {
302
- if (!props || props.fields.length === 0) {
303
- return "export type Props = Record<string, never>;";
304
26
  }
305
- const lines = props.fields.map((field) => {
306
- const optional = field.optional ? "?" : "";
307
- return ` ${field.name}${optional}: ${field.typeText};`;
308
- });
309
- return ["export interface Props {", ...lines, "}"].join("\n");
27
+ return false;
310
28
  }
311
- function emitPropsDestructure(props) {
312
- if (!props || props.fields.length === 0) {
313
- return null;
29
+ function isPropAlias(env, name) {
30
+ if (isLocal(env, name)) {
31
+ return false;
314
32
  }
315
- const names = props.fields.map((field) => field.name);
316
- return `const { ${names.join(", ")} } = props ?? {};`;
317
- }
318
- function escapeText(value) {
319
- return value.replace(/[&<>{}]/g, (char) => {
320
- switch (char) {
321
- case "&":
322
- return "&amp;";
323
- case "<":
324
- return "&lt;";
325
- case ">":
326
- return "&gt;";
327
- case "{":
328
- return "&#123;";
329
- case "}":
330
- return "&#125;";
331
- default:
332
- return char;
333
- }
334
- });
33
+ return env.propAliases.has(name);
335
34
  }
336
35
  var IGNORED_IDENTIFIERS = /* @__PURE__ */ new Set([
337
36
  "null",
@@ -384,13 +83,15 @@ var RESERVED_KEYWORDS = /* @__PURE__ */ new Set([
384
83
  "with",
385
84
  "yield"
386
85
  ]);
387
- function emitIdentifier(name) {
388
- return `props?.${name}`;
389
- }
390
- function rewriteExpression(expression, locals) {
86
+ function rewriteExpression(expression, env) {
391
87
  let i = 0;
392
88
  let state = "code";
393
89
  let output = "";
90
+ const usedBare = /* @__PURE__ */ new Set();
91
+ const usedPropsDot = /* @__PURE__ */ new Set();
92
+ const callSitesBare = /* @__PURE__ */ new Set();
93
+ const callSitesPropsDot = /* @__PURE__ */ new Set();
94
+ const rewrittenAliases = /* @__PURE__ */ new Set();
394
95
  while (i < expression.length) {
395
96
  const ch = expression[i];
396
97
  if (state === "code") {
@@ -428,12 +129,37 @@ function rewriteExpression(expression, locals) {
428
129
  const prevNonSpace = findPreviousNonSpace(expression, start - 1);
429
130
  const nextNonSpace = findNextNonSpace(expression, i);
430
131
  const isMemberAccess = prevNonSpace === ".";
431
- const isObjectKey = nextNonSpace === ":";
432
- if (isMemberAccess || isObjectKey || locals.has(name) || shouldIgnoreIdentifier(name)) {
132
+ const isObjectKey = nextNonSpace === ":" && (prevNonSpace === "{" || prevNonSpace === ",");
133
+ const isCall = nextNonSpace === "(";
134
+ if (prevNonSpace === "." && start >= 2) {
135
+ const propsStart = findPreviousIdentifierStart(expression, start - 2);
136
+ if (propsStart !== null) {
137
+ const possibleProps = expression.slice(propsStart, start - 1).trim();
138
+ if (possibleProps === "props") {
139
+ usedPropsDot.add(name);
140
+ if (isCall) {
141
+ callSitesPropsDot.add(name);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ if (isMemberAccess || isObjectKey || isLocal(env, name) || shouldIgnoreIdentifier(name)) {
433
147
  output += name;
434
148
  continue;
435
149
  }
436
- output += emitIdentifier(name);
150
+ if (isPropAlias(env, name)) {
151
+ output += `props.${name}`;
152
+ rewrittenAliases.add(name);
153
+ if (isCall) {
154
+ callSitesBare.add(name);
155
+ }
156
+ continue;
157
+ }
158
+ usedBare.add(name);
159
+ if (isCall) {
160
+ callSitesBare.add(name);
161
+ }
162
+ output += name;
437
163
  continue;
438
164
  }
439
165
  output += ch;
@@ -505,11 +231,16 @@ function rewriteExpression(expression, locals) {
505
231
  continue;
506
232
  }
507
233
  }
508
- return output;
234
+ return { code: output, usedBare, usedPropsDot, callSitesBare, callSitesPropsDot, rewrittenAliases };
509
235
  }
510
- function rewriteJsxExpression(expression, locals) {
236
+ function rewriteJsxExpression(expression, env) {
511
237
  let output = "";
512
238
  let i = 0;
239
+ const usedBare = /* @__PURE__ */ new Set();
240
+ const usedPropsDot = /* @__PURE__ */ new Set();
241
+ const callSitesBare = /* @__PURE__ */ new Set();
242
+ const callSitesPropsDot = /* @__PURE__ */ new Set();
243
+ const rewrittenAliases = /* @__PURE__ */ new Set();
513
244
  while (i < expression.length) {
514
245
  const ch = expression[i];
515
246
  if (ch === "{") {
@@ -518,15 +249,20 @@ function rewriteJsxExpression(expression, locals) {
518
249
  output += expression.slice(i);
519
250
  break;
520
251
  }
521
- const rewritten = rewriteExpression(braceResult.content, locals);
522
- output += `{${rewritten}}`;
252
+ const result = rewriteExpression(braceResult.content, env);
253
+ output += `{${result.code}}`;
254
+ for (const name of result.usedBare) usedBare.add(name);
255
+ for (const name of result.usedPropsDot) usedPropsDot.add(name);
256
+ for (const name of result.callSitesBare) callSitesBare.add(name);
257
+ for (const name of result.callSitesPropsDot) callSitesPropsDot.add(name);
258
+ for (const name of result.rewrittenAliases) rewrittenAliases.add(name);
523
259
  i = braceResult.endIndex + 1;
524
260
  continue;
525
261
  }
526
262
  output += ch;
527
263
  i++;
528
264
  }
529
- return output;
265
+ return { code: output, usedBare, usedPropsDot, callSitesBare, callSitesPropsDot, rewrittenAliases };
530
266
  }
531
267
  function readBalancedBraces(source, startIndex) {
532
268
  let i = startIndex;
@@ -616,34 +352,385 @@ function readBalancedBraces(source, startIndex) {
616
352
  continue;
617
353
  }
618
354
  }
619
- return null;
355
+ return null;
356
+ }
357
+ function findPreviousNonSpace(text, index) {
358
+ for (let i = index; i >= 0; i--) {
359
+ const ch = text[i];
360
+ if (!/\s/.test(ch)) {
361
+ return ch;
362
+ }
363
+ }
364
+ return null;
365
+ }
366
+ function findNextNonSpace(text, index) {
367
+ for (let i = index; i < text.length; i++) {
368
+ const ch = text[i];
369
+ if (!/\s/.test(ch)) {
370
+ return ch;
371
+ }
372
+ }
373
+ return null;
374
+ }
375
+ function findPreviousIdentifierStart(text, index) {
376
+ let i = index;
377
+ while (i >= 0 && /\s/.test(text[i])) {
378
+ i--;
379
+ }
380
+ if (i < 0) return null;
381
+ if (!isIdentifierPart(text[i])) {
382
+ return null;
383
+ }
384
+ while (i > 0 && isIdentifierPart(text[i - 1])) {
385
+ i--;
386
+ }
387
+ return i;
388
+ }
389
+ function isIdentifierStart(ch) {
390
+ return /[A-Za-z_$]/.test(ch);
391
+ }
392
+ function isIdentifierPart(ch) {
393
+ return /[A-Za-z0-9_$]/.test(ch);
394
+ }
395
+ function shouldIgnoreIdentifier(name) {
396
+ return IGNORED_IDENTIFIERS.has(name) || RESERVED_KEYWORDS.has(name);
397
+ }
398
+
399
+ // src/codegen.ts
400
+ function generateRenderModule(root, options) {
401
+ const { prelude, propsType, propsDestructure, jsx, isTsx } = buildModuleParts(root, options);
402
+ const parts = [...prelude, propsType];
403
+ if (!isTsx) {
404
+ parts.push(`/** @param {any} props */`);
405
+ }
406
+ const functionLines = [
407
+ isTsx ? "export function render(props: any) {" : "export function render(props) {"
408
+ ];
409
+ if (propsDestructure) {
410
+ functionLines.push(` ${propsDestructure}`);
411
+ }
412
+ functionLines.push(` return ${jsx};`, `}`);
413
+ parts.push(functionLines.join("\n"));
414
+ return parts.join("\n\n");
415
+ }
416
+ function buildModuleParts(root, options) {
417
+ const { jsxRuntime, flavor } = options;
418
+ const isTsx = flavor === "tsx";
419
+ const aliasEnv = buildClassAliasEnvironment(root.classAliases);
420
+ const env = createTemplateEnv(root.propsDecls);
421
+ const jsx = renderRootChildren(root.children, aliasEnv, env);
422
+ const propsDestructure = emitPropsDestructure(root.props);
423
+ const prelude = [];
424
+ if (root.clientComponent) {
425
+ prelude.push(`"use client";`);
426
+ }
427
+ if (jsxRuntime === "classic" && templateUsesJsx(root)) {
428
+ prelude.push(`import React from "react";`);
429
+ }
430
+ const propsType = emitPropsType(root.props, flavor);
431
+ return { prelude, propsType, propsDestructure, jsx, isTsx };
432
+ }
433
+ function buildClassAliasEnvironment(decl) {
434
+ const env = /* @__PURE__ */ new Map();
435
+ if (!decl) {
436
+ return env;
437
+ }
438
+ for (const alias of decl.aliases) {
439
+ env.set(alias.name, alias.classes);
440
+ }
441
+ return env;
442
+ }
443
+ function renderRootChildren(children, aliasEnv, env) {
444
+ return emitNodesExpression(children, aliasEnv, env);
445
+ }
446
+ function templateUsesJsx(root) {
447
+ if (root.children.length === 0) {
448
+ return false;
449
+ }
450
+ if (root.children.length > 1) {
451
+ return true;
452
+ }
453
+ return nodeUsesJsx(root.children[0]);
454
+ }
455
+ function nodeUsesJsx(node) {
456
+ if (node.type === "Element" || node.type === "Text" || node.type === "Component") {
457
+ return true;
458
+ }
459
+ if (node.type === "Expression" || node.type === "JSXPassthrough") {
460
+ return false;
461
+ }
462
+ if (node.type === "Conditional") {
463
+ return node.branches.some((branch) => branchUsesJsx(branch));
464
+ }
465
+ if (node.type === "For") {
466
+ return node.body.some((child) => nodeUsesJsx(child));
467
+ }
468
+ return false;
469
+ }
470
+ function branchUsesJsx(branch) {
471
+ if (!branch.body.length) {
472
+ return false;
473
+ }
474
+ return branch.body.some((child) => nodeUsesJsx(child));
475
+ }
476
+ function emitNodeInJsx(node, aliasEnv, env) {
477
+ if (node.type === "Text") {
478
+ return emitText(node, env);
479
+ }
480
+ if (node.type === "Expression") {
481
+ return `{${emitExpressionValue(node.value, env)}}`;
482
+ }
483
+ if (node.type === "JSXPassthrough") {
484
+ return `{${emitJsxExpression(node.expression, env)}}`;
485
+ }
486
+ if (node.type === "Conditional") {
487
+ return `{${emitConditionalExpression(node, aliasEnv, env)}}`;
488
+ }
489
+ if (node.type === "For") {
490
+ return `{${emitForExpression(node, aliasEnv, env)}}`;
491
+ }
492
+ if (node.type === "Component") {
493
+ return wrapWithGuard(emitComponent(node, aliasEnv, env), node.guard, "jsx", env);
494
+ }
495
+ return wrapWithGuard(emitElement(node, aliasEnv, env), node.guard, "jsx", env);
496
+ }
497
+ function emitElement(node, aliasEnv, env) {
498
+ const expanded = expandClasses(node.classes, aliasEnv);
499
+ const classAttr = expanded.length ? ` className="${expanded.join(" ")}"` : "";
500
+ const attrs = emitAttributes(node.attributes, aliasEnv, env);
501
+ const allAttrs = classAttr + attrs;
502
+ const children = emitChildrenWithSpacing(node.children, aliasEnv, env);
503
+ if (children.length > 0) {
504
+ return `<${node.name}${allAttrs}>${children}</${node.name}>`;
505
+ } else {
506
+ return `<${node.name}${allAttrs} />`;
507
+ }
508
+ }
509
+ function emitComponent(node, aliasEnv, env) {
510
+ const attrs = emitAttributes(node.attributes, aliasEnv, env);
511
+ const slotProps = emitSlotProps(node, aliasEnv, env);
512
+ const allAttrs = `${attrs}${slotProps}`;
513
+ const children = emitChildrenWithSpacing(node.children, aliasEnv, env);
514
+ if (children.length > 0) {
515
+ return `<${node.name}${allAttrs}>${children}</${node.name}>`;
516
+ } else {
517
+ return `<${node.name}${allAttrs} />`;
518
+ }
519
+ }
520
+ function emitChildrenWithSpacing(children, aliasEnv, env) {
521
+ if (children.length === 0) {
522
+ return "";
523
+ }
524
+ const parts = [];
525
+ for (let i = 0; i < children.length; i++) {
526
+ const child = children[i];
527
+ const emitted = emitNodeInJsx(child, aliasEnv, env);
528
+ parts.push(emitted);
529
+ if (i < children.length - 1) {
530
+ const nextChild = children[i + 1];
531
+ const needsSpace = child.type === "Text" && (nextChild.type === "Element" || nextChild.type === "Component" || nextChild.type === "Expression" || nextChild.type === "JSXPassthrough");
532
+ if (needsSpace) {
533
+ parts.push(" ");
534
+ }
535
+ }
536
+ }
537
+ return parts.join("");
538
+ }
539
+ function emitAttributes(attributes, aliasEnv, env) {
540
+ if (attributes.length === 0) {
541
+ return "";
542
+ }
543
+ return attributes.map((attr) => {
544
+ if (attr.value === null) {
545
+ return ` ${attr.name}`;
546
+ }
547
+ return ` ${attr.name}=${emitAttributeValue(attr.value, env)}`;
548
+ }).join("");
549
+ }
550
+ function emitSlotProps(node, aliasEnv, env) {
551
+ if (!node.slots || node.slots.length === 0) {
552
+ return "";
553
+ }
554
+ return node.slots.map((slot) => {
555
+ const expr = emitNodesExpression(slot.children, aliasEnv, env);
556
+ return ` ${slot.name}={${expr}}`;
557
+ }).join("");
558
+ }
559
+ function wrapWithGuard(rendered, guard, context, env) {
560
+ if (!guard) {
561
+ return rendered;
562
+ }
563
+ const condition = emitExpressionValue(guard, env);
564
+ const expression = `(${condition}) && ${rendered}`;
565
+ return context === "jsx" ? `{${expression}}` : expression;
566
+ }
567
+ function emitForExpression(node, aliasEnv, env) {
568
+ const arrayExpr = emitExpressionValue(node.arrayExpr, env);
569
+ pushLocals(env, [node.itemName]);
570
+ const body = emitNodesExpression(node.body, aliasEnv, env);
571
+ popLocals(env);
572
+ return `(${arrayExpr} ?? []).map((${node.itemName}) => ${body})`;
573
+ }
574
+ function expandClasses(classes, aliasEnv) {
575
+ const result = [];
576
+ for (const cls of classes) {
577
+ const match = cls.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
578
+ if (!match) {
579
+ result.push(cls);
580
+ continue;
581
+ }
582
+ const aliasClasses = aliasEnv.get(match[1]);
583
+ if (!aliasClasses) {
584
+ continue;
585
+ }
586
+ result.push(...aliasClasses);
587
+ }
588
+ return result;
589
+ }
590
+ function emitExpressionValue(expression, env) {
591
+ return rewriteExpression(expression, env).code;
592
+ }
593
+ function emitJsxExpression(expression, env) {
594
+ const trimmed = expression.trimStart();
595
+ if (trimmed.startsWith("<")) {
596
+ return rewriteJsxExpression(expression, env).code;
597
+ }
598
+ return rewriteExpression(expression, env).code;
599
+ }
600
+ function emitAttributeValue(value, env) {
601
+ const trimmed = value.trim();
602
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
603
+ return value;
604
+ }
605
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
606
+ const inner = trimmed.slice(1, -1);
607
+ const rewritten = rewriteExpression(inner, env).code;
608
+ return `{${rewritten}}`;
609
+ }
610
+ return rewriteExpression(trimmed, env).code;
611
+ }
612
+ function emitText(node, env) {
613
+ if (!node.parts.length) {
614
+ return "";
615
+ }
616
+ return node.parts.map((part) => {
617
+ if (part.type === "text") {
618
+ return escapeText(part.value);
619
+ }
620
+ return `{${emitExpressionValue(part.value, env)}}`;
621
+ }).join("");
622
+ }
623
+ function emitConditionalExpression(node, aliasEnv, env) {
624
+ if (!node.branches.length) {
625
+ return "null";
626
+ }
627
+ const first = node.branches[0];
628
+ if (node.branches.length === 1 && first.test) {
629
+ const test = emitExpressionValue(first.test, env);
630
+ return `(${test}) && ${emitBranchExpression(first, aliasEnv, env)}`;
631
+ }
632
+ const hasElse = node.branches[node.branches.length - 1].test === void 0;
633
+ let fallback = hasElse ? emitBranchExpression(node.branches[node.branches.length - 1], aliasEnv, env) : "null";
634
+ const startIndex = hasElse ? node.branches.length - 2 : node.branches.length - 1;
635
+ if (startIndex < 0) {
636
+ return fallback;
637
+ }
638
+ for (let i = startIndex; i >= 0; i--) {
639
+ const branch = node.branches[i];
640
+ const test = branch.test ? emitExpressionValue(branch.test, env) : "false";
641
+ fallback = `(${test}) ? ${emitBranchExpression(branch, aliasEnv, env)} : ${fallback}`;
642
+ }
643
+ return fallback;
644
+ }
645
+ function emitBranchExpression(branch, aliasEnv, env) {
646
+ return emitNodesExpression(branch.body, aliasEnv, env);
647
+ }
648
+ function emitNodesExpression(children, aliasEnv, env) {
649
+ if (children.length === 0) {
650
+ return "null";
651
+ }
652
+ if (children.length === 1) {
653
+ return emitSingleNodeExpression(children[0], aliasEnv, env);
654
+ }
655
+ return `<>${children.map((child) => emitNodeInJsx(child, aliasEnv, env)).join("")}</>`;
620
656
  }
621
- function findPreviousNonSpace(text, index) {
622
- for (let i = index; i >= 0; i--) {
623
- const ch = text[i];
624
- if (!/\s/.test(ch)) {
625
- return ch;
626
- }
657
+ function emitSingleNodeExpression(node, aliasEnv, env) {
658
+ if (node.type === "Expression") {
659
+ return emitExpressionValue(node.value, env);
627
660
  }
628
- return null;
661
+ if (node.type === "JSXPassthrough") {
662
+ return emitJsxExpression(node.expression, env);
663
+ }
664
+ if (node.type === "Conditional") {
665
+ return emitConditionalExpression(node, aliasEnv, env);
666
+ }
667
+ if (node.type === "For") {
668
+ return emitForExpression(node, aliasEnv, env);
669
+ }
670
+ if (node.type === "Element") {
671
+ return wrapWithGuard(emitElement(node, aliasEnv, env), node.guard, "expression", env);
672
+ }
673
+ if (node.type === "Component") {
674
+ return wrapWithGuard(emitComponent(node, aliasEnv, env), node.guard, "expression", env);
675
+ }
676
+ if (node.type === "Text") {
677
+ return `<>${emitNodeInJsx(node, aliasEnv, env)}</>`;
678
+ }
679
+ return emitNodeInJsx(node, aliasEnv, env);
629
680
  }
630
- function findNextNonSpace(text, index) {
631
- for (let i = index; i < text.length; i++) {
632
- const ch = text[i];
633
- if (!/\s/.test(ch)) {
634
- return ch;
635
- }
681
+ function emitPropsType(props, flavor) {
682
+ if (flavor === "tsx") {
683
+ return emitTsPropsType(props);
636
684
  }
637
- return null;
685
+ return emitJsDocPropsType(props);
638
686
  }
639
- function isIdentifierStart(ch) {
640
- return /[A-Za-z_$]/.test(ch);
687
+ function emitJsDocPropsType(props) {
688
+ if (!props) {
689
+ return "/** @typedef {any} Props */";
690
+ }
691
+ if (!props.fields.length) {
692
+ return "/** @typedef {{}} Props */";
693
+ }
694
+ const fields = props.fields.map((field) => {
695
+ const optional = field.optional ? "?" : "";
696
+ return `${field.name}${optional}: ${field.typeText}`;
697
+ }).join("; ");
698
+ return `/** @typedef {{ ${fields} }} Props */`;
641
699
  }
642
- function isIdentifierPart(ch) {
643
- return /[A-Za-z0-9_$]/.test(ch);
700
+ function emitTsPropsType(props) {
701
+ if (!props || props.fields.length === 0) {
702
+ return "export type Props = Record<string, never>;";
703
+ }
704
+ const lines = props.fields.map((field) => {
705
+ const optional = field.optional ? "?" : "";
706
+ return ` ${field.name}${optional}: ${field.typeText};`;
707
+ });
708
+ return ["export interface Props {", ...lines, "}"].join("\n");
644
709
  }
645
- function shouldIgnoreIdentifier(name) {
646
- return IGNORED_IDENTIFIERS.has(name) || RESERVED_KEYWORDS.has(name);
710
+ function emitPropsDestructure(props) {
711
+ if (!props || props.fields.length === 0) {
712
+ return null;
713
+ }
714
+ const names = props.fields.map((field) => field.name);
715
+ return `const { ${names.join(", ")} } = props ?? {};`;
716
+ }
717
+ function escapeText(value) {
718
+ return value.replace(/[&<>{}]/g, (char) => {
719
+ switch (char) {
720
+ case "&":
721
+ return "&amp;";
722
+ case "<":
723
+ return "&lt;";
724
+ case ">":
725
+ return "&gt;";
726
+ case "{":
727
+ return "&#123;";
728
+ case "}":
729
+ return "&#125;";
730
+ default:
731
+ return char;
732
+ }
733
+ });
647
734
  }
648
735
 
649
736
  // src/html-codegen.ts
@@ -1439,6 +1526,185 @@ function offsetSpan(base, index, length) {
1439
1526
  }
1440
1527
  };
1441
1528
  }
1529
+ function enforcePropAliases(root) {
1530
+ if (!root.propsDecls || root.propsDecls.length === 0) {
1531
+ return [];
1532
+ }
1533
+ const diagnostics = [];
1534
+ const declaredProps = new Map(root.propsDecls.map((d) => [d.name, d]));
1535
+ const allUsage = collectTemplateUsage(root);
1536
+ for (const name of allUsage.usedBare) {
1537
+ if (!declaredProps.has(name) && !shouldIgnoreForDiagnostics(name)) {
1538
+ diagnostics.push({
1539
+ severity: "warning",
1540
+ code: "props.missingDeclaration",
1541
+ message: `Identifier "${name}" is used without "props." but is not declared in #props. Declare "${name}" in #props or use "props.${name}".`
1542
+ });
1543
+ }
1544
+ }
1545
+ for (const [name, decl] of declaredProps) {
1546
+ const usedAsBare = allUsage.usedBareAliases.has(name);
1547
+ const usedAsProps = allUsage.usedPropsDot.has(name);
1548
+ if (!usedAsBare && !usedAsProps) {
1549
+ diagnostics.push({
1550
+ severity: "warning",
1551
+ code: "props.unusedDeclaration",
1552
+ message: `Prop "${name}" is declared in #props but never used in this template.`,
1553
+ span: decl.span
1554
+ });
1555
+ }
1556
+ }
1557
+ for (const name of allUsage.usedPropsDot) {
1558
+ if (declaredProps.has(name)) {
1559
+ diagnostics.push({
1560
+ severity: "warning",
1561
+ code: "props.style.nonPreferred",
1562
+ message: `"props.${name}" is unnecessary because "${name}" is declared in #props. Use "{${name}}" instead.`
1563
+ });
1564
+ }
1565
+ }
1566
+ for (const [name, decl] of declaredProps) {
1567
+ const isCallable = decl.kind === "callable";
1568
+ const usedAsCall = allUsage.callSitesBare.has(name);
1569
+ const usedAsValue = allUsage.usedBareAliases.has(name) && !usedAsCall;
1570
+ if (isCallable && usedAsValue) {
1571
+ diagnostics.push({
1572
+ severity: "warning",
1573
+ code: "props.style.nonPreferred",
1574
+ message: `"${name}" is declared as callable in #props (${name}()) but used as a value.`
1575
+ });
1576
+ } else if (!isCallable && usedAsCall) {
1577
+ diagnostics.push({
1578
+ severity: "warning",
1579
+ code: "props.style.nonPreferred",
1580
+ message: `"${name}" is declared as a value in #props but used as a function call.`
1581
+ });
1582
+ }
1583
+ }
1584
+ return diagnostics;
1585
+ }
1586
+ function collectTemplateUsage(root) {
1587
+ const usage = {
1588
+ usedBare: /* @__PURE__ */ new Set(),
1589
+ usedBareAliases: /* @__PURE__ */ new Set(),
1590
+ usedPropsDot: /* @__PURE__ */ new Set(),
1591
+ callSitesBare: /* @__PURE__ */ new Set(),
1592
+ callSitesPropsDot: /* @__PURE__ */ new Set()
1593
+ };
1594
+ const env = createTemplateEnv(root.propsDecls);
1595
+ function mergeResult(result) {
1596
+ for (const name of result.usedBare) {
1597
+ usage.usedBare.add(name);
1598
+ }
1599
+ for (const name of result.rewrittenAliases) {
1600
+ usage.usedBareAliases.add(name);
1601
+ }
1602
+ for (const name of result.usedPropsDot) {
1603
+ usage.usedPropsDot.add(name);
1604
+ }
1605
+ for (const name of result.callSitesBare) {
1606
+ usage.callSitesBare.add(name);
1607
+ }
1608
+ for (const name of result.callSitesPropsDot) {
1609
+ usage.callSitesPropsDot.add(name);
1610
+ }
1611
+ }
1612
+ function analyzeExpression(expr) {
1613
+ if (!expr) return;
1614
+ const result = rewriteExpression(expr, env);
1615
+ mergeResult(result);
1616
+ }
1617
+ function analyzeJsxExpression(expr) {
1618
+ if (!expr) return;
1619
+ const result = rewriteJsxExpression(expr, env);
1620
+ mergeResult(result);
1621
+ }
1622
+ function walkNode(node) {
1623
+ switch (node.type) {
1624
+ case "Text":
1625
+ for (const part of node.parts) {
1626
+ if (part.type === "expr") {
1627
+ analyzeExpression(part.value);
1628
+ }
1629
+ }
1630
+ break;
1631
+ case "Expression":
1632
+ analyzeExpression(node.value);
1633
+ break;
1634
+ case "JSXPassthrough":
1635
+ analyzeJsxExpression(node.expression);
1636
+ break;
1637
+ case "Element":
1638
+ if (node.guard) {
1639
+ analyzeExpression(node.guard);
1640
+ }
1641
+ for (const attr of node.attributes) {
1642
+ if (attr.value) {
1643
+ analyzeAttributeValue(attr.value);
1644
+ }
1645
+ }
1646
+ for (const child of node.children) {
1647
+ walkNode(child);
1648
+ }
1649
+ break;
1650
+ case "Component":
1651
+ if (node.guard) {
1652
+ analyzeExpression(node.guard);
1653
+ }
1654
+ for (const attr of node.attributes) {
1655
+ if (attr.value) {
1656
+ analyzeAttributeValue(attr.value);
1657
+ }
1658
+ }
1659
+ if (node.slots) {
1660
+ for (const slot of node.slots) {
1661
+ for (const child of slot.children) {
1662
+ walkNode(child);
1663
+ }
1664
+ }
1665
+ }
1666
+ for (const child of node.children) {
1667
+ walkNode(child);
1668
+ }
1669
+ break;
1670
+ case "Conditional":
1671
+ for (const branch of node.branches) {
1672
+ if (branch.test) {
1673
+ analyzeExpression(branch.test);
1674
+ }
1675
+ for (const child of branch.body) {
1676
+ walkNode(child);
1677
+ }
1678
+ }
1679
+ break;
1680
+ case "For":
1681
+ analyzeExpression(node.arrayExpr);
1682
+ for (const child of node.body) {
1683
+ walkNode(child);
1684
+ }
1685
+ break;
1686
+ }
1687
+ }
1688
+ function analyzeAttributeValue(value) {
1689
+ const trimmed = value.trim();
1690
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
1691
+ return;
1692
+ }
1693
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
1694
+ const inner = trimmed.slice(1, -1);
1695
+ analyzeExpression(inner);
1696
+ } else {
1697
+ analyzeExpression(trimmed);
1698
+ }
1699
+ }
1700
+ for (const child of root.children) {
1701
+ walkNode(child);
1702
+ }
1703
+ return usage;
1704
+ }
1705
+ function shouldIgnoreForDiagnostics(name) {
1706
+ return IGNORED_IDENTIFIERS2.has(name) || RESERVED_KEYWORDS2.has(name);
1707
+ }
1442
1708
 
1443
1709
  // src/parser.ts
1444
1710
  var TEMPLATE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9._-]*$/;
@@ -1755,6 +2021,7 @@ function parseTemplateBlock(lines, lineOffsets, startIndex, endIndex, options) {
1755
2021
  );
1756
2022
  } else {
1757
2023
  root.props = { fields: [] };
2024
+ root.propsDecls = [];
1758
2025
  }
1759
2026
  if (level === 0) {
1760
2027
  propsBlockLevel = level;
@@ -1809,6 +2076,23 @@ function parseTemplateBlock(lines, lineOffsets, startIndex, endIndex, options) {
1809
2076
  );
1810
2077
  continue;
1811
2078
  }
2079
+ const decl = parsePropDecl(lineContent, lineNumber, indent + 1, lineOffset, diagnostics);
2080
+ if (decl && root.propsDecls) {
2081
+ const existing = root.propsDecls.find((d) => d.name === decl.name);
2082
+ if (existing) {
2083
+ pushDiag(
2084
+ diagnostics,
2085
+ "COLLIE106",
2086
+ `Duplicate prop declaration "${decl.name}".`,
2087
+ lineNumber,
2088
+ indent + 1,
2089
+ lineOffset,
2090
+ trimmed.length
2091
+ );
2092
+ } else {
2093
+ root.propsDecls.push(decl);
2094
+ }
2095
+ }
1812
2096
  continue;
1813
2097
  }
1814
2098
  if (classesBlockLevel !== null && level > classesBlockLevel) {
@@ -2221,6 +2505,7 @@ function parseTemplateBlock(lines, lineOffsets, startIndex, endIndex, options) {
2221
2505
  diagnostics.push(...enforceDialect(root, options.dialect));
2222
2506
  diagnostics.push(...enforceProps(root, options.dialect.props));
2223
2507
  }
2508
+ diagnostics.push(...enforcePropAliases(root));
2224
2509
  return { root, diagnostics };
2225
2510
  }
2226
2511
  function buildLineOffsets(lines) {
@@ -2271,7 +2556,7 @@ function parseConditionalHeader(kind, lineContent, lineNumber, column, lineOffse
2271
2556
  pushDiag(
2272
2557
  diagnostics,
2273
2558
  "COLLIE201",
2274
- kind === "if" ? "Invalid @if syntax. Use @if <condition>." : "Invalid @elseIf syntax. Use @elseIf <condition>.",
2559
+ kind === "if" ? "Invalid @if syntax. Use @if (condition)." : "Invalid @elseIf syntax. Use @elseIf (condition).",
2275
2560
  lineNumber,
2276
2561
  column,
2277
2562
  lineOffset,
@@ -2295,29 +2580,36 @@ function parseConditionalHeader(kind, lineContent, lineNumber, column, lineOffse
2295
2580
  }
2296
2581
  const remainderTrimmed = remainder.trimStart();
2297
2582
  const usesParens = remainderTrimmed.startsWith("(");
2583
+ if (!usesParens) {
2584
+ pushDiag(
2585
+ diagnostics,
2586
+ "COLLIE211",
2587
+ kind === "if" ? "@if requires parentheses: @if (condition)" : "@elseIf requires parentheses: @elseIf (condition)",
2588
+ lineNumber,
2589
+ column,
2590
+ lineOffset,
2591
+ trimmed.length || token.length
2592
+ );
2593
+ return null;
2594
+ }
2298
2595
  let testRaw = "";
2299
2596
  let remainderRaw = "";
2300
- if (usesParens) {
2301
- const openIndex = trimmed.indexOf("(", token.length);
2302
- const closeIndex = trimmed.lastIndexOf(")");
2303
- if (openIndex === -1 || closeIndex <= openIndex) {
2304
- pushDiag(
2305
- diagnostics,
2306
- "COLLIE201",
2307
- kind === "if" ? "Invalid @if syntax. Use @if <condition>." : "Invalid @elseIf syntax. Use @elseIf <condition>.",
2308
- lineNumber,
2309
- column,
2310
- lineOffset,
2311
- trimmed.length || token.length
2312
- );
2313
- return null;
2314
- }
2315
- testRaw = trimmed.slice(openIndex + 1, closeIndex);
2316
- remainderRaw = trimmed.slice(closeIndex + 1);
2317
- } else {
2318
- testRaw = remainderTrimmed;
2319
- remainderRaw = "";
2597
+ const openIndex = trimmed.indexOf("(", token.length);
2598
+ const closeIndex = trimmed.lastIndexOf(")");
2599
+ if (openIndex === -1 || closeIndex <= openIndex) {
2600
+ pushDiag(
2601
+ diagnostics,
2602
+ "COLLIE212",
2603
+ kind === "if" ? "Unclosed parentheses in @if ( ... )" : "Unclosed parentheses in @elseIf ( ... )",
2604
+ lineNumber,
2605
+ column,
2606
+ lineOffset,
2607
+ trimmed.length || token.length
2608
+ );
2609
+ return null;
2320
2610
  }
2611
+ testRaw = trimmed.slice(openIndex + 1, closeIndex);
2612
+ remainderRaw = trimmed.slice(closeIndex + 1);
2321
2613
  const test = testRaw.trim();
2322
2614
  if (!test) {
2323
2615
  pushDiag(
@@ -2366,7 +2658,20 @@ function parseElseHeader(lineContent, lineNumber, column, lineOffset, diagnostic
2366
2658
  const token = "@else";
2367
2659
  const tokenSpan = createSpan(lineNumber, column, token.length, lineOffset);
2368
2660
  const remainderRaw = match[1] ?? "";
2369
- const inlineBody = remainderRaw.trim();
2661
+ const remainderTrimmed = remainderRaw.trim();
2662
+ if (remainderTrimmed.startsWith("(")) {
2663
+ pushDiag(
2664
+ diagnostics,
2665
+ "COLLIE213",
2666
+ "@else does not accept a condition",
2667
+ lineNumber,
2668
+ column,
2669
+ lineOffset,
2670
+ trimmed.length || 4
2671
+ );
2672
+ return null;
2673
+ }
2674
+ const inlineBody = remainderTrimmed;
2370
2675
  const remainderOffset = trimmed.length - remainderRaw.length;
2371
2676
  const leadingWhitespace = remainderRaw.length - inlineBody.length;
2372
2677
  const inlineColumn = inlineBody.length > 0 ? column + remainderOffset + leadingWhitespace : void 0;
@@ -2380,12 +2685,12 @@ function parseElseHeader(lineContent, lineNumber, column, lineOffset, diagnostic
2380
2685
  }
2381
2686
  function parseForHeader(lineContent, lineNumber, column, lineOffset, diagnostics) {
2382
2687
  const trimmed = lineContent.trimEnd();
2383
- const match = trimmed.match(/^@for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.+)$/);
2384
- if (!match) {
2688
+ const token = "@for";
2689
+ if (!trimmed.startsWith(token)) {
2385
2690
  pushDiag(
2386
2691
  diagnostics,
2387
2692
  "COLLIE210",
2388
- "Invalid @for syntax. Use @for itemName in arrayExpr.",
2693
+ "Invalid @for syntax. Use @for (item in array).",
2389
2694
  lineNumber,
2390
2695
  column,
2391
2696
  lineOffset,
@@ -2393,15 +2698,55 @@ function parseForHeader(lineContent, lineNumber, column, lineOffset, diagnostics
2393
2698
  );
2394
2699
  return null;
2395
2700
  }
2396
- const token = "@for";
2397
2701
  const tokenSpan = createSpan(lineNumber, column, token.length, lineOffset);
2702
+ const remainder = trimmed.slice(token.length).trimStart();
2703
+ if (!remainder.startsWith("(")) {
2704
+ pushDiag(
2705
+ diagnostics,
2706
+ "COLLIE211",
2707
+ "@for requires parentheses: @for (item in array)",
2708
+ lineNumber,
2709
+ column,
2710
+ lineOffset,
2711
+ trimmed.length || 4
2712
+ );
2713
+ return null;
2714
+ }
2715
+ const openIndex = trimmed.indexOf("(", token.length);
2716
+ const closeIndex = trimmed.lastIndexOf(")");
2717
+ if (openIndex === -1 || closeIndex <= openIndex) {
2718
+ pushDiag(
2719
+ diagnostics,
2720
+ "COLLIE212",
2721
+ "Unclosed parentheses in @for ( ... )",
2722
+ lineNumber,
2723
+ column,
2724
+ lineOffset,
2725
+ trimmed.length || 4
2726
+ );
2727
+ return null;
2728
+ }
2729
+ const content = trimmed.slice(openIndex + 1, closeIndex).trim();
2730
+ const match = content.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.+)$/);
2731
+ if (!match) {
2732
+ pushDiag(
2733
+ diagnostics,
2734
+ "COLLIE210",
2735
+ "Invalid @for syntax. Use @for (item in array).",
2736
+ lineNumber,
2737
+ column,
2738
+ lineOffset,
2739
+ trimmed.length || 4
2740
+ );
2741
+ return null;
2742
+ }
2398
2743
  const itemName = match[1];
2399
2744
  const arrayExprRaw = match[2];
2400
2745
  if (!itemName || !arrayExprRaw) {
2401
2746
  pushDiag(
2402
2747
  diagnostics,
2403
2748
  "COLLIE210",
2404
- "Invalid @for syntax. Use @for itemName in arrayExpr.",
2749
+ "Invalid @for syntax. Use @for (item in array).",
2405
2750
  lineNumber,
2406
2751
  column,
2407
2752
  lineOffset,
@@ -2423,8 +2768,9 @@ function parseForHeader(lineContent, lineNumber, column, lineOffset, diagnostics
2423
2768
  return null;
2424
2769
  }
2425
2770
  const arrayExprLeadingWhitespace = arrayExprRaw.length - arrayExprRaw.trimStart().length;
2426
- const arrayExprStart = trimmed.length - arrayExprRaw.length;
2427
- const arrayExprColumn = column + arrayExprStart + arrayExprLeadingWhitespace;
2771
+ const contentStart = openIndex + 1;
2772
+ const arrayExprStartInContent = content.length - arrayExprRaw.length;
2773
+ const arrayExprColumn = column + contentStart + arrayExprStartInContent + arrayExprLeadingWhitespace;
2428
2774
  const arrayExprSpan = createSpan(lineNumber, arrayExprColumn, arrayExpr.length, lineOffset);
2429
2775
  return { itemName, arrayExpr, token, tokenSpan, arrayExprSpan };
2430
2776
  }
@@ -3319,6 +3665,53 @@ function parseAndAddAttribute(attrStr, attributes, diagnostics, lineNumber, colu
3319
3665
  }
3320
3666
  }
3321
3667
  }
3668
+ function parsePropDecl(line, lineNumber, column, lineOffset, diagnostics) {
3669
+ const trimmed = line.trim();
3670
+ if (trimmed.includes(":") || trimmed.includes("<") || trimmed.includes("?")) {
3671
+ pushDiag(
3672
+ diagnostics,
3673
+ "COLLIE104",
3674
+ 'Types are not supported in #props yet. Use "name" or "name()".',
3675
+ lineNumber,
3676
+ column,
3677
+ lineOffset,
3678
+ trimmed.length
3679
+ );
3680
+ return null;
3681
+ }
3682
+ const callableMatch = trimmed.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\(\)$/);
3683
+ if (callableMatch) {
3684
+ const name = callableMatch[1];
3685
+ const nameStart = line.indexOf(name);
3686
+ const nameColumn = column + nameStart;
3687
+ return {
3688
+ name,
3689
+ kind: "callable",
3690
+ span: createSpan(lineNumber, nameColumn, name.length, lineOffset)
3691
+ };
3692
+ }
3693
+ const valueMatch = trimmed.match(/^([A-Za-z_$][A-Za-z0-9_$]*)$/);
3694
+ if (valueMatch) {
3695
+ const name = valueMatch[1];
3696
+ const nameStart = line.indexOf(name);
3697
+ const nameColumn = column + nameStart;
3698
+ return {
3699
+ name,
3700
+ kind: "value",
3701
+ span: createSpan(lineNumber, nameColumn, name.length, lineOffset)
3702
+ };
3703
+ }
3704
+ pushDiag(
3705
+ diagnostics,
3706
+ "COLLIE105",
3707
+ 'Invalid #props declaration. Use "name" or "name()".',
3708
+ lineNumber,
3709
+ column,
3710
+ lineOffset,
3711
+ trimmed.length
3712
+ );
3713
+ return null;
3714
+ }
3322
3715
  function pushDiag(diagnostics, code, message, line, column, lineOffset, length = 1) {
3323
3716
  diagnostics.push({
3324
3717
  severity: "error",