@bquery/bquery 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +44 -19
  2. package/dist/{a11y-DG2i4iZN.js → a11y-DgUQ8-fI.js} +1 -1
  3. package/dist/{a11y-DG2i4iZN.js.map → a11y-DgUQ8-fI.js.map} +1 -1
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/{component-DRotf1hl.js → component-D8ydhe58.js} +2 -2
  6. package/dist/{component-DRotf1hl.js.map → component-D8ydhe58.js.map} +1 -1
  7. package/dist/component.es.mjs +1 -1
  8. package/dist/concurrency-BU1wPEsZ.js.map +1 -1
  9. package/dist/{constraints-CqjhmpZC.js → constraints-Dlbx_m1b.js} +1 -1
  10. package/dist/{constraints-CqjhmpZC.js.map → constraints-Dlbx_m1b.js.map} +1 -1
  11. package/dist/{core-EMYSLzaT.js → core-tOP6QOrY.js} +2 -2
  12. package/dist/{core-EMYSLzaT.js.map → core-tOP6QOrY.js.map} +1 -1
  13. package/dist/core.es.mjs +1 -1
  14. package/dist/{custom-directives-BjFzFhuf.js → custom-directives-5DlKqvd2.js} +1 -1
  15. package/dist/{custom-directives-BjFzFhuf.js.map → custom-directives-5DlKqvd2.js.map} +1 -1
  16. package/dist/{devtools-C5FExMwv.js → devtools-QosAqo0T.js} +2 -2
  17. package/dist/{devtools-C5FExMwv.js.map → devtools-QosAqo0T.js.map} +1 -1
  18. package/dist/devtools.es.mjs +1 -1
  19. package/dist/{dnd-BAqzPlSo.js → dnd-d2OU4len.js} +1 -1
  20. package/dist/{dnd-BAqzPlSo.js.map → dnd-d2OU4len.js.map} +1 -1
  21. package/dist/dnd.es.mjs +1 -1
  22. package/dist/{forms-Dx1Scvh0.js → forms-BLx4ZzT7.js} +1 -1
  23. package/dist/{forms-Dx1Scvh0.js.map → forms-BLx4ZzT7.js.map} +1 -1
  24. package/dist/forms.es.mjs +1 -1
  25. package/dist/full.d.ts +4 -2
  26. package/dist/full.d.ts.map +1 -1
  27. package/dist/full.es.mjs +258 -219
  28. package/dist/full.iife.js +41 -37
  29. package/dist/full.iife.js.map +1 -1
  30. package/dist/full.umd.js +41 -37
  31. package/dist/full.umd.js.map +1 -1
  32. package/dist/{i18n-Cazyk9RD.js → i18n--p7PM-9r.js} +1 -1
  33. package/dist/{i18n-Cazyk9RD.js.map → i18n--p7PM-9r.js.map} +1 -1
  34. package/dist/i18n.es.mjs +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.es.mjs +291 -252
  38. package/dist/match-CrZRVC4z.js +174 -0
  39. package/dist/match-CrZRVC4z.js.map +1 -0
  40. package/dist/{media-dAKIGPk3.js → media-gjbWNq50.js} +1 -1
  41. package/dist/{media-dAKIGPk3.js.map → media-gjbWNq50.js.map} +1 -1
  42. package/dist/media.es.mjs +1 -1
  43. package/dist/motion-BBMso9Ir.js.map +1 -1
  44. package/dist/{mount-C8O2vXkQ.js → mount-0A9qtcRJ.js} +3 -3
  45. package/dist/{mount-C8O2vXkQ.js.map → mount-0A9qtcRJ.js.map} +1 -1
  46. package/dist/platform-BPHIXbw8.js.map +1 -1
  47. package/dist/{plugin-DjTqWg-P.js → plugin-SZEirbwq.js} +2 -2
  48. package/dist/{plugin-DjTqWg-P.js.map → plugin-SZEirbwq.js.map} +1 -1
  49. package/dist/plugin.es.mjs +1 -1
  50. package/dist/reactive-BAd2hfl8.js.map +1 -1
  51. package/dist/{registry-Cr6VH8CR.js → registry-jpUQHf4E.js} +1 -1
  52. package/dist/{registry-Cr6VH8CR.js.map → registry-jpUQHf4E.js.map} +1 -1
  53. package/dist/router-C4weu0QL.js +333 -0
  54. package/dist/router-C4weu0QL.js.map +1 -0
  55. package/dist/router.es.mjs +1 -1
  56. package/dist/{sanitize-B1V4JswB.js → sanitize-DOMkRO9G.js} +12 -7
  57. package/dist/{sanitize-B1V4JswB.js.map → sanitize-DOMkRO9G.js.map} +1 -1
  58. package/dist/security.es.mjs +1 -1
  59. package/dist/server/create-server.d.ts +25 -0
  60. package/dist/server/create-server.d.ts.map +1 -0
  61. package/dist/server/index.d.ts +11 -0
  62. package/dist/server/index.d.ts.map +1 -0
  63. package/dist/server/types.d.ts +396 -0
  64. package/dist/server/types.d.ts.map +1 -0
  65. package/dist/server-QdyKtCS1.js +349 -0
  66. package/dist/server-QdyKtCS1.js.map +1 -0
  67. package/dist/server.es.mjs +6 -0
  68. package/dist/ssr/adapters.d.ts +74 -0
  69. package/dist/ssr/adapters.d.ts.map +1 -0
  70. package/dist/ssr/async.d.ts +40 -0
  71. package/dist/ssr/async.d.ts.map +1 -0
  72. package/dist/ssr/config.d.ts +60 -0
  73. package/dist/ssr/config.d.ts.map +1 -0
  74. package/dist/ssr/context.d.ts +73 -0
  75. package/dist/ssr/context.d.ts.map +1 -0
  76. package/dist/ssr/defer-brand.d.ts +5 -0
  77. package/dist/ssr/defer-brand.d.ts.map +1 -0
  78. package/dist/ssr/escape.d.ts +17 -0
  79. package/dist/ssr/escape.d.ts.map +1 -0
  80. package/dist/ssr/expression.d.ts +44 -0
  81. package/dist/ssr/expression.d.ts.map +1 -0
  82. package/dist/ssr/hash.d.ts +39 -0
  83. package/dist/ssr/hash.d.ts.map +1 -0
  84. package/dist/ssr/head.d.ts +102 -0
  85. package/dist/ssr/head.d.ts.map +1 -0
  86. package/dist/ssr/html-parser.d.ts +58 -0
  87. package/dist/ssr/html-parser.d.ts.map +1 -0
  88. package/dist/ssr/index.d.ts +49 -43
  89. package/dist/ssr/index.d.ts.map +1 -1
  90. package/dist/ssr/mismatch.d.ts +60 -0
  91. package/dist/ssr/mismatch.d.ts.map +1 -0
  92. package/dist/ssr/render-async.d.ts +84 -0
  93. package/dist/ssr/render-async.d.ts.map +1 -0
  94. package/dist/ssr/render.d.ts.map +1 -1
  95. package/dist/ssr/renderer.d.ts +25 -0
  96. package/dist/ssr/renderer.d.ts.map +1 -0
  97. package/dist/ssr/resumability.d.ts +65 -0
  98. package/dist/ssr/resumability.d.ts.map +1 -0
  99. package/dist/ssr/router-bridge.d.ts +101 -0
  100. package/dist/ssr/router-bridge.d.ts.map +1 -0
  101. package/dist/ssr/runtime.d.ts +63 -0
  102. package/dist/ssr/runtime.d.ts.map +1 -0
  103. package/dist/ssr/serialize.d.ts.map +1 -1
  104. package/dist/ssr/store-snapshot.d.ts +87 -0
  105. package/dist/ssr/store-snapshot.d.ts.map +1 -0
  106. package/dist/ssr/strategies.d.ts +43 -0
  107. package/dist/ssr/strategies.d.ts.map +1 -0
  108. package/dist/ssr/suspense.d.ts +47 -0
  109. package/dist/ssr/suspense.d.ts.map +1 -0
  110. package/dist/ssr/types.d.ts +17 -0
  111. package/dist/ssr/types.d.ts.map +1 -1
  112. package/dist/ssr-Bt6BQA3J.js +2127 -0
  113. package/dist/ssr-Bt6BQA3J.js.map +1 -0
  114. package/dist/ssr.es.mjs +42 -7
  115. package/dist/{store-CjmEeX9-.js → store-DnXuu6Li.js} +2 -2
  116. package/dist/{store-CjmEeX9-.js.map → store-DnXuu6Li.js.map} +1 -1
  117. package/dist/store.es.mjs +2 -2
  118. package/dist/storybook.es.mjs +1 -1
  119. package/dist/{testing-TdfaL7VE.js → testing-CeMUwrRD.js} +2 -2
  120. package/dist/{testing-TdfaL7VE.js.map → testing-CeMUwrRD.js.map} +1 -1
  121. package/dist/testing.es.mjs +1 -1
  122. package/dist/view.es.mjs +1 -1
  123. package/package.json +17 -12
  124. package/src/full.ts +99 -0
  125. package/src/index.ts +3 -0
  126. package/src/server/create-server.ts +754 -0
  127. package/src/server/index.ts +33 -0
  128. package/src/server/types.ts +490 -0
  129. package/src/ssr/adapters.ts +330 -0
  130. package/src/ssr/async.ts +125 -0
  131. package/src/ssr/config.ts +86 -0
  132. package/src/ssr/context.ts +245 -0
  133. package/src/ssr/defer-brand.ts +3 -0
  134. package/src/ssr/escape.ts +25 -0
  135. package/src/ssr/expression.ts +669 -0
  136. package/src/ssr/hash.ts +71 -0
  137. package/src/ssr/head.ts +240 -0
  138. package/src/ssr/html-parser.ts +387 -0
  139. package/src/ssr/index.ts +136 -43
  140. package/src/ssr/mismatch.ts +110 -0
  141. package/src/ssr/render-async.ts +286 -0
  142. package/src/ssr/render.ts +130 -59
  143. package/src/ssr/renderer.ts +453 -0
  144. package/src/ssr/resumability.ts +142 -0
  145. package/src/ssr/router-bridge.ts +177 -0
  146. package/src/ssr/runtime.ts +131 -0
  147. package/src/ssr/serialize.ts +1 -27
  148. package/src/ssr/store-snapshot.ts +209 -0
  149. package/src/ssr/strategies.ts +245 -0
  150. package/src/ssr/suspense.ts +504 -0
  151. package/src/ssr/types.ts +18 -0
  152. package/dist/router-CCepRMpC.js +0 -493
  153. package/dist/router-CCepRMpC.js.map +0 -1
  154. package/dist/ssr-D-1IPcfw.js +0 -248
  155. package/dist/ssr-D-1IPcfw.js.map +0 -1
@@ -0,0 +1,669 @@
1
+ /**
2
+ * CSP-safe expression evaluator for SSR templates.
3
+ *
4
+ * Evaluates a tightly scoped subset of JavaScript expressions against a
5
+ * binding context without using `eval` or `new Function()`. Runs in any
6
+ * runtime (Bun, Deno, Node, browsers) and is safe under strict CSP without
7
+ * `'unsafe-eval'`.
8
+ *
9
+ * Supported grammar (operator-precedence Pratt parser):
10
+ *
11
+ * - Literals: numbers, single/double-quoted strings, `true`, `false`, `null`,
12
+ * `undefined`.
13
+ * - Identifiers and member access (`a.b`, `a['b']`, `a[0]`).
14
+ * - Optional chaining (`a?.b`, `a?.[b]`).
15
+ * - Unary `!`, `+`, `-`, `typeof`.
16
+ * - Binary `+`, `-`, `*`, `/`, `%`, `==`, `===`, `!=`, `!==`, `<`, `<=`,
17
+ * `>`, `>=`, `&&`, `||`, `??`.
18
+ * - Ternary `cond ? a : b`.
19
+ * - Parentheses for grouping.
20
+ * - Function calls `fn(arg1, arg2, ...)` (only on identifiers / member chains
21
+ * resolved against the context — no arbitrary expression invocation).
22
+ *
23
+ * Anything outside this grammar throws a parse error which the caller
24
+ * converts into the standard SSR fallback (`undefined`).
25
+ *
26
+ * @module bquery/ssr
27
+ * @internal
28
+ */
29
+
30
+ import { isComputed, isSignal, type Signal } from '../reactive/index';
31
+ import { isPrototypePollutionKey } from '../core/utils/object';
32
+ import type { BindingContext } from '../view/types';
33
+
34
+ const unwrap = (value: unknown): unknown => {
35
+ if (isSignal(value) || isComputed(value)) {
36
+ return (value as Signal<unknown>).value;
37
+ }
38
+ return value;
39
+ };
40
+
41
+ /* ---------------------------------------------------------------------------
42
+ * Tokenizer
43
+ * ------------------------------------------------------------------------- */
44
+
45
+ type TokenKind = 'number' | 'string' | 'ident' | 'punct' | 'eof';
46
+
47
+ interface Token {
48
+ kind: TokenKind;
49
+ value: string;
50
+ start: number;
51
+ }
52
+
53
+ const PUNCT_MULTI = ['===', '!==', '==', '!=', '<=', '>=', '&&', '||', '??', '?.'];
54
+
55
+ const PUNCT_SINGLE = '+-*/%<>!?:,.()[]';
56
+
57
+ const isIdentStart = (ch: string): boolean =>
58
+ (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$';
59
+
60
+ const isIdentCont = (ch: string): boolean => isIdentStart(ch) || (ch >= '0' && ch <= '9');
61
+
62
+ const isDigit = (ch: string): boolean => ch >= '0' && ch <= '9';
63
+
64
+ const tokenize = (input: string): Token[] => {
65
+ const tokens: Token[] = [];
66
+ const len = input.length;
67
+ let i = 0;
68
+
69
+ while (i < len) {
70
+ const ch = input[i];
71
+
72
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
73
+ i++;
74
+ continue;
75
+ }
76
+
77
+ // Strings
78
+ if (ch === '"' || ch === "'") {
79
+ const quote = ch;
80
+ const start = i;
81
+ i++;
82
+ let value = '';
83
+ while (i < len && input[i] !== quote) {
84
+ if (input[i] === '\\' && i + 1 < len) {
85
+ const next = input[i + 1];
86
+ if (next === 'n') value += '\n';
87
+ else if (next === 't') value += '\t';
88
+ else if (next === 'r') value += '\r';
89
+ else if (next === '\\') value += '\\';
90
+ else if (next === quote) value += quote;
91
+ else value += next;
92
+ i += 2;
93
+ continue;
94
+ }
95
+ value += input[i];
96
+ i++;
97
+ }
98
+ if (i >= len) {
99
+ throw new Error('Unterminated string literal in SSR expression');
100
+ }
101
+ i++; // closing quote
102
+ tokens.push({ kind: 'string', value, start });
103
+ continue;
104
+ }
105
+
106
+ // Numbers
107
+ if (isDigit(ch) || (ch === '.' && i + 1 < len && isDigit(input[i + 1]))) {
108
+ const start = i;
109
+ while (i < len && (isDigit(input[i]) || input[i] === '.')) {
110
+ i++;
111
+ }
112
+ tokens.push({ kind: 'number', value: input.slice(start, i), start });
113
+ continue;
114
+ }
115
+
116
+ // Identifiers / keywords
117
+ if (isIdentStart(ch)) {
118
+ const start = i;
119
+ while (i < len && isIdentCont(input[i])) {
120
+ i++;
121
+ }
122
+ tokens.push({ kind: 'ident', value: input.slice(start, i), start });
123
+ continue;
124
+ }
125
+
126
+ // Multi-char punctuation
127
+ let matched = false;
128
+ for (const p of PUNCT_MULTI) {
129
+ if (input.startsWith(p, i)) {
130
+ tokens.push({ kind: 'punct', value: p, start: i });
131
+ i += p.length;
132
+ matched = true;
133
+ break;
134
+ }
135
+ }
136
+ if (matched) continue;
137
+
138
+ if (PUNCT_SINGLE.includes(ch)) {
139
+ tokens.push({ kind: 'punct', value: ch, start: i });
140
+ i++;
141
+ continue;
142
+ }
143
+
144
+ throw new Error(`Unexpected character "${ch}" in SSR expression`);
145
+ }
146
+
147
+ tokens.push({ kind: 'eof', value: '', start: len });
148
+ return tokens;
149
+ };
150
+
151
+ /* ---------------------------------------------------------------------------
152
+ * Parser → directly evaluated AST
153
+ * ------------------------------------------------------------------------- */
154
+
155
+ interface ParserState {
156
+ tokens: Token[];
157
+ pos: number;
158
+ context: BindingContext;
159
+ }
160
+
161
+ const peek = (s: ParserState): Token => s.tokens[s.pos];
162
+ const advance = (s: ParserState): Token => s.tokens[s.pos++];
163
+
164
+ const expectPunct = (s: ParserState, value: string): void => {
165
+ const t = peek(s);
166
+ if (t.kind !== 'punct' || t.value !== value) {
167
+ throw new Error(`Expected "${value}" in SSR expression, got "${t.value}"`);
168
+ }
169
+ s.pos++;
170
+ };
171
+
172
+ const matchPunct = (s: ParserState, value: string): boolean => {
173
+ const t = peek(s);
174
+ if (t.kind === 'punct' && t.value === value) {
175
+ s.pos++;
176
+ return true;
177
+ }
178
+ return false;
179
+ };
180
+
181
+ const skipBalancedGroup = (s: ParserState, open: '(' | '[', close: ')' | ']'): void => {
182
+ expectPunct(s, open);
183
+ const stack: Array<'(' | '['> = [open];
184
+ while (stack.length > 0) {
185
+ const t = advance(s);
186
+ if (!t || t.kind === 'eof') {
187
+ throw new Error(`Unterminated "${open}${close}" group in SSR expression`);
188
+ }
189
+ if (t.kind !== 'punct') continue;
190
+ if (t.value === '(' || t.value === '[') {
191
+ stack.push(t.value);
192
+ continue;
193
+ }
194
+ if (t.value === ')' || t.value === ']') {
195
+ const current = stack[stack.length - 1];
196
+ const expected = current === '(' ? ')' : ']';
197
+ if (t.value !== expected) {
198
+ throw new Error(`Mismatched "${current}${expected}" group in SSR expression`);
199
+ }
200
+ stack.pop();
201
+ }
202
+ }
203
+ };
204
+
205
+ const skipShortCircuitedChainTarget = (s: ParserState): void => {
206
+ const next = peek(s);
207
+ if (next.kind === 'punct' && next.value === '[') {
208
+ skipBalancedGroup(s, '[', ']');
209
+ return;
210
+ }
211
+ if (next.kind === 'punct' && next.value === '(') {
212
+ skipBalancedGroup(s, '(', ')');
213
+ return;
214
+ }
215
+ if (next.kind === 'ident') {
216
+ s.pos++;
217
+ return;
218
+ }
219
+ throw new Error('Invalid optional chain in SSR expression');
220
+ };
221
+
222
+ const skipShortCircuitedChainRemainder = (s: ParserState): void => {
223
+ while (true) {
224
+ const t = peek(s);
225
+ if (t.kind !== 'punct') break;
226
+ if (t.value === '.') {
227
+ s.pos++;
228
+ const id = advance(s);
229
+ if (id.kind !== 'ident') {
230
+ throw new Error('Expected identifier after "."');
231
+ }
232
+ continue;
233
+ }
234
+ if (t.value === '[') {
235
+ skipBalancedGroup(s, '[', ']');
236
+ continue;
237
+ }
238
+ if (t.value === '(') {
239
+ skipBalancedGroup(s, '(', ')');
240
+ continue;
241
+ }
242
+ if (t.value === '?.') {
243
+ s.pos++;
244
+ skipShortCircuitedChainTarget(s);
245
+ continue;
246
+ }
247
+ break;
248
+ }
249
+ };
250
+
251
+ /**
252
+ * Advances through expression tokens without evaluating them.
253
+ *
254
+ * Mirrors the `parsePrimary()` / `parsePostfix()` / `parseUnary()` /
255
+ * `parseExpression()` hierarchy so `parseExpression()` can preserve JavaScript
256
+ * short-circuit semantics for `&&`, `||`, `??`, and ternaries while still
257
+ * leaving the parser in the correct token position.
258
+ *
259
+ * @internal
260
+ */
261
+ const skipPrimary = (s: ParserState): void => {
262
+ const t = advance(s);
263
+ if (!t || t.kind === 'eof') {
264
+ throw new Error('Unexpected end of SSR expression');
265
+ }
266
+ if (t.kind === 'number' || t.kind === 'string' || t.kind === 'ident') {
267
+ return;
268
+ }
269
+ if (t.kind === 'punct' && t.value === '(') {
270
+ skipExpression(s, 0);
271
+ expectPunct(s, ')');
272
+ return;
273
+ }
274
+ throw new Error(`Unexpected token "${t.value}" in SSR expression`);
275
+ };
276
+
277
+ /** Skips a function-call argument list without evaluating any argument expressions. */
278
+ const skipCall = (s: ParserState): void => {
279
+ expectPunct(s, '(');
280
+ if (!matchPunct(s, ')')) {
281
+ while (true) {
282
+ skipExpression(s, 0);
283
+ if (matchPunct(s, ',')) continue;
284
+ expectPunct(s, ')');
285
+ break;
286
+ }
287
+ }
288
+ };
289
+
290
+ /** Skips postfix chains such as member access, indexing, optional chaining, and calls. */
291
+ const skipPostfix = (s: ParserState): void => {
292
+ skipPrimary(s);
293
+
294
+ while (true) {
295
+ const t = peek(s);
296
+ if (t.kind !== 'punct') break;
297
+
298
+ if (t.value === '.') {
299
+ s.pos++;
300
+ const id = advance(s);
301
+ if (id.kind !== 'ident') {
302
+ throw new Error('Expected identifier after "."');
303
+ }
304
+ continue;
305
+ }
306
+
307
+ if (t.value === '?.') {
308
+ s.pos++;
309
+ skipShortCircuitedChainTarget(s);
310
+ continue;
311
+ }
312
+
313
+ if (t.value === '[') {
314
+ s.pos++;
315
+ skipExpression(s, 0);
316
+ expectPunct(s, ']');
317
+ continue;
318
+ }
319
+
320
+ if (t.value === '(') {
321
+ skipCall(s);
322
+ continue;
323
+ }
324
+
325
+ break;
326
+ }
327
+ };
328
+
329
+ /** Skips unary operators before delegating to the postfix-skipping parser path. */
330
+ const skipUnary = (s: ParserState): void => {
331
+ const t = peek(s);
332
+ if (t.kind === 'punct' && (t.value === '!' || t.value === '-' || t.value === '+')) {
333
+ s.pos++;
334
+ skipUnary(s);
335
+ return;
336
+ }
337
+ if (t.kind === 'ident' && t.value === 'typeof') {
338
+ s.pos++;
339
+ skipUnary(s);
340
+ return;
341
+ }
342
+ skipPostfix(s);
343
+ };
344
+
345
+ /** Skips a full expression subtree while preserving operator precedence and token position. */
346
+ const skipExpression = (s: ParserState, minPrec = 0): void => {
347
+ skipUnary(s);
348
+
349
+ while (true) {
350
+ const t = peek(s);
351
+ if (t.kind !== 'punct') break;
352
+
353
+ if (t.value === '?' && minPrec <= 0) {
354
+ s.pos++;
355
+ skipExpression(s, 0);
356
+ expectPunct(s, ':');
357
+ skipExpression(s, 0);
358
+ continue;
359
+ }
360
+
361
+ const prec = BIN_PRECEDENCE[t.value];
362
+ if (prec === undefined || prec < minPrec) break;
363
+ s.pos++;
364
+ skipExpression(s, prec + 1);
365
+ }
366
+ };
367
+
368
+ const lookupIdent = (s: ParserState, name: string): unknown => {
369
+ if (name === 'true') return true;
370
+ if (name === 'false') return false;
371
+ if (name === 'null') return null;
372
+ if (name === 'undefined') return undefined;
373
+ if (isPrototypePollutionKey(name)) return undefined;
374
+ return unwrap((s.context as Record<string, unknown>)[name]);
375
+ };
376
+
377
+ const safeMember = (obj: unknown, key: PropertyKey): unknown => {
378
+ if (obj == null) return undefined;
379
+ if (typeof key === 'string' && isPrototypePollutionKey(key)) return undefined;
380
+ return (obj as Record<PropertyKey, unknown>)[key];
381
+ };
382
+
383
+ // Pratt-parser precedence table for binary operators.
384
+ const BIN_PRECEDENCE: Record<string, number> = {
385
+ '||': 1,
386
+ '??': 1,
387
+ '&&': 2,
388
+ '==': 3,
389
+ '!=': 3,
390
+ '===': 3,
391
+ '!==': 3,
392
+ '<': 4,
393
+ '<=': 4,
394
+ '>': 4,
395
+ '>=': 4,
396
+ '+': 5,
397
+ '-': 5,
398
+ '*': 6,
399
+ '/': 6,
400
+ '%': 6,
401
+ };
402
+
403
+ const parseExpression = (s: ParserState, minPrec = 0): unknown => {
404
+ let left = parseUnary(s);
405
+
406
+ while (true) {
407
+ const t = peek(s);
408
+ if (t.kind !== 'punct') break;
409
+
410
+ // Ternary
411
+ if (t.value === '?' && minPrec <= 0) {
412
+ s.pos++;
413
+ if (left) {
414
+ const consequent = parseExpression(s, 0);
415
+ expectPunct(s, ':');
416
+ skipExpression(s, 0);
417
+ left = consequent;
418
+ } else {
419
+ skipExpression(s, 0);
420
+ expectPunct(s, ':');
421
+ left = parseExpression(s, 0);
422
+ }
423
+ continue;
424
+ }
425
+
426
+ const prec = BIN_PRECEDENCE[t.value];
427
+ if (prec === undefined || prec < minPrec) break;
428
+ s.pos++;
429
+ if (t.value === '&&') {
430
+ if (!left) {
431
+ skipExpression(s, prec + 1);
432
+ } else {
433
+ left = parseExpression(s, prec + 1);
434
+ }
435
+ continue;
436
+ }
437
+ if (t.value === '||') {
438
+ if (left) {
439
+ skipExpression(s, prec + 1);
440
+ } else {
441
+ left = parseExpression(s, prec + 1);
442
+ }
443
+ continue;
444
+ }
445
+ if (t.value === '??') {
446
+ if (left !== null && left !== undefined) {
447
+ skipExpression(s, prec + 1);
448
+ } else {
449
+ left = parseExpression(s, prec + 1);
450
+ }
451
+ continue;
452
+ }
453
+ const right = parseExpression(s, prec + 1);
454
+ left = applyBinary(t.value, left, right);
455
+ }
456
+
457
+ return left;
458
+ };
459
+
460
+ const applyBinary = (op: string, l: unknown, r: unknown): unknown => {
461
+ switch (op) {
462
+ case '||':
463
+ return l || r;
464
+ case '&&':
465
+ return l && r;
466
+ case '??':
467
+ return l ?? r;
468
+ case '==':
469
+ // Intentional loose equality: the SSR expression grammar mirrors the
470
+ // JavaScript operators users write in templates. `===` and `!==` are
471
+ // available for strict comparisons.
472
+
473
+ return l == r;
474
+ case '!=':
475
+ // Intentional loose inequality (see `==` note above).
476
+
477
+ return l != r;
478
+ case '===':
479
+ return l === r;
480
+ case '!==':
481
+ return l !== r;
482
+ case '<':
483
+ return (l as number) < (r as number);
484
+ case '<=':
485
+ return (l as number) <= (r as number);
486
+ case '>':
487
+ return (l as number) > (r as number);
488
+ case '>=':
489
+ return (l as number) >= (r as number);
490
+ case '+':
491
+ // String concat for either operand is a string
492
+ if (typeof l === 'string' || typeof r === 'string') {
493
+ return String(l ?? '') + String(r ?? '');
494
+ }
495
+ return (l as number) + (r as number);
496
+ case '-':
497
+ return (l as number) - (r as number);
498
+ case '*':
499
+ return (l as number) * (r as number);
500
+ case '/':
501
+ return (l as number) / (r as number);
502
+ case '%':
503
+ return (l as number) % (r as number);
504
+ default:
505
+ throw new Error(`Unsupported binary operator "${op}"`);
506
+ }
507
+ };
508
+
509
+ const parseUnary = (s: ParserState): unknown => {
510
+ const t = peek(s);
511
+ if (t.kind === 'punct') {
512
+ if (t.value === '!') {
513
+ s.pos++;
514
+ return !parseUnary(s);
515
+ }
516
+ if (t.value === '-') {
517
+ s.pos++;
518
+ return -(parseUnary(s) as number);
519
+ }
520
+ if (t.value === '+') {
521
+ s.pos++;
522
+ return +(parseUnary(s) as number);
523
+ }
524
+ }
525
+ if (t.kind === 'ident' && t.value === 'typeof') {
526
+ s.pos++;
527
+ return typeof parseUnary(s);
528
+ }
529
+ return parsePostfix(s);
530
+ };
531
+
532
+ const parsePostfix = (s: ParserState): unknown => {
533
+ let value = parsePrimary(s);
534
+ let thisArg: unknown = undefined;
535
+
536
+ while (true) {
537
+ const t = peek(s);
538
+ if (t.kind !== 'punct') break;
539
+
540
+ if (t.value === '.') {
541
+ s.pos++;
542
+ const id = advance(s);
543
+ if (id.kind !== 'ident') {
544
+ throw new Error('Expected identifier after "."');
545
+ }
546
+ thisArg = value;
547
+ value = safeMember(value, id.value);
548
+ continue;
549
+ }
550
+
551
+ if (t.value === '?.') {
552
+ s.pos++;
553
+ if (value == null) {
554
+ skipShortCircuitedChainTarget(s);
555
+ skipShortCircuitedChainRemainder(s);
556
+ value = undefined;
557
+ thisArg = undefined;
558
+ continue;
559
+ }
560
+ const next = peek(s);
561
+ if (next.kind === 'punct' && next.value === '[') {
562
+ s.pos++;
563
+ const receiver = value;
564
+ const key = parseExpression(s, 0);
565
+ expectPunct(s, ']');
566
+ thisArg = receiver;
567
+ value = safeMember(value, key as PropertyKey);
568
+ } else if (next.kind === 'punct' && next.value === '(') {
569
+ value = parseCall(s, value, thisArg);
570
+ thisArg = undefined;
571
+ } else if (next.kind === 'ident') {
572
+ s.pos++;
573
+ thisArg = value;
574
+ value = safeMember(value, next.value);
575
+ } else {
576
+ throw new Error('Invalid optional chain in SSR expression');
577
+ }
578
+ continue;
579
+ }
580
+
581
+ if (t.value === '[') {
582
+ s.pos++;
583
+ const receiver = value;
584
+ const key = parseExpression(s, 0);
585
+ expectPunct(s, ']');
586
+ thisArg = receiver;
587
+ value = safeMember(value, key as PropertyKey);
588
+ continue;
589
+ }
590
+
591
+ if (t.value === '(') {
592
+ // Function call
593
+ value = parseCall(s, value, thisArg);
594
+ thisArg = undefined;
595
+ continue;
596
+ }
597
+
598
+ break;
599
+ }
600
+
601
+ return value;
602
+ };
603
+
604
+ const parseCall = (s: ParserState, callee: unknown, thisArg: unknown): unknown => {
605
+ expectPunct(s, '(');
606
+ const args: unknown[] = [];
607
+ if (!matchPunct(s, ')')) {
608
+ while (true) {
609
+ args.push(parseExpression(s, 0));
610
+ if (matchPunct(s, ',')) continue;
611
+ expectPunct(s, ')');
612
+ break;
613
+ }
614
+ }
615
+ if (typeof callee !== 'function') {
616
+ return undefined;
617
+ }
618
+ return (callee as (...a: unknown[]) => unknown).apply(thisArg, args);
619
+ };
620
+
621
+ const parsePrimary = (s: ParserState): unknown => {
622
+ const t = advance(s);
623
+ if (t.kind === 'number') {
624
+ return Number(t.value);
625
+ }
626
+ if (t.kind === 'string') {
627
+ return t.value;
628
+ }
629
+ if (t.kind === 'ident') {
630
+ return lookupIdent(s, t.value);
631
+ }
632
+ if (t.kind === 'punct' && t.value === '(') {
633
+ const value = parseExpression(s, 0);
634
+ expectPunct(s, ')');
635
+ return value;
636
+ }
637
+ throw new Error(`Unexpected token "${t.value}" in SSR expression`);
638
+ };
639
+
640
+ /**
641
+ * Evaluates a tightly scoped expression against a binding context.
642
+ *
643
+ * Returns `undefined` when the expression cannot be parsed or evaluated.
644
+ * This matches the behaviour of the previous `new Function()`-based fallback
645
+ * but never invokes dynamic code generation.
646
+ *
647
+ * @param expression - Expression source.
648
+ * @param context - Binding context whose top-level signal/computed values are
649
+ * automatically unwrapped.
650
+ *
651
+ * @internal
652
+ */
653
+ export const evaluateExpression = <T = unknown>(expression: string, context: BindingContext): T => {
654
+ const trimmed = expression.trim();
655
+ if (trimmed === '') return undefined as T;
656
+
657
+ try {
658
+ const tokens = tokenize(trimmed);
659
+ const state: ParserState = { tokens, pos: 0, context };
660
+ const value = parseExpression(state, 0);
661
+ if (peek(state).kind !== 'eof') {
662
+ // Unexpected trailing tokens — fall back to undefined.
663
+ return undefined as T;
664
+ }
665
+ return value as T;
666
+ } catch {
667
+ return undefined as T;
668
+ }
669
+ };