@escape-game-over/atlas 0.1.18 → 0.1.19

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,10 +1,6 @@
1
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.
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,47 +19,116 @@
23
19
  * }
24
20
  * ```
25
21
  *
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:
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
- * - **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.
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
- * 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.
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: builder functions would put names as general as
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; `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"`,
124
+ * - `number` — finite only.
125
+ * - `list` — several words, space-separated, as `class` is.
126
+ * - `choice` — one of `of`, checked at compile time and again when read.
127
+ * - `flag` — present or absent. Off is the attribute left out, never `"false"`,
63
128
  * so a stylesheet can select it with a bare `[data-…]`.
64
129
  *
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.
130
+ * `optional` lets a field be left off, and reads back `undefined`. A `flag` has
131
+ * no `optional`: absent is already one of its two values.
67
132
  */
68
133
  export type MarkupField =
69
134
  | { readonly kind: "text"; readonly optional?: boolean }
@@ -115,10 +180,8 @@ type Flatten<T> = { [K in keyof T]: T[K] };
115
180
 
116
181
  /**
117
182
  * 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.
183
+ * The optional ones also accept `undefined`, so a value that is itself optional
184
+ * — `store.state` — passes straight through.
122
185
  */
123
186
  export type MarkupInput<F extends MarkupFields> = Flatten<
124
187
  { readonly [K in RequiredKeys<F>]: Held<F[K]> } & {
@@ -135,29 +198,19 @@ export interface Marked<T extends Element, F extends MarkupFields> {
135
198
  }
136
199
 
137
200
  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
- */
201
+ /** `[data-<name>]`: what finds this role, for the lookups below and `closest`. */
144
202
  readonly selector: string;
145
203
 
146
204
  /**
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.
205
+ * The attribute one field lives in, for a consumer that needs the name as a
206
+ * string — a `MutationObserver`'s `attributeFilter`. A stylesheet cannot use
207
+ * it: Tailwind only sees selectors written as literals.
153
208
  */
154
209
  attribute(field: keyof F & string): string;
155
210
 
156
211
  /**
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.
212
+ * The attributes to spread onto an element, in a template. Always includes
213
+ * the marker; a role with nothing required takes no argument at all.
161
214
  */
162
215
  attrs(
163
216
  ...values: [RequiredKeys<F>] extends [never]
@@ -166,12 +219,9 @@ export interface Markup<F extends MarkupFields> {
166
219
  ): Record<string, string>;
167
220
 
168
221
  /**
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.
222
+ * One element's values, parsed. Throws when the element does not carry this
223
+ * role, or when a field is missing or malformed — an `undefined` here would
224
+ * only resurface later as a list that silently matches nothing.
175
225
  */
176
226
  read(element: Element): MarkupValues<F>;
177
227
 
@@ -185,28 +235,13 @@ export interface Markup<F extends MarkupFields> {
185
235
  require<T extends Element = HTMLElement>(root: ParentNode): Marked<T, F>;
186
236
 
187
237
  /**
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.
238
+ * Writes some of this role's fields back onto an element, for state a
239
+ * stylesheet reads. A field set to `undefined`, or a flag set to `false`,
240
+ * removes the attribute; fields left out are not touched.
194
241
  */
195
242
  write(element: Element, values: Partial<MarkupInput<F>>): void;
196
243
  }
197
244
 
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
245
  const hyphenate = (field: string): string =>
211
246
  field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
212
247
 
@@ -217,10 +252,9 @@ const show = (value: unknown): string =>
217
252
  /**
218
253
  * One field's value as its attribute, or `undefined` to leave it off.
219
254
  *
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.
255
+ * Checked although the types were: a value can reach here from a cast or an env
256
+ * var, and this runs at build time, where a throw fails the build instead of
257
+ * shipping a page whose script throws on every visit.
224
258
  */
225
259
  function encode(
226
260
  field: MarkupField,
@@ -301,41 +335,24 @@ function decode(
301
335
  }
302
336
 
303
337
  /**
304
- * Declares one role: a marker attribute, and a typed attribute per field.
338
+ * One role, built from a name the caller has already vouched for.
305
339
  *
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.
340
+ * Not exported: `markup` is this with the name checked, and `component` builds
341
+ * its names from a tag it checked itself, so neither can reach here with
342
+ * something malformed.
314
343
  */
315
- export function markup<const F extends MarkupFields = Record<never, never>>(
344
+ function role<const F extends MarkupFields>(
316
345
  name: string,
317
- fields?: F
346
+ fields: F
318
347
  ): 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
348
  const marker = `data-${name}`;
326
349
  const selector = `[${marker}]`;
327
350
 
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}>`;
351
+ const entries = Object.entries(fields).map(([key, field]) => ({
352
+ key,
353
+ field,
354
+ attribute: `${marker}-${hyphenate(key)}`,
355
+ }));
339
356
 
340
357
  const read = (element: Element): MarkupValues<F> => {
341
358
  if (!element.hasAttribute(marker)) {
@@ -354,7 +371,7 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
354
371
  } catch (error) {
355
372
  const reason =
356
373
  error instanceof Error ? error.message : String(error);
357
- throw new Error(`${describe(element)}: ${reason}`, {
374
+ throw new Error(`<${element.localName} ${marker}>: ${reason}`, {
358
375
  cause: error,
359
376
  });
360
377
  }
@@ -413,8 +430,8 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
413
430
  },
414
431
 
415
432
  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.
433
+ // Checked like `read`: writing a role's fields onto an element that
434
+ // is not that role leaves it half-marked, and nothing reads it.
418
435
  if (!element.hasAttribute(marker)) {
419
436
  throw new Error(
420
437
  `<${element.localName}> does not carry ${marker}, so its fields cannot be written to it`
@@ -431,6 +448,21 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
431
448
  };
432
449
  }
433
450
 
451
+ /**
452
+ * Declares one role: `data-<name>` marks the element, `data-<name>-<field>`
453
+ * holds each field.
454
+ *
455
+ * Two roles whose names make one a prefix of the other collide: `markup("card")`
456
+ * with a field `title`, and `markup("card-title")`, both write `data-card-title`.
457
+ * `component` below refuses that; here, name them so it cannot happen.
458
+ */
459
+ export function markup<
460
+ const N extends string,
461
+ const F extends MarkupFields & CheckedKeys<F> = Record<never, never>,
462
+ >(name: N & CheckedMarkupName<N>, fields?: F): Markup<F> {
463
+ return role(name, (fields ?? {}) as F);
464
+ }
465
+
434
466
  /** A component's roles, by name: each is the fields of one `markup` role. */
435
467
  export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
436
468
 
@@ -444,9 +476,6 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
444
476
  define(element: CustomElementConstructor): void;
445
477
  } & { readonly [K in keyof R]: Markup<R[K]> };
446
478
 
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
479
  /**
451
480
  * A custom element and the roles inside it, with lookups scoped to one
452
481
  * instance.
@@ -466,8 +495,8 @@ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
466
495
  * - **Names.** Every attribute is `data-<tag>-<role>[-<field>]`. The browser
467
496
  * refuses to define one tag twice, so two components cannot share an
468
497
  * attribute, and clashes inside one component are refused here.
469
- * - **Scope.** A lookup returns only elements owned by the same instance as
470
- * its root — the owner being the nearest ancestor with this tag. A nested
498
+ * - **Scope.** A lookup returns only elements owned by the same instance as its
499
+ * root — the owner being the nearest ancestor with this tag. A nested
471
500
  * instance keeps its elements to itself, and the root may be any element
472
501
  * inside the instance, not only the instance itself.
473
502
  *
@@ -476,14 +505,9 @@ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
476
505
  */
477
506
  export function component<
478
507
  const Tag extends string,
479
- const R extends ComponentRoles & { readonly [K in Reserved]?: never },
480
- >(tag: Tag, roles: R): Component<Tag, R> {
481
- if (!TAG.test(tag)) {
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
-
508
+ const R extends ComponentRoles &
509
+ CheckedKeys<R> & { readonly [K in Reserved]?: never },
510
+ >(tag: Tag & CheckedTagName<Tag>, roles: R): Component<Tag, R> {
487
511
  // Every attribute the component writes, so two that coincide are refused
488
512
  // rather than left to overwrite each other.
489
513
  const claimed = new Map<string, string>();
@@ -504,24 +528,21 @@ export function component<
504
528
  : null;
505
529
 
506
530
  const scoped = (
507
- role: string,
531
+ name: string,
508
532
  fields: MarkupFields
509
533
  ): Markup<MarkupFields> => {
510
- if (role === "tag" || role === "define") {
511
- throw new Error(
512
- `component(${show(tag)}): ${show(role)} is taken by the component itself`
513
- );
514
- }
515
- if (!FIELD.test(role)) {
534
+ // A compile error already, and cheap to keep: these two would overwrite
535
+ // the component's own keys and leave nothing to define the element with.
536
+ if (name === "tag" || name === "define") {
516
537
  throw new Error(
517
- `component(${show(tag)}): the role ${show(role)} must be camelCase — it becomes part of the attribute`
538
+ `component(${show(tag)}): ${show(name)} is taken by the component itself`
518
539
  );
519
540
  }
520
541
 
521
- const base = markup(`${tag}-${hyphenate(role)}`, fields);
522
- claim(base.selector.slice(1, -1), `the role ${show(role)}`);
542
+ const base = role(`${tag}-${hyphenate(name)}`, fields);
543
+ claim(base.selector.slice(1, -1), `the role ${show(name)}`);
523
544
  for (const field of Object.keys(fields)) {
524
- claim(base.attribute(field), `${role}.${field}`);
545
+ claim(base.attribute(field), `${name}.${field}`);
525
546
  }
526
547
 
527
548
  // Filtered before anything is read, so a malformed element in a nested
@@ -567,8 +588,8 @@ export function component<
567
588
  define: (element: CustomElementConstructor) =>
568
589
  customElements.define(tag, element),
569
590
  };
570
- for (const [role, fields] of Object.entries(roles)) {
571
- built[role] = scoped(role, fields);
591
+ for (const [name, fields] of Object.entries(roles)) {
592
+ built[name] = scoped(name, fields);
572
593
  }
573
594
  return built as Component<Tag, R>;
574
595
  }