@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.
- package/docs/client-scripts.md +27 -3
- package/package.json +2 -1
- package/src/astro/client.ts +23 -0
- package/src/astro/element.ts +1 -27
- package/src/astro/markup.ts +54 -6
package/docs/client-scripts.md
CHANGED
|
@@ -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`,
|
|
412
|
-
`optional`. A `flag` is off by being absent, never `"false"`, so a
|
|
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.
|
|
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/element.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/astro/markup.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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: "
|
|
155
|
-
?
|
|
156
|
-
:
|
|
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
|
-
?
|
|
168
|
-
:
|
|
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
|
|