@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.
Files changed (85) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +20 -19
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +14 -9
  9. package/index.d.ts +53 -10
  10. package/index.js +17 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.d.ts +18 -0
  16. package/src/asset-url.js +12 -13
  17. package/src/async-directive.d.ts +18 -0
  18. package/src/async-directive.js +88 -0
  19. package/src/attribute-reader.js +197 -23
  20. package/src/binding-prefixes.js +0 -12
  21. package/src/component/lifecycle.js +760 -209
  22. package/src/component/reactive.js +2 -10
  23. package/src/component/server-element.js +23 -1
  24. package/src/component.d.ts +151 -41
  25. package/src/context.d.ts +94 -18
  26. package/src/context.js +341 -217
  27. package/src/converters.d.ts +7 -0
  28. package/src/converters.js +38 -0
  29. package/src/csp-nonce.d.ts +18 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +38 -1
  35. package/src/directives.js +158 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +4 -0
  40. package/src/html.js +35 -3
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +8 -3
  51. package/src/render-client/parts.js +745 -31
  52. package/src/render-client/reconciler.js +56 -11
  53. package/src/render-client/template-compiler.js +139 -44
  54. package/src/render-client.d.ts +5 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +34 -0
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +61 -8
  59. package/src/render-server/served-attrs.js +1 -2
  60. package/src/render-server/walk.js +135 -3
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/dom-differ.js +17 -0
  66. package/src/router-client/fetch-apply.js +86 -5
  67. package/src/router-client/navigator.js +45 -9
  68. package/src/router-client/prefetch.js +3 -4
  69. package/src/router-client/root-attrs.js +43 -0
  70. package/src/router-client/stream.js +7 -0
  71. package/src/router-client/swap.js +4 -1
  72. package/src/routes.d.ts +75 -18
  73. package/src/serialize.d.ts +2 -0
  74. package/src/serialize.js +35 -4
  75. package/src/signal.d.ts +7 -0
  76. package/src/signal.js +26 -6
  77. package/src/slot/polyfills.js +3 -3
  78. package/src/slot.js +4 -8
  79. package/src/static-html.d.ts +22 -0
  80. package/src/static-html.js +144 -0
  81. package/src/task.d.ts +63 -20
  82. package/src/task.js +326 -293
  83. package/src/testing.d.ts +42 -0
  84. package/src/webjs-config.d.ts +1 -8
  85. 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
- return { _$webjs: 'unsafe-html', value: String(htmlString ?? '') };
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';
@@ -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[] };
@@ -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. Move the handler into `
567
- + `a server action and import it, or pass a url string.`,
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
  /**
@@ -0,0 +1,2 @@
1
+ /** lit's `isServer`: `true` outside a browser (the SSR pass), `false` in one. */
2
+ export const isServer: boolean;
@@ -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');
@@ -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 `&lt;Link&gt;` 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
+ }