@webjsdev/core 0.8.0 → 0.9.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 +5 -0
- package/dist/webjs-core-browser.js +6 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -19
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +14 -9
- package/index.d.ts +53 -10
- package/index.js +17 -9
- package/package.json +15 -2
- package/src/action-effects-client.d.ts +5 -0
- package/src/action-effects-client.js +84 -0
- package/src/action-seed-client.js +5 -5
- package/src/asset-url.d.ts +18 -0
- package/src/asset-url.js +12 -13
- package/src/async-directive.d.ts +18 -0
- package/src/async-directive.js +88 -0
- package/src/attribute-reader.js +197 -23
- package/src/binding-prefixes.js +0 -12
- package/src/component/lifecycle.js +760 -209
- package/src/component/reactive.js +2 -10
- package/src/component/server-element.js +23 -1
- package/src/component.d.ts +151 -41
- package/src/context.d.ts +94 -18
- package/src/context.js +341 -217
- package/src/converters.d.ts +7 -0
- package/src/converters.js +38 -0
- package/src/csp-nonce.d.ts +18 -0
- package/src/css.d.ts +32 -6
- package/src/css.js +178 -14
- package/src/directive.d.ts +58 -0
- package/src/directive.js +119 -0
- package/src/directives.d.ts +38 -1
- package/src/directives.js +158 -1
- package/src/form-action.d.ts +13 -0
- package/src/form-action.js +139 -4
- package/src/hot.js +23 -0
- package/src/html.d.ts +4 -0
- package/src/html.js +35 -3
- package/src/is-server.d.ts +2 -0
- package/src/is-server.js +18 -0
- package/src/jsx-guard.js +130 -0
- package/src/metadata.d.ts +22 -3
- package/src/nav.d.ts +4 -1
- package/src/nav.js +44 -11
- package/src/optimistic.js +2 -2
- package/src/refresh-header.js +52 -0
- package/src/registry.js +10 -1
- package/src/render-client/adopt-slots.js +8 -3
- package/src/render-client/parts.js +745 -31
- package/src/render-client/reconciler.js +56 -11
- package/src/render-client/template-compiler.js +139 -44
- package/src/render-client.d.ts +5 -1
- package/src/render-server/attrs.js +2 -2
- package/src/render-server/compile.js +34 -0
- package/src/render-server/errors.js +37 -0
- package/src/render-server/expand.js +61 -8
- package/src/render-server/served-attrs.js +1 -2
- package/src/render-server/walk.js +135 -3
- package/src/render-server.d.ts +3 -0
- package/src/render-server.js +2 -0
- package/src/repeat.d.ts +7 -2
- package/src/repeat.js +20 -6
- package/src/router-client/dom-differ.js +17 -0
- package/src/router-client/fetch-apply.js +86 -5
- package/src/router-client/navigator.js +45 -9
- package/src/router-client/prefetch.js +3 -4
- package/src/router-client/root-attrs.js +43 -0
- package/src/router-client/stream.js +7 -0
- package/src/router-client/swap.js +4 -1
- package/src/routes.d.ts +75 -18
- package/src/serialize.d.ts +2 -0
- package/src/serialize.js +35 -4
- package/src/signal.d.ts +7 -0
- package/src/signal.js +26 -6
- package/src/slot/polyfills.js +3 -3
- package/src/slot.js +4 -8
- package/src/static-html.d.ts +22 -0
- package/src/static-html.js +144 -0
- package/src/task.d.ts +63 -20
- package/src/task.js +326 -293
- package/src/testing.d.ts +42 -0
- package/src/webjs-config.d.ts +1 -8
- package/src/websocket-client.d.ts +24 -0
package/src/directives.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* @module directives
|
|
20
20
|
*/
|
|
21
|
+
import { nothing, noChange } from './sentinels.js';
|
|
21
22
|
|
|
22
23
|
// Re-export so the `/directives` subpath surface matches the documented table
|
|
23
24
|
// in both src and dist mode. The implementation stays in repeat.js.
|
|
@@ -51,7 +52,24 @@ export { repeat, isRepeat } from './repeat.js';
|
|
|
51
52
|
* @returns {{ _$webjs: 'unsafe-html', value: string }}
|
|
52
53
|
*/
|
|
53
54
|
export function unsafeHTML(htmlString) {
|
|
54
|
-
|
|
55
|
+
const v = unsafeInput(htmlString, 'unsafeHTML');
|
|
56
|
+
return typeof v === 'string' ? { _$webjs: 'unsafe-html', value: v } : v;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The value an `unsafe*` directive was given, as Lit takes it (#1686):
|
|
61
|
+
* `nothing`, `null` or `undefined` render nothing, `noChange` keeps what is
|
|
62
|
+
* there, and anything else that is not a string throws (it used to be
|
|
63
|
+
* stringified, so `nothing` rendered as "Symbol(webjs.nothing)").
|
|
64
|
+
* @param {unknown} v
|
|
65
|
+
* @param {string} name
|
|
66
|
+
* @returns {string | typeof nothing | typeof noChange}
|
|
67
|
+
*/
|
|
68
|
+
function unsafeInput(v, name) {
|
|
69
|
+
if (v == null || v === nothing) return nothing;
|
|
70
|
+
if (v === noChange) return noChange;
|
|
71
|
+
if (typeof v !== 'string') throw new Error(`${name}() takes a string of trusted markup, not ${Array.isArray(v) ? 'an array' : typeof v}.`);
|
|
72
|
+
return v;
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
/**
|
|
@@ -63,6 +81,37 @@ export function isUnsafeHTML(x) {
|
|
|
63
81
|
return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'unsafe-html';
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Render a trusted SVG string inside an `<svg>` element (lit's `unsafeSVG`).
|
|
86
|
+
* The client parses the markup in the SVG namespace, so `<circle>` and
|
|
87
|
+
* `<path>` paint; the server emits it verbatim, exactly as `unsafeHTML` does.
|
|
88
|
+
* Same rule as `unsafeHTML`: NEVER pass user input.
|
|
89
|
+
*
|
|
90
|
+
* ```js
|
|
91
|
+
* html`<svg viewBox="0 0 10 10">${unsafeSVG(iconMarkup)}</svg>`;
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* @param {string | null | undefined} svgString
|
|
95
|
+
* @returns {{ _$webjs: 'unsafe-html', value: string, ns: 'svg' }}
|
|
96
|
+
*/
|
|
97
|
+
export function unsafeSVG(svgString) {
|
|
98
|
+
const v = unsafeInput(svgString, 'unsafeSVG');
|
|
99
|
+
return typeof v === 'string' ? { _$webjs: 'unsafe-html', value: v, ns: 'svg' } : v;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render a trusted MathML string inside a `<math>` element (lit's
|
|
104
|
+
* `unsafeMathML`). The client parses it in the MathML namespace; the server
|
|
105
|
+
* emits it verbatim. NEVER pass user input.
|
|
106
|
+
*
|
|
107
|
+
* @param {string | null | undefined} mathString
|
|
108
|
+
* @returns {{ _$webjs: 'unsafe-html', value: string, ns: 'mathml' }}
|
|
109
|
+
*/
|
|
110
|
+
export function unsafeMathML(mathString) {
|
|
111
|
+
const v = unsafeInput(mathString, 'unsafeMathML');
|
|
112
|
+
return typeof v === 'string' ? { _$webjs: 'unsafe-html', value: v, ns: 'mathml' } : v;
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
/* ================================================================
|
|
67
116
|
* live
|
|
68
117
|
* ================================================================ */
|
|
@@ -459,4 +508,112 @@ export function isWatch(x) {
|
|
|
459
508
|
return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjs === 'watch';
|
|
460
509
|
}
|
|
461
510
|
|
|
511
|
+
/* ================================================================
|
|
512
|
+
* when / choose / map / join / range (lit-html parity)
|
|
513
|
+
*
|
|
514
|
+
* Plain functions, not markers: each returns an ordinary value (or a
|
|
515
|
+
* generator) the renderers already accept, so they behave identically on
|
|
516
|
+
* the server and the client and need no renderer support beyond iterables.
|
|
517
|
+
* ================================================================ */
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Render one of two templates on a condition. The case functions receive the
|
|
521
|
+
* condition value, as lit's `when` passes it.
|
|
522
|
+
*
|
|
523
|
+
* ```js
|
|
524
|
+
* html`${when(this.user, (u) => html`<p>Hi ${u.name}</p>`, () => html`<a href="/login">Sign in</a>`)}`;
|
|
525
|
+
* ```
|
|
526
|
+
*
|
|
527
|
+
* @template C, T, F
|
|
528
|
+
* @param {C} condition
|
|
529
|
+
* @param {(c: C) => T} trueCase
|
|
530
|
+
* @param {(c: C) => F} [falseCase]
|
|
531
|
+
* @returns {T | F | undefined}
|
|
532
|
+
*/
|
|
533
|
+
export function when(condition, trueCase, falseCase) {
|
|
534
|
+
return condition ? trueCase(condition) : falseCase?.(condition);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Render the case whose key is strictly equal (`===`) to `value`, else the
|
|
539
|
+
* default case, lit's `choose`.
|
|
540
|
+
*
|
|
541
|
+
* ```js
|
|
542
|
+
* html`${choose(this.tab, [
|
|
543
|
+
* ['home', () => html`<home-tab></home-tab>`],
|
|
544
|
+
* ['about', () => html`<about-tab></about-tab>`],
|
|
545
|
+
* ], () => html`<p>Not found</p>`)}`;
|
|
546
|
+
* ```
|
|
547
|
+
*
|
|
548
|
+
* @template T, V
|
|
549
|
+
* @param {T} value
|
|
550
|
+
* @param {Array<[T, () => V]>} cases
|
|
551
|
+
* @param {() => V} [defaultCase]
|
|
552
|
+
* @returns {V | undefined}
|
|
553
|
+
*/
|
|
554
|
+
export function choose(value, cases, defaultCase) {
|
|
555
|
+
for (const [key, fn] of cases) {
|
|
556
|
+
if (key === value) return fn();
|
|
557
|
+
}
|
|
558
|
+
return defaultCase?.();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Lazily map an iterable, lit's `map`. Returns a generator, which both
|
|
563
|
+
* renderers accept anywhere an array is.
|
|
564
|
+
*
|
|
565
|
+
* @template T, U
|
|
566
|
+
* @param {Iterable<T> | undefined} items
|
|
567
|
+
* @param {(value: T, index: number) => U} f
|
|
568
|
+
* @returns {Generator<U>}
|
|
569
|
+
*/
|
|
570
|
+
export function* map(items, f) {
|
|
571
|
+
if (items === undefined) return;
|
|
572
|
+
let i = 0;
|
|
573
|
+
for (const value of items) yield f(value, i++);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Interleave a separator between the items of an iterable, lit's `join`. The
|
|
578
|
+
* joiner is a value or a function of the separator's index.
|
|
579
|
+
*
|
|
580
|
+
* ```js
|
|
581
|
+
* html`${join(tags, html`<span>, </span>`)}`;
|
|
582
|
+
* ```
|
|
583
|
+
*
|
|
584
|
+
* @template I, J
|
|
585
|
+
* @param {Iterable<I> | undefined} items
|
|
586
|
+
* @param {J | ((index: number) => J)} joiner
|
|
587
|
+
* @returns {Generator<I | J>}
|
|
588
|
+
*/
|
|
589
|
+
export function* join(items, joiner) {
|
|
590
|
+
if (items === undefined) return;
|
|
591
|
+
const isFunction = typeof joiner === 'function';
|
|
592
|
+
let i = -1;
|
|
593
|
+
for (const value of items) {
|
|
594
|
+
if (i > -1) yield isFunction ? /** @type {any} */ (joiner)(i) : joiner;
|
|
595
|
+
i++;
|
|
596
|
+
yield value;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* A generator of numbers, lit's `range`: `range(end)`, `range(start, end)` or
|
|
602
|
+
* `range(start, end, step)`. Pair it with `map` to render a counted list.
|
|
603
|
+
*
|
|
604
|
+
* ```js
|
|
605
|
+
* html`<ul>${map(range(3), (i) => html`<li>${i}</li>`)}</ul>`;
|
|
606
|
+
* ```
|
|
607
|
+
*
|
|
608
|
+
* @param {number} startOrEnd
|
|
609
|
+
* @param {number} [end]
|
|
610
|
+
* @param {number} [step]
|
|
611
|
+
* @returns {Generator<number>}
|
|
612
|
+
*/
|
|
613
|
+
export function* range(startOrEnd, end, step = 1) {
|
|
614
|
+
const start = end === undefined ? 0 : startOrEnd;
|
|
615
|
+
end ??= startOrEnd;
|
|
616
|
+
for (let i = start; step > 0 ? i < end : end < i; i += step) yield i;
|
|
617
|
+
}
|
|
618
|
+
|
|
462
619
|
export { classMap, styleMap, ifDefined } from './attribute-directives.js';
|
package/src/form-action.d.ts
CHANGED
|
@@ -14,3 +14,16 @@ export const FORM_ACTION_ID_KEY: '$$webjsAction';
|
|
|
14
14
|
export function setFormActionResolver(
|
|
15
15
|
fn: (fn: Function) => string | null | Promise<string | null>,
|
|
16
16
|
): void;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Give an action (or its browser stub) a `.bind` that keeps its identity, so
|
|
20
|
+
* `<form action=${rsvp.bind(null, slug)}>` submits `rsvp(slug, formData)`.
|
|
21
|
+
* Wired by `@webjsdev/server` and by every generated stub; apps just call `.bind`.
|
|
22
|
+
*/
|
|
23
|
+
export function installActionBind<F extends Function>(fn: F): F;
|
|
24
|
+
|
|
25
|
+
/** The identity a form submits for an action bound to `args` (`<hash>/<fn>#<args>`). */
|
|
26
|
+
export function bindActionId(id: string, args: unknown[]): string;
|
|
27
|
+
|
|
28
|
+
/** Split a submitted identity into the action's own identity and its bound arguments. */
|
|
29
|
+
export function splitActionId(value: string): { id: string; args: unknown[] };
|
package/src/form-action.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isBindingPrefix } from './binding-prefixes.js';
|
|
2
2
|
import { escapeAttr } from './escape.js';
|
|
3
|
+
import { stringifySync, parse } from './serialize.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Form actions: binding a `'use server'` action straight into a form (#1155),
|
|
@@ -109,6 +110,89 @@ export const FORM_ACTION_FIELD = '__webjs_action';
|
|
|
109
110
|
*/
|
|
110
111
|
export const FORM_ACTION_ID_KEY = '$$webjsAction';
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Bound form actions, Next.js's way to give a form action an extra argument:
|
|
115
|
+
* `<form action=${rsvp.bind(null, event.slug)}>` calls `rsvp(slug, formData)`.
|
|
116
|
+
* The arguments ride in the same hidden field as the identity, after a `#`
|
|
117
|
+
* (`<hash>/<fn>#<encoded args>`), so the submission works with JavaScript off
|
|
118
|
+
* and the server renderer, the browser renderer and the dispatcher share one
|
|
119
|
+
* encoding. They are not signed: an action is a public endpoint anyone can call
|
|
120
|
+
* with any arguments, so it authorizes them like any other input, as in Next.
|
|
121
|
+
*/
|
|
122
|
+
const BOUND_SEP = '#';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The identity a form submits for `action` bound to `args`.
|
|
126
|
+
* @param {string} id the action's own `<hash>/<fn>` identity
|
|
127
|
+
* @param {unknown[]} args
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function bindActionId(id, args) {
|
|
131
|
+
return args.length ? `${id}${BOUND_SEP}${encodeURIComponent(stringifySync(args))}` : id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Split a submitted identity into the action's own identity and its bound
|
|
136
|
+
* arguments. Throws on an argument list that does not decode to an array.
|
|
137
|
+
* @param {string} value
|
|
138
|
+
* @returns {{ id: string, args: unknown[] }}
|
|
139
|
+
*/
|
|
140
|
+
export function splitActionId(value) {
|
|
141
|
+
const at = value.indexOf(BOUND_SEP);
|
|
142
|
+
if (at === -1) return { id: value, args: [] };
|
|
143
|
+
const args = parse(decodeURIComponent(value.slice(at + 1)));
|
|
144
|
+
if (!Array.isArray(args)) throw new TypeError('bound action arguments are not a list');
|
|
145
|
+
return { id: value.slice(0, at), args };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bound functions made by an action's own `.bind`, mapped to the unbound
|
|
150
|
+
* action and every argument bound so far.
|
|
151
|
+
* @type {WeakMap<Function, { target: Function, args: unknown[] }>}
|
|
152
|
+
*/
|
|
153
|
+
const _bound = new WeakMap();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Give a server action (or its browser stub) a `.bind` that keeps its identity,
|
|
157
|
+
* so the bound function can still be bound to a form. Called by the server's
|
|
158
|
+
* action wrapper and by every generated stub. Idempotent.
|
|
159
|
+
* @template {Function} F
|
|
160
|
+
* @param {F} fn
|
|
161
|
+
* @returns {F}
|
|
162
|
+
*/
|
|
163
|
+
export function installActionBind(fn) {
|
|
164
|
+
if (typeof fn !== 'function' || Object.prototype.hasOwnProperty.call(fn, 'bind')) return fn;
|
|
165
|
+
try {
|
|
166
|
+
Object.defineProperty(fn, 'bind', { value: bindAction, configurable: true, writable: true });
|
|
167
|
+
} catch {}
|
|
168
|
+
return fn;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `Function.prototype.bind`, plus the record that lets the renderers find the
|
|
173
|
+
* action and its arguments. The identity is a getter, computed when a form
|
|
174
|
+
* reads it, so a bound action called directly (over RPC, with a File among its
|
|
175
|
+
* arguments) never has to encode anything.
|
|
176
|
+
* @this {Function}
|
|
177
|
+
* @param {unknown} thisArg
|
|
178
|
+
* @param {...unknown} args
|
|
179
|
+
* @returns {Function}
|
|
180
|
+
*/
|
|
181
|
+
function bindAction(thisArg, ...args) {
|
|
182
|
+
const bound = Function.prototype.bind.call(this, thisArg, ...args);
|
|
183
|
+
const prev = _bound.get(this);
|
|
184
|
+
const target = prev ? prev.target : this;
|
|
185
|
+
const all = prev ? [...prev.args, ...args] : args;
|
|
186
|
+
_bound.set(bound, { target, args: all });
|
|
187
|
+
const own = formActionId(target);
|
|
188
|
+
if (own) {
|
|
189
|
+
try {
|
|
190
|
+
Object.defineProperty(bound, FORM_ACTION_ID_KEY, { get: () => bindActionId(own, all) });
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
return installActionBind(bound);
|
|
194
|
+
}
|
|
195
|
+
|
|
112
196
|
/** @type {((fn: Function) => (string | null | Promise<string | null>)) | null} */
|
|
113
197
|
let _resolver = null;
|
|
114
198
|
|
|
@@ -155,6 +239,11 @@ export async function resolveFormActionId(fn) {
|
|
|
155
239
|
const own = formActionId(fn);
|
|
156
240
|
if (own) return own;
|
|
157
241
|
if (typeof fn !== 'function' || !_resolver) return null;
|
|
242
|
+
const bound = _bound.get(fn);
|
|
243
|
+
if (bound) {
|
|
244
|
+
const base = await resolveFormActionId(bound.target);
|
|
245
|
+
return base ? bindActionId(base, bound.args) : null;
|
|
246
|
+
}
|
|
158
247
|
try {
|
|
159
248
|
const id = await _resolver(/** @type {Function} */ (fn));
|
|
160
249
|
return typeof id === 'string' && id ? id : null;
|
|
@@ -491,6 +580,9 @@ export function assertSubmittableSubmitter(tag, formMethod, formEnctype) {
|
|
|
491
580
|
*/
|
|
492
581
|
export function assertNotFunctionActionAttr(val, attrName, tag) {
|
|
493
582
|
if (!carriesFunction(val)) return;
|
|
583
|
+
// Every attribute commit site already calls this guard, so the React-style
|
|
584
|
+
// `onClick=${fn}` refusal rides it too (one call site per renderer path).
|
|
585
|
+
assertNotFunctionEventAttr(val, attrName, tag);
|
|
494
586
|
if (!isFormActionAttr(attrName)) return;
|
|
495
587
|
throw new Error(formActionError(attrName, tag));
|
|
496
588
|
}
|
|
@@ -552,19 +644,36 @@ export function assertNotFunctionReflectedActionProp(val, propName, tag) {
|
|
|
552
644
|
* looks right and silently posts nowhere, which is the one outcome a
|
|
553
645
|
* progressive-enhancement form must never have, so this throws instead.
|
|
554
646
|
*
|
|
647
|
+
* A function made with the native `Function.prototype.bind` (anything but an
|
|
648
|
+
* action's own `.bind`, which keeps the identity) gets its own message, since
|
|
649
|
+
* the inline-`'use server'` advice would send its author the wrong way.
|
|
650
|
+
*
|
|
555
651
|
* @param {string | null} id
|
|
556
652
|
* @param {string} [tag]
|
|
653
|
+
* @param {unknown} [fn] the bound value, to name a `.bind()` result
|
|
557
654
|
* @returns {string} the identity, narrowed
|
|
558
655
|
*/
|
|
559
|
-
export function assertIdentifiableAction(id, tag) {
|
|
656
|
+
export function assertIdentifiableAction(id, tag, fn) {
|
|
560
657
|
if (id) return id;
|
|
561
658
|
const attr = tag === 'button' || tag === 'input' ? 'formaction=' : 'action=';
|
|
659
|
+
if (typeof fn === 'function' && /^bound /.test(fn.name)) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`[webjs] the function in ${attr} on <${tag || 'form'}> is a bound function that is not a `
|
|
662
|
+
+ `server action. Bind the imported action itself: action.bind(null, id) on a function `
|
|
663
|
+
+ `exported from a 'use server' *.server.{js,ts} module keeps its identity, and the bound `
|
|
664
|
+
+ `arguments arrive before the form data. See https://webjs.dev/docs/server-actions#bound-arguments`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
562
667
|
throw new Error(
|
|
563
668
|
`[webjs] the function in ${attr} on <${tag || 'form'}> is not a server action. `
|
|
564
669
|
+ `Only a function imported from a 'use server' *.server.{js,ts} module can be `
|
|
565
670
|
+ `bound to a form or a submitter, because the identity the browser submits `
|
|
566
|
-
+ `has to resolve back to something the server can run.
|
|
567
|
-
+ `a
|
|
671
|
+
+ `has to resolve back to something the server can run. A 'use server' `
|
|
672
|
+
+ `directive inside a function body (Next.js's inline action) is not one: the `
|
|
673
|
+
+ `boundary is a file. Move the function into its own module, e.g. `
|
|
674
|
+
+ `modules/<feature>/actions/<name>.server.ts starting with 'use server', `
|
|
675
|
+
+ `export it and import it here, or pass a url string. `
|
|
676
|
+
+ `See https://webjs.dev/docs/troubleshooting#inline-use-server`,
|
|
568
677
|
);
|
|
569
678
|
}
|
|
570
679
|
|
|
@@ -883,7 +992,7 @@ export function reconcileFormAction(form, value, method, enctype, shape) {
|
|
|
883
992
|
// Not a bound action (a url string, null, or a function the browser stub
|
|
884
993
|
// never stamped). A function that was MEANT as an action still refuses, so
|
|
885
994
|
// a form never silently posts nowhere.
|
|
886
|
-
if (typeof value === 'function') assertIdentifiableAction(null, form.localName);
|
|
995
|
+
if (typeof value === 'function') assertIdentifiableAction(null, form.localName, value);
|
|
887
996
|
releaseFormAction(form, method, enctype, shape.propAttrs);
|
|
888
997
|
return;
|
|
889
998
|
}
|
|
@@ -1256,3 +1365,29 @@ function formActionError(attrName, tag) {
|
|
|
1256
1365
|
+ `action=\${action} on the <form> itself or a formaction=\${action} on a `
|
|
1257
1366
|
+ `<button> inside a bound form. Anywhere else, pass a URL string.`;
|
|
1258
1367
|
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Refuse a function interpolated into an `on*` ATTRIBUTE (`onClick=${fn}`,
|
|
1371
|
+
* `onclick="${fn}"`, `?onclick=${fn}`), React's event spelling. An attribute
|
|
1372
|
+
* stringifies its value, so the function's SOURCE would be written into the
|
|
1373
|
+
* HTML as an inline handler that runs in the global scope with none of its
|
|
1374
|
+
* closure: it never does what it says, and it ships the code to every
|
|
1375
|
+
* visitor. A WebComponent template binds events with `@click=${fn}`.
|
|
1376
|
+
*
|
|
1377
|
+
* @param {unknown} val
|
|
1378
|
+
* @param {string | undefined} attrName
|
|
1379
|
+
* @param {string | undefined} tag
|
|
1380
|
+
*/
|
|
1381
|
+
export function assertNotFunctionEventAttr(val, attrName, tag) {
|
|
1382
|
+
if (!carriesFunction(val)) return;
|
|
1383
|
+
const name = String(attrName || '').replace(/^[?.@]/, '');
|
|
1384
|
+
if (!/^on[a-z]/i.test(name)) return;
|
|
1385
|
+
const event = name.slice(2).toLowerCase();
|
|
1386
|
+
throw new Error(
|
|
1387
|
+
`webjs: a function was interpolated into the \`${name}\` attribute on <${tag || 'element'}>. `
|
|
1388
|
+
+ `An attribute is a string, so the function's source would be written into the HTML as an inline handler `
|
|
1389
|
+
+ `that runs without its closure. Bind the event with \`@${event}=\${handler}\` in a WebComponent's render() `
|
|
1390
|
+
+ `(a page or layout never hydrates, so its handlers must live in a component). `
|
|
1391
|
+
+ `See https://webjs.dev/docs/troubleshooting#function-in-on-attribute`,
|
|
1392
|
+
);
|
|
1393
|
+
}
|
package/src/hot.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev hot-swap seam (#1689). The dev server's reload client installs a hot
|
|
3
|
+
* client on `globalThis[Symbol.for('webjs.hot')]` before any component module
|
|
4
|
+
* runs; production never does, so each read here is one global lookup that
|
|
5
|
+
* finds nothing. No logic lives in core: the client is inlined into
|
|
6
|
+
* `/__webjs/reload.js` from `@webjsdev/server`'s `dev-hot-client.js`.
|
|
7
|
+
*/
|
|
8
|
+
const HOT = Symbol.for('webjs.hot');
|
|
9
|
+
const g = /** @type {any} */ (globalThis);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The template strings `html()` / `svg()` / `mathml()` keep. Under the dev
|
|
13
|
+
* hot client, textually identical templates share one strings object across
|
|
14
|
+
* module evaluations, so a hot swap rebuilds only the templates whose text
|
|
15
|
+
* changed.
|
|
16
|
+
* @param {TemplateStringsArray | string[]} s
|
|
17
|
+
* @param {0 | 1 | 2} kind `html`, `svg` or `mathml`, so equal text under two tags stays apart
|
|
18
|
+
* @returns {TemplateStringsArray | string[]}
|
|
19
|
+
*/
|
|
20
|
+
export function hotStrings(s, kind) { const h = g[HOT]; return h === undefined ? s : h.strings(s, kind); }
|
|
21
|
+
|
|
22
|
+
/** @returns {((tag: string, prev: Function, next: Function) => Function) | null} */
|
|
23
|
+
export function hotReplacer() { const h = g[HOT]; return h === undefined ? null : h.replace; }
|
package/src/html.d.ts
CHANGED
|
@@ -4,10 +4,14 @@ export interface TemplateResult {
|
|
|
4
4
|
values: unknown[];
|
|
5
5
|
/** Set by the `svg` tag: the client parses the strings inside an `<svg>` wrapper. */
|
|
6
6
|
svg?: boolean;
|
|
7
|
+
/** Set by the `mathml` tag: the client parses the strings inside a `<math>` wrapper. */
|
|
8
|
+
mathml?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export function html(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|
|
10
12
|
/** lit's `svg` tag: a fragment parsed as SVG on the client, rendered like `html` on the server. */
|
|
11
13
|
export function svg(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|
|
14
|
+
/** lit's `mathml` tag: a fragment parsed as MathML on the client, rendered like `html` on the server. */
|
|
15
|
+
export function mathml(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|
|
12
16
|
export function isTemplate(x: unknown): x is TemplateResult;
|
|
13
17
|
export const MARKER: 'wjm-';
|
package/src/html.js
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
|
+
import { hotStrings } from './hot.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Tagged template literal producing a {@link TemplateResult}.
|
|
3
5
|
*
|
|
4
6
|
* A TemplateResult is an isomorphic description of HTML: the static string
|
|
5
7
|
* pieces plus the dynamic values. Server and client renderers both consume it.
|
|
6
8
|
*
|
|
7
|
-
* @typedef {{ _$webjs: 'template', strings: TemplateStringsArray | string[], values: unknown[], svg?: boolean }} TemplateResult
|
|
9
|
+
* @typedef {{ _$webjs: 'template', strings: TemplateStringsArray | string[], values: unknown[], svg?: boolean, mathml?: boolean }} TemplateResult
|
|
8
10
|
*
|
|
9
11
|
* @param {TemplateStringsArray | string[]} strings
|
|
10
12
|
* @param {...unknown} values
|
|
11
13
|
* @returns {TemplateResult}
|
|
12
14
|
*/
|
|
13
15
|
export function html(strings, ...values) {
|
|
14
|
-
return { _$webjs: 'template', strings, values };
|
|
16
|
+
return { _$webjs: 'template', strings: hotStrings(trusted(strings), 0), values };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The renderers render a template result's strings as markup only when they
|
|
21
|
+
* carry `raw`, which a tagged template's strings always do, so a result that
|
|
22
|
+
* arrived as data (JSON with the `_$webjs` brand) is refused. A plain array
|
|
23
|
+
* passed to `html()` / `svg()` / `mathml()` is code, as `string[]` in the type
|
|
24
|
+
* says, so it is marked the same way (kept in place so the template cache
|
|
25
|
+
* still keys on it, copied only when frozen).
|
|
26
|
+
* @param {TemplateStringsArray | string[]} strings
|
|
27
|
+
*/
|
|
28
|
+
function trusted(strings) {
|
|
29
|
+
if (!Array.isArray(strings) || Object.prototype.hasOwnProperty.call(strings, 'raw')) return strings;
|
|
30
|
+
const target = Object.isExtensible(strings) ? strings : strings.slice();
|
|
31
|
+
Object.defineProperty(target, 'raw', { value: target });
|
|
32
|
+
return target;
|
|
15
33
|
}
|
|
16
34
|
|
|
17
35
|
/**
|
|
@@ -32,7 +50,21 @@ export function html(strings, ...values) {
|
|
|
32
50
|
* @returns {TemplateResult}
|
|
33
51
|
*/
|
|
34
52
|
export function svg(strings, ...values) {
|
|
35
|
-
return { _$webjs: 'template', strings, values, svg: true };
|
|
53
|
+
return { _$webjs: 'template', strings: hotStrings(trusted(strings), 1), values, svg: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tagged template literal for a MathML FRAGMENT, lit's `mathml` tag: markup
|
|
58
|
+
* that sits inside a `<math>` element but is built in its own template. The
|
|
59
|
+
* client parses it inside a `<math>` wrapper so its elements land in the
|
|
60
|
+
* MathML namespace; the server renders it exactly like `html`.
|
|
61
|
+
*
|
|
62
|
+
* @param {TemplateStringsArray | string[]} strings
|
|
63
|
+
* @param {...unknown} values
|
|
64
|
+
* @returns {TemplateResult}
|
|
65
|
+
*/
|
|
66
|
+
export function mathml(strings, ...values) {
|
|
67
|
+
return { _$webjs: 'template', strings: hotStrings(trusted(strings), 2), values, mathml: true };
|
|
36
68
|
}
|
|
37
69
|
|
|
38
70
|
/**
|
package/src/is-server.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `isServer`, lit's flag: `true` when this module runs outside a browser (the
|
|
3
|
+
* SSR pass on Node or Bun), `false` in a browser.
|
|
4
|
+
*
|
|
5
|
+
* Use it to guard browser-only work in code that also runs at SSR (a
|
|
6
|
+
* component's constructor, `render()`, `willUpdate()`, a controller's
|
|
7
|
+
* `hostUpdate()`):
|
|
8
|
+
*
|
|
9
|
+
* ```js
|
|
10
|
+
* import { isServer } from '@webjsdev/core';
|
|
11
|
+
* if (!isServer) this.width = window.innerWidth;
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Decided by the presence of `window` and `document`, the same test the
|
|
15
|
+
* `WebComponent` base uses to pick between `HTMLElement` and its server shim,
|
|
16
|
+
* so the two can never disagree.
|
|
17
|
+
*/
|
|
18
|
+
export const isServer = !(typeof window !== 'undefined' && typeof document !== 'undefined');
|
package/src/jsx-guard.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-isms in an `html` template that can never work, found once per
|
|
3
|
+
* template (the compilers call this beside their compile cache), so the
|
|
4
|
+
* mistake is a loud render error naming the fix instead of a silently wrong
|
|
5
|
+
* page:
|
|
6
|
+
*
|
|
7
|
+
* - `className=` / `htmlFor=`: JSX's DOM-property spellings. In HTML they
|
|
8
|
+
* are plain unknown attributes (`classname`, `htmlfor`), so the class
|
|
9
|
+
* never applies and the label never points at its control.
|
|
10
|
+
* - A capitalised tag (`<Link>`, `<Button>`): a JSX component. HTML
|
|
11
|
+
* lowercases it into an unknown element (`<link>` is even a void
|
|
12
|
+
* metadata element), so nothing renders.
|
|
13
|
+
*
|
|
14
|
+
* The scan follows the HTML tokenizer states that matter: text, a start tag
|
|
15
|
+
* and its attributes, quoted values, comments, and raw text (`script`,
|
|
16
|
+
* `style`, `textarea`, `title`), so prose such as `<Link>` or a
|
|
17
|
+
* `className` mentioned in text is never flagged.
|
|
18
|
+
*
|
|
19
|
+
* @module jsx-guard
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const RAW = new Set(['script', 'style', 'textarea', 'title']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{ kind: 'attr' | 'tag', name: string }} JsxIsm
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The first React-ism in a template's static strings, or null.
|
|
30
|
+
* @param {readonly string[]} strings
|
|
31
|
+
* @returns {JsxIsm | null}
|
|
32
|
+
*/
|
|
33
|
+
export function findJsxIsm(strings) {
|
|
34
|
+
let mode = 'text'; // text | tagname | tag | attr | q | comment | raw
|
|
35
|
+
let quote = '';
|
|
36
|
+
let name = '';
|
|
37
|
+
let tag = '';
|
|
38
|
+
let closing = false;
|
|
39
|
+
let rawTag = '';
|
|
40
|
+
for (let s = 0; s < strings.length; s++) {
|
|
41
|
+
const str = strings[s];
|
|
42
|
+
// A hole ends whatever name was being read.
|
|
43
|
+
if (s > 0) {
|
|
44
|
+
if (mode === 'tagname') mode = 'tag';
|
|
45
|
+
else if (mode === 'attr') { name = ''; mode = 'tag'; }
|
|
46
|
+
}
|
|
47
|
+
for (let i = 0; i < str.length; i++) {
|
|
48
|
+
const c = str[i];
|
|
49
|
+
switch (mode) {
|
|
50
|
+
case 'text':
|
|
51
|
+
if (c === '<') {
|
|
52
|
+
if (str.startsWith('<!--', i)) { mode = 'comment'; i += 3; }
|
|
53
|
+
else if (/[A-Za-z/]/.test(str[i + 1] || '')) {
|
|
54
|
+
closing = str[i + 1] === '/';
|
|
55
|
+
if (closing) i++;
|
|
56
|
+
mode = 'tagname';
|
|
57
|
+
tag = '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case 'tagname':
|
|
62
|
+
if (/[\s/>]/.test(c)) {
|
|
63
|
+
if (!closing && /^[A-Z](?=.*[a-z])/.test(tag)) return { kind: 'tag', name: tag };
|
|
64
|
+
mode = c === '>' ? afterTag(tag, closing) : 'tag';
|
|
65
|
+
if (mode === 'raw') rawTag = tag.toLowerCase();
|
|
66
|
+
} else tag += c;
|
|
67
|
+
break;
|
|
68
|
+
case 'tag':
|
|
69
|
+
if (c === '>') { mode = afterTag(tag, closing); if (mode === 'raw') rawTag = tag.toLowerCase(); }
|
|
70
|
+
else if (c === '"' || c === "'") { mode = 'q'; quote = c; }
|
|
71
|
+
else if (!/[\s/=]/.test(c)) { mode = 'attr'; name = c; }
|
|
72
|
+
break;
|
|
73
|
+
case 'attr':
|
|
74
|
+
if (/[\s=/>]/.test(c)) {
|
|
75
|
+
if (name === 'className' || name === 'htmlFor') return { kind: 'attr', name };
|
|
76
|
+
name = '';
|
|
77
|
+
if (c === '>') { mode = afterTag(tag, closing); if (mode === 'raw') rawTag = tag.toLowerCase(); }
|
|
78
|
+
else mode = 'tag';
|
|
79
|
+
} else name += c;
|
|
80
|
+
break;
|
|
81
|
+
case 'q':
|
|
82
|
+
if (c === quote) mode = 'tag';
|
|
83
|
+
break;
|
|
84
|
+
case 'comment':
|
|
85
|
+
if (str.startsWith('-->', i)) { mode = 'text'; i += 2; }
|
|
86
|
+
break;
|
|
87
|
+
case 'raw':
|
|
88
|
+
if (c === '<' && str.slice(i + 2, i + 2 + rawTag.length).toLowerCase() === rawTag && str[i + 1] === '/') {
|
|
89
|
+
mode = 'tagname'; closing = true; tag = ''; i += 1;
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (mode === 'attr' && (name === 'className' || name === 'htmlFor')) return { kind: 'attr', name };
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} tag
|
|
103
|
+
* @param {boolean} closing
|
|
104
|
+
*/
|
|
105
|
+
function afterTag(tag, closing) {
|
|
106
|
+
return !closing && RAW.has(tag.toLowerCase()) ? 'raw' : 'text';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The error message for a React-ism, naming the WebJs fix.
|
|
111
|
+
* @param {JsxIsm} ism
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function jsxIsmMessage(ism) {
|
|
115
|
+
if (ism.kind === 'attr') {
|
|
116
|
+
const html = ism.name === 'className' ? 'class' : 'for';
|
|
117
|
+
return `webjs: \`${ism.name}=\` in an html template is JSX, not HTML: the browser reads it as an unknown \`${ism.name.toLowerCase()}\` attribute and it does nothing. Write \`${html}=\` instead (\`${html}=\${...}\` for a dynamic value). See https://webjs.dev/docs/troubleshooting#jsx-in-html-template`;
|
|
118
|
+
}
|
|
119
|
+
return `webjs: \`<${ism.name}>\` in an html template is a JSX component, not HTML: the browser lowercases it into an unknown \`<${ism.name.toLowerCase()}>\` element and renders nothing. Use the custom element's tag (\`<my-${ism.name.toLowerCase()}>\`, registered with ${ism.name}.register('my-${ism.name.toLowerCase()}')), or a plain element such as \`<a href>\` for a link. See https://webjs.dev/docs/troubleshooting#jsx-in-html-template`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Throw when a template carries a React-ism. Called once per template by
|
|
124
|
+
* each compiler, so the cost is paid at first render only.
|
|
125
|
+
* @param {readonly string[]} strings
|
|
126
|
+
*/
|
|
127
|
+
export function assertNoJsxIsms(strings) {
|
|
128
|
+
const ism = findJsxIsm(strings);
|
|
129
|
+
if (ism) throw new Error(jsxIsmMessage(ism));
|
|
130
|
+
}
|