@escape-game-over/atlas 0.1.23 → 0.1.25
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/README.md +29 -45
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +73 -141
- package/docs/rich-text.md +25 -20
- package/package.json +5 -12
- package/src/analytics/google.ts +6 -6
- package/src/analytics/index.ts +4 -3
- package/src/analytics/tags.ts +14 -60
- package/src/analytics/umami.ts +8 -8
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +61 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +102 -0
- package/src/astro/MetaTags.astro +3 -26
- package/src/astro/RichText.astro +71 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +19 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +111 -112
- package/src/astro/filters-view.ts +48 -64
- package/src/astro/filters.ts +46 -37
- package/src/astro/images.ts +27 -26
- package/src/astro/index.ts +2 -9
- package/src/astro/markup.ts +6 -6
- package/src/astro/site-routes.ts +10 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +2 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +58 -29
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +41 -57
- package/src/jsonld/faq.ts +2 -1
- package/src/jsonld/node.ts +4 -14
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +6 -19
- package/src/meta/tag.ts +1 -45
- package/src/money.ts +161 -6
- package/src/project.ts +84 -73
- package/src/routes/define.ts +8 -44
- package/src/routes/resolve.ts +1 -1
- package/src/site/api.ts +7 -33
- package/src/site/create.ts +6 -10
- package/src/site/define.ts +120 -0
- package/src/site/index.ts +2 -5
- package/src/site/page.ts +4 -2
- package/src/sitemap.ts +2 -35
- package/src/warn.ts +16 -17
- package/src/astro/dom.ts +0 -35
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Linking to a page the active deployment switched off is a compile error, not a
|
|
|
61
61
|
One file per deployment, saying only what differs:
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
|
-
export default
|
|
64
|
+
export default atlas.project(defaultMessages, defaultRoutes, {
|
|
65
65
|
url: "https://acme.example",
|
|
66
66
|
siteName: "Acme Rome",
|
|
67
67
|
icon,
|
|
@@ -92,22 +92,22 @@ never writes a type argument:
|
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
94
|
// 1. which languages exist, how URLs are shaped
|
|
95
|
-
export
|
|
95
|
+
export const atlas = defineSite({ locales: {...}, defaultRouting: {...} })
|
|
96
96
|
|
|
97
97
|
// 2. the base copy and routes
|
|
98
|
-
export const baseMessages =
|
|
99
|
-
export const baseRoutes =
|
|
98
|
+
export const baseMessages = atlas.messages({...})
|
|
99
|
+
export const baseRoutes = atlas.routes({...})
|
|
100
100
|
|
|
101
101
|
// 3. one deployment
|
|
102
|
-
export default
|
|
102
|
+
export default atlas.project(baseMessages, baseRoutes, {...})
|
|
103
103
|
|
|
104
104
|
// 4. the API the pages use
|
|
105
|
-
export const site =
|
|
105
|
+
export const site = atlas.site(baseMessages, baseRoutes, project)
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
|
|
109
|
-
`
|
|
110
|
-
`
|
|
109
|
+
`localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
|
|
110
|
+
`getStaticPaths()`, `routes`, `sitemap()`, `robots()`, `llms()` and
|
|
111
111
|
`redirects()` already wired.
|
|
112
112
|
|
|
113
113
|
`t()` is bound to a locale and knows each message's `{placeholders}` from its
|
|
@@ -166,7 +166,8 @@ route id or a URL. A slot is filled at the call site, where it is checked agains
|
|
|
166
166
|
the routes this deployment builds and written once instead of once per language.
|
|
167
167
|
|
|
168
168
|
`site.plain(locale)` reads the same message as words alone, for a
|
|
169
|
-
`<meta description>` or `llms.txt
|
|
169
|
+
`<meta description>` or `llms.txt`, and `html(rich(…))` as inline markup for a
|
|
170
|
+
structured-data answer; `t()` refuses a message with marks rather
|
|
170
171
|
than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
|
|
171
172
|
|
|
172
173
|
## What the build emits, and who asks for it
|
|
@@ -232,7 +233,7 @@ worded.
|
|
|
232
233
|
The same question answers it every time: *would this differ between two pages of
|
|
233
234
|
the same site?*
|
|
234
235
|
|
|
235
|
-
| Per page — passed to `metaFor()` | Site-level — declared in `
|
|
236
|
+
| Per page — passed to `metaFor()` | Site-level — declared in `atlas.project()` |
|
|
236
237
|
| -------------------------------- | ------------------------------------------ |
|
|
237
238
|
| `title`, `description` | `siteName`, `twitterSite` |
|
|
238
239
|
| `image` (asset **and** its alt) | `icon`, `themeColor`, `colorScheme` |
|
|
@@ -250,16 +251,9 @@ actually renders, and why `twitter:card` is pinned.
|
|
|
250
251
|
|
|
251
252
|
A project switches routes off. Anything defined *alongside* a route then has two
|
|
252
253
|
ways to rot: the page is built and its config is missing, or the page is gone and
|
|
253
|
-
its config lingers.
|
|
254
|
+
its config lingers. `PerRoute<typeof site, T>` rejects both.
|
|
254
255
|
|
|
255
|
-
|
|
256
|
-
| --------------------- | ------------------------------------ | ------------------------------------------------ |
|
|
257
|
-
| Shape | a table keyed by route id | a single value |
|
|
258
|
-
| Says | "every built route has one of these" | "this value belongs to *that* page" |
|
|
259
|
-
| When the route is off | that key is rejected | the type is `never` — the field cannot be filled |
|
|
260
|
-
| Use with | `satisfies` | a normal annotation |
|
|
261
|
-
|
|
262
|
-
`PerRoute` must be used with `satisfies`, not as an annotation: excess-property
|
|
256
|
+
It must be used with `satisfies`, not as an annotation: excess-property
|
|
263
257
|
checking is what catches the stale half, and only an object literal checked
|
|
264
258
|
against a known target gets it.
|
|
265
259
|
|
|
@@ -268,8 +262,6 @@ const heroes = {
|
|
|
268
262
|
home: { variant: "wide" },
|
|
269
263
|
about: { variant: "tall" },
|
|
270
264
|
} satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
|
|
271
|
-
|
|
272
|
-
const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; // ✗ if off
|
|
273
265
|
```
|
|
274
266
|
|
|
275
267
|
## The rules this package keeps
|
|
@@ -298,16 +290,8 @@ const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; /
|
|
|
298
290
|
".": "./src/index.ts",
|
|
299
291
|
"./astro": "./src/astro/index.ts",
|
|
300
292
|
"./astro/images": "./src/astro/images.ts",
|
|
301
|
-
"./astro/
|
|
302
|
-
"./
|
|
303
|
-
"./astro/consent": "./src/astro/consent.ts",
|
|
304
|
-
"./astro/dev-log": "./src/astro/dev-log.ts",
|
|
305
|
-
"./astro/dom": "./src/astro/dom.ts",
|
|
306
|
-
"./astro/element": "./src/astro/element.ts",
|
|
307
|
-
"./astro/filters": "./src/astro/filters.ts",
|
|
308
|
-
"./astro/filters-view": "./src/astro/filters-view.ts",
|
|
309
|
-
"./astro/meta-tags": "./src/astro/MetaTags.astro",
|
|
310
|
-
"./astro/youtube": "./src/astro/youtube.ts"
|
|
293
|
+
"./astro/document": "./src/astro/Document.astro",
|
|
294
|
+
"./client": "./src/astro/client.ts"
|
|
311
295
|
}
|
|
312
296
|
```
|
|
313
297
|
|
|
@@ -323,27 +307,27 @@ may use `node:` built-ins but not `astro:assets`. `./astro/images` is the
|
|
|
323
307
|
opposite: it runs inside the build, from a page, and is unusable from a config.
|
|
324
308
|
Merging them breaks whichever caller loads first.
|
|
325
309
|
|
|
326
|
-
|
|
327
|
-
`youtube`, `background-video`, `consent`, `element
|
|
328
|
-
|
|
329
|
-
than in the build, and is separate again for the same reason: none of it can be
|
|
330
|
-
reached from a config, and none of it draws. See
|
|
310
|
+
`./client` is the browser half — `carousel`, `filters`, `filters-view`,
|
|
311
|
+
`youtube`, `background-video`, `consent`, `element` — one path
|
|
312
|
+
for all of it. See
|
|
331
313
|
[`docs/client-scripts.md`](docs/client-scripts.md).
|
|
332
314
|
|
|
333
315
|
**The package ships TypeScript source, and there is no build step.**
|
|
334
|
-
`
|
|
316
|
+
`Document.astro` could not go through `tsc` anyway, and Astro's own
|
|
335
317
|
`tsconfigs/base.json` already sets `allowImportingTsExtensions`, so every
|
|
336
318
|
consumer gets it for free.
|
|
337
319
|
|
|
338
320
|
## The `atlas` command
|
|
339
321
|
|
|
340
322
|
```bash
|
|
341
|
-
atlas use <project> #
|
|
323
|
+
atlas use <project> # links config/project -> config/projects/<name>
|
|
342
324
|
atlas use --fallback rome # only when nothing else named one
|
|
343
325
|
```
|
|
344
326
|
|
|
345
|
-
|
|
346
|
-
|
|
327
|
+
A link, not a copy: an edit made through `config/project` lands in the real
|
|
328
|
+
file. A directory junction on Windows, which needs no admin rights. Ignore it with
|
|
329
|
+
`**/config/project` — no trailing slash, since git sees a link as a file. The
|
|
330
|
+
contract is just that the project contains `project.ts`.
|
|
347
331
|
It resolves paths from the working directory, so a workspace's own
|
|
348
332
|
`package.json` passes nothing. A deployment pipeline can skip it and write its
|
|
349
333
|
own `config/project`.
|
|
@@ -357,7 +341,7 @@ command whose output gets deployed has to say what it is building.
|
|
|
357
341
|
```txt
|
|
358
342
|
src/ the package — see the export map above for what is reachable
|
|
359
343
|
index.ts the public API
|
|
360
|
-
site/ createSite() and the Site it returns
|
|
344
|
+
site/ defineSite(), createSite() and the Site it returns
|
|
361
345
|
meta/ head tags: canonical, hreflang, Open Graph, Twitter, robots
|
|
362
346
|
jsonld/ the @graph — one file per node, each linked to Google's docs
|
|
363
347
|
i18n/ defineMessages(), t(), placeholder extraction
|
|
@@ -399,9 +383,9 @@ proves the package is genuinely independent of anything consuming it.
|
|
|
399
383
|
|
|
400
384
|
The `astro check` pass covers `src/astro/`, the one directory that cannot meet
|
|
401
385
|
that bar. It needs `astro/client` for `astro:assets` and `ImageMetadata`, and
|
|
402
|
-
|
|
403
|
-
zero `.astro` files no matter how they are globbed. Without this pass the
|
|
404
|
-
|
|
386
|
+
the `.astro` components need a checker that can parse `.astro` at all — `tsc` reads
|
|
387
|
+
zero `.astro` files no matter how they are globbed. Without this pass the
|
|
388
|
+
components the package ships are checked by nothing: `astro check` in
|
|
405
389
|
`test:examples` only ever walks an example's own `src/`, so it never reaches it,
|
|
406
390
|
and a type error there passes the entire gate.
|
|
407
391
|
|
|
@@ -411,7 +395,7 @@ practical reason: `astro check` writes a `.astro/` types directory and a
|
|
|
411
395
|
publish both. The `tsconfig.json` there adds nothing — it extends
|
|
412
396
|
`src/astro/tsconfig.json` and only widens the glob, so the rules stay in one
|
|
413
397
|
place. `src/astro/tsconfig.json` is what an editor finds; without it, opening
|
|
414
|
-
`
|
|
398
|
+
`Document.astro` resolves against the root config, which supplies no ambient
|
|
415
399
|
types, and every prop degrades to `any`.
|
|
416
400
|
|
|
417
401
|
`test:examples` **builds every deployment**, not just type-checks one, and both
|
package/bin/use-project.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Links `config/project` to one project overlay, the folder the whole build
|
|
4
4
|
* reads from. Run automatically before `dev`, `build` and `check`.
|
|
5
5
|
*
|
|
6
|
+
* A link rather than a copy, so an edit made through `config/project` — where
|
|
7
|
+
* an editor's go-to-definition lands — is an edit to the real file. A
|
|
8
|
+
* junction on Windows, which needs no admin rights; a symlink elsewhere.
|
|
9
|
+
*
|
|
6
10
|
* atlas use acme
|
|
7
11
|
* PROJECT=acme npm run build
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* own `config/project` — the contract is just that it contains `project.ts`.
|
|
13
|
+
* A deployment pipeline can skip this command and write its own
|
|
14
|
+
* `config/project` — the contract is just that it contains `project.ts`.
|
|
12
15
|
*/
|
|
13
|
-
import {
|
|
16
|
+
import { lstat, readdir, rm, stat, symlink, unlink } from "node:fs/promises";
|
|
14
17
|
import { join } from "node:path";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -26,10 +29,7 @@ import { join } from "node:path";
|
|
|
26
29
|
const ROOT = process.cwd();
|
|
27
30
|
const PROJECTS_DIR = join(ROOT, "config", "projects");
|
|
28
31
|
const TARGET_DIR = join(ROOT, "config", "project");
|
|
29
|
-
/**
|
|
30
|
-
* Only an entry-point check — the whole directory is copied recursively, so a
|
|
31
|
-
* project can add files and folders freely without touching this script.
|
|
32
|
-
*/
|
|
32
|
+
/** Only an entry-point check: a project can add files and folders freely. */
|
|
33
33
|
const ENTRY_FILE = "project.ts";
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -122,10 +122,15 @@ if (!(await exists(join(sourceDir, ENTRY_FILE)))) {
|
|
|
122
122
|
process.exit(1);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
await
|
|
125
|
+
// Only the link goes, never what it points at. A real directory is a copy left
|
|
126
|
+
// by an older version of this command.
|
|
127
|
+
const existing = await lstat(TARGET_DIR).catch(() => undefined);
|
|
128
|
+
if (existing?.isSymbolicLink()) await unlink(TARGET_DIR);
|
|
129
|
+
else if (existing !== undefined) await rm(TARGET_DIR, { recursive: true });
|
|
130
|
+
|
|
131
|
+
// "junction" is Windows' no-admin directory link, and ignored everywhere else.
|
|
132
|
+
await symlink(sourceDir, TARGET_DIR, "junction");
|
|
128
133
|
|
|
129
134
|
console.log(
|
|
130
|
-
`Using project "${name}" (config/projects/${name}
|
|
135
|
+
`Using project "${name}" (config/project -> config/projects/${name})`
|
|
131
136
|
);
|
package/docs/NOT-BUILT.md
CHANGED
|
@@ -230,7 +230,7 @@ type could — a property that is not valid for the type it sits on.
|
|
|
230
230
|
### Should a paragraph be authored as an array of translation keys?
|
|
231
231
|
|
|
232
232
|
**No. One sentence is one message, and the styling lives in the copy as
|
|
233
|
-
`[marks]`. `content/` carries the parser, the runs and `plain()`; nothing
|
|
233
|
+
`[marks]`. `content/` carries the parser, the runs and `site.plain()`; nothing
|
|
234
234
|
carries a `ContentItem[]`.**
|
|
235
235
|
|
|
236
236
|
This is the shape the sibling B2C template uses, and it is the one thing from
|
package/docs/client-scripts.md
CHANGED
|
@@ -32,33 +32,26 @@ the referrer policy — and everything that does, classes above all, comes from
|
|
|
32
32
|
caller through a callback. It too is handed the button and the box rather than
|
|
33
33
|
finding them.
|
|
34
34
|
|
|
35
|
-
| Module
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
the banner that is rendered. See NOT-BUILT.md on where the halves divide.
|
|
56
|
-
|
|
57
|
-
**A script imports one path: `@escape-game-over/atlas/client`**, which re-exports
|
|
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.
|
|
35
|
+
| Module | Owns | Leaves to the project |
|
|
36
|
+
| ------------------ | -------------------------------------------------------------- | ----------------------------------------------- |
|
|
37
|
+
| `carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
|
|
38
|
+
| `filters` | which items match, and what the address bar says | every DOM read and write |
|
|
39
|
+
| `filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
|
|
40
|
+
| `youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
|
|
41
|
+
| `background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
|
|
42
|
+
| `consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
|
|
43
|
+
| `element` | registering a custom element, and ending what it started | what the element does while it is on the page |
|
|
44
|
+
|
|
45
|
+
`consent` is the one module that ships its own behaviour: `<ConsentBanner
|
|
46
|
+
analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`) renders the
|
|
47
|
+
site's markup as its children, skips itself — script included — when the
|
|
48
|
+
analytics need no permission, and remembers, expires and applies the answer. The
|
|
49
|
+
markup spreads `consent.accept` and `consent.decline` on its buttons, and
|
|
50
|
+
`consent.reopen` on the control that brings it back.
|
|
51
|
+
|
|
52
|
+
**Browser code has one import path: `@escape-game-over/atlas/client`**, which
|
|
53
|
+
re-exports every module above. None of it touches the DOM at import, so an
|
|
54
|
+
element's contract can be imported in frontmatter from the same path.
|
|
62
55
|
|
|
63
56
|
## Lifetimes are the recurring bug
|
|
64
57
|
|
|
@@ -75,21 +68,17 @@ const detach = loop.attach(video); // background-video: the <video>
|
|
|
75
68
|
const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
|
|
76
69
|
```
|
|
77
70
|
|
|
78
|
-
`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
everything else.
|
|
71
|
+
`element` says the same thing in the shape a custom element needs: its function
|
|
72
|
+
runs when the element enters the page, `signal` ends what it registered when the
|
|
73
|
+
element leaves, and what it returns is the undo for everything else.
|
|
82
74
|
|
|
83
75
|
```ts
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return loop.attach(host);
|
|
76
|
+
export const thing = element("go-thing", { button: marker }, (host, { button }, signal) => {
|
|
77
|
+
button.one()?.addEventListener("click", open, { signal }); // ends with the visit
|
|
78
|
+
return loop.attach(host); // and so does this
|
|
87
79
|
});
|
|
88
80
|
```
|
|
89
81
|
|
|
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
|
-
|
|
93
82
|
An element can enter the page more than once — moving it in the DOM runs the
|
|
94
83
|
undo and then the function again — so it has to be able to run twice. State that
|
|
95
84
|
must survive a move goes in a `WeakMap` keyed by `host`, which is what the
|
|
@@ -114,9 +103,9 @@ the URL. The caller declares fields; the item shape follows from them.
|
|
|
114
103
|
```ts
|
|
115
104
|
const list = filters({
|
|
116
105
|
fields: {
|
|
117
|
-
q: { kind: "text"
|
|
118
|
-
category: { kind: "choice"
|
|
119
|
-
featured: { kind: "flag"
|
|
106
|
+
q: { kind: "text" },
|
|
107
|
+
category: { kind: "choice" },
|
|
108
|
+
featured: { kind: "flag" },
|
|
120
109
|
},
|
|
121
110
|
items: entries.map((entry) => ({
|
|
122
111
|
key: entry.id,
|
|
@@ -186,7 +175,7 @@ pass. What is deliberately absent is one field holding several selected values;
|
|
|
186
175
|
that is a `choices` kind, and it forces a URL encoding decision (repeated
|
|
187
176
|
parameters or comma-joined) that nothing has needed yet.
|
|
188
177
|
|
|
189
|
-
`param`
|
|
178
|
+
`param` defaults to the field's name. `param: false` keeps a field state-only, out of the URL.
|
|
190
179
|
|
|
191
180
|
### Facet counts: `matchedWithout`
|
|
192
181
|
|
|
@@ -278,25 +267,23 @@ The twelve lines every one of the three wrote around its search field, byte for
|
|
|
278
267
|
byte.
|
|
279
268
|
|
|
280
269
|
```ts
|
|
281
|
-
const search = searchBox({
|
|
282
|
-
input: one<HTMLInputElement>("[data-filter-search]"),
|
|
283
|
-
clear: one("[data-filter-clear]"),
|
|
284
|
-
empty: one("[data-filter-empty]"),
|
|
285
|
-
});
|
|
286
|
-
|
|
287
270
|
const list = filters({
|
|
288
271
|
fields, items,
|
|
289
|
-
onChange({
|
|
290
|
-
search.render(state.q, matched.size);
|
|
272
|
+
onChange({ matched }) {
|
|
291
273
|
// …everything this list draws for itself
|
|
292
274
|
},
|
|
293
275
|
});
|
|
294
|
-
|
|
295
|
-
const unbind = search.bind(list, "q");
|
|
296
276
|
const detach = list.attach();
|
|
277
|
+
const unbind = searchBox(list, "q", {
|
|
278
|
+
input: one<HTMLInputElement>("[data-filter-search]"),
|
|
279
|
+
clear: one("[data-filter-clear]"),
|
|
280
|
+
empty: one("[data-filter-empty]"),
|
|
281
|
+
});
|
|
297
282
|
```
|
|
298
283
|
|
|
299
|
-
|
|
284
|
+
It paints the list's current state at once, then repaints on every change
|
|
285
|
+
through `list.subscribe`, after the page's own `onChange`. The part worth
|
|
286
|
+
centralising is this:
|
|
300
287
|
|
|
301
288
|
```ts
|
|
302
289
|
if (input != null && input.value !== query) input.value = query;
|
|
@@ -306,19 +293,13 @@ It exists because `popstate` and `reset` move state without touching the
|
|
|
306
293
|
keyboard — forget it and the back button leaves a stale query sitting in the box
|
|
307
294
|
while the list shows something else. The inequality is not an optimisation:
|
|
308
295
|
assigning `value` moves the caret to the end, so writing it unconditionally would
|
|
309
|
-
make the field unusable mid-word.
|
|
310
|
-
|
|
311
|
-
`bind` is a second call rather than part of construction because of an ordering
|
|
312
|
-
knot. `render` runs inside the list's `onChange`, so the box must exist before
|
|
313
|
-
the list; `bind` needs the list. Two `const`s in sequence is the honest shape,
|
|
314
|
-
and the alternative is a `let` the reader has to carry. Its return is the undo,
|
|
315
|
-
like `attach` — so a page-load teardown aborts both.
|
|
296
|
+
make the field unusable mid-word. Its return is the undo, like `attach`.
|
|
316
297
|
|
|
317
298
|
`TextFieldOf<F>` restricts the second argument to the list's `text` fields, so a
|
|
318
299
|
box pointed at a `flag` is a compile error rather than a filter that silently
|
|
319
300
|
never matches.
|
|
320
301
|
|
|
321
|
-
Every element is optional and `null` is accepted, because `
|
|
302
|
+
Every element is optional and `null` is accepted, because a role's `one()`
|
|
322
303
|
returns `null` and a page may have a search field with no clear button. Handing
|
|
323
304
|
over what a lookup returned, unchecked, is the point.
|
|
324
305
|
|
|
@@ -333,7 +314,7 @@ errors, and the page looks wrong in a way that does not point at the filter.
|
|
|
333
314
|
```ts
|
|
334
315
|
onChange({ matched }) {
|
|
335
316
|
for (const item of items) item.hidden = !matched.has(keyOf(item));
|
|
336
|
-
hideEmpty(groups, (group) =>
|
|
317
|
+
hideEmpty(groups, (group) => rowsIn.get(group) ?? []);
|
|
337
318
|
}
|
|
338
319
|
```
|
|
339
320
|
|
|
@@ -346,8 +327,8 @@ city in it is hidden; a region is empty when every country in it is hidden,
|
|
|
346
327
|
**Which makes the order load-bearing: innermost first.**
|
|
347
328
|
|
|
348
329
|
```ts
|
|
349
|
-
hideEmpty(countries, (c) =>
|
|
350
|
-
hideEmpty(regions, (r) =>
|
|
330
|
+
hideEmpty(countries, (c) => citiesIn.get(c) ?? []);
|
|
331
|
+
hideEmpty(regions, (r) => countriesIn.get(r) ?? []);
|
|
351
332
|
```
|
|
352
333
|
|
|
353
334
|
Run the other way round, the regions are judged against countries nothing has
|
|
@@ -380,37 +361,39 @@ filters({ … }) + searchBox + hideEmpty // the roll-up gone too
|
|
|
380
361
|
A page whose markup wants a class instead of `hidden`, or removal from the DOM,
|
|
381
362
|
skips the helper and writes it in `onChange`. Nothing degrades.
|
|
382
363
|
|
|
383
|
-
## `
|
|
364
|
+
## `element`
|
|
384
365
|
|
|
385
366
|
A script that enhances server-rendered markup has to agree with the template on
|
|
386
367
|
attribute names, and nothing checks that agreement. `data-filter-regoin` in the
|
|
387
368
|
template and `dataset.filterRegion` in the script compile, build and ship, and
|
|
388
|
-
the facet simply never matches
|
|
389
|
-
|
|
390
|
-
and its template share, and the values in them.
|
|
369
|
+
the facet simply never matches. `element` names every attribute a script and its
|
|
370
|
+
template share, types the values in them, and registers the behaviour.
|
|
391
371
|
|
|
392
372
|
```ts
|
|
393
|
-
//
|
|
394
|
-
export const
|
|
395
|
-
key: { kind: "text" },
|
|
396
|
-
|
|
397
|
-
|
|
373
|
+
// faq.ts — imported by the template, and loaded by the page's script
|
|
374
|
+
export const faq = element("go-faq", {
|
|
375
|
+
row: { key: { kind: "text" }, status: { kind: "choice", of: ["open", "soon"] } },
|
|
376
|
+
search: marker,
|
|
377
|
+
}, (host, { row, search }, signal) => {
|
|
378
|
+
for (const { element, values } of row.all()) {
|
|
379
|
+
values.status; // "open" | "soon"
|
|
380
|
+
}
|
|
381
|
+
const input = search.one<HTMLInputElement>(); // a marker role is the element itself
|
|
398
382
|
});
|
|
399
|
-
export const search = markup("network-search");
|
|
400
383
|
```
|
|
401
384
|
|
|
402
385
|
```astro
|
|
403
|
-
<
|
|
404
|
-
<input {...search.attrs()} type="search">
|
|
405
|
-
```
|
|
386
|
+
<script src="./faq.ts"></script>
|
|
406
387
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const input = search.require<HTMLInputElement>(this).element;
|
|
388
|
+
<go-faq {...faq.root}>
|
|
389
|
+
<input {...faq.search.attrs()} type="search">
|
|
390
|
+
<details {...faq.row.attrs({ key, status: "open" })}>…</details>
|
|
391
|
+
</go-faq>
|
|
412
392
|
```
|
|
413
393
|
|
|
394
|
+
It registers itself when loaded in a browser and does nothing in Node, so the
|
|
395
|
+
template imports the same file for the contract.
|
|
396
|
+
|
|
414
397
|
- **Names are derived, never spelled.** `data-network-row` marks the element and
|
|
415
398
|
`data-network-row-status` holds a field. A field that does not exist is a
|
|
416
399
|
compile error in the template and in the script.
|
|
@@ -460,67 +443,16 @@ lookup: it finds only elements carrying its own marker, writes only its own
|
|
|
460
443
|
attributes, and sets no class, `aria` or `hidden`. The names are the project's,
|
|
461
444
|
so nothing in this package has to be matched.
|
|
462
445
|
|
|
463
|
-
### `component`: roles owned by one custom element
|
|
464
|
-
|
|
465
|
-
`markup` leaves two things to the project, and both bite: a name per role, which
|
|
466
|
-
two components can pick alike, and scope, since `querySelectorAll` from an
|
|
467
|
-
element also finds everything inside a nested copy of that element. `component`
|
|
468
|
-
ties every role to a custom element and settles both.
|
|
469
|
-
|
|
470
|
-
```ts
|
|
471
|
-
export const faq = component("go-faq", {
|
|
472
|
-
row: { key: { kind: "text" }, text: { kind: "text" } },
|
|
473
|
-
search: {},
|
|
474
|
-
});
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
```astro
|
|
478
|
-
<faq.tag {...faq.root}>
|
|
479
|
-
<input {...faq.search.attrs()} type="search">
|
|
480
|
-
<details {...faq.row.attrs({ key, text })}>…</details>
|
|
481
|
-
</faq.tag>
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
defineElement(faq, (host, signal, { row }) => {
|
|
486
|
-
for (const { element, values } of row.all()) { … }
|
|
487
|
-
});
|
|
488
|
-
```
|
|
489
|
-
|
|
490
446
|
- **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
|
|
491
|
-
field, and the browser refuses to define one tag twice, so two
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
- **Lookups stay in their instance.** `all`, `one` and `require` return only
|
|
496
|
-
elements whose nearest ancestor with this tag is the root's. A nested copy
|
|
497
|
-
keeps its elements, and a lookup from an element inside the instance, like a
|
|
498
|
-
dropdown's own panel, still counts as that instance. Elements are filtered
|
|
499
|
-
before they are read, so a broken nested copy cannot fail the outer lookup.
|
|
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
|
|
502
|
-
way: it names things, and nothing in it touches a document, which is why
|
|
503
|
-
frontmatter can import it.
|
|
504
|
-
- **The names are checked in the editor.** A tag without a hyphen, or a role
|
|
505
|
-
that is not camelCase, is a compile error where it is written — the rules are
|
|
506
|
-
types, character by character, as in `i18n/placeholders.ts`. Nothing is
|
|
507
|
-
validated at run time, because a name that reached the browser malformed would
|
|
508
|
-
already have been refused there: by `setAttribute`, by `querySelectorAll`, or
|
|
509
|
-
by `customElements.define`.
|
|
510
|
-
- **Most roles carry nothing**, and say so: `search: marker` rather than
|
|
511
|
-
`search: {}`, which reads like options somebody forgot to fill in.
|
|
512
|
-
|
|
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.
|
|
447
|
+
field, and the browser refuses to define one tag twice, so two elements cannot
|
|
448
|
+
share an attribute.
|
|
449
|
+
- **Lookups stay in their instance.** A nested copy of the element keeps its
|
|
450
|
+
own roles.
|
|
517
451
|
- **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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
that lives outside every instance, such as a footer button that reopens a
|
|
523
|
-
banner, is still plain `markup`.
|
|
452
|
+
project gives every root a display in one stylesheet rule. Forget it and the
|
|
453
|
+
element says so on the dev server.
|
|
454
|
+
- **Names are checked in the editor.** A tag without a hyphen, or a role that is
|
|
455
|
+
not camelCase, is a compile error where it is written.
|
|
524
456
|
|
|
525
457
|
## `youtube`
|
|
526
458
|
|
|
@@ -664,7 +596,7 @@ right properties is a faithful stand-in.
|
|
|
664
596
|
inside the instance, and a broken nested element that must not be read.
|
|
665
597
|
`type-tests/component.ts` pins the role types and the reserved keys.
|
|
666
598
|
|
|
667
|
-
`carousel`, `consent
|
|
599
|
+
`carousel`, `consent` and `element` have none. They need a real DOM and
|
|
668
600
|
this package carries no environment for one; adding `happy-dom` as a dev
|
|
669
601
|
dependency and setting `environment` in `vitest.config.ts` is what that would
|
|
670
602
|
take.
|