@escape-game-over/atlas 0.1.15 → 0.1.17

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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * A markup contract: the attributes a template writes and a script reads,
3
+ * named once, typed on both sides.
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.
8
+ *
9
+ * ```ts
10
+ * // network-markup.ts, imported by the template and by the script
11
+ * export const row = markup("network-row", {
12
+ * key: { kind: "text" },
13
+ * status: { kind: "choice", of: ["open", "soon"] },
14
+ * state: { kind: "text", optional: true },
15
+ * });
16
+ *
17
+ * // StoreRow.astro
18
+ * <a {...row.attrs({ key, status: "open", state: store.state })}>
19
+ *
20
+ * // network.ts
21
+ * for (const { element, values } of row.all(this)) {
22
+ * values.status; // "open" | "soon"
23
+ * }
24
+ * ```
25
+ *
26
+ * **Why it exists.** A client script and the template it enhances agree on
27
+ * attribute names, and nothing checks the agreement. `data-filter-regoin` in a
28
+ * template and `dataset.filterRegion` in a script compile, build and ship; the
29
+ * facet never matches, and the page reads as a filter with no results rather
30
+ * than as a typo. A shared constant pins the name of one attribute. This pins
31
+ * all of them, and their values with them:
32
+ *
33
+ * - **Names are derived, never spelled.** `data-network-row` marks the element
34
+ * and `data-network-row-status` holds a field. Neither side writes either
35
+ * string, so a rename is one edit, and a field that does not exist is a
36
+ * compile error in the template and in the script alike.
37
+ * - **Values are typed on the way in.** `attrs` takes each field's own type, so
38
+ * a missing required field, or a status outside its list, fails `astro check`.
39
+ * - **And checked on the way out.** A script cannot trust markup it did not
40
+ * write — a page cached from before a deploy, a template that spread the wrong
41
+ * role — so `read` parses every field and throws, naming the element and the
42
+ * attribute, rather than handing back an `undefined` that filters to nothing.
43
+ *
44
+ * The rule in docs/client-scripts.md still holds, and this is the reading of it
45
+ * that admits a lookup. It finds only what carries its own marker, writes only
46
+ * its own attributes, and has no opinion about classes, `aria` or `hidden`. The
47
+ * names come from the project: nothing in this package has to be matched.
48
+ */
49
+
50
+ /**
51
+ * What one field is, as data rather than a constructor — the choice `filters`
52
+ * makes, for the same reason: builder functions would put names as general as
53
+ * `text` into every module that imports this.
54
+ *
55
+ * - `text` — any string, the empty one included.
56
+ * - `number` — finite only; `NaN` and the infinities have no attribute form
57
+ * that reads back as themselves.
58
+ * - `list` — several words, space-separated, as `class` is. No item may be
59
+ * empty or contain whitespace, which is checked when it is written.
60
+ * - `choice` — one of `of`. Out-of-list values fail on both sides: at compile
61
+ * time in the template, at run time when read.
62
+ * - `flag` — present or absent. Off is the attribute left out, not `"false"`,
63
+ * so a stylesheet can select it with a bare `[data-…]`.
64
+ *
65
+ * `optional` lets a field be left off, and reads back `undefined` when it was.
66
+ * A `flag` has no `optional`: absent is already one of its two values.
67
+ */
68
+ export type MarkupField =
69
+ | { readonly kind: "text"; readonly optional?: boolean }
70
+ | { readonly kind: "number"; readonly optional?: boolean }
71
+ | { readonly kind: "list"; readonly optional?: boolean }
72
+ | {
73
+ readonly kind: "choice";
74
+ readonly of: readonly [string, ...string[]];
75
+ readonly optional?: boolean;
76
+ }
77
+ | { readonly kind: "flag" };
78
+
79
+ /** The fields of one role, named by the caller. */
80
+ export type MarkupFields = Readonly<Record<string, MarkupField>>;
81
+
82
+ /** The value a field holds when present. */
83
+ type Held<F extends MarkupField> = F extends { kind: "text" }
84
+ ? string
85
+ : F extends { kind: "number" }
86
+ ? number
87
+ : F extends { kind: "list" }
88
+ ? readonly string[]
89
+ : F extends { kind: "choice"; of: readonly (infer V)[] }
90
+ ? V
91
+ : boolean;
92
+
93
+ /** Whether a template may leave the field out. */
94
+ type Omittable<F extends MarkupField> = F extends { kind: "flag" }
95
+ ? true
96
+ : F extends { optional: true }
97
+ ? true
98
+ : false;
99
+
100
+ /** One field's value as a script reads it. */
101
+ export type MarkupValue<F extends MarkupField> = F extends { optional: true }
102
+ ? Held<F> | undefined
103
+ : Held<F>;
104
+
105
+ /** Every field's value, as a script reads it. */
106
+ export type MarkupValues<F extends MarkupFields> = {
107
+ readonly [K in keyof F]: MarkupValue<F[K]>;
108
+ };
109
+
110
+ type RequiredKeys<F extends MarkupFields> = {
111
+ [K in keyof F]-?: Omittable<F[K]> extends true ? never : K;
112
+ }[keyof F];
113
+
114
+ type Flatten<T> = { [K in keyof T]: T[K] };
115
+
116
+ /**
117
+ * What a template hands `attrs`: every required field, and any of the rest.
118
+ *
119
+ * The optional ones accept `undefined` as well as being left out, so a value
120
+ * that is itself optional — `store.state` — can be passed straight through
121
+ * without a conditional spread, whatever `exactOptionalPropertyTypes` says.
122
+ */
123
+ export type MarkupInput<F extends MarkupFields> = Flatten<
124
+ { readonly [K in RequiredKeys<F>]: Held<F[K]> } & {
125
+ readonly [K in Exclude<keyof F, RequiredKeys<F>>]?:
126
+ | Held<F[K]>
127
+ | undefined;
128
+ }
129
+ >;
130
+
131
+ /** One marked element, and what its attributes say. */
132
+ export interface Marked<T extends Element, F extends MarkupFields> {
133
+ readonly element: T;
134
+ readonly values: MarkupValues<F>;
135
+ }
136
+
137
+ 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
+ */
144
+ readonly selector: string;
145
+
146
+ /**
147
+ * The attribute one field lives in.
148
+ *
149
+ * For the rare consumer that needs the name as a string, such as a
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.
153
+ */
154
+ attribute(field: keyof F & string): string;
155
+
156
+ /**
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.
161
+ */
162
+ attrs(
163
+ ...values: [RequiredKeys<F>] extends [never]
164
+ ? [values?: MarkupInput<F>]
165
+ : [values: MarkupInput<F>]
166
+ ): Record<string, string>;
167
+
168
+ /**
169
+ * One element's values, parsed.
170
+ *
171
+ * Throws when the element does not carry this role, or when any field is
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.
175
+ */
176
+ read(element: Element): MarkupValues<F>;
177
+
178
+ /** Every element under `root` carrying this role, each with its values. */
179
+ all<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>[];
180
+
181
+ /** The first element under `root` carrying this role, or `null`. */
182
+ one<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F> | null;
183
+
184
+ /** The first, or a thrown error naming the marker nobody carried. */
185
+ require<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>;
186
+
187
+ /**
188
+ * Writes some of this role's fields back onto an element.
189
+ *
190
+ * For state a stylesheet reads — a dropdown that is blocked, a card that is
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.
194
+ */
195
+ write(element: Element, values: Partial<MarkupInput<F>>): void;
196
+ }
197
+
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
+ const hyphenate = (field: string): string =>
211
+ field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
212
+
213
+ /** A value for the error messages, quoted when it is a string. */
214
+ const show = (value: unknown): string =>
215
+ typeof value === "string" ? `"${value}"` : String(value);
216
+
217
+ /**
218
+ * One field's value as its attribute, or `undefined` to leave it off.
219
+ *
220
+ * Checked although the types already were: a template can reach here with a
221
+ * value typed as `string` that came from an env var or a cast, and this runs at
222
+ * build time, where a throw fails the build instead of shipping a page whose
223
+ * script will throw on every visit.
224
+ */
225
+ function encode(
226
+ field: MarkupField,
227
+ value: unknown,
228
+ attribute: string
229
+ ): string | undefined {
230
+ if (field.kind === "flag") return value === true ? "" : undefined;
231
+ if (value === undefined && field.optional === true) return undefined;
232
+
233
+ const refuse = (why: string): never => {
234
+ throw new Error(`${attribute} cannot hold ${show(value)}: ${why}`);
235
+ };
236
+
237
+ switch (field.kind) {
238
+ case "text":
239
+ return typeof value === "string"
240
+ ? value
241
+ : refuse("it is a text field");
242
+ case "number":
243
+ return typeof value === "number" && Number.isFinite(value)
244
+ ? String(value)
245
+ : refuse("only a finite number reads back as itself");
246
+ case "choice":
247
+ return typeof value === "string" && field.of.includes(value)
248
+ ? value
249
+ : refuse(`expected one of ${field.of.join(", ")}`);
250
+ case "list": {
251
+ if (!Array.isArray(value)) return refuse("it is a list field");
252
+ for (const item of value) {
253
+ if (
254
+ typeof item !== "string" ||
255
+ item === "" ||
256
+ /\s/.test(item)
257
+ ) {
258
+ refuse(
259
+ `the item ${show(item)} is empty or contains whitespace, which separates items`
260
+ );
261
+ }
262
+ }
263
+ return value.join(" ");
264
+ }
265
+ }
266
+ }
267
+
268
+ /** An attribute's value as its field's, or a thrown error saying why not. */
269
+ function decode(
270
+ field: MarkupField,
271
+ raw: string | null,
272
+ attribute: string
273
+ ): unknown {
274
+ if (field.kind === "flag") return raw !== null;
275
+ if (raw === null) {
276
+ if (field.optional === true) return undefined;
277
+ throw new Error(`${attribute} is missing`);
278
+ }
279
+
280
+ switch (field.kind) {
281
+ case "text":
282
+ return raw;
283
+ case "number": {
284
+ const value = Number(raw);
285
+ // `Number("")` is 0, so a blank attribute would read as a zero.
286
+ if (raw.trim() === "" || !Number.isFinite(value)) {
287
+ throw new Error(`${attribute} is ${show(raw)}, not a number`);
288
+ }
289
+ return value;
290
+ }
291
+ case "choice":
292
+ if (!field.of.includes(raw)) {
293
+ throw new Error(
294
+ `${attribute} is ${show(raw)}, expected one of ${field.of.join(", ")}`
295
+ );
296
+ }
297
+ return raw;
298
+ case "list":
299
+ return raw.split(/\s+/).filter((item) => item !== "");
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Declares one role: a marker attribute, and a typed attribute per field.
305
+ *
306
+ * `name` becomes `data-<name>`, and each field `data-<name>-<field>` with the
307
+ * field hyphenated. Both are checked here, at construction, because a name that
308
+ * is not a valid attribute would otherwise surface as a `querySelector` syntax
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.
314
+ */
315
+ export function markup<const F extends MarkupFields = Record<never, never>>(
316
+ name: string,
317
+ fields?: F
318
+ ): 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
+ const marker = `data-${name}`;
326
+ const selector = `[${marker}]`;
327
+
328
+ const entries = Object.entries(fields ?? {}).map(([key, field]) => {
329
+ if (!FIELD.test(key)) {
330
+ throw new Error(
331
+ `markup(${show(name)}): the field ${show(key)} must be camelCase — it becomes the last part of the attribute`
332
+ );
333
+ }
334
+ return { key, field, attribute: `${marker}-${hyphenate(key)}` };
335
+ });
336
+
337
+ const describe = (element: Element): string =>
338
+ `<${element.localName} ${marker}>`;
339
+
340
+ const read = (element: Element): MarkupValues<F> => {
341
+ if (!element.hasAttribute(marker)) {
342
+ throw new Error(
343
+ `<${element.localName}> does not carry ${marker}, so it has none of its fields`
344
+ );
345
+ }
346
+ const values: Record<string, unknown> = {};
347
+ for (const { key, field, attribute } of entries) {
348
+ try {
349
+ values[key] = decode(
350
+ field,
351
+ element.getAttribute(attribute),
352
+ attribute
353
+ );
354
+ } catch (error) {
355
+ const reason =
356
+ error instanceof Error ? error.message : String(error);
357
+ throw new Error(`${describe(element)}: ${reason}`, {
358
+ cause: error,
359
+ });
360
+ }
361
+ }
362
+ return values as MarkupValues<F>;
363
+ };
364
+
365
+ const mark = <T extends Element>(element: T): Marked<T, F> => ({
366
+ element,
367
+ values: read(element),
368
+ });
369
+
370
+ const one = <T extends Element = HTMLElement>(
371
+ root: ParentNode
372
+ ): Marked<T, F> | null => {
373
+ const found = root.querySelector<T>(selector);
374
+ return found === null ? null : mark(found);
375
+ };
376
+
377
+ return {
378
+ selector,
379
+
380
+ attribute(key) {
381
+ const entry = entries.find((each) => each.key === key);
382
+ if (entry === undefined) {
383
+ throw new Error(`${marker} has no field ${show(key)}`);
384
+ }
385
+ return entry.attribute;
386
+ },
387
+
388
+ attrs(...[values]) {
389
+ const given = (values ?? {}) as Record<string, unknown>;
390
+ const out: Record<string, string> = { [marker]: "" };
391
+ for (const { key, field, attribute } of entries) {
392
+ const raw = encode(field, given[key], attribute);
393
+ if (raw !== undefined) out[attribute] = raw;
394
+ }
395
+ return out;
396
+ },
397
+
398
+ read,
399
+
400
+ all: <T extends Element = HTMLElement>(root: ParentNode) =>
401
+ [...root.querySelectorAll<T>(selector)].map(mark),
402
+
403
+ one,
404
+
405
+ require<T extends Element = HTMLElement>(root: ParentNode) {
406
+ const found = one<T>(root);
407
+ if (found === null) {
408
+ throw new Error(
409
+ `nothing here carries ${marker} — is the script still a deferred module, and does the template spread its attrs?`
410
+ );
411
+ }
412
+ return found;
413
+ },
414
+
415
+ write(element, values) {
416
+ // Checked like `read` is: writing a role's fields onto an element
417
+ // that is not that role leaves it half-marked, and nothing reads it.
418
+ if (!element.hasAttribute(marker)) {
419
+ throw new Error(
420
+ `<${element.localName}> does not carry ${marker}, so its fields cannot be written to it`
421
+ );
422
+ }
423
+ const given = values as Record<string, unknown>;
424
+ for (const { key, field, attribute } of entries) {
425
+ if (!Object.hasOwn(given, key)) continue;
426
+ const raw = encode(field, given[key], attribute);
427
+ if (raw === undefined) element.removeAttribute(attribute);
428
+ else element.setAttribute(attribute, raw);
429
+ }
430
+ },
431
+ };
432
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A YouTube video that loads nothing from YouTube until a reader asks for it.
3
+ *
4
+ * In `astro/` because it listens for a click and makes an element, which the
5
+ * core is type-checked without.
6
+ *
7
+ * An embedded player is the heaviest thing most pages carry that nobody asked
8
+ * for: the iframe fetches the player, its scripts and its tracking on load, once
9
+ * per video, whether or not anyone presses play. So the page renders a poster
10
+ * and a button — the project's own markup, drawn however it likes — and this
11
+ * swaps the player in on the click, with `autoplay=1` so that the one press the
12
+ * reader already made is the one that starts it.
13
+ *
14
+ * ```ts
15
+ * const trailer = youtubeEmbed({
16
+ * id: "dQw4w9WgXcQ",
17
+ * title: "Trailer: The Blue Room",
18
+ * frame: (iframe) => {
19
+ * iframe.className = "absolute inset-0 h-full w-full";
20
+ * },
21
+ * });
22
+ *
23
+ * const detach = trailer.attach(button, poster);
24
+ * ```
25
+ *
26
+ * **It draws one element, and decides nothing about how it looks.** Unlike
27
+ * `carousel` this has to put something into the page — the player is the
28
+ * behaviour — so it is the second exception to the rule in
29
+ * `docs/client-scripts.md`, after `filters-view`, and it is drawn as narrowly.
30
+ * What it sets on the iframe is what every embed needs and has no taste in it:
31
+ * the address, the title, the permissions, full screen, the referrer policy.
32
+ * Classes, size and position are the caller's, through `frame`.
33
+ *
34
+ * Kept apart from `videoObject` in `jsonld/video.ts` although both are about a
35
+ * YouTube video: that one describes it to a crawler at build time, this plays
36
+ * it in a browser, and the only thing they share is an id the caller already
37
+ * holds. They do not even want the same host — Google reads the `youtube.com`
38
+ * player as `embedUrl`, and a reader is better served by `youtube-nocookie.com`.
39
+ */
40
+
41
+ export interface YouTubeEmbedOptions {
42
+ /**
43
+ * The video's id: what follows `v=` in a watch link, or `youtu.be/`.
44
+ *
45
+ * Checked at construction, because the tempting mistake is pasting the
46
+ * whole link, and that fails in the one place nobody looks — inside the
47
+ * player, after the click, as YouTube's own "video unavailable".
48
+ */
49
+ readonly id: string;
50
+ /**
51
+ * The iframe's title, which is its accessible name.
52
+ *
53
+ * Required and refused when blank: a frame without one is announced as
54
+ * "frame" and nothing else, and a screen-reader user tabbing into the
55
+ * player has no way to tell which video it is.
56
+ */
57
+ readonly title: string;
58
+ /**
59
+ * Anything the project wants on the iframe — classes, above all.
60
+ *
61
+ * Called once, after everything this module sets and before the iframe is
62
+ * in the page, so what it writes is the last word and nothing is drawn
63
+ * unstyled first.
64
+ */
65
+ readonly frame?: (iframe: HTMLIFrameElement) => void;
66
+ }
67
+
68
+ export interface YouTubeEmbed {
69
+ /**
70
+ * Swaps `container`'s contents for the player when `trigger` is clicked,
71
+ * and returns the undo.
72
+ *
73
+ * Two elements rather than one, because they are different things: the
74
+ * trigger is the button a reader presses, and the container is what the
75
+ * player replaces — usually the poster and that same button together. A
76
+ * click anywhere in the container would be simpler and wrong, since a
77
+ * caption link or a credit inside it would then start the video.
78
+ *
79
+ * Once only. The button is gone with the poster, and a second player in
80
+ * the same box would be two videos playing over each other.
81
+ *
82
+ * The undo removes the listener and nothing else. A player already loaded
83
+ * stays where it is — taking it out would stop a video someone is
84
+ * watching, and a page swap removes it anyway.
85
+ */
86
+ attach(trigger: HTMLElement, container: Element): () => void;
87
+ }
88
+
89
+ /**
90
+ * The shape of a YouTube video id.
91
+ *
92
+ * Eleven characters from the URL-safe base64 alphabet, which is what every id
93
+ * has been. A shape check rather than a lookup: it cannot say the video exists,
94
+ * only that what was handed over is an id and not a link.
95
+ */
96
+ const VIDEO_ID = /^[\w-]{11}$/;
97
+
98
+ /**
99
+ * The permissions YouTube's own embed code asks for.
100
+ *
101
+ * `autoplay` is the one that matters here: without it the `autoplay=1` in the
102
+ * address is refused, and the reader who pressed play gets a second play
103
+ * button to press.
104
+ */
105
+ const ALLOW =
106
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
107
+
108
+ export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
109
+ const { id, title, frame } = options;
110
+
111
+ if (!VIDEO_ID.test(id)) {
112
+ throw new Error(
113
+ `"${id}" is not a YouTube video id — expected the id alone, as it appears after "v=" or "youtu.be/", not a link.`
114
+ );
115
+ }
116
+ if (title.trim() === "") {
117
+ throw new Error(
118
+ `The YouTube embed for "${id}" has no title. It is the frame's accessible name — without one a screen reader announces "frame" and nothing else.`
119
+ );
120
+ }
121
+
122
+ // `youtube-nocookie.com` rather than `youtube.com`: the same player, which
123
+ // sets no cookie until the video is played — and here it is only ever
124
+ // loaded to be played, so nothing is lost by it.
125
+ const src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=1`;
126
+
127
+ return {
128
+ attach(trigger, container) {
129
+ const listeners = new AbortController();
130
+
131
+ trigger.addEventListener(
132
+ "click",
133
+ () => {
134
+ const iframe =
135
+ container.ownerDocument.createElement("iframe");
136
+ iframe.src = src;
137
+ iframe.title = title;
138
+ iframe.allow = ALLOW;
139
+ iframe.allowFullscreen = true;
140
+ // Stated rather than inherited. YouTube refuses to play an
141
+ // embed that arrives with no referrer at all, and a site
142
+ // sending `Referrer-Policy: no-referrer` would otherwise get
143
+ // a player that loads and shows an error in place of the
144
+ // video. This is the policy YouTube's own embed code sets.
145
+ iframe.referrerPolicy = "strict-origin-when-cross-origin";
146
+ frame?.(iframe);
147
+
148
+ container.replaceChildren(iframe);
149
+ // The button that had focus has just left the page, which
150
+ // drops a keyboard reader back at the top of the document.
151
+ // The player is where they were, and what they asked for.
152
+ iframe.focus();
153
+ },
154
+ { once: true, signal: listeners.signal }
155
+ );
156
+
157
+ return () => listeners.abort();
158
+ },
159
+ };
160
+ }