@webjsdev/core 0.7.65 → 0.9.0

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 (92) 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 +22 -21
  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 +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -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.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -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 +40 -1
  35. package/src/directives.js +160 -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 +8 -0
  40. package/src/html.js +55 -2
  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 +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  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/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
@@ -1,4 +1,5 @@
1
- import { isTemplate, MARKER } from '../html.js';
1
+ import { nothing, noChange } from '../sentinels.js';
2
+ import { isTemplate, MARKER, html } from '../html.js';
2
3
  import {
3
4
  reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName, assertSubmitterType,
4
5
  assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
@@ -10,17 +11,69 @@ import { isLive } from '../directives.js';
10
11
  import { attrHoleValue } from '../binding-prefixes.js';
11
12
  import { RENDERING, SLOT_OWNER, SLOT_STATE, drainRendererBackstop, rescueAssignedNodes } from '../slot.js';
12
13
  import { compile, submitterActionBindings, INSTANCE } from './template-compiler.js';
14
+ import { commitAdoption, planSlotAdoption, retargetParts } from './adopt-slots.js';
13
15
  import {
14
- applyPart, bindPart, currentRenderRoot, findSlotHost, setCurrentRenderRoot, updateInstance,
16
+ applyPart, bindPart, currentRenderHost, setCurrentRenderHost, currentRenderRoot, findSlotHost, setCurrentRenderRoot, updateInstance,
17
+ releaseInstanceDirectives, setInstanceDirectivesConnected, ROOT_DISCONNECTED,
15
18
  } from './parts.js';
16
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
+
17
27
  /**
18
28
  * Render a value into a container, reusing DOM where possible.
19
29
  *
20
30
  * @param {unknown} value
21
31
  * @param {Element | DocumentFragment | ShadowRoot} container
32
+ * @param {{host?: object}} [options]
22
33
  */
23
- export function render(value, container) {
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) {
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
+ }
24
77
  const host = /** @type any */ (container);
25
78
  // Open the renderer-write window for the whole commit: every host-receiver
26
79
  // write below (and in createInstance / updateInstance / clearInstance / all
@@ -29,6 +82,8 @@ export function render(value, container) {
29
82
  // between a renderer commit and an author write.
30
83
  const prevRendering = host[RENDERING];
31
84
  host[RENDERING] = true;
85
+ const prevHost = currentRenderHost;
86
+ setCurrentRenderHost(options.host);
32
87
  const prevRenderRoot = currentRenderRoot;
33
88
  setCurrentRenderRoot(container);
34
89
  try {
@@ -44,12 +99,12 @@ export function render(value, container) {
44
99
 
45
100
  // Light DOM hydration: if container has SSR content (marked by
46
101
  // <!--webjs-hydrate-->), remove the marker and proceed with normal
47
- // rendering. The content will be replaced with identical output -
48
- // no visible flash because SSR and client render produce the same HTML.
102
+ // rendering. The content is replaced with identical output, so nothing
103
+ // flashes, except that the SSR slots holding projected children stay in
104
+ // place (createInstance, #1666), so those children never leave the page.
49
105
  const firstChild = container.firstChild;
50
- if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
51
- firstChild.remove();
52
- }
106
+ const hydrating = !!firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate';
107
+ if (hydrating) firstChild.remove();
53
108
 
54
109
  // Pre-set the symbol to an explicit null BEFORE the commit,
55
110
  // UNCONDITIONALLY: if createInstance throws after its replaceChildren
@@ -59,7 +114,7 @@ export function render(value, container) {
59
114
  // instance on the template-swap path (whose bookends would misclassify
60
115
  // the half-committed new roots as unowned and fold them).
61
116
  host[INSTANCE] = null;
62
- const inst = createInstance(tr, container);
117
+ const inst = createInstance(tr, container, hydrating);
63
118
  host[INSTANCE] = inst;
64
119
  return;
65
120
  }
@@ -68,17 +123,11 @@ export function render(value, container) {
68
123
  if (prev) clearInstance(prev, container);
69
124
  host[INSTANCE] = null;
70
125
  container.replaceChildren();
71
- if (value == null || value === false || value === true) return;
72
- if (Array.isArray(value)) {
73
- for (const v of value) {
74
- const text = document.createTextNode(String(v ?? ''));
75
- container.appendChild(text);
76
- }
77
- return;
78
- }
126
+ if (value == null || value === nothing) return;
79
127
  container.appendChild(document.createTextNode(String(value)));
80
128
  } finally {
81
129
  setCurrentRenderRoot(prevRenderRoot);
130
+ setCurrentRenderHost(prevHost);
82
131
  host[RENDERING] = prevRendering;
83
132
  // Outermost window closing: drain this commit's childList records off the
84
133
  // slot backstop (drainRendererBackstop processes them with a renderer-output
@@ -203,7 +252,7 @@ function reconcileSubmitterAction(
203
252
  if (!id) {
204
253
  // A function that was MEANT as an action still refuses, so a button never
205
254
  // silently submits the form's action instead of its own.
206
- if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
255
+ if (typeof value === 'function') assertIdentifiableAction(null, el.localName, value);
207
256
  releaseSubmitterAction(el);
208
257
  // The submission attributes the framework supplied go with the identity, or
209
258
  // a released button keeps a `formmethod` / `formenctype` SSR does not emit
@@ -307,14 +356,19 @@ function resolveHoleValue(v) {
307
356
  * @param {import('../html.js').TemplateResult} tr
308
357
  * @param {Element | DocumentFragment | ShadowRoot} container
309
358
  */
310
- function createInstance(tr, container) {
359
+ function createInstance(tr, container, hydrating = false) {
311
360
  const { templateEl, parts, formActions } = compile(tr);
312
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
361
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
313
362
  // Bookend markers bound the instance so we can tear it down cleanly.
314
363
  const startNode = document.createComment(`${MARKER}s`);
315
364
  const endNode = document.createComment(`${MARKER}e`);
316
365
 
317
366
  const bound = parts.map((p) => bindPart(p, frag));
367
+ // Hydration keeps the SSR slots that hold projected children, and their
368
+ // ancestors, in the document (#1666): the parts targeting them are pointed at
369
+ // the live elements now, before any applies.
370
+ const adoption = hydrating ? planSlotAdoption(container, frag, bound) : null;
371
+ if (adoption) retargetParts(adoption, bound);
318
372
  const lastValues = [];
319
373
  for (let i = 0; i < tr.values.length; i++) {
320
374
  applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActions);
@@ -322,7 +376,11 @@ function createInstance(tr, container) {
322
376
  }
323
377
  reconcileFormActions(formActions, bound, tr.values);
324
378
 
325
- /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
379
+ if (adoption) {
380
+ commitAdoption(adoption, /** @type {Element} */ (container), [startNode, ...frag.childNodes, endNode]);
381
+ } else {
382
+ /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
383
+ }
326
384
 
327
385
  // Slot parts have no value-hole to drive applyPart from the loop above.
328
386
  // Apply them once now that the fragment is inserted into the live
@@ -362,8 +420,11 @@ function clearInstance(inst, container) {
362
420
  // element parts, and rescue any projected children sitting inside
363
421
  // slot parts so they survive teardown of a collapsing conditional
364
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);
365
426
  for (const p of inst.bound) {
366
- if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
427
+ if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher, p.handler);
367
428
  if (p.kind === 'element') {
368
429
  // Guarded for the same reason as the sibling in `disposeInstance`, and
369
430
  // the stakes are higher here: this is the container-level teardown
@@ -375,7 +436,7 @@ function clearInstance(inst, container) {
375
436
  const prev = /** @type any */ (p).lastTarget;
376
437
  if (prev) {
377
438
  if (typeof prev === 'function') {
378
- try { prev(undefined); } catch {}
439
+ try { prev.call(/** @type any */ (p).refHost, undefined); } catch {}
379
440
  } else if (typeof prev === 'object') {
380
441
  try { prev.value = undefined; } catch {}
381
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 {{
@@ -31,13 +38,13 @@ export const INSTANCE = Symbol.for('webjs.instance');
31
38
  * }} TemplateInstance
32
39
  *
33
40
  * @typedef {
34
- * | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
41
+ * | { kind: 'child', marker: Comment, __eventHost?: object, child?: TemplateInstance | ChildNode[] }
35
42
  * | { kind: 'attr', el: Element, name: string }
36
43
  * | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
37
- * | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
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,19 +198,27 @@ 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
 
214
+ const specialized = isBindingPrefix(mixedAttr.name[0]);
215
+ if (specialized && (group.length !== 1 || statics.some((piece) => piece !== ''))) {
216
+ throw new Error('Event, property and boolean bindings require exactly one expression with no surrounding text.');
217
+ }
172
218
  parts[idx0] = {
173
- kind: 'attr-mixed',
219
+ kind: specialized ? BINDING_PREFIXES[mixedAttr.name[0]] : 'attr-mixed',
174
220
  path: [],
175
- name: mixedAttr.name,
221
+ name: specialized ? mixedAttr.name.slice(1) : mixedAttr.name,
176
222
  statics,
177
223
  group,
178
224
  };
@@ -187,8 +233,11 @@ export function compile(tr) {
187
233
  }
188
234
  mixedAttr = null;
189
235
  }
190
- state = 'in-tag';
191
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';
192
241
  }
193
242
  break;
194
243
  }
@@ -205,28 +254,38 @@ export function compile(tr) {
205
254
  continue;
206
255
  }
207
256
  if (state === 'rawtext') {
208
- // Inside <script>/<style>: per-render interpolation isn't supported;
209
- // the compile cache would lock in whatever was first rendered. The
210
- // hole is dropped and authors should set body text via a child part
211
- // 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.
212
262
  rawTail = '';
213
- parts.push({ kind: 'noop', path: [] });
263
+ html += `${RAW_SENTINEL}${partIdx}${RAW_SENTINEL}`;
264
+ parts.push({ kind: 'child', path: [] });
214
265
  continue;
215
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));
216
272
  if (state === 'text') {
217
273
  // Child hole: insert a comment marker. Use bracketed markers so we can
218
274
  // later walk all comments and find ours without ambiguity.
219
275
  html += `<!--${MARKER}${partIdx}-->`;
220
276
  parts.push({ kind: 'child', path: [] });
221
- } else if (state === 'in-tag') {
222
- // 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
223
280
  // (and any future element-bound directive). Emit a sentinel attribute
224
281
  // on the current open tag; at bind time the attribute is stripped
225
282
  // and the element is captured into the part.
226
283
  const sentinel = `data-${MARKER}${partIdx}`;
227
284
  html += `${sentinel}=""`;
228
285
  parts.push({ kind: 'element', path: [] });
229
- } else if (state === 'after-eq') {
286
+ state = 'in-tag';
287
+ attrName = '';
288
+ } else if (state === 'after-eq' && !unquotedGoesOn) {
230
289
  const prefix = attrName[0];
231
290
  const name = attrName.slice(1);
232
291
  if (isBindingPrefix(prefix)) {
@@ -245,24 +304,42 @@ export function compile(tr) {
245
304
  }
246
305
  state = 'in-tag';
247
306
  attrName = '';
248
- } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
307
+ } else if (state === 'attr-quoted' || state === 'attr-unquoted' || state === 'after-eq') {
249
308
  // First hole inside a quoted attribute value: start mixed-attr tracking.
250
309
  // Replace the entire attribute with a sentinel (same as regular attr).
310
+ if (state !== 'attr-quoted') attrQuote = '';
251
311
  html = html.slice(0, attrStart);
252
312
  const sentinel = `data-${MARKER}${partIdx}`;
253
313
  html += `${sentinel}=""`;
254
314
  mixedAttr = { name: attrName, firstPartIdx: partIdx };
255
315
  parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
256
316
  state = 'skip-attr';
257
- } else if (state === 'skip-attr') {
258
- // 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.
259
322
  parts.push({ kind: 'noop', path: [] });
260
323
  }
261
324
  }
262
325
  }
263
326
 
264
327
  const templateEl = document.createElement('template');
265
- templateEl.innerHTML = html;
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}>`;
336
+ const wrapper = /** @type {Element} */ (templateEl.content.firstChild);
337
+ wrapper.replaceWith(...wrapper.childNodes);
338
+ } else {
339
+ templateEl.innerHTML = html;
340
+ }
341
+
342
+ if (html.includes(RAW_SENTINEL)) splitRawTextSentinels(templateEl.content);
266
343
 
267
344
  // Mark every <slot> in the template for framework projection and
268
345
  // register a SLOT part for each so the slot-apply step can find them on
@@ -281,6 +358,38 @@ export function compile(tr) {
281
358
  return cached;
282
359
  }
283
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
+
284
393
  /**
285
394
  * Walk the compiled template content for <slot> elements (the static ones
286
395
  * written into the template, not dynamically-inserted ones). For each:
@@ -1 +1,6 @@
1
- export function render(value: unknown, container: Element | DocumentFragment | ShadowRoot): void;
1
+ export interface RenderOptions { host?: object; }
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