@bquery/bquery 1.1.2 → 1.2.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.
- package/README.md +110 -6
- package/dist/full.d.ts +6 -0
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +58 -35
- package/dist/full.es.mjs.map +1 -1
- package/dist/full.iife.js +6 -1
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +6 -1
- package/dist/full.umd.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.es.mjs +58 -35
- package/dist/index.es.mjs.map +1 -1
- package/dist/reactive/signal.d.ts +7 -0
- package/dist/reactive/signal.d.ts.map +1 -1
- package/dist/reactive.es.mjs +34 -25
- package/dist/reactive.es.mjs.map +1 -1
- package/dist/router/index.d.ts +287 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router.es.mjs +166 -0
- package/dist/router.es.mjs.map +1 -0
- package/dist/store/index.d.ts +288 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store.es.mjs +229 -0
- package/dist/store.es.mjs.map +1 -0
- package/dist/view/index.d.ts +201 -0
- package/dist/view/index.d.ts.map +1 -0
- package/dist/view.es.mjs +325 -0
- package/dist/view.es.mjs.map +1 -0
- package/package.json +132 -120
- package/src/full.ts +44 -0
- package/src/index.ts +9 -0
- package/src/reactive/signal.ts +14 -0
- package/src/router/index.ts +718 -0
- package/src/store/index.ts +848 -0
- package/src/view/index.ts +1041 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative DOM bindings via data attributes.
|
|
3
|
+
*
|
|
4
|
+
* This module provides Vue/Svelte-style template directives without
|
|
5
|
+
* requiring a compiler. Bindings are evaluated at runtime using
|
|
6
|
+
* bQuery's reactive system. Features include:
|
|
7
|
+
* - Conditional rendering (bq-if)
|
|
8
|
+
* - List rendering (bq-for)
|
|
9
|
+
* - Two-way binding (bq-model)
|
|
10
|
+
* - Class binding (bq-class)
|
|
11
|
+
* - Text/HTML binding (bq-text, bq-html)
|
|
12
|
+
* - Attribute binding (bq-bind)
|
|
13
|
+
* - Event binding (bq-on)
|
|
14
|
+
*
|
|
15
|
+
* ## Security Considerations
|
|
16
|
+
*
|
|
17
|
+
* **WARNING:** This module uses `new Function()` to evaluate expressions at runtime.
|
|
18
|
+
* This is similar to Vue/Alpine's approach but carries inherent security risks:
|
|
19
|
+
*
|
|
20
|
+
* - **NEVER** use expressions derived from user input or untrusted sources
|
|
21
|
+
* - Expressions should only come from developer-controlled templates
|
|
22
|
+
* - The context object should not contain sensitive data that could be exfiltrated
|
|
23
|
+
* - For user-generated content, use static bindings with sanitized values instead
|
|
24
|
+
*
|
|
25
|
+
* Since bQuery is runtime-only (no build-time compilation), expressions are evaluated
|
|
26
|
+
* dynamically. If your application loads templates from external sources (APIs, databases),
|
|
27
|
+
* ensure they are trusted and validated before mounting.
|
|
28
|
+
*
|
|
29
|
+
* ## Content Security Policy (CSP) Compatibility
|
|
30
|
+
*
|
|
31
|
+
* **IMPORTANT:** This module requires `'unsafe-eval'` in your CSP `script-src` directive.
|
|
32
|
+
* The `new Function()` constructor used for expression evaluation will be blocked by
|
|
33
|
+
* strict CSP policies that omit `'unsafe-eval'`.
|
|
34
|
+
*
|
|
35
|
+
* ### Required CSP Header
|
|
36
|
+
* ```
|
|
37
|
+
* Content-Security-Policy: script-src 'self' 'unsafe-eval';
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* ### CSP-Strict Alternatives
|
|
41
|
+
*
|
|
42
|
+
* If your application requires a strict CSP without `'unsafe-eval'`, consider these alternatives:
|
|
43
|
+
*
|
|
44
|
+
* 1. **Use bQuery's core reactive system directly** - Bind signals to DOM elements manually
|
|
45
|
+
* using `effect()` without the view module's template directives:
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { signal, effect } from 'bquery/reactive';
|
|
48
|
+
* import { $ } from 'bquery';
|
|
49
|
+
*
|
|
50
|
+
* const count = signal(0);
|
|
51
|
+
* effect(() => {
|
|
52
|
+
* $('#counter').text(String(count.value));
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* 2. **Use bQuery's component module** - Web Components with typed props don't require
|
|
57
|
+
* dynamic expression evaluation:
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { component } from 'bquery/component';
|
|
60
|
+
* component('my-counter', {
|
|
61
|
+
* props: { count: { type: Number } },
|
|
62
|
+
* render: ({ props }) => `<span>${props.count}</span>`,
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* 3. **Pre-compile templates at build time** - Use a build step to transform bq-* attributes
|
|
67
|
+
* into static JavaScript (similar to Svelte/Vue SFC compilation). This is outside bQuery's
|
|
68
|
+
* scope but can be achieved with custom Vite/Rollup plugins.
|
|
69
|
+
*
|
|
70
|
+
* The view module is designed for rapid prototyping and applications where CSP flexibility
|
|
71
|
+
* is acceptable. For security-critical applications requiring strict CSP, use the alternatives above.
|
|
72
|
+
*
|
|
73
|
+
* @module bquery/view
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```html
|
|
77
|
+
* <div id="app">
|
|
78
|
+
* <input bq-model="name" />
|
|
79
|
+
* <p bq-text="greeting"></p>
|
|
80
|
+
* <ul>
|
|
81
|
+
* <li bq-for="item in items" bq-text="item.name"></li>
|
|
82
|
+
* </ul>
|
|
83
|
+
* <button bq-on:click="handleClick">Click me</button>
|
|
84
|
+
* <div bq-if="showDetails" bq-class="{ active: isActive }">
|
|
85
|
+
* Details here
|
|
86
|
+
* </div>
|
|
87
|
+
* </div>
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* import { mount } from 'bquery/view';
|
|
92
|
+
* import { signal } from 'bquery/reactive';
|
|
93
|
+
*
|
|
94
|
+
* mount('#app', {
|
|
95
|
+
* name: signal('World'),
|
|
96
|
+
* greeting: computed(() => `Hello, ${name.value}!`),
|
|
97
|
+
* items: signal([{ name: 'Item 1' }, { name: 'Item 2' }]),
|
|
98
|
+
* showDetails: signal(true),
|
|
99
|
+
* isActive: signal(false),
|
|
100
|
+
* handleClick: () => console.log('Clicked!'),
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
import { effect, isComputed, isSignal, type CleanupFn, type Signal } from '../reactive/index';
|
|
106
|
+
import { sanitizeHtml } from '../security/index';
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Types
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Context object passed to binding expressions.
|
|
114
|
+
*/
|
|
115
|
+
export type BindingContext = Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Configuration options for mount.
|
|
119
|
+
*/
|
|
120
|
+
export type MountOptions = {
|
|
121
|
+
/** Prefix for directive attributes (default: 'bq') */
|
|
122
|
+
prefix?: string;
|
|
123
|
+
/** Whether to sanitize bq-html content (default: true) */
|
|
124
|
+
sanitize?: boolean;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Mounted view instance.
|
|
129
|
+
*/
|
|
130
|
+
export type View = {
|
|
131
|
+
/** The root element */
|
|
132
|
+
el: Element;
|
|
133
|
+
/** The binding context */
|
|
134
|
+
context: BindingContext;
|
|
135
|
+
/** Update the context and re-render */
|
|
136
|
+
update: (newContext: Partial<BindingContext>) => void;
|
|
137
|
+
/** Destroy the view and cleanup effects */
|
|
138
|
+
destroy: () => void;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Internal directive handler type.
|
|
143
|
+
* @internal
|
|
144
|
+
*/
|
|
145
|
+
type DirectiveHandler = (
|
|
146
|
+
el: Element,
|
|
147
|
+
expression: string,
|
|
148
|
+
context: BindingContext,
|
|
149
|
+
cleanups: CleanupFn[]
|
|
150
|
+
) => void;
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// Expression Evaluation
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Evaluates an expression in the given context using `new Function()`.
|
|
158
|
+
*
|
|
159
|
+
* @security **WARNING:** This function uses dynamic code execution via `new Function()`.
|
|
160
|
+
* - NEVER pass expressions derived from user input or untrusted sources
|
|
161
|
+
* - Expressions should only come from developer-controlled templates
|
|
162
|
+
* - Malicious expressions can access and exfiltrate context data
|
|
163
|
+
* - Consider this equivalent to `eval()` in terms of security implications
|
|
164
|
+
*
|
|
165
|
+
* @internal
|
|
166
|
+
*/
|
|
167
|
+
const evaluate = <T = unknown>(expression: string, context: BindingContext): T => {
|
|
168
|
+
try {
|
|
169
|
+
// Build context keys for function scope
|
|
170
|
+
const keys = Object.keys(context);
|
|
171
|
+
const values = keys.map((key) => {
|
|
172
|
+
const value = context[key];
|
|
173
|
+
// Auto-unwrap signals/computed
|
|
174
|
+
if (isSignal(value) || isComputed(value)) {
|
|
175
|
+
return (value as Signal<unknown>).value;
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Create function with context variables in scope
|
|
181
|
+
const fn = new Function(...keys, `return (${expression})`);
|
|
182
|
+
return fn(...values) as T;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error(`bQuery view: Error evaluating "${expression}"`, error);
|
|
185
|
+
return undefined as T;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Evaluates an expression and returns the raw value (for signal access).
|
|
191
|
+
*
|
|
192
|
+
* @security **WARNING:** Uses dynamic code execution. See {@link evaluate} for security notes.
|
|
193
|
+
* @internal
|
|
194
|
+
*/
|
|
195
|
+
const evaluateRaw = <T = unknown>(expression: string, context: BindingContext): T => {
|
|
196
|
+
try {
|
|
197
|
+
const keys = Object.keys(context);
|
|
198
|
+
const values = keys.map((key) => context[key]);
|
|
199
|
+
const fn = new Function(...keys, `return (${expression})`);
|
|
200
|
+
return fn(...values) as T;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error(`bQuery view: Error evaluating "${expression}"`, error);
|
|
203
|
+
return undefined as T;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parses object expression like "{ active: isActive, disabled: !enabled }".
|
|
209
|
+
* Handles nested structures like function calls, arrays, and template literals.
|
|
210
|
+
* @internal
|
|
211
|
+
*/
|
|
212
|
+
const parseObjectExpression = (expression: string): Record<string, string> => {
|
|
213
|
+
const result: Record<string, string> = {};
|
|
214
|
+
|
|
215
|
+
// Remove outer braces and trim
|
|
216
|
+
const inner = expression
|
|
217
|
+
.trim()
|
|
218
|
+
.replace(/^\{|\}$/g, '')
|
|
219
|
+
.trim();
|
|
220
|
+
if (!inner) return result;
|
|
221
|
+
|
|
222
|
+
// Split by comma at depth 0, respecting strings and nesting
|
|
223
|
+
const parts: string[] = [];
|
|
224
|
+
let current = '';
|
|
225
|
+
let depth = 0;
|
|
226
|
+
let inString: string | null = null;
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < inner.length; i++) {
|
|
229
|
+
const char = inner[i];
|
|
230
|
+
const prevChar = i > 0 ? inner[i - 1] : '';
|
|
231
|
+
|
|
232
|
+
// Handle string literals (including escape sequences)
|
|
233
|
+
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
|
|
234
|
+
if (inString === null) {
|
|
235
|
+
inString = char;
|
|
236
|
+
} else if (inString === char) {
|
|
237
|
+
inString = null;
|
|
238
|
+
}
|
|
239
|
+
current += char;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Skip if inside string
|
|
244
|
+
if (inString !== null) {
|
|
245
|
+
current += char;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Track nesting depth for parentheses, brackets, and braces
|
|
250
|
+
if (char === '(' || char === '[' || char === '{') {
|
|
251
|
+
depth++;
|
|
252
|
+
current += char;
|
|
253
|
+
} else if (char === ')' || char === ']' || char === '}') {
|
|
254
|
+
depth--;
|
|
255
|
+
current += char;
|
|
256
|
+
} else if (char === ',' && depth === 0) {
|
|
257
|
+
// Top-level comma - split point
|
|
258
|
+
parts.push(current.trim());
|
|
259
|
+
current = '';
|
|
260
|
+
} else {
|
|
261
|
+
current += char;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Add the last part
|
|
266
|
+
if (current.trim()) {
|
|
267
|
+
parts.push(current.trim());
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Parse each part to extract key and value
|
|
271
|
+
for (const part of parts) {
|
|
272
|
+
// Find the first colon at depth 0 (to handle ternary operators in values)
|
|
273
|
+
let colonIndex = -1;
|
|
274
|
+
let partDepth = 0;
|
|
275
|
+
let partInString: string | null = null;
|
|
276
|
+
|
|
277
|
+
for (let i = 0; i < part.length; i++) {
|
|
278
|
+
const char = part[i];
|
|
279
|
+
const prevChar = i > 0 ? part[i - 1] : '';
|
|
280
|
+
|
|
281
|
+
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
|
|
282
|
+
if (partInString === null) {
|
|
283
|
+
partInString = char;
|
|
284
|
+
} else if (partInString === char) {
|
|
285
|
+
partInString = null;
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (partInString !== null) continue;
|
|
291
|
+
|
|
292
|
+
if (char === '(' || char === '[' || char === '{') {
|
|
293
|
+
partDepth++;
|
|
294
|
+
} else if (char === ')' || char === ']' || char === '}') {
|
|
295
|
+
partDepth--;
|
|
296
|
+
} else if (char === ':' && partDepth === 0) {
|
|
297
|
+
colonIndex = i;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (colonIndex > -1) {
|
|
303
|
+
const key = part
|
|
304
|
+
.slice(0, colonIndex)
|
|
305
|
+
.trim()
|
|
306
|
+
.replace(/^['"]|['"]$/g, '');
|
|
307
|
+
const value = part.slice(colonIndex + 1).trim();
|
|
308
|
+
result[key] = value;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// Directive Handlers
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handles bq-text directive - sets text content.
|
|
321
|
+
* @internal
|
|
322
|
+
*/
|
|
323
|
+
const handleText: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
324
|
+
const cleanup = effect(() => {
|
|
325
|
+
const value = evaluate(expression, context);
|
|
326
|
+
el.textContent = String(value ?? '');
|
|
327
|
+
});
|
|
328
|
+
cleanups.push(cleanup);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Handles bq-html directive - sets innerHTML (sanitized by default).
|
|
333
|
+
* @internal
|
|
334
|
+
*/
|
|
335
|
+
const handleHtml = (sanitize: boolean): DirectiveHandler => {
|
|
336
|
+
return (el, expression, context, cleanups) => {
|
|
337
|
+
const cleanup = effect(() => {
|
|
338
|
+
const value = evaluate<string>(expression, context);
|
|
339
|
+
const html = String(value ?? '');
|
|
340
|
+
el.innerHTML = sanitize ? sanitizeHtml(html) : html;
|
|
341
|
+
});
|
|
342
|
+
cleanups.push(cleanup);
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Handles bq-if directive - conditional rendering.
|
|
348
|
+
* @internal
|
|
349
|
+
*/
|
|
350
|
+
const handleIf: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
351
|
+
const parent = el.parentNode;
|
|
352
|
+
const placeholder = document.createComment(`bq-if: ${expression}`);
|
|
353
|
+
|
|
354
|
+
// Store original element state
|
|
355
|
+
let isInserted = true;
|
|
356
|
+
|
|
357
|
+
const cleanup = effect(() => {
|
|
358
|
+
const condition = evaluate<boolean>(expression, context);
|
|
359
|
+
|
|
360
|
+
if (condition && !isInserted) {
|
|
361
|
+
// Insert element
|
|
362
|
+
parent?.replaceChild(el, placeholder);
|
|
363
|
+
isInserted = true;
|
|
364
|
+
} else if (!condition && isInserted) {
|
|
365
|
+
// Remove element
|
|
366
|
+
parent?.replaceChild(placeholder, el);
|
|
367
|
+
isInserted = false;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
cleanups.push(cleanup);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Handles bq-show directive - toggle visibility.
|
|
376
|
+
* @internal
|
|
377
|
+
*/
|
|
378
|
+
const handleShow: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
379
|
+
const htmlEl = el as HTMLElement;
|
|
380
|
+
const originalDisplay = htmlEl.style.display;
|
|
381
|
+
|
|
382
|
+
const cleanup = effect(() => {
|
|
383
|
+
const condition = evaluate<boolean>(expression, context);
|
|
384
|
+
htmlEl.style.display = condition ? originalDisplay : 'none';
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
cleanups.push(cleanup);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Handles bq-class directive - dynamic class binding.
|
|
392
|
+
* Tracks previously added classes to ensure proper cleanup when expressions change.
|
|
393
|
+
* @internal
|
|
394
|
+
*/
|
|
395
|
+
const handleClass: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
396
|
+
// Track classes added by this directive to clean them up on re-evaluation
|
|
397
|
+
let previousClasses: Set<string> = new Set();
|
|
398
|
+
|
|
399
|
+
const cleanup = effect(() => {
|
|
400
|
+
const newClasses: Set<string> = new Set();
|
|
401
|
+
|
|
402
|
+
if (expression.startsWith('{')) {
|
|
403
|
+
// Object syntax: { active: isActive, disabled: !enabled }
|
|
404
|
+
const classMap = parseObjectExpression(expression);
|
|
405
|
+
for (const [className, conditionExpr] of Object.entries(classMap)) {
|
|
406
|
+
const condition = evaluate<boolean>(conditionExpr, context);
|
|
407
|
+
el.classList.toggle(className, Boolean(condition));
|
|
408
|
+
// Track class regardless of condition - toggle handles add/remove
|
|
409
|
+
newClasses.add(className);
|
|
410
|
+
}
|
|
411
|
+
} else if (expression.includes('[')) {
|
|
412
|
+
// Array syntax: [class1, class2]
|
|
413
|
+
const classes = evaluate<string[]>(expression, context);
|
|
414
|
+
if (Array.isArray(classes)) {
|
|
415
|
+
for (const cls of classes) {
|
|
416
|
+
if (cls) {
|
|
417
|
+
el.classList.add(cls);
|
|
418
|
+
newClasses.add(cls);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
// Single expression returning string or array
|
|
424
|
+
const result = evaluate<string | string[]>(expression, context);
|
|
425
|
+
if (typeof result === 'string') {
|
|
426
|
+
result.split(/\s+/).forEach((cls) => {
|
|
427
|
+
if (cls) {
|
|
428
|
+
el.classList.add(cls);
|
|
429
|
+
newClasses.add(cls);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
} else if (Array.isArray(result)) {
|
|
433
|
+
result.forEach((cls) => {
|
|
434
|
+
if (cls) {
|
|
435
|
+
el.classList.add(cls);
|
|
436
|
+
newClasses.add(cls);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Remove classes that were previously added but are no longer in the new set
|
|
443
|
+
// Skip for object syntax since toggle already handles removal
|
|
444
|
+
if (!expression.startsWith('{')) {
|
|
445
|
+
for (const cls of previousClasses) {
|
|
446
|
+
if (!newClasses.has(cls)) {
|
|
447
|
+
el.classList.remove(cls);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
previousClasses = newClasses;
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
cleanups.push(cleanup);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Handles bq-style directive - dynamic style binding.
|
|
460
|
+
* @internal
|
|
461
|
+
*/
|
|
462
|
+
const handleStyle: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
463
|
+
const htmlEl = el as HTMLElement;
|
|
464
|
+
|
|
465
|
+
const cleanup = effect(() => {
|
|
466
|
+
if (expression.startsWith('{')) {
|
|
467
|
+
const styleMap = parseObjectExpression(expression);
|
|
468
|
+
for (const [prop, valueExpr] of Object.entries(styleMap)) {
|
|
469
|
+
const value = evaluate<string>(valueExpr, context);
|
|
470
|
+
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
471
|
+
htmlEl.style.setProperty(cssProp, String(value ?? ''));
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
const result = evaluate<Record<string, string>>(expression, context);
|
|
475
|
+
if (result && typeof result === 'object') {
|
|
476
|
+
for (const [prop, value] of Object.entries(result)) {
|
|
477
|
+
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
478
|
+
htmlEl.style.setProperty(cssProp, String(value ?? ''));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
cleanups.push(cleanup);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Handles bq-model directive - two-way binding.
|
|
489
|
+
* @internal
|
|
490
|
+
*/
|
|
491
|
+
const handleModel: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
492
|
+
const input = el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
493
|
+
const rawValue = evaluateRaw<Signal<unknown>>(expression, context);
|
|
494
|
+
|
|
495
|
+
if (!isSignal(rawValue)) {
|
|
496
|
+
console.warn(`bQuery view: bq-model requires a signal, got "${expression}"`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const sig = rawValue as Signal<unknown>;
|
|
501
|
+
|
|
502
|
+
// Initial value sync
|
|
503
|
+
const isCheckbox = input.type === 'checkbox';
|
|
504
|
+
const isRadio = input.type === 'radio';
|
|
505
|
+
|
|
506
|
+
const updateInput = () => {
|
|
507
|
+
if (isCheckbox) {
|
|
508
|
+
(input as HTMLInputElement).checked = Boolean(sig.value);
|
|
509
|
+
} else if (isRadio) {
|
|
510
|
+
(input as HTMLInputElement).checked = sig.value === input.value;
|
|
511
|
+
} else {
|
|
512
|
+
input.value = String(sig.value ?? '');
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// Effect to sync signal -> input
|
|
517
|
+
const cleanup = effect(() => {
|
|
518
|
+
updateInput();
|
|
519
|
+
});
|
|
520
|
+
cleanups.push(cleanup);
|
|
521
|
+
|
|
522
|
+
// Event listener to sync input -> signal
|
|
523
|
+
const eventType = input.tagName === 'SELECT' ? 'change' : 'input';
|
|
524
|
+
const handler = () => {
|
|
525
|
+
if (isCheckbox) {
|
|
526
|
+
sig.value = (input as HTMLInputElement).checked;
|
|
527
|
+
} else if (isRadio) {
|
|
528
|
+
if ((input as HTMLInputElement).checked) {
|
|
529
|
+
sig.value = input.value;
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
sig.value = input.value;
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
input.addEventListener(eventType, handler);
|
|
537
|
+
cleanups.push(() => input.removeEventListener(eventType, handler));
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Handles bq-bind:attr directive - attribute binding.
|
|
542
|
+
* @internal
|
|
543
|
+
*/
|
|
544
|
+
const handleBind = (attrName: string): DirectiveHandler => {
|
|
545
|
+
return (el, expression, context, cleanups) => {
|
|
546
|
+
const cleanup = effect(() => {
|
|
547
|
+
const value = evaluate(expression, context);
|
|
548
|
+
if (value == null || value === false) {
|
|
549
|
+
el.removeAttribute(attrName);
|
|
550
|
+
} else if (value === true) {
|
|
551
|
+
el.setAttribute(attrName, '');
|
|
552
|
+
} else {
|
|
553
|
+
el.setAttribute(attrName, String(value));
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
cleanups.push(cleanup);
|
|
557
|
+
};
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Handles bq-on:event directive - event binding.
|
|
562
|
+
* @internal
|
|
563
|
+
*/
|
|
564
|
+
const handleOn = (eventName: string): DirectiveHandler => {
|
|
565
|
+
return (el, expression, context, cleanups) => {
|
|
566
|
+
const handler = (event: Event) => {
|
|
567
|
+
// Add $event to context for expression evaluation
|
|
568
|
+
const eventContext = { ...context, $event: event, $el: el };
|
|
569
|
+
|
|
570
|
+
// Check if expression is just a function reference (no parentheses)
|
|
571
|
+
// In that case, we should call it directly
|
|
572
|
+
const isPlainFunctionRef = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(expression.trim());
|
|
573
|
+
|
|
574
|
+
if (isPlainFunctionRef) {
|
|
575
|
+
// Get the function and call it with the event
|
|
576
|
+
const fn = evaluateRaw<unknown>(expression, eventContext);
|
|
577
|
+
if (typeof fn === 'function') {
|
|
578
|
+
fn(event);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Otherwise evaluate as expression (e.g., "handleClick($event)" or "count++")
|
|
584
|
+
evaluate(expression, eventContext);
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
el.addEventListener(eventName, handler);
|
|
588
|
+
cleanups.push(() => el.removeEventListener(eventName, handler));
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Represents a rendered item in bq-for with its DOM element and associated cleanup functions.
|
|
594
|
+
* @internal
|
|
595
|
+
*/
|
|
596
|
+
type RenderedItem = {
|
|
597
|
+
key: unknown;
|
|
598
|
+
element: Element;
|
|
599
|
+
cleanups: CleanupFn[];
|
|
600
|
+
item: unknown;
|
|
601
|
+
index: number;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Extracts a key from an item using the key expression or falls back to index.
|
|
606
|
+
* @internal
|
|
607
|
+
*/
|
|
608
|
+
const getItemKey = (
|
|
609
|
+
item: unknown,
|
|
610
|
+
index: number,
|
|
611
|
+
keyExpression: string | null,
|
|
612
|
+
itemName: string,
|
|
613
|
+
indexName: string | undefined,
|
|
614
|
+
context: BindingContext
|
|
615
|
+
): unknown => {
|
|
616
|
+
if (!keyExpression) {
|
|
617
|
+
return index; // Fallback to index-based keying
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const keyContext: BindingContext = {
|
|
621
|
+
...context,
|
|
622
|
+
[itemName]: item,
|
|
623
|
+
};
|
|
624
|
+
if (indexName) {
|
|
625
|
+
keyContext[indexName] = index;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return evaluate(keyExpression, keyContext);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Handles bq-for directive - list rendering with keyed reconciliation.
|
|
633
|
+
*
|
|
634
|
+
* Supports optional `:key` attribute for efficient DOM reuse:
|
|
635
|
+
* ```html
|
|
636
|
+
* <li bq-for="item in items" :key="item.id">...</li>
|
|
637
|
+
* ```
|
|
638
|
+
*
|
|
639
|
+
* Without a key, falls back to index-based tracking (less efficient for reordering).
|
|
640
|
+
*
|
|
641
|
+
* @internal
|
|
642
|
+
*/
|
|
643
|
+
const handleFor = (prefix: string, sanitize: boolean): DirectiveHandler => {
|
|
644
|
+
return (el, expression, context, cleanups) => {
|
|
645
|
+
const parent = el.parentNode;
|
|
646
|
+
if (!parent) return;
|
|
647
|
+
|
|
648
|
+
// Parse expression: "item in items" or "(item, index) in items"
|
|
649
|
+
// Use \S.* instead of .+ to prevent ReDoS by requiring non-whitespace start
|
|
650
|
+
const match = expression.match(/^\(?(\w+)(?:\s*,\s*(\w+))?\)?\s+in\s+(\S.*)$/);
|
|
651
|
+
if (!match) {
|
|
652
|
+
console.error(`bQuery view: Invalid bq-for expression "${expression}"`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const [, itemName, indexName, listExpression] = match;
|
|
657
|
+
|
|
658
|
+
// Extract :key attribute if present
|
|
659
|
+
const keyExpression = el.getAttribute(':key') || el.getAttribute(`${prefix}-key`);
|
|
660
|
+
|
|
661
|
+
const template = el.cloneNode(true) as Element;
|
|
662
|
+
template.removeAttribute(`${prefix}-for`);
|
|
663
|
+
template.removeAttribute(':key');
|
|
664
|
+
template.removeAttribute(`${prefix}-key`);
|
|
665
|
+
|
|
666
|
+
// Create placeholder comment
|
|
667
|
+
const placeholder = document.createComment(`bq-for: ${expression}`);
|
|
668
|
+
parent.replaceChild(placeholder, el);
|
|
669
|
+
|
|
670
|
+
// Track rendered items by key for reconciliation
|
|
671
|
+
let renderedItemsMap = new Map<unknown, RenderedItem>();
|
|
672
|
+
let renderedOrder: unknown[] = [];
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Creates a new DOM element for an item.
|
|
676
|
+
*/
|
|
677
|
+
const createItemElement = (item: unknown, index: number, key: unknown): RenderedItem => {
|
|
678
|
+
const clone = template.cloneNode(true) as Element;
|
|
679
|
+
const itemCleanups: CleanupFn[] = [];
|
|
680
|
+
|
|
681
|
+
const childContext: BindingContext = {
|
|
682
|
+
...context,
|
|
683
|
+
[itemName]: item,
|
|
684
|
+
};
|
|
685
|
+
if (indexName) {
|
|
686
|
+
childContext[indexName] = index;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Process bindings on the clone
|
|
690
|
+
processElement(clone, childContext, prefix, sanitize, itemCleanups);
|
|
691
|
+
processChildren(clone, childContext, prefix, sanitize, itemCleanups);
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
key,
|
|
695
|
+
element: clone,
|
|
696
|
+
cleanups: itemCleanups,
|
|
697
|
+
item,
|
|
698
|
+
index,
|
|
699
|
+
};
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Removes a rendered item and cleans up its effects.
|
|
704
|
+
*/
|
|
705
|
+
const removeItem = (rendered: RenderedItem): void => {
|
|
706
|
+
for (const cleanup of rendered.cleanups) {
|
|
707
|
+
cleanup();
|
|
708
|
+
}
|
|
709
|
+
rendered.element.remove();
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Updates an existing item's index context if needed.
|
|
714
|
+
* Note: For deep reactivity, items should use signals internally.
|
|
715
|
+
*/
|
|
716
|
+
const updateItemIndex = (rendered: RenderedItem, newIndex: number): void => {
|
|
717
|
+
if (rendered.index !== newIndex && indexName) {
|
|
718
|
+
// Index changed - we need to re-process bindings for index-dependent expressions
|
|
719
|
+
// For now, we mark the index as updated (future: could use signals for index)
|
|
720
|
+
rendered.index = newIndex;
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const cleanup = effect(() => {
|
|
725
|
+
const list = evaluate<unknown[]>(listExpression, context);
|
|
726
|
+
|
|
727
|
+
if (!Array.isArray(list)) {
|
|
728
|
+
// Clear all if list is invalid
|
|
729
|
+
for (const rendered of renderedItemsMap.values()) {
|
|
730
|
+
removeItem(rendered);
|
|
731
|
+
}
|
|
732
|
+
renderedItemsMap.clear();
|
|
733
|
+
renderedOrder = [];
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Build new key order and detect changes
|
|
738
|
+
const newKeys: unknown[] = [];
|
|
739
|
+
const newItemsByKey = new Map<unknown, { item: unknown; index: number }>();
|
|
740
|
+
|
|
741
|
+
list.forEach((item, index) => {
|
|
742
|
+
const key = getItemKey(item, index, keyExpression, itemName, indexName, context);
|
|
743
|
+
newKeys.push(key);
|
|
744
|
+
newItemsByKey.set(key, { item, index });
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Identify items to remove (in old but not in new)
|
|
748
|
+
const keysToRemove: unknown[] = [];
|
|
749
|
+
for (const key of renderedOrder) {
|
|
750
|
+
if (!newItemsByKey.has(key)) {
|
|
751
|
+
keysToRemove.push(key);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Remove deleted items
|
|
756
|
+
for (const key of keysToRemove) {
|
|
757
|
+
const rendered = renderedItemsMap.get(key);
|
|
758
|
+
if (rendered) {
|
|
759
|
+
removeItem(rendered);
|
|
760
|
+
renderedItemsMap.delete(key);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Process new list: create new items, update indices, reorder
|
|
765
|
+
const newRenderedMap = new Map<unknown, RenderedItem>();
|
|
766
|
+
let lastInsertedElement: Element | Comment = placeholder;
|
|
767
|
+
|
|
768
|
+
for (let i = 0; i < newKeys.length; i++) {
|
|
769
|
+
const key = newKeys[i];
|
|
770
|
+
const { item, index } = newItemsByKey.get(key)!;
|
|
771
|
+
let rendered = renderedItemsMap.get(key);
|
|
772
|
+
|
|
773
|
+
if (rendered) {
|
|
774
|
+
// Reuse existing element
|
|
775
|
+
updateItemIndex(rendered, index);
|
|
776
|
+
newRenderedMap.set(key, rendered);
|
|
777
|
+
|
|
778
|
+
// Check if element needs to be moved
|
|
779
|
+
const currentNext: ChildNode | null = lastInsertedElement.nextSibling;
|
|
780
|
+
if (currentNext !== rendered.element) {
|
|
781
|
+
// Move element to correct position
|
|
782
|
+
lastInsertedElement.after(rendered.element);
|
|
783
|
+
}
|
|
784
|
+
lastInsertedElement = rendered.element;
|
|
785
|
+
} else {
|
|
786
|
+
// Create new element
|
|
787
|
+
rendered = createItemElement(item, index, key);
|
|
788
|
+
newRenderedMap.set(key, rendered);
|
|
789
|
+
|
|
790
|
+
// Insert at correct position
|
|
791
|
+
lastInsertedElement.after(rendered.element);
|
|
792
|
+
lastInsertedElement = rendered.element;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Update tracking state
|
|
797
|
+
renderedItemsMap = newRenderedMap;
|
|
798
|
+
renderedOrder = newKeys;
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// When the bq-for itself is cleaned up, also cleanup all rendered items
|
|
802
|
+
cleanups.push(() => {
|
|
803
|
+
cleanup();
|
|
804
|
+
for (const rendered of renderedItemsMap.values()) {
|
|
805
|
+
for (const itemCleanup of rendered.cleanups) {
|
|
806
|
+
itemCleanup();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
renderedItemsMap.clear();
|
|
810
|
+
});
|
|
811
|
+
};
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Handles bq-ref directive - element reference.
|
|
816
|
+
* @internal
|
|
817
|
+
*/
|
|
818
|
+
const handleRef: DirectiveHandler = (el, expression, context, cleanups) => {
|
|
819
|
+
const rawValue = evaluateRaw<Signal<Element | null>>(expression, context);
|
|
820
|
+
|
|
821
|
+
if (isSignal(rawValue)) {
|
|
822
|
+
(rawValue as Signal<Element | null>).value = el;
|
|
823
|
+
cleanups.push(() => {
|
|
824
|
+
(rawValue as Signal<Element | null>).value = null;
|
|
825
|
+
});
|
|
826
|
+
} else if (typeof context[expression] === 'object' && context[expression] !== null) {
|
|
827
|
+
// Object with .value property
|
|
828
|
+
(context[expression] as { value: Element | null }).value = el;
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// ============================================================================
|
|
833
|
+
// Core Processing
|
|
834
|
+
// ============================================================================
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Processes a single element for directives.
|
|
838
|
+
* @internal
|
|
839
|
+
*/
|
|
840
|
+
const processElement = (
|
|
841
|
+
el: Element,
|
|
842
|
+
context: BindingContext,
|
|
843
|
+
prefix: string,
|
|
844
|
+
sanitize: boolean,
|
|
845
|
+
cleanups: CleanupFn[]
|
|
846
|
+
): void => {
|
|
847
|
+
const attributes = Array.from(el.attributes);
|
|
848
|
+
|
|
849
|
+
for (const attr of attributes) {
|
|
850
|
+
const { name, value } = attr;
|
|
851
|
+
|
|
852
|
+
if (!name.startsWith(prefix)) continue;
|
|
853
|
+
|
|
854
|
+
const directive = name.slice(prefix.length + 1); // Remove prefix and dash
|
|
855
|
+
|
|
856
|
+
// Handle bq-for specially (creates new scope)
|
|
857
|
+
if (directive === 'for') {
|
|
858
|
+
handleFor(prefix, sanitize)(el, value, context, cleanups);
|
|
859
|
+
return; // Don't process children, bq-for handles it
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Handle other directives
|
|
863
|
+
if (directive === 'text') {
|
|
864
|
+
handleText(el, value, context, cleanups);
|
|
865
|
+
} else if (directive === 'html') {
|
|
866
|
+
handleHtml(sanitize)(el, value, context, cleanups);
|
|
867
|
+
} else if (directive === 'if') {
|
|
868
|
+
handleIf(el, value, context, cleanups);
|
|
869
|
+
} else if (directive === 'show') {
|
|
870
|
+
handleShow(el, value, context, cleanups);
|
|
871
|
+
} else if (directive === 'class') {
|
|
872
|
+
handleClass(el, value, context, cleanups);
|
|
873
|
+
} else if (directive === 'style') {
|
|
874
|
+
handleStyle(el, value, context, cleanups);
|
|
875
|
+
} else if (directive === 'model') {
|
|
876
|
+
handleModel(el, value, context, cleanups);
|
|
877
|
+
} else if (directive === 'ref') {
|
|
878
|
+
handleRef(el, value, context, cleanups);
|
|
879
|
+
} else if (directive.startsWith('bind:')) {
|
|
880
|
+
const attrName = directive.slice(5);
|
|
881
|
+
handleBind(attrName)(el, value, context, cleanups);
|
|
882
|
+
} else if (directive.startsWith('on:')) {
|
|
883
|
+
const eventName = directive.slice(3);
|
|
884
|
+
handleOn(eventName)(el, value, context, cleanups);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Recursively processes children of an element.
|
|
891
|
+
* @internal
|
|
892
|
+
*/
|
|
893
|
+
const processChildren = (
|
|
894
|
+
el: Element,
|
|
895
|
+
context: BindingContext,
|
|
896
|
+
prefix: string,
|
|
897
|
+
sanitize: boolean,
|
|
898
|
+
cleanups: CleanupFn[]
|
|
899
|
+
): void => {
|
|
900
|
+
const children = Array.from(el.children);
|
|
901
|
+
for (const child of children) {
|
|
902
|
+
// Skip if element has bq-for (handled separately)
|
|
903
|
+
if (!child.hasAttribute(`${prefix}-for`)) {
|
|
904
|
+
processElement(child, context, prefix, sanitize, cleanups);
|
|
905
|
+
processChildren(child, context, prefix, sanitize, cleanups);
|
|
906
|
+
} else {
|
|
907
|
+
processElement(child, context, prefix, sanitize, cleanups);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// ============================================================================
|
|
913
|
+
// Public API
|
|
914
|
+
// ============================================================================
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Mounts a reactive view to an element.
|
|
918
|
+
*
|
|
919
|
+
* @param selector - CSS selector or Element
|
|
920
|
+
* @param context - Binding context with signals, computed, and functions
|
|
921
|
+
* @param options - Mount options
|
|
922
|
+
* @returns The mounted View instance
|
|
923
|
+
*
|
|
924
|
+
* @security **WARNING:** Directive expressions (bq-text, bq-if, bq-on, etc.) are evaluated
|
|
925
|
+
* using `new Function()` at runtime. This means:
|
|
926
|
+
* - Template attributes must come from trusted sources only
|
|
927
|
+
* - NEVER load templates containing bq-* attributes from user input or untrusted APIs
|
|
928
|
+
* - If you must use external templates, validate/sanitize attribute values first
|
|
929
|
+
*
|
|
930
|
+
* @example
|
|
931
|
+
* ```ts
|
|
932
|
+
* import { mount } from 'bquery/view';
|
|
933
|
+
* import { signal, computed } from 'bquery/reactive';
|
|
934
|
+
*
|
|
935
|
+
* const name = signal('World');
|
|
936
|
+
* const greeting = computed(() => `Hello, ${name.value}!`);
|
|
937
|
+
* const items = signal([
|
|
938
|
+
* { id: 1, text: 'Item 1' },
|
|
939
|
+
* { id: 2, text: 'Item 2' },
|
|
940
|
+
* ]);
|
|
941
|
+
*
|
|
942
|
+
* const view = mount('#app', {
|
|
943
|
+
* name,
|
|
944
|
+
* greeting,
|
|
945
|
+
* items,
|
|
946
|
+
* addItem: () => {
|
|
947
|
+
* items.value = [...items.value, { id: Date.now(), text: 'New Item' }];
|
|
948
|
+
* },
|
|
949
|
+
* });
|
|
950
|
+
*
|
|
951
|
+
* // Later, cleanup
|
|
952
|
+
* view.destroy();
|
|
953
|
+
* ```
|
|
954
|
+
*/
|
|
955
|
+
export const mount = (
|
|
956
|
+
selector: string | Element,
|
|
957
|
+
context: BindingContext,
|
|
958
|
+
options: MountOptions = {}
|
|
959
|
+
): View => {
|
|
960
|
+
const { prefix = 'bq', sanitize = true } = options;
|
|
961
|
+
|
|
962
|
+
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
|
963
|
+
|
|
964
|
+
if (!el) {
|
|
965
|
+
throw new Error(`bQuery view: Element "${selector}" not found.`);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const cleanups: CleanupFn[] = [];
|
|
969
|
+
|
|
970
|
+
// Process the root element and its children
|
|
971
|
+
processElement(el, context, prefix, sanitize, cleanups);
|
|
972
|
+
processChildren(el, context, prefix, sanitize, cleanups);
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
el,
|
|
976
|
+
context,
|
|
977
|
+
|
|
978
|
+
update: (newContext: Partial<BindingContext>) => {
|
|
979
|
+
Object.assign(context, newContext);
|
|
980
|
+
},
|
|
981
|
+
|
|
982
|
+
destroy: () => {
|
|
983
|
+
for (const cleanup of cleanups) {
|
|
984
|
+
cleanup();
|
|
985
|
+
}
|
|
986
|
+
cleanups.length = 0;
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Creates a reactive template function.
|
|
993
|
+
*
|
|
994
|
+
* @param template - HTML template string
|
|
995
|
+
* @returns A function that creates a mounted element with the given context
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* ```ts
|
|
999
|
+
* import { createTemplate } from 'bquery/view';
|
|
1000
|
+
* import { signal } from 'bquery/reactive';
|
|
1001
|
+
*
|
|
1002
|
+
* const TodoItem = createTemplate(`
|
|
1003
|
+
* <li bq-class="{ completed: done }">
|
|
1004
|
+
* <input type="checkbox" bq-model="done" />
|
|
1005
|
+
* <span bq-text="text"></span>
|
|
1006
|
+
* </li>
|
|
1007
|
+
* `);
|
|
1008
|
+
*
|
|
1009
|
+
* const item = TodoItem({
|
|
1010
|
+
* done: signal(false),
|
|
1011
|
+
* text: 'Buy groceries',
|
|
1012
|
+
* });
|
|
1013
|
+
*
|
|
1014
|
+
* document.querySelector('#list').append(item.el);
|
|
1015
|
+
* ```
|
|
1016
|
+
*/
|
|
1017
|
+
export const createTemplate = (
|
|
1018
|
+
template: string,
|
|
1019
|
+
options: MountOptions = {}
|
|
1020
|
+
): ((context: BindingContext) => View) => {
|
|
1021
|
+
return (context: BindingContext) => {
|
|
1022
|
+
const container = document.createElement('div');
|
|
1023
|
+
container.innerHTML = template.trim();
|
|
1024
|
+
|
|
1025
|
+
const el = container.firstElementChild;
|
|
1026
|
+
if (!el) {
|
|
1027
|
+
throw new Error('bQuery view: Template must contain a single root element.');
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return mount(el, context, options);
|
|
1031
|
+
};
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// ============================================================================
|
|
1035
|
+
// Utility Exports
|
|
1036
|
+
// ============================================================================
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Re-export reactive primitives for convenience.
|
|
1040
|
+
*/
|
|
1041
|
+
export { batch, computed, effect, signal } from '../reactive/index';
|