@escape-game-over/atlas 0.1.18 → 0.1.20

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.
@@ -54,6 +54,13 @@ 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. It is browser-only — `element.ts` evaluates
59
+ `class AtlasElement extends HTMLElement` as it loads, which throws in Node — so
60
+ frontmatter and `astro.config.ts` cannot use it. A component's contract is the
61
+ one thing both halves need, and it stays on `./astro/markup`, which touches
62
+ nothing.
63
+
57
64
  ## Lifetimes are the recurring bug
58
65
 
59
66
  Every module here that binds a listener hands back the undo, and the undo is
@@ -408,9 +415,24 @@ const input = search.require<HTMLInputElement>(this).element;
408
415
  a list that quietly loses a row is the failure this exists to prevent.
409
416
 
410
417
  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.
418
+ `text`, `number`, `list`, `template`, `choice` and `flag`, all but `flag`
419
+ optionally `optional`. A `flag` is off by being absent, never `"false"`, so a
420
+ stylesheet can select it with a bare attribute.
421
+
422
+ A `template` is the one that keeps a sentence out of the script. It is written
423
+ as the translated string with its placeholders left in, and read back as the
424
+ function that fills them:
425
+
426
+ ```astro
427
+ <p {...faq.line.attrs({ sentence: t("faq.matched", { count: "{count}" }) })}></p>
428
+ ```
429
+
430
+ ```ts
431
+ line.element.textContent = line.values.sentence({ count: String(hits.size) });
432
+ ```
433
+
434
+ So no script spells `"{count}"`, and a sentence that never says one of its own
435
+ placeholders fails the build rather than shipping a brace to a reader.
414
436
 
415
437
  `write` sets fields back onto an element, for state a stylesheet reads — a
416
438
  dropdown that is blocked — so the script uses the names the template did.
@@ -470,6 +492,14 @@ faq.define(Faq);
470
492
  before they are read, so a broken nested copy cannot fail the outer lookup.
471
493
  - **The tag is written once.** `faq.tag` is what the template renders and
472
494
  `faq.define` registers the class under it.
495
+ - **The names are checked in the editor.** A tag without a hyphen, or a role
496
+ that is not camelCase, is a compile error where it is written — the rules are
497
+ types, character by character, as in `i18n/placeholders.ts`. Nothing is
498
+ validated at run time, because a name that reached the browser malformed would
499
+ already have been refused there: by `setAttribute`, by `querySelectorAll`, or
500
+ by `customElements.define`.
501
+ - **Most roles carry nothing**, and say so: `search: marker` rather than
502
+ `search: {}`, which reads like options somebody forgot to fill in.
473
503
 
474
504
  `tag` and `define` are the component's own keys, so no role may take them. A role
475
505
  that lives outside every instance, such as a footer button that reopens a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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 { AtlasElement, filters, searchBox } from "@escape-game-over/atlas/client";
6
+ * ```
7
+ *
8
+ * **Browser only.** `element.ts` evaluates `class AtlasElement extends
9
+ * HTMLElement` as this module loads, which throws in Node — so frontmatter and
10
+ * `astro.config.ts` must not import it. A component's contract is the one thing
11
+ * both halves need, and it stays on `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/dom.ts CHANGED
@@ -1,40 +1,20 @@
1
1
  /**
2
- * Scoped element lookups, without the ceremony.
3
- *
4
- * `querySelector` returns `Element`, so anything that wants to set `hidden`,
5
- * read `dataset` or call `focus` has to say `<HTMLElement>` at every call — long
6
- * enough that the line wraps, on lookups that are otherwise trivial. And
7
- * `querySelectorAll` returns a `NodeList`, which needs spreading before it will
8
- * `map` or `entries`. Both are noise, and both hide the one thing worth reading:
9
- * what is being looked for.
2
+ * Scoped element lookups, typed.
10
3
  *
11
4
  * ```ts
12
5
  * const { one, all } = within(root);
13
- * const track = one("[data-carousel-track]");
14
6
  * const dots = all<HTMLButtonElement>("[data-carousel-dot]");
15
7
  * ```
16
8
  *
17
- * The root is bound once, which is the point: a client script that looks
18
- * everything up inside the element it was given cannot reach a second copy of
19
- * itself elsewhere on the page. `document` is a valid root, and should appear
20
- * exactly once per script — to find the roots.
9
+ * The root is bound once, so a script cannot reach a second copy of itself
10
+ * elsewhere on the page. `document` belongs in one place per script: finding
11
+ * the roots.
21
12
  *
22
13
  * **A selector with a combinator still matches against the whole document.**
23
- * `within(form).one("form p")` can match a `<p>` whose ancestor `<form>` is not
24
- * the one bound here; only the final filter is scoped. Simple selectors an
25
- * attribute, a class — are unaffected, which is what these are for. Use
26
- * `:scope` if a combinator is ever genuinely needed.
14
+ * `within(form).one("form p")` can match a `<p>` under a different form; only
15
+ * the final filter is scoped. Use `:scope` if a combinator is needed.
27
16
  */
28
17
 
29
- /**
30
- * Constrained to `Element` but defaulting to `HTMLElement`.
31
- *
32
- * The default is what almost every lookup wants — `hidden`, `dataset` and
33
- * `focus` all live on `HTMLElement`, and having to name it at each call is the
34
- * noise this exists to remove. The wider constraint is for the rest: inline
35
- * `<svg>` and `<use>` are `SVGElement`, which is an `Element` and not an
36
- * `HTMLElement`, so a narrower bound would refuse a perfectly ordinary lookup.
37
- */
38
18
  export interface Within {
39
19
  /** The first match inside the root, or `null`. */
40
20
  one<T extends Element = HTMLElement>(selector: string): T | null;
@@ -42,6 +22,8 @@ export interface Within {
42
22
  all<T extends Element = HTMLElement>(selector: string): T[];
43
23
  }
44
24
 
25
+ // `HTMLElement` by default because `hidden`, `dataset` and `focus` live there;
26
+ // the bound stays `Element` so inline `<svg>` lookups are not refused.
45
27
  export function within(root: ParentNode): Within {
46
28
  return {
47
29
  one: <T extends Element = HTMLElement>(selector: string) =>
@@ -1,20 +1,13 @@
1
1
  import { reportDevError } from "./dev-log.ts";
2
- import { within } from "./dom.ts";
3
2
 
4
3
  /**
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.
4
+ * A base for custom elements: two lifetimes, and one abort signal.
10
5
  *
11
6
  * ```ts
12
7
  * 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, {
8
+ * protected setup(): void { … } // once, ever: what the element is
9
+ * protected connect(): void { // every connection: bind with signal
10
+ * this.querySelector("form")?.addEventListener("submit", send, {
18
11
  * signal: this.signal,
19
12
  * });
20
13
  * }
@@ -22,75 +15,49 @@ import { within } from "./dom.ts";
22
15
  * customElements.define("atlas-thing", Thing);
23
16
  * ```
24
17
  *
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.
18
+ * - **An element is constructed once and connected many times.** Moving it in
19
+ * the DOM runs `disconnectedCallback` then `connectedCallback` again, so
20
+ * state belongs in `setup` and listeners in `connect`. Anything registered
21
+ * with `signal` is dropped on disconnect and rebound on the next connect.
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.
25
+ * - **The defining script must stay a deferred module.** Astro emits
26
+ * `<script src>` as `type="module"`, so the element has its children when it
27
+ * is upgraded. `is:inline` runs it too early and every lookup finds nothing.
28
+ * - **`attributeChangedCallback` runs before `connectedCallback`**, where
29
+ * `signal` is not readable yet. Record what changed and act on it in
30
+ * `connect`.
31
+ * - **`disconnect` is not a destructor.** A move fires it and connects again a
32
+ * moment later, so it is connection teardown and nothing else.
39
33
  *
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.
34
+ * See docs/client-scripts.md.
56
35
  */
57
36
  export abstract class AtlasElement extends HTMLElement {
58
37
  /**
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.
38
+ * This connection's listeners, and only this connection's. Remade on every
39
+ * connect: an `AbortController` is single-use, and a reused one comes back
40
+ * already aborted, leaving the element inert but normal-looking.
66
41
  */
67
42
  #ac?: AbortController;
68
43
 
69
- /** Whether `setup` has run. See the two-lifetimes note above. */
70
44
  #ready = false;
71
45
 
72
46
  constructor() {
73
47
  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.
48
+ // The bare expression Vite substitutes, so this collapses to
49
+ // `if (false)` and the method below leaves the bundle.
79
50
  if (import.meta.env.DEV) this.#assertHooks();
80
51
  }
81
52
 
82
53
  /**
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.
54
+ * Refuses a subclass that overrode the wrong lifecycle method. An override
55
+ * is an *own* property of the subclass prototype; the loop covers an
56
+ * intermediate base class that got it wrong.
89
57
  */
90
58
  #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.
59
+ // A tuple array, not an object: `Object.entries` would widen the keys
60
+ // back to `string` and stop checking the pairing.
94
61
  const wrong = [
95
62
  ["connectedCallback", "connect"],
96
63
  ["disconnectedCallback", "disconnect"],
@@ -106,11 +73,8 @@ export abstract class AtlasElement extends HTMLElement {
106
73
  const error = new Error(
107
74
  `override "${hook}", not "${callback}" — see AtlasElement`
108
75
  );
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.
76
+ // Reported before throwing: this runs during upgrade, where
77
+ // a throw would only surface as an uncaught console error.
114
78
  reportDevError(this.constructor.name, error);
115
79
  throw error;
116
80
  }
@@ -120,10 +84,7 @@ export abstract class AtlasElement extends HTMLElement {
120
84
 
121
85
  /**
122
86
  * 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.
87
+ * element leaves the document. Throws outside a connection.
127
88
  */
128
89
  protected get signal(): AbortSignal {
129
90
  if (!this.#ac) {
@@ -135,25 +96,17 @@ export abstract class AtlasElement extends HTMLElement {
135
96
  }
136
97
 
137
98
  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.
99
+ // Insurance: overwriting a live controller would orphan its listeners.
142
100
  this.#ac?.abort();
143
101
  this.#ac = new AbortController();
144
102
 
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.
103
+ // Caught so one broken element logs and sits inert rather than taking
104
+ // its siblings with it which is what lets `require` throw.
149
105
  try {
150
106
  if (!this.#ready) {
151
107
  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.
108
+ // Only once it returned, so a `setup` that threw is retried
109
+ // rather than leaving `connect` to run against nothing.
157
110
  this.#ready = true;
158
111
  }
159
112
  this.connect();
@@ -163,18 +116,13 @@ export abstract class AtlasElement extends HTMLElement {
163
116
  }
164
117
 
165
118
  disconnectedCallback(): void {
166
- // Idempotent, so `adoptedCallback` can delegate here without running a
167
- // subclass's teardown twice.
119
+ // Idempotent, so `adoptedCallback` can delegate here.
168
120
  if (!this.#ac) return;
169
121
  this.#ac.abort();
170
122
 
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
123
  try {
175
124
  // 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.
125
+ // aborted: an async continuation can check it and bail.
178
126
  this.disconnect();
179
127
  } catch (error) {
180
128
  this.#report(error);
@@ -183,13 +131,7 @@ export abstract class AtlasElement extends HTMLElement {
183
131
  this.#ac = undefined;
184
132
  }
185
133
 
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
- */
134
+ /** Moving to another document ends the old document's connection. */
193
135
  adoptedCallback(): void {
194
136
  this.disconnectedCallback();
195
137
  }
@@ -202,58 +144,12 @@ export abstract class AtlasElement extends HTMLElement {
202
144
  console.error(`${this.localName}:`, error);
203
145
  }
204
146
 
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
- */
147
+ /** Runs once per element, before its first `connect`. State belongs here. */
212
148
  protected setup(): void {}
213
149
 
214
150
  /** Runs on every connect, with `signal` and the children both available. */
215
151
  protected abstract connect(): void;
216
152
 
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
- */
153
+ /** Runs on every disconnect, once the signal has been aborted. */
229
154
  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
155
  }