@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 +14 -11
- package/docs/client-scripts.md +263 -20
- package/package.json +2 -1
- package/src/astro/markup.ts +574 -0
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":
|
|
300
|
-
"./astro/images":
|
|
301
|
-
"./astro/
|
|
302
|
-
"./astro/
|
|
303
|
-
"./astro/
|
|
304
|
-
"./astro/
|
|
305
|
-
"./astro/
|
|
306
|
-
"./astro/
|
|
307
|
-
"./astro/
|
|
308
|
-
"./astro/filters-view":
|
|
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
|
|
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).
|
package/docs/client-scripts.md
CHANGED
|
@@ -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
|
|
39
|
-
| `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn
|
|
40
|
-
| `./astro/
|
|
41
|
-
| `./astro/
|
|
42
|
-
| `./astro/
|
|
43
|
-
| `./astro/
|
|
44
|
-
| `./astro/
|
|
45
|
-
| `./astro/
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
`
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
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
|
+
}
|