@escape-game-over/atlas 0.1.22 → 0.1.23

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.
@@ -81,12 +81,15 @@ what it registered when the element leaves, and what it returns is the undo for
81
81
  everything else.
82
82
 
83
83
  ```ts
84
- defineElement("atlas-thing", (host, signal) => {
84
+ defineElement(thing, (host, signal) => {
85
85
  host.addEventListener("click", open, { signal }); // ends with the visit
86
86
  return loop.attach(host); // and so does this
87
87
  });
88
88
  ```
89
89
 
90
+ It takes the component rather than a tag name, so the element it registers and
91
+ the roles it hands the function are the same contract the template spread.
92
+
90
93
  An element can enter the page more than once — moving it in the DOM runs the
91
94
  undo and then the function again — so it has to be able to run twice. State that
92
95
  must survive a move goes in a `WeakMap` keyed by `host`, which is what the
@@ -472,15 +475,15 @@ export const faq = component("go-faq", {
472
475
  ```
473
476
 
474
477
  ```astro
475
- <faq.tag>
478
+ <faq.tag {...faq.root}>
476
479
  <input {...faq.search.attrs()} type="search">
477
480
  <details {...faq.row.attrs({ key, text })}>…</details>
478
481
  </faq.tag>
479
482
  ```
480
483
 
481
484
  ```ts
482
- defineElement(faq.tag, (host, signal) => {
483
- for (const { element, values } of faq.row.all(host)) { … }
485
+ defineElement(faq, (host, signal, { row }) => {
486
+ for (const { element, values } of row.all()) { … }
484
487
  });
485
488
  ```
486
489
 
@@ -494,8 +497,8 @@ defineElement(faq.tag, (host, signal) => {
494
497
  keeps its elements, and a lookup from an element inside the instance, like a
495
498
  dropdown's own panel, still counts as that instance. Elements are filtered
496
499
  before they are read, so a broken nested copy cannot fail the outer lookup.
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
500
+ - **The tag is written once.** `faq.tag` is what the template renders, and the
501
+ component itself is what `defineElement` registers the behaviour under. A contract stays inert either
499
502
  way: it names things, and nothing in it touches a document, which is why
500
503
  frontmatter can import it.
501
504
  - **The names are checked in the editor.** A tag without a hyphen, or a role
@@ -507,9 +510,17 @@ defineElement(faq.tag, (host, signal) => {
507
510
  - **Most roles carry nothing**, and say so: `search: marker` rather than
508
511
  `search: {}`, which reads like options somebody forgot to fill in.
509
512
 
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`.
513
+ - **The roles arrive bound to the instance.** `defineElement` takes the
514
+ component, so `all`, `one` and `require` need no root: there is no way to
515
+ write `faq.row.all(document)`, which compiles and returns the roles belonging
516
+ to no instance at all.
517
+ - **The root says it is one.** `{...faq.root}` writes `data-atlas-root`, so a
518
+ project gives every root a display in one stylesheet rule rather than a class
519
+ per component. Forget it and the element says so on the dev server.
520
+
521
+ `tag` and `root` are the component's own keys, so no role may take them. A role
522
+ that lives outside every instance, such as a footer button that reopens a
523
+ banner, is still plain `markup`.
513
524
 
514
525
  ## `youtube`
515
526
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -59,9 +59,9 @@
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "2.5.13",
61
61
  "@types/node": "26.5.1",
62
- "@vitest/coverage-istanbul": "5.0.0",
62
+ "@vitest/coverage-istanbul": "5.0.1",
63
63
  "astro": "7.3.2",
64
64
  "typescript": "6.0.3",
65
- "vitest": "5.0.0"
65
+ "vitest": "5.0.1"
66
66
  }
67
67
  }
@@ -1,4 +1,6 @@
1
1
  import { reportDevError } from "./dev-log.ts";
2
+ import type { Marked, Markup } from "./markup.ts";
3
+ import { ROOT_ATTRIBUTE } from "./markup.ts";
2
4
 
3
5
  /**
4
6
  * What is left to undo when the element leaves, or nothing.
@@ -10,43 +12,87 @@ import { reportDevError } from "./dev-log.ts";
10
12
  // biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
11
13
  type Undo = void | (() => void);
12
14
 
15
+ /** One role's lookups, already pointed at the element they belong to. */
16
+ type Bound<M> =
17
+ M extends Markup<infer F>
18
+ ? {
19
+ all<T extends Element = HTMLElement>(): Marked<T, F>[];
20
+ one<T extends Element = HTMLElement>(): Marked<T, F> | null;
21
+ require<T extends Element = HTMLElement>(): Marked<T, F>;
22
+ }
23
+ : never;
24
+
25
+ /**
26
+ * Every role of a component, bound to one instance.
27
+ *
28
+ * The root is not a parameter here, which is the point: `faq.row.all(document)`
29
+ * compiles and quietly returns the roles that belong to *no* instance, and this
30
+ * removes the chance to write it.
31
+ */
32
+ export type Roles<C> = {
33
+ readonly [K in Exclude<keyof C, "tag" | "root">]: Bound<C[K]>;
34
+ };
35
+
13
36
  /**
14
37
  * What an element does while it is on the page.
15
38
  *
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.
39
+ * `host` is the element itself, `signal` is aborted when it leaves — so
40
+ * anything registered with `signal` comes down on its own — and `roles` are
41
+ * this instance's. Whatever else has to be undone is the returned function,
42
+ * which runs at the same moment.
19
43
  */
20
- export type Connect = (host: HTMLElement, signal: AbortSignal) => Undo;
44
+ export type Connect<C> = (
45
+ host: HTMLElement,
46
+ signal: AbortSignal,
47
+ roles: Roles<C>
48
+ ) => Undo;
49
+
50
+ /** The shape this file needs off a role, without importing its field types. */
51
+ interface AnyRole {
52
+ all(root: ParentNode): unknown;
53
+ one(root: ParentNode): unknown;
54
+ require(root: ParentNode): unknown;
55
+ }
21
56
 
22
57
  /**
23
- * Registers a custom element whose behaviour is one function.
58
+ * Registers a component's custom element, with its behaviour as one function.
24
59
  *
25
60
  * ```ts
26
- * defineElement("atlas-thing", (host, signal) => {
27
- * host.querySelector("form")?.addEventListener("submit", send, { signal });
61
+ * export const faq = component("go-faq", { row: { key: { kind: "text" } } });
62
+ *
63
+ * defineElement(faq, (host, signal, { row }) => {
64
+ * for (const { element, values } of row.all()) { … }
28
65
  * return loop.attach(host); // runs when the element leaves the page
29
66
  * });
30
67
  * ```
31
68
  *
32
69
  * - **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.
70
+ * and then `connect` again, so `connect` has to be able to run twice. Astro's
71
+ * `ClientRouter` connects a persisted element three times per navigation.
72
+ * State that must survive belongs in a `WeakMap` keyed by `host`.
36
73
  * - **The defining script must stay a deferred module.** Astro emits
37
74
  * `<script src>` as `type="module"`, so the element has its children by the
38
75
  * time it is upgraded. `is:inline` runs it too early and every lookup inside
39
76
  * the element finds nothing.
40
77
  * - **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.
78
+ * rather than taking its siblings down with it.
43
79
  *
44
80
  * See docs/client-scripts.md.
45
81
  */
46
- export function defineElement(tag: string, connect: Connect): void {
82
+ export function defineElement<C extends { readonly tag: string }>(
83
+ component: C,
84
+ connect: Connect<C>
85
+ ): void {
86
+ const { tag } = component;
87
+
88
+ // Read once: the roles are fixed when the component is built, and only the
89
+ // element they point at changes.
90
+ const roleNames = Object.keys(component).filter(
91
+ (key) => key !== "tag" && key !== "root"
92
+ );
93
+
47
94
  // 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.
95
+ // is only read in a browser.
50
96
  customElements.define(
51
97
  tag,
52
98
  class extends HTMLElement {
@@ -68,7 +114,16 @@ export function defineElement(tag: string, connect: Connect): void {
68
114
  const ac = new AbortController();
69
115
  this.#ac = ac;
70
116
  try {
71
- this.#undo = connect(this, ac.signal) ?? undefined;
117
+ if (
118
+ import.meta.env.DEV &&
119
+ !this.hasAttribute(ROOT_ATTRIBUTE)
120
+ ) {
121
+ throw new Error(
122
+ `<${tag}> carries no ${ROOT_ATTRIBUTE} — does the template spread {...x.root} on it?`
123
+ );
124
+ }
125
+ this.#undo =
126
+ connect(this, ac.signal, this.#roles()) ?? undefined;
72
127
  } catch (error) {
73
128
  this.#report(error);
74
129
  }
@@ -83,6 +138,22 @@ export function defineElement(tag: string, connect: Connect): void {
83
138
  this.#end();
84
139
  }
85
140
 
141
+ /** This instance's roles: the component's, with the root supplied. */
142
+ #roles(): Roles<C> {
143
+ const bound: Record<string, unknown> = {};
144
+ for (const name of roleNames) {
145
+ const role = (component as Record<string, unknown>)[
146
+ name
147
+ ] as AnyRole;
148
+ bound[name] = {
149
+ all: () => role.all(this),
150
+ one: () => role.one(this),
151
+ require: () => role.require(this),
152
+ };
153
+ }
154
+ return bound as Roles<C>;
155
+ }
156
+
86
157
  #end(): void {
87
158
  // Aborted before the undo runs, so an async continuation that
88
159
  // checks the signal can see the visit is over.
@@ -514,13 +514,28 @@ 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
- /** The key the component object uses itself, so no role may take it. */
518
- type Reserved = "tag";
517
+ /** The keys the component object uses itself, so no role may take them. */
518
+ type Reserved = "tag" | "root";
519
+
520
+ /**
521
+ * What marks a component's root element.
522
+ *
523
+ * One name, so a project gives every root a display in a single stylesheet rule
524
+ * rather than a class per component, and so `defineElement` can say when a
525
+ * template forgot to spread it.
526
+ */
527
+ export const ROOT_ATTRIBUTE = "data-atlas-root";
519
528
 
520
529
  export type Component<Tag extends string, R extends ComponentRoles> = {
521
530
  /** The custom element's name: what the template renders, and what
522
531
  * `defineElement` registers the behaviour under. */
523
532
  readonly tag: Tag;
533
+
534
+ /**
535
+ * What the root element carries, spread by the template that renders it:
536
+ * `<faq.tag {...faq.root}>`.
537
+ */
538
+ readonly root: Readonly<Record<string, string>>;
524
539
  } & { readonly [K in keyof R]: Markup<R[K]> };
525
540
 
526
541
  /**
@@ -533,8 +548,8 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
533
548
  * search: {},
534
549
  * });
535
550
  *
536
- * // template: <faq.tag> … <details {...faq.row.attrs({ key, text })}>
537
- * // script: defineElement(faq.tag, (host) => faq.row.all(host).forEach(…));
551
+ * // template: <faq.tag {...faq.root}> … <details {...faq.row.attrs({ key, text })}>
552
+ * // script: defineElement(faq, (host, signal, { row }) => row.all().forEach(…));
538
553
  * ```
539
554
  *
540
555
  * Two things `markup` alone leaves to the project:
@@ -630,7 +645,10 @@ export function component<
630
645
  };
631
646
  };
632
647
 
633
- const built: Record<string, unknown> = { tag };
648
+ const built: Record<string, unknown> = {
649
+ tag,
650
+ root: { [ROOT_ATTRIBUTE]: "" },
651
+ };
634
652
  for (const [name, fields] of Object.entries(roles)) {
635
653
  built[name] = scoped(name, fields);
636
654
  }