@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.
@@ -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
- }