@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.
@@ -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` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
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. 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.
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
- `AtlasElement` says the same thing in the shape a custom element needs, because
80
- an element is constructed once and may be connected many times — moving it in
81
- the DOM runs `disconnectedCallback` and then `connectedCallback` again:
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
- protected setup(): void { … } // once, ever: what the element is
85
- protected connect(): void { } // every connection: bind with this.signal
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
- class Faq extends AtlasElement {
476
- protected connect(): void {
477
- for (const { element, values } of faq.row.all(this)) { … }
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
- `faq.define` registers the class under it.
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` and `define` are the component's own keys, so no role may take them. A role
505
- that lives outside every instance, such as a footer button that reopens a
506
- banner, is still plain `markup`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -2,13 +2,13 @@
2
2
  * Everything a client script needs, from one import.
3
3
  *
4
4
  * ```ts
5
- * import { AtlasElement, filters, searchBox } from "@escape-game-over/atlas/client";
5
+ * import { filters, searchBox } from "@escape-game-over/atlas/client";
6
6
  * ```
7
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.
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";
@@ -1,155 +1,112 @@
1
1
  import { reportDevError } from "./dev-log.ts";
2
2
 
3
3
  /**
4
- * A base for custom elements: two lifetimes, and one abort signal.
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
- * class Thing extends AtlasElement {
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, {
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 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.
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 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.
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 abstract class AtlasElement extends HTMLElement {
37
- /**
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.
41
- */
42
- #ac?: AbortController;
43
-
44
- #ready = false;
45
-
46
- constructor() {
47
- super();
48
- // The bare expression Vite substitutes, so this collapses to
49
- // `if (false)` and the method below leaves the bundle.
50
- if (import.meta.env.DEV) this.#assertHooks();
51
- }
52
-
53
- /**
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.
57
- */
58
- #assertHooks(): void {
59
- // A tuple array, not an object: `Object.entries` would widen the keys
60
- // back to `string` and stop checking the pairing.
61
- const wrong = [
62
- ["connectedCallback", "connect"],
63
- ["disconnectedCallback", "disconnect"],
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
- // Caught so one broken element logs and sits inert rather than taking
104
- // its siblings with it — which is what lets `require` throw.
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
- try {
124
- // Before `#ac` is cleared, so `this.signal` is readable and already
125
- // aborted: an async continuation can check it and bail.
126
- this.disconnect();
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
- /** Moving to another document ends the old document's connection. */
135
- adoptedCallback(): void {
136
- this.disconnectedCallback();
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
- #report(error: unknown): void {
140
- if (import.meta.env.DEV) {
141
- reportDevError(this.localName, error);
142
- return;
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
- console.error(`${this.localName}:`, error);
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
  }
@@ -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
- /** Keys the component object uses itself, so no role may take them. */
518
- type Reserved = "tag" | "define";
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(this); faq.define(Faq);
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: these two would overwrite
583
- // the component's own keys and leave nothing to define the element with.
584
- if (name === "tag" || name === "define") {
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: `${lines.join("\n").trimEnd()}\n`,
165
+ body: `\uFEFF${lines.join("\n").trimEnd()}\n`,
158
166
  contentType: "text/markdown; charset=utf-8",
159
167
  };
160
168
  }