@escape-game-over/atlas 0.1.24 → 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.
Files changed (48) hide show
  1. package/README.md +27 -44
  2. package/bin/use-project.mjs +18 -13
  3. package/docs/NOT-BUILT.md +1 -1
  4. package/docs/client-scripts.md +73 -141
  5. package/docs/rich-text.md +13 -20
  6. package/package.json +5 -12
  7. package/src/analytics/google.ts +6 -6
  8. package/src/analytics/index.ts +4 -3
  9. package/src/analytics/tags.ts +13 -58
  10. package/src/analytics/umami.ts +8 -8
  11. package/src/astro/ConsentBanner.astro +25 -0
  12. package/src/astro/ConsentElement.astro +61 -0
  13. package/src/astro/Document.astro +44 -0
  14. package/src/astro/Image.astro +102 -0
  15. package/src/astro/MetaTags.astro +3 -26
  16. package/src/astro/RichText.astro +71 -0
  17. package/src/astro/Zoom.astro +61 -0
  18. package/src/astro/client.ts +19 -9
  19. package/src/astro/consent.ts +20 -0
  20. package/src/astro/dev-log.ts +8 -14
  21. package/src/astro/element.ts +111 -112
  22. package/src/astro/filters-view.ts +48 -64
  23. package/src/astro/filters.ts +42 -35
  24. package/src/astro/index.ts +2 -9
  25. package/src/astro/markup.ts +6 -6
  26. package/src/astro/site-routes.ts +9 -15
  27. package/src/config.ts +23 -36
  28. package/src/content/index.ts +1 -1
  29. package/src/content/marks.ts +13 -13
  30. package/src/content/rich.ts +26 -42
  31. package/src/hours.ts +48 -11
  32. package/src/i18n/define.ts +14 -74
  33. package/src/index.ts +40 -57
  34. package/src/meta/index.ts +7 -13
  35. package/src/meta/share-image.ts +2 -26
  36. package/src/meta/tag.ts +1 -45
  37. package/src/money.ts +161 -6
  38. package/src/project.ts +84 -73
  39. package/src/routes/define.ts +8 -44
  40. package/src/routes/resolve.ts +1 -1
  41. package/src/site/api.ts +7 -33
  42. package/src/site/create.ts +6 -10
  43. package/src/site/define.ts +120 -0
  44. package/src/site/index.ts +2 -5
  45. package/src/site/page.ts +4 -2
  46. package/src/sitemap.ts +2 -35
  47. package/src/warn.ts +16 -17
  48. 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 defineProject(config, defaultMessages, defaultRoutes, {
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 default defineSiteConfig({ locales: {...}, defaultRouting: {...} })
95
+ export const atlas = defineSite({ locales: {...}, defaultRouting: {...} })
96
96
 
97
97
  // 2. the base copy and routes
98
- export const baseMessages = defineMessages(config, {...})
99
- export const baseRoutes = defineRoutes(config, {...})
98
+ export const baseMessages = atlas.messages({...})
99
+ export const baseRoutes = atlas.routes({...})
100
100
 
101
101
  // 3. one deployment
102
- export default defineProject(config, baseMessages, baseRoutes, {...})
102
+ export default atlas.project(baseMessages, baseRoutes, {...})
103
103
 
104
104
  // 4. the API the pages use
105
- export const site = createSite(config, baseMessages, baseRoutes, project)
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
- `alternatesFor()`, `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
110
- `staticPaths()`, `routes`, `entries`, `sitemap()`, `robots()`, `llms()` and
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
@@ -233,7 +233,7 @@ worded.
233
233
  The same question answers it every time: *would this differ between two pages of
234
234
  the same site?*
235
235
 
236
- | Per page — passed to `metaFor()` | Site-level — declared in `defineProject()` |
236
+ | Per page — passed to `metaFor()` | Site-level — declared in `atlas.project()` |
237
237
  | -------------------------------- | ------------------------------------------ |
238
238
  | `title`, `description` | `siteName`, `twitterSite` |
239
239
  | `image` (asset **and** its alt) | `icon`, `themeColor`, `colorScheme` |
@@ -251,16 +251,9 @@ actually renders, and why `twitter:card` is pinned.
251
251
 
252
252
  A project switches routes off. Anything defined *alongside* a route then has two
253
253
  ways to rot: the page is built and its config is missing, or the page is gone and
254
- its config lingers. Two types, differing only in how many routes they speak for:
254
+ its config lingers. `PerRoute<typeof site, T>` rejects both.
255
255
 
256
- | | `PerRoute<typeof site, T>` | `WhenEnabled<typeof site, "careers", T>` |
257
- | --------------------- | ------------------------------------ | ------------------------------------------------ |
258
- | Shape | a table keyed by route id | a single value |
259
- | Says | "every built route has one of these" | "this value belongs to *that* page" |
260
- | When the route is off | that key is rejected | the type is `never` — the field cannot be filled |
261
- | Use with | `satisfies` | a normal annotation |
262
-
263
- `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
264
257
  checking is what catches the stale half, and only an object literal checked
265
258
  against a known target gets it.
266
259
 
@@ -269,8 +262,6 @@ const heroes = {
269
262
  home: { variant: "wide" },
270
263
  about: { variant: "tall" },
271
264
  } satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
272
-
273
- const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; // ✗ if off
274
265
  ```
275
266
 
276
267
  ## The rules this package keeps
@@ -299,16 +290,8 @@ const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; /
299
290
  ".": "./src/index.ts",
300
291
  "./astro": "./src/astro/index.ts",
301
292
  "./astro/images": "./src/astro/images.ts",
302
- "./astro/background-video": "./src/astro/background-video.ts",
303
- "./astro/carousel": "./src/astro/carousel.ts",
304
- "./astro/consent": "./src/astro/consent.ts",
305
- "./astro/dev-log": "./src/astro/dev-log.ts",
306
- "./astro/dom": "./src/astro/dom.ts",
307
- "./astro/element": "./src/astro/element.ts",
308
- "./astro/filters": "./src/astro/filters.ts",
309
- "./astro/filters-view": "./src/astro/filters-view.ts",
310
- "./astro/meta-tags": "./src/astro/MetaTags.astro",
311
- "./astro/youtube": "./src/astro/youtube.ts"
293
+ "./astro/document": "./src/astro/Document.astro",
294
+ "./client": "./src/astro/client.ts"
312
295
  }
313
296
  ```
314
297
 
@@ -324,27 +307,27 @@ may use `node:` built-ins but not `astro:assets`. `./astro/images` is the
324
307
  opposite: it runs inside the build, from a page, and is unusable from a config.
325
308
  Merging them breaks whichever caller loads first.
326
309
 
327
- The rest of `astro/` is the browser half — `carousel`, `filters`, `filters-view`,
328
- `youtube`, `background-video`, `consent`, `element`, `dom`, `dev-log` — which
329
- runs in a reader's browser rather
330
- than in the build, and is separate again for the same reason: none of it can be
331
- 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
332
313
  [`docs/client-scripts.md`](docs/client-scripts.md).
333
314
 
334
315
  **The package ships TypeScript source, and there is no build step.**
335
- `MetaTags.astro` could not go through `tsc` anyway, and Astro's own
316
+ `Document.astro` could not go through `tsc` anyway, and Astro's own
336
317
  `tsconfigs/base.json` already sets `allowImportingTsExtensions`, so every
337
318
  consumer gets it for free.
338
319
 
339
320
  ## The `atlas` command
340
321
 
341
322
  ```bash
342
- atlas use <project> # copies config/projects/<name> -> config/project
323
+ atlas use <project> # links config/project -> config/projects/<name>
343
324
  atlas use --fallback rome # only when nothing else named one
344
325
  ```
345
326
 
346
- The whole directory is copied, so extra files and nested folders come along
347
- without touching the tool — the contract is just that it contains `project.ts`.
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`.
348
331
  It resolves paths from the working directory, so a workspace's own
349
332
  `package.json` passes nothing. A deployment pipeline can skip it and write its
350
333
  own `config/project`.
@@ -358,7 +341,7 @@ command whose output gets deployed has to say what it is building.
358
341
  ```txt
359
342
  src/ the package — see the export map above for what is reachable
360
343
  index.ts the public API
361
- site/ createSite() and the Site it returns
344
+ site/ defineSite(), createSite() and the Site it returns
362
345
  meta/ head tags: canonical, hreflang, Open Graph, Twitter, robots
363
346
  jsonld/ the @graph — one file per node, each linked to Google's docs
364
347
  i18n/ defineMessages(), t(), placeholder extraction
@@ -400,9 +383,9 @@ proves the package is genuinely independent of anything consuming it.
400
383
 
401
384
  The `astro check` pass covers `src/astro/`, the one directory that cannot meet
402
385
  that bar. It needs `astro/client` for `astro:assets` and `ImageMetadata`, and
403
- `MetaTags.astro` needs a checker that can parse `.astro` at all — `tsc` reads
404
- zero `.astro` files no matter how they are globbed. Without this pass the one
405
- component the package ships is checked by nothing: `astro check` in
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
406
389
  `test:examples` only ever walks an example's own `src/`, so it never reaches it,
407
390
  and a type error there passes the entire gate.
408
391
 
@@ -412,7 +395,7 @@ practical reason: `astro check` writes a `.astro/` types directory and a
412
395
  publish both. The `tsconfig.json` there adds nothing — it extends
413
396
  `src/astro/tsconfig.json` and only widens the glob, so the rules stay in one
414
397
  place. `src/astro/tsconfig.json` is what an editor finds; without it, opening
415
- `MetaTags.astro` resolves against the root config, which supplies no ambient
398
+ `Document.astro` resolves against the root config, which supplies no ambient
416
399
  types, and every prop degrades to `any`.
417
400
 
418
401
  `test:examples` **builds every deployment**, not just type-checks one, and both
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Copies a project overlay into `config/project`, the folder the whole build
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
- * The entire project directory is copied, so extra files and nested folders come
10
- * along automatically. A deployment pipeline can skip this command and write its
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 { cp, readdir, rm, stat, writeFile } from "node:fs/promises";
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
- await rm(TARGET_DIR, { recursive: true, force: true });
126
- await cp(sourceDir, TARGET_DIR, { recursive: true });
127
- await writeFile(join(TARGET_DIR, ".project"), `${name}\n`, "utf8");
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} -> config/project)`
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
@@ -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 | Owns | Leaves to the project |
36
- | -------------------------- | -------------------------------------------------------------- | ----------------------------------------------- |
37
- | `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
38
- | `./astro/filters` | which items match, and what the address bar says | every DOM read and write |
39
- | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
40
- | `./astro/markup` | the attributes a template writes and a script reads, typed | naming the roles, and everything done with them |
41
- | `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
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
- | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
44
- | `./astro/element` | registering a custom element, and ending what it started | what the element does while it is on the page |
45
- | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
46
- | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
47
-
48
- `./astro/consent` is the only one of these with a build-time half. Its
49
- `consentApplies()` reports whether there is a Google tag to consent to, which a
50
- banner has to know — but it can only answer once it has been downloaded, so a
51
- project that asks it alone ships the banner's script to every page to be told,
52
- three requests later, that there was nothing to ask about. `consentRequired()`
53
- from the package root is the same question at build time: false, and the banner
54
- never reaches the HTML. Gate the render on that and keep the runtime check for
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
- `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.
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
- defineElement(thing, (host, signal) => {
85
- host.addEventListener("click", open, { signal }); // ends with the visit
86
- return loop.attach(host); // and so does this
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", param: "q" },
118
- category: { kind: "choice", param: "category" },
119
- featured: { kind: "flag", param: "featured" },
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` is optional. A field without one is state-only and never reaches the URL.
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({ state, matched }) {
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
- `render` is the half worth centralising, and specifically this:
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 `within(root).one(…)`
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) => within(group).all("[data-faq-key]"));
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) => within(c).all("[data-city]"));
350
- hideEmpty(regions, (r) => within(r).all("[data-country]"));
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
- ## `markup`
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: the page reads as a filter with no results, not
389
- as a typo. A shared constant pins one name. `markup` pins every name a script
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
- // network-markup.ts — imported by the template and by the script
394
- export const row = markup("network-row", {
395
- key: { kind: "text" },
396
- status: { kind: "choice", of: ["open", "soon"] },
397
- state: { kind: "text", optional: true },
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
- <a {...row.attrs({ key, status: "open", state: store.state })}>
404
- <input {...search.attrs()} type="search">
405
- ```
386
+ <script src="./faq.ts"></script>
406
387
 
407
- ```ts
408
- for (const { element, values } of row.all(this)) {
409
- values.status; // "open" | "soon"
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 components
492
- cannot share an attribute. Two roles of one component that would write the
493
- same attribute — `search` with a field `clear`, and a role `searchClear` — are
494
- refused when the component is created.
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 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`.
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`, `element` and `dom` have none. They need a real DOM and
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.
package/docs/rich-text.md CHANGED
@@ -29,10 +29,10 @@ Five marks, one void mark, two escapes. That is the whole vocabulary.
29
29
  | Written | Short for | Run | Argument | Notes |
30
30
  | ---------------- | ----------- | ---------------------------------- | -------- | -------------------------------------------- |
31
31
  | `[b]…[/b]` | **b**old | `{ kind: "bold" }` | — | `<strong>` — emphasis, not a font weight |
32
- | `[v:name]…[/v]` | **v**ariant | `{ kind: "styled", variant }` | required | the renderer maps `name` to classes |
32
+ | `[v:name]…[/v]` | **v**ariant | `{ kind: "variant", variant }` | required | the renderer maps `name` to classes |
33
33
  | `[a:slot]…[/a]` | **a**nchor | `{ kind: "link", to, href, url? }` | required | the **call site** says where `slot` goes |
34
- | `[mail]…[/mail]` | `mailto:` | `{ kind: "email", href }` | — | the wrapped text must be the address |
35
- | `[tel]…[/tel]` | `tel:` | `{ kind: "phone", href }` | — | the wrapped text must be the number |
34
+ | `[mail]…[/mail]` | `mailto:` | `{ kind: "mail", href }` | — | the wrapped text must be the address |
35
+ | `[tel]…[/tel]` | `tel:` | `{ kind: "tel", href }` | — | the wrapped text must be the number |
36
36
  | `[br]` | **br**eak | `{ kind: "break" }` | — | wraps nothing, closed by nothing |
37
37
  | `[[` and `]]` | an escape | literal `[` and `]` | — | the escapes, matching `{{` and `}}` in `t()` |
38
38
 
@@ -192,7 +192,7 @@ what to do:
192
192
 
193
193
  ```txt
194
194
  Message "about.intro" (en-US) carries marks, and t() can only print them.
195
- Read it with rich(), or with plain(rich(…)) for the words alone.
195
+ Read it with rich(), or with site.plain() for the words alone.
196
196
  ```
197
197
 
198
198
  A malformed mark fails there too, with the same error it would give anywhere
@@ -215,10 +215,8 @@ const plainText = site.plain(locale);
215
215
  plainText("about.intro", { company }) // no `venue`, no `ask`
216
216
  ```
217
217
 
218
- `plain(rich(…))` is the same answer when the runs are already in hand.
219
-
220
218
  Reach for `t()` when the copy is structurally plain and should stay that way — a
221
- button label, an `aria-label`. Reach for `plain()` when the answer is prose: it
219
+ button label, an `aria-label`. Reach for `site.plain()` when the answer is prose: it
222
220
  keeps working on the day someone adds emphasis to the sentence, where `t()` would
223
221
  start throwing.
224
222
 
@@ -236,20 +234,15 @@ faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }]
236
234
 
237
235
  ## The renderer half
238
236
 
239
- lib decides which runs exist and what they say; the project decides what they
240
- look like. A renderer is a `switch` over `kind` and nothing else — every string
241
- is already translated, every `href` already resolved.
237
+ `@escape-game-over/atlas/astro/rich-text` renders the runs. The site supplies the
238
+ look — a class per `[v:name]` and one for links — usually in a small wrapper
239
+ ([`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)):
242
240
 
243
- Make the `switch` exhaustive. `Span` is a closed union, so `satisfies never` on
244
- the fallthrough turns a run type added in lib into a compile error in the
245
- project, which is the failure a `default: return null` cannot have — a new kind
246
- rendering as nothing at all, on every page, silently.
241
+ ```astro
242
+ <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
243
+ ```
247
244
 
248
- [`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)
249
- is one to copy and restyle. The part worth keeping is its `VARIANTS` map: it is
250
- the only place a role becomes a colour, so a rebrand is that object rather than a
251
- sweep through every deployment's sentences. It falls back to no classes for a
252
- variant it does not know — losing the sentence is the worse failure.
245
+ A variant with no class still renders its words, and warns at build.
253
246
 
254
247
  ## What fails, and where
255
248
 
@@ -270,4 +263,4 @@ variant it does not know — losing the sentence is the worse failure.
270
263
  | `[tel]` around a local number | build error — write it with a country code |
271
264
  | `http://` as a link target | build error — an `http://` link is a downgrade |
272
265
  | `"contact#"` — a fragment that names nothing | build error |
273
- | A marked message read with `t()` | build error, pointing at `rich()` and `plain()` |
266
+ | A marked message read with `t()` | build error, pointing at `rich()` and `site.plain()` |