@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/src/ui/Level.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  } from '#shared/reactive/share.ts'
7
7
  import { untracked } from '#shared/reactive/tracking.ts'
8
8
  import { type Disposer, watchTracked } from '#shared/reactive/watch.ts'
9
- import { applyHead, withdrawHead } from './applyHead.ts'
9
+ import { reconciled, reconcileHead } from './reconcileHead.ts'
10
10
  import { isEmptyRegion, type Region } from './regions.ts'
11
11
  import type { Scope } from './Scope.ts'
12
12
 
@@ -43,10 +43,10 @@ export class Level {
43
43
  */
44
44
  share: ShareScope | null = null
45
45
  /**
46
- * The head the components built on the client contributed while this level mounts, applied
47
- * once its `mount` returns; `undefined` outside one, where a contribution applies at once.
46
+ * Whether a component built on the client contributed a head while this level mounts, which is
47
+ * reconciled once its `mount` returns; `undefined` outside one, where it is reconciled at once.
48
48
  */
49
- unapplied: string | undefined = undefined
49
+ unapplied: boolean | undefined = undefined
50
50
  /**
51
51
  * This level's own head markup as it reads now; `undefined` for a level with no `<head>`.
52
52
  *
@@ -82,11 +82,31 @@ export class Level {
82
82
  this.share = share
83
83
  }
84
84
 
85
- /** The level's head as it reads now: its own, then every component's, deepest last (39.4). */
86
- headMarkup(): string {
87
- let markup = this.head?.() ?? ''
88
- for (const one of this.contributed) markup += one.head()
89
- return markup
85
+ /** Reconciles the document head from the chain this level stands in (`reconcileHead`). */
86
+ reconcile(): void {
87
+ Level.reconcile(this.levels)
88
+ }
89
+
90
+ /**
91
+ * Reconciles the document head from `levels`: each level's own head, then every component's in
92
+ * the order they mounted, outermost first — so the deepest wins by key (39.4). Read untracked:
93
+ * a head read here is followed by its own watch, not by whatever wrote.
94
+ */
95
+ static reconcile(levels: Level[]): void {
96
+ reconcileHead(untracked(Level.chain, levels, undefined))
97
+ }
98
+
99
+ /** Every contributor in `levels` and the head it reads now, outermost first. */
100
+ static chain(
101
+ levels: Level[],
102
+ _?: undefined,
103
+ ): [who: object, text: string][] {
104
+ const chain: [object, string][] = []
105
+ for (const level of levels) {
106
+ if (level.head !== undefined) chain.push([level, level.head()])
107
+ for (const one of level.contributed) chain.push([one, one.head()])
108
+ }
109
+ return chain
90
110
  }
91
111
 
92
112
  /**
@@ -100,78 +120,18 @@ export class Level {
100
120
  const contribution = new HeadContribution(this, head)
101
121
  this.contributed.add(contribution)
102
122
  contribution.dispose = watchTracked(() => contribution.follow())
103
- // Batched while this level mounts; applied at once by an arm or a row mounted after it,
104
- // under whatever is deeper (39.4) — only the levels below, since it was added last here.
105
- const text = contribution.last ?? ''
123
+ // Batched while this level mounts; reconciled at once for an arm or a row mounted after it.
106
124
  if (isEmptyRegion(region))
107
- if (this.unapplied !== undefined) this.unapplied += text
108
- else
109
- applyHead(
110
- text,
111
- untracked(Level.headsOf, this.levels, this.at + 1),
112
- )
125
+ if (this.unapplied !== undefined) this.unapplied = true
126
+ else this.reconcile()
113
127
  return contribution
114
128
  }
115
129
 
116
- /**
117
- * Writes `text` over the `last` that `contributor` in this level wrote.
118
- *
119
- * 39.4 — IT YIELDS TO EVERY CONTRIBUTOR DEEPER, read as they stand at the write and untracked:
120
- * the components that contributed after it, then every level below, so a layout's
121
- * `<title>{site}</title>` moving does not write over the page's `<title>`, and the page's head
122
- * moving wakes only the page's own watch. 39.15 — its keyless nodes replace the ones it wrote.
123
- */
124
- reapply(text: string, last: string, contributor: HeadContribution): void {
125
- applyHead(
126
- text,
127
- untracked(Level.headsAfter, this, contributor),
128
- last,
129
- // Read only where a key `last` held is withdrawn.
130
- () => untracked(Level.headsBefore, this, contributor),
131
- )
132
- }
133
-
134
- /**
135
- * The heads either side of `contribution` in `level` (`headsBefore`, `headsAfter`). One `level`
136
- * does not hold is the level's own head (`followHead`), which stands before every component's.
137
- */
138
- static headsAround(
139
- level: Level,
140
- contribution: HeadContribution,
141
- ): [string, string] {
142
- return [
143
- Level.headsBefore(level, contribution),
144
- Level.headsAfter(level, contribution),
145
- ]
146
- }
147
-
148
- /** Every level above `level`, then what stands before `contribution` in it. */
149
- static headsBefore(level: Level, contribution: HeadContribution): string {
150
- let outer = Level.headsOf(level.levels, 0, level.at)
151
- if (!level.contributed.has(contribution)) return outer
152
- outer += level.head?.() ?? ''
153
- for (const one of level.contributed) {
154
- if (one === contribution) break
155
- outer += one.head()
156
- }
157
- return outer
158
- }
159
-
160
- /** What stands after `contribution` in `level`, then every level below it. */
161
- static headsAfter(level: Level, contribution: HeadContribution): string {
162
- let inner = ''
163
- let past = !level.contributed.has(contribution)
164
- for (const one of level.contributed)
165
- if (past) inner += one.head()
166
- else if (one === contribution) past = true
167
- return inner + Level.headsOf(level.levels, level.at + 1)
168
- }
169
-
170
130
  /**
171
131
  * 39.13 — A HEAD THAT READS A VALUE FOLLOWS IT for as long as the level is mounted, which is
172
132
  * `scope`'s life. The first run only subscribes: the head it would write is already there,
173
133
  * written by the server or applied by the level's mount ahead of its fill (39.6). Followed as a
174
- * component's is, by a contribution the level does not hold (`headsAround`).
134
+ * component's is, by a contribution the level does not hold.
175
135
  */
176
136
  followHead(scope: Scope): void {
177
137
  const own = new HeadContribution(this, this.head as () => string)
@@ -190,8 +150,8 @@ export class Level {
190
150
  values: new Map(),
191
151
  level: this,
192
152
  })
193
- this.unapplied = ''
194
- let unapplied: string
153
+ this.unapplied = false
154
+ let unapplied: boolean
195
155
  let result: Result
196
156
  try {
197
157
  result = run()
@@ -200,20 +160,9 @@ export class Level {
200
160
  this.unapplied = undefined
201
161
  leaveShare(back)
202
162
  }
203
- // 39.4 — under whatever a level below wrote while this one was mounting: the page in a
204
- // layout's `<slot/>` applied its own head ahead of the components that layout holds.
205
- if (unapplied !== '')
206
- applyHead(unapplied, Level.headsOf(this.levels, this.at + 1))
163
+ if (unapplied) this.reconcile()
207
164
  return result
208
165
  }
209
-
210
- /** The heads of `levels` from `from` to `to`, outermost first. */
211
- static headsOf(levels: Level[], from: number, to = levels.length): string {
212
- let markup = ''
213
- for (let at = from; at < to; at += 1)
214
- markup += (levels[at] as Level).headMarkup()
215
- return markup
216
- }
217
166
  }
218
167
 
219
168
  /**
@@ -236,24 +185,23 @@ export class HeadContribution {
236
185
  follow(): void {
237
186
  const text = this.head()
238
187
  const last = this.last
239
- if (last !== undefined && text !== last)
240
- this.level.reapply(text, last, this)
241
188
  this.last = text
189
+ if (last !== undefined && text !== last) this.level.reconcile()
242
190
  }
243
191
 
244
192
  /**
245
- * 39.14 — WITHDRAWN AS A LEAVING LEVEL'S IS, its keys going back to what stands outside it and
246
- * staying where something deeper holds them. Nothing where the level is leaving too: the router
247
- * withdraws the whole level once, and cleared its contributions before stopping it.
193
+ * 39.14 — WITHDRAWN AS A LEAVING LEVEL'S IS: the chain is reconciled without it. Adopted first
194
+ * where the server painted it and nothing has reconciled since, so what it painted is known to
195
+ * be its own. Nothing where the level is leaving too: the router reconciles the whole chain
196
+ * once, and cleared its contributions before stopping it.
248
197
  */
249
198
  stop(): void {
250
199
  this.dispose?.()
251
200
  const level = this.level
252
201
  if (!level.contributed.has(this)) return
253
- withdrawHead(this.last ?? '', () =>
254
- untracked(Level.headsAround, level, this),
255
- )
202
+ if (!reconciled(this)) level.reconcile()
256
203
  level.contributed.delete(this)
204
+ level.reconcile()
257
205
  }
258
206
  }
259
207
 
package/src/ui/Router.ts CHANGED
@@ -10,7 +10,6 @@ import { arrive, route } from '#shared/route.ts'
10
10
  import { rankRoutes, type Segment } from '#shared/routePattern.ts'
11
11
  import { setUpLevels } from '#shared/setUpLevels.ts'
12
12
  import { paintedCallsLanded } from '#shared/takeEarlyCall.ts'
13
- import { withdrawHead } from './applyHead.ts'
14
13
  import { LAYER_MOUNT, LAYER_NAVIGATE } from './LAYER_NAMES.ts'
15
14
  import { Level } from './Level.ts'
16
15
  import { beginLayer, endLayer } from './measureLayer.ts'
@@ -469,12 +468,9 @@ export class Router {
469
468
  // table written by hand may answer with a fresh record each call. The function a module
470
469
  // exports is the same function either way.
471
470
  const shared = this.sharedLevels(arriving)
472
- // READ BEFORE THE STOP, while the leaving levels' heads read what they read on screen.
473
- const leaving = Level.headsOf(
474
- this.levels,
475
- shared,
476
- this.levels.length,
477
- )
471
+ // RECONCILED BEFORE THE STOP, while the leaving levels' heads read what they read on
472
+ // screen: what the server painted for them is adopted as theirs, so it leaves with them.
473
+ Level.reconcile(this.levels)
478
474
  const left: Scroll = [scrollX, scrollY]
479
475
  this.stopFrom(shared)
480
476
  // The region the arriving part goes into: the page region where nothing is kept, and
@@ -495,11 +491,8 @@ export class Router {
495
491
  // outermost first; a kept layout is not mounted again, so neither is its head.
496
492
  this.mountChain(target, found, arriving, shared, into)
497
493
  // 39.14 — what the leaving levels keyed and the arriving ones did not goes back to the
498
- // nearest kept layout's, else leaves.
499
- withdrawHead(leaving, () => [
500
- Level.headsOf(this.levels, 0, shared),
501
- Level.headsOf(this.levels, shared, this.levels.length),
502
- ])
494
+ // nearest kept layout's, else leaves; their keyless nodes leave with them (39.16).
495
+ Level.reconcile(this.levels)
503
496
  // 30.29 — where the entry was left, on a history step; else the top of the page, or —
504
497
  // as the browser does on a load — the fragment's element.
505
498
  if (step.scroll !== undefined)
@@ -542,11 +535,13 @@ export class Router {
542
535
  private stopFrom(at: number): void {
543
536
  for (let index = this.levels.length - 1; index >= at; index -= 1) {
544
537
  const level = this.levels[index] as Level
538
+ // OUT OF THE CHAIN BEFORE IT STOPS, so a slot stop it fires on the way finds it gone
539
+ // (`release`) and leaves the head to whoever stopped it.
540
+ this.levels.length = index
545
541
  // The level's head leaves whole, so none of its components withdraws its own.
546
542
  level.contributed.clear()
547
543
  level.scope?.stop()
548
544
  }
549
- this.levels.length = at
550
545
  }
551
546
 
552
547
  /**
@@ -629,7 +624,11 @@ export class Router {
629
624
  private release(level: Level, at: number): void {
630
625
  if (this.levels[at] !== level) return
631
626
  level.slot = undefined
627
+ // 39.14, 39.16 — THE LEVELS BELOW LEAVE WITH THE SLOT'S REGION, and their head with them:
628
+ // reconciled first, so what the server painted for them is adopted as theirs.
629
+ Level.reconcile(this.levels)
632
630
  this.stopFrom(at + 1)
631
+ Level.reconcile(this.levels)
633
632
  }
634
633
 
635
634
  /**
@@ -0,0 +1,41 @@
1
+ import {
2
+ boundValue,
3
+ groupHolds,
4
+ heldValue,
5
+ writeBound,
6
+ } from '#shared/boundValue.ts'
7
+ import type { Scope } from './Scope.ts'
8
+
9
+ /**
10
+ * Keeps a radio or checkbox's `checked` in step with a `bind:group` value, both ways.
11
+ *
12
+ * 32.8 — COMPARED AGAINST THE INPUT'S OWN VALUE, read through `own` — the `value` attribute's
13
+ * expression, or its static text, or `on` where it has none — rather than off the element: a
14
+ * `value={option.id}` that moves reruns the compare, and a number stays a number in the group
15
+ * where the element's `value` property would have handed back its text.
16
+ */
17
+ export function bindGroup(
18
+ element: Element,
19
+ operand: unknown,
20
+ own: () => unknown,
21
+ scope: Scope,
22
+ ): void {
23
+ const bound = element as HTMLInputElement
24
+ scope.watch(() => {
25
+ const checked = groupHolds(boundValue(operand), own())
26
+ bound.checked = checked
27
+ bound.toggleAttribute('checked', checked)
28
+ })
29
+ bound.addEventListener('change', () => {
30
+ const value = own()
31
+ const standing = heldValue(operand)
32
+ // A checkbox group holds a list, and keeps its order; a radio group holds the one value.
33
+ if (Array.isArray(standing)) {
34
+ const next: unknown[] = []
35
+ for (const member of standing)
36
+ if (member !== value) next.push(member)
37
+ if (bound.checked) next.push(value)
38
+ writeBound(operand, next)
39
+ } else if (bound.checked) writeBound(operand, value)
40
+ })
41
+ }
@@ -8,7 +8,6 @@
8
8
 
9
9
  import {
10
10
  boundValue,
11
- groupHolds,
12
11
  heldValue,
13
12
  selectHolds,
14
13
  writeBound,
@@ -20,7 +19,7 @@ type Bound = HTMLInputElement & HTMLDetailsElement
20
19
 
21
20
  export function bindProperty(
22
21
  element: Element,
23
- kind: 'value' | 'checked' | 'open' | 'group',
22
+ kind: 'value' | 'checked' | 'open',
24
23
  operand: unknown,
25
24
  scope: Scope,
26
25
  ): void {
@@ -29,25 +28,6 @@ export function bindProperty(
29
28
  bindValue(bound, operand, scope)
30
29
  return
31
30
  }
32
- if (kind === 'group') {
33
- scope.watch(() => {
34
- const checked = groupHolds(boundValue(operand), bound.value)
35
- bound.checked = checked
36
- bound.toggleAttribute('checked', checked)
37
- })
38
- bound.addEventListener('change', () => {
39
- const standing = heldValue(operand)
40
- // A checkbox group holds a list, and keeps its order; a radio group holds the one value.
41
- if (Array.isArray(standing)) {
42
- const next: unknown[] = []
43
- for (const member of standing)
44
- if (member !== bound.value) next.push(member)
45
- if (bound.checked) next.push(bound.value)
46
- writeBound(operand, next)
47
- } else if (bound.checked) writeBound(operand, bound.value)
48
- })
49
- return
50
- }
51
31
  // `checked` on an input, `open` on a `<details>`.
52
32
  const property = kind
53
33
  scope.watch(() => {
package/src/ui/index.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  // and every `class:name` on an element is merged by the server into the single `style` or `class`
16
16
  // attribute beside the author's own, so neither of those two attributes can be written back whole
17
17
  // — the CSSOM and `classList` are what touch the one declaration or the one token a binding owns.
18
+ // A `class={expr}` or `style={expr}` beside its directives is the same answer once more
19
+ // (`setClassGroup`, `setStyleGroup`, 32.71): the group writes what it owns, and nothing else.
18
20
  // A list is reached through `listAt` and `updateList` alone, so its rows' shape is no caller's.
19
21
  // Nothing that finds a node by something added to the document, because a compiler may not invent
20
22
  // structure an author's CSS or queries can see.
@@ -37,10 +39,8 @@ export {
37
39
  runHere,
38
40
  textHere,
39
41
  } from './adoptWalk.ts'
40
- // 39.6 — a module mounted into an empty region applies its own head, from inside its instance
41
- // (`emitClient`); only a module that carries its own markup and a `<head>` imports it.
42
- export { applyHead } from './applyHead.ts'
43
42
  export { bindElement } from './bindElement.ts'
43
+ export { bindGroup } from './bindGroup.ts'
44
44
  export { bindProperty } from './bindProperty.ts'
45
45
  export { bindSpread } from './bindSpread.ts'
46
46
  export { escapeless } from './escapeless.ts'
@@ -60,7 +60,9 @@ export { itemOf, noUpdate, replaceRow, rowCell } from './rows.ts'
60
60
  export { EMPTY_SCOPE, Scope } from './Scope.ts'
61
61
  export { setAttribute } from './setAttribute.ts'
62
62
  export { setClass } from './setClass.ts'
63
+ export { setClassGroup } from './setClassGroup.ts'
63
64
  export { setMarkup } from './setMarkup.ts'
65
+ export { setStyleGroup } from './setStyleGroup.ts'
64
66
  export { setStyleProperty } from './setStyleProperty.ts'
65
67
  export { setText } from './setText.ts'
66
68
  export { TryRegion } from './TryRegion.ts'
@@ -0,0 +1,215 @@
1
+ // 39.4–39.6, 39.13–39.17 — THE DOCUMENT HEAD HAS ONE WRITER. Every routed level and every component
2
+ // with a `<head>` is a contributor, and the head is reconciled from all of them at once, in the
3
+ // chain's order — each level's own head, then its components' in the order they mounted — rather
4
+ // than patched by each with what it can learn of the others.
5
+ //
6
+ // A KEY IS WON BY THE LAST CONTRIBUTOR HOLDING IT (39.4), and one nobody holds any more leaves or
7
+ // goes back to whoever still does (39.14) because that is what the chain now says. A KEYLESS NODE
8
+ // IS ITS CONTRIBUTOR'S, held by reference (39.5, 39.15, 39.16): two contributors writing the same
9
+ // `<style>` each own their own, and one leaving takes its own with it — told apart by where they
10
+ // stand, never by being equal.
11
+ //
12
+ // NOTHING IS ADDED TO MARK OWNERSHIP, and nothing a contributor did not write is touched: a node the
13
+ // server painted is ADOPTED the first time its contributor is reconciled, matched in chain order.
14
+
15
+ import { headKeyOf } from '#shared/headKeyOf.ts'
16
+ import { markupNodes } from './parseMarkup.ts'
17
+
18
+ /** One contributor's head, parsed once per text it reads. */
19
+ type Parsed = { text: string; keyed: Map<string, Element>; keyless: Node[] }
20
+
21
+ const parsedBy = new WeakMap<object, Parsed>()
22
+ /** The keyless nodes standing in the head for each contributor, in its order. */
23
+ const ownedBy = new Map<object, Node[]>()
24
+ /**
25
+ * The element standing for each key the chain held at the last reconcile, so one it drops is
26
+ * withdrawn — that element, and never another holding the same key that nothing here wrote.
27
+ */
28
+ const keyedNodes = new Map<string, Element>()
29
+
30
+ /** Whether `who` has been reconciled — adopted or written — since it joined. */
31
+ export function reconciled(who: object): boolean {
32
+ return ownedBy.has(who)
33
+ }
34
+
35
+ /**
36
+ * Writes the head the chain contributes: `chain` is every contributor and the head it reads now,
37
+ * outermost first.
38
+ *
39
+ * Only what moved is written. A key standing as it is wanted, and a keyless node equal to the one
40
+ * its contributor wants there, are left standing — a `<link rel=stylesheet>` put back as itself is
41
+ * refetched and flashes the page unstyled, and a `<script>` would run again.
42
+ */
43
+ export function reconcileHead(
44
+ chain: readonly (readonly [who: object, text: string])[],
45
+ ): void {
46
+ const head = document.head
47
+ const present = new Set<object>()
48
+ const wanted = new Map<string, Element>()
49
+ for (const [who, text] of chain) {
50
+ present.add(who)
51
+ for (const [key, element] of parsedOf(head, who, text).keyed)
52
+ wanted.set(key, element)
53
+ }
54
+ const standing = standingKeys(head)
55
+ for (const [key, node] of keyedNodes)
56
+ if (!wanted.has(key)) {
57
+ node.remove()
58
+ keyedNodes.delete(key)
59
+ }
60
+ for (const [key, element] of wanted) {
61
+ let at = standing.get(key)
62
+ if (at === undefined) {
63
+ at = element.cloneNode(true) as Element
64
+ head.appendChild(at)
65
+ } else if (at.outerHTML !== element.outerHTML) {
66
+ const next = element.cloneNode(true) as Element
67
+ head.replaceChild(next, at)
68
+ at = next
69
+ }
70
+ keyedNodes.set(key, at)
71
+ }
72
+
73
+ for (const [who, nodes] of ownedBy)
74
+ if (!present.has(who)) {
75
+ for (const node of nodes) node.parentNode?.removeChild(node)
76
+ ownedBy.delete(who)
77
+ }
78
+ const claimed = new Set<Node>()
79
+ for (const nodes of ownedBy.values())
80
+ for (const node of nodes) claimed.add(node)
81
+ let previous: Node | null = null
82
+ for (let at = 0; at < chain.length; at += 1) {
83
+ const who = (chain[at] as readonly [object, string])[0]
84
+ const wants = (parsedBy.get(who) as Parsed).keyless
85
+ const owned = ownedBy.get(who) ?? adopt(head, wants, claimed)
86
+ const next: Node[] = []
87
+ for (let index = 0; index < wants.length; index += 1) {
88
+ const want = wants[index] as Node
89
+ const have = owned[index]
90
+ let node: Node
91
+ if (have?.isEqualNode(want) === true) node = have as Node
92
+ else {
93
+ node = runnable(want.cloneNode(true))
94
+ if (have !== undefined) head.replaceChild(node, have)
95
+ else
96
+ head.insertBefore(
97
+ node,
98
+ previous === null
99
+ ? firstOwnedAfter(chain, at)
100
+ : previous.nextSibling,
101
+ )
102
+ }
103
+ next.push(node)
104
+ previous = node
105
+ }
106
+ for (let index = wants.length; index < owned.length; index += 1)
107
+ (owned[index] as Node).parentNode?.removeChild(owned[index] as Node)
108
+ ownedBy.set(who, next)
109
+ }
110
+ }
111
+
112
+ /** `who`'s head parsed, from the cache while it reads the same text. */
113
+ function parsedOf(head: HTMLHeadElement, who: object, text: string): Parsed {
114
+ const cached = parsedBy.get(who)
115
+ if (cached !== undefined && cached.text === text) return cached
116
+ const parsed: Parsed = { text, keyed: new Map(), keyless: [] }
117
+ for (const node of markupNodes(head, text)) {
118
+ const key = node.nodeType === 1 ? headKey(node as Element) : undefined
119
+ if (key === undefined) parsed.keyless.push(node)
120
+ else parsed.keyed.set(key, node as Element)
121
+ }
122
+ parsedBy.set(who, parsed)
123
+ return parsed
124
+ }
125
+
126
+ /**
127
+ * The nodes the server painted for a contributor reconciled for the first time: each one it wants,
128
+ * the first equal node nobody has claimed, in order. Contributors are adopted in the chain's
129
+ * order, which is the order the server painted them in, so an earlier one claims the earlier of
130
+ * two equal nodes.
131
+ */
132
+ function adopt(
133
+ head: HTMLHeadElement,
134
+ wants: Node[],
135
+ claimed: Set<Node>,
136
+ ): Node[] {
137
+ const adopted: Node[] = []
138
+ let from = head.firstChild
139
+ for (const want of wants) {
140
+ let found: Node | null = null
141
+ for (let at = from; at !== null; at = at.nextSibling)
142
+ if (!claimed.has(at) && at.isEqualNode(want)) {
143
+ found = at
144
+ break
145
+ }
146
+ if (found === null) break
147
+ claimed.add(found)
148
+ adopted.push(found)
149
+ from = found.nextSibling
150
+ }
151
+ return adopted
152
+ }
153
+
154
+ /** The first keyless node a contributor after `at` owns, which a new one goes before. */
155
+ function firstOwnedAfter(
156
+ chain: readonly (readonly [object, string])[],
157
+ at: number,
158
+ ): Node | null {
159
+ for (let later = at + 1; later < chain.length; later += 1) {
160
+ const owned = ownedBy.get(
161
+ (chain[later] as readonly [object, string])[0],
162
+ )
163
+ if (owned !== undefined && owned.length > 0) return owned[0] as Node
164
+ }
165
+ return null
166
+ }
167
+
168
+ /**
169
+ * `node`, or — for a `<script>` — the same script made by the document rather than the parser.
170
+ *
171
+ * 39.17 — A PARSED SCRIPT NEVER RUNS. The fragment parser marks every `<script>` it makes "already
172
+ * started", and a clone keeps the mark, so a script it made and then moved into the head is inert
173
+ * where the server's, made by the document's own parser, ran. One made by `createElement` starts
174
+ * clear and runs on insertion. Keyless always (39.11 keys no `<script>`).
175
+ */
176
+ function runnable(node: Node): Node {
177
+ if (node.nodeName !== 'SCRIPT') return node
178
+ const parsed = node as HTMLScriptElement
179
+ const script = document.createElement('script')
180
+ const attributes = parsed.attributes
181
+ for (let index = 0; index < attributes.length; index += 1) {
182
+ const attribute = attributes[index] as Attr
183
+ script.setAttribute(attribute.name, attribute.value)
184
+ }
185
+ script.text = parsed.text
186
+ return script
187
+ }
188
+
189
+ /**
190
+ * The head's keyed elements by key, the first holding each — 39.12 within the standing head: the
191
+ * FIRST element holding a key is the one a contribution replaces.
192
+ */
193
+ function standingKeys(head: HTMLHeadElement): Map<string, Element> {
194
+ const standingByKey = new Map<string, Element>()
195
+ for (
196
+ let node = head.firstElementChild;
197
+ node !== null;
198
+ node = node.nextElementSibling
199
+ ) {
200
+ const key = headKey(node)
201
+ if (key !== undefined && !standingByKey.has(key))
202
+ standingByKey.set(key, node)
203
+ }
204
+ return standingByKey
205
+ }
206
+
207
+ /**
208
+ * The key a head element replaces by, or `undefined` for one that appends — 39.11 through the one
209
+ * grammar (`headKeyOf`), asked with a resolved attribute.
210
+ */
211
+ function headKey(element: Element): string | undefined {
212
+ return headKeyOf(element.tagName.toLowerCase(), (name) =>
213
+ element.getAttribute(name),
214
+ )
215
+ }
@@ -0,0 +1,79 @@
1
+ import { ASCII_WHITESPACE } from '#shared/ASCII_WHITESPACE.ts'
2
+ import { textOf } from '#shared/Raw.ts'
3
+ import { WORK } from '#shared/WORK.ts'
4
+
5
+ /**
6
+ * 32.71 — what a `class={expr}` and the `class:name` directives beside it last wrote, as one
7
+ * group: the attribute's text and tokens, and the directives' conditions in the order `names`
8
+ * gives them.
9
+ *
10
+ * KEYED BY ELEMENT rather than declared by the mount, because a row's `update` writes the same
11
+ * element from a function of its own and both have to read one record.
12
+ */
13
+ const owned = new WeakMap<
14
+ Element,
15
+ {
16
+ text: string | undefined
17
+ tokens: ReadonlySet<string>
18
+ conditions: readonly unknown[] | undefined
19
+ names: string[]
20
+ }
21
+ >()
22
+
23
+ // Every record's tokens until its `class` is first written, which replaces the set.
24
+ const NO_TOKENS: ReadonlySet<string> = new Set()
25
+
26
+ /**
27
+ * Writes a `class={expr}` and the `class:name` directives beside it as one group, through
28
+ * `classList`: `plain` is the attribute's value, and `conditions` each directive's, in the order
29
+ * of `names`, its classes space-separated.
30
+ *
31
+ * 32.71 — ONLY ITS OWN CLASSES, and only what moved: the attribute's tokens are re-split only when
32
+ * its text changed, a token it dropped comes off unless a directive holds it, and a directive is
33
+ * written only when its condition flipped, kept on while the attribute holds its class. Every
34
+ * write is guarded by `contains`, so the first run over a served page writes nothing.
35
+ */
36
+ export function setClassGroup(
37
+ element: Element,
38
+ names: string,
39
+ plain: unknown,
40
+ conditions: readonly unknown[],
41
+ ): void {
42
+ WORK.bindingRuns += 1
43
+ let held = owned.get(element)
44
+ if (held === undefined) {
45
+ held = {
46
+ text: undefined,
47
+ tokens: NO_TOKENS,
48
+ conditions: undefined,
49
+ names: names.split(' '),
50
+ }
51
+ owned.set(element, held)
52
+ }
53
+ const classes = element.classList
54
+ const text = textOf(plain)
55
+ const was = held.conditions
56
+ if (text !== held.text) {
57
+ const next = new Set<string>()
58
+ for (const token of text.split(ASCII_WHITESPACE))
59
+ if (token !== '') next.add(token)
60
+ for (const token of held.tokens) {
61
+ if (next.has(token)) continue
62
+ const at = held.names.indexOf(token)
63
+ if (at !== -1 && conditions[at]) continue
64
+ if (classes.contains(token)) classes.remove(token)
65
+ }
66
+ for (const token of next)
67
+ if (!classes.contains(token)) classes.add(token)
68
+ held.text = text
69
+ held.tokens = next
70
+ }
71
+ for (let at = 0; at < held.names.length; at += 1) {
72
+ const on = Boolean(conditions[at])
73
+ if (was !== undefined && Boolean(was[at]) === on) continue
74
+ const name = held.names[at] as string
75
+ const wanted = on || held.tokens.has(name)
76
+ if (classes.contains(name) !== wanted) classes.toggle(name, wanted)
77
+ }
78
+ held.conditions = conditions
79
+ }