@wippy-fe/webcomponent-core 0.0.34 → 0.0.36

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.
@@ -1,399 +0,0 @@
1
- import type { ReactiveAdapter } from './reactive-adapter.ts'
2
- import type { WippyElementConfig } from './types.ts'
3
- import { addCollection } from '@iconify/vue'
4
- import { addIcons, define } from '@wippy-fe/proxy'
5
- import { injectInlineCss, loadHostCss } from './css-loader.ts'
6
- import { getWippyHost } from './host-scope.ts'
7
- import { parseProps } from './prop-parser.ts'
8
- import { ReactiveAdapterImpl } from './reactive-adapter.ts'
9
-
10
- /**
11
- * Abstract base class for Wippy web components.
12
- *
13
- * Generic over `Props` — the parsed prop object type. This types `onMount`,
14
- * `onPropsChanged`, and `validateProps`. Defaults to `Record<string, unknown>`.
15
- *
16
- * Lifecycle order (first connect):
17
- * 1. shadow DOM attached
18
- * 2. `onInit(shadow)` — hook for early shadow DOM customization
19
- * 3. inline CSS injected
20
- * 4. host CSS loading started (async, non-blocking)
21
- * 5. container div created and appended
22
- * 6. icons registered
23
- * 7. props parsed from attributes + custom validation
24
- * 8. `onMount(shadow, container, props, errors)` — framework setup
25
- * 9. internals state → ready
26
- * 10. `onReady()` — post-mount hook
27
- * 11. `load` event emitted
28
- *
29
- * On reconnect (move-style DOM op: detach → reattach), the shadow root
30
- * persists across the cycle, so steps 1, 3, 4, 5 (CSS injection +
31
- * container creation) are skipped to avoid duplicating `<style>` and
32
- * container `<div>` siblings inside the persistent shadow. The stored
33
- * container is reused and `onMount` is re-invoked with `isRemount = true`
34
- * so framework adapters can decide between fresh-mount and remount paths.
35
- *
36
- * On error: `onError(error)` → internals state → error → `error` event
37
- * On disconnect: `onUnmount()` → `unload` event → internals cleared.
38
- * Note: `disconnectedCallback` does NOT clear the shadow root or the
39
- * stored container — they're reused on reconnect.
40
- */
41
- export abstract class WippyElement<
42
- Props = Record<string, unknown>,
43
- Events = Record<string, unknown>,
44
- > extends HTMLElement {
45
- private _internals!: ElementInternals
46
- private _contentObserver: MutationObserver | null = null
47
- /** True after the first successful connectedCallback completes. */
48
- private _initialized: boolean = false
49
- /** Container div created on first connect; reused on subsequent connects. */
50
- private _container: HTMLElement | null = null
51
- /**
52
- * Opt-in reactive adapter. Lazy-allocated on first `this.reactive`
53
- * access, disposed on `disconnectedCallback`, re-allocated on next
54
- * access after reconnect. Subclasses that never touch `this.reactive`
55
- * pay zero allocation cost — `notifyProps`/`notifyContent` short-circuit
56
- * on the null guard.
57
- */
58
- private _reactive: ReactiveAdapterImpl<Props, Events> | null = null
59
- /**
60
- * Cached results of the most recent `parseProps` + `validateProps`
61
- * pass. Set by `connectedCallback` and `attributeChangedCallback`.
62
- * Read by `get reactive()` to avoid re-parsing on lazy allocation —
63
- * means `validateProps` only ever runs once per attribute change,
64
- * even for consumers that touch `this.reactive` inside `onMount`.
65
- */
66
- private _lastProps: Props | null = null
67
- private _lastErrors: readonly string[] = []
68
- private _lastContent: string | null = null
69
-
70
- /**
71
- * Phantom field to retain the `Events` type parameter so it shows up in
72
- * type errors / `Reflect.get` shape. Adapter typing reads it indirectly
73
- * via the generic.
74
- * @internal
75
- */
76
- declare readonly _events: Events
77
-
78
- /**
79
- * Override to provide the component's configuration.
80
- * Must be static because `observedAttributes` reads it before construction.
81
- *
82
- * Specify the generic to get typed `validateProps`:
83
- * ```ts
84
- * static get wippyConfig(): WippyElementConfig<MyProps> { ... }
85
- * ```
86
- */
87
- static get wippyConfig(): WippyElementConfig {
88
- return { propsSchema: { properties: {} } }
89
- }
90
-
91
- /**
92
- * Derived from the props schema + any `extraObservedAttributes`.
93
- */
94
- static get observedAttributes(): string[] {
95
- const config = this.wippyConfig
96
- const schemaAttrs = Object.keys(config.propsSchema.properties)
97
- const extra = config.extraObservedAttributes ?? []
98
- return [...schemaAttrs, ...extra]
99
- }
100
-
101
- constructor() {
102
- super()
103
- this._internals = this.attachInternals()
104
- }
105
-
106
- /**
107
- * Panel-scoped `host` wrapper attached by the managed-layout shell's
108
- * content resolvers (`ComponentResolver` / `WebComponentPackageLoader`).
109
- *
110
- * Inside a managed-layout panel, this is a wrapper around the universal
111
- * `host` API where context-aware calls (`layout.broadcast / send / on`)
112
- * are routed through the panel-bound bus — so `sourcePanelId` is
113
- * attributed correctly without postMessage indirection. Layout
114
- * mutations and other host methods pass through unchanged.
115
- *
116
- * `null` outside a managed-layout context (compat shell, chat sidebar,
117
- * standalone playground). Subclass code that needs a host in those
118
- * cases can fall back to `import { host } from '@wippy-fe/proxy'`.
119
- */
120
- get host(): ReturnType<typeof getWippyHost> {
121
- return getWippyHost(this)
122
- }
123
-
124
- /**
125
- * Emit a CustomEvent that bubbles and crosses shadow DOM boundaries.
126
- */
127
- protected emitEvent(eventName: string, detail?: unknown): void {
128
- this.dispatchEvent(new CustomEvent(eventName, {
129
- bubbles: true,
130
- composed: true,
131
- detail,
132
- }))
133
- }
134
-
135
- /**
136
- * Opt-in reactive adapter — framework-agnostic. Subscribe to prop
137
- * changes, content changes, or emit typed events from a non-Vue
138
- * consumer without re-rolling reactivity.
139
- *
140
- * ```ts
141
- * class MyEl extends WippyElement<{ count: number }, { tick: { n: number } }> {
142
- * protected onMount() {
143
- * const ctrl = new AbortController()
144
- * this.reactive.props.subscribe(({ count }) => {
145
- * this.shadowRoot!.querySelector('.n')!.textContent = String(count)
146
- * }, { signal: ctrl.signal, immediate: true })
147
- * }
148
- * tick(n: number) { this.reactive.events.emit('tick', { n }) }
149
- * }
150
- * ```
151
- *
152
- * Allocation cost is zero unless this getter is touched. Disposed on
153
- * `disconnectedCallback`; a fresh adapter is allocated on the next
154
- * access after reconnect.
155
- */
156
- protected get reactive(): ReactiveAdapter<Props, Events> {
157
- if (!this._reactive) {
158
- const config = (this.constructor as typeof WippyElement).wippyConfig
159
- const hasContent = !!config.contentTemplate
160
- // Reuse the cached parse result from the most recent
161
- // `connectedCallback` / `attributeChangedCallback` whenever we
162
- // have one; otherwise fall back to a fresh parse (this happens
163
- // when `reactive` is touched before the element is ever
164
- // connected — rare but legal).
165
- let props: Props
166
- let errors: readonly string[]
167
- if (this._lastProps !== null) {
168
- props = this._lastProps
169
- errors = this._lastErrors
170
- }
171
- else {
172
- const parsed = parseProps(this, config.propsSchema)
173
- if (config.validateProps) {
174
- parsed.errors.push(...config.validateProps(parsed.props))
175
- }
176
- props = parsed.props as Props
177
- errors = parsed.errors
178
- this._lastProps = props
179
- this._lastErrors = errors
180
- }
181
- const content = hasContent
182
- ? (this._lastContent ?? this._extractContent(config.contentTemplate!))
183
- : null
184
- if (hasContent && this._lastContent === null)
185
- this._lastContent = content
186
- this._reactive = new ReactiveAdapterImpl<Props, Events>(
187
- { props, errors, content, hasContent },
188
- this.emitEvent.bind(this),
189
- )
190
- }
191
- return this._reactive
192
- }
193
-
194
- // ── Lifecycle ──────────────────────────────────────────────
195
-
196
- connectedCallback(): void {
197
- this._internals.states.add('loading')
198
- try {
199
- const config = (this.constructor as typeof WippyElement).wippyConfig
200
- const isRemount = this._initialized
201
-
202
- // 1. Shadow DOM (guard against reconnect — attachShadow throws if called twice)
203
- const shadow = this.shadowRoot ?? this.attachShadow({ mode: config.shadowMode ?? 'open' })
204
-
205
- let container: HTMLElement
206
- if (!isRemount) {
207
- // 2. Early hook — customize shadow before CSS/container
208
- this.onInit(shadow)
209
-
210
- // 3. Inline CSS
211
- if (config.inlineCss) {
212
- injectInlineCss(shadow, config.inlineCss)
213
- }
214
-
215
- // 4. Host CSS (async, non-blocking)
216
- if (config.hostCssKeys === undefined || config.hostCssKeys.length > 0) {
217
- loadHostCss(shadow, config.hostCssKeys)
218
- }
219
-
220
- // 5. Container
221
- container = document.createElement('div')
222
- const classes = config.containerClasses ?? []
223
- if (classes.length > 0) {
224
- container.classList.add(...classes)
225
- }
226
- shadow.appendChild(container)
227
- this._container = container
228
-
229
- // 6. Icons
230
- addIcons(addCollection)
231
- }
232
- else {
233
- // Reconnect path — reuse the existing shadow children + container.
234
- // Skip CSS/icon/container setup so we don't accumulate duplicate
235
- // `<style>` and container `<div>` siblings on every detach/reattach.
236
- // The framework adapter decides whether to remount on the existing
237
- // container or recreate its app instance.
238
- container = this._container!
239
- }
240
-
241
- // 7. Parse initial props + custom validation
242
- const { props, errors } = parseProps(this, config.propsSchema)
243
- if (config.validateProps) {
244
- errors.push(...config.validateProps(props))
245
- }
246
- const typedProps = props as Props
247
- // Cache so a lazy `get reactive()` call from inside `onMount`
248
- // doesn't re-parse / re-validate.
249
- this._lastProps = typedProps
250
- this._lastErrors = errors
251
-
252
- // 7b. Extract children content (if contentTemplate configured)
253
- let initialContent: string | null = null
254
- if (config.contentTemplate) {
255
- initialContent = this._extractContent(config.contentTemplate)
256
- this._lastContent = initialContent
257
- this._contentObserver = new MutationObserver(() => {
258
- const content = this._extractContent(config.contentTemplate!)
259
- this._lastContent = content
260
- // Fan out to the reactive adapter first so subclass overrides of
261
- // `onContentChanged` that read `this.reactive.content.value` see
262
- // the new value. No-op when the adapter hasn't been allocated.
263
- this._reactive?.notifyContent(content)
264
- this.onContentChanged(content)
265
- })
266
- this._contentObserver.observe(this, {
267
- childList: true,
268
- characterData: true,
269
- subtree: true,
270
- })
271
- }
272
-
273
- // 8. Framework mount (first time) or remount (reconnect)
274
- this.onMount(shadow, container, typedProps, errors, initialContent, isRemount)
275
-
276
- // 9. Ready
277
- this._internals.states.delete('loading')
278
- this._internals.states.add('ready')
279
-
280
- // First-connect bookkeeping after a clean mount.
281
- if (!isRemount) {
282
- this._initialized = true
283
- }
284
-
285
- // 10. Post-mount hook
286
- this.onReady()
287
-
288
- // 11. Emit load
289
- this.emitEvent('load')
290
- }
291
- catch (error) {
292
- this.onError(error)
293
- this._internals.states.delete('loading')
294
- this._internals.states.add('error')
295
- this.emitEvent('error', {
296
- message: error instanceof Error ? error.message : String(error),
297
- error,
298
- })
299
- }
300
- }
301
-
302
- disconnectedCallback(): void {
303
- if (this._contentObserver) {
304
- this._contentObserver.disconnect()
305
- this._contentObserver = null
306
- }
307
- this.onUnmount()
308
- this.emitEvent('unload')
309
- this._internals.states.clear()
310
- // Drop the reactive adapter so subscribers from this attachment are
311
- // cleared and a fresh adapter is allocated on the next `this.reactive`
312
- // access (i.e. after reconnect).
313
- this._reactive?.dispose()
314
- this._reactive = null
315
- // Clear cached parse state — `connectedCallback` re-populates on
316
- // reconnect; before then, a lazy `get reactive()` access should
317
- // re-parse from current attributes (rare path).
318
- this._lastProps = null
319
- this._lastErrors = []
320
- this._lastContent = null
321
- // Drop panel-context wires so a detached element held by a
322
- // consumer (or re-inserted into a different panel) doesn't carry
323
- // stale wires that would silently misattribute. The resolver
324
- // re-installs them on the next mount.
325
- delete (this as unknown as { __wippyHost?: unknown }).__wippyHost
326
- delete (this as unknown as { __wippyHostBus?: unknown }).__wippyHostBus
327
- }
328
-
329
- attributeChangedCallback(_name: string, oldVal: string | null, newVal: string | null): void {
330
- if (oldVal === newVal)
331
- return
332
- const config = (this.constructor as typeof WippyElement).wippyConfig
333
- const { props, errors } = parseProps(this, config.propsSchema)
334
- if (config.validateProps) {
335
- errors.push(...config.validateProps(props))
336
- }
337
- const typedProps = props as Props
338
- this._lastProps = typedProps
339
- this._lastErrors = errors
340
- // Fan out to the reactive adapter BEFORE the legacy hook so subclasses
341
- // that override `onPropsChanged` AND read `this.reactive.props.value`
342
- // inside it see consistent state. No-op when the adapter hasn't been
343
- // lazy-allocated.
344
- this._reactive?.notifyProps(typedProps, errors)
345
- this.onPropsChanged(typedProps, errors)
346
- }
347
-
348
- // ── Hooks ──────────────────────────────────────────────────
349
-
350
- /** Called right after shadow DOM is attached, before CSS or container. */
351
- protected onInit(_shadow: ShadowRoot): void {}
352
-
353
- /**
354
- * Called after shadow DOM, CSS, and container are ready. Mount your
355
- * framework here.
356
- *
357
- * Invoked on first connect AND on every subsequent reconnect (move-style
358
- * DOM op: detach → reattach). On reconnect the same `container` instance
359
- * is reused. Adapters that hold a framework app handle should either
360
- * detect `isRemount === true` and re-mount on the existing container, or
361
- * leave the previous app intact across the detach/reattach cycle.
362
- */
363
- protected abstract onMount(
364
- shadow: ShadowRoot,
365
- container: HTMLElement,
366
- initialProps: Props,
367
- initialErrors: string[],
368
- initialContent?: string | null,
369
- isRemount?: boolean,
370
- ): void
371
-
372
- /** Called after internals state is set to ready, before the `load` event. */
373
- protected onReady(): void {}
374
-
375
- /** Called when connectedCallback throws. Default logs to console. */
376
- protected onError(error: unknown): void {
377
- console.error(`${this.constructor.name} initialization failed:`, error)
378
- }
379
-
380
- /** Called during disconnectedCallback — clean up framework resources. */
381
- protected abstract onUnmount(): void
382
-
383
- /** Called when observed attributes change. Override to update framework state. */
384
- protected onPropsChanged(_props: Props, _errors: string[]): void {}
385
-
386
- /**
387
- * Extract text from a child `<template data-type="...">` element.
388
- * Uses `.content.textContent` since `<template>` stores content in a DocumentFragment.
389
- */
390
- private _extractContent(dataType: string): string | null {
391
- const tpl = this.querySelector(`template[data-type="${dataType}"]`) as HTMLTemplateElement | null
392
- return tpl?.content.textContent?.trim() ?? null
393
- }
394
-
395
- /** Called when child `<template>` content changes. Override to update framework state. */
396
- protected onContentChanged(_content: string | null): void {}
397
- }
398
-
399
- export { define }
package/src/css-loader.ts DELETED
@@ -1,52 +0,0 @@
1
- import type { HostCssKey } from './types.ts'
2
- import { hostCss, loadCss } from '@wippy-fe/proxy'
3
-
4
- const DEFAULT_HOST_CSS_KEYS: HostCssKey[] = [
5
- 'themeConfigUrl',
6
- 'primeVueCssUrl',
7
- 'markdownCssUrl',
8
- 'iframeCssUrl',
9
- ]
10
-
11
- /**
12
- * Loads host CSS URLs into the shadow root as `<style>` elements.
13
- * Non-blocking — returns a promise but the component doesn't need to await it.
14
- */
15
- export function loadHostCss(shadow: ShadowRoot, keys?: HostCssKey[]): Promise<void> {
16
- const cssKeys = keys ?? DEFAULT_HOST_CSS_KEYS
17
- // Filter out keys whose URL is missing (e.g. removed CSS like fontCssUrl).
18
- // Load each independently so one failure doesn't block the rest.
19
- const jobs = cssKeys.map(async (key) => {
20
- const url = hostCss[key]
21
- if (!url) {
22
- console.warn(`[wippy-fe/webcomponent-core] hostCss key "${key}" is undefined — skipping. Remove it from hostCssKeys if the CSS was removed.`)
23
- return null
24
- }
25
- try {
26
- return await loadCss(url)
27
- }
28
- catch (err) {
29
- console.warn(`[wippy-fe/webcomponent-core] Failed to load hostCss "${key}" (${url}):`, err)
30
- return null
31
- }
32
- })
33
- return Promise.all(jobs).then((cssChunks) => {
34
- for (const css of cssChunks) {
35
- if (!css)
36
- continue
37
- const style = document.createElement('style')
38
- style.textContent = css
39
- style.setAttribute('role', '@wippy-fe/host-css')
40
- shadow.appendChild(style)
41
- }
42
- })
43
- }
44
-
45
- /**
46
- * Injects inline CSS text into the shadow root.
47
- */
48
- export function injectInlineCss(shadow: ShadowRoot, text: string): void {
49
- const style = document.createElement('style')
50
- style.textContent = text
51
- shadow.appendChild(style)
52
- }
package/src/host-bus.ts DELETED
@@ -1,44 +0,0 @@
1
- /**
2
- * Layout bus types shared between `@wippy-fe/webcomponent-core` and
3
- * `@wippy-fe/webcomponent-vue`. Mirror of the host-side
4
- * `LayoutBusBound` / `BroadcastEnvelope` shapes; structurally
5
- * compatible so a host-mounted web component can read its
6
- * `__wippyHostBus` property without importing host-private types.
7
- *
8
- * The host (gen-2-chat managed-layout shell) sets:
9
- * - `el.dataset.wippyPanelId` (string)
10
- * - `(el as { __wippyHostBus?: LayoutBusBound }).__wippyHostBus`
11
- * BEFORE the element is appended to the DOM. Web components see them
12
- * on first `connectedCallback` tick.
13
- *
14
- * Outside a managed-layout host, both are absent — read code MUST
15
- * tolerate `undefined`.
16
- *
17
- * The interface bodies live in `@wippy-fe/shared` so the host can
18
- * import them without dragging in this package's `@wippy-fe/proxy`
19
- * runtime dependency. Consumers continue to import from
20
- * `@wippy-fe/webcomponent-core` (this re-export) for backward compat.
21
- */
22
-
23
- import type { LayoutBusBound } from '@wippy-fe/shared'
24
-
25
- export type { BroadcastEnvelope, LayoutBusBound } from '@wippy-fe/shared'
26
-
27
- /**
28
- * Read the host bus attached to an element by `ComponentResolver` in
29
- * the managed-layout host. Returns `null` outside a managed-layout
30
- * host (or before the element is connected). Stable across element
31
- * remounts of the same panel — the resolver re-attaches a fresh bus
32
- * each time.
33
- */
34
- export function getWippyHostBus(el: HTMLElement): LayoutBusBound | null {
35
- return (el as { __wippyHostBus?: LayoutBusBound }).__wippyHostBus ?? null
36
- }
37
-
38
- /**
39
- * Read the panelId attribute set by `ComponentResolver`. `null`
40
- * outside a managed-layout host.
41
- */
42
- export function getWippyPanelId(el: HTMLElement): string | null {
43
- return el.dataset.wippyPanelId ?? null
44
- }
@@ -1,19 +0,0 @@
1
- /**
2
- * @vitest-environment jsdom
3
- */
4
- import { describe, expect, it } from 'vitest'
5
- import { getWippyHost } from './host-scope.ts'
6
-
7
- describe('getWippyHost', () => {
8
- it('returns the wrapper attached as __wippyHost', () => {
9
- const el = document.createElement('div')
10
- const fake = { layout: { broadcast: () => {} } }
11
- ;(el as unknown as { __wippyHost: unknown }).__wippyHost = fake
12
- expect(getWippyHost(el)).toBe(fake)
13
- })
14
-
15
- it('returns null when no wrapper attached', () => {
16
- const el = document.createElement('div')
17
- expect(getWippyHost(el)).toBeNull()
18
- })
19
- })
package/src/host-scope.ts DELETED
@@ -1,28 +0,0 @@
1
- /**
2
- * Element-attached host wrapper reader for web components mounted as
3
- * `kind: 'component'` panels in the gen-2-chat managed-layout shell.
4
- *
5
- * The host stamps a panel-scoped wrapper as `__wippyHost` on each
6
- * element BEFORE it is appended to the DOM (see the host's
7
- * `panel-context-wires.ts` and `host-scope.ts`). Consumer web
8
- * components read it back via `getWippyHost(this)` in
9
- * `connectedCallback` and onwards.
10
- *
11
- * The wrapper is **constructed by the host** — this package only
12
- * exposes the reader. Outside a managed-layout host (raw mounts,
13
- * compat shells, tests without the connector) the property is absent
14
- * and `getWippyHost` returns `null`.
15
- */
16
-
17
- /**
18
- * Read the panel-scoped host wrapper attached to an element by the
19
- * managed-layout shell's content resolvers. Returns `null` outside a
20
- * managed-layout host (or before the element is connected).
21
- *
22
- * Subclass authors of `WippyElement` / `WippyVueElement` access this
23
- * via `this.host` / `useHost()` and rarely need to call this directly.
24
- * The helper is exposed for raw `HTMLElement` consumers.
25
- */
26
- export function getWippyHost<H = unknown>(el: HTMLElement): H | null {
27
- return ((el as unknown) as { __wippyHost?: H }).__wippyHost ?? null
28
- }
package/src/index.ts DELETED
@@ -1,22 +0,0 @@
1
- export { define, WippyElement } from './base-element.ts'
2
- export { injectInlineCss, loadHostCss } from './css-loader.ts'
3
- export { getWippyHostBus, getWippyPanelId } from './host-bus.ts'
4
- export type { BroadcastEnvelope, LayoutBusBound } from './host-bus.ts'
5
- export { getWippyHost } from './host-scope.ts'
6
- export { attrToCamel, parseProps } from './prop-parser.ts'
7
- export type { ParseResult } from './prop-parser.ts'
8
- export type {
9
- ContentListener,
10
- ContentSubscription,
11
- EventEmitterHandle,
12
- PropsListener,
13
- PropsSubscription,
14
- ReactiveAdapter,
15
- SubscribeOptions,
16
- } from './reactive-adapter.ts'
17
- export type {
18
- HostCssKey,
19
- WippyElementConfig,
20
- WippyPropDefinition,
21
- WippyPropsSchema,
22
- } from './types.ts'
@@ -1,91 +0,0 @@
1
- import type { WippyPropDefinition, WippyPropsSchema } from './types.ts'
2
-
3
- export interface ParseResult {
4
- props: Record<string, unknown>
5
- errors: string[]
6
- }
7
-
8
- /**
9
- * Converts a kebab-case attribute name to camelCase.
10
- * `allowed-types` → `allowedTypes`
11
- */
12
- export function attrToCamel(attr: string): string {
13
- return attr.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
14
- }
15
-
16
- /**
17
- * Parses a single attribute value according to its schema definition.
18
- */
19
- function parseValue(attr: string, raw: string, def: WippyPropDefinition): { value: unknown, error?: string } {
20
- switch (def.type) {
21
- case 'string':
22
- return { value: raw }
23
-
24
- case 'number': {
25
- const n = Number.parseFloat(raw)
26
- if (Number.isNaN(n))
27
- return { value: undefined, error: `Invalid ${attr}: expected a number` }
28
- return { value: n }
29
- }
30
-
31
- case 'integer': {
32
- const n = Number.parseInt(raw, 10)
33
- if (Number.isNaN(n))
34
- return { value: undefined, error: `Invalid ${attr}: expected an integer` }
35
- return { value: n }
36
- }
37
-
38
- case 'boolean':
39
- // Presence of the attribute means true (HTML convention), explicit "false" means false
40
- return { value: raw !== 'false' }
41
-
42
- case 'array':
43
- case 'object': {
44
- try {
45
- const parsed = JSON.parse(raw)
46
- if (def.type === 'array' && !Array.isArray(parsed)) {
47
- return { value: undefined, error: `Invalid ${attr}: expected a JSON array` }
48
- }
49
- return { value: parsed }
50
- }
51
- catch {
52
- return { value: undefined, error: `Invalid ${attr}: must be valid JSON` }
53
- }
54
- }
55
-
56
- default:
57
- return { value: raw }
58
- }
59
- }
60
-
61
- /**
62
- * Parses all attributes on an element according to its props schema.
63
- * Returns `{ props, errors }` where errors is an array of human-readable messages.
64
- */
65
- export function parseProps(element: HTMLElement, schema: WippyPropsSchema): ParseResult {
66
- const props: Record<string, unknown> = {}
67
- const errors: string[] = []
68
-
69
- for (const [attr, def] of Object.entries(schema.properties)) {
70
- const raw = element.getAttribute(attr)
71
- const camel = attrToCamel(attr)
72
-
73
- if (raw === null) {
74
- // Attribute not set — use default if available
75
- if (def.default !== undefined) {
76
- props[camel] = def.default
77
- }
78
- continue
79
- }
80
-
81
- const result = parseValue(attr, raw, def)
82
- if (result.error) {
83
- errors.push(result.error)
84
- }
85
- else {
86
- props[camel] = result.value
87
- }
88
- }
89
-
90
- return { props, errors }
91
- }