@bquery/bquery 1.5.0 → 1.6.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 (106) hide show
  1. package/README.md +586 -546
  2. package/dist/component/component.d.ts +13 -5
  3. package/dist/component/component.d.ts.map +1 -1
  4. package/dist/component/html.d.ts +40 -3
  5. package/dist/component/html.d.ts.map +1 -1
  6. package/dist/component/index.d.ts +2 -2
  7. package/dist/component/index.d.ts.map +1 -1
  8. package/dist/component/library.d.ts.map +1 -1
  9. package/dist/component/types.d.ts +131 -16
  10. package/dist/component/types.d.ts.map +1 -1
  11. package/dist/component-BEQgt5hl.js +600 -0
  12. package/dist/component-BEQgt5hl.js.map +1 -0
  13. package/dist/component.es.mjs +7 -6
  14. package/dist/config-DRmZZno3.js.map +1 -1
  15. package/dist/core-BGQJVw0-.js +35 -0
  16. package/dist/core-BGQJVw0-.js.map +1 -0
  17. package/dist/{core-CK2Mfpf4.js → core-CCEabVHl.js} +2 -2
  18. package/dist/{core-CK2Mfpf4.js.map → core-CCEabVHl.js.map} +1 -1
  19. package/dist/core.es.mjs +1 -1
  20. package/dist/effect-AFRW_Plg.js +84 -0
  21. package/dist/effect-AFRW_Plg.js.map +1 -0
  22. package/dist/full.d.ts +4 -4
  23. package/dist/full.d.ts.map +1 -1
  24. package/dist/full.es.mjs +98 -94
  25. package/dist/full.iife.js +14 -14
  26. package/dist/full.iife.js.map +1 -1
  27. package/dist/full.umd.js +14 -14
  28. package/dist/full.umd.js.map +1 -1
  29. package/dist/index.es.mjs +143 -139
  30. package/dist/{motion-C5DRdPnO.js → motion-D9TcHxOF.js} +1 -1
  31. package/dist/{motion-C5DRdPnO.js.map → motion-D9TcHxOF.js.map} +1 -1
  32. package/dist/motion.es.mjs +1 -1
  33. package/dist/{platform-B7JhGBc7.js → platform-Dr9b6fsq.js} +21 -20
  34. package/dist/platform-Dr9b6fsq.js.map +1 -0
  35. package/dist/platform.es.mjs +1 -1
  36. package/dist/{reactive-BDya-ia8.js → reactive-DSkct0dO.js} +51 -50
  37. package/dist/reactive-DSkct0dO.js.map +1 -0
  38. package/dist/reactive.es.mjs +19 -17
  39. package/dist/{router-CijiICxt.js → router-CbDhl8rS.js} +3 -3
  40. package/dist/{router-CijiICxt.js.map → router-CbDhl8rS.js.map} +1 -1
  41. package/dist/router.es.mjs +1 -1
  42. package/dist/{sanitize-jyJ2ryE2.js → sanitize-Bs2dkMby.js} +94 -83
  43. package/dist/sanitize-Bs2dkMby.js.map +1 -0
  44. package/dist/security/index.d.ts +4 -2
  45. package/dist/security/index.d.ts.map +1 -1
  46. package/dist/security/sanitize.d.ts +4 -1
  47. package/dist/security/sanitize.d.ts.map +1 -1
  48. package/dist/security/trusted-html.d.ts +53 -0
  49. package/dist/security/trusted-html.d.ts.map +1 -0
  50. package/dist/security.es.mjs +10 -9
  51. package/dist/store/define-store.d.ts +1 -1
  52. package/dist/store/define-store.d.ts.map +1 -1
  53. package/dist/store/mapping.d.ts +1 -1
  54. package/dist/store/mapping.d.ts.map +1 -1
  55. package/dist/store/persisted.d.ts +1 -1
  56. package/dist/store/persisted.d.ts.map +1 -1
  57. package/dist/store/types.d.ts +2 -2
  58. package/dist/store/types.d.ts.map +1 -1
  59. package/dist/store/watch.d.ts +1 -1
  60. package/dist/store/watch.d.ts.map +1 -1
  61. package/dist/{store-CPK9E62U.js → store-BwDvI45q.js} +49 -48
  62. package/dist/{store-CPK9E62U.js.map → store-BwDvI45q.js.map} +1 -1
  63. package/dist/store.es.mjs +1 -1
  64. package/dist/storybook/index.d.ts +37 -0
  65. package/dist/storybook/index.d.ts.map +1 -0
  66. package/dist/storybook.es.mjs +151 -0
  67. package/dist/storybook.es.mjs.map +1 -0
  68. package/dist/untrack-B0rVscTc.js +7 -0
  69. package/dist/untrack-B0rVscTc.js.map +1 -0
  70. package/dist/{view-Cdi0g-qo.js → view-C70lA3vf.js} +29 -28
  71. package/dist/{view-Cdi0g-qo.js.map → view-C70lA3vf.js.map} +1 -1
  72. package/dist/view.es.mjs +9 -8
  73. package/package.json +141 -136
  74. package/src/component/component.ts +259 -54
  75. package/src/component/html.ts +153 -53
  76. package/src/component/index.ts +10 -2
  77. package/src/component/library.ts +42 -28
  78. package/src/component/types.ts +184 -19
  79. package/src/full.ts +8 -2
  80. package/src/motion/transition.ts +97 -97
  81. package/src/motion/types.ts +208 -208
  82. package/src/platform/announcer.ts +208 -208
  83. package/src/platform/config.ts +163 -163
  84. package/src/platform/cookies.ts +165 -165
  85. package/src/platform/index.ts +39 -39
  86. package/src/platform/meta.ts +168 -168
  87. package/src/reactive/async-data.ts +486 -486
  88. package/src/reactive/index.ts +37 -37
  89. package/src/reactive/signal.ts +29 -29
  90. package/src/security/constants.ts +211 -211
  91. package/src/security/index.ts +17 -10
  92. package/src/security/sanitize.ts +70 -66
  93. package/src/security/trusted-html.ts +71 -0
  94. package/src/store/define-store.ts +49 -48
  95. package/src/store/mapping.ts +74 -73
  96. package/src/store/persisted.ts +62 -61
  97. package/src/store/types.ts +92 -94
  98. package/src/store/watch.ts +53 -52
  99. package/src/storybook/index.ts +479 -0
  100. package/dist/component-CY5MVoYN.js +0 -531
  101. package/dist/component-CY5MVoYN.js.map +0 -1
  102. package/dist/core-DPdbItcq.js +0 -112
  103. package/dist/core-DPdbItcq.js.map +0 -1
  104. package/dist/platform-B7JhGBc7.js.map +0 -1
  105. package/dist/reactive-BDya-ia8.js.map +0 -1
  106. package/dist/sanitize-jyJ2ryE2.js.map +0 -1
@@ -5,8 +5,55 @@
5
5
  */
6
6
 
7
7
  import { sanitizeHtml } from '../security/sanitize';
8
+ import { effect, untrack } from '../reactive/signal';
9
+ import type { CleanupFn } from '../reactive/signal';
8
10
  import { coercePropValue } from './props';
9
- import type { ComponentDefinition, PropDefinition } from './types';
11
+ import type {
12
+ AttributeChange,
13
+ ComponentClass,
14
+ ComponentDefinition,
15
+ ComponentSignalLike,
16
+ ComponentSignals,
17
+ ComponentStateShape,
18
+ PropDefinition,
19
+ } from './types';
20
+
21
+ /**
22
+ * Base extra tags preserved for component shadow DOM renders in addition to the
23
+ * global sanitizer defaults. `slot` must remain allowed here because shadow DOM
24
+ * content projection depends on authored `<slot>` elements in component render
25
+ * output.
26
+ */
27
+ const COMPONENT_ALLOWED_TAGS = ['slot'];
28
+
29
+ /**
30
+ * Base extra attributes preserved for component shadow DOM renders in addition
31
+ * to the global sanitizer defaults.
32
+ */
33
+ const COMPONENT_ALLOWED_ATTRIBUTES = [
34
+ 'part',
35
+ // Standard form attributes required by interactive shadow DOM content
36
+ 'disabled',
37
+ 'checked',
38
+ 'placeholder',
39
+ 'value',
40
+ 'rows',
41
+ 'cols',
42
+ 'readonly',
43
+ 'required',
44
+ 'maxlength',
45
+ 'minlength',
46
+ 'max',
47
+ 'min',
48
+ 'step',
49
+ 'pattern',
50
+ 'autocomplete',
51
+ 'autofocus',
52
+ 'for',
53
+ 'multiple',
54
+ 'selected',
55
+ 'wrap',
56
+ ];
10
57
 
11
58
  /**
12
59
  * Creates a custom element class for a component definition.
@@ -18,19 +65,34 @@ import type { ComponentDefinition, PropDefinition } from './types';
18
65
  * @param tagName - The custom element tag name (used for diagnostics)
19
66
  * @param definition - The component configuration
20
67
  */
21
- export const defineComponent = <TProps extends Record<string, unknown>>(
68
+ const createComponentClass = <
69
+ TProps extends Record<string, unknown>,
70
+ TState extends Record<string, unknown> | undefined = undefined,
71
+ TSignals extends ComponentSignals = Record<string, never>,
72
+ >(
22
73
  tagName: string,
23
- definition: ComponentDefinition<TProps>
24
- ): typeof HTMLElement => {
74
+ definition: ComponentDefinition<TProps, TState, TSignals>
75
+ ): ComponentClass<TState> => {
76
+ const componentAllowedTags = [...COMPONENT_ALLOWED_TAGS, ...(definition.sanitize?.allowTags ?? [])];
77
+ const componentAllowedAttributes = [
78
+ ...COMPONENT_ALLOWED_ATTRIBUTES,
79
+ ...(definition.sanitize?.allowAttributes ?? []),
80
+ ];
81
+ const signalSources = Object.values(definition.signals ?? {}) as ComponentSignalLike<unknown>[];
82
+
25
83
  class BQueryComponent extends HTMLElement {
26
84
  /** Internal state object for the component */
27
- private readonly state = { ...(definition.state ?? {}) };
85
+ private readonly state: ComponentStateShape<TState> = {
86
+ ...(definition.state ?? {}),
87
+ } as ComponentStateShape<TState>;
28
88
  /** Typed props object populated from attributes */
29
89
  private props = {} as TProps;
30
90
  /** Tracks missing required props for validation during connectedCallback */
31
91
  private missingRequiredProps = new Set<string>();
32
92
  /** Tracks whether the component has completed its initial mount */
33
93
  private hasMounted = false;
94
+ /** Cleanup for external signal subscriptions */
95
+ private signalEffectCleanup?: CleanupFn;
34
96
 
35
97
  constructor() {
36
98
  super();
@@ -50,13 +112,23 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
50
112
  */
51
113
  connectedCallback(): void {
52
114
  try {
53
- // Defer initial render until all required props are present
54
- // This allows attributes to be set after element creation
55
- if (this.missingRequiredProps.size > 0) {
115
+ // Defer only the initial mount until all required props are present.
116
+ // Already-mounted components must still reconnect their signal
117
+ // subscriptions so reactive updates can resume after reattachment.
118
+ if (!this.hasMounted && this.missingRequiredProps.size > 0) {
56
119
  // Component will mount once all required props are satisfied
57
120
  // via attributeChangedCallback
58
121
  return;
59
122
  }
123
+ if (this.hasMounted) {
124
+ try {
125
+ definition.connected?.call(this);
126
+ } catch (error) {
127
+ this.handleError(error as Error);
128
+ }
129
+ this.setupSignalSubscriptions(true);
130
+ return;
131
+ }
60
132
  this.mount();
61
133
  } catch (error) {
62
134
  this.handleError(error as Error);
@@ -73,6 +145,7 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
73
145
  definition.beforeMount?.call(this);
74
146
  definition.connected?.call(this);
75
147
  this.render();
148
+ this.setupSignalSubscriptions();
76
149
  this.hasMounted = true;
77
150
  }
78
151
 
@@ -81,6 +154,8 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
81
154
  */
82
155
  disconnectedCallback(): void {
83
156
  try {
157
+ this.signalEffectCleanup?.();
158
+ this.signalEffectCleanup = undefined;
84
159
  definition.disconnected?.call(this);
85
160
  } catch (error) {
86
161
  this.handleError(error as Error);
@@ -91,16 +166,17 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
91
166
  * Called when an observed attribute changes.
92
167
  */
93
168
  attributeChangedCallback(
94
- _name: string,
95
- _oldValue: string | null,
96
- _newValue: string | null
169
+ name: string,
170
+ oldValue: string | null,
171
+ newValue: string | null
97
172
  ): void {
98
173
  try {
174
+ const previousProps = this.cloneProps();
99
175
  this.syncProps();
100
176
 
101
177
  if (this.hasMounted) {
102
178
  // Component already mounted - trigger update render
103
- this.render(true);
179
+ this.render(true, previousProps, { name, oldValue, newValue });
104
180
  } else if (this.isConnected && this.missingRequiredProps.size === 0) {
105
181
  // All required props are now satisfied and element is connected
106
182
  // Trigger the deferred initial mount
@@ -129,9 +205,12 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
129
205
  * @param key - The state property key
130
206
  * @param value - The new value
131
207
  */
132
- setState(key: string, value: unknown): void {
208
+ setState<TKey extends keyof ComponentStateShape<TState>>(
209
+ key: TKey,
210
+ value: ComponentStateShape<TState>[TKey]
211
+ ): void {
133
212
  this.state[key] = value;
134
- this.render(true);
213
+ this.render(true, this.cloneProps(), undefined, false);
135
214
  }
136
215
 
137
216
  /**
@@ -140,8 +219,58 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
140
219
  * @param key - The state property key
141
220
  * @returns The current value
142
221
  */
143
- getState<T = unknown>(key: string): T {
144
- return this.state[key] as T;
222
+ getState<TKey extends keyof ComponentStateShape<TState>>(
223
+ key: TKey
224
+ ): ComponentStateShape<TState>[TKey];
225
+ getState<TResult = unknown>(key: string): TResult;
226
+ getState(key: string): unknown {
227
+ return (this.state as Record<string, unknown>)[key];
228
+ }
229
+
230
+ /**
231
+ * Subscribes to declared reactive sources and re-renders on change.
232
+ *
233
+ * @param renderOnInitialRun - When true, immediately re-renders after
234
+ * re-subscribing so detached components resync with any signal changes
235
+ * that happened while they were disconnected.
236
+ * @internal
237
+ */
238
+ private setupSignalSubscriptions(renderOnInitialRun = false): void {
239
+ if (this.signalEffectCleanup || signalSources.length === 0) return;
240
+
241
+ let isInitialRun = true;
242
+ this.signalEffectCleanup = effect(() => {
243
+ try {
244
+ for (const source of signalSources) {
245
+ // Intentionally read each source to register this effect as a subscriber.
246
+ void source.value;
247
+ }
248
+
249
+ if (isInitialRun) {
250
+ isInitialRun = false;
251
+ if (renderOnInitialRun && this.hasMounted && this.isConnected) {
252
+ // Signal-driven reconnect renders do not change props, so the
253
+ // previous-props snapshot is the current prop set at reconnect time.
254
+ const previousProps = this.cloneProps();
255
+ untrack(() => {
256
+ this.render(true, previousProps, undefined, false);
257
+ });
258
+ }
259
+ return;
260
+ }
261
+
262
+ if (!this.hasMounted || !this.isConnected) return;
263
+
264
+ // Signal updates leave props unchanged, so cloning the current props
265
+ // provides the previous-props snapshot expected by beforeUpdate().
266
+ const previousProps = this.cloneProps();
267
+ untrack(() => {
268
+ this.render(true, previousProps, undefined, false);
269
+ });
270
+ } catch (error) {
271
+ this.handleError(error as Error);
272
+ }
273
+ });
145
274
  }
146
275
 
147
276
  /**
@@ -183,14 +312,41 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
183
312
  }
184
313
  }
185
314
 
315
+ /**
316
+ * Creates a shallow snapshot of the current props for lifecycle diffing.
317
+ * A shallow copy is sufficient because component props are re-derived from
318
+ * reflected attributes on each update, so nested object mutation is not
319
+ * tracked as part of this lifecycle diff.
320
+ * @internal
321
+ */
322
+ private cloneProps(): TProps {
323
+ return { ...(this.props as Record<string, unknown>) } as TProps;
324
+ }
325
+
186
326
  /**
187
327
  * Renders the component to its shadow root.
188
328
  * @internal
189
329
  */
190
- private render(triggerUpdated = false): void {
330
+ private render(): void;
331
+ private render(triggerUpdated: true, oldProps: TProps, change?: AttributeChange): void;
332
+ private render(
333
+ triggerUpdated: true,
334
+ oldProps: TProps,
335
+ change: AttributeChange | undefined,
336
+ runBeforeUpdate: boolean
337
+ ): void;
338
+ private render(
339
+ triggerUpdated = false,
340
+ oldProps?: TProps,
341
+ change?: AttributeChange,
342
+ runBeforeUpdate = true
343
+ ): void {
191
344
  try {
192
- if (triggerUpdated && definition.beforeUpdate) {
193
- const shouldUpdate = definition.beforeUpdate.call(this, this.props);
345
+ if (triggerUpdated && runBeforeUpdate && definition.beforeUpdate) {
346
+ if (!oldProps) {
347
+ throw new Error('bQuery component: previous props are required for update renders');
348
+ }
349
+ const shouldUpdate = definition.beforeUpdate.call(this, this.props, oldProps);
194
350
  if (shouldUpdate === false) return;
195
351
  }
196
352
 
@@ -203,6 +359,7 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
203
359
  const markup = definition.render({
204
360
  props: this.props,
205
361
  state: this.state,
362
+ signals: (definition.signals ?? {}) as TSignals,
206
363
  emit,
207
364
  });
208
365
 
@@ -211,42 +368,29 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
211
368
  // the stylistic `part` attribute, and standard form/input attributes without
212
369
  // relaxing the global DOM sanitization rules.
213
370
  const sanitizedMarkup = sanitizeHtml(markup, {
214
- allowTags: ['slot'],
215
- allowAttributes: [
216
- 'part',
217
- // Standard form attributes required by interactive shadow DOM content
218
- 'disabled',
219
- 'checked',
220
- 'placeholder',
221
- 'value',
222
- 'rows',
223
- 'cols',
224
- 'readonly',
225
- 'required',
226
- 'maxlength',
227
- 'minlength',
228
- 'max',
229
- 'min',
230
- 'step',
231
- 'pattern',
232
- 'autocomplete',
233
- 'autofocus',
234
- 'for',
235
- 'multiple',
236
- 'selected',
237
- 'wrap',
238
- ],
371
+ allowTags: componentAllowedTags,
372
+ allowAttributes: componentAllowedAttributes,
239
373
  });
374
+ let existingStyleElement: HTMLStyleElement | null = null;
375
+ if (definition.styles) {
376
+ existingStyleElement = this.shadowRoot.querySelector<HTMLStyleElement>(
377
+ 'style[data-bquery-component-style]'
378
+ );
379
+ }
380
+
240
381
  this.shadowRoot.innerHTML = sanitizedMarkup;
241
382
 
242
383
  if (definition.styles) {
243
- const styleElement = document.createElement('style');
384
+ const styleElement = existingStyleElement ?? document.createElement('style');
385
+ if (!existingStyleElement) {
386
+ styleElement.setAttribute('data-bquery-component-style', '');
387
+ }
244
388
  styleElement.textContent = definition.styles;
245
389
  this.shadowRoot.prepend(styleElement);
246
390
  }
247
391
 
248
392
  if (triggerUpdated) {
249
- definition.updated?.call(this);
393
+ definition.updated?.call(this, change);
250
394
  }
251
395
  } catch (error) {
252
396
  this.handleError(error as Error);
@@ -254,9 +398,48 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
254
398
  }
255
399
  }
256
400
 
257
- return BQueryComponent;
401
+ return BQueryComponent as ComponentClass<TState>;
258
402
  };
259
403
 
404
+ /**
405
+ * Creates a custom element class for a component definition.
406
+ *
407
+ * This is useful when you want to extend or register the class manually
408
+ * (e.g. with different tag names in tests or custom registries).
409
+ *
410
+ * @template TProps - Type of the component's props
411
+ * @template TState - Type of the component's internal state. When provided,
412
+ * `definition.state` is required, `render({ state })` is strongly typed, and
413
+ * returned instances expose typed `getState()` / `setState()` helpers.
414
+ * @param tagName - The custom element tag name (used for diagnostics)
415
+ * @param definition - The component configuration
416
+ */
417
+ export function defineComponent<
418
+ TProps extends Record<string, unknown>,
419
+ TSignals extends ComponentSignals = Record<string, never>,
420
+ >(
421
+ tagName: string,
422
+ definition: ComponentDefinition<TProps, undefined, TSignals>
423
+ ): ComponentClass<undefined>;
424
+ export function defineComponent<
425
+ TProps extends Record<string, unknown>,
426
+ TState extends Record<string, unknown>,
427
+ TSignals extends ComponentSignals = Record<string, never>,
428
+ >(
429
+ tagName: string,
430
+ definition: ComponentDefinition<TProps, TState, TSignals>
431
+ ): ComponentClass<TState>;
432
+ export function defineComponent<
433
+ TProps extends Record<string, unknown>,
434
+ TState extends Record<string, unknown> | undefined = undefined,
435
+ TSignals extends ComponentSignals = Record<string, never>,
436
+ >(
437
+ tagName: string,
438
+ definition: ComponentDefinition<TProps, TState, TSignals>
439
+ ): ComponentClass<TState> {
440
+ return createComponentClass(tagName, definition);
441
+ }
442
+
260
443
  /**
261
444
  * Defines and registers a custom Web Component.
262
445
  *
@@ -265,12 +448,15 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
265
448
  * and automatically re-renders when observed attributes change.
266
449
  *
267
450
  * @template TProps - Type of the component's props
451
+ * @template TState - Type of the component's internal state. When provided,
452
+ * `definition.state` is required and lifecycle hooks receive typed state
453
+ * helpers via `this.getState()` / `this.setState()`.
268
454
  * @param tagName - The custom element tag name (must contain a hyphen)
269
455
  * @param definition - The component configuration
270
456
  *
271
457
  * @example
272
458
  * ```ts
273
- * component('counter-button', {
459
+ * component<{ start: number }, { count: number }>('counter-button', {
274
460
  * props: {
275
461
  * start: { type: Number, default: 0 },
276
462
  * },
@@ -283,7 +469,7 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
283
469
  * const handleClick = (event: Event) => {
284
470
  * const target = event.target as HTMLElement | null;
285
471
  * if (target?.matches('button')) {
286
- * this.setState('count', (this.getState('count') as number) + 1);
472
+ * this.setState('count', this.getState('count') + 1);
287
473
  * }
288
474
  * };
289
475
  * this.shadowRoot?.addEventListener('click', handleClick);
@@ -307,13 +493,32 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
307
493
  * });
308
494
  * ```
309
495
  */
310
- export const component = <TProps extends Record<string, unknown>>(
496
+ export function component<
497
+ TProps extends Record<string, unknown>,
498
+ TSignals extends ComponentSignals = Record<string, never>,
499
+ >(
311
500
  tagName: string,
312
- definition: ComponentDefinition<TProps>
313
- ): void => {
314
- const elementClass = defineComponent(tagName, definition);
501
+ definition: ComponentDefinition<TProps, undefined, TSignals>
502
+ ): void;
503
+ export function component<
504
+ TProps extends Record<string, unknown>,
505
+ TState extends Record<string, unknown>,
506
+ TSignals extends ComponentSignals = Record<string, never>,
507
+ >(
508
+ tagName: string,
509
+ definition: ComponentDefinition<TProps, TState, TSignals>
510
+ ): void;
511
+ export function component<
512
+ TProps extends Record<string, unknown>,
513
+ TState extends Record<string, unknown> | undefined = undefined,
514
+ TSignals extends ComponentSignals = Record<string, never>,
515
+ >(
516
+ tagName: string,
517
+ definition: ComponentDefinition<TProps, TState, TSignals>
518
+ ): void {
519
+ const elementClass = createComponentClass(tagName, definition);
315
520
 
316
521
  if (!customElements.get(tagName)) {
317
522
  customElements.define(tagName, elementClass);
318
523
  }
319
- };
524
+ }
@@ -1,53 +1,153 @@
1
- /**
2
- * Tagged template literal for creating HTML strings.
3
- *
4
- * This function handles interpolation of values into HTML templates,
5
- * converting null/undefined to empty strings.
6
- *
7
- * @param strings - Template literal string parts
8
- * @param values - Interpolated values
9
- * @returns Combined HTML string
10
- *
11
- * @example
12
- * ```ts
13
- * const name = 'World';
14
- * const greeting = html`<h1>Hello, ${name}!</h1>`;
15
- * // Result: '<h1>Hello, World!</h1>'
16
- * ```
17
- */
18
- export const html = (strings: TemplateStringsArray, ...values: unknown[]): string => {
19
- return strings.reduce((acc, part, index) => `${acc}${part}${values[index] ?? ''}`, '');
20
- };
21
-
22
- /**
23
- * Escapes HTML entities in interpolated values for XSS prevention.
24
- * Use this when you need to safely embed user content in templates.
25
- *
26
- * @param strings - Template literal string parts
27
- * @param values - Interpolated values to escape
28
- * @returns Combined HTML string with escaped values
29
- *
30
- * @example
31
- * ```ts
32
- * const userInput = '<script>alert("xss")</script>';
33
- * const safe = safeHtml`<div>${userInput}</div>`;
34
- * // Result: '<div>&lt;script&gt;alert("xss")&lt;/script&gt;</div>'
35
- * ```
36
- */
37
- export const safeHtml = (strings: TemplateStringsArray, ...values: unknown[]): string => {
38
- const escapeMap: Record<string, string> = {
39
- '&': '&amp;',
40
- '<': '&lt;',
41
- '>': '&gt;',
42
- '"': '&quot;',
43
- "'": '&#x27;',
44
- '`': '&#x60;',
45
- };
46
-
47
- const escape = (value: unknown): string => {
48
- const str = String(value ?? '');
49
- return str.replace(/[&<>"'`]/g, (char) => escapeMap[char]);
50
- };
51
-
52
- return strings.reduce((acc, part, index) => `${acc}${part}${escape(values[index])}`, '');
53
- };
1
+ import {
2
+ isTrustedHtml,
3
+ type SanitizedHtml,
4
+ toSanitizedHtml,
5
+ unwrapTrustedHtml,
6
+ } from '../security/trusted-html';
7
+ const BOOLEAN_ATTRIBUTE_MARKER: unique symbol = Symbol('bquery.booleanAttribute');
8
+ const BOOLEAN_ATTRIBUTE_NAME = /^[^\0-\x20"'/>=]+$/;
9
+
10
+ /**
11
+ * Public shape of a boolean HTML attribute created by {@link bool}.
12
+ *
13
+ * This type is returned from {@link bool} and can be interpolated into
14
+ * {@link html} / {@link safeHtml} templates to conditionally include or omit
15
+ * an attribute by name. The internal marker property used for runtime checks
16
+ * remains private and is not part of the public API.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const disabled = bool('disabled', isDisabled);
21
+ * const button = html`<button ${disabled}>Click</button>`;
22
+ * ```
23
+ */
24
+ export interface BooleanAttribute {
25
+ readonly enabled: boolean;
26
+ readonly name: string;
27
+ }
28
+
29
+ interface BooleanAttributeValue extends BooleanAttribute {
30
+ readonly [BOOLEAN_ATTRIBUTE_MARKER]: true;
31
+ }
32
+
33
+ const isBooleanAttributeValue = (value: unknown): value is BooleanAttributeValue => {
34
+ if (typeof value !== 'object' || value === null) {
35
+ return false;
36
+ }
37
+
38
+ const candidate = value as Partial<BooleanAttributeValue>;
39
+ return (
40
+ candidate[BOOLEAN_ATTRIBUTE_MARKER] === true &&
41
+ typeof candidate.enabled === 'boolean' &&
42
+ typeof candidate.name === 'string'
43
+ );
44
+ };
45
+
46
+ const stringifyTemplateValue = (value: unknown): string => {
47
+ if (isBooleanAttributeValue(value)) {
48
+ return value.enabled ? value.name : '';
49
+ }
50
+
51
+ return String(value ?? '');
52
+ };
53
+
54
+ const escapeMap: Record<string, string> = {
55
+ '&': '&amp;',
56
+ '<': '&lt;',
57
+ '>': '&gt;',
58
+ '"': '&quot;',
59
+ "'": '&#x27;',
60
+ '`': '&#x60;',
61
+ };
62
+
63
+ const escapeTemplateValue = (value: unknown): string => {
64
+ if (isBooleanAttributeValue(value)) {
65
+ return value.enabled ? value.name : '';
66
+ }
67
+
68
+ return stringifyTemplateValue(value).replace(/[&<>"'`]/g, (char) => escapeMap[char]);
69
+ };
70
+
71
+ /**
72
+ * Creates a boolean-attribute marker for the {@link html} and {@link safeHtml} template tags.
73
+ *
74
+ * When the condition is truthy, the attribute name is rendered without a value.
75
+ * When the condition is falsy, an empty string is rendered and any surrounding
76
+ * template-literal whitespace is preserved.
77
+ *
78
+ * @param name - HTML attribute name to emit
79
+ * @param enabled - Whether the boolean attribute should be present
80
+ * @returns Internal marker consumed by template tags
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * html`<button ${bool('disabled', isDisabled)}>Save</button>`;
85
+ * // Result when isDisabled = true: '<button disabled>Save</button>'
86
+ * ```
87
+ */
88
+ export const bool = (name: string, enabled: unknown): BooleanAttribute => {
89
+ if (!BOOLEAN_ATTRIBUTE_NAME.test(name)) {
90
+ throw new TypeError(`Invalid boolean attribute name: ${name}`);
91
+ }
92
+
93
+ const attribute: BooleanAttributeValue = {
94
+ [BOOLEAN_ATTRIBUTE_MARKER]: true,
95
+ enabled: Boolean(enabled),
96
+ name,
97
+ };
98
+
99
+ return Object.freeze(attribute);
100
+ };
101
+
102
+ /**
103
+ * Tagged template literal for creating HTML strings.
104
+ *
105
+ * This function handles interpolation of values into HTML templates,
106
+ * converting null/undefined to empty strings.
107
+ *
108
+ * @param strings - Template literal string parts
109
+ * @param values - Interpolated values
110
+ * @returns Combined HTML string
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const name = 'World';
115
+ * const greeting = html`<h1>Hello, ${name}!</h1>`;
116
+ * // Result: '<h1>Hello, World!</h1>'
117
+ * ```
118
+ */
119
+ export const html = (strings: TemplateStringsArray, ...values: unknown[]): string => {
120
+ return strings.reduce((acc, part, index) => `${acc}${part}${stringifyTemplateValue(values[index])}`, '');
121
+ };
122
+
123
+ /**
124
+ * Escapes HTML entities in interpolated values for XSS prevention.
125
+ * Use this when you need to safely embed user content in templates.
126
+ *
127
+ * @param strings - Template literal string parts
128
+ * @param values - Interpolated values to escape
129
+ * @returns Branded escaped HTML string safe for bQuery template composition
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * const userInput = '<script>alert("xss")</script>';
134
+ * const safe = safeHtml`<div>${userInput}</div>`;
135
+ * // Result: '<div>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</div>'
136
+ * ```
137
+ */
138
+ export const safeHtml = (
139
+ strings: TemplateStringsArray,
140
+ ...values: unknown[]
141
+ ): SanitizedHtml => {
142
+ const escape = (value: unknown): string => {
143
+ if (isTrustedHtml(value)) return unwrapTrustedHtml(value);
144
+ return escapeTemplateValue(value);
145
+ };
146
+
147
+ return toSanitizedHtml(
148
+ strings.reduce(
149
+ (acc, part, index) => `${acc}${part}${index < values.length ? escape(values[index]) : ''}`,
150
+ ''
151
+ )
152
+ );
153
+ };
@@ -36,7 +36,15 @@
36
36
  */
37
37
 
38
38
  export { component, defineComponent } from './component';
39
- export { html, safeHtml } from './html';
39
+ export { bool, html, safeHtml } from './html';
40
40
  export { registerDefaultComponents } from './library';
41
41
  export type { DefaultComponentLibraryOptions, RegisteredDefaultComponents } from './library';
42
- export type { ComponentDefinition, ComponentRenderContext, PropDefinition } from './types';
42
+ export type {
43
+ AttributeChange,
44
+ ComponentDefinition,
45
+ ComponentRenderContext,
46
+ ComponentStateKey,
47
+ ComponentSignalLike,
48
+ ComponentSignals,
49
+ PropDefinition,
50
+ } from './types';