@abide/abide 0.111.0 → 0.112.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @abide/abide
2
2
 
3
+ ## 0.112.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cabe8d1: a class or style attribute beside class: and style: directives keeps moving and writes only its own classes and declarations, and an element where a bind: and something else both write its value is a build error ([`bbbfd81`](https://github.com/briancray/abide/commit/bbbfd810d817235ddb51b8c605756187e473719e))
8
+
9
+ ### Patch Changes
10
+
11
+ - cabe8d1: one writer owns document.head — a keyless head node stands in its contributors' order, an equal one another contributor made stays when the first leaves, and a page's head leaves with its layout's slot ([`f21f687`](https://github.com/briancray/abide/commit/f21f68742efb463f7873a6aafc4482fd572363e7))
12
+
3
13
  ## 0.111.0
4
14
 
5
15
  ### Minor Changes
package/docs/REGISTRY.md CHANGED
@@ -529,18 +529,18 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
529
529
  | `{expr}` | Reactive text, escaped. | 32.1, 32.23 |
530
530
  | formatting whitespace | What a run of whitespace renders as. | 33.17, 33.18 |
531
531
  | `{raw(...)}` | Raw HTML. | 32.2, 32.25, 32.26, 32.30, 32.31, 32.70 |
532
- | `name={expr}` | A reactive attribute or property. | 32.3, 32.20, 32.23, 32.37 |
532
+ | `name={expr}` | A reactive attribute or property. | 32.3, 32.20, 32.23, 32.37, 32.71 |
533
533
  | `on<event>={fn}` | A native listener on an element, and an ordinary prop on a component. | 32.4 |
534
- | `name="…{expr}…"` | An interpolated quoted value. | 32.3 |
535
- | `bind:value` | Two-way bind on an element. | 5.16, 32.5, 32.58, 32.59, 32.60, 32.61, 32.62 |
534
+ | `name="…{expr}…"` | An interpolated quoted value. | 32.3, 32.71 |
535
+ | `bind:value` | Two-way bind on an element. | 5.16, 32.5, 32.58, 32.59, 32.60, 32.61, 32.62, 32.72 |
536
536
  | `bind:prop={state}` | Hands a component prop the caller's `Reactive`, or a `{get, set}` pair, to write through. | 32.6, 33.6, 33.8, 14.18 |
537
- | `bind:checked` | A boolean bind on an input. | 32.7 |
538
- | `bind:open` | The same, on a `<details>`. | 32.7 |
539
- | `bind:group` | Radio or checkbox membership. | 32.8 |
537
+ | `bind:checked` | A boolean bind on an input. | 32.7, 32.72 |
538
+ | `bind:open` | The same, on a `<details>`. | 32.7, 32.72 |
539
+ | `bind:group` | Radio or checkbox membership. | 32.8, 32.72 |
540
540
  | `bind:value={{get, set}}` | Two-way bind over an explicit accessor pair, on an element or a component. | 32.5, 32.6 |
541
541
  | `bind:element={Reactive<Element> \| ((element: Element) => void \| Disposer)}` | A node reference. | 32.9, 32.38, 32.66, 32.68, 32.69 |
542
- | `class:name={cond}` | Toggles a class. | 32.10 |
543
- | `style:prop={value}` | Sets one style property. | 32.10, 32.19 |
542
+ | `class:name={cond}` | Toggles a class. | 32.10, 32.71 |
543
+ | `style:prop={value}` | Sets one style property. | 32.10, 32.19, 32.71 |
544
544
  | `{...expr}` | Spreads props or attributes. | 32.11, 32.37, 33.12, 33.13 |
545
545
  | static template text | What an author writes outside a slot, in body text and in an attribute value. | 32.27, 32.28 |
546
546
 
package/docs/RULEBOOK.md CHANGED
@@ -2003,6 +2003,15 @@ reading layout, querying its descendants or calling `focus()` acts on a connecte
2003
2003
  32.70 Markup the client parses for a slot holding a value branded by `raw`, a `{#if}` arm or a
2004
2004
  `{#for}` row MUST be parsed as the document's parser parses it inside the element it goes into.
2005
2005
 
2006
+ 32.71 A `class` or `style` attribute beside a `class:name` or `style:prop` MUST keep moving (32.3)
2007
+ and MUST write only its own classes or declarations; a class either of them holds MUST stay on the
2008
+ element, and a style property MUST resolve as the server's merged attribute does — the attribute's
2009
+ declarations first, then each directive's in the tag's order.
2010
+
2011
+ 32.72 An element MUST be a build error where a `bind:` and something else both write its value: the
2012
+ attribute a `bind:name` names, `checked` beside `bind:group`, an `<option selected>` inside a
2013
+ `<select bind:value>`, or text inside a `<textarea bind:value>`.
2014
+
2006
2015
  # 33. Props
2007
2016
 
2008
2017
  33.1 `props` MUST be callable from a `<script>` block only, and calling it from a `<script module>`
@@ -2674,8 +2683,9 @@ hydration woke before it settles; it MUST throw where the component was not impo
2674
2683
  39.4 `<head>` contributions MUST be merged by key, and the deepest contributor MUST win. The shell's
2675
2684
  `<head>` MUST be the outermost contributor.
2676
2685
 
2677
- 39.5 A `<head>` contribution with no merge key MUST append — in tree order among the contributions
2678
- its level mounts with, and when it mounts for a component mounted after its level.
2686
+ 39.5 A `<head>` contribution with no merge key MUST append, and MUST stand in its contributors'
2687
+ order — each level's own, then its components' in the order they mounted, outermost level first — a
2688
+ component mounted after its level included.
2679
2689
 
2680
2690
  39.6 On a client navigation the incoming page's keyed `<head>` contributions MUST replace the
2681
2691
  outgoing page's by key, and a layout's MUST NOT move.
@@ -2709,7 +2719,7 @@ contributor has that key.
2709
2719
  MUST replace, in place, the one that same contributor wrote before.
2710
2720
 
2711
2721
  39.16 A `<head>` contribution with no merge key MUST be removed when the page, layout or component
2712
- that made it leaves.
2722
+ that made it leaves, and an equal one another contributor made MUST stay.
2713
2723
 
2714
2724
  39.17 A `<script>` a `<head>` contribution writes in a browser MUST run when it is written, as the
2715
2725
  one a server paints does, and MUST NOT run again while the same contributor writes it unchanged.
package/docs/abide.md CHANGED
@@ -8628,18 +8628,18 @@ Generated surfaces: what is written in place rather than imported.
8628
8628
  | `{expr}` | Reactive text, escaped. | [32.1](/rulebook#32.1), [32.23](/rulebook#32.23) |
8629
8629
  | formatting whitespace | What a run of whitespace renders as. | [33.17](/rulebook#33.17), [33.18](/rulebook#33.18) |
8630
8630
  | `{raw(...)}` | Raw HTML. | [32.2](/rulebook#32.2), [32.25](/rulebook#32.25), [32.26](/rulebook#32.26), [32.30](/rulebook#32.30), [32.31](/rulebook#32.31), [32.70](/rulebook#32.70) |
8631
- | `name={expr}` | A reactive attribute or property. | [32.3](/rulebook#32.3), [32.20](/rulebook#32.20), [32.23](/rulebook#32.23), [32.37](/rulebook#32.37) |
8631
+ | `name={expr}` | A reactive attribute or property. | [32.3](/rulebook#32.3), [32.20](/rulebook#32.20), [32.23](/rulebook#32.23), [32.37](/rulebook#32.37), [32.71](/rulebook#32.71) |
8632
8632
  | `on<event>={fn}` | A native listener on an element, and an ordinary prop on a component. | [32.4](/rulebook#32.4) |
8633
- | `name="…{expr}…"` | An interpolated quoted value. | [32.3](/rulebook#32.3) |
8634
- | `bind:value` | Two-way bind on an element. | [5.16](/rulebook#5.16), [32.5](/rulebook#32.5), [32.58](/rulebook#32.58), [32.59](/rulebook#32.59), [32.60](/rulebook#32.60), [32.61](/rulebook#32.61), [32.62](/rulebook#32.62) |
8633
+ | `name="…{expr}…"` | An interpolated quoted value. | [32.3](/rulebook#32.3), [32.71](/rulebook#32.71) |
8634
+ | `bind:value` | Two-way bind on an element. | [5.16](/rulebook#5.16), [32.5](/rulebook#32.5), [32.58](/rulebook#32.58), [32.59](/rulebook#32.59), [32.60](/rulebook#32.60), [32.61](/rulebook#32.61), [32.62](/rulebook#32.62), [32.72](/rulebook#32.72) |
8635
8635
  | `bind:prop={state}` | Hands a component prop the caller's `Reactive`, or a `{get, set}` pair, to write through. | [32.6](/rulebook#32.6), [33.6](/rulebook#33.6), [33.8](/rulebook#33.8), [14.18](/rulebook#14.18) |
8636
- | `bind:checked` | A boolean bind on an input. | [32.7](/rulebook#32.7) |
8637
- | `bind:open` | The same, on a `<details>`. | [32.7](/rulebook#32.7) |
8638
- | `bind:group` | Radio or checkbox membership. | [32.8](/rulebook#32.8) |
8636
+ | `bind:checked` | A boolean bind on an input. | [32.7](/rulebook#32.7), [32.72](/rulebook#32.72) |
8637
+ | `bind:open` | The same, on a `<details>`. | [32.7](/rulebook#32.7), [32.72](/rulebook#32.72) |
8638
+ | `bind:group` | Radio or checkbox membership. | [32.8](/rulebook#32.8), [32.72](/rulebook#32.72) |
8639
8639
  | `bind:value={{get, set}}` | Two-way bind over an explicit accessor pair, on an element or a component. | [32.5](/rulebook#32.5), [32.6](/rulebook#32.6) |
8640
8640
  | `bind:element={Reactive<Element> \| ((element: Element) => void \| Disposer)}` | A node reference. | [32.9](/rulebook#32.9), [32.38](/rulebook#32.38), [32.66](/rulebook#32.66), [32.68](/rulebook#32.68), [32.69](/rulebook#32.69) |
8641
- | `class:name={cond}` | Toggles a class. | [32.10](/rulebook#32.10) |
8642
- | `style:prop={value}` | Sets one style property. | [32.10](/rulebook#32.10), [32.19](/rulebook#32.19) |
8641
+ | `class:name={cond}` | Toggles a class. | [32.10](/rulebook#32.10), [32.71](/rulebook#32.71) |
8642
+ | `style:prop={value}` | Sets one style property. | [32.10](/rulebook#32.10), [32.19](/rulebook#32.19), [32.71](/rulebook#32.71) |
8643
8643
  | `{...expr}` | Spreads props or attributes. | [32.11](/rulebook#32.11), [32.37](/rulebook#32.37), [33.12](/rulebook#33.12), [33.13](/rulebook#33.13) |
8644
8644
  | static template text | What an author writes outside a slot, in body text and in an attribute value. | [32.27](/rulebook#32.27), [32.28](/rulebook#32.28) |
8645
8645
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.111.0",
3
+ "version": "0.112.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -222,6 +222,11 @@ export class Lowering {
222
222
  }
223
223
  }
224
224
 
225
+ /** 32.67 — whether a position reads any row's item. */
226
+ readsAnItem(holder: object): boolean {
227
+ return this.holdsRead(holder, (read) => read.item !== -1)
228
+ }
229
+
225
230
  /** 32.67 — whether a position reads `block`'s item. */
226
231
  readsItem(holder: object, block: object): boolean {
227
232
  const item = this.itemOf(block)
@@ -1,7 +1,9 @@
1
+ import { ASCII_WHITESPACE } from '#shared/ASCII_WHITESPACE.ts'
1
2
  import { escapeAttribute, escapeQuote } from '#shared/escapeText.ts'
2
3
  import { isEventName } from '#shared/isEventName.ts'
3
4
  import { safeUrl } from '#shared/safeUrl.ts'
4
5
  import { URL_ATTRIBUTES } from '#shared/URL_ATTRIBUTES.ts'
6
+ import { decodeCharacterReferences } from './decodeCharacterReferences.ts'
5
7
  import {
6
8
  type Attribute,
7
9
  bodiesOf,
@@ -90,8 +92,11 @@ export type ClassifiedAttribute = {
90
92
  kind: 'bound'
91
93
  bind: ValueBind
92
94
  reflects: 'value' | 'checked' | 'open' | undefined
93
- /** 32.8 — a group member's own `value`, which the group is compared against. */
94
- own: string
95
+ /**
96
+ * 32.8 — a group member's `value` attribute, which the group is compared against —
97
+ * `undefined` where it has none, and the input's value is then `on`.
98
+ */
99
+ own: Attribute | undefined
95
100
  }
96
101
  // 32.9 — the node itself, which a server has none of: written out it was `bind:element=""`,
97
102
  // inert and a tell. The client does not set it either; what the value takes is the element.
@@ -102,24 +107,39 @@ export type ClassifiedAttribute = {
102
107
  // 32.10, 32.19 — a member of a `class` or `style` merge group (`MergeGroup`). `group` is set on
103
108
  // the FIRST member only, where the whole group is written, which keeps the tag in the author's
104
109
  // attribute order. `toggles` is the one class or style property a directive's binding writes,
105
- // and `undefined` for the author's own plain attribute — see where that is decided.
110
+ // or a shared group's whole value at its first member (32.71), named by the directives'
111
+ // classes or properties, and `undefined` for any other member — see where that is decided.
106
112
  | {
107
113
  kind: 'merged'
108
114
  group: MergeGroup | undefined
109
- toggles: { write: 'class' | 'style'; name: string } | undefined
115
+ toggles:
116
+ | {
117
+ write: 'class' | 'style' | 'classGroup' | 'styleGroup'
118
+ name: string
119
+ }
120
+ | undefined
110
121
  }
111
122
  )
112
123
 
113
124
  /**
114
125
  * Every attribute of an element, classified, in the order the tag carries them.
115
126
  *
116
- * `lowering` answers `live` (`Lowering.isLive`).
127
+ * `lowering` answers `live` (`Lowering.isLive`), and whether a shared `class` group can move
128
+ * (`Lowering.readsAnItem`: a row's item read at its top level is not live, and a row's `update`
129
+ * writes it).
117
130
  */
118
131
  export function classifyAttributes(
119
132
  node: ElementNode,
120
- lowering: { isLive(holder: object): boolean },
133
+ lowering: {
134
+ isLive(holder: object): boolean
135
+ readsAnItem(holder: object): boolean
136
+ },
121
137
  ): ClassifiedAttribute[] {
138
+ refuseTwoOwners(node)
122
139
  const groups = mergeGroupsOf(node)
140
+ const sharing: ReturnType<typeof sharingOf>[] = []
141
+ for (const group of groups) sharing.push(sharingOf(group, lowering))
142
+ const held = heldClasses(groups)
123
143
  const written = new Set<MergeGroup>()
124
144
  const classified: ClassifiedAttribute[] = []
125
145
  for (const attribute of node.attributes) {
@@ -130,33 +150,37 @@ export function classifyAttributes(
130
150
  if (group !== undefined) {
131
151
  const leads = !written.has(group)
132
152
  written.add(group)
133
- // 32.10, 32.19 — THE PLAIN `style={expr}` OR `class={expr}` BESIDE A DIRECTIVE IS LEFT
134
- // UNBOUND. `setAttribute` replaces the whole merged value and takes the directives'
135
- // halves with it, and the two bindings then overwrite each other in whichever order
136
- // they reran.
153
+ // 32.71 — A SHARED GROUP IS WRITTEN WHOLE AT ITS FIRST MEMBER, through `classList` or
154
+ // the CSSOM, as the server merged it, and moves where any member does — so a directive
155
+ // keeps what the plain attribute still holds without either knowing of the other.
137
156
  //
138
- // ALLOWED, rather than refused. The page still RENDERS right — the server writes both
139
- // halves into the one merged attribute — so the only thing a build error would buy is
140
- // breaking a page that works today for a clause that is not built. What it costs is
141
- // the hole: this one attribute keeps the value standing at render where 32.3 says it
142
- // moves, and nothing reports that.
143
- //
144
- // 32.19 IS WHAT CLOSES THE `style` HALF: an interpolated `style` lowers to `style:prop`
145
- // under a static name and is never built as a string, and then there is no
146
- // whole-attribute write left to clobber anything. No clause spells the same road for
147
- // `class`, and until one does the two share this answer.
157
+ // 32.10 — A STATIC PLAIN ATTRIBUTE IS LEFT UNBOUND, and so is a directive naming one of
158
+ // a static `class`'s classes, which is always on. The static value is never written
159
+ // again, and the directives write through `classList` or the CSSOM beside it.
160
+ const shared = sharing[groups.indexOf(group)]
161
+ if (shared !== undefined) {
162
+ classified.push({
163
+ attribute,
164
+ live: leads ? shared.live : live,
165
+ kind: 'merged',
166
+ group: leads ? group : undefined,
167
+ toggles: leads
168
+ ? { write: `${group.name}Group`, name: shared.names }
169
+ : undefined,
170
+ })
171
+ continue
172
+ }
173
+ const name = attribute.name.slice(group.name.length + 1)
148
174
  classified.push({
149
175
  attribute,
150
176
  live,
151
177
  kind: 'merged',
152
178
  group: leads ? group : undefined,
153
179
  toggles:
154
- attribute === group.plain
180
+ attribute === group.plain ||
181
+ (group.name === 'class' && held.includes(name))
155
182
  ? undefined
156
- : {
157
- write: group.name,
158
- name: attribute.name.slice(group.name.length + 1),
159
- },
183
+ : { write: group.name, name },
160
184
  })
161
185
  continue
162
186
  }
@@ -191,7 +215,7 @@ export function classifyAttributes(
191
215
  : bind === 'group'
192
216
  ? 'checked'
193
217
  : bind,
194
- own: staticAttribute(node, 'value') ?? 'on',
218
+ own: attributeOf(node, 'value'),
195
219
  })
196
220
  else if (isElementBind(attribute.lowerName))
197
221
  classified.push({ attribute, live, kind: 'element' })
@@ -294,6 +318,85 @@ function mergeGroupsOf(node: ElementNode): MergeGroup[] {
294
318
  return groups
295
319
  }
296
320
 
321
+ /**
322
+ * 32.71 — for a group whose plain attribute is an expression and where some member can move, so
323
+ * neither half can be written without the other: whether a member is `live`, and the directives'
324
+ * classes or properties, which the group owns before its first write. `undefined` for any other.
325
+ */
326
+ function sharingOf(
327
+ group: MergeGroup,
328
+ lowering: Parameters<typeof classifyAttributes>[1],
329
+ ): { live: boolean; names: string } | undefined {
330
+ if (!group.plain?.expression) return undefined
331
+ let live = false
332
+ let moves = false
333
+ for (const member of group.members) {
334
+ if (!member.expression) continue
335
+ if (lowering.isLive(member)) live = moves = true
336
+ else if (lowering.readsAnItem(member)) moves = true
337
+ }
338
+ if (!moves) return undefined
339
+ const names: string[] = []
340
+ for (const directive of group.directives)
341
+ names.push(directive.name.slice(group.name.length + 1))
342
+ return { live, names: names.join(' ') }
343
+ }
344
+
345
+ /** 32.71 — the classes a static `class` beside a `class:name` holds, each always on. */
346
+ function heldClasses(groups: MergeGroup[]): string[] {
347
+ for (const group of groups)
348
+ if (group.name === 'class' && group.plain && !group.plain.expression)
349
+ return decodeCharacterReferences(
350
+ group.plain.value,
351
+ 'attribute',
352
+ ).split(ASCII_WHITESPACE)
353
+ return []
354
+ }
355
+
356
+ /**
357
+ * 32.72 — WHAT A `bind:` WRITES IS THE BIND'S ALONE: `checked` beside `bind:checked` or
358
+ * `bind:group`, `value` beside `bind:value`, any `bind:name` beside `name` — and an
359
+ * `<option selected>` inside a `<select bind:value>`, and a `<textarea bind:value>`'s text. Two
360
+ * owners of one value render whichever the browser keeps and move with whichever wrote last.
361
+ */
362
+ function refuseTwoOwners(node: ElementNode): void {
363
+ for (const attribute of node.attributes) {
364
+ const lower = attribute.lowerName
365
+ if (!lower.startsWith('bind:') || lower === 'bind:element') continue
366
+ const owned =
367
+ lower === 'bind:group' ? 'checked' : lower.slice('bind:'.length)
368
+ const beside = attributeOf(node, owned)
369
+ if (beside !== undefined)
370
+ throw new Error(
371
+ `abide: <${node.tag}> has ${attribute.name} and ${beside.name} — one value, two owners. ${attribute.name} writes ${owned} already; drop ${beside.name}. RULEBOOK 32.72.`,
372
+ )
373
+ if (owned !== 'value') continue
374
+ if (node.lowerTag === 'textarea' && node.children.length > 0)
375
+ throw new Error(
376
+ `abide: <${node.tag} ${attribute.name}> has text of its own — one value, two owners. The bind writes the text; drop what stands between the tags. RULEBOOK 32.72.`,
377
+ )
378
+ if (node.lowerTag === 'select' && holdsSelectedOption(node.children))
379
+ throw new Error(
380
+ `abide: <${node.tag} ${attribute.name}> has an <option selected> — one value, two owners. The bind selects the option its value holds; drop selected. RULEBOOK 32.72.`,
381
+ )
382
+ }
383
+ }
384
+
385
+ /** Whether an `<option>` with a `selected` attribute stands anywhere among `nodes`, blocks included. */
386
+ function holdsSelectedOption(nodes: readonly TemplateNode[]): boolean {
387
+ for (const node of nodes) {
388
+ if (
389
+ node.kind === 'element' &&
390
+ node.lowerTag === 'option' &&
391
+ attributeOf(node, 'selected') !== undefined
392
+ )
393
+ return true
394
+ for (const body of bodiesOf(node))
395
+ if (holdsSelectedOption(body.children)) return true
396
+ }
397
+ return false
398
+ }
399
+
297
400
  /** An element's attribute by its folded name, or `undefined`. */
298
401
  export function attributeOf(
299
402
  node: ElementNode,
@@ -304,14 +407,6 @@ export function attributeOf(
304
407
  return undefined
305
408
  }
306
409
 
307
- /** An attribute's static value, or `undefined` where it is absent or an expression. */
308
- function staticAttribute(node: ElementNode, name: string): string | undefined {
309
- const attribute = attributeOf(node, name)
310
- return attribute === undefined || attribute.expression
311
- ? undefined
312
- : attribute.value
313
- }
314
-
315
410
  /**
316
411
  * Whether a tag names a component: it starts with an uppercase ASCII letter.
317
412
  *
@@ -21,6 +21,7 @@ import {
21
21
  classifyAttributes,
22
22
  isBound,
23
23
  isSingleRooted,
24
+ type MergeGroup,
24
25
  isSlot,
25
26
  } from './classifyTemplate.ts'
26
27
  import { decodeCharacterReferences } from './decodeCharacterReferences.ts'
@@ -220,9 +221,7 @@ export function emitClient(
220
221
  const registered = heads
221
222
  ? `if (${ROUTED}) ${ROUTED}.setHead(() => ${headMarkup})\n`
222
223
  : ''
223
- const applied = heads
224
- ? `if (${ROUTED}) ${lowering.use('applyHead')}(${ROUTED}.headMarkup()); `
225
- : ''
224
+ const applied = heads ? `if (${ROUTED}) ${ROUTED}.reconcile(); ` : ''
226
225
  // Applied with the fill either way, so the watch's first run only subscribes: what it would
227
226
  // write is already there, from the server or from the line above.
228
227
  if (liveHeads) lines.push(`if (${ROUTED}) ${ROUTED}.followHead(${SCOPE})`)
@@ -767,6 +766,17 @@ function elementUnits(
767
766
  )
768
767
  // 32.5, 32.7, 32.8 — THE PROPERTY AND THE VALUE, IN STEP BOTH WAYS: a watch on this
769
768
  // region's scope writes the property, and a listener writes it back.
769
+ // 32.8 — a group compares against the input's own value, read where the value is.
770
+ else if (one.kind === 'bound' && one.bind === 'group')
771
+ binds.push(
772
+ `${lowering.use('bindGroup')}(${element}, ${lowering.reference(one.attribute)}, () => (${
773
+ one.own === undefined
774
+ ? "'on'"
775
+ : one.own.expression
776
+ ? lowering.binding(one.own)
777
+ : JSON.stringify(one.own.value)
778
+ }), ${SCOPE})`,
779
+ )
770
780
  else if (one.kind === 'bound')
771
781
  binds.push(
772
782
  `${lowering.use('bindProperty')}(${element}, ${JSON.stringify(one.bind)}, ${lowering.reference(one.attribute)}, ${SCOPE})`,
@@ -798,16 +808,32 @@ if (__r${slot} !== undefined) ${SCOPE}.stops.push(__r${slot})`,
798
808
  write: 'attribute' as const,
799
809
  name: one.attribute.name,
800
810
  }
811
+ // 32.71 — A SHARED GROUP IS ONE WATCH over its whole value, which wakes for any member:
812
+ // a directive cannot keep what the plain attribute holds from a watch of its own.
813
+ const group =
814
+ one.kind === 'merged' && writesGroup(write)
815
+ ? one.group
816
+ : undefined
801
817
  // 32.67 — one reading a row's item is one of the row's item watches, on a name of its
802
818
  // own because it is installed apart from the element's other attributes.
803
- if (lowering.readsOwnItem(one.attribute))
804
- itemWrites.push(
805
- `${SCOPE}.itemWatch(() => ${lowering.use(WRITES[write])}(__i${slot}, ${JSON.stringify(name)}, ${lowering.binding(one.attribute)}))`,
806
- )
807
- else
808
- writes.push(
809
- `${SCOPE}.watch(() => ${lowering.use(WRITES[write])}(__a${slot}, ${JSON.stringify(name)}, ${lowering.binding(one.attribute)}))`,
810
- )
819
+ const asItem =
820
+ group === undefined
821
+ ? lowering.readsOwnItem(one.attribute)
822
+ : groupReadsOwnItem(group, lowering)
823
+ const call = writeCall(
824
+ write,
825
+ asItem ? `__i${slot}` : `__a${slot}`,
826
+ name,
827
+ group === undefined
828
+ ? lowering.binding(one.attribute)
829
+ : groupArguments(group, (attribute) =>
830
+ lowering.binding(attribute),
831
+ ),
832
+ lowering,
833
+ )
834
+ ;(asItem ? itemWrites : writes).push(
835
+ `${SCOPE}.${asItem ? 'itemWatch' : 'watch'}(() => ${call})`,
836
+ )
811
837
  }
812
838
  }
813
839
  if (itemWrites.length > 0)
@@ -825,14 +851,27 @@ if (__r${slot} !== undefined) ${SCOPE}.stops.push(__r${slot})`,
825
851
  const SCOPE = '__scope'
826
852
 
827
853
  // Which runtime write each kind of attribute binding is handed to. A record rather than a chain
828
- // of ternaries, so a fourth kind is a row here and the call site stays one line — all three take
829
- // the same three arguments, which is what makes them one site at all.
854
+ // of ternaries, so a sixth kind is a row here and the call site stays one line — all take the
855
+ // same three arguments, which is what makes them one site at all.
830
856
  const WRITES = {
831
857
  attribute: 'setAttribute',
832
858
  class: 'setClass',
833
859
  style: 'setStyleProperty',
860
+ classGroup: 'setClassGroup',
861
+ styleGroup: 'setStyleGroup',
834
862
  } as const
835
863
 
864
+ /** One attribute binding's write: the element, the one name it owns, and the value. */
865
+ function writeCall(
866
+ write: keyof typeof WRITES,
867
+ element: string,
868
+ name: string,
869
+ value: string,
870
+ lowering: Lowering,
871
+ ): string {
872
+ return `${lowering.use(WRITES[write])}(${element}, ${JSON.stringify(name)}, ${value})`
873
+ }
874
+
836
875
  // The share scope an instance captures for its deferred mounts (10.5), named apart from the one an
837
876
  // instance OPENS (`shareScope`): a page that shares nothing still carries this for a child that does.
838
877
  const SHARE_AT = '__shareAt'
@@ -1059,6 +1098,41 @@ function buildRender(lowering: Lowering): ValueRender {
1059
1098
  }
1060
1099
  }
1061
1100
 
1101
+ /** 32.71 — whether a write is a shared group's whole value. */
1102
+ function writesGroup(write: keyof typeof WRITES): boolean {
1103
+ return write === 'classGroup' || write === 'styleGroup'
1104
+ }
1105
+
1106
+ /** 32.71 — whether a shared group is one of a row's item watches: any member is. */
1107
+ function groupReadsOwnItem(group: MergeGroup, lowering: Lowering): boolean {
1108
+ for (const member of group.members)
1109
+ if (member.expression && lowering.readsOwnItem(member)) return true
1110
+ return false
1111
+ }
1112
+
1113
+ /**
1114
+ * 32.71 — a shared group's write arguments: the plain attribute's value, then every directive's
1115
+ * condition or value in the order the group names them, each read by `read`. A directive with no
1116
+ * expression is a class that is always on, or a property's own bytes.
1117
+ */
1118
+ function groupArguments(
1119
+ group: MergeGroup,
1120
+ read: (attribute: Attribute) => string,
1121
+ ): string {
1122
+ const values: string[] = []
1123
+ for (const directive of group.directives)
1124
+ values.push(
1125
+ directive.expression
1126
+ ? read(directive)
1127
+ : group.name === 'class'
1128
+ ? 'true'
1129
+ : JSON.stringify(
1130
+ decodeCharacterReferences(directive.value, 'attribute'),
1131
+ ),
1132
+ )
1133
+ return `${read(group.plain as Attribute)}, [${values.join(', ')}]`
1134
+ }
1135
+
1062
1136
  /**
1063
1137
  * Emits a region's `_build` and `_mount` pair into `helpers`, for a region nobody painted.
1064
1138
  *
@@ -1565,6 +1639,40 @@ function rowUpdate(
1565
1639
  position.holder as ElementNode,
1566
1640
  lowering,
1567
1641
  )) {
1642
+ // 32.71 — a shared group is written whole where a member moves with the item — and
1643
+ // where none is live, as for any attribute: a live group's watch reads the item too.
1644
+ if (
1645
+ one.kind === 'merged' &&
1646
+ one.group !== undefined &&
1647
+ one.toggles !== undefined &&
1648
+ writesGroup(one.toggles.write)
1649
+ ) {
1650
+ if (one.live) continue
1651
+ for (const member of one.group.members)
1652
+ if (
1653
+ member.expression &&
1654
+ !lowering.isLive(member) &&
1655
+ lowering.readsItem(member, block)
1656
+ ) {
1657
+ own.push(
1658
+ writeCall(
1659
+ one.toggles.write,
1660
+ `__a${slot}`,
1661
+ one.toggles.name,
1662
+ groupArguments(one.group, (attribute) =>
1663
+ unlandable(
1664
+ lowering,
1665
+ attribute,
1666
+ lowering.expression(attribute),
1667
+ ),
1668
+ ),
1669
+ lowering,
1670
+ ),
1671
+ )
1672
+ break
1673
+ }
1674
+ continue
1675
+ }
1568
1676
  if (!one.attribute.expression || one.live) continue
1569
1677
  if (!lowering.readsItem(one.attribute, block)) continue
1570
1678
  const toggles =
@@ -1578,7 +1686,13 @@ function rowUpdate(
1578
1686
  : undefined
1579
1687
  if (toggles === undefined) continue
1580
1688
  own.push(
1581
- `${lowering.use(WRITES[toggles.write])}(__a${slot}, ${JSON.stringify(toggles.name)}, ${lowering.expression(one.attribute)})`,
1689
+ writeCall(
1690
+ toggles.write,
1691
+ `__a${slot}`,
1692
+ toggles.name,
1693
+ lowering.expression(one.attribute),
1694
+ lowering,
1695
+ ),
1582
1696
  )
1583
1697
  }
1584
1698
  if (own.length === 0) continue
@@ -354,13 +354,9 @@ function renderPosition(
354
354
  // `<br>`, and a close tag written for one is markup no browser accepts.
355
355
  if (node.children.length === 0 && VOID_ELEMENTS.has(node.lowerTag))
356
356
  return parts.join(' + ')
357
- // 32.61 — THE VALUE IN PLACE OF THE AUTHOR'S TEXT, led by the newline the parser drops after
358
- // `<textarea>` so a value opening on one keeps it.
359
- if (
360
- within !== undefined &&
361
- node.lowerTag === 'textarea' &&
362
- node.children.length === 0
363
- )
357
+ // 32.61 — THE VALUE AS THE TEXT, led by the newline the parser drops after `<textarea>` so a
358
+ // value opening on one keeps it. The author wrote none: 32.72 refuses it beside the bind.
359
+ if (within !== undefined && node.lowerTag === 'textarea')
364
360
  return [
365
361
  ...parts,
366
362
  `'\\n' + ${lowering.use('escapeAttribute')}(${within})`,
@@ -614,7 +610,17 @@ function boundMarkup(
614
610
  return `' value="' + ${lowering.use('escapeAttribute')}(${read}) + '"'`
615
611
  const on =
616
612
  written.bind === 'group'
617
- ? `${lowering.use('groupHolds')}(${read}, ${JSON.stringify(written.own)})`
613
+ ? `${lowering.use('groupHolds')}(${read}, ${
614
+ written.own === undefined
615
+ ? "'on'"
616
+ : written.own.expression
617
+ ? unlandable(
618
+ lowering,
619
+ written.own,
620
+ lowering.expression(written.own),
621
+ )
622
+ : JSON.stringify(written.own.value)
623
+ })`
618
624
  : read
619
625
  return `(${on} ? ' ${written.reflects}' : '')`
620
626
  }
@@ -117,10 +117,10 @@ export async function composePage(
117
117
  * 39.4, 39.11 — THE DEEPEST CONTRIBUTOR WINS, and the markup is in tree order — the shell's head
118
118
  * (its outermost contributor), then each level's, outermost first — so the deepest is the last.
119
119
  * Written end to end instead, a shell's or a layout's `<title>` stands ahead of its page's and a
120
- * browser reads the first. The compiler merges within a file (39.12) and `applyHead` across a
120
+ * browser reads the first. The compiler merges within a file (39.12) and `reconcileHead` across a
121
121
  * navigation; this is the same grammar across the contributors of one render.
122
122
  *
123
- * TOP LEVEL OF THE HEAD ONLY, as `applyHead` keys the head's children and nothing inside them.
123
+ * TOP LEVEL OF THE HEAD ONLY, as `reconcileHead` keys the head's children and nothing inside them.
124
124
  * The attribute is the one WRITTEN rather than decoded — a shell's escaping and the compiler's
125
125
  * agree on every key a shell and a page would plausibly share.
126
126
  *
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A run of ASCII whitespace, which is what a `class` value is split into tokens on — read by the
3
+ * compiler finding the classes a static `class` holds and by the runtime writing a `class={expr}`'s
4
+ * tokens (32.71), so both split one way.
5
+ */
6
+ export const ASCII_WHITESPACE = /[\t\n\f\r ]+/