@escape-game-over/atlas 0.1.19 → 0.1.21
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/docs/client-scripts.md +50 -20
- package/package.json +2 -1
- package/src/astro/client.ts +23 -0
- package/src/astro/element.ts +92 -161
- package/src/astro/markup.ts +63 -20
package/docs/client-scripts.md
CHANGED
|
@@ -41,7 +41,7 @@ finding them.
|
|
|
41
41
|
| `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
|
|
42
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
43
|
| `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
|
|
44
|
-
| `./astro/element` |
|
|
44
|
+
| `./astro/element` | registering a custom element, and ending what it started | what the element does while it is on the page |
|
|
45
45
|
| `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
|
|
46
46
|
| `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
|
|
47
47
|
|
|
@@ -54,6 +54,12 @@ from the package root is the same question at build time: false, and the banner
|
|
|
54
54
|
never reaches the HTML. Gate the render on that and keep the runtime check for
|
|
55
55
|
the banner that is rendered. See NOT-BUILT.md on where the halves divide.
|
|
56
56
|
|
|
57
|
+
**A script imports one path: `@escape-game-over/atlas/client`**, which re-exports
|
|
58
|
+
every module above. All of it exists to touch a live document, so it belongs in
|
|
59
|
+
a `<script src>` and not in frontmatter or `astro.config.ts`. A component's
|
|
60
|
+
contract is the one thing both halves need, and it stays on `./astro/markup`,
|
|
61
|
+
which touches nothing.
|
|
62
|
+
|
|
57
63
|
## Lifetimes are the recurring bug
|
|
58
64
|
|
|
59
65
|
Every module here that binds a listener hands back the undo, and the undo is
|
|
@@ -69,15 +75,23 @@ const detach = loop.attach(video); // background-video: the <video>
|
|
|
69
75
|
const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
the
|
|
78
|
+
`defineElement` says the same thing in the shape a custom element needs. One
|
|
79
|
+
function per element: it runs when the element enters the page, `signal` ends
|
|
80
|
+
what it registered when the element leaves, and what it returns is the undo for
|
|
81
|
+
everything else.
|
|
75
82
|
|
|
76
83
|
```ts
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
defineElement("atlas-thing", (host, signal) => {
|
|
85
|
+
host.addEventListener("click", open, { signal }); // ends with the visit
|
|
86
|
+
return loop.attach(host); // and so does this
|
|
87
|
+
});
|
|
79
88
|
```
|
|
80
89
|
|
|
90
|
+
An element can enter the page more than once — moving it in the DOM runs the
|
|
91
|
+
undo and then the function again — so it has to be able to run twice. State that
|
|
92
|
+
must survive a move goes in a `WeakMap` keyed by `host`, which is what the
|
|
93
|
+
carousel example does with its index.
|
|
94
|
+
|
|
81
95
|
**The trap this exists for is view transitions.** A bundled `<script src>` is an
|
|
82
96
|
ES module, cached by URL, so it executes once per session — not once per
|
|
83
97
|
navigation. Bind at module scope with `ClientRouter` on and the incoming page
|
|
@@ -408,9 +422,24 @@ const input = search.require<HTMLInputElement>(this).element;
|
|
|
408
422
|
a list that quietly loses a row is the failure this exists to prevent.
|
|
409
423
|
|
|
410
424
|
The kinds are data, not builders, for the reason `filters` gives:
|
|
411
|
-
`text`, `number`, `list`, `choice` and `flag`,
|
|
412
|
-
`optional`. A `flag` is off by being absent, never `"false"`, so a
|
|
413
|
-
select it with a bare attribute.
|
|
425
|
+
`text`, `number`, `list`, `template`, `choice` and `flag`, all but `flag`
|
|
426
|
+
optionally `optional`. A `flag` is off by being absent, never `"false"`, so a
|
|
427
|
+
stylesheet can select it with a bare attribute.
|
|
428
|
+
|
|
429
|
+
A `template` is the one that keeps a sentence out of the script. It is written
|
|
430
|
+
as the translated string with its placeholders left in, and read back as the
|
|
431
|
+
function that fills them:
|
|
432
|
+
|
|
433
|
+
```astro
|
|
434
|
+
<p {...faq.line.attrs({ sentence: t("faq.matched", { count: "{count}" }) })}></p>
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
line.element.textContent = line.values.sentence({ count: String(hits.size) });
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
So no script spells `"{count}"`, and a sentence that never says one of its own
|
|
442
|
+
placeholders fails the build rather than shipping a brace to a reader.
|
|
414
443
|
|
|
415
444
|
`write` sets fields back onto an element, for state a stylesheet reads — a
|
|
416
445
|
dropdown that is blocked — so the script uses the names the template did.
|
|
@@ -450,12 +479,9 @@ export const faq = component("go-faq", {
|
|
|
450
479
|
```
|
|
451
480
|
|
|
452
481
|
```ts
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
faq.define(Faq);
|
|
482
|
+
defineElement(faq.tag, (host, signal) => {
|
|
483
|
+
for (const { element, values } of faq.row.all(host)) { … }
|
|
484
|
+
});
|
|
459
485
|
```
|
|
460
486
|
|
|
461
487
|
- **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
|
|
@@ -468,18 +494,22 @@ faq.define(Faq);
|
|
|
468
494
|
keeps its elements, and a lookup from an element inside the instance, like a
|
|
469
495
|
dropdown's own panel, still counts as that instance. Elements are filtered
|
|
470
496
|
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
|
-
`
|
|
497
|
+
- **The tag is written once.** `faq.tag` is what the template renders and what
|
|
498
|
+
`defineElement` registers the behaviour under. A contract stays inert either
|
|
499
|
+
way: it names things, and nothing in it touches a document, which is why
|
|
500
|
+
frontmatter can import it.
|
|
473
501
|
- **The names are checked in the editor.** A tag without a hyphen, or a role
|
|
474
502
|
that is not camelCase, is a compile error where it is written — the rules are
|
|
475
503
|
types, character by character, as in `i18n/placeholders.ts`. Nothing is
|
|
476
504
|
validated at run time, because a name that reached the browser malformed would
|
|
477
505
|
already have been refused there: by `setAttribute`, by `querySelectorAll`, or
|
|
478
506
|
by `customElements.define`.
|
|
507
|
+
- **Most roles carry nothing**, and say so: `search: marker` rather than
|
|
508
|
+
`search: {}`, which reads like options somebody forgot to fill in.
|
|
479
509
|
|
|
480
|
-
`tag`
|
|
481
|
-
|
|
482
|
-
|
|
510
|
+
`tag` is the component's own key, so no role may take it. A role that lives
|
|
511
|
+
outside every instance, such as a footer button that reopens a banner, is still
|
|
512
|
+
plain `markup`.
|
|
483
513
|
|
|
484
514
|
## `youtube`
|
|
485
515
|
|
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.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./astro/images": "./src/astro/images.ts",
|
|
18
18
|
"./astro/background-video": "./src/astro/background-video.ts",
|
|
19
19
|
"./astro/carousel": "./src/astro/carousel.ts",
|
|
20
|
+
"./client": "./src/astro/client.ts",
|
|
20
21
|
"./astro/consent": "./src/astro/consent.ts",
|
|
21
22
|
"./astro/dev-log": "./src/astro/dev-log.ts",
|
|
22
23
|
"./astro/dom": "./src/astro/dom.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a client script needs, from one import.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { filters, searchBox } from "@escape-game-over/atlas/client";
|
|
6
|
+
* ```
|
|
7
|
+
*
|
|
8
|
+
* **For the browser.** Everything here exists to touch a live document, and is
|
|
9
|
+
* meant for a `<script src>`, not for frontmatter or `astro.config.ts`. A
|
|
10
|
+
* component's contract is the one thing both halves need, and it stays on
|
|
11
|
+
* `astro/markup`, which touches nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from "./background-video.ts";
|
|
15
|
+
export * from "./carousel.ts";
|
|
16
|
+
export * from "./consent.ts";
|
|
17
|
+
export * from "./dev-log.ts";
|
|
18
|
+
export * from "./dom.ts";
|
|
19
|
+
export * from "./element.ts";
|
|
20
|
+
export * from "./filters.ts";
|
|
21
|
+
export * from "./filters-view.ts";
|
|
22
|
+
export * from "./markup.ts";
|
|
23
|
+
export * from "./youtube.ts";
|
package/src/astro/element.ts
CHANGED
|
@@ -1,181 +1,112 @@
|
|
|
1
1
|
import { reportDevError } from "./dev-log.ts";
|
|
2
|
-
import { within } from "./dom.ts";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
4
|
+
* What is left to undo when the element leaves, or nothing.
|
|
5
|
+
*
|
|
6
|
+
* Nothing is the common case — a connect that registered everything with
|
|
7
|
+
* `signal` has already said how it comes down — so `void` rather than
|
|
8
|
+
* `undefined`, which would make every connect end in a `return`.
|
|
9
|
+
*/
|
|
10
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
|
|
11
|
+
type Undo = void | (() => void);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What an element does while it is on the page.
|
|
15
|
+
*
|
|
16
|
+
* `host` is the element itself and `signal` is aborted when it leaves, so
|
|
17
|
+
* anything registered with `signal` comes down on its own. Whatever else has to
|
|
18
|
+
* be undone is the returned function, which runs at the same moment.
|
|
19
|
+
*/
|
|
20
|
+
export type Connect = (host: HTMLElement, signal: AbortSignal) => Undo;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registers a custom element whose behaviour is one function.
|
|
6
24
|
*
|
|
7
25
|
* ```ts
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* signal: this.signal,
|
|
13
|
-
* });
|
|
14
|
-
* }
|
|
15
|
-
* }
|
|
16
|
-
* customElements.define("atlas-thing", Thing);
|
|
26
|
+
* defineElement("atlas-thing", (host, signal) => {
|
|
27
|
+
* host.querySelector("form")?.addEventListener("submit", send, { signal });
|
|
28
|
+
* return loop.attach(host); // runs when the element leaves the page
|
|
29
|
+
* });
|
|
17
30
|
* ```
|
|
18
31
|
*
|
|
19
|
-
* - **An element
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - **Override `setup`, `connect` and `disconnect`, never the callbacks.**
|
|
24
|
-
* Overriding `connectedCallback` skips the controller and leaks every
|
|
25
|
-
* listener; that mistake throws in development, below.
|
|
32
|
+
* - **An element can enter the page more than once.** Moving it runs the undo
|
|
33
|
+
* and then `connect` again, so `connect` has to be able to run twice. State
|
|
34
|
+
* that must survive a move belongs in a `WeakMap` keyed by `host`, as the
|
|
35
|
+
* carousel example keeps its index.
|
|
26
36
|
* - **The defining script must stay a deferred module.** Astro emits
|
|
27
|
-
* `<script src>` as `type="module"`, so the element has its children
|
|
28
|
-
* is upgraded. `is:inline` runs it too early and every lookup
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* moment later, so it is connection teardown and nothing else.
|
|
37
|
+
* `<script src>` as `type="module"`, so the element has its children by the
|
|
38
|
+
* time it is upgraded. `is:inline` runs it too early and every lookup inside
|
|
39
|
+
* the element finds nothing.
|
|
40
|
+
* - **A `connect` that throws leaves that one element inert** and reports it,
|
|
41
|
+
* rather than taking its siblings down with it — which is what lets a lookup
|
|
42
|
+
* like `require` throw and say what is missing.
|
|
34
43
|
*
|
|
35
44
|
* See docs/client-scripts.md.
|
|
36
45
|
*/
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
] as const;
|
|
66
|
-
|
|
67
|
-
for (
|
|
68
|
-
let proto = Object.getPrototypeOf(this);
|
|
69
|
-
proto && proto !== AtlasElement.prototype;
|
|
70
|
-
proto = Object.getPrototypeOf(proto)
|
|
71
|
-
) {
|
|
72
|
-
for (const [callback, hook] of wrong) {
|
|
73
|
-
if (Object.hasOwn(proto, callback)) {
|
|
74
|
-
const error = new Error(
|
|
75
|
-
`override "${hook}", not "${callback}" — see AtlasElement`
|
|
76
|
-
);
|
|
77
|
-
// Reported before throwing: this runs during upgrade, where
|
|
78
|
-
// a throw would only surface as an uncaught console error.
|
|
79
|
-
reportDevError(this.constructor.name, error);
|
|
80
|
-
throw error;
|
|
46
|
+
export function defineElement(tag: string, connect: Connect): void {
|
|
47
|
+
// The class is built in here rather than at module scope so `HTMLElement`
|
|
48
|
+
// is only read in a browser: `astro/markup` reaches this file, templates
|
|
49
|
+
// import it, and the build runs in Node.
|
|
50
|
+
customElements.define(
|
|
51
|
+
tag,
|
|
52
|
+
class extends HTMLElement {
|
|
53
|
+
/**
|
|
54
|
+
* This visit's listeners, and only this visit's. Remade every time:
|
|
55
|
+
* an `AbortController` is single-use, and a reused one comes back
|
|
56
|
+
* already aborted, leaving the element inert but normal-looking.
|
|
57
|
+
*/
|
|
58
|
+
#ac?: AbortController;
|
|
59
|
+
|
|
60
|
+
/** What `connect` handed back, if anything. */
|
|
61
|
+
#undo?: () => void;
|
|
62
|
+
|
|
63
|
+
connectedCallback(): void {
|
|
64
|
+
// Insurance: a live controller still here would orphan its
|
|
65
|
+
// listeners.
|
|
66
|
+
this.#end();
|
|
67
|
+
|
|
68
|
+
const ac = new AbortController();
|
|
69
|
+
this.#ac = ac;
|
|
70
|
+
try {
|
|
71
|
+
this.#undo = connect(this, ac.signal) ?? undefined;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.#report(error);
|
|
81
74
|
}
|
|
82
75
|
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Pass to `addEventListener`, observers, anything that should stop when the
|
|
88
|
-
* element leaves the document. Throws outside a connection.
|
|
89
|
-
*/
|
|
90
|
-
protected get signal(): AbortSignal {
|
|
91
|
-
if (!this.#ac) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`${this.localName}: signal read outside a connection`
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
return this.#ac.signal;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
connectedCallback(): void {
|
|
100
|
-
// Insurance: overwriting a live controller would orphan its listeners.
|
|
101
|
-
this.#ac?.abort();
|
|
102
|
-
this.#ac = new AbortController();
|
|
103
76
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
if (!this.#ready) {
|
|
108
|
-
this.setup();
|
|
109
|
-
// Only once it returned, so a `setup` that threw is retried
|
|
110
|
-
// rather than leaving `connect` to run against nothing.
|
|
111
|
-
this.#ready = true;
|
|
77
|
+
disconnectedCallback(): void {
|
|
78
|
+
this.#end();
|
|
112
79
|
}
|
|
113
|
-
this.connect();
|
|
114
|
-
} catch (error) {
|
|
115
|
-
this.#report(error);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
disconnectedCallback(): void {
|
|
120
|
-
// Idempotent, so `adoptedCallback` can delegate here.
|
|
121
|
-
if (!this.#ac) return;
|
|
122
|
-
this.#ac.abort();
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
// Before `#ac` is cleared, so `this.signal` is readable and already
|
|
126
|
-
// aborted: an async continuation can check it and bail.
|
|
127
|
-
this.disconnect();
|
|
128
|
-
} catch (error) {
|
|
129
|
-
this.#report(error);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
this.#ac = undefined;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Moving to another document ends the old document's connection. */
|
|
136
|
-
adoptedCallback(): void {
|
|
137
|
-
this.disconnectedCallback();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
#report(error: unknown): void {
|
|
141
|
-
if (import.meta.env.DEV) {
|
|
142
|
-
reportDevError(this.localName, error);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
console.error(`${this.localName}:`, error);
|
|
146
|
-
}
|
|
147
80
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
protected abstract connect(): void;
|
|
153
|
-
|
|
154
|
-
/** Runs on every disconnect, once the signal has been aborted. */
|
|
155
|
-
protected disconnect(): void {}
|
|
156
|
-
|
|
157
|
-
/** The first match inside this element, or `null`. */
|
|
158
|
-
protected one<T extends Element = HTMLElement>(selector: string): T | null {
|
|
159
|
-
return within(this).one<T>(selector);
|
|
160
|
-
}
|
|
81
|
+
/** Moving to another document ends the old document's visit. */
|
|
82
|
+
adoptedCallback(): void {
|
|
83
|
+
this.#end();
|
|
84
|
+
}
|
|
161
85
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
86
|
+
#end(): void {
|
|
87
|
+
// Aborted before the undo runs, so an async continuation that
|
|
88
|
+
// checks the signal can see the visit is over.
|
|
89
|
+
this.#ac?.abort();
|
|
90
|
+
this.#ac = undefined;
|
|
91
|
+
|
|
92
|
+
const undo = this.#undo;
|
|
93
|
+
this.#undo = undefined;
|
|
94
|
+
try {
|
|
95
|
+
undo?.();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.#report(error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
166
100
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
`nothing matched "${selector}" — is the script still a deferred module?`
|
|
177
|
-
);
|
|
101
|
+
#report(error: unknown): void {
|
|
102
|
+
// The bare expression Vite substitutes, so this collapses to
|
|
103
|
+
// `if (false)` and the dev panel leaves the bundle.
|
|
104
|
+
if (import.meta.env.DEV) {
|
|
105
|
+
reportDevError(this.localName, error);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.error(`${this.localName}:`, error);
|
|
109
|
+
}
|
|
178
110
|
}
|
|
179
|
-
|
|
180
|
-
}
|
|
111
|
+
);
|
|
181
112
|
}
|
package/src/astro/markup.ts
CHANGED
|
@@ -123,6 +123,9 @@ type CheckedKeys<T> = {
|
|
|
123
123
|
* - `text` — any string, the empty one included.
|
|
124
124
|
* - `number` — finite only.
|
|
125
125
|
* - `list` — several words, space-separated, as `class` is.
|
|
126
|
+
* - `template` — a sentence with `{placeholders}` in it, read back as the
|
|
127
|
+
* function that fills them. A template that never says one of its own
|
|
128
|
+
* `params` fails the build, rather than shipping a brace to a reader.
|
|
126
129
|
* - `choice` — one of `of`, checked at compile time and again when read.
|
|
127
130
|
* - `flag` — present or absent. Off is the attribute left out, never `"false"`,
|
|
128
131
|
* so a stylesheet can select it with a bare `[data-…]`.
|
|
@@ -134,6 +137,11 @@ export type MarkupField =
|
|
|
134
137
|
| { readonly kind: "text"; readonly optional?: boolean }
|
|
135
138
|
| { readonly kind: "number"; readonly optional?: boolean }
|
|
136
139
|
| { readonly kind: "list"; readonly optional?: boolean }
|
|
140
|
+
| {
|
|
141
|
+
readonly kind: "template";
|
|
142
|
+
readonly params: readonly [string, ...string[]];
|
|
143
|
+
readonly optional?: boolean;
|
|
144
|
+
}
|
|
137
145
|
| {
|
|
138
146
|
readonly kind: "choice";
|
|
139
147
|
readonly of: readonly [string, ...string[]];
|
|
@@ -144,16 +152,36 @@ export type MarkupField =
|
|
|
144
152
|
/** The fields of one role, named by the caller. */
|
|
145
153
|
export type MarkupFields = Readonly<Record<string, MarkupField>>;
|
|
146
154
|
|
|
147
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* A role that carries nothing: `search: marker`, where `search: {}` reads like
|
|
157
|
+
* options somebody forgot to fill in. Most roles are these — they name an
|
|
158
|
+
* element the script has to find, and hold no values.
|
|
159
|
+
*/
|
|
160
|
+
export const marker = {} as const;
|
|
161
|
+
|
|
162
|
+
/** What `attrs` takes for a field, and what lands in the attribute. */
|
|
148
163
|
type Held<F extends MarkupField> = F extends { kind: "text" }
|
|
149
164
|
? string
|
|
150
165
|
: F extends { kind: "number" }
|
|
151
166
|
? number
|
|
152
167
|
: F extends { kind: "list" }
|
|
153
168
|
? readonly string[]
|
|
154
|
-
: F extends { kind: "
|
|
155
|
-
?
|
|
156
|
-
:
|
|
169
|
+
: F extends { kind: "template" }
|
|
170
|
+
? string
|
|
171
|
+
: F extends { kind: "choice"; of: readonly (infer V)[] }
|
|
172
|
+
? V
|
|
173
|
+
: boolean;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What a script reads back — the same, except that a `template` arrives as the
|
|
177
|
+
* function that fills it, so no script spells a placeholder.
|
|
178
|
+
*/
|
|
179
|
+
type Read<F extends MarkupField> = F extends {
|
|
180
|
+
kind: "template";
|
|
181
|
+
params: readonly (infer P)[];
|
|
182
|
+
}
|
|
183
|
+
? (values: Record<P & string, string>) => string
|
|
184
|
+
: Held<F>;
|
|
157
185
|
|
|
158
186
|
/** Whether a template may leave the field out. */
|
|
159
187
|
type Omittable<F extends MarkupField> = F extends { kind: "flag" }
|
|
@@ -164,8 +192,8 @@ type Omittable<F extends MarkupField> = F extends { kind: "flag" }
|
|
|
164
192
|
|
|
165
193
|
/** One field's value as a script reads it. */
|
|
166
194
|
export type MarkupValue<F extends MarkupField> = F extends { optional: true }
|
|
167
|
-
?
|
|
168
|
-
:
|
|
195
|
+
? Read<F> | undefined
|
|
196
|
+
: Read<F>;
|
|
169
197
|
|
|
170
198
|
/** Every field's value, as a script reads it. */
|
|
171
199
|
export type MarkupValues<F extends MarkupFields> = {
|
|
@@ -281,6 +309,19 @@ function encode(
|
|
|
281
309
|
return typeof value === "string" && field.of.includes(value)
|
|
282
310
|
? value
|
|
283
311
|
: refuse(`expected one of ${field.of.join(", ")}`);
|
|
312
|
+
case "template": {
|
|
313
|
+
if (typeof value !== "string") {
|
|
314
|
+
return refuse("it is a template field");
|
|
315
|
+
}
|
|
316
|
+
for (const param of field.params) {
|
|
317
|
+
if (!value.includes(`{${param}}`)) {
|
|
318
|
+
refuse(
|
|
319
|
+
`it never says {${param}}, so that value would have nowhere to go`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
284
325
|
case "list": {
|
|
285
326
|
if (!Array.isArray(value)) return refuse("it is a list field");
|
|
286
327
|
for (const item of value) {
|
|
@@ -331,6 +372,13 @@ function decode(
|
|
|
331
372
|
return raw;
|
|
332
373
|
case "list":
|
|
333
374
|
return raw.split(/\s+/).filter((item) => item !== "");
|
|
375
|
+
case "template":
|
|
376
|
+
return (values: Record<string, string>) =>
|
|
377
|
+
field.params.reduce(
|
|
378
|
+
(text, param) =>
|
|
379
|
+
text.replaceAll(`{${param}}`, values[param] ?? ""),
|
|
380
|
+
raw
|
|
381
|
+
);
|
|
334
382
|
}
|
|
335
383
|
}
|
|
336
384
|
|
|
@@ -466,14 +514,13 @@ export function markup<
|
|
|
466
514
|
/** A component's roles, by name: each is the fields of one `markup` role. */
|
|
467
515
|
export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
|
|
468
516
|
|
|
469
|
-
/**
|
|
470
|
-
type Reserved = "tag"
|
|
517
|
+
/** The key the component object uses itself, so no role may take it. */
|
|
518
|
+
type Reserved = "tag";
|
|
471
519
|
|
|
472
520
|
export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
473
|
-
/** The custom element's name: what the template renders
|
|
521
|
+
/** The custom element's name: what the template renders, and what
|
|
522
|
+
* `defineElement` registers the behaviour under. */
|
|
474
523
|
readonly tag: Tag;
|
|
475
|
-
/** Registers the element's class under `tag`. */
|
|
476
|
-
define(element: CustomElementConstructor): void;
|
|
477
524
|
} & { readonly [K in keyof R]: Markup<R[K]> };
|
|
478
525
|
|
|
479
526
|
/**
|
|
@@ -487,7 +534,7 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
|
487
534
|
* });
|
|
488
535
|
*
|
|
489
536
|
* // template: <faq.tag> … <details {...faq.row.attrs({ key, text })}>
|
|
490
|
-
* // script: faq.row.all(
|
|
537
|
+
* // script: defineElement(faq.tag, (host) => faq.row.all(host).forEach(…));
|
|
491
538
|
* ```
|
|
492
539
|
*
|
|
493
540
|
* Two things `markup` alone leaves to the project:
|
|
@@ -531,9 +578,9 @@ export function component<
|
|
|
531
578
|
name: string,
|
|
532
579
|
fields: MarkupFields
|
|
533
580
|
): Markup<MarkupFields> => {
|
|
534
|
-
// A compile error already, and cheap to keep:
|
|
535
|
-
// the component's own
|
|
536
|
-
if (name === "tag"
|
|
581
|
+
// A compile error already, and cheap to keep: this one would overwrite
|
|
582
|
+
// the component's own key and leave nothing to render the element as.
|
|
583
|
+
if (name === "tag") {
|
|
537
584
|
throw new Error(
|
|
538
585
|
`component(${show(tag)}): ${show(name)} is taken by the component itself`
|
|
539
586
|
);
|
|
@@ -583,11 +630,7 @@ export function component<
|
|
|
583
630
|
};
|
|
584
631
|
};
|
|
585
632
|
|
|
586
|
-
const built: Record<string, unknown> = {
|
|
587
|
-
tag,
|
|
588
|
-
define: (element: CustomElementConstructor) =>
|
|
589
|
-
customElements.define(tag, element),
|
|
590
|
-
};
|
|
633
|
+
const built: Record<string, unknown> = { tag };
|
|
591
634
|
for (const [name, fields] of Object.entries(roles)) {
|
|
592
635
|
built[name] = scoped(name, fields);
|
|
593
636
|
}
|