@escape-game-over/atlas 0.1.18 → 0.1.20
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/docs/client-scripts.md +33 -3
- package/package.json +2 -1
- package/src/astro/client.ts +23 -0
- package/src/astro/dom.ts +8 -26
- package/src/astro/element.ts +43 -147
- package/src/astro/filters-view.ts +46 -147
- package/src/astro/filters.ts +88 -248
- package/src/astro/markup.ts +217 -148
package/src/astro/markup.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A markup contract: the attributes a template writes and a script reads,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* In `astro/` because the reading half takes elements. The writing half runs in
|
|
6
|
-
* an Astro template at build time, and both halves come off the one object —
|
|
7
|
-
* which is the entire point.
|
|
2
|
+
* A markup contract: the attributes a template writes and a script reads, named
|
|
3
|
+
* once and typed on both sides.
|
|
8
4
|
*
|
|
9
5
|
* ```ts
|
|
10
6
|
* // network-markup.ts, imported by the template and by the script
|
|
@@ -23,52 +19,129 @@
|
|
|
23
19
|
* }
|
|
24
20
|
* ```
|
|
25
21
|
*
|
|
26
|
-
* **Why
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
22
|
+
* **Why.** A script and the template it enhances agree on attribute names, and
|
|
23
|
+
* nothing checks the agreement: `data-filter-regoin` in one and
|
|
24
|
+
* `dataset.filterRegion` in the other compile, build and ship, and the facet
|
|
25
|
+
* just never matches. Here names are derived rather than spelled, values are
|
|
26
|
+
* typed on the way in, and parsed on the way out — a missing or malformed
|
|
27
|
+
* attribute throws, naming the element, instead of reading as an empty string.
|
|
32
28
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
29
|
+
* `component` below binds a set of roles to one custom element, which is what
|
|
30
|
+
* most consumers want. See docs/client-scripts.md.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Digit, Letter } from "../types.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The names below become attributes, and a malformed one is not a small
|
|
37
|
+
* mistake: `data-store row` is refused by `setAttribute`, `[data-store row]`
|
|
38
|
+
* is refused by `querySelectorAll`, and a tag without a hyphen is refused by
|
|
39
|
+
* `customElements.define` — each of them in the reader's browser, long after
|
|
40
|
+
* the page was built. They are literals in a source file, so the rules are
|
|
41
|
+
* spelled as types and the mistake is an error in the editor instead.
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* names come from the project: nothing in this package has to be matched.
|
|
43
|
+
* Per character rather than by pattern, in the way `i18n/placeholders.ts`
|
|
44
|
+
* checks a placeholder's name. A widened `string` — from a variable rather than
|
|
45
|
+
* a literal — is accepted: there is nothing to check.
|
|
48
46
|
*/
|
|
47
|
+
type WordChar = Lowercase<Letter> | Digit;
|
|
48
|
+
|
|
49
|
+
/** `store`, `row2`: one lowercase word. */
|
|
50
|
+
type IsWord<S extends string> = S extends `${infer Head}${infer Tail}`
|
|
51
|
+
? Head extends WordChar
|
|
52
|
+
? Tail extends ""
|
|
53
|
+
? true
|
|
54
|
+
: IsWord<Tail>
|
|
55
|
+
: false
|
|
56
|
+
: false;
|
|
57
|
+
|
|
58
|
+
/** `store-row`, `go-faq`: lowercase words joined by single hyphens. */
|
|
59
|
+
type IsHyphenated<S extends string> = S extends `${infer Head}-${infer Rest}`
|
|
60
|
+
? IsWord<Head> extends true
|
|
61
|
+
? IsHyphenated<Rest>
|
|
62
|
+
: false
|
|
63
|
+
: IsWord<S>;
|
|
64
|
+
|
|
65
|
+
/** Letters and digits, in either case: what follows a field's first letter. */
|
|
66
|
+
type IsAlphanumeric<S extends string> = S extends `${infer Head}${infer Tail}`
|
|
67
|
+
? Head extends Letter | Lowercase<Letter> | Digit
|
|
68
|
+
? Tail extends ""
|
|
69
|
+
? true
|
|
70
|
+
: IsAlphanumeric<Tail>
|
|
71
|
+
: false
|
|
72
|
+
: false;
|
|
73
|
+
|
|
74
|
+
/** `blockedLabel`: a lowercase letter, then letters and digits. */
|
|
75
|
+
type IsCamelCase<S extends string> = S extends `${infer Head}${infer Tail}`
|
|
76
|
+
? Head extends Lowercase<Letter>
|
|
77
|
+
? Tail extends ""
|
|
78
|
+
? true
|
|
79
|
+
: IsAlphanumeric<Tail>
|
|
80
|
+
: false
|
|
81
|
+
: false;
|
|
82
|
+
|
|
83
|
+
export interface MalformedMarkupName<N extends string> {
|
|
84
|
+
readonly __MALFORMED_MARKUP_NAME__: `"${N}" cannot be a role name: it becomes an attribute, so it must be lowercase words joined by single hyphens, as in "store-row"`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MalformedTagName<N extends string> {
|
|
88
|
+
readonly __MALFORMED_TAG_NAME__: `"${N}" cannot be a custom element name: lowercase words with at least one hyphen, as in "go-faq"`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MalformedKey<N extends string> {
|
|
92
|
+
readonly __MALFORMED_KEY__: `"${N}" cannot be a field or role name: it becomes part of an attribute, so it must be camelCase, as in "blockedLabel"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Nothing extra for a good name, and an impossible shape for a bad one. */
|
|
96
|
+
type CheckedMarkupName<N extends string> = string extends N
|
|
97
|
+
? unknown
|
|
98
|
+
: IsHyphenated<N> extends true
|
|
99
|
+
? unknown
|
|
100
|
+
: MalformedMarkupName<N>;
|
|
101
|
+
|
|
102
|
+
type CheckedTagName<N extends string> = string extends N
|
|
103
|
+
? unknown
|
|
104
|
+
: IsHyphenated<N> extends true
|
|
105
|
+
? N extends `${string}-${string}`
|
|
106
|
+
? unknown
|
|
107
|
+
: MalformedTagName<N>
|
|
108
|
+
: MalformedTagName<N>;
|
|
109
|
+
|
|
110
|
+
/** The same, for every key of a fields or roles object. */
|
|
111
|
+
type CheckedKeys<T> = {
|
|
112
|
+
[K in keyof T]: K extends string
|
|
113
|
+
? IsCamelCase<K> extends true
|
|
114
|
+
? unknown
|
|
115
|
+
: MalformedKey<K>
|
|
116
|
+
: never;
|
|
117
|
+
};
|
|
49
118
|
|
|
50
119
|
/**
|
|
51
120
|
* What one field is, as data rather than a constructor — the choice `filters`
|
|
52
|
-
* makes, for the same reason
|
|
53
|
-
* `text` into every module that imports this.
|
|
121
|
+
* makes, and for the same reason.
|
|
54
122
|
*
|
|
55
123
|
* - `text` — any string, the empty one included.
|
|
56
|
-
* - `number` — finite only
|
|
57
|
-
*
|
|
58
|
-
* - `
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* - `flag` — present or absent. Off is the attribute left out,
|
|
124
|
+
* - `number` — finite only.
|
|
125
|
+
* - `list` — several words, space-separated, as `class` is.
|
|
126
|
+
* - `template` — a sentence with `{placeholders}` in it, read back as the
|
|
127
|
+
* function that fills them. A template that never says one of its own
|
|
128
|
+
* `params` fails the build, rather than shipping a brace to a reader.
|
|
129
|
+
* - `choice` — one of `of`, checked at compile time and again when read.
|
|
130
|
+
* - `flag` — present or absent. Off is the attribute left out, never `"false"`,
|
|
63
131
|
* so a stylesheet can select it with a bare `[data-…]`.
|
|
64
132
|
*
|
|
65
|
-
* `optional` lets a field be left off, and reads back `undefined
|
|
66
|
-
*
|
|
133
|
+
* `optional` lets a field be left off, and reads back `undefined`. A `flag` has
|
|
134
|
+
* no `optional`: absent is already one of its two values.
|
|
67
135
|
*/
|
|
68
136
|
export type MarkupField =
|
|
69
137
|
| { readonly kind: "text"; readonly optional?: boolean }
|
|
70
138
|
| { readonly kind: "number"; readonly optional?: boolean }
|
|
71
139
|
| { readonly kind: "list"; readonly optional?: boolean }
|
|
140
|
+
| {
|
|
141
|
+
readonly kind: "template";
|
|
142
|
+
readonly params: readonly [string, ...string[]];
|
|
143
|
+
readonly optional?: boolean;
|
|
144
|
+
}
|
|
72
145
|
| {
|
|
73
146
|
readonly kind: "choice";
|
|
74
147
|
readonly of: readonly [string, ...string[]];
|
|
@@ -79,16 +152,36 @@ export type MarkupField =
|
|
|
79
152
|
/** The fields of one role, named by the caller. */
|
|
80
153
|
export type MarkupFields = Readonly<Record<string, MarkupField>>;
|
|
81
154
|
|
|
82
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* A role that carries nothing: `search: marker`, where `search: {}` reads like
|
|
157
|
+
* options somebody forgot to fill in. Most roles are these — they name an
|
|
158
|
+
* element the script has to find, and hold no values.
|
|
159
|
+
*/
|
|
160
|
+
export const marker = {} as const;
|
|
161
|
+
|
|
162
|
+
/** What `attrs` takes for a field, and what lands in the attribute. */
|
|
83
163
|
type Held<F extends MarkupField> = F extends { kind: "text" }
|
|
84
164
|
? string
|
|
85
165
|
: F extends { kind: "number" }
|
|
86
166
|
? number
|
|
87
167
|
: F extends { kind: "list" }
|
|
88
168
|
? readonly string[]
|
|
89
|
-
: F extends { kind: "
|
|
90
|
-
?
|
|
91
|
-
:
|
|
169
|
+
: F extends { kind: "template" }
|
|
170
|
+
? string
|
|
171
|
+
: F extends { kind: "choice"; of: readonly (infer V)[] }
|
|
172
|
+
? V
|
|
173
|
+
: boolean;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What a script reads back — the same, except that a `template` arrives as the
|
|
177
|
+
* function that fills it, so no script spells a placeholder.
|
|
178
|
+
*/
|
|
179
|
+
type Read<F extends MarkupField> = F extends {
|
|
180
|
+
kind: "template";
|
|
181
|
+
params: readonly (infer P)[];
|
|
182
|
+
}
|
|
183
|
+
? (values: Record<P & string, string>) => string
|
|
184
|
+
: Held<F>;
|
|
92
185
|
|
|
93
186
|
/** Whether a template may leave the field out. */
|
|
94
187
|
type Omittable<F extends MarkupField> = F extends { kind: "flag" }
|
|
@@ -99,8 +192,8 @@ type Omittable<F extends MarkupField> = F extends { kind: "flag" }
|
|
|
99
192
|
|
|
100
193
|
/** One field's value as a script reads it. */
|
|
101
194
|
export type MarkupValue<F extends MarkupField> = F extends { optional: true }
|
|
102
|
-
?
|
|
103
|
-
:
|
|
195
|
+
? Read<F> | undefined
|
|
196
|
+
: Read<F>;
|
|
104
197
|
|
|
105
198
|
/** Every field's value, as a script reads it. */
|
|
106
199
|
export type MarkupValues<F extends MarkupFields> = {
|
|
@@ -115,10 +208,8 @@ type Flatten<T> = { [K in keyof T]: T[K] };
|
|
|
115
208
|
|
|
116
209
|
/**
|
|
117
210
|
* What a template hands `attrs`: every required field, and any of the rest.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* that is itself optional — `store.state` — can be passed straight through
|
|
121
|
-
* without a conditional spread, whatever `exactOptionalPropertyTypes` says.
|
|
211
|
+
* The optional ones also accept `undefined`, so a value that is itself optional
|
|
212
|
+
* — `store.state` — passes straight through.
|
|
122
213
|
*/
|
|
123
214
|
export type MarkupInput<F extends MarkupFields> = Flatten<
|
|
124
215
|
{ readonly [K in RequiredKeys<F>]: Held<F[K]> } & {
|
|
@@ -135,29 +226,19 @@ export interface Marked<T extends Element, F extends MarkupFields> {
|
|
|
135
226
|
}
|
|
136
227
|
|
|
137
228
|
export interface Markup<F extends MarkupFields> {
|
|
138
|
-
/**
|
|
139
|
-
* `[data-<name>]`: what finds this role.
|
|
140
|
-
*
|
|
141
|
-
* For the lookups the methods below do not cover — `closest`, a
|
|
142
|
-
* `matches` in an event handler — so those do not spell it either.
|
|
143
|
-
*/
|
|
229
|
+
/** `[data-<name>]`: what finds this role, for the lookups below and `closest`. */
|
|
144
230
|
readonly selector: string;
|
|
145
231
|
|
|
146
232
|
/**
|
|
147
|
-
* The attribute one field lives in
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* `MutationObserver`'s `attributeFilter`. A stylesheet cannot use it —
|
|
151
|
-
* Tailwind has to see its selectors as literals in the source — so a
|
|
152
|
-
* styling hook still spells the attribute once, in a class; see the docs.
|
|
233
|
+
* The attribute one field lives in, for a consumer that needs the name as a
|
|
234
|
+
* string — a `MutationObserver`'s `attributeFilter`. A stylesheet cannot use
|
|
235
|
+
* it: Tailwind only sees selectors written as literals.
|
|
153
236
|
*/
|
|
154
237
|
attribute(field: keyof F & string): string;
|
|
155
238
|
|
|
156
239
|
/**
|
|
157
|
-
* The attributes to spread onto an element, in a template.
|
|
158
|
-
*
|
|
159
|
-
* Always includes the marker. A role with nothing required may be called
|
|
160
|
-
* with no argument at all.
|
|
240
|
+
* The attributes to spread onto an element, in a template. Always includes
|
|
241
|
+
* the marker; a role with nothing required takes no argument at all.
|
|
161
242
|
*/
|
|
162
243
|
attrs(
|
|
163
244
|
...values: [RequiredKeys<F>] extends [never]
|
|
@@ -166,12 +247,9 @@ export interface Markup<F extends MarkupFields> {
|
|
|
166
247
|
): Record<string, string>;
|
|
167
248
|
|
|
168
249
|
/**
|
|
169
|
-
* One element's values, parsed.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* missing or malformed — naming the element and the attribute, because an
|
|
173
|
-
* `undefined` returned here would only resurface later as a list that
|
|
174
|
-
* silently matches nothing.
|
|
250
|
+
* One element's values, parsed. Throws when the element does not carry this
|
|
251
|
+
* role, or when a field is missing or malformed — an `undefined` here would
|
|
252
|
+
* only resurface later as a list that silently matches nothing.
|
|
175
253
|
*/
|
|
176
254
|
read(element: Element): MarkupValues<F>;
|
|
177
255
|
|
|
@@ -185,28 +263,13 @@ export interface Markup<F extends MarkupFields> {
|
|
|
185
263
|
require<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>;
|
|
186
264
|
|
|
187
265
|
/**
|
|
188
|
-
* Writes some of this role's fields back onto an element
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* selected — so the script sets it through the same names the template
|
|
192
|
-
* wrote. A field set to `undefined`, or a flag set to `false`, removes the
|
|
193
|
-
* attribute; fields left out of `values` are not touched.
|
|
266
|
+
* Writes some of this role's fields back onto an element, for state a
|
|
267
|
+
* stylesheet reads. A field set to `undefined`, or a flag set to `false`,
|
|
268
|
+
* removes the attribute; fields left out are not touched.
|
|
194
269
|
*/
|
|
195
270
|
write(element: Element, values: Partial<MarkupInput<F>>): void;
|
|
196
271
|
}
|
|
197
272
|
|
|
198
|
-
/** Lowercase words joined by single hyphens: what follows `data-`. */
|
|
199
|
-
const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* camelCase, so the hyphenated form below is unambiguous.
|
|
203
|
-
*
|
|
204
|
-
* `aB` → `a-b` is one-to-one only while a field cannot contain a hyphen of its
|
|
205
|
-
* own: allowing `a-b` too would give two fields one attribute, and each write
|
|
206
|
-
* would erase the other.
|
|
207
|
-
*/
|
|
208
|
-
const FIELD = /^[a-z][a-zA-Z0-9]*$/;
|
|
209
|
-
|
|
210
273
|
const hyphenate = (field: string): string =>
|
|
211
274
|
field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
212
275
|
|
|
@@ -217,10 +280,9 @@ const show = (value: unknown): string =>
|
|
|
217
280
|
/**
|
|
218
281
|
* One field's value as its attribute, or `undefined` to leave it off.
|
|
219
282
|
*
|
|
220
|
-
* Checked although the types
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* script will throw on every visit.
|
|
283
|
+
* Checked although the types were: a value can reach here from a cast or an env
|
|
284
|
+
* var, and this runs at build time, where a throw fails the build instead of
|
|
285
|
+
* shipping a page whose script throws on every visit.
|
|
224
286
|
*/
|
|
225
287
|
function encode(
|
|
226
288
|
field: MarkupField,
|
|
@@ -247,6 +309,19 @@ function encode(
|
|
|
247
309
|
return typeof value === "string" && field.of.includes(value)
|
|
248
310
|
? value
|
|
249
311
|
: refuse(`expected one of ${field.of.join(", ")}`);
|
|
312
|
+
case "template": {
|
|
313
|
+
if (typeof value !== "string") {
|
|
314
|
+
return refuse("it is a template field");
|
|
315
|
+
}
|
|
316
|
+
for (const param of field.params) {
|
|
317
|
+
if (!value.includes(`{${param}}`)) {
|
|
318
|
+
refuse(
|
|
319
|
+
`it never says {${param}}, so that value would have nowhere to go`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
250
325
|
case "list": {
|
|
251
326
|
if (!Array.isArray(value)) return refuse("it is a list field");
|
|
252
327
|
for (const item of value) {
|
|
@@ -297,45 +372,35 @@ function decode(
|
|
|
297
372
|
return raw;
|
|
298
373
|
case "list":
|
|
299
374
|
return raw.split(/\s+/).filter((item) => item !== "");
|
|
375
|
+
case "template":
|
|
376
|
+
return (values: Record<string, string>) =>
|
|
377
|
+
field.params.reduce(
|
|
378
|
+
(text, param) =>
|
|
379
|
+
text.replaceAll(`{${param}}`, values[param] ?? ""),
|
|
380
|
+
raw
|
|
381
|
+
);
|
|
300
382
|
}
|
|
301
383
|
}
|
|
302
384
|
|
|
303
385
|
/**
|
|
304
|
-
*
|
|
386
|
+
* One role, built from a name the caller has already vouched for.
|
|
305
387
|
*
|
|
306
|
-
* `
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* error in the browser, far from the line that caused it.
|
|
310
|
-
*
|
|
311
|
-
* Two roles whose names make one a prefix of the other can collide:
|
|
312
|
-
* `markup("card")` with a field `title` and `markup("card-title")` both write
|
|
313
|
-
* `data-card-title`. Name roles so they do not.
|
|
388
|
+
* Not exported: `markup` is this with the name checked, and `component` builds
|
|
389
|
+
* its names from a tag it checked itself, so neither can reach here with
|
|
390
|
+
* something malformed.
|
|
314
391
|
*/
|
|
315
|
-
|
|
392
|
+
function role<const F extends MarkupFields>(
|
|
316
393
|
name: string,
|
|
317
|
-
fields
|
|
394
|
+
fields: F
|
|
318
395
|
): Markup<F> {
|
|
319
|
-
if (!NAME.test(name)) {
|
|
320
|
-
throw new Error(
|
|
321
|
-
`markup(${show(name)}): a name is lowercase words joined by single hyphens, as in "store-row"`
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
396
|
const marker = `data-${name}`;
|
|
326
397
|
const selector = `[${marker}]`;
|
|
327
398
|
|
|
328
|
-
const entries = Object.entries(fields
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
return { key, field, attribute: `${marker}-${hyphenate(key)}` };
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
const describe = (element: Element): string =>
|
|
338
|
-
`<${element.localName} ${marker}>`;
|
|
399
|
+
const entries = Object.entries(fields).map(([key, field]) => ({
|
|
400
|
+
key,
|
|
401
|
+
field,
|
|
402
|
+
attribute: `${marker}-${hyphenate(key)}`,
|
|
403
|
+
}));
|
|
339
404
|
|
|
340
405
|
const read = (element: Element): MarkupValues<F> => {
|
|
341
406
|
if (!element.hasAttribute(marker)) {
|
|
@@ -354,7 +419,7 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
|
|
|
354
419
|
} catch (error) {
|
|
355
420
|
const reason =
|
|
356
421
|
error instanceof Error ? error.message : String(error);
|
|
357
|
-
throw new Error(
|
|
422
|
+
throw new Error(`<${element.localName} ${marker}>: ${reason}`, {
|
|
358
423
|
cause: error,
|
|
359
424
|
});
|
|
360
425
|
}
|
|
@@ -413,8 +478,8 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
|
|
|
413
478
|
},
|
|
414
479
|
|
|
415
480
|
write(element, values) {
|
|
416
|
-
// Checked like `read
|
|
417
|
-
//
|
|
481
|
+
// Checked like `read`: writing a role's fields onto an element that
|
|
482
|
+
// is not that role leaves it half-marked, and nothing reads it.
|
|
418
483
|
if (!element.hasAttribute(marker)) {
|
|
419
484
|
throw new Error(
|
|
420
485
|
`<${element.localName}> does not carry ${marker}, so its fields cannot be written to it`
|
|
@@ -431,6 +496,21 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
|
|
|
431
496
|
};
|
|
432
497
|
}
|
|
433
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Declares one role: `data-<name>` marks the element, `data-<name>-<field>`
|
|
501
|
+
* holds each field.
|
|
502
|
+
*
|
|
503
|
+
* Two roles whose names make one a prefix of the other collide: `markup("card")`
|
|
504
|
+
* with a field `title`, and `markup("card-title")`, both write `data-card-title`.
|
|
505
|
+
* `component` below refuses that; here, name them so it cannot happen.
|
|
506
|
+
*/
|
|
507
|
+
export function markup<
|
|
508
|
+
const N extends string,
|
|
509
|
+
const F extends MarkupFields & CheckedKeys<F> = Record<never, never>,
|
|
510
|
+
>(name: N & CheckedMarkupName<N>, fields?: F): Markup<F> {
|
|
511
|
+
return role(name, (fields ?? {}) as F);
|
|
512
|
+
}
|
|
513
|
+
|
|
434
514
|
/** A component's roles, by name: each is the fields of one `markup` role. */
|
|
435
515
|
export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
|
|
436
516
|
|
|
@@ -444,9 +524,6 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
|
444
524
|
define(element: CustomElementConstructor): void;
|
|
445
525
|
} & { readonly [K in keyof R]: Markup<R[K]> };
|
|
446
526
|
|
|
447
|
-
/** A custom element name, as far as a pattern can check it: needs a hyphen. */
|
|
448
|
-
const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
449
|
-
|
|
450
527
|
/**
|
|
451
528
|
* A custom element and the roles inside it, with lookups scoped to one
|
|
452
529
|
* instance.
|
|
@@ -466,8 +543,8 @@ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
|
466
543
|
* - **Names.** Every attribute is `data-<tag>-<role>[-<field>]`. The browser
|
|
467
544
|
* refuses to define one tag twice, so two components cannot share an
|
|
468
545
|
* attribute, and clashes inside one component are refused here.
|
|
469
|
-
* - **Scope.** A lookup returns only elements owned by the same instance as
|
|
470
|
-
*
|
|
546
|
+
* - **Scope.** A lookup returns only elements owned by the same instance as its
|
|
547
|
+
* root — the owner being the nearest ancestor with this tag. A nested
|
|
471
548
|
* instance keeps its elements to itself, and the root may be any element
|
|
472
549
|
* inside the instance, not only the instance itself.
|
|
473
550
|
*
|
|
@@ -476,14 +553,9 @@ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
|
476
553
|
*/
|
|
477
554
|
export function component<
|
|
478
555
|
const Tag extends string,
|
|
479
|
-
const R extends ComponentRoles &
|
|
480
|
-
>
|
|
481
|
-
|
|
482
|
-
throw new Error(
|
|
483
|
-
`component(${show(tag)}): a custom element name is lowercase words with at least one hyphen, as in "go-faq"`
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
556
|
+
const R extends ComponentRoles &
|
|
557
|
+
CheckedKeys<R> & { readonly [K in Reserved]?: never },
|
|
558
|
+
>(tag: Tag & CheckedTagName<Tag>, roles: R): Component<Tag, R> {
|
|
487
559
|
// Every attribute the component writes, so two that coincide are refused
|
|
488
560
|
// rather than left to overwrite each other.
|
|
489
561
|
const claimed = new Map<string, string>();
|
|
@@ -504,24 +576,21 @@ export function component<
|
|
|
504
576
|
: null;
|
|
505
577
|
|
|
506
578
|
const scoped = (
|
|
507
|
-
|
|
579
|
+
name: string,
|
|
508
580
|
fields: MarkupFields
|
|
509
581
|
): Markup<MarkupFields> => {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
if (!FIELD.test(role)) {
|
|
582
|
+
// A compile error already, and cheap to keep: these two would overwrite
|
|
583
|
+
// the component's own keys and leave nothing to define the element with.
|
|
584
|
+
if (name === "tag" || name === "define") {
|
|
516
585
|
throw new Error(
|
|
517
|
-
`component(${show(tag)}):
|
|
586
|
+
`component(${show(tag)}): ${show(name)} is taken by the component itself`
|
|
518
587
|
);
|
|
519
588
|
}
|
|
520
589
|
|
|
521
|
-
const base =
|
|
522
|
-
claim(base.selector.slice(1, -1), `the role ${show(
|
|
590
|
+
const base = role(`${tag}-${hyphenate(name)}`, fields);
|
|
591
|
+
claim(base.selector.slice(1, -1), `the role ${show(name)}`);
|
|
523
592
|
for (const field of Object.keys(fields)) {
|
|
524
|
-
claim(base.attribute(field), `${
|
|
593
|
+
claim(base.attribute(field), `${name}.${field}`);
|
|
525
594
|
}
|
|
526
595
|
|
|
527
596
|
// Filtered before anything is read, so a malformed element in a nested
|
|
@@ -567,8 +636,8 @@ export function component<
|
|
|
567
636
|
define: (element: CustomElementConstructor) =>
|
|
568
637
|
customElements.define(tag, element),
|
|
569
638
|
};
|
|
570
|
-
for (const [
|
|
571
|
-
built[
|
|
639
|
+
for (const [name, fields] of Object.entries(roles)) {
|
|
640
|
+
built[name] = scoped(name, fields);
|
|
572
641
|
}
|
|
573
642
|
return built as Component<Tag, R>;
|
|
574
643
|
}
|