@escape-game-over/atlas 0.1.25 → 0.1.27
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 +84 -128
- package/docs/rich-text.md +9 -4
- package/package.json +5 -4
- package/src/astro/AtlasElement.astro +26 -0
- package/src/astro/ConsentElement.astro +20 -14
- package/src/astro/Image.astro +1 -2
- package/src/astro/MetaTags.astro +27 -14
- package/src/astro/RichText.astro +60 -43
- package/src/astro/client.ts +7 -8
- package/src/astro/consent.ts +10 -10
- package/src/astro/element.ts +59 -93
- package/src/astro/filters-view.ts +10 -20
- package/src/astro/filters.ts +108 -116
- package/src/astro/ref.ts +46 -0
- package/src/astro/markup.ts +0 -656
package/src/astro/markup.ts
DELETED
|
@@ -1,656 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A markup contract: the attributes a template writes and a script reads, named
|
|
3
|
-
* once and typed on both sides.
|
|
4
|
-
*
|
|
5
|
-
* ```ts
|
|
6
|
-
* // network-markup.ts, imported by the template and by the script
|
|
7
|
-
* export const row = markup("network-row", {
|
|
8
|
-
* key: { kind: "text" },
|
|
9
|
-
* status: { kind: "choice", of: ["open", "soon"] },
|
|
10
|
-
* state: { kind: "text", optional: true },
|
|
11
|
-
* });
|
|
12
|
-
*
|
|
13
|
-
* // StoreRow.astro
|
|
14
|
-
* <a {...row.attrs({ key, status: "open", state: store.state })}>
|
|
15
|
-
*
|
|
16
|
-
* // network.ts
|
|
17
|
-
* for (const { element, values } of row.all(this)) {
|
|
18
|
-
* values.status; // "open" | "soon"
|
|
19
|
-
* }
|
|
20
|
-
* ```
|
|
21
|
-
*
|
|
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.
|
|
28
|
-
*
|
|
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.
|
|
42
|
-
*
|
|
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.
|
|
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
|
-
export 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
|
-
export type CheckedKeys<T> = {
|
|
112
|
-
[K in keyof T]: K extends string
|
|
113
|
-
? IsCamelCase<K> extends true
|
|
114
|
-
? unknown
|
|
115
|
-
: MalformedKey<K>
|
|
116
|
-
: never;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* What one field is, as data rather than a constructor — the choice `filters`
|
|
121
|
-
* makes, and for the same reason.
|
|
122
|
-
*
|
|
123
|
-
* - `text` — any string, the empty one included.
|
|
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"`,
|
|
131
|
-
* so a stylesheet can select it with a bare `[data-…]`.
|
|
132
|
-
*
|
|
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.
|
|
135
|
-
*/
|
|
136
|
-
export type MarkupField =
|
|
137
|
-
| { readonly kind: "text"; readonly optional?: boolean }
|
|
138
|
-
| { readonly kind: "number"; readonly optional?: boolean }
|
|
139
|
-
| { readonly kind: "list"; readonly optional?: boolean }
|
|
140
|
-
| {
|
|
141
|
-
readonly kind: "template";
|
|
142
|
-
readonly params: readonly [string, ...string[]];
|
|
143
|
-
readonly optional?: boolean;
|
|
144
|
-
}
|
|
145
|
-
| {
|
|
146
|
-
readonly kind: "choice";
|
|
147
|
-
readonly of: readonly [string, ...string[]];
|
|
148
|
-
readonly optional?: boolean;
|
|
149
|
-
}
|
|
150
|
-
| { readonly kind: "flag" };
|
|
151
|
-
|
|
152
|
-
/** The fields of one role, named by the caller. */
|
|
153
|
-
export type MarkupFields = Readonly<Record<string, MarkupField>>;
|
|
154
|
-
|
|
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. */
|
|
163
|
-
type Held<F extends MarkupField> = F extends { kind: "text" }
|
|
164
|
-
? string
|
|
165
|
-
: F extends { kind: "number" }
|
|
166
|
-
? number
|
|
167
|
-
: F extends { kind: "list" }
|
|
168
|
-
? readonly string[]
|
|
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>;
|
|
185
|
-
|
|
186
|
-
/** Whether a template may leave the field out. */
|
|
187
|
-
type Omittable<F extends MarkupField> = F extends { kind: "flag" }
|
|
188
|
-
? true
|
|
189
|
-
: F extends { optional: true }
|
|
190
|
-
? true
|
|
191
|
-
: false;
|
|
192
|
-
|
|
193
|
-
/** One field's value as a script reads it. */
|
|
194
|
-
export type MarkupValue<F extends MarkupField> = F extends { optional: true }
|
|
195
|
-
? Read<F> | undefined
|
|
196
|
-
: Read<F>;
|
|
197
|
-
|
|
198
|
-
/** Every field's value, as a script reads it. */
|
|
199
|
-
export type MarkupValues<F extends MarkupFields> = {
|
|
200
|
-
readonly [K in keyof F]: MarkupValue<F[K]>;
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
type RequiredKeys<F extends MarkupFields> = {
|
|
204
|
-
[K in keyof F]-?: Omittable<F[K]> extends true ? never : K;
|
|
205
|
-
}[keyof F];
|
|
206
|
-
|
|
207
|
-
type Flatten<T> = { [K in keyof T]: T[K] };
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* What a template hands `attrs`: every required field, and any of the rest.
|
|
211
|
-
* The optional ones also accept `undefined`, so a value that is itself optional
|
|
212
|
-
* — `store.state` — passes straight through.
|
|
213
|
-
*/
|
|
214
|
-
export type MarkupInput<F extends MarkupFields> = Flatten<
|
|
215
|
-
{ readonly [K in RequiredKeys<F>]: Held<F[K]> } & {
|
|
216
|
-
readonly [K in Exclude<keyof F, RequiredKeys<F>>]?:
|
|
217
|
-
| Held<F[K]>
|
|
218
|
-
| undefined;
|
|
219
|
-
}
|
|
220
|
-
>;
|
|
221
|
-
|
|
222
|
-
/** One marked element, and what its attributes say. */
|
|
223
|
-
export interface Marked<T extends Element, F extends MarkupFields> {
|
|
224
|
-
readonly element: T;
|
|
225
|
-
readonly values: MarkupValues<F>;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export interface Markup<F extends MarkupFields> {
|
|
229
|
-
/** `[data-<name>]`: what finds this role, for the lookups below and `closest`. */
|
|
230
|
-
readonly selector: string;
|
|
231
|
-
|
|
232
|
-
/**
|
|
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.
|
|
236
|
-
*/
|
|
237
|
-
attribute(field: keyof F & string): string;
|
|
238
|
-
|
|
239
|
-
/**
|
|
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.
|
|
242
|
-
*/
|
|
243
|
-
attrs(
|
|
244
|
-
...values: [RequiredKeys<F>] extends [never]
|
|
245
|
-
? [values?: MarkupInput<F>]
|
|
246
|
-
: [values: MarkupInput<F>]
|
|
247
|
-
): Record<string, string>;
|
|
248
|
-
|
|
249
|
-
/**
|
|
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.
|
|
253
|
-
*/
|
|
254
|
-
read(element: Element): MarkupValues<F>;
|
|
255
|
-
|
|
256
|
-
/** Every element under `root` carrying this role, each with its values. */
|
|
257
|
-
all<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>[];
|
|
258
|
-
|
|
259
|
-
/** The first element under `root` carrying this role, or `null`. */
|
|
260
|
-
one<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F> | null;
|
|
261
|
-
|
|
262
|
-
/** The first, or a thrown error naming the marker nobody carried. */
|
|
263
|
-
require<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>;
|
|
264
|
-
|
|
265
|
-
/**
|
|
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.
|
|
269
|
-
*/
|
|
270
|
-
write(element: Element, values: Partial<MarkupInput<F>>): void;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const hyphenate = (field: string): string =>
|
|
274
|
-
field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
275
|
-
|
|
276
|
-
/** A value for the error messages, quoted when it is a string. */
|
|
277
|
-
const show = (value: unknown): string =>
|
|
278
|
-
typeof value === "string" ? `"${value}"` : String(value);
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* One field's value as its attribute, or `undefined` to leave it off.
|
|
282
|
-
*
|
|
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.
|
|
286
|
-
*/
|
|
287
|
-
function encode(
|
|
288
|
-
field: MarkupField,
|
|
289
|
-
value: unknown,
|
|
290
|
-
attribute: string
|
|
291
|
-
): string | undefined {
|
|
292
|
-
if (field.kind === "flag") return value === true ? "" : undefined;
|
|
293
|
-
if (value === undefined && field.optional === true) return undefined;
|
|
294
|
-
|
|
295
|
-
const refuse = (why: string): never => {
|
|
296
|
-
throw new Error(`${attribute} cannot hold ${show(value)}: ${why}`);
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
switch (field.kind) {
|
|
300
|
-
case "text":
|
|
301
|
-
return typeof value === "string"
|
|
302
|
-
? value
|
|
303
|
-
: refuse("it is a text field");
|
|
304
|
-
case "number":
|
|
305
|
-
return typeof value === "number" && Number.isFinite(value)
|
|
306
|
-
? String(value)
|
|
307
|
-
: refuse("only a finite number reads back as itself");
|
|
308
|
-
case "choice":
|
|
309
|
-
return typeof value === "string" && field.of.includes(value)
|
|
310
|
-
? value
|
|
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
|
-
}
|
|
325
|
-
case "list": {
|
|
326
|
-
if (!Array.isArray(value)) return refuse("it is a list field");
|
|
327
|
-
for (const item of value) {
|
|
328
|
-
if (
|
|
329
|
-
typeof item !== "string" ||
|
|
330
|
-
item === "" ||
|
|
331
|
-
/\s/.test(item)
|
|
332
|
-
) {
|
|
333
|
-
refuse(
|
|
334
|
-
`the item ${show(item)} is empty or contains whitespace, which separates items`
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return value.join(" ");
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** An attribute's value as its field's, or a thrown error saying why not. */
|
|
344
|
-
function decode(
|
|
345
|
-
field: MarkupField,
|
|
346
|
-
raw: string | null,
|
|
347
|
-
attribute: string
|
|
348
|
-
): unknown {
|
|
349
|
-
if (field.kind === "flag") return raw !== null;
|
|
350
|
-
if (raw === null) {
|
|
351
|
-
if (field.optional === true) return undefined;
|
|
352
|
-
throw new Error(`${attribute} is missing`);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
switch (field.kind) {
|
|
356
|
-
case "text":
|
|
357
|
-
return raw;
|
|
358
|
-
case "number": {
|
|
359
|
-
const value = Number(raw);
|
|
360
|
-
// `Number("")` is 0, so a blank attribute would read as a zero.
|
|
361
|
-
if (raw.trim() === "" || !Number.isFinite(value)) {
|
|
362
|
-
throw new Error(`${attribute} is ${show(raw)}, not a number`);
|
|
363
|
-
}
|
|
364
|
-
return value;
|
|
365
|
-
}
|
|
366
|
-
case "choice":
|
|
367
|
-
if (!field.of.includes(raw)) {
|
|
368
|
-
throw new Error(
|
|
369
|
-
`${attribute} is ${show(raw)}, expected one of ${field.of.join(", ")}`
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
return raw;
|
|
373
|
-
case "list":
|
|
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
|
-
);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* One role, built from a name the caller has already vouched for.
|
|
387
|
-
*
|
|
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.
|
|
391
|
-
*/
|
|
392
|
-
function role<const F extends MarkupFields>(
|
|
393
|
-
name: string,
|
|
394
|
-
fields: F
|
|
395
|
-
): Markup<F> {
|
|
396
|
-
const marker = `data-${name}`;
|
|
397
|
-
const selector = `[${marker}]`;
|
|
398
|
-
|
|
399
|
-
const entries = Object.entries(fields).map(([key, field]) => ({
|
|
400
|
-
key,
|
|
401
|
-
field,
|
|
402
|
-
attribute: `${marker}-${hyphenate(key)}`,
|
|
403
|
-
}));
|
|
404
|
-
|
|
405
|
-
const read = (element: Element): MarkupValues<F> => {
|
|
406
|
-
if (!element.hasAttribute(marker)) {
|
|
407
|
-
throw new Error(
|
|
408
|
-
`<${element.localName}> does not carry ${marker}, so it has none of its fields`
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
const values: Record<string, unknown> = {};
|
|
412
|
-
for (const { key, field, attribute } of entries) {
|
|
413
|
-
try {
|
|
414
|
-
values[key] = decode(
|
|
415
|
-
field,
|
|
416
|
-
element.getAttribute(attribute),
|
|
417
|
-
attribute
|
|
418
|
-
);
|
|
419
|
-
} catch (error) {
|
|
420
|
-
const reason =
|
|
421
|
-
error instanceof Error ? error.message : String(error);
|
|
422
|
-
throw new Error(`<${element.localName} ${marker}>: ${reason}`, {
|
|
423
|
-
cause: error,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
return values as MarkupValues<F>;
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
const mark = <T extends Element>(element: T): Marked<T, F> => ({
|
|
431
|
-
element,
|
|
432
|
-
values: read(element),
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
const one = <T extends Element = HTMLElement>(
|
|
436
|
-
root: ParentNode
|
|
437
|
-
): Marked<T, F> | null => {
|
|
438
|
-
const found = root.querySelector<T>(selector);
|
|
439
|
-
return found === null ? null : mark(found);
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
return {
|
|
443
|
-
selector,
|
|
444
|
-
|
|
445
|
-
attribute(key) {
|
|
446
|
-
const entry = entries.find((each) => each.key === key);
|
|
447
|
-
if (entry === undefined) {
|
|
448
|
-
throw new Error(`${marker} has no field ${show(key)}`);
|
|
449
|
-
}
|
|
450
|
-
return entry.attribute;
|
|
451
|
-
},
|
|
452
|
-
|
|
453
|
-
attrs(...[values]) {
|
|
454
|
-
const given = (values ?? {}) as Record<string, unknown>;
|
|
455
|
-
const out: Record<string, string> = { [marker]: "" };
|
|
456
|
-
for (const { key, field, attribute } of entries) {
|
|
457
|
-
const raw = encode(field, given[key], attribute);
|
|
458
|
-
if (raw !== undefined) out[attribute] = raw;
|
|
459
|
-
}
|
|
460
|
-
return out;
|
|
461
|
-
},
|
|
462
|
-
|
|
463
|
-
read,
|
|
464
|
-
|
|
465
|
-
all: <T extends Element = HTMLElement>(root: ParentNode) =>
|
|
466
|
-
[...root.querySelectorAll<T>(selector)].map(mark),
|
|
467
|
-
|
|
468
|
-
one,
|
|
469
|
-
|
|
470
|
-
require<T extends Element = HTMLElement>(root: ParentNode) {
|
|
471
|
-
const found = one<T>(root);
|
|
472
|
-
if (found === null) {
|
|
473
|
-
throw new Error(
|
|
474
|
-
`nothing here carries ${marker} — is the script still a deferred module, and does the template spread its attrs?`
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
return found;
|
|
478
|
-
},
|
|
479
|
-
|
|
480
|
-
write(element, values) {
|
|
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.
|
|
483
|
-
if (!element.hasAttribute(marker)) {
|
|
484
|
-
throw new Error(
|
|
485
|
-
`<${element.localName}> does not carry ${marker}, so its fields cannot be written to it`
|
|
486
|
-
);
|
|
487
|
-
}
|
|
488
|
-
const given = values as Record<string, unknown>;
|
|
489
|
-
for (const { key, field, attribute } of entries) {
|
|
490
|
-
if (!Object.hasOwn(given, key)) continue;
|
|
491
|
-
const raw = encode(field, given[key], attribute);
|
|
492
|
-
if (raw === undefined) element.removeAttribute(attribute);
|
|
493
|
-
else element.setAttribute(attribute, raw);
|
|
494
|
-
}
|
|
495
|
-
},
|
|
496
|
-
};
|
|
497
|
-
}
|
|
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
|
-
|
|
514
|
-
/** A component's roles, by name: each is the fields of one `markup` role. */
|
|
515
|
-
export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
|
|
516
|
-
|
|
517
|
-
/** The keys the component object uses itself, so no role may take them. */
|
|
518
|
-
export type Reserved = "tag" | "root";
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* What marks a component's root element.
|
|
522
|
-
*
|
|
523
|
-
* One name, so a project gives every root a display in a single stylesheet rule
|
|
524
|
-
* rather than a class per component, and so `element` can say when a
|
|
525
|
-
* template forgot to spread it.
|
|
526
|
-
*/
|
|
527
|
-
export const ROOT_ATTRIBUTE = "data-atlas-root";
|
|
528
|
-
|
|
529
|
-
export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
530
|
-
/** The custom element's name: what the template renders, and what
|
|
531
|
-
* `element` registers the behaviour under. */
|
|
532
|
-
readonly tag: Tag;
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* What the root element carries, spread by the template that renders it:
|
|
536
|
-
* `<faq.tag {...faq.root}>`.
|
|
537
|
-
*/
|
|
538
|
-
readonly root: Readonly<Record<string, string>>;
|
|
539
|
-
} & { readonly [K in keyof R]: Markup<R[K]> };
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* A custom element and the roles inside it, with lookups scoped to one
|
|
543
|
-
* instance.
|
|
544
|
-
*
|
|
545
|
-
* ```ts
|
|
546
|
-
* export const faq = component("go-faq", {
|
|
547
|
-
* row: { key: { kind: "text" }, text: { kind: "text" } },
|
|
548
|
-
* search: {},
|
|
549
|
-
* });
|
|
550
|
-
*
|
|
551
|
-
* // template: <faq.tag {...faq.root}> … <details {...faq.row.attrs({ key, text })}>
|
|
552
|
-
* // behaviour: see `element` in element.ts, which builds on this.
|
|
553
|
-
* ```
|
|
554
|
-
*
|
|
555
|
-
* Two things `markup` alone leaves to the project:
|
|
556
|
-
*
|
|
557
|
-
* - **Names.** Every attribute is `data-<tag>-<role>[-<field>]`. The browser
|
|
558
|
-
* refuses to define one tag twice, so two components cannot share an
|
|
559
|
-
* attribute, and clashes inside one component are refused here.
|
|
560
|
-
* - **Scope.** A lookup returns only elements owned by the same instance as its
|
|
561
|
-
* root — the owner being the nearest ancestor with this tag. A nested
|
|
562
|
-
* instance keeps its elements to itself, and the root may be any element
|
|
563
|
-
* inside the instance, not only the instance itself.
|
|
564
|
-
*
|
|
565
|
-
* Roles outside any instance, like a footer button that reopens a banner, are
|
|
566
|
-
* what plain `markup` is still for.
|
|
567
|
-
*/
|
|
568
|
-
export function component<
|
|
569
|
-
const Tag extends string,
|
|
570
|
-
const R extends ComponentRoles &
|
|
571
|
-
CheckedKeys<R> & { readonly [K in Reserved]?: never },
|
|
572
|
-
>(tag: Tag & CheckedTagName<Tag>, roles: R): Component<Tag, R> {
|
|
573
|
-
// Every attribute the component writes, so two that coincide are refused
|
|
574
|
-
// rather than left to overwrite each other.
|
|
575
|
-
const claimed = new Map<string, string>();
|
|
576
|
-
const claim = (attribute: string, by: string): void => {
|
|
577
|
-
const earlier = claimed.get(attribute);
|
|
578
|
-
if (earlier !== undefined) {
|
|
579
|
-
throw new Error(
|
|
580
|
-
`component(${show(tag)}): ${by} and ${earlier} would both write ${attribute}`
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
claimed.set(attribute, by);
|
|
584
|
-
};
|
|
585
|
-
|
|
586
|
-
/** The instance a root belongs to; `null` for the document. */
|
|
587
|
-
const ownerOf = (root: ParentNode): Element | null =>
|
|
588
|
-
"closest" in root && typeof root.closest === "function"
|
|
589
|
-
? root.closest(tag)
|
|
590
|
-
: null;
|
|
591
|
-
|
|
592
|
-
const scoped = (
|
|
593
|
-
name: string,
|
|
594
|
-
fields: MarkupFields
|
|
595
|
-
): Markup<MarkupFields> => {
|
|
596
|
-
// A compile error already, and cheap to keep: this one would overwrite
|
|
597
|
-
// the component's own key and leave nothing to render the element as.
|
|
598
|
-
if (name === "tag") {
|
|
599
|
-
throw new Error(
|
|
600
|
-
`component(${show(tag)}): ${show(name)} is taken by the component itself`
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const base = role(`${tag}-${hyphenate(name)}`, fields);
|
|
605
|
-
claim(base.selector.slice(1, -1), `the role ${show(name)}`);
|
|
606
|
-
for (const field of Object.keys(fields)) {
|
|
607
|
-
claim(base.attribute(field), `${name}.${field}`);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// Filtered before anything is read, so a malformed element in a nested
|
|
611
|
-
// instance cannot fail a lookup that was never going to return it.
|
|
612
|
-
const mine = <T extends Element>(root: ParentNode): T[] => {
|
|
613
|
-
const owner = ownerOf(root);
|
|
614
|
-
return [...root.querySelectorAll<T>(base.selector)].filter(
|
|
615
|
-
(element) => element.closest(tag) === owner
|
|
616
|
-
);
|
|
617
|
-
};
|
|
618
|
-
|
|
619
|
-
const one = <T extends Element = HTMLElement>(
|
|
620
|
-
root: ParentNode
|
|
621
|
-
): Marked<T, MarkupFields> | null => {
|
|
622
|
-
const [found] = mine<T>(root);
|
|
623
|
-
return found === undefined
|
|
624
|
-
? null
|
|
625
|
-
: { element: found, values: base.read(found) };
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
return {
|
|
629
|
-
...base,
|
|
630
|
-
all: <T extends Element = HTMLElement>(root: ParentNode) =>
|
|
631
|
-
mine<T>(root).map((element) => ({
|
|
632
|
-
element,
|
|
633
|
-
values: base.read(element),
|
|
634
|
-
})),
|
|
635
|
-
one,
|
|
636
|
-
require<T extends Element = HTMLElement>(root: ParentNode) {
|
|
637
|
-
const found = one<T>(root);
|
|
638
|
-
if (found === null) {
|
|
639
|
-
throw new Error(
|
|
640
|
-
`nothing in this <${tag}> carries ${base.selector.slice(1, -1)} — does the template spread its attrs inside the element?`
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
return found;
|
|
644
|
-
},
|
|
645
|
-
};
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
const built: Record<string, unknown> = {
|
|
649
|
-
tag,
|
|
650
|
-
root: { [ROOT_ATTRIBUTE]: "" },
|
|
651
|
-
};
|
|
652
|
-
for (const [name, fields] of Object.entries(roles)) {
|
|
653
|
-
built[name] = scoped(name, fields);
|
|
654
|
-
}
|
|
655
|
-
return built as Component<Tag, R>;
|
|
656
|
-
}
|