@escape-game-over/atlas 0.1.16 → 0.1.18

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/README.md CHANGED
@@ -296,16 +296,18 @@ const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; /
296
296
  ```jsonc
297
297
  "exports": {
298
298
  ".": "./src/index.ts",
299
- "./astro": "./src/astro/index.ts",
300
- "./astro/images": "./src/astro/images.ts",
301
- "./astro/carousel": "./src/astro/carousel.ts",
302
- "./astro/consent": "./src/astro/consent.ts",
303
- "./astro/dev-log": "./src/astro/dev-log.ts",
304
- "./astro/dom": "./src/astro/dom.ts",
305
- "./astro/element": "./src/astro/element.ts",
306
- "./astro/filters": "./src/astro/filters.ts",
307
- "./astro/meta-tags": "./src/astro/MetaTags.astro",
308
- "./astro/filters-view": "./src/astro/filters-view.ts"
299
+ "./astro": "./src/astro/index.ts",
300
+ "./astro/images": "./src/astro/images.ts",
301
+ "./astro/background-video": "./src/astro/background-video.ts",
302
+ "./astro/carousel": "./src/astro/carousel.ts",
303
+ "./astro/consent": "./src/astro/consent.ts",
304
+ "./astro/dev-log": "./src/astro/dev-log.ts",
305
+ "./astro/dom": "./src/astro/dom.ts",
306
+ "./astro/element": "./src/astro/element.ts",
307
+ "./astro/filters": "./src/astro/filters.ts",
308
+ "./astro/filters-view": "./src/astro/filters-view.ts",
309
+ "./astro/meta-tags": "./src/astro/MetaTags.astro",
310
+ "./astro/youtube": "./src/astro/youtube.ts"
309
311
  }
310
312
  ```
311
313
 
@@ -322,7 +324,8 @@ opposite: it runs inside the build, from a page, and is unusable from a config.
322
324
  Merging them breaks whichever caller loads first.
323
325
 
324
326
  The rest of `astro/` is the browser half — `carousel`, `filters`, `filters-view`,
325
- `consent`, `element`, `dom`, `dev-log` — which runs in a reader's browser rather
327
+ `youtube`, `background-video`, `consent`, `element`, `dom`, `dev-log` — which
328
+ runs in a reader's browser rather
326
329
  than in the build, and is separate again for the same reason: none of it can be
327
330
  reached from a config, and none of it draws. See
328
331
  [`docs/client-scripts.md`](docs/client-scripts.md).
@@ -32,17 +32,18 @@ the referrer policy — and everything that does, classes above all, comes from
32
32
  caller through a callback. It too is handed the button and the box rather than
33
33
  finding them.
34
34
 
35
- | Module | Owns | Leaves to the project |
36
- | -------------------------- | -------------------------------------------------------------- | ------------------------------------------------- |
37
- | `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
38
- | `./astro/filters` | which items match, and what the address bar says | every DOM read and write, and the markup contract |
39
- | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
40
- | `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
41
- | `./astro/background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
42
- | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner its wording, its buttons, its law |
43
- | `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
44
- | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
45
- | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
35
+ | Module | Owns | Leaves to the project |
36
+ | -------------------------- | -------------------------------------------------------------- | ----------------------------------------------- |
37
+ | `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
38
+ | `./astro/filters` | which items match, and what the address bar says | every DOM read and write |
39
+ | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
40
+ | `./astro/markup` | the attributes a template writes and a script reads, typed | naming the roles, and everything done with them |
41
+ | `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
42
+ | `./astro/background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
43
+ | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner its wording, its buttons, its law |
44
+ | `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
45
+ | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
46
+ | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
46
47
 
47
48
  `./astro/consent` is the only one of these with a build-time half. Its
48
49
  `consentApplies()` reports whether there is a Google tag to consent to, which a
@@ -362,17 +363,259 @@ filters({ … }) + searchBox + hideEmpty // the roll-up gone too
362
363
  A page whose markup wants a class instead of `hidden`, or removal from the DOM,
363
364
  skips the helper and writes it in `onChange`. Nothing degrades.
364
365
 
366
+ ## `markup`
367
+
368
+ A script that enhances server-rendered markup has to agree with the template on
369
+ attribute names, and nothing checks that agreement. `data-filter-regoin` in the
370
+ template and `dataset.filterRegion` in the script compile, build and ship, and
371
+ the facet simply never matches: the page reads as a filter with no results, not
372
+ as a typo. A shared constant pins one name. `markup` pins every name a script
373
+ and its template share, and the values in them.
374
+
375
+ ```ts
376
+ // network-markup.ts — imported by the template and by the script
377
+ export const row = markup("network-row", {
378
+ key: { kind: "text" },
379
+ status: { kind: "choice", of: ["open", "soon"] },
380
+ state: { kind: "text", optional: true },
381
+ });
382
+ export const search = markup("network-search");
383
+ ```
384
+
385
+ ```astro
386
+ <a {...row.attrs({ key, status: "open", state: store.state })}>
387
+ <input {...search.attrs()} type="search">
388
+ ```
389
+
390
+ ```ts
391
+ for (const { element, values } of row.all(this)) {
392
+ values.status; // "open" | "soon"
393
+ }
394
+ const input = search.require<HTMLInputElement>(this).element;
395
+ ```
396
+
397
+ - **Names are derived, never spelled.** `data-network-row` marks the element and
398
+ `data-network-row-status` holds a field. A field that does not exist is a
399
+ compile error in the template and in the script.
400
+ - **Values are typed on the way in.** `attrs` takes each field's type: a missing
401
+ required field, or a status outside its list, fails `astro check`. It also
402
+ checks at run time, because this runs at build time and a value can arrive as
403
+ a plain `string` from an env var or a cast; a throw there fails the build
404
+ rather than shipping a page whose script throws on every visit.
405
+ - **And parsed on the way out.** `read`, `all`, `one` and `require` parse every
406
+ field and throw, naming the element and the attribute, when one is missing or
407
+ malformed. A malformed match fails the whole `all` rather than being skipped:
408
+ a list that quietly loses a row is the failure this exists to prevent.
409
+
410
+ The kinds are data, not builders, for the reason `filters` gives:
411
+ `text`, `number`, `list`, `choice` and `flag`, the first four optionally
412
+ `optional`. A `flag` is off by being absent, never `"false"`, so a stylesheet can
413
+ select it with a bare attribute.
414
+
415
+ `write` sets fields back onto an element, for state a stylesheet reads — a
416
+ dropdown that is blocked — so the script uses the names the template did.
417
+
418
+ **What it still cannot reach is the stylesheet.** Tailwind generates CSS from
419
+ class names it finds as literals in the source, so a variant keyed on one of
420
+ these attributes has to spell it: `group-data-network-dropdown-blocked/dd:`.
421
+ That is the one place a rename is not a compile error. It is also the one place
422
+ a drift is visible, since the style stops applying, where a script reading the
423
+ wrong attribute fails silently. `attribute(field)` returns the name for the
424
+ consumers that can take a computed one, such as an `attributeFilter`.
425
+
426
+ It keeps the rule at the top of this page in the only reading that admits a
427
+ lookup: it finds only elements carrying its own marker, writes only its own
428
+ attributes, and sets no class, `aria` or `hidden`. The names are the project's,
429
+ so nothing in this package has to be matched.
430
+
431
+ ### `component`: roles owned by one custom element
432
+
433
+ `markup` leaves two things to the project, and both bite: a name per role, which
434
+ two components can pick alike, and scope, since `querySelectorAll` from an
435
+ element also finds everything inside a nested copy of that element. `component`
436
+ ties every role to a custom element and settles both.
437
+
438
+ ```ts
439
+ export const faq = component("go-faq", {
440
+ row: { key: { kind: "text" }, text: { kind: "text" } },
441
+ search: {},
442
+ });
443
+ ```
444
+
445
+ ```astro
446
+ <faq.tag>
447
+ <input {...faq.search.attrs()} type="search">
448
+ <details {...faq.row.attrs({ key, text })}>…</details>
449
+ </faq.tag>
450
+ ```
451
+
452
+ ```ts
453
+ class Faq extends AtlasElement {
454
+ protected connect(): void {
455
+ for (const { element, values } of faq.row.all(this)) { … }
456
+ }
457
+ }
458
+ faq.define(Faq);
459
+ ```
460
+
461
+ - **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
462
+ field, and the browser refuses to define one tag twice, so two components
463
+ cannot share an attribute. Two roles of one component that would write the
464
+ same attribute — `search` with a field `clear`, and a role `searchClear` — are
465
+ refused when the component is created.
466
+ - **Lookups stay in their instance.** `all`, `one` and `require` return only
467
+ elements whose nearest ancestor with this tag is the root's. A nested copy
468
+ keeps its elements, and a lookup from an element inside the instance, like a
469
+ dropdown's own panel, still counts as that instance. Elements are filtered
470
+ before they are read, so a broken nested copy cannot fail the outer lookup.
471
+ - **The tag is written once.** `faq.tag` is what the template renders and
472
+ `faq.define` registers the class under it.
473
+
474
+ `tag` and `define` are the component's own keys, so no role may take them. A role
475
+ that lives outside every instance, such as a footer button that reopens a
476
+ banner, is still plain `markup`.
477
+
478
+ ## `youtube`
479
+
480
+ A YouTube video that loads nothing from YouTube until a reader asks for it. The
481
+ page renders a poster and a button, drawn however the project likes; the click
482
+ swaps the player in, with `autoplay=1` so the one press already made is the one
483
+ that starts it.
484
+
485
+ ```ts
486
+ const trailer = youtubeEmbed({
487
+ id: box.dataset.trailer,
488
+ title: box.dataset.trailerTitle,
489
+ frame: (iframe) => {
490
+ iframe.className = "absolute inset-0 h-full w-full";
491
+ },
492
+ });
493
+
494
+ const detach = trailer.attach(button, box);
495
+ ```
496
+
497
+ An embedded player is the heaviest thing most pages carry that nobody asked for:
498
+ the iframe fetches the player, its scripts and its tracking on load, per video,
499
+ whether or not anyone presses play. Deferring it is the whole point, and the rest
500
+ is what every embed needs and has no taste in:
501
+
502
+ - **`youtube-nocookie.com`**, the same player without a cookie until it plays —
503
+ and here it is only ever loaded to be played.
504
+ - **A title**, which is the frame's accessible name. Refused when blank: a
505
+ frame without one is announced as "frame" and nothing else.
506
+ - **`allow` with `autoplay`**, or the `autoplay=1` is refused and the reader who
507
+ pressed play gets a second button to press. The list is YouTube's own.
508
+ - **`allowfullscreen`**, and **`referrerpolicy="strict-origin-when-cross-origin"`**
509
+ stated rather than inherited: YouTube will not play an embed that arrives with
510
+ no referrer, so a site sending `Referrer-Policy: no-referrer` would otherwise
511
+ get a player showing an error where the video should be.
512
+ - **Focus moves to the player.** The button that had it has just left the page,
513
+ which would drop a keyboard reader back at the top of the document.
514
+
515
+ `frame` is called after all of that and before the iframe is in the page, so its
516
+ word is the last one and nothing is drawn unstyled first.
517
+
518
+ **The id is checked at construction.** The tempting mistake is pasting the whole
519
+ link, and that fails in the one place nobody looks — inside the player, after the
520
+ click, as YouTube's own "video unavailable". A shape check cannot say the video
521
+ exists, only that what was handed over is an id and not a URL.
522
+
523
+ **Two elements, and a click on the first only.** The trigger is what a reader
524
+ presses; the container is what the player replaces, usually the poster and that
525
+ same button together. Listening on the whole container would be simpler and
526
+ wrong, since a caption link inside it would start the video. Once only, and the
527
+ undo removes the listener and nothing else — a player already loaded stays,
528
+ because taking it out would stop a video someone is watching.
529
+
530
+ It is kept apart from `videoObject` in `jsonld/video.ts`. That one describes the
531
+ video to a crawler at build time and this plays it in a browser; they share
532
+ only an id the caller already holds, and do not even want the same host — Google
533
+ reads the `youtube.com` player as `embedUrl`.
534
+
535
+ ## `background-video`
536
+
537
+ A muted, looping, decorative `<video>` — a hero loop. It owns whether the video
538
+ plays, whether there is anything to play, and which cut is loaded; the project
539
+ draws its own play/pause control from what `onChange` reports and points the
540
+ control at `toggle`.
541
+
542
+ ```ts
543
+ const loop = backgroundVideo({
544
+ onChange: ({ playing, playable }) => {
545
+ button.hidden = !playable;
546
+ button.setAttribute("aria-label", playing ? pauseLabel : playLabel);
547
+ playIcon.toggleAttribute("hidden", playing);
548
+ pauseIcon.toggleAttribute("hidden", !playing);
549
+ },
550
+ });
551
+
552
+ button.addEventListener("click", () => loop.toggle(), { signal });
553
+ const detach = loop.attach(video);
554
+ ```
555
+
556
+ What it owns is what every hero loop needs and most get quietly wrong:
557
+
558
+ - **Reduced motion starts it paused.** A reader who asked their system for less
559
+ motion should not get a full-width loop behind the headline.
560
+ - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for more
561
+ than five seconds, and a loop moves forever. The control, its icons and its
562
+ label are the project's; `playing` is what they show.
563
+ - **No control over nothing.** A `<source media>` that matches no viewport
564
+ leaves the element empty. `playable` says so — `currentSrc` empty, or the
565
+ element out of sources — and the caller hides the control.
566
+ - **The right cut after a rotation.** A browser chooses among `<source media>`
567
+ once, when the element loads, and never again, so a phone turned sideways
568
+ keeps the portrait cut. This listens to every source's query and calls
569
+ `load()` when the answer changes — *the answer*, not any crossing: a later
570
+ source's query flipping while an earlier one still matches chooses the same
571
+ file, and a reload would restart the loop from its first frame for nothing.
572
+ - **The reader's pause surviving that reload.** A reload resets the element to
573
+ its markup, and markup that says `autoplay` starts again the video they had
574
+ stopped. `autoplay` is kept equal to what is wanted, which is what a reload
575
+ consults.
576
+
577
+ `toggle` reads the element rather than the last press, because the control shows
578
+ what the video is doing and pressing it must do the opposite — including when
579
+ the browser paused it rather than the reader. The reader's choice outranks
580
+ reduced motion once made, and survives a detach, so an element moved in the DOM
581
+ comes back as the reader left it.
582
+
583
+ The markup wants `muted`, `loop` and `playsinline`, and — being decoration —
584
+ `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
585
+ starts the video itself, and without this script there is no control to stop it
586
+ with, while with the attribute a reduced-motion reader sees the first frames
587
+ move before this pauses them. Left in, everything still works. `muted` is also
588
+ set on attach, since no browser will start an unmuted video nobody pressed play
589
+ on.
590
+
365
591
  ## Testing
366
592
 
367
- `filters` and `filters-view` are the only modules here with tests, and both for
368
- the same reason: neither finds an element. `filters` takes item text as data and
369
- `filters-view` takes elements it was handed, so both run against stubs rather
370
- than a document. `tests/filters.test.ts` fakes `location`, `history` and one
371
- `popstate` listener, covering the matching, the four URL decisions and
372
- attach/detach; `tests/filters-view.test.ts` fakes plain objects with a `hidden`
373
- property, pins the write-back guard by counting *writes* — so deleting the guard
374
- fails rather than passing on an identical value — and pins the nesting order in
375
- both directions.
593
+ None of these tests has a document, and none needs one: every module under test
594
+ is handed what it touches rather than finding it, so a stub object with the
595
+ right properties is a faithful stand-in.
596
+
597
+ - `tests/filters.test.ts` fakes `location`, `history` and one `popstate`
598
+ listener, covering the matching, `matchedWithout`, the four URL decisions and
599
+ attach/detach.
600
+ - `tests/filters-view.test.ts` fakes plain objects with a `hidden` property,
601
+ pins the write-back guard by counting *writes* — so deleting the guard fails
602
+ rather than passing on an identical value — and pins the nesting order in
603
+ both directions.
604
+ - `tests/youtube.test.ts` fakes the trigger, and a container whose
605
+ `ownerDocument` makes the iframe — which is why the module reaches the
606
+ document through the container rather than the global.
607
+ - `tests/background-video.test.ts` stubs `matchMedia` with `vi.stubGlobal`, with
608
+ `matches` read live as a browser's is, and fakes the `<video>` down to
609
+ `paused`, `currentSrc`, `networkState`, `autoplay`, `play`, `pause` and `load`,
610
+ including what resource selection does after a reload.
611
+ - `tests/markup.test.ts` fakes elements down to four attribute methods and a
612
+ root that answers bare attribute selectors, and round-trips every field kind
613
+ through `attrs` and `read`. `type-tests/markup.ts` pins the half that is a
614
+ compile error.
615
+ - `tests/component.test.ts` builds a small element tree with parents and
616
+ `closest`, and pins the scoping: a nested instance, two side by side, a root
617
+ inside the instance, and a broken nested element that must not be read.
618
+ `type-tests/component.ts` pins the role types and the reserved keys.
376
619
 
377
620
  `carousel`, `consent`, `element` and `dom` have none. They need a real DOM and
378
621
  this package carries no environment for one; adding `happy-dom` as a dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -23,6 +23,7 @@
23
23
  "./astro/element": "./src/astro/element.ts",
24
24
  "./astro/filters": "./src/astro/filters.ts",
25
25
  "./astro/filters-view": "./src/astro/filters-view.ts",
26
+ "./astro/markup": "./src/astro/markup.ts",
26
27
  "./astro/meta-tags": "./src/astro/MetaTags.astro",
27
28
  "./astro/youtube": "./src/astro/youtube.ts"
28
29
  },
@@ -0,0 +1,574 @@
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
+ }
433
+
434
+ /** A component's roles, by name: each is the fields of one `markup` role. */
435
+ export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
436
+
437
+ /** Keys the component object uses itself, so no role may take them. */
438
+ type Reserved = "tag" | "define";
439
+
440
+ export type Component<Tag extends string, R extends ComponentRoles> = {
441
+ /** The custom element's name: what the template renders. */
442
+ readonly tag: Tag;
443
+ /** Registers the element's class under `tag`. */
444
+ define(element: CustomElementConstructor): void;
445
+ } & { readonly [K in keyof R]: Markup<R[K]> };
446
+
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
+ /**
451
+ * A custom element and the roles inside it, with lookups scoped to one
452
+ * instance.
453
+ *
454
+ * ```ts
455
+ * export const faq = component("go-faq", {
456
+ * row: { key: { kind: "text" }, text: { kind: "text" } },
457
+ * search: {},
458
+ * });
459
+ *
460
+ * // template: <faq.tag> … <details {...faq.row.attrs({ key, text })}>
461
+ * // script: faq.row.all(this); faq.define(Faq);
462
+ * ```
463
+ *
464
+ * Two things `markup` alone leaves to the project:
465
+ *
466
+ * - **Names.** Every attribute is `data-<tag>-<role>[-<field>]`. The browser
467
+ * refuses to define one tag twice, so two components cannot share an
468
+ * 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
471
+ * instance keeps its elements to itself, and the root may be any element
472
+ * inside the instance, not only the instance itself.
473
+ *
474
+ * Roles outside any instance, like a footer button that reopens a banner, are
475
+ * what plain `markup` is still for.
476
+ */
477
+ export function component<
478
+ 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
+
487
+ // Every attribute the component writes, so two that coincide are refused
488
+ // rather than left to overwrite each other.
489
+ const claimed = new Map<string, string>();
490
+ const claim = (attribute: string, by: string): void => {
491
+ const earlier = claimed.get(attribute);
492
+ if (earlier !== undefined) {
493
+ throw new Error(
494
+ `component(${show(tag)}): ${by} and ${earlier} would both write ${attribute}`
495
+ );
496
+ }
497
+ claimed.set(attribute, by);
498
+ };
499
+
500
+ /** The instance a root belongs to; `null` for the document. */
501
+ const ownerOf = (root: ParentNode): Element | null =>
502
+ "closest" in root && typeof root.closest === "function"
503
+ ? root.closest(tag)
504
+ : null;
505
+
506
+ const scoped = (
507
+ role: string,
508
+ fields: MarkupFields
509
+ ): 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)) {
516
+ throw new Error(
517
+ `component(${show(tag)}): the role ${show(role)} must be camelCase — it becomes part of the attribute`
518
+ );
519
+ }
520
+
521
+ const base = markup(`${tag}-${hyphenate(role)}`, fields);
522
+ claim(base.selector.slice(1, -1), `the role ${show(role)}`);
523
+ for (const field of Object.keys(fields)) {
524
+ claim(base.attribute(field), `${role}.${field}`);
525
+ }
526
+
527
+ // Filtered before anything is read, so a malformed element in a nested
528
+ // instance cannot fail a lookup that was never going to return it.
529
+ const mine = <T extends Element>(root: ParentNode): T[] => {
530
+ const owner = ownerOf(root);
531
+ return [...root.querySelectorAll<T>(base.selector)].filter(
532
+ (element) => element.closest(tag) === owner
533
+ );
534
+ };
535
+
536
+ const one = <T extends Element = HTMLElement>(
537
+ root: ParentNode
538
+ ): Marked<T, MarkupFields> | null => {
539
+ const [found] = mine<T>(root);
540
+ return found === undefined
541
+ ? null
542
+ : { element: found, values: base.read(found) };
543
+ };
544
+
545
+ return {
546
+ ...base,
547
+ all: <T extends Element = HTMLElement>(root: ParentNode) =>
548
+ mine<T>(root).map((element) => ({
549
+ element,
550
+ values: base.read(element),
551
+ })),
552
+ one,
553
+ require<T extends Element = HTMLElement>(root: ParentNode) {
554
+ const found = one<T>(root);
555
+ if (found === null) {
556
+ throw new Error(
557
+ `nothing in this <${tag}> carries ${base.selector.slice(1, -1)} — does the template spread its attrs inside the element?`
558
+ );
559
+ }
560
+ return found;
561
+ },
562
+ };
563
+ };
564
+
565
+ const built: Record<string, unknown> = {
566
+ tag,
567
+ define: (element: CustomElementConstructor) =>
568
+ customElements.define(tag, element),
569
+ };
570
+ for (const [role, fields] of Object.entries(roles)) {
571
+ built[role] = scoped(role, fields);
572
+ }
573
+ return built as Component<Tag, R>;
574
+ }