@vesk/compiler 0.2.50-canary.8 → 0.2.50

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.
@@ -0,0 +1,7 @@
1
+ import { type ComponentIR } from '@vesk/compiler/src/ir';
2
+ /**
3
+ * Run the abuse checks for one component. Called after its body is built, so
4
+ * the tracked names are known.
5
+ */
6
+ export declare function validateAbuse(comp: ComponentIR, file?: string, source?: string): void;
7
+ //# sourceMappingURL=abuse-checks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abuse-checks.d.ts","sourceRoot":"","sources":["../src/abuse-checks.ts"],"names":[],"mappings":"AA6BA,OAAO,EAQL,KAAK,WAAW,EACjB,MAAM,uBAAuB,CAAC;AAsS/B;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAMrF"}
@@ -0,0 +1,315 @@
1
+ import { walk } from 'zimmerframe';
2
+ import { ClientBlock, DynamicBinding, OpaqueDynamicRegion, RuntimeStatement, ServerBlock, TrackDecl, StaticNode, } from '@vesk/compiler/src/ir';
3
+ import { VeskError, codeFrame } from '@vesk/compiler/src/errors';
4
+ /** Offset -> 1-based line/column, for a code frame in a diagnostic. */
5
+ function offsetToLineCol(source, offset) {
6
+ let line = 1;
7
+ let lineStart = 0;
8
+ for (let i = 0; i < offset && i < source.length; i++) {
9
+ if (source[i] === '\n') {
10
+ line++;
11
+ lineStart = i + 1;
12
+ }
13
+ }
14
+ return { line, column: offset - lineStart + 1 };
15
+ }
16
+ /**
17
+ * Browser-only globals, with what to reach for instead. `navigator` and
18
+ * `location` are the dangerous ones: they do not throw on the server, they
19
+ * return undefined and the page renders wrong.
20
+ */
21
+ /**
22
+ * READS only. A bare read of one of these at statement level is unambiguous:
23
+ * it executes during SSR, either throwing (`window`) or silently returning the
24
+ * wrong thing (`navigator` exists in modern Node, so `navigator.onLine` renders
25
+ * as offline). Calls are NOT in this list: `setInterval`/`addEventListener`
26
+ * inside an effect or a hoisted statement are indistinguishable from a body
27
+ * statement at this point in the walk, and a diagnostic that fires on correct
28
+ * code is worse than none. Timer/listener misuse is a documented follow-up.
29
+ */
30
+ const SSR_UNSAFE = {
31
+ window: 'the DOM',
32
+ document: 'the DOM',
33
+ localStorage: 'storage',
34
+ sessionStorage: 'storage',
35
+ navigator: 'the Network Information API (see getNetworkState)',
36
+ location: 'routing (see usePathname)',
37
+ history: 'routing (see useNavigate)',
38
+ matchMedia: 'a CSS media query',
39
+ };
40
+ function at(node, ctx) {
41
+ const opts = { inComponent: ctx.compName };
42
+ if (ctx.file)
43
+ opts.file = ctx.file;
44
+ if (ctx.source && typeof node.start === 'number') {
45
+ const { line, column } = offsetToLineCol(ctx.source, node.start);
46
+ opts.line = line;
47
+ opts.column = column;
48
+ opts.frame = codeFrame(ctx.source, line, column);
49
+ }
50
+ return opts;
51
+ }
52
+ /** Walk an expression, reporting SSR-unsafe globals and cell misuse. */
53
+ /** Calls whose bodies are emitted on the client only, so the DOM is legal there. */
54
+ const CLIENT_ONLY_CALLS = new Set(['on_destroy', 'effect', 'pre_effect', 'user_effect']);
55
+ /**
56
+ * The global named by a `typeof x !== 'undefined'` / `=== 'undefined'` test, or
57
+ * null. `&&` / `||` short-circuit, so the other side of the expression only runs
58
+ * when the global exists.
59
+ */
60
+ function typeofGlobalIn(node) {
61
+ const isTypeofOf = (n) => n && n.type === 'UnaryExpression' && n.operator === 'typeof' && n.argument?.type === 'Identifier'
62
+ ? String(n.argument.name)
63
+ : null;
64
+ if (!node)
65
+ return null;
66
+ const pair = (a, b) => {
67
+ const fromA = isTypeofOf(a);
68
+ if (fromA && b?.type === 'BinaryExpression' && b.operator === '!==')
69
+ return fromA;
70
+ const fromB = isTypeofOf(b);
71
+ if (fromB && a?.type === 'BinaryExpression' && a.operator === '!==')
72
+ return fromB;
73
+ return null;
74
+ };
75
+ if (node.type === 'LogicalExpression')
76
+ return pair(node.left, node.right);
77
+ if (node.type === 'ConditionalExpression') {
78
+ return pair(node.test, node.consequent) ?? pair(node.test, node.alternate);
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * The global a subexpression tests for existence with `typeof`, whether the test
84
+ * stands alone (`typeof x !== 'undefined'`), guards an `&&` chain, or selects a
85
+ * conditional branch. Anything evaluated only when that test passed may read the
86
+ * global safely.
87
+ */
88
+ function guardNameOf(node) {
89
+ if (!node)
90
+ return null;
91
+ const direct = typeofGlobalIn(node);
92
+ if (direct)
93
+ return direct;
94
+ if (node.type === 'LogicalExpression')
95
+ return guardNameOf(node.left);
96
+ return null;
97
+ }
98
+ /**
99
+ * Does this statement set up a client-only lifecycle call anywhere?
100
+ *
101
+ * `effect(() => { const h = setInterval(…); on_destroy(() => clearInterval(h)) })`
102
+ * can arrive as ONE hoisted statement, so a statement that mentions a
103
+ * client-only call is treated as client-only. That errs toward silence rather
104
+ * than a false positive, which is the right direction for a new diagnostic.
105
+ */
106
+ function mentionsClientOnlyCall(ast) {
107
+ if (!ast)
108
+ return false;
109
+ let found = false;
110
+ try {
111
+ walk(ast, null, {
112
+ CallExpression(node) {
113
+ const callee = node.callee;
114
+ if (callee && callee.type === 'Identifier' && CLIENT_ONLY_CALLS.has(String(callee.name))) {
115
+ found = true;
116
+ return;
117
+ }
118
+ return;
119
+ },
120
+ });
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ return found;
126
+ }
127
+ function checkExpression(ast, ctx, insideHandler) {
128
+ if (!ast)
129
+ return;
130
+ walk(ast, null, {
131
+ ConditionalExpression(node, context) {
132
+ // `test ? a : typeof x !== 'undefined' && x.use()` — the chosen branch is
133
+ // guarded, so a read of that global there is safe.
134
+ const guarded = guardNameOf(node.alternate) ?? guardNameOf(node.consequent);
135
+ if (!guarded)
136
+ return context.next();
137
+ ctx.guardedGlobals = new Set([...(ctx.guardedGlobals || []), guarded]);
138
+ return context.next();
139
+ },
140
+ LogicalExpression(node, context) {
141
+ // `typeof document !== 'undefined' && !!document.querySelector(…)` is the
142
+ // standard client-only guard, and the right side never evaluates on the
143
+ // server. Honour the short-circuit instead of flagging correct code (the
144
+ // docs app's error boundary uses exactly this shape).
145
+ const guarded = guardNameOf(node.left);
146
+ if (guarded) {
147
+ ctx.guardedGlobals = new Set([...(ctx.guardedGlobals || []), guarded]);
148
+ return context.next();
149
+ }
150
+ return context.next();
151
+ },
152
+ UnaryExpression(node, context) {
153
+ // `typeof window !== 'undefined'` is the CORRECT guard for a client-only
154
+ // value — the test-app's own broken-component fixture uses it. A `typeof`
155
+ // test never reads the value, so it must not be flagged.
156
+ if (node.operator === 'typeof')
157
+ return;
158
+ return context.next();
159
+ },
160
+ ArrowFunctionExpression(node, context) {
161
+ // A function body runs when it is CALLED, not when the render creates it.
162
+ // `const probe = () => document.querySelector(…)` called from a handler is
163
+ // correct code, so the walk stops here.
164
+ return;
165
+ },
166
+ FunctionExpression(node, context) {
167
+ return;
168
+ },
169
+ Identifier(node, context) {
170
+ if ((ctx.clientOnlyDepth || 0) > 0)
171
+ return context.next();
172
+ const parent = context.path.at(-1);
173
+ // A member's PROPERTY is a name, not a global: `foo.window` is fine.
174
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed)
175
+ return context.next();
176
+ if (parent && parent.type === 'Property' && parent.key === node && !parent.computed && !parent.shorthand)
177
+ return context.next();
178
+ const name = String(node.name);
179
+ if (insideHandler)
180
+ return context.next();
181
+ if (ctx.guardedGlobals?.has(name))
182
+ return context.next();
183
+ if (name in SSR_UNSAFE) {
184
+ throw VeskError.ssrUnsafeGlobal(name, at(node, ctx));
185
+ }
186
+ return context.next();
187
+ },
188
+ CallExpression(node, context) {
189
+ const callee = node.callee;
190
+ // Depth is carried on the context object, not a module global: a global is
191
+ // never decremented, so one on_destroy call would silently disable every
192
+ // later check in the file.
193
+ const insideClientOnly = (ctx.clientOnlyDepth || 0) > 0;
194
+ if (callee && callee.type === 'Identifier') {
195
+ const name = String(callee.name);
196
+ if (insideHandler || insideClientOnly)
197
+ return context.next();
198
+ // on_destroy/effect bodies are emitted on the client only, so anything
199
+ // they touch is legal — and `context.skip()` keeps the walk out.
200
+ if (CLIENT_ONLY_CALLS.has(name)) {
201
+ ctx.clientOnlyDepth = (ctx.clientOnlyDepth || 0) + 1;
202
+ context.next();
203
+ ctx.clientOnlyDepth -= 1;
204
+ return;
205
+ }
206
+ if (name in SSR_UNSAFE)
207
+ throw VeskError.ssrUnsafeGlobal(name, at(callee, ctx));
208
+ }
209
+ // Anything else (a nested arrow, a call argument) is still evaluated on
210
+ // the server when it runs in the body — unless we are inside a
211
+ // client-only call.
212
+ if (!insideHandler && !insideClientOnly)
213
+ checkExpression(callee ?? null, ctx, false);
214
+ return context.next();
215
+ },
216
+ });
217
+ }
218
+ /** The nodes whose expressions the server evaluates. */
219
+ function checkNode(node, ctx, insideHandler = false) {
220
+ if (!node || typeof node !== 'object')
221
+ return;
222
+ // Guarded globals are scoped to one top-level statement.
223
+ if (insideHandler === false && node instanceof RuntimeStatement)
224
+ ctx.guardedGlobals = new Set();
225
+ // `{#client}` never runs on the server; `{#server}` DOES (that is its whole
226
+ // purpose), so it is checked like any other body position.
227
+ if (node instanceof ClientBlock)
228
+ return;
229
+ if (node instanceof ServerBlock) {
230
+ for (const child of (node.children
231
+ || node.body
232
+ || [])) {
233
+ checkNode(child, ctx);
234
+ }
235
+ return;
236
+ }
237
+ if (node instanceof RuntimeStatement) {
238
+ if (!insideHandler && !mentionsClientOnlyCall(node.ast))
239
+ checkExpression(node.ast, ctx, false);
240
+ return;
241
+ }
242
+ if (node instanceof TrackDecl) {
243
+ // `track(...)` is safe in the body; a track() inside a nested function is not,
244
+ // but that is a different failure (a raw syntax error) and the parser owns it.
245
+ return;
246
+ }
247
+ if (node instanceof DynamicBinding) {
248
+ if (insideHandler)
249
+ return;
250
+ // An event handler is client-only by definition: it is dispatched by the
251
+ // browser, so `document.title = …` there is correct, not a bug.
252
+ const binding = node;
253
+ const target = String(binding.target || '');
254
+ if (target.startsWith('on') || binding.kind === 'event')
255
+ return;
256
+ checkExpression(node.expression?.ast ?? null, ctx, false);
257
+ return;
258
+ }
259
+ if (node instanceof OpaqueDynamicRegion) {
260
+ checkExpression(node.condition?.ast ?? null, ctx, false);
261
+ for (const child of (node.consequentNodes || []))
262
+ checkNode(child, ctx);
263
+ for (const child of (node.alternateNodes || []))
264
+ checkNode(child, ctx);
265
+ return;
266
+ }
267
+ if (node instanceof StaticNode) {
268
+ for (const attr of node.attributes || [])
269
+ void attr;
270
+ for (const child of node.children || [])
271
+ checkNode(child, ctx);
272
+ return;
273
+ }
274
+ // ComponentCall / MapRegion / prop slots / anything else: recurse over children
275
+ // and check their expressions, still in body position.
276
+ const record = node;
277
+ if (record.expression && typeof record.expression === 'object') {
278
+ checkExpression(record.expression.ast ?? null, ctx, false);
279
+ }
280
+ for (const key of Object.keys(record)) {
281
+ const value = record[key];
282
+ if (Array.isArray(value))
283
+ for (const item of value)
284
+ checkNode(item, ctx);
285
+ else if (value && typeof value === 'object' && value.constructor?.name?.endsWith('Node')) {
286
+ checkNode(value, ctx);
287
+ }
288
+ }
289
+ }
290
+ /** The cell/value halves of every `track()` declaration in a component. */
291
+ function collectCells(comp) {
292
+ const cells = new Map();
293
+ const values = new Map();
294
+ for (const node of comp.body) {
295
+ if (!(node instanceof TrackDecl))
296
+ continue;
297
+ const cellName = node.rawName || node.name;
298
+ if (node.rawName)
299
+ cells.set(node.rawName, { value: node.name });
300
+ values.set(node.name, { cell: cellName });
301
+ }
302
+ return { cells, values };
303
+ }
304
+ /**
305
+ * Run the abuse checks for one component. Called after its body is built, so
306
+ * the tracked names are known.
307
+ */
308
+ export function validateAbuse(comp, file, source) {
309
+ // A `client` island renders on the server too — SSR runs the same body — so
310
+ // the same rules apply. What differs is `{#server}`/`{#client}`.
311
+ const { cells, values } = collectCells(comp);
312
+ const ctx = { compName: comp.name, file, source, cells, values };
313
+ for (const node of comp.body)
314
+ checkNode(node, ctx);
315
+ }
package/dist/errors.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface VeskErrorOptions {
9
9
  nextSteps?: string[];
10
10
  tip?: string;
11
11
  code?: string;
12
+ /** Rendered code frame (see `codeFrame`). Never a V-code. */
13
+ frame?: string;
12
14
  [key: string]: unknown;
13
15
  }
14
16
  export declare class VeskError extends Error {
@@ -19,7 +21,10 @@ export declare class VeskError extends Error {
19
21
  suggestions: string[];
20
22
  nextSteps: string[];
21
23
  tip: string;
24
+ /** The V-diagnostic code, e.g. `V0501`. */
22
25
  code?: string;
26
+ /** The rendered code frame. Used to be written into `code`, which silently replaced the V-code. */
27
+ frame?: string;
23
28
  constructor(message: string, opts?: VeskErrorOptions);
24
29
  static notFound(name: string, candidates?: string[], context?: VeskErrorOptions): VeskError;
25
30
  /**
@@ -37,6 +42,40 @@ export declare class VeskError extends Error {
37
42
  form?: 'nested' | 'value';
38
43
  }): VeskError;
39
44
  static classDecl(context?: VeskErrorOptions): VeskError;
45
+ /**
46
+ * A browser-only global used in code the SERVER evaluates.
47
+ *
48
+ * On the server these are either absent (`window is not defined`) or, worse,
49
+ * silently present-but-wrong (`navigator` exists in modern Node, so
50
+ * `navigator.onLine` is `undefined` and a page renders as if it were offline).
51
+ * A raw `ReferenceError` at request time is the worst possible report: it
52
+ * names neither the component nor the fix. This points at the line.
53
+ */
54
+ static ssrUnsafeGlobal(name: string, context?: Partial<VeskErrorOptions> & {
55
+ inComponent?: string;
56
+ }): VeskError;
57
+ /**
58
+ * The second name from `track()` called as a function.
59
+ *
60
+ * `const &[count, setCount] = track(0)` returns a CELL, not a setter function, so
61
+ * `setCount(1)` throws `setCount is not a function` from generated code with
62
+ * no line of user code in the message. The cell is the writer: `.set(v)`.
63
+ */
64
+ static cellCalledAsFunction(name: string, context?: Partial<VeskErrorOptions> & {
65
+ value?: string;
66
+ cell?: string;
67
+ }): VeskError;
68
+ /**
69
+ * `set(value, …)` used outside a rewritten expression.
70
+ *
71
+ * The compiler rewrites `set(count, v)` to `countCell.set(v)` inside JSX and
72
+ * event handlers — but a bare statement in the component body is NOT
73
+ * rewritten, so the runtime `set` receives the value and throws
74
+ * "Cannot read properties of undefined". Pass the CELL, not the value.
75
+ */
76
+ static setWithValueName(name: string, context?: Partial<VeskErrorOptions> & {
77
+ cell?: string;
78
+ }): VeskError;
40
79
  static serverBlockInClient(compName: string, context?: VeskErrorOptions): VeskError;
41
80
  static clientBlockInServer(compName: string, context?: VeskErrorOptions): VeskError;
42
81
  static componentNotFound(name: string, available?: string[], context?: VeskErrorOptions): VeskError;
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAoBxD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,WAAW,GAAE,MAAU,GAAG,MAAM,GAAG,IAAI,CAWrG;AAqCD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAI,GAAG,MAAM,CAkBrG;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAgBD,qBAAa,SAAU,SAAQ,KAAK;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEF,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB;IAYxD,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IA8BnG;;;;;;;;;;OAUG;IACH,MAAM,CAAC,kBAAkB,CAAC,OAAO,GAAE,gBAAgB,GAAG;QAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAA;KAAO,GAAG,SAAS;IA6BpG,MAAM,CAAC,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAoB3D,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBvF,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBvF,MAAM,CAAC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAO3G,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAYvG,MAAM,CAAC,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmB/G,MAAM,CAAC,cAAc,CAAC,OAAO,GAAE,gBAAgB,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,SAAS;IAmBpF,QAAQ,IAAI,MAAM;IA0BlB,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAclC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
package/dist/errors.js CHANGED
@@ -107,7 +107,10 @@ export class VeskError extends Error {
107
107
  suggestions;
108
108
  nextSteps;
109
109
  tip;
110
+ /** The V-diagnostic code, e.g. `V0501`. */
110
111
  code;
112
+ /** The rendered code frame. Used to be written into `code`, which silently replaced the V-code. */
113
+ frame;
111
114
  constructor(message, opts = {}) {
112
115
  super(message);
113
116
  this.name = 'VeskError';
@@ -117,8 +120,13 @@ export class VeskError extends Error {
117
120
  this.suggestions = opts.suggestions || [];
118
121
  this.nextSteps = opts.nextSteps || [];
119
122
  this.tip = opts.tip || '';
120
- if (opts.code !== undefined)
123
+ // The diagnostic code owns `code`. The code frame used to be written there
124
+ // too, which silently replaced `V0501` with a wall of source text — the
125
+ // report lost the one thing that identifies it.
126
+ if (opts.code !== undefined && !opts.code.includes('\n'))
121
127
  this.code = opts.code;
128
+ if (opts.frame !== undefined)
129
+ this.frame = opts.frame;
122
130
  }
123
131
  static notFound(name, candidates = [], context = {}) {
124
132
  const allCandidates = [...new Set([...candidates, ...VESK_BUILTINS])];
@@ -202,6 +210,75 @@ export class VeskError extends Error {
202
210
  tip: 'Use plain objects for data and factory functions for constructors inside .vsk files.',
203
211
  });
204
212
  }
213
+ /**
214
+ * A browser-only global used in code the SERVER evaluates.
215
+ *
216
+ * On the server these are either absent (`window is not defined`) or, worse,
217
+ * silently present-but-wrong (`navigator` exists in modern Node, so
218
+ * `navigator.onLine` is `undefined` and a page renders as if it were offline).
219
+ * A raw `ReferenceError` at request time is the worst possible report: it
220
+ * names neither the component nor the fix. This points at the line.
221
+ */
222
+ static ssrUnsafeGlobal(name, context = {}) {
223
+ return new VeskError(`\`${name}\` is browser-only, and this component body runs during SSR.`, {
224
+ ...context,
225
+ code: context.code || 'V0501',
226
+ suggestions: [
227
+ `Wrap the part that needs the DOM in a {#client} block.`,
228
+ `Move the code into an event handler — handlers never run on the server.`,
229
+ `Do the setup in on_destroy(), which the compiler only emits on the client.`,
230
+ ],
231
+ nextSteps: [
232
+ `component ${context.inComponent ?? 'this component'}: wrap the \`${name}\` access in {#client}...{/client}.`,
233
+ `Or guard it: if (typeof ${name} !== 'undefined') { ... }`,
234
+ ],
235
+ tip: 'SSR renders this component before the client exists. Reading a browser global here either throws during the request or (for `navigator`) silently returns undefined.',
236
+ });
237
+ }
238
+ /**
239
+ * The second name from `track()` called as a function.
240
+ *
241
+ * `const &[count, setCount] = track(0)` returns a CELL, not a setter function, so
242
+ * `setCount(1)` throws `setCount is not a function` from generated code with
243
+ * no line of user code in the message. The cell is the writer: `.set(v)`.
244
+ */
245
+ static cellCalledAsFunction(name, context = {}) {
246
+ return new VeskError(`\`${name}\` is a cell, not a function — cells are written with \`.set()\`.`, {
247
+ ...context,
248
+ code: context.code || 'V0502',
249
+ suggestions: [
250
+ `Use \`${name}.set(...)\` instead of \`${name}(...)\`.`,
251
+ `Or use the tracked spelling inside an event handler: set(${context.value ?? 'value'}, ...)`,
252
+ ],
253
+ nextSteps: [
254
+ `const &[${context.value ?? 'value'}, ${name}] = track(0) → \`${name}.set(v)\``,
255
+ 'The value read in markup is the first name; the second is the cell object.',
256
+ ],
257
+ tip: `\`track()\` returns [value, cell]. \`${name}\` is the cell half.`,
258
+ });
259
+ }
260
+ /**
261
+ * `set(value, …)` used outside a rewritten expression.
262
+ *
263
+ * The compiler rewrites `set(count, v)` to `countCell.set(v)` inside JSX and
264
+ * event handlers — but a bare statement in the component body is NOT
265
+ * rewritten, so the runtime `set` receives the value and throws
266
+ * "Cannot read properties of undefined". Pass the CELL, not the value.
267
+ */
268
+ static setWithValueName(name, context = {}) {
269
+ return new VeskError(`\`set(${name}, …)\` needs the CELL, and \`${name}\` is the value half of track().`, {
270
+ ...context,
271
+ code: context.code || 'V0503',
272
+ suggestions: [
273
+ `Use \`set(${context.cell ?? 'cell'}, …)\` or \`${context.cell ?? 'cell'}.set(…)\`.`,
274
+ `Or move the set() into an event handler, where the compiler rewrites it for you.`,
275
+ ],
276
+ nextSteps: [
277
+ `const &[${name}, ${context.cell ?? 'cell'}] = track(0) → \`${context.cell ?? 'cell'}.set(v)\``,
278
+ ],
279
+ tip: 'set(cell, value) is only rewritten inside JSX and handlers; in a body statement it reaches the runtime untouched.',
280
+ });
281
+ }
205
282
  static serverBlockInClient(compName, context = {}) {
206
283
  return new VeskError(`{#server} block found in client island "${compName}". Client islands render on both server and client, so {#server} blocks have no effect.`, {
207
284
  ...context,
@@ -1524,7 +1524,7 @@ function validateBlocks(compName, isClient, body, file, source) {
1524
1524
  const { line, column } = offsetToLineCol(source, pos);
1525
1525
  opts.line = line;
1526
1526
  opts.column = column;
1527
- opts.code = codeFrame(source, line, column);
1527
+ opts.frame = codeFrame(source, line, column);
1528
1528
  }
1529
1529
  throw VeskError.serverBlockInClient(compName, opts);
1530
1530
  }
@@ -1537,7 +1537,7 @@ function validateBlocks(compName, isClient, body, file, source) {
1537
1537
  const { line, column } = offsetToLineCol(source, pos);
1538
1538
  opts.line = line;
1539
1539
  opts.column = column;
1540
- opts.code = codeFrame(source, line, column);
1540
+ opts.frame = codeFrame(source, line, column);
1541
1541
  }
1542
1542
  throw VeskError.clientBlockInServer(compName, opts);
1543
1543
  }
package/dist/scan.d.ts CHANGED
@@ -93,6 +93,21 @@ export declare function hasTopLevelComma(text: string): boolean;
93
93
  * and `for (... in ...)` headers apart from classic `for` loops.
94
94
  */
95
95
  export declare function containsForOfIn(text: string): boolean;
96
+ /**
97
+ * Reports whether `ident` appears in `text` as a CALLED identifier — i.e.
98
+ * followed by an optional TS generic clause and then `(`.
99
+ *
100
+ * This is a character-loop tokenizer, not a text match: it steps over
101
+ * whitespace, `//` and block comments, and quoted / template strings, so
102
+ * `track(` appearing inside a string, a comment or a property access
103
+ * (`obj.track(...)`) never counts. Template literals are skipped whole —
104
+ * including their `${...}` holes — because a `track(` in an interpolation is
105
+ * not a reliable enough signal to inject a runtime binding for.
106
+ *
107
+ * Used to decide which runtime helpers a raw statement needs without
108
+ * requiring the caller to have been lowered into a TrackDecl first.
109
+ */
110
+ export declare function callsIdentifier(text: string, ident: string): boolean;
96
111
  /**
97
112
  * Extracts the argument text of a `track<...>(...)` call from an init
98
113
  * expression. Handles nested generics (`track<Map<string, number>>(...)`)
@@ -1 +1 @@
1
- {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAItD;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAOzE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWrD;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS5D;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAc9D;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAsB1D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAY3D;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmDlD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAkBvE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CA2BjE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAgBtD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAkBrD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAYpD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcnE;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAqB9D;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAY3D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAexD"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAItD;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAOzE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWrD;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS5D;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAc9D;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAsB1D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAY3D;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmDlD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAkBvE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CA2BjE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAgBtD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAkBrD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAwDpE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAYpD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcnE;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAqB9D;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAY3D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAexD"}
package/dist/scan.js CHANGED
@@ -408,6 +408,70 @@ export function containsForOfIn(text) {
408
408
  }
409
409
  return false;
410
410
  }
411
+ /**
412
+ * Reports whether `ident` appears in `text` as a CALLED identifier — i.e.
413
+ * followed by an optional TS generic clause and then `(`.
414
+ *
415
+ * This is a character-loop tokenizer, not a text match: it steps over
416
+ * whitespace, `//` and block comments, and quoted / template strings, so
417
+ * `track(` appearing inside a string, a comment or a property access
418
+ * (`obj.track(...)`) never counts. Template literals are skipped whole —
419
+ * including their `${...}` holes — because a `track(` in an interpolation is
420
+ * not a reliable enough signal to inject a runtime binding for.
421
+ *
422
+ * Used to decide which runtime helpers a raw statement needs without
423
+ * requiring the caller to have been lowered into a TrackDecl first.
424
+ */
425
+ export function callsIdentifier(text, ident) {
426
+ let i = 0;
427
+ const len = text.length;
428
+ // True when the previous significant character means the next identifier
429
+ // is a member access (`obj.track`) rather than a free call.
430
+ let afterDot = false;
431
+ while (i < len) {
432
+ const ch = text[i];
433
+ if (isWhitespaceChar(ch)) {
434
+ i++;
435
+ continue;
436
+ }
437
+ const commentEnd = skipComment(text, i);
438
+ if (commentEnd !== i) {
439
+ i = commentEnd;
440
+ continue;
441
+ }
442
+ if (ch === '"' || ch === "'" || ch === '`') {
443
+ i = skipString(text, i);
444
+ afterDot = false;
445
+ continue;
446
+ }
447
+ if (ch === '.' || (ch === '?' && text[i + 1] === '.')) {
448
+ afterDot = true;
449
+ i += ch === '.' ? 1 : 2;
450
+ continue;
451
+ }
452
+ if (isIdentStartCode(ch.charCodeAt(0))) {
453
+ let j = i + 1;
454
+ while (j < len && isIdentCharCode(text.charCodeAt(j)))
455
+ j++;
456
+ const name = text.slice(i, j);
457
+ let k = skipWhitespace(text, j);
458
+ // A `name<T,>(...)` generic call — skip the balanced `<...>`.
459
+ if (text[k] === '<') {
460
+ k = skipTrackGeneric(text, k);
461
+ k = skipWhitespace(text, k);
462
+ }
463
+ if (name === ident && text[k] === '(' && !afterDot) {
464
+ return true;
465
+ }
466
+ afterDot = false;
467
+ i = j;
468
+ continue;
469
+ }
470
+ afterDot = false;
471
+ i++;
472
+ }
473
+ return false;
474
+ }
411
475
  /**
412
476
  * Extracts the argument text of a `track<...>(...)` call from an init
413
477
  * expression. Handles nested generics (`track<Map<string, number>>(...)`)
@@ -1 +1 @@
1
- {"version":3,"file":"server-jsgen.d.ts","sourceRoot":"","sources":["../src/server-jsgen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAOzE,OAAO,EAAkG,KAAK,WAAW,EAAE,MAAM,mCAAmC,CAAC;AAoDrK,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,OAAO,GAAE,OAAe,EAAE,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CAgEjJ;AA4XD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CAqD1F;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAkCzE;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAYhG"}
1
+ {"version":3,"file":"server-jsgen.d.ts","sourceRoot":"","sources":["../src/server-jsgen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAOzE,OAAO,EAAkG,KAAK,WAAW,EAAE,MAAM,mCAAmC,CAAC;AA4DrK,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,OAAO,GAAE,OAAe,EAAE,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CA4EjJ;AAyZD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CAwD1F;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAuFD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAkCzE;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAYhG"}
@@ -1,8 +1,15 @@
1
- import { StaticNode, TextNode, DynamicBinding, OpaqueDynamicRegion, MapRegion, WhileLoop, SwitchBlock, TryCatch, ForLoop, TrackDecl, RuntimeStatement, ComponentRef, ComponentCall, ServerBlock, ClientBlock, HeadBlock, SlotNode, PropSlot, PropSlotRender, } from '@vesk/compiler/src/ir';
1
+ import { Expression, StaticNode, TextNode, DynamicBinding, OpaqueDynamicRegion, MapRegion, WhileLoop, SwitchBlock, TryCatch, ForLoop, TrackDecl, RuntimeStatement, ComponentRef, ComponentCall, ServerBlock, ClientBlock, HeadBlock, SlotNode, PropSlot, PropSlotRender, } from '@vesk/compiler/src/ir';
2
2
  import { isStaticIR, collectTrackedNames, transformTracked, transformTrackedInit, semicolonizeStatement } from '@vesk/compiler/src/client-codegen';
3
- import { unwrapTrackCall, stripTrackGeneric, hasTopLevelComma, skipWhitespace, findBalancedEnd, startsWithIdentifier } from '@vesk/compiler/src/scan';
3
+ import { unwrapTrackCall, stripTrackGeneric, hasTopLevelComma, skipWhitespace, findBalancedEnd, startsWithIdentifier, callsIdentifier } from '@vesk/compiler/src/scan';
4
4
  import { escapeHtml, indent, exprJS, extractTopLevelNames, extractRuntimeNames, buildParamInit, __vskHydrate, __vskMarkerless, __vskImportedNames, setVskImportedNames, setVskForceClaim, takeVskForceClaim, nextVskId, } from '@vesk/compiler/src/server-utils';
5
5
  import { localValueImportNames } from '@vesk/compiler/src/module-imports';
6
+ import { lowerJsxInExpression } from '@vesk/compiler/src/server-jsx-lowering';
7
+ import { print } from 'esrap';
8
+ import tsPrinter from 'esrap/languages/ts';
9
+ /** Print an already-lowered AST with the plain TS printer (no JSX remains). */
10
+ function printLoweredTs(ast) {
11
+ return print(ast, tsPrinter()).code;
12
+ }
6
13
  import { vskImportValueNames } from '@vesk/compiler/src/vsk-imports';
7
14
  // Hydrate-mode component-boundary wrapper. Each server-rendered component call
8
15
  // is preceded by a `<!--vsk-->` marker and a single container element: the
@@ -107,8 +114,17 @@ export function irNodeToJS(node, importedNames, isAsync = false, tracked) {
107
114
  `})();`,
108
115
  ].join('\n');
109
116
  }
110
- if (node instanceof RuntimeStatement)
117
+ if (node instanceof RuntimeStatement) {
118
+ const stmt = node;
119
+ const lowered = stmt.ast ? lowerJsxInExpression(stmt.ast) : null;
120
+ if (lowered && lowered !== stmt.ast && (tracked?.size ?? 0) === 0) {
121
+ return semicolonizeStatement(printLoweredTs(lowered));
122
+ }
123
+ if (lowered && lowered !== stmt.ast) {
124
+ return semicolonizeStatement(transformTracked({ ...node, ast: lowered }, tracked || new Map()));
125
+ }
111
126
  return semicolonizeStatement(transformTracked(node, tracked || new Map()));
127
+ }
112
128
  if (node instanceof SlotNode) {
113
129
  // Markerless mode drops the slot boundary comments: SSR output is plain
114
130
  // HTML and slot claims scope structurally (createLayoutSlot subject to the
@@ -133,7 +149,21 @@ function isEvent(target) {
133
149
  return !!target && target.startsWith('on') && target.length > 2;
134
150
  }
135
151
  function exprJSX(node, tracked) {
136
- return exprJS(transformTracked(node, tracked || new Map()));
152
+ // A function-valued child — `<For each={items}>{(item) => <li/>}</For>` —
153
+ // carries JSX inside an EXPRESSION, and the server has no IR node for it, so
154
+ // it used to be printed verbatim and the render threw "Unexpected token '<'".
155
+ // Lower it to the string the body emitter would have produced. Client output
156
+ // is untouched (a chunk goes through esbuild's tsx loader, so JSX is legal
157
+ // there).
158
+ const lowered = node.ast ? lowerJsxInExpression(node.ast) : null;
159
+ const didLower = lowered !== node.ast;
160
+ if (didLower && (tracked?.size ?? 0) === 0) {
161
+ // `transformTracked` returns the RAW source when there is nothing to
162
+ // rewrite, which would throw the lowering away. With no tracked names there
163
+ // are no rewrites to lose.
164
+ return exprJS(printLoweredTs(lowered));
165
+ }
166
+ return exprJS(transformTracked({ ...node, ast: lowered }, tracked || new Map()));
137
167
  }
138
168
  function staticNodeToJS(node, isAsync = false, tracked) {
139
169
  const lines = [];
@@ -461,19 +491,35 @@ function componentCallToJS(node, importedNames, isAsync = false, tracked) {
461
491
  }
462
492
  }
463
493
  if (regularChildren.length > 0) {
464
- const childLines = [];
465
- for (const child of regularChildren) {
466
- const code = irNodeToJS(child, importedNames, isAsync, tracked);
467
- if (code)
468
- childLines.push(code);
494
+ // A single function-valued child (`<For each={items}>{(item) => …}</For>`,
495
+ // or a reference to one) must stay a FUNCTION: `For` calls it per item.
496
+ // Wrapping it in the `(() => { const __out = []; … })()` text emitter made it
497
+ // return undefined, so For rendered an empty list.
498
+ const fnType = regularChildren.length === 1 && regularChildren[0] instanceof DynamicBinding && regularChildren[0].kind === 'text'
499
+ ? regularChildren[0].expression.ast?.type
500
+ : null;
501
+ const isFnChild = fnType === 'ArrowFunctionExpression'
502
+ || fnType === 'FunctionExpression'
503
+ || fnType === 'Identifier'
504
+ || fnType === 'MemberExpression';
505
+ if (isFnChild) {
506
+ propsEntries.push(`children: ${exprJSX(regularChildren[0].expression, tracked)}`);
469
507
  }
470
- if (childLines.length > 0) {
471
- const childrenVar = `__ch${nextVskId()}`;
472
- propsEntries.push(`children: ${childrenVar}`);
473
- lines.push(`const ${childrenVar} = ${isAsync ? 'await (async ' : '('}() => {`);
474
- lines.push(`const __out = [];`);
475
- lines.push(indent(childLines.join('\n')));
476
- lines.push(`return __out.join(''); })();`);
508
+ else {
509
+ const childLines = [];
510
+ for (const child of regularChildren) {
511
+ const code = irNodeToJS(child, importedNames, isAsync, tracked);
512
+ if (code)
513
+ childLines.push(code);
514
+ }
515
+ if (childLines.length > 0) {
516
+ const childrenVar = `__ch${nextVskId()}`;
517
+ propsEntries.push(`children: ${childrenVar}`);
518
+ lines.push(`const ${childrenVar} = ${isAsync ? 'await (async ' : '('}() => {`);
519
+ lines.push(`const __out = [];`);
520
+ lines.push(indent(childLines.join('\n')));
521
+ lines.push(`return __out.join(''); })();`);
522
+ }
477
523
  }
478
524
  }
479
525
  const propsObj = `{ ${propsEntries.join(', ')} }`;
@@ -503,10 +549,10 @@ function componentCallToJS(node, importedNames, isAsync = false, tracked) {
503
549
  }
504
550
  const callExpr = `${awaitKw}${calleeVar}(${propsObj}, __registry, (${calleeVar}.__veskScope || __vesk))`;
505
551
  if (__vskHydrate && !__vskMarkerless) {
506
- lines.push(`__out.push(${JSON.stringify(componentMarker(compName))} + (${callExpr} || ''));`);
552
+ lines.push(`__out.push(${JSON.stringify(componentMarker(compName))} + __flat(${callExpr}));`);
507
553
  }
508
554
  else {
509
- lines.push(`__out.push(${callExpr} || '');`);
555
+ lines.push(`__out.push(__flat(${callExpr}));`);
510
556
  }
511
557
  return lines.join('\n');
512
558
  }
@@ -524,6 +570,9 @@ export function generateFunctionBody(comp, importedNames) {
524
570
  lines.push(`const __styleText = (v) => { if (typeof v === 'string') return v; if (v && typeof v === 'object') { let s = ''; for (const k in v) { const x = v[k]; if (x == null || x === false) continue; s += k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + ':' + x + ';'; } return s; } return String(v); };`);
525
571
  lines.push(`const __attr = (n, v) => (v == null || v === false) ? '' : ' ' + n + '="' + __escape(n === 'style' ? __styleText(v) : String(v)) + '"';`);
526
572
  lines.push(`const raw = (s) => s == null ? '' : String(s);`);
573
+ // A component may return an ARRAY of strings (`<For>` maps its children), and
574
+ // `Array#toString` joins the rows with commas — `<li>a</li>,<li>b</li>`.
575
+ lines.push(`const __flat = (v) => Array.isArray(v) ? v.join('') : (v == null ? '' : String(v));`);
527
576
  lines.push(`const __tk = globalThis.__vsk_ssr_token || '';`);
528
577
  if (asyncMode) {
529
578
  lines.push(`const __pk = __tk ? '__vsk_ssr_promises_' + __tk : '__vsk_ssr_promises';`);
@@ -563,6 +612,105 @@ export function generateFunctionBody(comp, importedNames) {
563
612
  __currentCompName = '';
564
613
  return lines.join('\n');
565
614
  }
615
+ /**
616
+ * Whether a component body needs `track` / `get` / `set` destructured into its
617
+ * `__vesk` scope.
618
+ *
619
+ * A `TrackDecl` (`const &[n] = track(0)`) always needs them. A PLAIN
620
+ * `const n = track(0)` does not lower to a TrackDecl — it stays a raw
621
+ * `RuntimeStatement` whose source still calls `track` — and used to reach the
622
+ * server renderer unbound, taking the whole page down with
623
+ * "track is not defined". The client codegen imports these names
624
+ * unconditionally, so the server has to recognise the plain form too or the
625
+ * two halves of a file disagree about what is in scope.
626
+ */
627
+ function bodyNeedsRuntimeCells(nodes) {
628
+ for (const node of nodes) {
629
+ if (node instanceof TrackDecl)
630
+ return true;
631
+ // Raw statement / expression text: the only place a plain `track(...)`
632
+ // call can appear outside a lowered node.
633
+ if (node instanceof RuntimeStatement && callsIdentifier(node.raw, 'track'))
634
+ return true;
635
+ if (node instanceof Expression && callsIdentifier(node.raw, 'track'))
636
+ return true;
637
+ if (node instanceof ForLoop) {
638
+ if (callsIdentifier(node.init, 'track'))
639
+ return true;
640
+ if (callsIdentifier(node.update, 'track'))
641
+ return true;
642
+ if (callsIdentifier(node.condition.raw, 'track'))
643
+ return true;
644
+ if (bodyNeedsRuntimeCells(node.bodyTemplate))
645
+ return true;
646
+ }
647
+ if (node instanceof StaticNode) {
648
+ for (const attr of node.attributes) {
649
+ if (callsIdentifier(attr.value, 'track'))
650
+ return true;
651
+ }
652
+ if (bodyNeedsRuntimeCells(node.children))
653
+ return true;
654
+ }
655
+ if (node instanceof ComponentCall) {
656
+ for (const prop of node.props) {
657
+ if (callsIdentifier(prop.value.raw, 'track'))
658
+ return true;
659
+ }
660
+ for (const spread of node.spreadProps) {
661
+ if (callsIdentifier(spread.raw, 'track'))
662
+ return true;
663
+ }
664
+ if (bodyNeedsRuntimeCells(node.children))
665
+ return true;
666
+ }
667
+ if (node instanceof ServerBlock || node instanceof ClientBlock || node instanceof HeadBlock) {
668
+ if (bodyNeedsRuntimeCells(node.children))
669
+ return true;
670
+ }
671
+ if (node instanceof PropSlot && bodyNeedsRuntimeCells(node.body))
672
+ return true;
673
+ if (node instanceof MapRegion) {
674
+ if (callsIdentifier(node.expression.raw, 'track'))
675
+ return true;
676
+ if (bodyNeedsRuntimeCells(node.bodyTemplate))
677
+ return true;
678
+ if (bodyNeedsRuntimeCells(node.alternateNodes))
679
+ return true;
680
+ }
681
+ if (node instanceof OpaqueDynamicRegion) {
682
+ if (callsIdentifier(node.condition.raw, 'track'))
683
+ return true;
684
+ if (bodyNeedsRuntimeCells(node.consequentNodes))
685
+ return true;
686
+ if (bodyNeedsRuntimeCells(node.alternateNodes))
687
+ return true;
688
+ }
689
+ if (node instanceof WhileLoop) {
690
+ if (callsIdentifier(node.condition.raw, 'track'))
691
+ return true;
692
+ if (bodyNeedsRuntimeCells(node.bodyTemplate))
693
+ return true;
694
+ }
695
+ if (node instanceof SwitchBlock) {
696
+ if (callsIdentifier(node.discriminant.raw, 'track'))
697
+ return true;
698
+ for (const c of node.cases) {
699
+ if (c.test && callsIdentifier(c.test.raw, 'track'))
700
+ return true;
701
+ if (bodyNeedsRuntimeCells(c.body))
702
+ return true;
703
+ }
704
+ }
705
+ if (node instanceof TryCatch) {
706
+ if (bodyNeedsRuntimeCells(node.bodyTemplate))
707
+ return true;
708
+ if (bodyNeedsRuntimeCells(node.catchBody))
709
+ return true;
710
+ }
711
+ }
712
+ return false;
713
+ }
566
714
  /**
567
715
  * Builds the compiled body source for every component in an IR root. Used by
568
716
  * both `buildComponentMap` (live functions) and `precompile.ts` (AOT SSR plans
@@ -579,7 +727,7 @@ export function buildComponentEntries(irRoot) {
579
727
  const componentNames = new Set(irRoot.components.map((c) => c.name));
580
728
  const topValueNames = topNames.filter((n) => !componentNames.has(n));
581
729
  const importedNames = new Set([...runtimeNames, ...localValueNames, ...topValueNames]);
582
- const hasTracked = irRoot.components.some((c) => c.body.some((n) => n instanceof TrackDecl));
730
+ const hasTracked = irRoot.components.some((c) => bodyNeedsRuntimeCells(c.body));
583
731
  const extraNames = hasTracked ? ['get', 'set', 'track'] : [];
584
732
  // Values exported by a `.vsk` module (`export const MAX = 10`) are hoisted
585
733
  // into this file's `__vesk` and must be destructured into component scope.
@@ -0,0 +1,16 @@
1
+ /** True when `node` contains JSX anywhere. */
2
+ export declare function containsJsx(node: unknown, depth?: number): boolean;
3
+ declare const RAW = "__raw";
4
+ /**
5
+ * JSX text semantics, matching what the compiler emits for StaticNode text:
6
+ * lines are trimmed, blank lines vanish, and interior newlines collapse to a
7
+ * single space. Getting this wrong is visible as stray whitespace in SSR HTML.
8
+ */
9
+ export declare function normalizeJsxText(value: string): string;
10
+ /**
11
+ * Rewrite every JSX node inside `ast` into the string it would have rendered.
12
+ * Returns the original node when there is no JSX, so the common path is free.
13
+ */
14
+ export declare function lowerJsxInExpression(ast: unknown): unknown;
15
+ export { RAW as RAW_HELPER };
16
+ //# sourceMappingURL=server-jsx-lowering.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-jsx-lowering.d.ts","sourceRoot":"","sources":["../src/server-jsx-lowering.ts"],"names":[],"mappings":"AAwBA,8CAA8C;AAC9C,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,SAAI,GAAG,OAAO,CAa7D;AAKD,QAAA,MAAM,GAAG,UAAU,CAAC;AAEpB;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAiBtD;AAiLD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAG1D;AAED,OAAO,EAAE,GAAG,IAAI,UAAU,EAAE,CAAC"}
@@ -0,0 +1,226 @@
1
+ /** True when `node` contains JSX anywhere. */
2
+ export function containsJsx(node, depth = 0) {
3
+ if (!node || typeof node !== 'object' || depth > 40)
4
+ return false;
5
+ if (Array.isArray(node)) {
6
+ for (const item of node)
7
+ if (containsJsx(item, depth + 1))
8
+ return true;
9
+ return false;
10
+ }
11
+ const type = node.type;
12
+ if (type === 'JSXElement' || type === 'JSXFragment')
13
+ return true;
14
+ for (const key of Object.keys(node)) {
15
+ const value = node[key];
16
+ if (value && typeof value === 'object' && containsJsx(value, depth + 1))
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ /** The helper names the generated server body already defines. */
22
+ const ESCAPE = '__escape';
23
+ const ATTR = '__attr';
24
+ const RAW = '__raw';
25
+ /**
26
+ * JSX text semantics, matching what the compiler emits for StaticNode text:
27
+ * lines are trimmed, blank lines vanish, and interior newlines collapse to a
28
+ * single space. Getting this wrong is visible as stray whitespace in SSR HTML.
29
+ */
30
+ export function normalizeJsxText(value) {
31
+ const lines = value.split(/\r\n|\n|\r/);
32
+ let lastNonEmpty = -1;
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (/[^ \t]/.test(lines[i]))
35
+ lastNonEmpty = i;
36
+ }
37
+ let out = '';
38
+ for (let i = 0; i < lines.length; i++) {
39
+ let line = lines[i].replace(/\t/g, ' ');
40
+ if (i !== 0)
41
+ line = line.replace(/^ +/, '');
42
+ if (i !== lines.length - 1)
43
+ line = line.replace(/ +$/, '');
44
+ if (line) {
45
+ if (i !== lastNonEmpty)
46
+ line += ' ';
47
+ out += line;
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ function lower(node, ctx) {
53
+ if (Array.isArray(node))
54
+ return node.map((n) => lower(n, ctx));
55
+ if (!node || typeof node !== 'object')
56
+ return node;
57
+ const n = node;
58
+ if (n.type === 'JSXElement') {
59
+ const opening = n.openingElement;
60
+ const name = jsxTagName(opening.name);
61
+ const attrs = lowerAttributes(opening.attributes, ctx);
62
+ const children = lowerChildren(n.children, ctx);
63
+ // statics[0] opens the tag, attributes interleave, then '>', then children,
64
+ // then the closing tag. Getting the '>' placement wrong silently produced
65
+ // `<lia</li>` — a tag that never closes and swallows the next element.
66
+ const statics = [`<${name}`, ...attrs.statics.slice(1)];
67
+ statics[statics.length - 1] += '>';
68
+ const quasis = [...statics, `</${name}>`];
69
+ const expressions = [...attrs.exprs, ...children];
70
+ return template(quasis, expressions);
71
+ }
72
+ if (n.type === 'JSXFragment') {
73
+ return template(['', ''], lowerChildren(n.children, ctx));
74
+ }
75
+ // Any other node: rebuild it with lowered children.
76
+ const out = {};
77
+ for (const key of Object.keys(n)) {
78
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') {
79
+ out[key] = n[key];
80
+ continue;
81
+ }
82
+ const value = n[key];
83
+ out[key] = value && typeof value === 'object' ? lower(value, ctx) : value;
84
+ }
85
+ return out;
86
+ }
87
+ function jsxTagName(nameNode) {
88
+ const n = nameNode;
89
+ if (!n)
90
+ return 'div';
91
+ if (n.type === 'JSXIdentifier')
92
+ return String(n.name);
93
+ if (n.type === 'JSXMemberExpression') {
94
+ const object = jsxTagName(n.object);
95
+ const property = n.property;
96
+ return `${object}.${String(property?.name)}`;
97
+ }
98
+ if (n.type === 'JSXNamespacedName') {
99
+ const ns = n.namespace;
100
+ const name = n.name;
101
+ return `${String(ns?.name)}:${String(name?.name)}`;
102
+ }
103
+ return 'div';
104
+ }
105
+ function lowerAttributes(attrs, ctx) {
106
+ const statics = [''];
107
+ const exprs = [];
108
+ const push = (text, expr) => {
109
+ if (expr === undefined) {
110
+ statics[statics.length - 1] += text;
111
+ return;
112
+ }
113
+ statics[statics.length - 1] += text;
114
+ statics.push('');
115
+ exprs.push(expr);
116
+ };
117
+ for (const attr of attrs || []) {
118
+ const a = attr;
119
+ if (a.type === 'JSXSpreadAttribute') {
120
+ // A spread inside a function-valued child is not lowered; dropping it
121
+ // would be silently wrong, so it is reported instead.
122
+ console.error('[vesk] a spread attribute in a JSX expression is not supported yet (dropped):', String(a.argument ?? '').slice(0, 60));
123
+ continue;
124
+ }
125
+ const name = jsxAttrName(a.name);
126
+ const value = a.value;
127
+ if (value === null || value === undefined) {
128
+ push(` ${name}`);
129
+ continue;
130
+ }
131
+ if (value.type === 'StringLiteral') {
132
+ push(` ${name}="${escapeAttrText(String(value.value))}"`);
133
+ continue;
134
+ }
135
+ if (value.type === 'JSXExpressionContainer') {
136
+ const inner = value.expression;
137
+ if (!inner || inner.type === 'JSXEmptyExpression') {
138
+ push(` ${name}="true"`);
139
+ continue;
140
+ }
141
+ push(` ${name}="`, callNode(ATTR, [literal(name), coerceString(lower(inner, ctx))]));
142
+ push('"');
143
+ continue;
144
+ }
145
+ push(` ${name}="`, callNode(ATTR, [literal(name), coerceString(lower(value, ctx))]));
146
+ push('"');
147
+ }
148
+ return { statics, exprs };
149
+ }
150
+ function jsxAttrName(nameNode) {
151
+ const n = nameNode;
152
+ if (!n)
153
+ return 'data';
154
+ if (n.type === 'JSXIdentifier')
155
+ return String(n.name);
156
+ return jsxTagName(n);
157
+ }
158
+ function lowerChildren(children, ctx) {
159
+ const out = [];
160
+ for (const child of children || []) {
161
+ const c = child;
162
+ if (c.type === 'JSXText') {
163
+ const text = normalizeJsxText(String(c.value ?? ''));
164
+ if (text)
165
+ out.push(literal(text));
166
+ continue;
167
+ }
168
+ if (c.type === 'JSXExpressionContainer') {
169
+ const inner = c.expression;
170
+ if (!inner || inner.type === 'JSXEmptyExpression')
171
+ continue;
172
+ const lowered = lower(inner, ctx);
173
+ out.push(coerceString(lowered));
174
+ continue;
175
+ }
176
+ const lowered = lower(c, ctx);
177
+ out.push(coerceString(lowered));
178
+ }
179
+ return out;
180
+ }
181
+ /** `{v}` in HTML output must be escaped, exactly as the body emitter does. */
182
+ function coerceString(node) {
183
+ if (!node || typeof node !== 'object')
184
+ return callNode(ESCAPE, [literal(String(node))]);
185
+ const n = node;
186
+ // Already a template literal we built: its parts are static text, but any
187
+ // expression inside came from a nested container that escaped itself.
188
+ if (n.type === 'TemplateLiteral')
189
+ return n;
190
+ if (n.type === 'Literal' || n.type === 'StringLiteral')
191
+ return callNode(ESCAPE, [node]);
192
+ return callNode(ESCAPE, [callNode('String', [node])]);
193
+ }
194
+ /** Assemble a template literal: `statics.length === expressions.length + 1`. */
195
+ function template(statics, expressions) {
196
+ const quasis = statics.map((raw, i) => ({
197
+ type: 'TemplateElement',
198
+ value: { raw },
199
+ tail: i === statics.length - 1,
200
+ }));
201
+ return { type: 'TemplateLiteral', quasis, expressions };
202
+ }
203
+ function literal(value) {
204
+ return { type: 'Literal', value };
205
+ }
206
+ function callNode(callee, args) {
207
+ return {
208
+ type: 'CallExpression',
209
+ callee: { type: 'Identifier', name: callee },
210
+ arguments: args,
211
+ optional: false,
212
+ };
213
+ }
214
+ function escapeAttrText(value) {
215
+ return value.split('&').join('&amp;').split('<').join('&lt;').split('>').join('&gt;').split('"').join('&quot;');
216
+ }
217
+ /**
218
+ * Rewrite every JSX node inside `ast` into the string it would have rendered.
219
+ * Returns the original node when there is no JSX, so the common path is free.
220
+ */
221
+ export function lowerJsxInExpression(ast) {
222
+ if (!containsJsx(ast))
223
+ return ast;
224
+ return lower(ast, { track: false });
225
+ }
226
+ export { RAW as RAW_HELPER };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/compiler",
3
- "version": "0.2.50-canary.8",
3
+ "version": "0.2.50",
4
4
  "type": "module",
5
5
  "description": "Vesk compiler — lexer, parser, semantic analysis, IR, codegen",
6
6
  "main": "./dist/index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@sveltejs/acorn-typescript": "^1.0.0",
23
- "@vesk/types": "^0.2.50-canary.8",
23
+ "@vesk/types": "^0.2.50",
24
24
  "acorn": "^8.15.0",
25
25
  "esrap": "^2.3.0",
26
26
  "is-reference": "^3.0.0",