@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.
- package/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
|
@@ -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, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @param {string} s */
|
|
10
|
+
export function escapeAttr(s) {
|
|
11
|
+
return String(s)
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/</g, '<');
|
|
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
|
+
}
|