@escape-game-over/atlas 0.1.19 → 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.
@@ -476,6 +498,8 @@ faq.define(Faq);
476
498
  validated at run time, because a name that reached the browser malformed would
477
499
  already have been refused there: by `setAttribute`, by `querySelectorAll`, or
478
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.
479
503
 
480
504
  `tag` and `define` are the component's own keys, so no role may take them. A role
481
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.19",
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";
@@ -1,5 +1,4 @@
1
1
  import { reportDevError } from "./dev-log.ts";
2
- import { within } from "./dom.ts";
3
2
 
4
3
  /**
5
4
  * A base for custom elements: two lifetimes, and one abort signal.
@@ -8,7 +7,7 @@ import { within } from "./dom.ts";
8
7
  * class Thing extends AtlasElement {
9
8
  * protected setup(): void { … } // once, ever: what the element is
10
9
  * protected connect(): void { // every connection: bind with signal
11
- * this.require("form").addEventListener("submit", send, {
10
+ * this.querySelector("form")?.addEventListener("submit", send, {
12
11
  * signal: this.signal,
13
12
  * });
14
13
  * }
@@ -153,29 +152,4 @@ export abstract class AtlasElement extends HTMLElement {
153
152
 
154
153
  /** Runs on every disconnect, once the signal has been aborted. */
155
154
  protected disconnect(): void {}
156
-
157
- /** The first match inside this element, or `null`. */
158
- protected one<T extends Element = HTMLElement>(selector: string): T | null {
159
- return within(this).one<T>(selector);
160
- }
161
-
162
- /** Every match inside this element, as an array. */
163
- protected all<T extends Element = HTMLElement>(selector: string): T[] {
164
- return within(this).all<T>(selector);
165
- }
166
-
167
- /**
168
- * The first match, or a thrown error naming what was missing. Throws rather
169
- * than returning `null`, because the `?.` at every call site swallows the
170
- * failure as thoroughly as the missing element did.
171
- */
172
- protected require<T extends Element = HTMLElement>(selector: string): T {
173
- const found = this.one<T>(selector);
174
- if (!found) {
175
- throw new Error(
176
- `nothing matched "${selector}" — is the script still a deferred module?`
177
- );
178
- }
179
- return found;
180
- }
181
155
  }
@@ -123,6 +123,9 @@ type CheckedKeys<T> = {
123
123
  * - `text` — any string, the empty one included.
124
124
  * - `number` — finite only.
125
125
  * - `list` — several words, space-separated, as `class` is.
126
+ * - `template` — a sentence with `{placeholders}` in it, read back as the
127
+ * function that fills them. A template that never says one of its own
128
+ * `params` fails the build, rather than shipping a brace to a reader.
126
129
  * - `choice` — one of `of`, checked at compile time and again when read.
127
130
  * - `flag` — present or absent. Off is the attribute left out, never `"false"`,
128
131
  * so a stylesheet can select it with a bare `[data-…]`.
@@ -134,6 +137,11 @@ export type MarkupField =
134
137
  | { readonly kind: "text"; readonly optional?: boolean }
135
138
  | { readonly kind: "number"; readonly optional?: boolean }
136
139
  | { readonly kind: "list"; readonly optional?: boolean }
140
+ | {
141
+ readonly kind: "template";
142
+ readonly params: readonly [string, ...string[]];
143
+ readonly optional?: boolean;
144
+ }
137
145
  | {
138
146
  readonly kind: "choice";
139
147
  readonly of: readonly [string, ...string[]];
@@ -144,16 +152,36 @@ export type MarkupField =
144
152
  /** The fields of one role, named by the caller. */
145
153
  export type MarkupFields = Readonly<Record<string, MarkupField>>;
146
154
 
147
- /** The value a field holds when present. */
155
+ /**
156
+ * A role that carries nothing: `search: marker`, where `search: {}` reads like
157
+ * options somebody forgot to fill in. Most roles are these — they name an
158
+ * element the script has to find, and hold no values.
159
+ */
160
+ export const marker = {} as const;
161
+
162
+ /** What `attrs` takes for a field, and what lands in the attribute. */
148
163
  type Held<F extends MarkupField> = F extends { kind: "text" }
149
164
  ? string
150
165
  : F extends { kind: "number" }
151
166
  ? number
152
167
  : F extends { kind: "list" }
153
168
  ? readonly string[]
154
- : F extends { kind: "choice"; of: readonly (infer V)[] }
155
- ? V
156
- : boolean;
169
+ : F extends { kind: "template" }
170
+ ? string
171
+ : F extends { kind: "choice"; of: readonly (infer V)[] }
172
+ ? V
173
+ : boolean;
174
+
175
+ /**
176
+ * What a script reads back — the same, except that a `template` arrives as the
177
+ * function that fills it, so no script spells a placeholder.
178
+ */
179
+ type Read<F extends MarkupField> = F extends {
180
+ kind: "template";
181
+ params: readonly (infer P)[];
182
+ }
183
+ ? (values: Record<P & string, string>) => string
184
+ : Held<F>;
157
185
 
158
186
  /** Whether a template may leave the field out. */
159
187
  type Omittable<F extends MarkupField> = F extends { kind: "flag" }
@@ -164,8 +192,8 @@ type Omittable<F extends MarkupField> = F extends { kind: "flag" }
164
192
 
165
193
  /** One field's value as a script reads it. */
166
194
  export type MarkupValue<F extends MarkupField> = F extends { optional: true }
167
- ? Held<F> | undefined
168
- : Held<F>;
195
+ ? Read<F> | undefined
196
+ : Read<F>;
169
197
 
170
198
  /** Every field's value, as a script reads it. */
171
199
  export type MarkupValues<F extends MarkupFields> = {
@@ -281,6 +309,19 @@ function encode(
281
309
  return typeof value === "string" && field.of.includes(value)
282
310
  ? value
283
311
  : refuse(`expected one of ${field.of.join(", ")}`);
312
+ case "template": {
313
+ if (typeof value !== "string") {
314
+ return refuse("it is a template field");
315
+ }
316
+ for (const param of field.params) {
317
+ if (!value.includes(`{${param}}`)) {
318
+ refuse(
319
+ `it never says {${param}}, so that value would have nowhere to go`
320
+ );
321
+ }
322
+ }
323
+ return value;
324
+ }
284
325
  case "list": {
285
326
  if (!Array.isArray(value)) return refuse("it is a list field");
286
327
  for (const item of value) {
@@ -331,6 +372,13 @@ function decode(
331
372
  return raw;
332
373
  case "list":
333
374
  return raw.split(/\s+/).filter((item) => item !== "");
375
+ case "template":
376
+ return (values: Record<string, string>) =>
377
+ field.params.reduce(
378
+ (text, param) =>
379
+ text.replaceAll(`{${param}}`, values[param] ?? ""),
380
+ raw
381
+ );
334
382
  }
335
383
  }
336
384