@escape-game-over/atlas 0.1.1 → 0.1.3
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 +48 -7
- package/docs/NOT-BUILT.md +84 -0
- package/docs/toolchain.md +49 -22
- package/package.json +11 -6
- package/src/astro/MetaTags.astro +2 -4
- package/src/astro/build-cache.ts +80 -0
- package/src/astro/carousel.ts +342 -0
- package/src/astro/dev-log.ts +128 -0
- package/src/astro/dom.ts +53 -0
- package/src/astro/element.ts +259 -0
- package/src/astro/filters.ts +457 -0
- package/src/astro/images.ts +73 -0
- package/src/astro/site-routes.ts +126 -21
- package/src/astro/tsconfig.json +8 -0
- package/src/contact-form.ts +177 -0
- package/src/index.ts +7 -0
- package/src/meta/index.ts +79 -32
- package/src/routes/define.ts +3 -1
- package/src/routes/resolve.ts +28 -3
- package/src/site/api.ts +12 -0
- package/src/site/create.ts +144 -43
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { reportDevError } from "./dev-log.ts";
|
|
2
|
+
import { within } from "./dom.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A base for custom elements, holding the parts every one of them gets wrong.
|
|
6
|
+
*
|
|
7
|
+
* In `astro/` because it touches the DOM, which the core is type-checked
|
|
8
|
+
* without — this, `consent.ts`, `carousel.ts` and `dom.ts` are the folder
|
|
9
|
+
* allowed it.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* class Thing extends AtlasElement {
|
|
13
|
+
* #count = 0;
|
|
14
|
+
*
|
|
15
|
+
* protected setup(): void { … } // once, ever
|
|
16
|
+
* protected connect(): void { // every time it enters the document
|
|
17
|
+
* this.require("form").addEventListener("submit", this.#send, {
|
|
18
|
+
* signal: this.signal,
|
|
19
|
+
* });
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* customElements.define("atlas-thing", Thing);
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* **Two lifetimes, and confusing them is the whole reason this class exists.**
|
|
26
|
+
* An element is constructed once and may be connected many times: moving it in
|
|
27
|
+
* the DOM runs `disconnectedCallback` then `connectedCallback` again. So
|
|
28
|
+
* `setup` is for what the element *is* — state, defaults, anything a reader
|
|
29
|
+
* would be annoyed to lose — and `connect` is for what a connection *owns* —
|
|
30
|
+
* listeners, timers, observers. Anything registered with `signal` is torn down
|
|
31
|
+
* on every disconnect and re-registered on every connect, so a re-entry rebinds
|
|
32
|
+
* rather than duplicating.
|
|
33
|
+
*
|
|
34
|
+
* **Subclasses implement `setup`, `connect` and `disconnect` — never
|
|
35
|
+
* `connectedCallback` or `disconnectedCallback`.** Overriding those replaces
|
|
36
|
+
* this class's, the controller is never created, and every listener leaks on
|
|
37
|
+
* each disconnect. `connect` is abstract, so forgetting it is a compile error;
|
|
38
|
+
* the adjacent mistake is caught at construction in development, below.
|
|
39
|
+
*
|
|
40
|
+
* **The script that defines a subclass must stay a deferred module.** Astro
|
|
41
|
+
* emits `<script>` and `<script src="./…">` as `type="module"`, which runs after
|
|
42
|
+
* the document is parsed, so an element has its children by the time it is
|
|
43
|
+
* upgraded. `is:inline` opts out and runs the script where it sits — usually
|
|
44
|
+
* above the element it wires — and every lookup then finds nothing. The
|
|
45
|
+
* protection comes from the bundling, not from custom elements.
|
|
46
|
+
*
|
|
47
|
+
* **`attributeChangedCallback` runs before `connectedCallback`** for attributes
|
|
48
|
+
* present in the initial markup, so `signal` is not available there and reading
|
|
49
|
+
* it throws. A subclass with `observedAttributes` should record what changed and
|
|
50
|
+
* act on it in `connect`.
|
|
51
|
+
*
|
|
52
|
+
* What is deliberately *not* modelled is destruction. `disconnect` fires on
|
|
53
|
+
* every move, so it is connection teardown and nothing else — it is not the
|
|
54
|
+
* place to flush state or release something genuinely scarce, because the
|
|
55
|
+
* element may well be back a microtask later.
|
|
56
|
+
*/
|
|
57
|
+
export abstract class AtlasElement extends HTMLElement {
|
|
58
|
+
/**
|
|
59
|
+
* Cancels this connection's listeners, and only this connection's.
|
|
60
|
+
*
|
|
61
|
+
* Remade on every connect rather than held in a field initializer, because
|
|
62
|
+
* an `AbortController` is single-use: a controller that outlived the first
|
|
63
|
+
* connection would come back already aborted, `addEventListener` with an
|
|
64
|
+
* aborted signal silently adds nothing, and the element would return looking
|
|
65
|
+
* perfectly normal and be inert forever.
|
|
66
|
+
*/
|
|
67
|
+
#ac?: AbortController;
|
|
68
|
+
|
|
69
|
+
/** Whether `setup` has run. See the two-lifetimes note above. */
|
|
70
|
+
#ready = false;
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
super();
|
|
74
|
+
// Written as the bare expression Vite substitutes: `import.meta.env.DEV`
|
|
75
|
+
// is replaced with a literal, so this collapses to `if (false)` and the
|
|
76
|
+
// method below is dropped from the bundle. An optional chain would be
|
|
77
|
+
// replaced as `import.meta.env` instead — an object literal, whose
|
|
78
|
+
// `.DEV` a minifier has to fold rather than simply delete.
|
|
79
|
+
if (import.meta.env.DEV) this.#assertHooks();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refuses a subclass that overrode the wrong lifecycle method.
|
|
84
|
+
*
|
|
85
|
+
* An override is an *own* property of the subclass prototype, where the
|
|
86
|
+
* inherited one is not — so walking up to this class's prototype finds it.
|
|
87
|
+
* The loop rather than a single check, because an intermediate base class
|
|
88
|
+
* could be the one that got it wrong.
|
|
89
|
+
*/
|
|
90
|
+
#assertHooks(): void {
|
|
91
|
+
// A tuple array rather than an object: `Object.entries` widens its keys
|
|
92
|
+
// back to `string`, so the pairing would stop being checked exactly
|
|
93
|
+
// where a typo would matter.
|
|
94
|
+
const wrong = [
|
|
95
|
+
["connectedCallback", "connect"],
|
|
96
|
+
["disconnectedCallback", "disconnect"],
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
for (
|
|
100
|
+
let proto = Object.getPrototypeOf(this);
|
|
101
|
+
proto && proto !== AtlasElement.prototype;
|
|
102
|
+
proto = Object.getPrototypeOf(proto)
|
|
103
|
+
) {
|
|
104
|
+
for (const [callback, hook] of wrong) {
|
|
105
|
+
if (Object.hasOwn(proto, callback)) {
|
|
106
|
+
const error = new Error(
|
|
107
|
+
`override "${hook}", not "${callback}" — see AtlasElement`
|
|
108
|
+
);
|
|
109
|
+
// Reported before throwing: this runs during upgrade, so
|
|
110
|
+
// `connectedCallback`'s catch has not been entered and the
|
|
111
|
+
// throw would surface only as an uncaught error in the
|
|
112
|
+
// console — invisible, for the one mistake this whole
|
|
113
|
+
// apparatus exists to catch.
|
|
114
|
+
reportDevError(this.constructor.name, error);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pass to `addEventListener`, observers, anything that should stop when the
|
|
123
|
+
* element leaves the document.
|
|
124
|
+
*
|
|
125
|
+
* Throws when read outside a connection, which is a programming error
|
|
126
|
+
* rather than a state to handle: there is nothing sensible to return.
|
|
127
|
+
*/
|
|
128
|
+
protected get signal(): AbortSignal {
|
|
129
|
+
if (!this.#ac) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`${this.localName}: signal read outside a connection`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return this.#ac.signal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
connectedCallback(): void {
|
|
138
|
+
// Insurance. The spec pairs the callbacks, so this should never find a
|
|
139
|
+
// live controller — but if it ever did, overwriting one would orphan
|
|
140
|
+
// every listener it owned, silently, which is the failure this class
|
|
141
|
+
// exists to make impossible.
|
|
142
|
+
this.#ac?.abort();
|
|
143
|
+
this.#ac = new AbortController();
|
|
144
|
+
|
|
145
|
+
// Caught here so one broken element logs and sits inert rather than
|
|
146
|
+
// taking its siblings with it: the browser calls this once per element,
|
|
147
|
+
// so the blast radius is already one. That is what lets `require`
|
|
148
|
+
// throw instead of returning something every call site has to check.
|
|
149
|
+
try {
|
|
150
|
+
if (!this.#ready) {
|
|
151
|
+
this.setup();
|
|
152
|
+
// Only once it returned. Setting the flag first would mean a
|
|
153
|
+
// `setup` that threw was never retried, and the next connect
|
|
154
|
+
// would run `connect` against state that was never initialized
|
|
155
|
+
// — a component that renders perfectly and does nothing, which
|
|
156
|
+
// is the failure this class exists to make impossible.
|
|
157
|
+
this.#ready = true;
|
|
158
|
+
}
|
|
159
|
+
this.connect();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.#report(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
disconnectedCallback(): void {
|
|
166
|
+
// Idempotent, so `adoptedCallback` can delegate here without running a
|
|
167
|
+
// subclass's teardown twice.
|
|
168
|
+
if (!this.#ac) return;
|
|
169
|
+
this.#ac.abort();
|
|
170
|
+
|
|
171
|
+
// Caught for the same reason as `connect`, and it matters more: a
|
|
172
|
+
// throw here escapes into whatever is swapping the DOM — a router, a
|
|
173
|
+
// view transition — rather than staying in the component.
|
|
174
|
+
try {
|
|
175
|
+
// Before `#ac` is cleared, so `this.signal` is readable and already
|
|
176
|
+
// aborted: an async continuation can check `signal.aborted` and
|
|
177
|
+
// bail rather than finishing against a detached element.
|
|
178
|
+
this.disconnect();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.#report(error);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.#ac = undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fires when the element moves to another document.
|
|
188
|
+
*
|
|
189
|
+
* Delegates, because the controller belongs to the connection in the old
|
|
190
|
+
* document and nothing else would tear it down. Exotic — `adoptNode` and
|
|
191
|
+
* iframe work — and one line either way.
|
|
192
|
+
*/
|
|
193
|
+
adoptedCallback(): void {
|
|
194
|
+
this.disconnectedCallback();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#report(error: unknown): void {
|
|
198
|
+
if (import.meta.env.DEV) {
|
|
199
|
+
reportDevError(this.localName, error);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
console.error(`${this.localName}:`, error);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Runs once per element, before its first `connect`.
|
|
207
|
+
*
|
|
208
|
+
* Where state belongs. A move re-runs `connect` but never this, so anything
|
|
209
|
+
* initialised here survives one — which is the difference between a reader
|
|
210
|
+
* coming back to the slide they left and coming back to the first one.
|
|
211
|
+
*/
|
|
212
|
+
protected setup(): void {}
|
|
213
|
+
|
|
214
|
+
/** Runs on every connect, with `signal` and the children both available. */
|
|
215
|
+
protected abstract connect(): void;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Runs on every disconnect, once the signal has been aborted.
|
|
219
|
+
*
|
|
220
|
+
* `signal` is still readable here and reports `aborted` — which is what an
|
|
221
|
+
* async continuation should check before touching a now-detached element.
|
|
222
|
+
* What it is *not* is a destructor: a move fires this and then `connect`
|
|
223
|
+
* again a moment later, so it is connection teardown and nothing else.
|
|
224
|
+
* Flushing state or releasing something scarce does not belong here.
|
|
225
|
+
*
|
|
226
|
+
* Empty by default: anything registered with `signal` is already gone, and
|
|
227
|
+
* most elements have nothing else to undo.
|
|
228
|
+
*/
|
|
229
|
+
protected disconnect(): void {}
|
|
230
|
+
|
|
231
|
+
/** The first match inside this element, or `null`. */
|
|
232
|
+
protected one<T extends Element = HTMLElement>(selector: string): T | null {
|
|
233
|
+
return within(this).one<T>(selector);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every match inside this element, as an array. */
|
|
237
|
+
protected all<T extends Element = HTMLElement>(selector: string): T[] {
|
|
238
|
+
return within(this).all<T>(selector);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The first match, or a thrown error naming what was missing.
|
|
243
|
+
*
|
|
244
|
+
* Throws rather than returning `T | null`, because a nullable return puts an
|
|
245
|
+
* `?.` at every call site — and that optional chain swallows the failure
|
|
246
|
+
* just as thoroughly as the missing element did, which is the thing worth
|
|
247
|
+
* avoiding. `connectedCallback` catches it, so one element with broken
|
|
248
|
+
* markup logs and stops while every other element on the page is untouched.
|
|
249
|
+
*/
|
|
250
|
+
protected require<T extends Element = HTMLElement>(selector: string): T {
|
|
251
|
+
const found = this.one<T>(selector);
|
|
252
|
+
if (!found) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`nothing matched "${selector}" — is the script still a deferred module?`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return found;
|
|
258
|
+
}
|
|
259
|
+
}
|