@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
@@ -1,5 +1,5 @@
1
1
  import { nothing, noChange } from '../sentinels.js';
2
- import { isTemplate, MARKER } from '../html.js';
2
+ import { isTemplate, MARKER, html } from '../html.js';
3
3
  import {
4
4
  reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName, assertSubmitterType,
5
5
  assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
@@ -14,8 +14,16 @@ import { compile, submitterActionBindings, INSTANCE } from './template-compiler.
14
14
  import { commitAdoption, planSlotAdoption, retargetParts } from './adopt-slots.js';
15
15
  import {
16
16
  applyPart, bindPart, currentRenderHost, setCurrentRenderHost, currentRenderRoot, findSlotHost, setCurrentRenderRoot, updateInstance,
17
+ releaseInstanceDirectives, setInstanceDirectivesConnected, ROOT_DISCONNECTED,
17
18
  } from './parts.js';
18
19
 
20
+ /**
21
+ * A top-level list as one child binding. One call site, so its strings are a
22
+ * stable identity and a re-render updates the list in place.
23
+ * @param {unknown} list
24
+ */
25
+ const rootList = (list) => html`${list}`;
26
+
19
27
  /**
20
28
  * Render a value into a container, reusing DOM where possible.
21
29
  *
@@ -24,7 +32,48 @@ import {
24
32
  * @param {{host?: object}} [options]
25
33
  */
26
34
  export function render(value, container, options = {}) {
35
+ renderValue(value, container, options);
36
+ return rootPart(container);
37
+ }
38
+
39
+ /** @type {WeakMap<object, { setConnected(connected: boolean): void }>} */
40
+ const rootParts = new WeakMap();
41
+
42
+ /**
43
+ * What `render()` returns, lit's `RootPart`: `setConnected(false)` reports
44
+ * every async directive in the container disconnected (and starts the ones a
45
+ * later render creates disconnected), `setConnected(true)` reconnects them.
46
+ * @param {any} container
47
+ */
48
+ function rootPart(container) {
49
+ let part = rootParts.get(container);
50
+ if (!part) {
51
+ part = {
52
+ setConnected(connected) {
53
+ container[ROOT_DISCONNECTED] = !connected;
54
+ setInstanceDirectivesConnected(container[INSTANCE], connected);
55
+ },
56
+ };
57
+ rootParts.set(container, part);
58
+ }
59
+ return part;
60
+ }
61
+
62
+ /**
63
+ * @param {unknown} value
64
+ * @param {Element | DocumentFragment | ShadowRoot} container
65
+ * @param {{host?: object}} options
66
+ */
67
+ function renderValue(value, container, options) {
27
68
  if (value === noChange) return;
69
+ // Any object that is not a template (an array or other iterable such as a
70
+ // `map()` / `join()` / `range()` generator or a Set, a DOM node, a directive)
71
+ // renders through a child binding, as Lit's `render()` renders every value
72
+ // (#1686). It used to be written as text, "[object Generator]" or
73
+ // "[object Object]".
74
+ if (value !== null && typeof value === 'object' && !isTemplate(value)) {
75
+ value = rootList(value);
76
+ }
28
77
  const host = /** @type any */ (container);
29
78
  // Open the renderer-write window for the whole commit: every host-receiver
30
79
  // write below (and in createInstance / updateInstance / clearInstance / all
@@ -75,13 +124,6 @@ export function render(value, container, options = {}) {
75
124
  host[INSTANCE] = null;
76
125
  container.replaceChildren();
77
126
  if (value == null || value === nothing) return;
78
- if (Array.isArray(value)) {
79
- for (const v of value) {
80
- const text = document.createTextNode(String(v ?? ''));
81
- container.appendChild(text);
82
- }
83
- return;
84
- }
85
127
  container.appendChild(document.createTextNode(String(value)));
86
128
  } finally {
87
129
  setCurrentRenderRoot(prevRenderRoot);
@@ -210,7 +252,7 @@ function reconcileSubmitterAction(
210
252
  if (!id) {
211
253
  // A function that was MEANT as an action still refuses, so a button never
212
254
  // silently submits the form's action instead of its own.
213
- if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
255
+ if (typeof value === 'function') assertIdentifiableAction(null, el.localName, value);
214
256
  releaseSubmitterAction(el);
215
257
  // The submission attributes the framework supplied go with the identity, or
216
258
  // a released button keeps a `formmethod` / `formenctype` SSR does not emit
@@ -316,7 +358,7 @@ function resolveHoleValue(v) {
316
358
  */
317
359
  function createInstance(tr, container, hydrating = false) {
318
360
  const { templateEl, parts, formActions } = compile(tr);
319
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
361
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
320
362
  // Bookend markers bound the instance so we can tear it down cleanly.
321
363
  const startNode = document.createComment(`${MARKER}s`);
322
364
  const endNode = document.createComment(`${MARKER}e`);
@@ -378,6 +420,9 @@ function clearInstance(inst, container) {
378
420
  // element parts, and rescue any projected children sitting inside
379
421
  // slot parts so they survive teardown of a collapsing conditional
380
422
  // fragment.
423
+ // Custom directives in the outgoing template are released first (an async
424
+ // one runs its `disconnected()`), as lit does when a part goes away.
425
+ releaseInstanceDirectives(inst);
381
426
  for (const p of inst.bound) {
382
427
  if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher, p.handler);
383
428
  if (p.kind === 'element') {
@@ -391,7 +436,7 @@ function clearInstance(inst, container) {
391
436
  const prev = /** @type any */ (p).lastTarget;
392
437
  if (prev) {
393
438
  if (typeof prev === 'function') {
394
- try { prev(undefined); } catch {}
439
+ try { prev.call(/** @type any */ (p).refHost, undefined); } catch {}
395
440
  } else if (typeof prev === 'object') {
396
441
  try { prev.value = undefined; } catch {}
397
442
  }
@@ -2,6 +2,7 @@ import { MARKER } from '../html.js';
2
2
  import { BINDING_PREFIXES, isBindingPrefix } from '../binding-prefixes.js';
3
3
  import { isSubmitterReflectedProp } from '../form-action.js';
4
4
  import { LIGHT_SLOT_ATTR } from '../slot.js';
5
+ import { assertNoJsxIsms } from '../jsx-guard.js';
5
6
 
6
7
  /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[], formActions: FormActionRecord[] | null }>} */
7
8
  export const templateCache = new WeakMap();
@@ -12,6 +13,12 @@ export const templateCache = new WeakMap();
12
13
  */
13
14
  export const submitterActionBindings = new WeakMap();
14
15
  export const INSTANCE = Symbol.for('webjs.instance');
16
+ /** Elements whose content the HTML parser reads as text, never markup. */
17
+ const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title']);
18
+ /** HTML's own whitespace; `\s` would also match U+00A0, which is attribute text. */
19
+ const HTML_WS = /[ \t\n\f\r]/;
20
+ /** Delimits a raw-text hole's index; a private-use character no parser alters. */
21
+ const RAW_SENTINEL = '\uE000';
15
22
 
16
23
  /**
17
24
  * @typedef {{
@@ -37,7 +44,7 @@ export const INSTANCE = Symbol.for('webjs.instance');
37
44
  * | { kind: 'event', el: Element, name: string, handler: (EventListenerOrEventListenerObject & AddEventListenerOptions) | null, eventHost?: object, dispatcher: (e: Event) => void }
38
45
  * | { kind: 'prop', el: Element, name: string }
39
46
  * | { kind: 'bool', el: Element, name: string }
40
- * | { kind: 'element', el: Element, lastTarget?: any }
47
+ * | { kind: 'element', el: Element, lastTarget?: any, refHost?: object }
41
48
  * | { kind: 'slot', slotEl: HTMLSlotElement, applied: boolean }
42
49
  * | { kind: 'noop' }
43
50
  * } BoundPart
@@ -48,6 +55,14 @@ export function compile(tr) {
48
55
  const { strings } = tr;
49
56
  let cached = templateCache.get(strings);
50
57
  if (cached) return cached;
58
+ // Only a tagged template's own strings array carries `raw`, so a template
59
+ // result that arrived as data (JSON with the `_$webjs` brand) is refused
60
+ // rather than parsed as markup, lit's `trustFromTemplateString` check.
61
+ if (!Array.isArray(strings) || !Object.prototype.hasOwnProperty.call(strings, 'raw')) {
62
+ throw new Error('[webjs] refusing to render a template result whose strings are not a tagged template\'s (a TemplateResult built from data, not by html``).');
63
+ }
64
+ // `className=`, `htmlFor=`, `<Link>`: JSX that HTML silently ignores.
65
+ assertNoJsxIsms(strings);
51
66
 
52
67
  /** @type {PartDescriptor[]} */
53
68
  const parts = [];
@@ -60,7 +75,15 @@ export function compile(tr) {
60
75
  /** @type {{ name: string, firstPartIdx: number } | null} */
61
76
  let mixedAttr = null;
62
77
  let currentTag = '';
78
+ let closingTag = false;
63
79
  let rawTail = '';
80
+ // The content state a start tag's `>` opens: raw text for `script` / `style`
81
+ // / `textarea` / `title`, whose content the HTML parser never reads as markup,
82
+ // whichever attribute state the tag ended in (lit's `rawTextElement`).
83
+ const afterTag = () => {
84
+ rawTail = '';
85
+ return (!closingTag && RAW_TEXT_TAGS.has(currentTag)) ? 'rawtext' : 'text';
86
+ };
64
87
 
65
88
  for (let i = 0; i < strings.length; i++) {
66
89
  const s = strings[i];
@@ -74,18 +97,23 @@ export function compile(tr) {
74
97
  case 'tag-open':
75
98
  html += c;
76
99
  if (c === '!') state = 'bang-1';
77
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
78
- else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
100
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; closingTag = true; }
101
+ else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); closingTag = false; }
79
102
  else state = 'text';
80
103
  break;
81
104
  case 'bang-1':
82
105
  html += c;
83
- state = c === '-' ? 'bang-dash' : 'tag-name';
106
+ state = c === '-' ? 'bang-dash' : 'bogus';
84
107
  break;
85
108
  case 'bang-dash':
86
109
  html += c;
87
110
  if (c === '-') { state = 'comment'; commentDashes = 0; }
88
- else state = 'tag-name';
111
+ else state = 'bogus';
112
+ break;
113
+ case 'bogus':
114
+ // A bogus comment (`<!x ...>`, `</ ...>`) runs to the next `>`.
115
+ html += c;
116
+ if (c === '>') state = 'text';
89
117
  break;
90
118
  case 'comment':
91
119
  html += c;
@@ -95,18 +123,15 @@ export function compile(tr) {
95
123
  break;
96
124
  case 'tag-name':
97
125
  html += c;
98
- if (c === '>') {
99
- state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
100
- if (state === 'rawtext') rawTail = '';
101
- } else if (/\s/.test(c)) state = 'in-tag';
126
+ if (closingTag && currentTag === '' && c !== '>' && !/[a-zA-Z]/.test(c)) state = 'bogus';
127
+ else if (c === '>') state = afterTag();
128
+ else if (HTML_WS.test(c)) state = 'in-tag';
102
129
  else currentTag += c.toLowerCase();
103
130
  break;
104
131
  case 'in-tag':
105
132
  html += c;
106
- if (c === '>') {
107
- state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
108
- if (state === 'rawtext') rawTail = '';
109
- } else if (!/\s/.test(c) && c !== '/') {
133
+ if (c === '>') state = afterTag();
134
+ else if (!HTML_WS.test(c) && c !== '/') {
110
135
  state = 'attr-name';
111
136
  attrName = c;
112
137
  attrStart = html.length - 1;
@@ -114,8 +139,10 @@ export function compile(tr) {
114
139
  break;
115
140
  case 'rawtext':
116
141
  html += c;
117
- rawTail = (rawTail + c.toLowerCase()).slice(-9);
118
- if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
142
+ // Only the element's OWN end tag closes it: a `</style>` inside a
143
+ // `<script>` is script text.
144
+ rawTail = (rawTail + c.toLowerCase()).slice(-(currentTag.length + 3));
145
+ if (rawTail === `</${currentTag}>`) {
119
146
  state = 'text';
120
147
  rawTail = '';
121
148
  currentTag = '';
@@ -123,19 +150,28 @@ export function compile(tr) {
123
150
  break;
124
151
  case 'attr-name':
125
152
  if (c === '=') { state = 'after-eq'; html += c; }
126
- else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
127
- else if (c === '>') { state = 'text'; attrName = ''; html += c; }
153
+ else if (HTML_WS.test(c)) { state = 'after-name'; html += c; }
154
+ else if (c === '>') { state = afterTag(); attrName = ''; html += c; }
128
155
  else { attrName += c; html += c; }
129
156
  break;
157
+ case 'after-name':
158
+ // Whitespace between a name and its `=` (`abc = ${x}`), as the HTML
159
+ // tokenizer allows.
160
+ html += c;
161
+ if (c === '=') state = 'after-eq';
162
+ else if (c === '>') { state = afterTag(); attrName = ''; }
163
+ else if (c === '/') { state = 'in-tag'; attrName = ''; }
164
+ else if (!HTML_WS.test(c)) { state = 'attr-name'; attrName = c; attrStart = html.length - 1; }
165
+ break;
130
166
  case 'after-eq':
131
167
  if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; }
132
- else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
133
- else if (c === '>') { state = 'text'; attrName = ''; html += c; }
168
+ else if (HTML_WS.test(c)) { html += c; }
169
+ else if (c === '>') { state = afterTag(); attrName = ''; html += c; }
134
170
  else { state = 'attr-unquoted'; html += c; }
135
171
  break;
136
172
  case 'attr-unquoted':
137
- if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
138
- else if (c === '>') { state = 'text'; attrName = ''; html += c; }
173
+ if (HTML_WS.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
174
+ else if (c === '>') { state = afterTag(); attrName = ''; html += c; }
139
175
  else html += c;
140
176
  break;
141
177
  case 'attr-quoted':
@@ -144,8 +180,10 @@ export function compile(tr) {
144
180
  break;
145
181
  case 'skip-attr':
146
182
  // Consume mixed-attribute chars without appending to html.
147
- // The attribute was replaced with a sentinel on the first hole.
148
- if (c === attrQuote) {
183
+ // The attribute was replaced with a sentinel on the first hole. An
184
+ // UNQUOTED value (`attrQuote` empty, `a=x${y}` or `a=${x}${y}`) ends
185
+ // at whitespace, `>` or `/>`, as the HTML tokenizer ends it.
186
+ if (attrQuote === '' ? (HTML_WS.test(c) || c === '>' || (c === '/' && s[j + 1] === '>')) : c === attrQuote) {
149
187
  // Closing quote: finalize the attr-mixed part.
150
188
  if (mixedAttr) {
151
189
  const idx0 = mixedAttr.firstPartIdx;
@@ -160,13 +198,17 @@ export function compile(tr) {
160
198
  // statics[n] = prefix of strings[last+1] up to closing quote
161
199
  const statics = [];
162
200
  const s0 = strings[group[0]];
163
- const qp = s0.lastIndexOf(attrQuote);
164
- statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
201
+ if (attrQuote === '') {
202
+ statics.push(s0.slice(s0.lastIndexOf('=') + 1).trimStart());
203
+ } else {
204
+ const qp = s0.lastIndexOf(attrQuote);
205
+ statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
206
+ }
165
207
  for (let k = 1; k < group.length; k++) {
166
208
  statics.push(strings[group[k]]);
167
209
  }
168
210
  const sLast = strings[group[group.length - 1] + 1];
169
- const eq = sLast.indexOf(attrQuote);
211
+ const eq = attrQuote === '' ? j : sLast.indexOf(attrQuote);
170
212
  statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
171
213
 
172
214
  const specialized = isBindingPrefix(mixedAttr.name[0]);
@@ -191,8 +233,11 @@ export function compile(tr) {
191
233
  }
192
234
  mixedAttr = null;
193
235
  }
194
- state = 'in-tag';
195
236
  attrName = '';
237
+ // A quoted value's closing quote was consumed with it; an unquoted
238
+ // value's terminator is markup and is kept.
239
+ if (attrQuote === '') html += c;
240
+ state = (attrQuote === '' && c === '>') ? afterTag() : 'in-tag';
196
241
  }
197
242
  break;
198
243
  }
@@ -209,28 +254,38 @@ export function compile(tr) {
209
254
  continue;
210
255
  }
211
256
  if (state === 'rawtext') {
212
- // Inside <script>/<style>: per-render interpolation isn't supported;
213
- // the compile cache would lock in whatever was first rendered. The
214
- // hole is dropped and authors should set body text via a child part
215
- // outside the raw-text container, or inline style/script directly.
257
+ // Inside <script> / <style> / <textarea> / <title> the parser reads a
258
+ // comment marker as text, so the hole leaves a text sentinel that
259
+ // `splitRawTextSentinels` turns into a marker comment after parsing.
260
+ // It is then an ordinary child part committing text nodes, which is
261
+ // what lit does and what SSR's raw-text hole emits.
216
262
  rawTail = '';
217
- parts.push({ kind: 'noop', path: [] });
263
+ html += `${RAW_SENTINEL}${partIdx}${RAW_SENTINEL}`;
264
+ parts.push({ kind: 'child', path: [] });
218
265
  continue;
219
266
  }
267
+ // An unquoted value that goes on after its hole (`a=${x}${y}`,
268
+ // `a=${x}px`) is interpolated, like a quoted one.
269
+ const next = strings[i + 1];
270
+ const unquotedGoesOn = state === 'after-eq'
271
+ && (next === '' ? i + 1 < strings.length - 1 : !/^([ \t\n\f\r]|>|\/>)/.test(next));
220
272
  if (state === 'text') {
221
273
  // Child hole: insert a comment marker. Use bracketed markers so we can
222
274
  // later walk all comments and find ours without ambiguity.
223
275
  html += `<!--${MARKER}${partIdx}-->`;
224
276
  parts.push({ kind: 'child', path: [] });
225
- } else if (state === 'in-tag') {
226
- // Element-position hole: `<tag ${expr}>`. Used by the `ref` directive
277
+ } else if (state === 'in-tag' || state === 'after-name') {
278
+ // Element-position hole: `<tag ${expr}>`, also after a bare attribute
279
+ // (`<input disabled ${expr}>`). Used by the `ref` directive
227
280
  // (and any future element-bound directive). Emit a sentinel attribute
228
281
  // on the current open tag; at bind time the attribute is stripped
229
282
  // and the element is captured into the part.
230
283
  const sentinel = `data-${MARKER}${partIdx}`;
231
284
  html += `${sentinel}=""`;
232
285
  parts.push({ kind: 'element', path: [] });
233
- } else if (state === 'after-eq') {
286
+ state = 'in-tag';
287
+ attrName = '';
288
+ } else if (state === 'after-eq' && !unquotedGoesOn) {
234
289
  const prefix = attrName[0];
235
290
  const name = attrName.slice(1);
236
291
  if (isBindingPrefix(prefix)) {
@@ -249,35 +304,43 @@ export function compile(tr) {
249
304
  }
250
305
  state = 'in-tag';
251
306
  attrName = '';
252
- } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
307
+ } else if (state === 'attr-quoted' || state === 'attr-unquoted' || state === 'after-eq') {
253
308
  // First hole inside a quoted attribute value: start mixed-attr tracking.
254
309
  // Replace the entire attribute with a sentinel (same as regular attr).
310
+ if (state !== 'attr-quoted') attrQuote = '';
255
311
  html = html.slice(0, attrStart);
256
312
  const sentinel = `data-${MARKER}${partIdx}`;
257
313
  html += `${sentinel}=""`;
258
314
  mixedAttr = { name: attrName, firstPartIdx: partIdx };
259
315
  parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
260
316
  state = 'skip-attr';
261
- } else if (state === 'skip-attr') {
262
- // Subsequent hole in the same mixed attribute.
317
+ } else {
318
+ // `skip-attr`: a later hole in the same mixed attribute. Any other
319
+ // state is a hole where no binding can sit (a tag name, `</div ${x}>`):
320
+ // it binds nothing, as in lit's production build, and still takes its
321
+ // index so every later part keeps its own.
263
322
  parts.push({ kind: 'noop', path: [] });
264
323
  }
265
324
  }
266
325
  }
267
326
 
268
327
  const templateEl = document.createElement('template');
269
- if (tr.svg) {
270
- // An svg`` fragment is parsed inside an <svg> wrapper so its elements
271
- // land in the SVG namespace, then the wrapper is unwrapped BEFORE the
272
- // slot and path walks, so every recorded path is relative to the
273
- // fragment's own root, as it is for an html`` template (lit does the same).
274
- templateEl.innerHTML = `<svg>${html}</svg>`;
328
+ if (tr.svg || tr.mathml) {
329
+ // An svg`` (or mathml``) fragment is parsed inside an <svg> (or <math>)
330
+ // wrapper so its elements land in that namespace, then the wrapper is
331
+ // unwrapped BEFORE the slot and path walks, so every recorded path is
332
+ // relative to the fragment's own root, as it is for an html`` template
333
+ // (lit does the same).
334
+ const wrapTag = tr.svg ? 'svg' : 'math';
335
+ templateEl.innerHTML = `<${wrapTag}>${html}</${wrapTag}>`;
275
336
  const wrapper = /** @type {Element} */ (templateEl.content.firstChild);
276
337
  wrapper.replaceWith(...wrapper.childNodes);
277
338
  } else {
278
339
  templateEl.innerHTML = html;
279
340
  }
280
341
 
342
+ if (html.includes(RAW_SENTINEL)) splitRawTextSentinels(templateEl.content);
343
+
281
344
  // Mark every <slot> in the template for framework projection and
282
345
  // register a SLOT part for each so the slot-apply step can find them on
283
346
  // clones. This runs BEFORE assignPaths so the sentinel attributes the
@@ -295,6 +358,38 @@ export function compile(tr) {
295
358
  return cached;
296
359
  }
297
360
 
361
+ /**
362
+ * Replace each raw-text hole's sentinel with a `wjm-<idx>` marker comment, so
363
+ * the path walk records it as a child part. A comment inside `<script>` or
364
+ * `<textarea>` is a DOM child the element's text (`textContent`, `.text`,
365
+ * `.value`) ignores, which is how lit anchors a raw-text binding too.
366
+ *
367
+ * @param {DocumentFragment} root
368
+ */
369
+ function splitRawTextSentinels(root) {
370
+ /** @type {Text[]} */
371
+ const hits = [];
372
+ /** @param {Node} node */
373
+ const collect = (node) => {
374
+ for (const child of node.childNodes) {
375
+ if (child.nodeType === 3) { if (/** @type {Text} */ (child).data.includes(RAW_SENTINEL)) hits.push(/** @type {Text} */ (child)); }
376
+ else collect(child);
377
+ }
378
+ };
379
+ collect(root);
380
+ for (const text of hits) {
381
+ const pieces = text.data.split(RAW_SENTINEL);
382
+ /** @type {Node[]} */
383
+ const nodes = [];
384
+ // pieces alternate text, index, text, index, ..., text
385
+ for (let k = 0; k < pieces.length; k++) {
386
+ if (k % 2) nodes.push(document.createComment(`${MARKER}${pieces[k]}`));
387
+ else if (pieces[k]) nodes.push(document.createTextNode(pieces[k]));
388
+ }
389
+ text.replaceWith(...nodes);
390
+ }
391
+ }
392
+
298
393
  /**
299
394
  * Walk the compiled template content for <slot> elements (the static ones
300
395
  * written into the template, not dynamically-inserted ones). For each:
@@ -1,2 +1,6 @@
1
1
  export interface RenderOptions { host?: object; }
2
- export function render(value: unknown, container: Element | DocumentFragment | ShadowRoot, options?: RenderOptions): void;
2
+ /** What `render()` returns, lit's `RootPart`: `setConnected(false)` disconnects the async directives the render created, `setConnected(true)` reconnects them. */
3
+ export interface RootPart {
4
+ setConnected(isConnected: boolean): void;
5
+ }
6
+ export function render(value: unknown, container: Element | DocumentFragment | ShadowRoot, options?: RenderOptions): RootPart;
@@ -67,7 +67,7 @@ export function withHostMarker(opening) {
67
67
  * `getAttribute` API so it works whether the instance is the server shim or a
68
68
  * real `HTMLElement`. Returns the opening tag unchanged when there is nothing
69
69
  * to add, so existing SSR output stays byte-identical when no component
70
- * reflects, which preserves the elision on-vs-off differential invariant.
70
+ * reflects.
71
71
  *
72
72
  * @param {string} opening the element's opening tag, ending in `>`
73
73
  * @param {any} instance
@@ -129,7 +129,7 @@ export function applyAttrsToInstance(instance, attrs, Cls) {
129
129
  // per-component error isolation below, which is deliberate: an author who
130
130
  // supplies a converter owns the read, the same rule `_reflectAttribute`
131
131
  // states for `toAttribute`. See the comment on `readAttributeValue`.
132
- instance[propName] = readAttributeValue(def, decodeAttrEntities(sourceValue));
132
+ instance[propName] = readAttributeValue(def, decodeAttrEntities(sourceValue), propName);
133
133
  }
134
134
  }
135
135
 
@@ -1,5 +1,6 @@
1
1
  import { BINDING_PREFIXES } from '../binding-prefixes.js';
2
2
  import { isVoidElement, isRawtextTag, isTextOnlyTag } from './html-scan.js';
3
+ import { assertNoJsxIsms } from '../jsx-guard.js';
3
4
 
4
5
  /**
5
6
  * The SSR template compiler (#1646): one tokenizer pass per template, run
@@ -74,6 +75,8 @@ export function _compileCount() { return compiled; }
74
75
  export function compile(strings) {
75
76
  let ops = cache.get(strings);
76
77
  if (ops === undefined) {
78
+ // `className=`, `htmlFor=`, `<Link>`: JSX that HTML silently ignores.
79
+ assertNoJsxIsms(strings);
77
80
  ops = tokenize(strings);
78
81
  cache.set(strings, ops);
79
82
  }
@@ -115,6 +118,9 @@ function tokenize(strings) {
115
118
  let attrName = '';
116
119
  let attrStart = 0; // index into `chunk` of the attribute's first character
117
120
  let attrQuote = '';
121
+ // An unquoted value holding a hole, written quoted (`a=x${v}` -> `a="x..."`),
122
+ // so a value with a space or `>` in it cannot add an attribute or close the tag.
123
+ let synthQuote = false;
118
124
  let commentDashes = 0;
119
125
  let currentTag = '';
120
126
  /** @type {Op | null} */
@@ -323,6 +329,18 @@ function tokenize(strings) {
323
329
  else chunk += c;
324
330
  break;
325
331
  case 'attr-quoted':
332
+ if (synthQuote) {
333
+ // It ends where the unquoted value it stands for ends, and the
334
+ // character that ends it is read again as the tag's own.
335
+ if (WS.test(c) || c === '>' || (c === '/' && s[j + 1] === '>')) {
336
+ chunk += '"';
337
+ synthQuote = false;
338
+ state = 'in-tag';
339
+ attrName = '';
340
+ j--;
341
+ } else chunk += c === '"' ? '&quot;' : c;
342
+ break;
343
+ }
326
344
  chunk += c;
327
345
  if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
328
346
  break;
@@ -368,6 +386,22 @@ function tokenize(strings) {
368
386
  }
369
387
 
370
388
  if (i < strings.length - 1) {
389
+ // An unquoted value with more than the one hole in it (`a=x${v}`,
390
+ // `a=${x}${y}`, `a=${x}/z`) is written quoted, so the walker resolves it
391
+ // whole through the quoted-value path, as the client does.
392
+ if (BINDING_PREFIXES[attrName[0]] === undefined && !synthQuote) {
393
+ const next = strings[i + 1];
394
+ const ends = next !== '' && (WS.test(next[0]) || next[0] === '>' || (next[0] === '/' && next[1] === '>'));
395
+ if (state === 'after-eq' && !ends) {
396
+ chunk += '"';
397
+ state = 'attr-quoted'; attrQuote = '"'; synthQuote = true;
398
+ } else if (state === 'attr-unquoted') {
399
+ const eq = chunk.indexOf('=', attrStart) + 1;
400
+ const gap = /^\s*/.exec(chunk.slice(eq))[0].length;
401
+ chunk = chunk.slice(0, eq + gap) + '"' + chunk.slice(eq + gap).replace(/"/g, '&quot;');
402
+ state = 'attr-quoted'; attrQuote = '"'; synthQuote = true;
403
+ }
404
+ }
371
405
  // A value hole sits here. Its op says where, and the walker decides what.
372
406
  if (state === 'comment' || state === 'comment-start' || state === 'comment-start-dash' || state === 'comment-bang') {
373
407
  flush();
@@ -36,6 +36,43 @@ export function browserMemberHint(e) {
36
36
  return null;
37
37
  }
38
38
 
39
+ /**
40
+ * Lit decorator names. Bun runs legacy decorators, so a decorated class fails
41
+ * there with a `ReferenceError` naming the undefined decorator; Node and the
42
+ * browser fail the whole module with a bare `SyntaxError` instead.
43
+ */
44
+ const DECORATOR_NAMES = new Set([
45
+ 'customElement', 'property', 'state', 'query', 'queryAll', 'queryAsync',
46
+ 'queryAssignedElements', 'queryAssignedNodes', 'eventOptions', 'provide', 'consume',
47
+ ]);
48
+
49
+ /**
50
+ * If `e` is the recognisable failure of a Lit DECORATOR reaching the runtime
51
+ * (WebJs has no build step and decorators are not erasable TypeScript, so the
52
+ * type stripper passes them through), return the WebJs fix; otherwise null.
53
+ * Node and the browser report a bare `SyntaxError: Invalid or unexpected
54
+ * token` for the `@`, which names neither cause nor fix; Bun reports a
55
+ * `ReferenceError: property is not defined`. The `no-non-erasable-typescript`
56
+ * check rule catches the same thing at edit time with a per-decorator fix.
57
+ * @param {unknown} e
58
+ * @returns {string | null}
59
+ */
60
+ export function decoratorHint(e) {
61
+ const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
62
+ const fix =
63
+ 'WebJs runs no build step, so decorators cannot run. Declare reactive properties in `static properties` ' +
64
+ '(or the `WebComponent({ ... })` factory), use `state: true` for `@state`, call `MyEl.register(tag)` for ' +
65
+ '`@customElement`, and write `@query` as a getter over `this.renderRoot`.';
66
+ if (e instanceof SyntaxError && /Invalid or unexpected token|Unexpected token '@'|Unexpected character '@'|[Dd]ecorators? /.test(msg)) {
67
+ return `A module failed to parse. If it uses a decorator (\`@property\`, \`@customElement\`, ...), that is the cause. ${fix}`;
68
+ }
69
+ const m = /^(\w+) is not defined\b/.exec(msg);
70
+ if (e instanceof ReferenceError && m && DECORATOR_NAMES.has(m[1])) {
71
+ return `\`${m[1]}\` is a Lit decorator, which WebJs does not provide. ${fix}`;
72
+ }
73
+ return null;
74
+ }
75
+
39
76
  // Browser-only names whose absence during SSR produces a recognisable error.
40
77
  // Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
41
78
  // these at edit time; this turns the runtime SSR crash into the same guidance.