@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { oneStyleValue } from '#shared/escapeText.ts'
|
|
2
|
+
import { textOf } from '#shared/Raw.ts'
|
|
3
|
+
import { WORK } from '#shared/WORK.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 32.71 — what a `style={expr}` and the `style:prop` directives beside it last wrote, as one
|
|
7
|
+
* group: the attribute's text, its declarations once they have been needed, and the directives'
|
|
8
|
+
* values in the order `names` 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
|
+
declared: Map<string, [value: string, priority: string]> | undefined
|
|
18
|
+
values: readonly unknown[] | undefined
|
|
19
|
+
names: string[]
|
|
20
|
+
}
|
|
21
|
+
>()
|
|
22
|
+
|
|
23
|
+
// THE PARSE IS THE BROWSER'S OWN: a declaration list is not split on `;` by hand, since one can sit
|
|
24
|
+
// inside `url()` or a string. A detached element's style is parsed and never laid out.
|
|
25
|
+
let parsed: CSSStyleDeclaration | undefined
|
|
26
|
+
|
|
27
|
+
/** A declaration list's properties, values and priorities, as the browser parses it. */
|
|
28
|
+
function declarationsOf(
|
|
29
|
+
text: string,
|
|
30
|
+
): Map<string, [value: string, priority: string]> {
|
|
31
|
+
parsed ??= document.createElement('div').style
|
|
32
|
+
parsed.cssText = text
|
|
33
|
+
const declared = new Map<string, [value: string, priority: string]>()
|
|
34
|
+
for (let index = 0; index < parsed.length; index += 1) {
|
|
35
|
+
const property = parsed.item(index)
|
|
36
|
+
declared.set(property, [
|
|
37
|
+
parsed.getPropertyValue(property),
|
|
38
|
+
parsed.getPropertyPriority(property),
|
|
39
|
+
])
|
|
40
|
+
}
|
|
41
|
+
return declared
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Writes a `style={expr}` and the `style:prop` directives beside it as one group, through the
|
|
46
|
+
* CSSOM: `plain` is the attribute's value, and `values` each directive's, in the order of `names`,
|
|
47
|
+
* its properties space-separated.
|
|
48
|
+
*
|
|
49
|
+
* 32.71 — ONLY ITS OWN PROPERTIES, in the server's cascade: the attribute's declarations, then
|
|
50
|
+
* each directive's in the tag's order (`styleValue`), a later one winning and one with no value
|
|
51
|
+
* declaring nothing. Only what moved is written. The attribute is parsed only when its text
|
|
52
|
+
* changed — never on the first run, whose value the served page or built row already carries —
|
|
53
|
+
* and every write is guarded by a compare. Where the attribute changed or a directive emptied,
|
|
54
|
+
* the cascade is laid again from the attribute, which is how a shorthand on one side and a
|
|
55
|
+
* longhand on the other resolve as they did in the server's list.
|
|
56
|
+
*/
|
|
57
|
+
export function setStyleGroup(
|
|
58
|
+
element: Element,
|
|
59
|
+
names: string,
|
|
60
|
+
plain: unknown,
|
|
61
|
+
values: readonly unknown[],
|
|
62
|
+
): void {
|
|
63
|
+
WORK.bindingRuns += 1
|
|
64
|
+
let held = owned.get(element)
|
|
65
|
+
if (held === undefined) {
|
|
66
|
+
held = {
|
|
67
|
+
text: undefined,
|
|
68
|
+
declared: undefined,
|
|
69
|
+
values: undefined,
|
|
70
|
+
names: names.split(' '),
|
|
71
|
+
}
|
|
72
|
+
owned.set(element, held)
|
|
73
|
+
}
|
|
74
|
+
const style = (element as HTMLElement).style
|
|
75
|
+
const text = textOf(plain)
|
|
76
|
+
const was = held.values
|
|
77
|
+
// The first directive to write again, every one after it too: a later one wins.
|
|
78
|
+
let from = -1
|
|
79
|
+
if (held.text === undefined) held.text = text
|
|
80
|
+
else if (text !== held.text) {
|
|
81
|
+
const before = held.declared ?? declarationsOf(held.text)
|
|
82
|
+
const after = declarationsOf(text)
|
|
83
|
+
for (const property of before.keys())
|
|
84
|
+
if (!after.has(property) && style.getPropertyValue(property) !== '')
|
|
85
|
+
style.removeProperty(property)
|
|
86
|
+
lay(style, after)
|
|
87
|
+
held.text = text
|
|
88
|
+
held.declared = after
|
|
89
|
+
from = 0
|
|
90
|
+
}
|
|
91
|
+
for (let at = 0; at < held.names.length && from !== 0; at += 1) {
|
|
92
|
+
const value = directiveValue(values[at])
|
|
93
|
+
if (was !== undefined && directiveValue(was[at]) === value) continue
|
|
94
|
+
if (value !== '' || was === undefined) {
|
|
95
|
+
if (from === -1 && value !== '') from = at
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
const name = held.names[at] as string
|
|
99
|
+
if (style.getPropertyValue(name) !== '') style.removeProperty(name)
|
|
100
|
+
held.declared ??= declarationsOf(held.text)
|
|
101
|
+
lay(style, held.declared)
|
|
102
|
+
from = 0
|
|
103
|
+
}
|
|
104
|
+
if (from !== -1)
|
|
105
|
+
for (let at = from; at < held.names.length; at += 1) {
|
|
106
|
+
const value = directiveValue(values[at])
|
|
107
|
+
const name = held.names[at] as string
|
|
108
|
+
if (value !== '' && style.getPropertyValue(name) !== value)
|
|
109
|
+
style.setProperty(name, value)
|
|
110
|
+
}
|
|
111
|
+
held.values = values
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A directive's value, cut as the server cuts it; `''` declares nothing. */
|
|
115
|
+
function directiveValue(value: unknown): string {
|
|
116
|
+
return oneStyleValue(textOf(value))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Writes each declaration the element does not already carry. */
|
|
120
|
+
function lay(
|
|
121
|
+
style: CSSStyleDeclaration,
|
|
122
|
+
declared: Map<string, [value: string, priority: string]>,
|
|
123
|
+
): void {
|
|
124
|
+
for (const [property, [value, priority]] of declared)
|
|
125
|
+
if (
|
|
126
|
+
style.getPropertyValue(property) !== value ||
|
|
127
|
+
style.getPropertyPriority(property) !== priority
|
|
128
|
+
)
|
|
129
|
+
style.setProperty(property, value, priority)
|
|
130
|
+
}
|
package/src/ui/applyHead.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
// 39.6 — ON A CLIENT NAVIGATION THE INCOMING PAGE'S KEYED CONTRIBUTIONS REPLACE THE OUTGOING
|
|
2
|
-
// PAGE'S BY KEY. The outgoing page's were written by a server on the first load and by this
|
|
3
|
-
// function on every one after, so they are found the same way either time: by their key, read off
|
|
4
|
-
// the document. Nothing is added to the head to mark ownership — a marker would be a fact about
|
|
5
|
-
// the framework in a document an author writes, and the key is already what decides.
|
|
6
|
-
//
|
|
7
|
-
// 39.5 — A CONTRIBUTION WITH NO KEY APPENDS. It cannot replace anything ANOTHER contributor wrote,
|
|
8
|
-
// because there is nothing it could be said to be the same as, and dropping it instead would lose
|
|
9
|
-
// the one kind of contribution a page makes several of. What its OWN contributor wrote last is
|
|
10
|
-
// another matter: a head written again puts its keyless nodes where the last ones stood (39.15),
|
|
11
|
-
// and a head withdrawn takes them with it (39.16). Found the way a key is — off the document, by
|
|
12
|
-
// the markup the contributor last wrote — so a head the server painted is found the same as one
|
|
13
|
-
// written here.
|
|
14
|
-
|
|
15
|
-
import { headKeyOf } from '#shared/headKeyOf.ts'
|
|
16
|
-
import { markupNodes } from './parseMarkup.ts'
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Writes `markup`'s contributions into the document head, a key `below` holds excepted.
|
|
20
|
-
*
|
|
21
|
-
* 39.4 — `below` is the head of every contributor deeper than the one writing, which a head
|
|
22
|
-
* re-applied live (39.13) must not undo: the deepest contributor wins whichever of them moved last.
|
|
23
|
-
*
|
|
24
|
-
* 39.15 — `last` is what the same contributor wrote before, whose keyless nodes this replaces in
|
|
25
|
-
* place, in order, rather than adding beside them. 39.14 — a key `last` held that `markup` does not
|
|
26
|
-
* is withdrawn to `outer`'s value for it, as a leaving contributor's is; a key list that did not
|
|
27
|
-
* move withdraws nothing and never asks `outer`.
|
|
28
|
-
*/
|
|
29
|
-
export function applyHead(
|
|
30
|
-
markup: string,
|
|
31
|
-
below = '',
|
|
32
|
-
last = '',
|
|
33
|
-
outer: () => string = () => '',
|
|
34
|
-
): void {
|
|
35
|
-
const head = document.head
|
|
36
|
-
if (markup === '') return
|
|
37
|
-
const incoming = markupNodes(head, markup)
|
|
38
|
-
const standingByKey = standingKeys(head)
|
|
39
|
-
const deeper = below === '' ? undefined : keyedIn(head, below)
|
|
40
|
-
const replaced: Node[] = []
|
|
41
|
-
const withdrawn = keyedIn(head, last, replaced)
|
|
42
|
-
let next = 0
|
|
43
|
-
for (const node of incoming) {
|
|
44
|
-
const key = node.nodeType === 1 ? headKey(node as Element) : undefined
|
|
45
|
-
if (key === undefined) {
|
|
46
|
-
const standing = replaced[next]
|
|
47
|
-
next += 1
|
|
48
|
-
if (standing === undefined) head.appendChild(runnable(node))
|
|
49
|
-
// The same node left standing, as a key's is: a `<style>` put back as itself is not
|
|
50
|
-
// re-parsed into the cascade, and a `<script>` is not run twice.
|
|
51
|
-
else if (!standing.isEqualNode(node))
|
|
52
|
-
head.replaceChild(runnable(node), standing)
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
const element = node as Element
|
|
56
|
-
withdrawn.delete(key)
|
|
57
|
-
if (deeper?.has(key) === true) continue
|
|
58
|
-
const standing = standingByKey.get(key)
|
|
59
|
-
if (standing === undefined) {
|
|
60
|
-
head.appendChild(element)
|
|
61
|
-
standingByKey.set(key, element)
|
|
62
|
-
continue
|
|
63
|
-
}
|
|
64
|
-
// WHAT THE TWO PAGES SHARE IS NOT TOUCHED. `hydrate` skips `applyHead` outright because
|
|
65
|
-
// re-applying a `<link rel=stylesheet>` refetches it and flashes the document unstyled —
|
|
66
|
-
// and a navigation between two pages carrying the same stylesheet, viewport or icon walks
|
|
67
|
-
// into that same cost per contribution. An identical node is the one case where the
|
|
68
|
-
// cheaper arm is to do nothing at all.
|
|
69
|
-
if (standing.outerHTML === element.outerHTML) continue
|
|
70
|
-
head.replaceChild(element, standing)
|
|
71
|
-
standingByKey.set(key, element)
|
|
72
|
-
}
|
|
73
|
-
for (; next < replaced.length; next += 1)
|
|
74
|
-
(replaced[next] as Node).parentNode?.removeChild(replaced[next] as Node)
|
|
75
|
-
if (withdrawn.size === 0) return
|
|
76
|
-
if (deeper !== undefined)
|
|
77
|
-
for (const key of deeper.keys()) withdrawn.delete(key)
|
|
78
|
-
restoreKeys(head, withdrawn, outer())
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* `node`, or — for a `<script>` — the same script made by the document rather than the parser.
|
|
83
|
-
*
|
|
84
|
-
* 39.17 — A PARSED SCRIPT NEVER RUNS. The fragment parser marks every `<script>` it makes "already
|
|
85
|
-
* started", and `parseMarkup` is that parser, so a script it made and then moved into the head is
|
|
86
|
-
* inert where the server's, made by the document's own parser, ran. One made by `createElement`
|
|
87
|
-
* starts clear and runs on insertion. Keyless always (39.11 keys no `<script>`), so this is reached
|
|
88
|
-
* only where a keyless contribution is actually written.
|
|
89
|
-
*/
|
|
90
|
-
function runnable(node: Node): Node {
|
|
91
|
-
if (node.nodeName !== 'SCRIPT') return node
|
|
92
|
-
const parsed = node as HTMLScriptElement
|
|
93
|
-
const script = document.createElement('script')
|
|
94
|
-
const attributes = parsed.attributes
|
|
95
|
-
for (let index = 0; index < attributes.length; index += 1) {
|
|
96
|
-
const attribute = attributes[index] as Attr
|
|
97
|
-
script.setAttribute(attribute.name, attribute.value)
|
|
98
|
-
}
|
|
99
|
-
script.text = parsed.text
|
|
100
|
-
return script
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Puts back, or removes, what the leaving levels keyed and the arriving ones do not.
|
|
105
|
-
*
|
|
106
|
-
* 39.14 — THE NEXT-OUTER CONTRIBUTOR'S VALUE, OR NONE. `leaving` is the head of the levels a
|
|
107
|
-
* navigation stopped; `around` answers `outer`, every contributor standing above them, and
|
|
108
|
-
* `arriving`, the heads of the levels it mounted. `outer` runs outermost first — the shell's keyed
|
|
109
|
-
* entries, then each kept layout's — so the LAST entry for a key in it is the one the server would
|
|
110
|
-
* paint there (39.4). A component leaving its region is withdrawn the same way, its level's
|
|
111
|
-
* contributors either side of it standing in for the two (`Level.headsAround`). What the arriving levels key they have
|
|
112
|
-
* already written (39.6), so only the keys they are silent on are touched — and 39.16, the leaving
|
|
113
|
-
* levels' keyless nodes leave with them.
|
|
114
|
-
*
|
|
115
|
-
* THE COMMON NAVIGATION WITHDRAWS NOTHING BY KEY: two pages that both key a `<title>` leave
|
|
116
|
-
* `outer` built but never parsed, and a head keying nothing never asks `around`.
|
|
117
|
-
*/
|
|
118
|
-
export function withdrawHead(
|
|
119
|
-
leaving: string,
|
|
120
|
-
around: () => [outer: string, arriving: string],
|
|
121
|
-
): void {
|
|
122
|
-
if (leaving === '') return
|
|
123
|
-
const head = document.head
|
|
124
|
-
const keyless: Node[] = []
|
|
125
|
-
const withdrawn = keyedIn(head, leaving, keyless)
|
|
126
|
-
for (const node of keyless) node.parentNode?.removeChild(node)
|
|
127
|
-
if (withdrawn.size === 0) return
|
|
128
|
-
const [outer, arriving] = around()
|
|
129
|
-
for (const key of keyedIn(head, arriving).keys()) withdrawn.delete(key)
|
|
130
|
-
restoreKeys(head, withdrawn, outer)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** 39.14 — each of `withdrawn`'s keys back to `outer`'s last value for it, or removed. */
|
|
134
|
-
function restoreKeys(
|
|
135
|
-
head: HTMLHeadElement,
|
|
136
|
-
withdrawn: Map<string, Element>,
|
|
137
|
-
outer: string,
|
|
138
|
-
): void {
|
|
139
|
-
if (withdrawn.size === 0) return
|
|
140
|
-
const restored = keyedIn(head, outer)
|
|
141
|
-
const standingByKey = standingKeys(head)
|
|
142
|
-
for (const key of withdrawn.keys()) {
|
|
143
|
-
const standing = standingByKey.get(key)
|
|
144
|
-
const next = restored.get(key)
|
|
145
|
-
if (next === undefined) standing?.remove()
|
|
146
|
-
else if (standing === undefined) head.appendChild(next)
|
|
147
|
-
// The same node as `applyHead`'s: a stylesheet put back as itself is not refetched.
|
|
148
|
-
else if (standing.outerHTML !== next.outerHTML)
|
|
149
|
-
head.replaceChild(next, standing)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* The keyed elements `markup` parses to, by key, the last holding each (39.4).
|
|
155
|
-
*
|
|
156
|
-
* Where `standing` is given, each keyless node is matched to the first node standing in the head
|
|
157
|
-
* equal to it and not already matched, and the match pushed there (39.15, 39.16).
|
|
158
|
-
*/
|
|
159
|
-
function keyedIn(
|
|
160
|
-
head: HTMLHeadElement,
|
|
161
|
-
markup: string,
|
|
162
|
-
standing?: Node[],
|
|
163
|
-
): Map<string, Element> {
|
|
164
|
-
const keyed = new Map<string, Element>()
|
|
165
|
-
if (markup === '') return keyed
|
|
166
|
-
for (const node of markupNodes(head, markup)) {
|
|
167
|
-
const key = node.nodeType === 1 ? headKey(node as Element) : undefined
|
|
168
|
-
if (key !== undefined) keyed.set(key, node as Element)
|
|
169
|
-
else if (standing !== undefined)
|
|
170
|
-
for (let at = head.firstChild; at !== null; at = at.nextSibling)
|
|
171
|
-
if (at.isEqualNode(node) && !standing.includes(at)) {
|
|
172
|
-
standing.push(at)
|
|
173
|
-
break
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return keyed
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* The head's keyed elements by key, the first holding each.
|
|
181
|
-
*
|
|
182
|
-
* KEYED ONCE, NOT ONCE PER CONTRIBUTION. Walking the head per incoming node asked `headKey` — a
|
|
183
|
-
* `toLowerCase` and a fresh closure per call — for every standing element times every arriving
|
|
184
|
-
* one. One pass builds the index and each contribution is then a lookup.
|
|
185
|
-
*/
|
|
186
|
-
function standingKeys(head: HTMLHeadElement): Map<string, Element> {
|
|
187
|
-
const standingByKey = new Map<string, Element>()
|
|
188
|
-
for (
|
|
189
|
-
let node = head.firstElementChild;
|
|
190
|
-
node !== null;
|
|
191
|
-
node = node.nextElementSibling
|
|
192
|
-
) {
|
|
193
|
-
const key = headKey(node)
|
|
194
|
-
// 39.12 WITHIN THE STANDING HEAD: the FIRST element holding a key is the one a
|
|
195
|
-
// contribution replaces, which is the node the per-contribution walk returned.
|
|
196
|
-
if (key !== undefined && !standingByKey.has(key))
|
|
197
|
-
standingByKey.set(key, node)
|
|
198
|
-
}
|
|
199
|
-
return standingByKey
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* The key a head element replaces by, or `undefined` for one that appends.
|
|
204
|
-
*
|
|
205
|
-
* 39.11 THROUGH THE ONE GRAMMAR (`headKeyOf`), asked with a RESOLVED attribute. The compiler
|
|
206
|
-
* answers the same question for a STATIC one and appends where it cannot (`<meta name={x}>` has a
|
|
207
|
-
* name only the run time knows) — here every attribute is resolved, so a contribution the compiler
|
|
208
|
-
* had to append arrives with a key. That is the same rule reaching further, not a different one.
|
|
209
|
-
*/
|
|
210
|
-
function headKey(element: Element): string | undefined {
|
|
211
|
-
return headKeyOf(element.tagName.toLowerCase(), (name) =>
|
|
212
|
-
element.getAttribute(name),
|
|
213
|
-
)
|
|
214
|
-
}
|