@webjsdev/core 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Built-in directives for the webjs `html` tagged template system.
3
+ *
4
+ * lit-html parity. Imports look like:
5
+ *
6
+ * ```js
7
+ * import { html } from '@webjsdev/core';
8
+ * import {
9
+ * unsafeHTML, live,
10
+ * keyed, guard, templateContent, ref, createRef,
11
+ * cache, until, asyncAppend, asyncReplace,
12
+ * } from '@webjsdev/core/directives';
13
+ * ```
14
+ *
15
+ * `repeat()` lives in `./repeat.js` (re-exported from the package root).
16
+ *
17
+ * @module directives
18
+ */
19
+
20
+ /* ================================================================
21
+ * unsafeHTML
22
+ * ================================================================ */
23
+
24
+ /**
25
+ * Render a raw HTML string without escaping. The string is injected
26
+ * directly into the DOM as parsed HTML nodes.
27
+ *
28
+ * **When to use (AI hint):** Use ONLY for trusted HTML: CMS content,
29
+ * markdown-to-HTML output, or sanitized rich text. NEVER use for
30
+ * user-supplied input: this is an XSS vector.
31
+ *
32
+ * ```js
33
+ * import { html } from '@webjsdev/core';
34
+ * import { unsafeHTML } from '@webjsdev/core/directives';
35
+ *
36
+ * // Good: trusted markdown output
37
+ * html`<article>${unsafeHTML(markdownToHtml(post.body))}</article>`;
38
+ *
39
+ * // DANGEROUS: user input: use ${text} instead (auto-escaped)
40
+ * // html`<p>${unsafeHTML(userInput)}</p>`; // ← XSS!
41
+ * ```
42
+ *
43
+ * @param {string | null | undefined} htmlString
44
+ * Trusted HTML string to render without escaping.
45
+ * @returns {{ _$webjs: 'unsafe-html', value: string }}
46
+ */
47
+ export function unsafeHTML(htmlString) {
48
+ return { _$webjs: 'unsafe-html', value: String(htmlString ?? '') };
49
+ }
50
+
51
+ /**
52
+ * Type guard: returns `true` if `x` is a marker produced by `unsafeHTML()`.
53
+ * @param {unknown} x
54
+ * @returns {x is { _$webjs: 'unsafe-html', value: string }}
55
+ */
56
+ export function isUnsafeHTML(x) {
57
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'unsafe-html';
58
+ }
59
+
60
+ /* ================================================================
61
+ * live
62
+ * ================================================================ */
63
+
64
+ /**
65
+ * Dirty-check a value against the **live DOM value** instead of the
66
+ * last rendered value. Essential for `<input>` two-way binding where
67
+ * the user can modify the DOM value between renders.
68
+ *
69
+ * **When to use (AI hint):** Use `live()` on `.value` or `.checked`
70
+ * bindings for `<input>`, `<textarea>`, `<select>` elements where the
71
+ * user types/selects between renders. Without `live()`, the renderer
72
+ * skips the update because its cached value matches: even though the
73
+ * DOM value has changed.
74
+ *
75
+ * ```js
76
+ * import { html } from '@webjsdev/core';
77
+ * import { live } from '@webjsdev/core/directives';
78
+ *
79
+ * html`<input .value=${live(this.query)}
80
+ * @input=${e => { this.query = e.target.value; }}>`;
81
+ * ```
82
+ *
83
+ * On the server, `live()` is a no-op: it unwraps to the inner value.
84
+ *
85
+ * @param {unknown} value The value to set on the element.
86
+ * @returns {{ _$webjs: 'live', value: unknown }}
87
+ */
88
+ export function live(value) {
89
+ return { _$webjs: 'live', value };
90
+ }
91
+
92
+ /**
93
+ * Type guard: returns `true` if `x` is a marker produced by `live()`.
94
+ * @param {unknown} x
95
+ * @returns {x is { _$webjs: 'live', value: unknown }}
96
+ */
97
+ export function isLive(x) {
98
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'live';
99
+ }
100
+
101
+ /* ================================================================
102
+ * keyed (lit-html parity)
103
+ * ================================================================ */
104
+
105
+ /**
106
+ * Wrap a template with a key. When the key changes between renders, the
107
+ * renderer discards the prior DOM and creates fresh. Useful for forcing
108
+ * a remount when the logical identity of the rendered content changes
109
+ * even though the template literal structure is the same.
110
+ *
111
+ * ```js
112
+ * import { keyed } from '@webjsdev/core/directives';
113
+ *
114
+ * // Form fully resets (input values, focus, etc.) when userId changes.
115
+ * html`${keyed(this.userId, html`<edit-form .user=${this.user}></edit-form>`)}`;
116
+ * ```
117
+ *
118
+ * On the server, the key is ignored (one-shot render). In the browser,
119
+ * the renderer compares the new key to the previously-rendered key at
120
+ * the same position and remounts on inequality.
121
+ *
122
+ * @template T
123
+ * @param {unknown} key Compared with `Object.is` against the previous render.
124
+ * @param {T} template Any value the renderer accepts (typically a `TemplateResult`).
125
+ * @returns {{ _$webjs: 'keyed', key: unknown, value: T }}
126
+ */
127
+ export function keyed(key, template) {
128
+ return { _$webjs: 'keyed', key, value: template };
129
+ }
130
+
131
+ /** @param {unknown} x */
132
+ export function isKeyed(x) {
133
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'keyed';
134
+ }
135
+
136
+ /* ================================================================
137
+ * guard (lit-html parity)
138
+ * ================================================================ */
139
+
140
+ /**
141
+ * Memoize a sub-template by its dependencies. If the deps array hasn't
142
+ * changed shallowly between renders, the renderer skips re-evaluating
143
+ * the value-producing function.
144
+ *
145
+ * ```js
146
+ * import { guard } from '@webjsdev/core/directives';
147
+ *
148
+ * render() {
149
+ * return html`
150
+ * <header>${guard([this.title], () => html`<h1>${this.title}</h1>`)}</header>
151
+ * <main>${this.body}</main>
152
+ * `;
153
+ * }
154
+ * ```
155
+ *
156
+ * On the server, `fn()` is always invoked (one-shot render, no cache).
157
+ * In the browser, the renderer maintains a per-part cache keyed by the
158
+ * shallow-compared deps array.
159
+ *
160
+ * @template T
161
+ * @param {readonly unknown[]} deps Shallow-compared between renders
162
+ * @param {() => T} fn Value-producing function
163
+ * @returns {{ _$webjs: 'guard', deps: readonly unknown[], fn: () => T }}
164
+ */
165
+ export function guard(deps, fn) {
166
+ return { _$webjs: 'guard', deps, fn };
167
+ }
168
+
169
+ /** @param {unknown} x */
170
+ export function isGuard(x) {
171
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'guard';
172
+ }
173
+
174
+ /* ================================================================
175
+ * templateContent (lit-html parity)
176
+ * ================================================================ */
177
+
178
+ /**
179
+ * Render the content of a `<template>` element. The template's content
180
+ * is cloned on the client; on the server, its `innerHTML` is emitted.
181
+ *
182
+ * ```js
183
+ * import { templateContent } from '@webjsdev/core/directives';
184
+ *
185
+ * const tpl = document.querySelector('#my-tpl');
186
+ * html`<div>${templateContent(tpl)}</div>`;
187
+ * ```
188
+ *
189
+ * The HTML inside the template element is trusted: it is NOT escaped.
190
+ * Do not pass templates whose content was assembled from user input.
191
+ *
192
+ * @param {HTMLTemplateElement | { innerHTML?: string, content?: DocumentFragment }} template
193
+ * @returns {{ _$webjs: 'template-content', template: any }}
194
+ */
195
+ export function templateContent(template) {
196
+ return { _$webjs: 'template-content', template };
197
+ }
198
+
199
+ /** @param {unknown} x */
200
+ export function isTemplateContent(x) {
201
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'template-content';
202
+ }
203
+
204
+ /* ================================================================
205
+ * ref (lit-html parity)
206
+ * ================================================================ */
207
+
208
+ /**
209
+ * Bind a Ref object or callback to the element produced at this position.
210
+ *
211
+ * ```js
212
+ * import { createRef, ref } from '@webjsdev/core/directives';
213
+ *
214
+ * class MyForm extends WebComponent {
215
+ * _input = createRef();
216
+ * render() {
217
+ * return html`<input ${ref(this._input)}>`;
218
+ * }
219
+ * firstUpdated() {
220
+ * this._input.value?.focus();
221
+ * }
222
+ * }
223
+ * ```
224
+ *
225
+ * Pass a callback instead of a Ref object to receive the element directly:
226
+ *
227
+ * ```js
228
+ * html`<input ${ref((el) => this._captureEl(el))}>`;
229
+ * ```
230
+ *
231
+ * On the server, `ref()` is a no-op: no DOM exists yet. The reference is
232
+ * populated after the first client-side render. The callback receives
233
+ * `undefined` when the element is removed.
234
+ *
235
+ * @param {{ value: unknown } | ((el: Element | undefined) => void)} refOrCallback
236
+ * @returns {{ _$webjs: 'ref', target: any }}
237
+ */
238
+ export function ref(refOrCallback) {
239
+ return { _$webjs: 'ref', target: refOrCallback };
240
+ }
241
+
242
+ /** @param {unknown} x */
243
+ export function isRef(x) {
244
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'ref';
245
+ }
246
+
247
+ /**
248
+ * Create a Ref object suitable for `ref()`. The element is assigned to
249
+ * `ref.value` after the first render commit.
250
+ *
251
+ * @template {Element} T
252
+ * @returns {{ value: T | undefined }}
253
+ */
254
+ export function createRef() {
255
+ return { value: undefined };
256
+ }
257
+
258
+ /* ================================================================
259
+ * cache (lit-html parity)
260
+ * ================================================================ */
261
+
262
+ /**
263
+ * Cache prior template instances by their `strings` array so that
264
+ * toggling between sub-templates preserves their detached DOM (input
265
+ * state, scroll position, focus). When the inner value is a template
266
+ * whose `strings` were cached on a previous render, the renderer
267
+ * re-attaches the stashed nodes and reconciles values against the new
268
+ * template instead of creating fresh DOM.
269
+ *
270
+ * ```js
271
+ * import { cache } from '@webjsdev/core/directives';
272
+ *
273
+ * render() {
274
+ * return html`
275
+ * <button @click=${() => this.tab = 'a'}>A</button>
276
+ * <button @click=${() => this.tab = 'b'}>B</button>
277
+ * ${cache(this.tab === 'a' ? html`<panel-a></panel-a>` : html`<panel-b></panel-b>`)}
278
+ * `;
279
+ * }
280
+ * ```
281
+ *
282
+ * On the server, `cache` is a pass-through (one-shot render, no DOM to
283
+ * cache).
284
+ *
285
+ * @template T
286
+ * @param {T} value
287
+ * @returns {{ _$webjs: 'cache', value: T }}
288
+ */
289
+ export function cache(value) {
290
+ return { _$webjs: 'cache', value };
291
+ }
292
+
293
+ /** @param {unknown} x */
294
+ export function isCache(x) {
295
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'cache';
296
+ }
297
+
298
+ /* ================================================================
299
+ * until (lit-html parity)
300
+ * ================================================================ */
301
+
302
+ /**
303
+ * Render the first synchronous value from a list of candidates. Any
304
+ * Promises in the list are unwrapped on the server (the first to
305
+ * resolve wins).
306
+ *
307
+ * ```js
308
+ * import { until } from '@webjsdev/core/directives';
309
+ *
310
+ * html`<div>${until(this.dataPromise, html`<p>Loading…</p>`)}</div>`;
311
+ * ```
312
+ *
313
+ * Priority is left-to-right: `args[0]` is highest priority. The
314
+ * highest-priority synchronous candidate (if any) renders immediately.
315
+ * Higher-priority Promises that later resolve replace the rendered
316
+ * value; lower-priority Promises are ignored once a higher-priority
317
+ * candidate is in place. When the marker is torn down (e.g. the
318
+ * parent re-renders with a different value), in-flight tracking is
319
+ * cleared so late Promise resolutions cannot overwrite newer DOM.
320
+ *
321
+ * SSR awaits `Promise.race` when all candidates are Promises;
322
+ * otherwise SSR renders the highest-priority synchronous candidate.
323
+ * For component-scoped async data with full pending/error states,
324
+ * prefer the `Task` controller (`@webjsdev/core/task`).
325
+ *
326
+ * @param {...unknown} args
327
+ * @returns {{ _$webjs: 'until', args: unknown[] }}
328
+ */
329
+ export function until(...args) {
330
+ return { _$webjs: 'until', args };
331
+ }
332
+
333
+ /** @param {unknown} x */
334
+ export function isUntil(x) {
335
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'until';
336
+ }
337
+
338
+ /* ================================================================
339
+ * asyncAppend / asyncReplace (lit-html parity)
340
+ * ================================================================ */
341
+
342
+ /**
343
+ * Render values from an `AsyncIterable` as they arrive, appending each
344
+ * mapped result before the marker. Iteration runs in the background;
345
+ * tearing down the marker (via a re-render that replaces the directive)
346
+ * aborts the loop.
347
+ *
348
+ * ```js
349
+ * import { asyncAppend } from '@webjsdev/core/directives';
350
+ *
351
+ * async function* logTail() {
352
+ * for await (const line of stream) yield line;
353
+ * }
354
+ *
355
+ * html`<ul>${asyncAppend(logTail(), (line, i) => html`<li>${i}: ${line}</li>`)}</ul>`;
356
+ * ```
357
+ *
358
+ * SSR renders empty (no iteration on a one-shot server render). For
359
+ * page-level streaming, prefer `Suspense({ fallback, children })`.
360
+ *
361
+ * @template T
362
+ * @param {AsyncIterable<T>} iterable
363
+ * @param {(value: T, index: number) => unknown} [mapper]
364
+ * @returns {{ _$webjs: 'async-append', iterable: AsyncIterable<T>, mapper?: (v: T, i: number) => unknown }}
365
+ */
366
+ export function asyncAppend(iterable, mapper) {
367
+ return { _$webjs: 'async-append', iterable, mapper };
368
+ }
369
+
370
+ /** @param {unknown} x */
371
+ export function isAsyncAppend(x) {
372
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'async-append';
373
+ }
374
+
375
+ /**
376
+ * Render values from an `AsyncIterable`, replacing the previous output
377
+ * each time a new value arrives. The latest yielded value is the only
378
+ * one that remains in the DOM.
379
+ *
380
+ * ```js
381
+ * import { asyncReplace } from '@webjsdev/core/directives';
382
+ *
383
+ * async function* ticker() {
384
+ * for (let i = 0; ; i++) {
385
+ * yield new Date().toLocaleTimeString();
386
+ * await new Promise(r => setTimeout(r, 1000));
387
+ * }
388
+ * }
389
+ *
390
+ * html`<time>${asyncReplace(ticker())}</time>`;
391
+ * ```
392
+ *
393
+ * SSR renders empty. Iteration aborts on teardown.
394
+ *
395
+ * @template T
396
+ * @param {AsyncIterable<T>} iterable
397
+ * @param {(value: T, index: number) => unknown} [mapper]
398
+ * @returns {{ _$webjs: 'async-replace', iterable: AsyncIterable<T>, mapper?: (v: T, i: number) => unknown }}
399
+ */
400
+ export function asyncReplace(iterable, mapper) {
401
+ return { _$webjs: 'async-replace', iterable, mapper };
402
+ }
403
+
404
+ /** @param {unknown} x */
405
+ export function isAsyncReplace(x) {
406
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'async-replace';
407
+ }
408
+
409
+ /* ================================================================
410
+ * watch (signal binding)
411
+ * ================================================================ */
412
+
413
+ /**
414
+ * Fine-grained reactive binding for a signal. Reads the signal at
415
+ * render time and subscribes the rendered template hole to subsequent
416
+ * changes. When the signal's value changes, only THIS hole updates,
417
+ * the surrounding component does not re-render.
418
+ *
419
+ * ```js
420
+ * import { html } from '@webjsdev/core';
421
+ * import { signal } from '@webjsdev/core';
422
+ * import { watch } from '@webjsdev/core/directives';
423
+ *
424
+ * const count = signal(0);
425
+ *
426
+ * class Counter extends WebComponent {
427
+ * render() {
428
+ * return html`
429
+ * <p>Count: ${watch(count)}</p>
430
+ * <button @click=${() => count.set(count.get() + 1)}>+</button>
431
+ * `;
432
+ * }
433
+ * }
434
+ * ```
435
+ *
436
+ * Without `watch()`, a plain `${count.get()}` works too (the host
437
+ * component subscribes automatically through its built-in
438
+ * SignalWatcher), but the whole component re-renders on each change.
439
+ * Use `watch()` when the surrounding template is expensive to re-run
440
+ * or when you want signal changes to bypass `shouldUpdate` / `willUpdate`.
441
+ *
442
+ * On SSR, `watch()` reads the signal once and inlines the value.
443
+ *
444
+ * @param {{ get: () => unknown, __isSignal: true }} sig
445
+ * @returns {{ _$webjs: 'watch', signal: { get: () => unknown, __isSignal: true } }}
446
+ */
447
+ export function watch(sig) {
448
+ return { _$webjs: 'watch', signal: sig };
449
+ }
450
+
451
+ /** @param {unknown} x */
452
+ export function isWatch(x) {
453
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'watch';
454
+ }
package/src/escape.js ADDED
@@ -0,0 +1,15 @@
1
+ /** @param {string} s */
2
+ export function escapeText(s) {
3
+ return String(s)
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;');
7
+ }
8
+
9
+ /** @param {string} s */
10
+ export function escapeAttr(s) {
11
+ return String(s)
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/</g, '&lt;');
15
+ }
package/src/expose.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Expose a server action as a first-class HTTP endpoint in addition to its
3
+ * internal RPC URL.
4
+ *
5
+ * ```js
6
+ * // actions/posts.server.js
7
+ * 'use server';
8
+ * import { expose } from '@webjsdev/core';
9
+ *
10
+ * export const createPost = expose('POST /api/posts', async ({ title, body }) => {
11
+ * // same function body you'd write without expose()
12
+ * });
13
+ * ```
14
+ *
15
+ * The action is reachable two ways, both backed by the exact same code:
16
+ * - from a client component: `import { createPost } from '.../posts.server.js'`
17
+ * - from curl / another service: `POST /api/posts` with a JSON body
18
+ *
19
+ * Adapter rules when invoked over HTTP:
20
+ * - URL path params ({`:slug`}) and query string are merged into a single
21
+ * object argument.
22
+ * - For methods with a body (POST/PUT/PATCH/DELETE), the parsed JSON body
23
+ * is merged on top of params/query. The function receives ONE argument:
24
+ * the merged object.
25
+ * - If `opts.validate` is provided, it runs BEFORE the function and can
26
+ * transform / reject the input. Throw to fail (→ 400 response). Return
27
+ * value replaces the input. Works cleanly with zod, valibot, or any
28
+ * parser that throws: `expose('...', fn, { validate: Schema.parse })`.
29
+ * - Return value becomes a JSON `Response`; throw or return a `Response`
30
+ * directly for full control.
31
+ *
32
+ *
33
+ * `cors` toggles browser CORS support for cross-origin callers:
34
+ * - `true` - allow any origin (`*`), reflects requested headers
35
+ * - string - allow that single origin (sets `Access-Control-Allow-Credentials: true`)
36
+ * - string[] - allow-list; non-matching origins are not granted
37
+ * - { origin, credentials, maxAge, headers }: full control
38
+ *
39
+ * When CORS is enabled, an `OPTIONS` preflight at the same path is auto-served
40
+ * with `Access-Control-Allow-Methods: <route's method>, OPTIONS`.
41
+ *
42
+ * @param {string} pattern e.g. `"POST /api/posts"` or `"GET /api/posts/:slug"`
43
+ * @param {Function} fn the async implementation
44
+ * @param {{
45
+ * validate?: (input: any) => any,
46
+ * cors?: boolean | string | string[] | { origin: string | string[], credentials?: boolean, maxAge?: number, headers?: string[] }
47
+ * }} [opts]
48
+ * @returns {Function} same function, tagged with HTTP metadata
49
+ */
50
+ export function expose(pattern, fn, opts) {
51
+ const match = /^\s*([A-Z]+)\s+(\/\S*)\s*$/.exec(pattern);
52
+ if (!match) {
53
+ throw new Error(
54
+ `expose(): bad pattern ${JSON.stringify(pattern)}: expected "METHOD /path"`
55
+ );
56
+ }
57
+ const [, method, path] = match;
58
+ /** @type any */ (fn).__webjsHttp = {
59
+ method,
60
+ path,
61
+ validate: opts && typeof opts.validate === 'function' ? opts.validate : null,
62
+ cors: opts && 'cors' in opts ? normaliseCors(opts.cors) : null,
63
+ };
64
+ return fn;
65
+ }
66
+
67
+ /** @param {any} c */
68
+ function normaliseCors(c) {
69
+ if (!c) return null;
70
+ if (c === true) {
71
+ return { origin: '*', credentials: false, maxAge: 86400, headers: null };
72
+ }
73
+ if (typeof c === 'string') {
74
+ return { origin: c, credentials: true, maxAge: 86400, headers: null };
75
+ }
76
+ if (Array.isArray(c)) {
77
+ return { origin: c, credentials: true, maxAge: 86400, headers: null };
78
+ }
79
+ return {
80
+ origin: c.origin,
81
+ credentials: !!c.credentials,
82
+ maxAge: typeof c.maxAge === 'number' ? c.maxAge : 86400,
83
+ headers: Array.isArray(c.headers) ? c.headers : null,
84
+ };
85
+ }
86
+
87
+ /** @param {unknown} fn */
88
+ export function getExposed(fn) {
89
+ return fn && typeof fn === 'function' ? /** @type any */ (fn).__webjsHttp || null : null;
90
+ }
package/src/html.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tagged template literal producing a {@link TemplateResult}.
3
+ *
4
+ * A TemplateResult is an isomorphic description of HTML: the static string
5
+ * pieces plus the dynamic values. Server and client renderers both consume it.
6
+ *
7
+ * @typedef {{ _$webjs: 'template', strings: TemplateStringsArray | string[], values: unknown[] }} TemplateResult
8
+ *
9
+ * @param {TemplateStringsArray | string[]} strings
10
+ * @param {...unknown} values
11
+ * @returns {TemplateResult}
12
+ */
13
+ export function html(strings, ...values) {
14
+ return { _$webjs: 'template', strings, values };
15
+ }
16
+
17
+ /**
18
+ * Identity check for TemplateResult.
19
+ * @param {unknown} x
20
+ * @returns {x is TemplateResult}
21
+ */
22
+ export function isTemplate(x) {
23
+ return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'template';
24
+ }
25
+
26
+ /**
27
+ * Marker used in the DOM to find hydration points.
28
+ * Same marker emitted by server and client so hydration can align.
29
+ */
30
+ export const MARKER = 'w$';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Lazy component loader using IntersectionObserver.
3
+ *
4
+ * Components with `static lazy = true` are NOT loaded eagerly via
5
+ * `<link rel="modulepreload">`. Instead, their `<script>` import is
6
+ * deferred and the module is only fetched when the element enters the
7
+ * viewport (or is within a generous margin of it).
8
+ *
9
+ * Usage (automatic: the SSR pipeline sets this up):
10
+ *
11
+ * <script type="module">
12
+ * import { observeLazy } from '@webjsdev/core/lazy-loader';
13
+ * observeLazy({
14
+ * 'my-lazy-widget': '/components/my-lazy-widget.ts',
15
+ * });
16
+ * </script>
17
+ *
18
+ * Or manually: mark a component and the framework handles the rest.
19
+ *
20
+ * class MyWidget extends WebComponent {
21
+ * static lazy = true;
22
+ * // ...
23
+ * }
24
+ * MyWidget.register('my-lazy-widget');
25
+ */
26
+
27
+ /** @type {Map<string, string>} tag → module URL */
28
+ const pending = new Map();
29
+
30
+ /** @type {IntersectionObserver | null} */
31
+ let observer = null;
32
+
33
+ /** @type {Map<string, Promise<unknown>>} in-flight module loads, keyed by URL */
34
+ const inflight = new Map();
35
+
36
+ /**
37
+ * Load a module, deduplicating concurrent requests for the same URL.
38
+ * @param {string} url
39
+ * @returns {Promise<unknown>}
40
+ */
41
+ function loadModule(url) {
42
+ let p = inflight.get(url);
43
+ if (p) return p;
44
+ p = import(url).finally(() => inflight.delete(url));
45
+ inflight.set(url, p);
46
+ return p;
47
+ }
48
+
49
+ /**
50
+ * Register tag → URL pairs for lazy loading. When any element matching
51
+ * a registered tag enters the viewport, its module is fetched via
52
+ * dynamic import(). The custom element class registers itself on load,
53
+ * upgrading the element.
54
+ *
55
+ * @param {Record<string, string>} entries { tagName: moduleUrl }
56
+ */
57
+ export function observeLazy(entries) {
58
+ for (const [tag, url] of Object.entries(entries)) {
59
+ pending.set(tag.toLowerCase(), url);
60
+ }
61
+
62
+ if (typeof IntersectionObserver === 'undefined') {
63
+ // No IO support: load everything immediately (SSR-only / old browser).
64
+ for (const url of pending.values()) loadModule(url);
65
+ pending.clear();
66
+ return;
67
+ }
68
+
69
+ if (!observer) {
70
+ observer = new IntersectionObserver(onIntersect, {
71
+ // Load when within 200px of the viewport: gives the module time
72
+ // to fetch before the element is fully visible.
73
+ rootMargin: '200px',
74
+ });
75
+ }
76
+
77
+ // Observe all matching elements currently in the DOM.
78
+ scan();
79
+
80
+ // Also watch for dynamically added elements via MutationObserver.
81
+ if (typeof MutationObserver !== 'undefined') {
82
+ const mo = new MutationObserver(() => scan());
83
+ mo.observe(document.body, { childList: true, subtree: true });
84
+ }
85
+ }
86
+
87
+ function scan() {
88
+ if (!observer || !pending.size) return;
89
+ for (const tag of pending.keys()) {
90
+ for (const el of document.querySelectorAll(tag)) {
91
+ observer.observe(el);
92
+ }
93
+ }
94
+ }
95
+
96
+ /** @param {IntersectionObserverEntry[]} entries */
97
+ function onIntersect(entries) {
98
+ for (const entry of entries) {
99
+ if (!entry.isIntersecting) continue;
100
+ const tag = entry.target.tagName.toLowerCase();
101
+ const url = pending.get(tag);
102
+ if (!url) continue;
103
+ loadModule(url);
104
+ pending.delete(tag);
105
+ observer?.unobserve(entry.target);
106
+ }
107
+ }