@escape-game-over/atlas 0.1.20 → 0.1.22
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 +28 -22
- package/package.json +1 -1
- package/src/astro/client.ts +5 -5
- package/src/astro/element.ts +92 -135
- package/src/astro/markup.ts +9 -14
- package/src/llms.ts +9 -1
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
|
|
|
@@ -55,11 +55,10 @@ 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
57
|
**A script imports one path: `@escape-game-over/atlas/client`**, which re-exports
|
|
58
|
-
every module above.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
nothing.
|
|
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.
|
|
63
62
|
|
|
64
63
|
## Lifetimes are the recurring bug
|
|
65
64
|
|
|
@@ -76,15 +75,23 @@ const detach = loop.attach(video); // background-video: the <video>
|
|
|
76
75
|
const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
|
|
77
76
|
```
|
|
78
77
|
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
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.
|
|
82
82
|
|
|
83
83
|
```ts
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
});
|
|
86
88
|
```
|
|
87
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
|
+
|
|
88
95
|
**The trap this exists for is view transitions.** A bundled `<script src>` is an
|
|
89
96
|
ES module, cached by URL, so it executes once per session — not once per
|
|
90
97
|
navigation. Bind at module scope with `ClientRouter` on and the incoming page
|
|
@@ -472,12 +479,9 @@ export const faq = component("go-faq", {
|
|
|
472
479
|
```
|
|
473
480
|
|
|
474
481
|
```ts
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
faq.define(Faq);
|
|
482
|
+
defineElement(faq.tag, (host, signal) => {
|
|
483
|
+
for (const { element, values } of faq.row.all(host)) { … }
|
|
484
|
+
});
|
|
481
485
|
```
|
|
482
486
|
|
|
483
487
|
- **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
|
|
@@ -490,8 +494,10 @@ faq.define(Faq);
|
|
|
490
494
|
keeps its elements, and a lookup from an element inside the instance, like a
|
|
491
495
|
dropdown's own panel, still counts as that instance. Elements are filtered
|
|
492
496
|
before they are read, so a broken nested copy cannot fail the outer lookup.
|
|
493
|
-
- **The tag is written once.** `faq.tag` is what the template renders and
|
|
494
|
-
`
|
|
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.
|
|
495
501
|
- **The names are checked in the editor.** A tag without a hyphen, or a role
|
|
496
502
|
that is not camelCase, is a compile error where it is written — the rules are
|
|
497
503
|
types, character by character, as in `i18n/placeholders.ts`. Nothing is
|
|
@@ -501,9 +507,9 @@ faq.define(Faq);
|
|
|
501
507
|
- **Most roles carry nothing**, and say so: `search: marker` rather than
|
|
502
508
|
`search: {}`, which reads like options somebody forgot to fill in.
|
|
503
509
|
|
|
504
|
-
`tag`
|
|
505
|
-
|
|
506
|
-
|
|
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`.
|
|
507
513
|
|
|
508
514
|
## `youtube`
|
|
509
515
|
|
package/package.json
CHANGED
package/src/astro/client.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Everything a client script needs, from one import.
|
|
3
3
|
*
|
|
4
4
|
* ```ts
|
|
5
|
-
* import {
|
|
5
|
+
* import { filters, searchBox } from "@escape-game-over/atlas/client";
|
|
6
6
|
* ```
|
|
7
7
|
*
|
|
8
|
-
* **
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
12
|
*/
|
|
13
13
|
|
|
14
14
|
export * from "./background-video.ts";
|
package/src/astro/element.ts
CHANGED
|
@@ -1,155 +1,112 @@
|
|
|
1
1
|
import { reportDevError } from "./dev-log.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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.
|
|
5
24
|
*
|
|
6
25
|
* ```ts
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* signal: this.signal,
|
|
12
|
-
* });
|
|
13
|
-
* }
|
|
14
|
-
* }
|
|
15
|
-
* 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
|
+
* });
|
|
16
30
|
* ```
|
|
17
31
|
*
|
|
18
|
-
* - **An element
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* - **Override `setup`, `connect` and `disconnect`, never the callbacks.**
|
|
23
|
-
* Overriding `connectedCallback` skips the controller and leaks every
|
|
24
|
-
* 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.
|
|
25
36
|
* - **The defining script must stay a deferred module.** Astro emits
|
|
26
|
-
* `<script src>` as `type="module"`, so the element has its children
|
|
27
|
-
* is upgraded. `is:inline` runs it too early and every lookup
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* 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.
|
|
33
43
|
*
|
|
34
44
|
* See docs/client-scripts.md.
|
|
35
45
|
*/
|
|
36
|
-
export
|
|
37
|
-
|
|
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
|
-
] as const;
|
|
65
|
-
|
|
66
|
-
for (
|
|
67
|
-
let proto = Object.getPrototypeOf(this);
|
|
68
|
-
proto && proto !== AtlasElement.prototype;
|
|
69
|
-
proto = Object.getPrototypeOf(proto)
|
|
70
|
-
) {
|
|
71
|
-
for (const [callback, hook] of wrong) {
|
|
72
|
-
if (Object.hasOwn(proto, callback)) {
|
|
73
|
-
const error = new Error(
|
|
74
|
-
`override "${hook}", not "${callback}" — see AtlasElement`
|
|
75
|
-
);
|
|
76
|
-
// Reported before throwing: this runs during upgrade, where
|
|
77
|
-
// a throw would only surface as an uncaught console error.
|
|
78
|
-
reportDevError(this.constructor.name, error);
|
|
79
|
-
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);
|
|
80
74
|
}
|
|
81
75
|
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Pass to `addEventListener`, observers, anything that should stop when the
|
|
87
|
-
* element leaves the document. Throws outside a connection.
|
|
88
|
-
*/
|
|
89
|
-
protected get signal(): AbortSignal {
|
|
90
|
-
if (!this.#ac) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
`${this.localName}: signal read outside a connection`
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
return this.#ac.signal;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
connectedCallback(): void {
|
|
99
|
-
// Insurance: overwriting a live controller would orphan its listeners.
|
|
100
|
-
this.#ac?.abort();
|
|
101
|
-
this.#ac = new AbortController();
|
|
102
76
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
if (!this.#ready) {
|
|
107
|
-
this.setup();
|
|
108
|
-
// Only once it returned, so a `setup` that threw is retried
|
|
109
|
-
// rather than leaving `connect` to run against nothing.
|
|
110
|
-
this.#ready = true;
|
|
77
|
+
disconnectedCallback(): void {
|
|
78
|
+
this.#end();
|
|
111
79
|
}
|
|
112
|
-
this.connect();
|
|
113
|
-
} catch (error) {
|
|
114
|
-
this.#report(error);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
disconnectedCallback(): void {
|
|
119
|
-
// Idempotent, so `adoptedCallback` can delegate here.
|
|
120
|
-
if (!this.#ac) return;
|
|
121
|
-
this.#ac.abort();
|
|
122
80
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
} catch (error) {
|
|
128
|
-
this.#report(error);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
this.#ac = undefined;
|
|
132
|
-
}
|
|
81
|
+
/** Moving to another document ends the old document's visit. */
|
|
82
|
+
adoptedCallback(): void {
|
|
83
|
+
this.#end();
|
|
84
|
+
}
|
|
133
85
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
100
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|
|
143
110
|
}
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Runs once per element, before its first `connect`. State belongs here. */
|
|
148
|
-
protected setup(): void {}
|
|
149
|
-
|
|
150
|
-
/** Runs on every connect, with `signal` and the children both available. */
|
|
151
|
-
protected abstract connect(): void;
|
|
152
|
-
|
|
153
|
-
/** Runs on every disconnect, once the signal has been aborted. */
|
|
154
|
-
protected disconnect(): void {}
|
|
111
|
+
);
|
|
155
112
|
}
|
package/src/astro/markup.ts
CHANGED
|
@@ -514,14 +514,13 @@ export function markup<
|
|
|
514
514
|
/** A component's roles, by name: each is the fields of one `markup` role. */
|
|
515
515
|
export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
|
|
516
516
|
|
|
517
|
-
/**
|
|
518
|
-
type Reserved = "tag"
|
|
517
|
+
/** The key the component object uses itself, so no role may take it. */
|
|
518
|
+
type Reserved = "tag";
|
|
519
519
|
|
|
520
520
|
export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
521
|
-
/** 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. */
|
|
522
523
|
readonly tag: Tag;
|
|
523
|
-
/** Registers the element's class under `tag`. */
|
|
524
|
-
define(element: CustomElementConstructor): void;
|
|
525
524
|
} & { readonly [K in keyof R]: Markup<R[K]> };
|
|
526
525
|
|
|
527
526
|
/**
|
|
@@ -535,7 +534,7 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
|
535
534
|
* });
|
|
536
535
|
*
|
|
537
536
|
* // template: <faq.tag> … <details {...faq.row.attrs({ key, text })}>
|
|
538
|
-
* // script: faq.row.all(
|
|
537
|
+
* // script: defineElement(faq.tag, (host) => faq.row.all(host).forEach(…));
|
|
539
538
|
* ```
|
|
540
539
|
*
|
|
541
540
|
* Two things `markup` alone leaves to the project:
|
|
@@ -579,9 +578,9 @@ export function component<
|
|
|
579
578
|
name: string,
|
|
580
579
|
fields: MarkupFields
|
|
581
580
|
): Markup<MarkupFields> => {
|
|
582
|
-
// A compile error already, and cheap to keep:
|
|
583
|
-
// the component's own
|
|
584
|
-
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") {
|
|
585
584
|
throw new Error(
|
|
586
585
|
`component(${show(tag)}): ${show(name)} is taken by the component itself`
|
|
587
586
|
);
|
|
@@ -631,11 +630,7 @@ export function component<
|
|
|
631
630
|
};
|
|
632
631
|
};
|
|
633
632
|
|
|
634
|
-
const built: Record<string, unknown> = {
|
|
635
|
-
tag,
|
|
636
|
-
define: (element: CustomElementConstructor) =>
|
|
637
|
-
customElements.define(tag, element),
|
|
638
|
-
};
|
|
633
|
+
const built: Record<string, unknown> = { tag };
|
|
639
634
|
for (const [name, fields] of Object.entries(roles)) {
|
|
640
635
|
built[name] = scoped(name, fields);
|
|
641
636
|
}
|
package/src/llms.ts
CHANGED
|
@@ -151,10 +151,18 @@ export function buildLlms(input: LlmsInput): GeneratedFile {
|
|
|
151
151
|
lines.push("");
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Opens with a byte-order mark, because `contentType` does not survive the
|
|
155
|
+
// build. Only the dev middleware sends it; once written to `dist` the file
|
|
156
|
+
// is served with whatever the host picks, and `astro preview` picks
|
|
157
|
+
// `text/plain` with no charset. A browser then reads the bytes as
|
|
158
|
+
// Windows-1252, and every `—`, `²` and line of Greek turns to `—`. This
|
|
159
|
+
// is the one generated file written in prose, so it is the one that breaks.
|
|
160
|
+
// The mark is the encoding stated by the bytes themselves, which a browser
|
|
161
|
+
// honours over a missing charset and a UTF-8 decoder strips.
|
|
154
162
|
return {
|
|
155
163
|
name: input.name,
|
|
156
164
|
url: joinUrl(input.siteUrl, `/${input.name}`),
|
|
157
|
-
body:
|
|
165
|
+
body: `\uFEFF${lines.join("\n").trimEnd()}\n`,
|
|
158
166
|
contentType: "text/markdown; charset=utf-8",
|
|
159
167
|
};
|
|
160
168
|
}
|