@velarscript/web 0.10.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.
Files changed (95) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +89 -0
  3. package/dist/analyzer.d.ts +302 -0
  4. package/dist/analyzer.d.ts.map +1 -0
  5. package/dist/analyzer.js +2931 -0
  6. package/dist/analyzer.js.map +1 -0
  7. package/dist/ast.d.ts +220 -0
  8. package/dist/ast.d.ts.map +1 -0
  9. package/dist/ast.js +102 -0
  10. package/dist/ast.js.map +1 -0
  11. package/dist/browser-test.d.ts +29 -0
  12. package/dist/browser-test.d.ts.map +1 -0
  13. package/dist/browser-test.js +33 -0
  14. package/dist/browser-test.js.map +1 -0
  15. package/dist/compiler.d.ts +9 -0
  16. package/dist/compiler.d.ts.map +1 -0
  17. package/dist/compiler.js +715 -0
  18. package/dist/compiler.js.map +1 -0
  19. package/dist/css-tokens.d.ts +46 -0
  20. package/dist/css-tokens.d.ts.map +1 -0
  21. package/dist/css-tokens.js +363 -0
  22. package/dist/css-tokens.js.map +1 -0
  23. package/dist/editor.d.ts +3 -0
  24. package/dist/editor.d.ts.map +1 -0
  25. package/dist/editor.js +147 -0
  26. package/dist/editor.js.map +1 -0
  27. package/dist/elements.d.ts +11 -0
  28. package/dist/elements.d.ts.map +1 -0
  29. package/dist/elements.js +24 -0
  30. package/dist/elements.js.map +1 -0
  31. package/dist/emitter.d.ts +79 -0
  32. package/dist/emitter.d.ts.map +1 -0
  33. package/dist/emitter.js +2699 -0
  34. package/dist/emitter.js.map +1 -0
  35. package/dist/host.d.ts +7 -0
  36. package/dist/host.d.ts.map +1 -0
  37. package/dist/host.js +224 -0
  38. package/dist/host.js.map +1 -0
  39. package/dist/index.d.ts +11 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +23 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/inspection.d.ts +3 -0
  44. package/dist/inspection.d.ts.map +1 -0
  45. package/dist/inspection.js +82 -0
  46. package/dist/inspection.js.map +1 -0
  47. package/dist/keyframes.d.ts +16 -0
  48. package/dist/keyframes.d.ts.map +1 -0
  49. package/dist/keyframes.js +29 -0
  50. package/dist/keyframes.js.map +1 -0
  51. package/dist/lexer.d.ts +85 -0
  52. package/dist/lexer.d.ts.map +1 -0
  53. package/dist/lexer.js +422 -0
  54. package/dist/lexer.js.map +1 -0
  55. package/dist/look-static.d.ts +31 -0
  56. package/dist/look-static.d.ts.map +1 -0
  57. package/dist/look-static.js +266 -0
  58. package/dist/look-static.js.map +1 -0
  59. package/dist/look.d.ts +134 -0
  60. package/dist/look.d.ts.map +1 -0
  61. package/dist/look.js +627 -0
  62. package/dist/look.js.map +1 -0
  63. package/dist/parser.d.ts +71 -0
  64. package/dist/parser.d.ts.map +1 -0
  65. package/dist/parser.js +976 -0
  66. package/dist/parser.js.map +1 -0
  67. package/dist/project-config.d.ts +33 -0
  68. package/dist/project-config.d.ts.map +1 -0
  69. package/dist/project-config.js +199 -0
  70. package/dist/project-config.js.map +1 -0
  71. package/dist/runtime-foundation.d.ts +7 -0
  72. package/dist/runtime-foundation.d.ts.map +1 -0
  73. package/dist/runtime-foundation.js +1245 -0
  74. package/dist/runtime-foundation.js.map +1 -0
  75. package/dist/runtime.d.ts +7 -0
  76. package/dist/runtime.d.ts.map +1 -0
  77. package/dist/runtime.js +3444 -0
  78. package/dist/runtime.js.map +1 -0
  79. package/dist/semantic.d.ts +3 -0
  80. package/dist/semantic.d.ts.map +1 -0
  81. package/dist/semantic.js +150 -0
  82. package/dist/semantic.js.map +1 -0
  83. package/dist/types.d.ts +56 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +178 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/websocket-runtime.d.ts +2 -0
  88. package/dist/websocket-runtime.d.ts.map +1 -0
  89. package/dist/websocket-runtime.js +30 -0
  90. package/dist/websocket-runtime.js.map +1 -0
  91. package/dist/worker-runtime.d.ts +2 -0
  92. package/dist/worker-runtime.d.ts.map +1 -0
  93. package/dist/worker-runtime.js +72 -0
  94. package/dist/worker-runtime.js.map +1 -0
  95. package/package.json +52 -0
package/dist/parser.js ADDED
@@ -0,0 +1,976 @@
1
+ import { typeParameterDeclarationFormsPhrase } from "@velarscript/compiler";
2
+ import { Parser, } from "@velarscript/compiler/extension";
3
+ import { WEB_JSX_TOKEN, WEB_KEYFRAMES_TOKEN, WEB_LOOK_TOKEN, WEB_UNSAFE_CSS_TOKEN, } from "./lexer.js";
4
+ const span = (start, end) => ({ start, end });
5
+ const diagnostic = (code, message, sourceSpan) => ({ code, message, span: sourceSpan });
6
+ const recoveredDiagnostic = (code, message, sourceSpan) => ({ code, message, span: sourceSpan, recovered: true });
7
+ const renderBlockSpellings = new Set(["render", "show", "view"]);
8
+ const lifecycleHookSpellings = new Set(["mounted", "cleanup"]);
9
+ // The tokens that may follow the declared name in each Web declaration head.
10
+ const componentHeaderShapes = new Set(["leftParen", "colon", "less", "identifier"]);
11
+ const reactiveBindingShapes = new Set(["assign", "colon"]);
12
+ const actionHeaderShapes = new Set(["leftParen"]);
13
+ // Tokens that open a fresh value rather than continuing the expression before
14
+ // them; only these make `expose` the component's expose item.
15
+ const exposeValueStartKinds = new Set([
16
+ "identifier", "string", "fstring", "number", "unitNumber", "true", "false", "null", "super", "leftBrace", "extensionToken",
17
+ ]);
18
+ export class VelarWebParser extends Parser {
19
+ insideComponentProps = 0;
20
+ constructor(tokens, lexicalExtensions) {
21
+ super(tokens, lexicalExtensions);
22
+ }
23
+ /**
24
+ * WEB-N4: a component prop list is where a Web author reaches for the HTML
25
+ * names, and `class`, `look`, and the Core keywords are all tokens rather than
26
+ * identifiers. Recovering the keyword as the prop name turns an eleven-message
27
+ * parser cascade into one directed message, and a declaration-position `?`
28
+ * teaches the default value that actually makes a prop omittable.
29
+ */
30
+ parseParameters() {
31
+ if (this.insideComponentProps === 0)
32
+ return super.parseParameters();
33
+ this.expect("leftParen", "Expected '('");
34
+ const parameters = [];
35
+ if (!this.check("rightParen")) {
36
+ do {
37
+ if (this.match("ellipsis"))
38
+ this.diagnostics.push(diagnostic("VEL2016", "Components use named props and do not support rest parameters", this.previous().span));
39
+ // 'class' and 'look' are the props every component already carries, so
40
+ // the same message answers both the keyword token and the now-ordinary
41
+ // name.
42
+ const universalProp = this.checkWord("look") || this.check("class");
43
+ if (universalProp) {
44
+ const token = this.advance();
45
+ this.diagnostics.push(diagnostic("VEL2016", `Every component already accepts '${token.value}'; remove it from the prop list and pass it at the call site with ${token.value}={...}`, token.span));
46
+ }
47
+ const nameToken = universalProp ? this.previous() : this.check("identifier") ? this.advance() : this.componentPropKeyword();
48
+ if (!nameToken) {
49
+ this.diagnostics.push(diagnostic("VEL2016", "A component prop list holds 'name: Type' props separated by commas", this.current().span));
50
+ break;
51
+ }
52
+ let optionalMarker = false;
53
+ if (this.check("question")) {
54
+ this.advance();
55
+ optionalMarker = true;
56
+ }
57
+ const type = this.match("colon") ? this.parseTypeReference() : null;
58
+ let defaultValue = this.match("assign") ? this.parseExpression() : null;
59
+ if (optionalMarker) {
60
+ this.diagnostics.push(diagnostic("VEL2016", `A component prop becomes omittable through its default value, not through '?': write '${nameToken.value}: Type = default' for a real default, or '${nameToken.value}: Type? = null' when absence is the value`, span(nameToken.span.start, (defaultValue ?? type ?? nameToken).span.end)));
61
+ defaultValue ??= { kind: "LiteralExpression", value: null, raw: "null", span: nameToken.span };
62
+ }
63
+ parameters.push({
64
+ name: nameToken.value,
65
+ type: optionalMarker && type ? { syntax: { kind: "OptionalTypeSyntax", inner: type.syntax, span: type.span }, span: type.span } : type,
66
+ defaultValue,
67
+ rest: false,
68
+ span: span(nameToken.span.start, (defaultValue ?? type ?? nameToken).span.end),
69
+ });
70
+ } while (this.match("comma") && !this.check("rightParen"));
71
+ }
72
+ this.expect("rightParen", "Expected ')' after parameters");
73
+ return parameters;
74
+ }
75
+ /**
76
+ * Consumes a keyword standing where a prop name belongs and reports the one
77
+ * message that names it, or returns null when the token cannot be a name.
78
+ */
79
+ componentPropKeyword() {
80
+ const token = this.current();
81
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(token.value))
82
+ return null;
83
+ if (token.kind === "identifier" || token.kind === "eof" || token.kind === "newline")
84
+ return null;
85
+ this.advance();
86
+ this.diagnostics.push(diagnostic("VEL2016", `'${token.value}' is a VelarScript keyword and cannot name a component prop; choose another name`, token.span));
87
+ return token;
88
+ }
89
+ createNestedParser(tokens) {
90
+ return new VelarWebParser(tokens, this.lexicalExtensions);
91
+ }
92
+ /**
93
+ * D43 item 67: a component's lifecycle hooks are spelled '@mounted:' and
94
+ * '@cleanup:'. The '@' marker is what keeps them out of the author's own
95
+ * namespace, so a hook is only recognized through it.
96
+ */
97
+ matchLifecycleHook(name) {
98
+ if (!this.check("at") || this.peekKind(1) !== "identifier" || this.peekValue(1) !== name)
99
+ return false;
100
+ this.advance();
101
+ this.advance();
102
+ return true;
103
+ }
104
+ validateExtensionTypeArguments(name, arguments_, nameSpan) {
105
+ if (name !== "Component")
106
+ return false;
107
+ if (arguments_.length !== 1 && arguments_.length !== 2) {
108
+ this.diagnostics.push(diagnostic("VEL2012", "Type 'Component' expects 1 or 2 type arguments", nameSpan));
109
+ }
110
+ return true;
111
+ }
112
+ parseExtensionNumericLiteral(token, value, unit) {
113
+ const core = super.parseExtensionNumericLiteral(token, value, unit);
114
+ if (core)
115
+ return core;
116
+ const expression = { kind: "ExtensionExpression:web:unit", value, unit, raw: token.value, span: token.span };
117
+ return expression;
118
+ }
119
+ parseExtensionStatement(start, modifiers) {
120
+ if (this.checkWord("look") && this.peekKind(1) === "identifier" && this.peekKind(2) === "colon") {
121
+ const keyword = this.advance();
122
+ const name = this.advance();
123
+ this.diagnostics.push(diagnostic("VEL5038", `Use 'const ${name.value} = look:'; Look is a value that is attached to an element with look={${name.value}}`, span(keyword.span.start, name.span.end)));
124
+ this.skipMistypedDeclaration();
125
+ return { kind: "PassStatement", span: span(keyword.span.start, name.span.end) };
126
+ }
127
+ if (this.namedDeclarationAhead("component", componentHeaderShapes)) {
128
+ this.advance();
129
+ if (modifiers.abstract)
130
+ this.diagnostics.push(diagnostic("VEL2013", "Only classes can be declared with 'abstract'", this.previous().span));
131
+ if (modifiers.asynchronous)
132
+ this.diagnostics.push(diagnostic("VEL2013", "Components are not declared with 'async'", this.previous().span));
133
+ return this.parseComponent(start, modifiers.exported);
134
+ }
135
+ if (modifiers.abstract || modifiers.asynchronous)
136
+ return undefined;
137
+ if (this.namedDeclarationAhead("state", reactiveBindingShapes)) {
138
+ this.advance();
139
+ return this.parseStateDeclaration(start, modifiers.exported);
140
+ }
141
+ if (this.namedDeclarationAhead("computed", reactiveBindingShapes)) {
142
+ this.advance();
143
+ return this.parseComputedDeclaration(start, modifiers.exported);
144
+ }
145
+ if (this.namedDeclarationAhead("resource", reactiveBindingShapes)) {
146
+ this.advance();
147
+ if (modifiers.exported)
148
+ this.diagnostics.push(diagnostic("VEL2018", "A resource is component-owned and cannot be exported", this.previous().span));
149
+ return this.parseResourceDeclaration(start, modifiers.exported);
150
+ }
151
+ if (this.namedDeclarationAhead("action", actionHeaderShapes)) {
152
+ this.advance();
153
+ return this.parseActionDeclaration(start, modifiers.exported);
154
+ }
155
+ if (this.blockHeaderAhead("watch")) {
156
+ this.advance();
157
+ if (modifiers.exported)
158
+ this.diagnostics.push(diagnostic("VEL2001", "A watch block cannot be exported", this.previous().span));
159
+ return this.parseWatchDeclaration(start);
160
+ }
161
+ if (this.exposeItemAhead()) {
162
+ this.advance();
163
+ const value = this.parseExpression();
164
+ this.diagnostics.push(diagnostic("VEL5056", "'expose' is only valid as a top-level component item; declare 'exposes HandleType' on that component", span(start, value.span.end)));
165
+ return { kind: "PassStatement", span: span(start, value.span.end) };
166
+ }
167
+ return undefined;
168
+ }
169
+ parseUnsafeExtensionStatement(start) {
170
+ if (!this.checkWord("css") || this.peekKind(1) !== "extensionToken"
171
+ || this.peekValue(1) !== WEB_UNSAFE_CSS_TOKEN)
172
+ return undefined;
173
+ this.advance();
174
+ const token = this.advance();
175
+ const payload = token.payload;
176
+ if (!payload || payload.kind !== "WebUnsafeCssBlockSyntax") {
177
+ this.diagnostics.push(diagnostic("VEL5037", "The inline unsafe CSS token is missing its raw source", token.span));
178
+ return { kind: "PassStatement", span: token.span };
179
+ }
180
+ const placement = this.parseUnsafeCssPlacement();
181
+ const declaration = {
182
+ kind: "ExtensionStatement:web:unsafe-css",
183
+ source: { kind: "inline", css: payload.css, span: payload.contentSpan },
184
+ placement,
185
+ span: span(start, this.previous().span.end),
186
+ };
187
+ return declaration;
188
+ }
189
+ /**
190
+ * D30 item 16: a Web declaration head is claimed only in its own declaration
191
+ * shape — the word, a name, and one of the tokens that can follow it there.
192
+ * `state = 1`, `state(x)`, and `state.field` all keep the identifier reading.
193
+ */
194
+ namedDeclarationAhead(word, shapes) {
195
+ return this.checkWord(word) && this.peekKind(1) === "identifier" && shapes.has(this.peekKind(2));
196
+ }
197
+ /**
198
+ * `watch` opens a block: its header line ends in ':' and an indented body
199
+ * follows. No expression statement can end in ':', so the lookahead is exact
200
+ * and `watch = 1` / `watch(value)` stay ordinary code.
201
+ */
202
+ blockHeaderAhead(word) {
203
+ if (!this.checkWord(word))
204
+ return false;
205
+ let depth = 0;
206
+ let offset = 1;
207
+ for (;; offset += 1) {
208
+ const kind = this.peekKind(offset);
209
+ if (kind === "leftParen" || kind === "leftBracket" || kind === "leftBrace")
210
+ depth += 1;
211
+ else if (kind === "rightParen" || kind === "rightBracket" || kind === "rightBrace")
212
+ depth -= 1;
213
+ else if (kind === "eof")
214
+ return false;
215
+ else if (depth === 0 && (kind === "newline" || kind === "dedent"))
216
+ break;
217
+ }
218
+ if (offset < 3 || this.peekKind(offset - 1) !== "colon")
219
+ return false;
220
+ while (this.peekKind(offset) === "newline")
221
+ offset += 1;
222
+ return this.peekKind(offset) === "indent";
223
+ }
224
+ /**
225
+ * `expose value` is the one Web statement head followed by a bare expression
226
+ * rather than by a name and a shape token. It is claimed only when the next
227
+ * token opens a fresh value, so `expose(handle)` reads as a call and
228
+ * `expose = handle` as an assignment — the identifier reading wins wherever
229
+ * the two could compete.
230
+ */
231
+ exposeItemAhead() {
232
+ return this.checkWord("expose") && exposeValueStartKinds.has(this.peekKind(1));
233
+ }
234
+ parseExtensionImport(start) {
235
+ // `import css unsafe "./file.css"` against `import css from "./module.vel"`:
236
+ // the CSS boundary is claimed only when the word is followed by the
237
+ // boundary marker or by the path itself, so a module named `css` still
238
+ // imports by name.
239
+ if (!this.checkWord("css") || !(this.peekKind(1) === "unsafe" || this.peekKind(1) === "string"))
240
+ return undefined;
241
+ this.advance();
242
+ this.expect("unsafe", "Native CSS is an unsafe boundary; write 'import css unsafe'");
243
+ const source = this.expect("string", "Expected a relative .css path after 'import css unsafe'");
244
+ const placement = this.parseUnsafeCssPlacement();
245
+ if ((!source.value.startsWith("./") && !source.value.startsWith("../")) || !source.value.endsWith(".css")) {
246
+ this.diagnostics.push(diagnostic("VEL5037", "Unsafe CSS imports require an explicit relative path ending in '.css'", source.span));
247
+ }
248
+ const declaration = {
249
+ kind: "ExtensionStatement:web:unsafe-css",
250
+ source: { kind: "external", path: source.value, span: source.span },
251
+ placement,
252
+ span: span(start, this.previous().span.end),
253
+ };
254
+ return declaration;
255
+ }
256
+ parseUnsafeCssPlacement() {
257
+ let placement = "before";
258
+ if (this.current().kind === "identifier" && this.current().value === "before") {
259
+ this.expect("identifier", "Expected 'before'");
260
+ placement = "before";
261
+ }
262
+ else if (this.current().kind === "identifier" && this.current().value === "after") {
263
+ this.expect("identifier", "Expected 'after'");
264
+ placement = "after";
265
+ }
266
+ else
267
+ this.diagnostics.push(diagnostic("VEL5037", "Unsafe CSS must explicitly declare 'before look' or 'after look'", this.current().span));
268
+ if (!this.matchExtensionKeyword("look")) {
269
+ this.diagnostics.push(diagnostic("VEL5037", "Unsafe CSS order must end with 'look'", this.current().span));
270
+ }
271
+ return placement;
272
+ }
273
+ parseExtensionExpression(token) {
274
+ if (token.kind === "extensionToken" && token.value === WEB_JSX_TOKEN) {
275
+ const payload = token.payload;
276
+ if (!payload || payload.kind !== "WebJsxElementSyntax") {
277
+ this.diagnostics.push(diagnostic("VEL5001", "The Web JSX token is missing its structured syntax", token.span));
278
+ return { kind: "LiteralExpression", value: null, raw: "null", span: token.span };
279
+ }
280
+ const syntax = shiftJsxSyntax(payload, token.span.start - payload.span.start);
281
+ return jsxExpression(syntax, (source) => this.parseJsxEmbedded(source), (item) => this.diagnostics.push(item));
282
+ }
283
+ if (token.kind !== "identifier")
284
+ return undefined;
285
+ // `look:` and `keyframes:` open a block-valued expression. Without the ':'
286
+ // the word is an ordinary name, so `const saved = look` reads a binding.
287
+ if (!this.check("colon"))
288
+ return undefined;
289
+ if (token.value === "keyframes")
290
+ return this.parseKeyframesExpression(token);
291
+ if (token.value !== "look")
292
+ return undefined;
293
+ // LOK-I3: an unfinished Look value used to unravel into six diagnostics as
294
+ // each expectation failed in turn. The shape is checked up front so the
295
+ // reader gets one message naming the whole spelling.
296
+ if (!this.check("colon") || this.peekKind(1) !== "newline") {
297
+ this.diagnostics.push(diagnostic("VEL5038", "A Look value is written as 'look:' followed by an indented block of 'property = value' entries", token.span));
298
+ this.skipMistypedDeclaration();
299
+ return { kind: "LiteralExpression", value: null, raw: "null", span: token.span };
300
+ }
301
+ let ahead = 1;
302
+ while (this.peekKind(ahead) === "newline")
303
+ ahead += 1;
304
+ if (this.peekKind(ahead) !== "indent") {
305
+ // The colon stays unconsumed so the surrounding statement still ends at
306
+ // its own newline; only this one message describes the missing block.
307
+ this.diagnostics.push(diagnostic("VEL5038", "A Look block requires at least one indented 'property = value' entry", token.span));
308
+ this.advance();
309
+ return { kind: "LiteralExpression", value: null, raw: "null", span: token.span };
310
+ }
311
+ this.advance();
312
+ this.consumeNewlines();
313
+ this.advance();
314
+ const block = this.expect("extensionToken", "Expected Look entries");
315
+ const payload = block.value === WEB_LOOK_TOKEN ? block.payload : undefined;
316
+ if (!payload || payload.kind !== "WebLookBlockSyntax") {
317
+ this.diagnostics.push(diagnostic("VEL5038", "The Look block is missing its structured syntax", block.span));
318
+ }
319
+ const syntax = payload?.kind === "WebLookBlockSyntax"
320
+ ? shiftLookSyntax(payload, block.span.start - payload.span.start)
321
+ : undefined;
322
+ this.consumeNewlines();
323
+ this.expect("dedent", "Expected the end of the Look block");
324
+ const entries = new LookSourceParser(syntax ?? { kind: "WebLookBlockSyntax", lines: [], span: block.span }, (text, offset, openingIndent) => openingIndent
325
+ ? this.parseNestedExpression(openingIndent + text, offset - openingIndent.length, true)
326
+ : this.parseNestedExpression(text, offset), (item) => this.diagnostics.push(item)).parse();
327
+ const expression = { kind: "ExtensionExpression:web:look", entries, span: span(token.span.start, block.span.end) };
328
+ return expression;
329
+ }
330
+ parseKeyframesExpression(token) {
331
+ if (!this.check("colon") || this.peekKind(1) !== "newline") {
332
+ this.diagnostics.push(diagnostic("VEL5060", "A keyframes value is written as 'keyframes:' followed by indented 'from:', 'to:', or 'N%:' stops", token.span));
333
+ this.skipMistypedDeclaration();
334
+ return { kind: "LiteralExpression", value: null, raw: "null", span: token.span };
335
+ }
336
+ let ahead = 1;
337
+ while (this.peekKind(ahead) === "newline")
338
+ ahead += 1;
339
+ if (this.peekKind(ahead) !== "indent") {
340
+ this.diagnostics.push(diagnostic("VEL5060", "A keyframes block requires at least one indented stop", token.span));
341
+ this.advance();
342
+ return { kind: "LiteralExpression", value: null, raw: "null", span: token.span };
343
+ }
344
+ this.advance();
345
+ this.consumeNewlines();
346
+ this.advance();
347
+ const block = this.expect("extensionToken", "Expected keyframe stops");
348
+ const payload = block.value === WEB_KEYFRAMES_TOKEN ? block.payload : undefined;
349
+ if (!payload || payload.kind !== "WebKeyframesBlockSyntax") {
350
+ this.diagnostics.push(diagnostic("VEL5060", "The keyframes block is missing its structured syntax", block.span));
351
+ }
352
+ const syntax = payload?.kind === "WebKeyframesBlockSyntax"
353
+ ? shiftKeyframesSyntax(payload, block.span.start - payload.span.start)
354
+ : undefined;
355
+ this.consumeNewlines();
356
+ this.expect("dedent", "Expected the end of the keyframes block");
357
+ const stops = new KeyframesSourceParser(syntax ?? { kind: "WebKeyframesBlockSyntax", lines: [], span: block.span }, (text, offset, openingIndent) => openingIndent
358
+ ? this.parseNestedExpression(openingIndent + text, offset - openingIndent.length, true)
359
+ : this.parseNestedExpression(text, offset), (item) => this.diagnostics.push(item)).parse();
360
+ const expression = { kind: "ExtensionExpression:web:keyframes", stops, span: span(token.span.start, block.span.end) };
361
+ return expression;
362
+ }
363
+ // A '{for item in items: ...}' block inside JSX gets targeted guidance to
364
+ // '.map(...)' instead of an expression-parse cascade; there is no magic JSX
365
+ // control flow. The child recovers as an inert null literal so the rest of
366
+ // the module still analyzes and reports its own guidance in the same compile.
367
+ parseJsxEmbedded(source) {
368
+ // WEB-U13: '{/* ... */}' is the JSX comment habit. VelarScript has no block
369
+ // comment at all, so the interpolation gets one message naming '//' instead
370
+ // of two 'Expected an expression' failures.
371
+ if (/^\s*\/[*/]/u.test(source.source)) {
372
+ this.diagnostics.push(recoveredDiagnostic("VEL5002", "JSX has no comment form; write a '//' comment on its own line outside the markup", source.span));
373
+ return { kind: "LiteralExpression", value: null, raw: "null", span: source.span };
374
+ }
375
+ if (/^\s*for\b/u.test(source.source)) {
376
+ const detail = /^\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([^:{\n]+):/u.exec(source.source);
377
+ const binding = detail?.[1] ?? "item";
378
+ const iterable = detail?.[2]?.trim() || "items";
379
+ this.diagnostics.push({
380
+ code: "VEL5049",
381
+ message: `Use '{${iterable}.map((${binding}) => ...)}'; JSX has no 'for' blocks, so lists render with '.map(...)'`,
382
+ span: source.span,
383
+ recovered: true,
384
+ });
385
+ return { kind: "LiteralExpression", value: null, raw: "null", span: source.span };
386
+ }
387
+ // JSX interpolation braces are a bracket context: the expression inside
388
+ // '{...}' continues across physical lines exactly as inside parentheses.
389
+ const layoutAtStart = /^[ \t]*(?:rf|fr|f|r)?["'](?:\r\n|\r|\n)/u.test(source.source);
390
+ return layoutAtStart
391
+ ? this.parseNestedExpression(source.openingIndent + source.source, source.span.start - source.openingIndent.length, true)
392
+ : this.parseNestedExpression(source.source, source.span.start, true);
393
+ }
394
+ parseStateDeclaration(start, exported) {
395
+ const name = this.expect("identifier", "Expected a state name");
396
+ const type = this.match("colon") ? this.parseTypeReference() : null;
397
+ this.expect("assign", "Expected '=' after state name");
398
+ const initializer = this.parseExpression();
399
+ return { kind: "ExtensionStatement:web:state", exported, name: name.value, type, initializer, span: span(start, initializer.span.end) };
400
+ }
401
+ /**
402
+ * D71 rule 182: `computed` parses exactly where `state` parses, through the
403
+ * same shape lookahead — the two halves of the reactive row differ in what
404
+ * they mean, not in how they are written.
405
+ */
406
+ parseComputedDeclaration(start, exported) {
407
+ const name = this.expect("identifier", "Expected a computed name");
408
+ const type = this.match("colon") ? this.parseTypeReference() : null;
409
+ this.expect("assign", "Expected '=' after computed name");
410
+ const initializer = this.parseExpression();
411
+ return { kind: "ExtensionStatement:web:computed", exported, name: name.value, type, initializer, span: span(start, initializer.span.end) };
412
+ }
413
+ parseResourceDeclaration(start, exported) {
414
+ const name = this.expect("identifier", "Expected a resource name");
415
+ const type = this.match("colon") ? this.parseTypeReference() : null;
416
+ this.expect("assign", "Expected '=' after resource name");
417
+ const initializer = this.parseExpression();
418
+ return { kind: "ExtensionStatement:web:resource", exported, name: name.value, type, initializer, span: span(start, initializer.span.end) };
419
+ }
420
+ parseActionDeclaration(start, exported) {
421
+ const name = this.expect("identifier", "Expected an action name");
422
+ const parameters = this.parseParameters();
423
+ const parameterListEnd = this.previous().span.end;
424
+ const returnType = this.match("arrow") ? this.parseTypeReference() : null;
425
+ const body = this.parseBlock();
426
+ const end = body.at(-1)?.span.end ?? returnType?.span.end ?? name.span.end;
427
+ return {
428
+ kind: "ExtensionStatement:web:action",
429
+ exported,
430
+ name: name.value,
431
+ parameters,
432
+ returnType,
433
+ ...(returnType ? { resultAnnotationSpan: span(parameterListEnd, returnType.span.end) } : {}),
434
+ signatureSpan: span(start, returnType?.span.end ?? parameterListEnd),
435
+ body,
436
+ span: span(start, end),
437
+ };
438
+ }
439
+ parseWatchDeclaration(start) {
440
+ const expression = this.parseExpression();
441
+ let currentName = null;
442
+ let previousName = null;
443
+ if (this.matchWord("as")) {
444
+ currentName = this.expect("identifier", "Expected the current watch value name").value;
445
+ this.expect("comma", "Expected ',' between watch value names");
446
+ previousName = this.expect("identifier", "Expected the previous watch value name").value;
447
+ }
448
+ const body = this.parseBlock();
449
+ return { kind: "ExtensionStatement:web:watch", expression, currentName, previousName, body, span: span(start, body.at(-1)?.span.end ?? expression.span.end) };
450
+ }
451
+ parseComponent(start, exported) {
452
+ const name = this.expect("identifier", "Expected a component name");
453
+ if (this.check("less")) {
454
+ this.parseTypeParameters();
455
+ this.diagnostics.push(diagnostic("VEL2025", `Component '${name.value}' cannot declare type parameters; ${typeParameterDeclarationFormsPhrase()} take '<T>'`, name.span));
456
+ }
457
+ this.insideComponentProps += 1;
458
+ const parameters = this.check("leftParen") ? this.parseParameters() : [];
459
+ this.insideComponentProps -= 1;
460
+ for (const parameter of parameters) {
461
+ if (parameter.rest) {
462
+ this.diagnostics.push(diagnostic("VEL2016", "Components use named props and do not support rest parameters", parameter.span));
463
+ }
464
+ }
465
+ const handleType = this.matchExtensionKeyword("exposes") ? this.parseTypeReference() : null;
466
+ this.expect("colon", "Expected ':' before component body");
467
+ this.expect("newline", "Expected a newline before component body");
468
+ this.consumeNewlines();
469
+ this.expect("indent", "Expected an indented component body");
470
+ const body = [];
471
+ this.consumeNewlines();
472
+ while (!this.check("dedent") && !this.check("eof")) {
473
+ const itemStart = this.current().span.start;
474
+ let item = null;
475
+ if (this.exposeItemAhead()) {
476
+ this.advance();
477
+ const value = this.parseExpression();
478
+ item = { kind: "ExtensionStatement:web:expose", value, span: span(itemStart, value.span.end) };
479
+ }
480
+ else if (this.namedDeclarationAhead("state", reactiveBindingShapes)) {
481
+ this.advance();
482
+ item = this.parseStateDeclaration(itemStart, false);
483
+ }
484
+ else if (this.namedDeclarationAhead("computed", reactiveBindingShapes)) {
485
+ this.advance();
486
+ item = this.parseComputedDeclaration(itemStart, false);
487
+ }
488
+ else if (this.namedDeclarationAhead("resource", reactiveBindingShapes)) {
489
+ this.advance();
490
+ item = this.parseResourceDeclaration(itemStart, false);
491
+ }
492
+ else if (this.namedDeclarationAhead("action", actionHeaderShapes)) {
493
+ this.advance();
494
+ item = this.parseActionDeclaration(itemStart, false);
495
+ }
496
+ else if (this.blockHeaderAhead("watch")) {
497
+ this.advance();
498
+ item = this.parseWatchDeclaration(itemStart);
499
+ }
500
+ else if (this.matchLifecycleHook("mounted")) {
501
+ const body = this.parseBlock();
502
+ item = { kind: "ExtensionStatement:web:mounted", body, span: span(itemStart, body.at(-1)?.span.end ?? itemStart) };
503
+ }
504
+ else if (this.matchLifecycleHook("cleanup")) {
505
+ const body = this.parseBlock();
506
+ item = { kind: "ExtensionStatement:web:cleanup", body, span: span(itemStart, body.at(-1)?.span.end ?? itemStart) };
507
+ }
508
+ else if (this.check("at")) {
509
+ const marker = this.advance();
510
+ const name = this.check("identifier") ? this.advance() : null;
511
+ this.diagnostics.push(diagnostic("VEL5061", name
512
+ ? `A component has no '@${name.value}' block; the lifecycle hooks are '@mounted:' and '@cleanup:'`
513
+ : "'@' marks a lifecycle hook; a component's hooks are '@mounted:' and '@cleanup:'", span(marker.span.start, (name ?? marker).span.end)));
514
+ this.skipMistypedDeclaration();
515
+ }
516
+ else if (this.check("identifier") && lifecycleHookSpellings.has(this.current().value) && this.peekKind(1) === "colon") {
517
+ // D43 item 67: the bare words are ordinary names now, so the removed
518
+ // hook spelling gets its own directed answer instead of falling into
519
+ // the statement-boundary message. The block still parses as the hook so
520
+ // its body keeps analyzing in the same compile.
521
+ const keyword = this.advance();
522
+ this.diagnostics.push(recoveredDiagnostic("VEL5061", `Use '@${keyword.value}:'; a lifecycle hook is a language-owned name, which leaves '${keyword.value}' free for your own method`, keyword.span));
523
+ const body = this.parseBlock();
524
+ item = keyword.value === "mounted"
525
+ ? { kind: "ExtensionStatement:web:mounted", body, span: span(itemStart, body.at(-1)?.span.end ?? itemStart) }
526
+ : { kind: "ExtensionStatement:web:cleanup", body, span: span(itemStart, body.at(-1)?.span.end ?? itemStart) };
527
+ }
528
+ else if (this.check("identifier") && renderBlockSpellings.has(this.current().value) && this.peekKind(1) === "colon") {
529
+ const keyword = this.advance();
530
+ this.diagnostics.push(diagnostic("VEL5048", `Use 'return <...>'; a component returns its JSX directly and has no '${keyword.value}:' block`, keyword.span));
531
+ this.skipMistypedDeclaration();
532
+ }
533
+ else {
534
+ item = this.parseStatement();
535
+ }
536
+ if (item)
537
+ body.push(item);
538
+ if (this.previous().kind !== "dedent")
539
+ this.expectStatementBoundary();
540
+ this.consumeNewlines();
541
+ }
542
+ const close = this.expect("dedent", "Expected the end of component body");
543
+ return { kind: "ExtensionStatement:web:component", exported, name: name.value, parameters, handleType, body, span: span(start, body.at(-1)?.span.end ?? close.span.end) };
544
+ }
545
+ }
546
+ function shiftSourceSpan(sourceSpan, offset) {
547
+ return offset === 0 ? sourceSpan : span(sourceSpan.start + offset, sourceSpan.end + offset);
548
+ }
549
+ function shiftExpressionSource(source, offset) {
550
+ return offset === 0 ? source : { ...source, span: shiftSourceSpan(source.span, offset) };
551
+ }
552
+ function shiftJsxSyntax(syntax, offset) {
553
+ if (offset === 0)
554
+ return syntax;
555
+ return {
556
+ ...syntax,
557
+ span: shiftSourceSpan(syntax.span, offset),
558
+ tagSpan: shiftSourceSpan(syntax.tagSpan, offset),
559
+ attributes: syntax.attributes.map((attribute) => ({
560
+ ...attribute,
561
+ span: shiftSourceSpan(attribute.span, offset),
562
+ value: typeof attribute.value === "object" && attribute.value !== null
563
+ ? shiftExpressionSource(attribute.value, offset)
564
+ : attribute.value,
565
+ })),
566
+ children: syntax.children.map((child) => {
567
+ if (child.kind === "WebJsxElementSyntax")
568
+ return shiftJsxSyntax(child, offset);
569
+ if (child.kind === "WebJsxExpressionSyntax") {
570
+ return {
571
+ ...child,
572
+ span: shiftSourceSpan(child.span, offset),
573
+ expression: shiftExpressionSource(child.expression, offset),
574
+ };
575
+ }
576
+ return { ...child, span: shiftSourceSpan(child.span, offset) };
577
+ }),
578
+ };
579
+ }
580
+ function shiftLookSyntax(syntax, offset) {
581
+ if (offset === 0)
582
+ return syntax;
583
+ return {
584
+ ...syntax,
585
+ span: shiftSourceSpan(syntax.span, offset),
586
+ lines: syntax.lines.map((line) => ({
587
+ ...line,
588
+ start: line.start + offset,
589
+ end: line.end + offset,
590
+ })),
591
+ };
592
+ }
593
+ function shiftKeyframesSyntax(syntax, offset) {
594
+ if (offset === 0)
595
+ return syntax;
596
+ return {
597
+ ...syntax,
598
+ span: shiftSourceSpan(syntax.span, offset),
599
+ lines: syntax.lines.map((line) => ({ ...line, start: line.start + offset, end: line.end + offset })),
600
+ };
601
+ }
602
+ function jsxExpression(syntax, parseExpression, report) {
603
+ return {
604
+ kind: "ExtensionExpression:web:jsx",
605
+ tag: syntax.tag,
606
+ tagSpan: syntax.tagSpan,
607
+ attributes: syntax.attributes.map((attribute) => ({
608
+ name: attribute.name,
609
+ value: typeof attribute.value === "object" && attribute.value !== null
610
+ ? parseExpression(attribute.value)
611
+ : attribute.value,
612
+ span: attribute.span,
613
+ })),
614
+ children: syntax.children.map((child) => {
615
+ if (child.kind === "WebJsxElementSyntax")
616
+ return jsxExpression(child, parseExpression, report);
617
+ if (child.kind === "WebJsxExpressionSyntax") {
618
+ return { kind: "JSXExpressionChild", expression: parseExpression(child.expression), span: child.span };
619
+ }
620
+ // A bare (unbraced) 'for name in expr:' line written directly as JSX
621
+ // content receives the same .map() guidance as its braced spelling;
622
+ // there is no magic JSX control flow.
623
+ const bareFor = /(?:^|\n)[ \t]*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([^:{<\n]+):/u.exec(child.value);
624
+ if (bareFor) {
625
+ const offset = child.span.start + (bareFor.index + bareFor[0].indexOf("for"));
626
+ report(recoveredDiagnostic("VEL5049", `Use '{${bareFor[2].trim()}.map((${bareFor[1]}) => ...)}'; JSX has no 'for' blocks, so lists render with '.map(...)'`, span(offset, offset + (bareFor[0].length - bareFor[0].indexOf("for")))));
627
+ }
628
+ return { kind: "JSXText", value: child.value, span: child.span };
629
+ }),
630
+ span: syntax.span,
631
+ };
632
+ }
633
+ class LookSourceParser {
634
+ lines;
635
+ blockSpan;
636
+ parseExpression;
637
+ report;
638
+ index = 0;
639
+ constructor(block, parseExpression, report) {
640
+ this.blockSpan = block.span;
641
+ this.parseExpression = parseExpression;
642
+ this.report = report;
643
+ this.lines = block.lines;
644
+ }
645
+ parse() {
646
+ if (this.lines.length === 0) {
647
+ this.report(diagnostic("VEL5038", "A Look block requires at least one entry", this.blockSpan));
648
+ return [];
649
+ }
650
+ return this.parseEntries(this.lines[0].indent);
651
+ }
652
+ parseEntries(indent) {
653
+ const entries = [];
654
+ while (this.index < this.lines.length) {
655
+ const line = this.lines[this.index];
656
+ if (line.indent < indent)
657
+ break;
658
+ if (line.indent > indent) {
659
+ this.report(diagnostic("VEL5038", "Unexpected Look indentation", this.lineSpan(line)));
660
+ this.index += 1;
661
+ continue;
662
+ }
663
+ this.index += 1;
664
+ if (line.text.startsWith("if ") && line.text.endsWith(":")) {
665
+ entries.push(this.parseIf(line, indent, "if "));
666
+ continue;
667
+ }
668
+ if (line.text === "else:" || line.text.startsWith("else if ")) {
669
+ this.report(diagnostic("VEL5038", "Look 'else' must immediately follow an 'if' at the same indentation", this.lineSpan(line)));
670
+ continue;
671
+ }
672
+ // A kebab-case property receives camelCase guidance and recovers as the
673
+ // camelCase entry, so semantic analysis still checks its value and every
674
+ // other Look and JSX diagnostic co-reports in the same compile.
675
+ const kebab = /^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+)\s*=(.*)$/u.exec(line.text);
676
+ const property = kebab
677
+ ? null
678
+ : /^([A-Za-z][A-Za-z0-9]*)\s*=\s*([\s\S]+)$/u.exec(line.text);
679
+ if (kebab && kebab[2].trim().length === 0) {
680
+ const camel = kebab[1].replace(/-+([A-Za-z])/gu, (_, letter) => letter.toUpperCase());
681
+ this.report(diagnostic("VEL5038", `Use '${camel}'; Look properties use the DOM camelCase spelling`, this.lineSpan(line)));
682
+ continue;
683
+ }
684
+ if (kebab || property) {
685
+ const propertyName = kebab
686
+ ? kebab[1].replace(/-+([A-Za-z])/gu, (_, letter) => letter.toUpperCase())
687
+ : property[1];
688
+ if (kebab) {
689
+ this.report(recoveredDiagnostic("VEL5038", `Use '${propertyName}'; Look properties use the DOM camelCase spelling`, this.lineSpan(line)));
690
+ }
691
+ const assignment = line.text.indexOf("=");
692
+ const afterAssignment = line.text.slice(assignment + 1);
693
+ const valueText = afterAssignment.trim();
694
+ const valueStart = line.start + assignment + 1 + (afterAssignment.length - afterAssignment.trimStart().length);
695
+ if (/^(?:margin|padding|inset)/u.test(propertyName) && /^[+-]?\d[\w.%]*(?:\s+[+-]?\d[\w.%]*)+$/u.test(valueText)) {
696
+ const builderArguments = valueText
697
+ .split(/\s+/u)
698
+ .map((token) => (/^[+-]?\d+(?:\.\d+)?$/u.test(token) ? `${token}px` : token))
699
+ .join(", ");
700
+ this.report(recoveredDiagnostic("VEL5038", `Use 'spacing(${builderArguments})'; Look multi-value shorthand is written with the spacing builder`, this.lineSpan(line)));
701
+ entries.push({
702
+ kind: "LookProperty",
703
+ name: propertyName,
704
+ value: { kind: "LiteralExpression", value: null, raw: "null", span: span(valueStart, valueStart + valueText.length) },
705
+ span: this.lineSpan(line),
706
+ });
707
+ continue;
708
+ }
709
+ entries.push({
710
+ kind: "LookProperty",
711
+ name: propertyName,
712
+ value: this.parseExpression(valueText, valueStart, /[\r\n]/u.test(valueText) ? line.openingIndent : undefined),
713
+ span: this.lineSpan(line),
714
+ });
715
+ continue;
716
+ }
717
+ if (line.text.startsWith("...")) {
718
+ const afterSpread = line.text.slice(3);
719
+ const valueText = afterSpread.trim();
720
+ if (!valueText) {
721
+ this.report(diagnostic("VEL5038", "Look composition requires a value after '...'", this.lineSpan(line)));
722
+ continue;
723
+ }
724
+ entries.push({
725
+ kind: "LookSpread",
726
+ value: this.parseExpression(valueText, line.start + 3 + (afterSpread.length - afterSpread.trimStart().length)),
727
+ span: this.lineSpan(line),
728
+ });
729
+ continue;
730
+ }
731
+ const target = /^@([A-Za-z][A-Za-z0-9]*):$/u.exec(line.text)?.[1];
732
+ if (!target) {
733
+ this.report(diagnostic("VEL5038", "Look entries use 'property = value', 'if condition:', '@target:', or composition with '...'", this.lineSpan(line)));
734
+ continue;
735
+ }
736
+ const next = this.lines[this.index];
737
+ if (!next || next.indent <= line.indent) {
738
+ this.report(diagnostic("VEL5038", `Look target '@${target}' requires an indented body`, this.lineSpan(line)));
739
+ continue;
740
+ }
741
+ const children = this.parseEntries(next.indent);
742
+ entries.push({ kind: "LookTarget", name: target, entries: children, span: span(line.start, children.at(-1)?.span.end ?? line.end) });
743
+ }
744
+ return entries;
745
+ }
746
+ parseIf(line, indent, prefix) {
747
+ const conditionSource = line.text.slice(prefix.length, -1);
748
+ const conditionText = conditionSource.trim();
749
+ const conditionOffset = line.start + prefix.length + (conditionSource.length - conditionSource.trimStart().length);
750
+ const condition = this.parseLookCondition(conditionText, conditionOffset);
751
+ const next = this.lines[this.index];
752
+ let thenEntries = [];
753
+ if (!next || next.indent <= line.indent) {
754
+ this.report(diagnostic("VEL5038", "A Look if branch requires an indented body", this.lineSpan(line)));
755
+ }
756
+ else {
757
+ thenEntries = this.parseEntries(next.indent);
758
+ }
759
+ let elseEntries = [];
760
+ const alternate = this.lines[this.index];
761
+ if (alternate?.indent === indent && alternate.text.startsWith("else if ") && alternate.text.endsWith(":")) {
762
+ this.index += 1;
763
+ elseEntries = [this.parseIf(alternate, indent, "else if ")];
764
+ }
765
+ else if (alternate?.indent === indent && alternate.text === "else:") {
766
+ this.index += 1;
767
+ const elseBody = this.lines[this.index];
768
+ if (!elseBody || elseBody.indent <= alternate.indent) {
769
+ this.report(diagnostic("VEL5038", "A Look else branch requires an indented body", this.lineSpan(alternate)));
770
+ }
771
+ else {
772
+ elseEntries = this.parseEntries(elseBody.indent);
773
+ }
774
+ }
775
+ return {
776
+ kind: "LookIf",
777
+ condition,
778
+ thenEntries,
779
+ elseEntries,
780
+ span: span(line.start, elseEntries.at(-1)?.span.end ?? thenEntries.at(-1)?.span.end ?? line.end),
781
+ };
782
+ }
783
+ parseLookCondition(text, absoluteOffset) {
784
+ const hooks = new Map();
785
+ let rewritten = "";
786
+ let quote = "";
787
+ for (let index = 0; index < text.length;) {
788
+ const character = text[index];
789
+ if (quote) {
790
+ rewritten += character;
791
+ if (character === "\\" && index + 1 < text.length) {
792
+ rewritten += text[index + 1];
793
+ index += 2;
794
+ continue;
795
+ }
796
+ if (character === quote)
797
+ quote = "";
798
+ index += 1;
799
+ continue;
800
+ }
801
+ if (character === '"' || character === "'") {
802
+ quote = character;
803
+ rewritten += character;
804
+ index += 1;
805
+ continue;
806
+ }
807
+ const match = /^@([A-Za-z][A-Za-z0-9]*)/u.exec(text.slice(index));
808
+ if (match) {
809
+ hooks.set(absoluteOffset + index, match[1]);
810
+ rewritten += `_${match[1]}`;
811
+ index += match[0].length;
812
+ continue;
813
+ }
814
+ rewritten += character;
815
+ index += 1;
816
+ }
817
+ const parsed = this.parseExpression(rewritten, absoluteOffset);
818
+ return replaceLookHooks(parsed, hooks);
819
+ }
820
+ lineSpan(line) {
821
+ return span(line.start, line.end);
822
+ }
823
+ }
824
+ class KeyframesSourceParser {
825
+ lines;
826
+ blockSpan;
827
+ parseExpression;
828
+ report;
829
+ seenOffsets = new Set();
830
+ index = 0;
831
+ previousGroupStart = -1;
832
+ constructor(block, parseExpression, report) {
833
+ this.lines = block.lines;
834
+ this.blockSpan = block.span;
835
+ this.parseExpression = parseExpression;
836
+ this.report = report;
837
+ }
838
+ parse() {
839
+ if (this.lines.length === 0) {
840
+ this.report(diagnostic("VEL5060", "A keyframes block requires at least one stop", this.blockSpan));
841
+ return [];
842
+ }
843
+ const indent = this.lines[0].indent;
844
+ const stops = [];
845
+ while (this.index < this.lines.length) {
846
+ const line = this.lines[this.index];
847
+ if (line.indent !== indent) {
848
+ this.report(diagnostic("VEL5060", "Unexpected keyframes indentation; stops share one indentation level", this.lineSpan(line)));
849
+ this.index += 1;
850
+ continue;
851
+ }
852
+ this.index += 1;
853
+ const label = /^(.+):$/u.exec(line.text)?.[1]?.trim();
854
+ if (!label) {
855
+ this.report(diagnostic("VEL5060", "A keyframe stop uses 'from:', 'to:', or a percentage such as '50%:'", this.lineSpan(line)));
856
+ continue;
857
+ }
858
+ const offsets = this.parseOffsets(label, line);
859
+ const next = this.lines[this.index];
860
+ if (!next || next.indent <= line.indent) {
861
+ this.report(diagnostic("VEL5060", `Keyframe stop '${label}' requires an indented property body`, this.lineSpan(line)));
862
+ continue;
863
+ }
864
+ const entries = this.parseEntries(next.indent, line.indent);
865
+ if (offsets.length > 0)
866
+ stops.push({
867
+ offsets,
868
+ entries,
869
+ span: span(line.start, entries.at(-1)?.span.end ?? line.end),
870
+ });
871
+ }
872
+ if (stops.length === 0)
873
+ this.report(diagnostic("VEL5060", "A keyframes block requires at least one valid stop", this.blockSpan));
874
+ return stops;
875
+ }
876
+ parseOffsets(label, line) {
877
+ const parts = label.split(",").map((part) => part.trim());
878
+ const offsets = [];
879
+ for (const part of parts) {
880
+ let offset = part === "from" ? 0 : part === "to" ? 100 : null;
881
+ const percentage = /^(\d+(?:\.\d+)?)%$/u.exec(part);
882
+ if (percentage) {
883
+ offset = Number(percentage[1]);
884
+ if (offset === 0 || offset === 100) {
885
+ this.report(diagnostic("VEL5060", `Use '${offset === 0 ? "from" : "to"}:'; ${offset}% has one canonical keyframe spelling`, this.lineSpan(line)));
886
+ continue;
887
+ }
888
+ if (!(offset > 0 && offset < 100)) {
889
+ this.report(diagnostic("VEL5060", `Keyframe percentage '${part}' must be greater than 0% and less than 100%`, this.lineSpan(line)));
890
+ continue;
891
+ }
892
+ }
893
+ if (offset === null) {
894
+ this.report(diagnostic("VEL5060", `Unknown keyframe stop '${part}'; use from, to, or a percentage between them`, this.lineSpan(line)));
895
+ continue;
896
+ }
897
+ if (this.seenOffsets.has(offset)) {
898
+ this.report(diagnostic("VEL5060", `Keyframe stop '${part}' duplicates ${offset === 0 ? "from" : offset === 100 ? "to" : `${offset}%`}`, this.lineSpan(line)));
899
+ continue;
900
+ }
901
+ this.seenOffsets.add(offset);
902
+ offsets.push(offset);
903
+ }
904
+ const groupStart = offsets.length > 0 ? Math.min(...offsets) : this.previousGroupStart;
905
+ if (groupStart < this.previousGroupStart) {
906
+ this.report(diagnostic("VEL5060", "Keyframe stops must be declared in ascending order", this.lineSpan(line)));
907
+ }
908
+ else
909
+ this.previousGroupStart = groupStart;
910
+ return offsets;
911
+ }
912
+ parseEntries(indent, stopIndent) {
913
+ const entries = [];
914
+ while (this.index < this.lines.length) {
915
+ const line = this.lines[this.index];
916
+ if (line.indent <= stopIndent)
917
+ break;
918
+ this.index += 1;
919
+ if (line.indent !== indent) {
920
+ this.report(diagnostic("VEL5060", "Keyframe stop bodies cannot contain nested targets, conditions, or blocks", this.lineSpan(line)));
921
+ continue;
922
+ }
923
+ if (line.text.startsWith("if ") || line.text.startsWith("@") || line.text.startsWith("...") || line.text === "look:") {
924
+ this.report(diagnostic("VEL5060", "Keyframe stops contain only direct Look properties; conditions, targets, composition, and spreads are not allowed", this.lineSpan(line)));
925
+ continue;
926
+ }
927
+ const kebab = /^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+)\s*=\s*([\s\S]+)$/u.exec(line.text);
928
+ const property = kebab ? null : /^([A-Za-z][A-Za-z0-9]*)\s*=\s*([\s\S]+)$/u.exec(line.text);
929
+ if (!kebab && !property) {
930
+ this.report(diagnostic("VEL5060", "A keyframe property is written as 'property = value'", this.lineSpan(line)));
931
+ continue;
932
+ }
933
+ const name = kebab
934
+ ? kebab[1].replace(/-+([A-Za-z])/gu, (_, letter) => letter.toUpperCase())
935
+ : property[1];
936
+ if (kebab)
937
+ this.report(recoveredDiagnostic("VEL5038", `Use '${name}'; Look properties use the DOM camelCase spelling`, this.lineSpan(line)));
938
+ const assignment = line.text.indexOf("=");
939
+ const afterAssignment = line.text.slice(assignment + 1);
940
+ const valueText = afterAssignment.trim();
941
+ const valueStart = line.start + assignment + 1 + (afterAssignment.length - afterAssignment.trimStart().length);
942
+ entries.push({
943
+ kind: "LookProperty",
944
+ name,
945
+ value: this.parseExpression(valueText, valueStart, /[\r\n]/u.test(valueText) ? line.openingIndent : undefined),
946
+ span: this.lineSpan(line),
947
+ });
948
+ }
949
+ return entries;
950
+ }
951
+ lineSpan(line) {
952
+ return span(line.start, line.end);
953
+ }
954
+ }
955
+ function replaceLookHooks(expression, hooks) {
956
+ if (expression.kind === "IdentifierExpression" && hooks.has(expression.span.start)) {
957
+ const hook = { kind: "ExtensionExpression:web:look-hook", name: hooks.get(expression.span.start), span: expression.span };
958
+ return hook;
959
+ }
960
+ const visit = (value) => {
961
+ if (Array.isArray(value))
962
+ return value.map(visit);
963
+ if (!value || typeof value !== "object")
964
+ return value;
965
+ const record = value;
966
+ if (record.kind === "IdentifierExpression" && typeof record.span === "object" && record.span) {
967
+ const sourceSpan = record.span;
968
+ const name = hooks.get(sourceSpan.start);
969
+ if (name)
970
+ return { kind: "ExtensionExpression:web:look-hook", name, span: sourceSpan };
971
+ }
972
+ return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, key === "span" ? child : visit(child)]));
973
+ };
974
+ return visit(expression);
975
+ }
976
+ //# sourceMappingURL=parser.js.map