@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 +10 -0
- package/docs/REGISTRY.md +8 -8
- package/docs/RULEBOOK.md +13 -3
- package/docs/abide.md +8 -8
- package/package.json +1 -1
- package/src/compiler/Lowering.ts +5 -0
- package/src/compiler/classifyTemplate.ts +129 -34
- package/src/compiler/emitClient.ts +128 -14
- package/src/compiler/renderMarkup.ts +14 -8
- package/src/server/render.ts +2 -2
- package/src/shared/ASCII_WHITESPACE.ts +6 -0
- package/src/ui/Level.ts +43 -95
- package/src/ui/Router.ts +12 -13
- package/src/ui/bindGroup.ts +41 -0
- package/src/ui/bindProperty.ts +1 -21
- package/src/ui/index.ts +5 -3
- package/src/ui/reconcileHead.ts +215 -0
- package/src/ui/setClassGroup.ts +79 -0
- package/src/ui/setStyleGroup.ts +130 -0
- package/src/ui/applyHead.ts +0 -214
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
|
|
2678
|
-
|
|
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
package/src/compiler/Lowering.ts
CHANGED
|
@@ -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
|
-
/**
|
|
94
|
-
|
|
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
|
-
//
|
|
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:
|
|
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: {
|
|
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.
|
|
134
|
-
//
|
|
135
|
-
//
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
|
829
|
-
//
|
|
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
|
-
|
|
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
|
|
358
|
-
//
|
|
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}, ${
|
|
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
|
}
|
package/src/server/render.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 ]+/
|