@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.
- package/LICENSE +201 -0
- package/README.md +89 -0
- package/dist/analyzer.d.ts +302 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +2931 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/ast.d.ts +220 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +102 -0
- package/dist/ast.js.map +1 -0
- package/dist/browser-test.d.ts +29 -0
- package/dist/browser-test.d.ts.map +1 -0
- package/dist/browser-test.js +33 -0
- package/dist/browser-test.js.map +1 -0
- package/dist/compiler.d.ts +9 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +715 -0
- package/dist/compiler.js.map +1 -0
- package/dist/css-tokens.d.ts +46 -0
- package/dist/css-tokens.d.ts.map +1 -0
- package/dist/css-tokens.js +363 -0
- package/dist/css-tokens.js.map +1 -0
- package/dist/editor.d.ts +3 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +147 -0
- package/dist/editor.js.map +1 -0
- package/dist/elements.d.ts +11 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +24 -0
- package/dist/elements.js.map +1 -0
- package/dist/emitter.d.ts +79 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +2699 -0
- package/dist/emitter.js.map +1 -0
- package/dist/host.d.ts +7 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +224 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/inspection.d.ts +3 -0
- package/dist/inspection.d.ts.map +1 -0
- package/dist/inspection.js +82 -0
- package/dist/inspection.js.map +1 -0
- package/dist/keyframes.d.ts +16 -0
- package/dist/keyframes.d.ts.map +1 -0
- package/dist/keyframes.js +29 -0
- package/dist/keyframes.js.map +1 -0
- package/dist/lexer.d.ts +85 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +422 -0
- package/dist/lexer.js.map +1 -0
- package/dist/look-static.d.ts +31 -0
- package/dist/look-static.d.ts.map +1 -0
- package/dist/look-static.js +266 -0
- package/dist/look-static.js.map +1 -0
- package/dist/look.d.ts +134 -0
- package/dist/look.d.ts.map +1 -0
- package/dist/look.js +627 -0
- package/dist/look.js.map +1 -0
- package/dist/parser.d.ts +71 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +976 -0
- package/dist/parser.js.map +1 -0
- package/dist/project-config.d.ts +33 -0
- package/dist/project-config.d.ts.map +1 -0
- package/dist/project-config.js +199 -0
- package/dist/project-config.js.map +1 -0
- package/dist/runtime-foundation.d.ts +7 -0
- package/dist/runtime-foundation.d.ts.map +1 -0
- package/dist/runtime-foundation.js +1245 -0
- package/dist/runtime-foundation.js.map +1 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +3444 -0
- package/dist/runtime.js.map +1 -0
- package/dist/semantic.d.ts +3 -0
- package/dist/semantic.d.ts.map +1 -0
- package/dist/semantic.js +150 -0
- package/dist/semantic.js.map +1 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +178 -0
- package/dist/types.js.map +1 -0
- package/dist/websocket-runtime.d.ts +2 -0
- package/dist/websocket-runtime.d.ts.map +1 -0
- package/dist/websocket-runtime.js +30 -0
- package/dist/websocket-runtime.js.map +1 -0
- package/dist/worker-runtime.d.ts +2 -0
- package/dist/worker-runtime.d.ts.map +1 -0
- package/dist/worker-runtime.js +72 -0
- package/dist/worker-runtime.js.map +1 -0
- 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
|