@escape-game-over/atlas 0.1.24 → 0.1.26

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 (50) 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 +96 -193
  5. package/docs/rich-text.md +18 -20
  6. package/package.json +9 -15
  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/AtlasElement.astro +26 -0
  12. package/src/astro/ConsentBanner.astro +25 -0
  13. package/src/astro/ConsentElement.astro +67 -0
  14. package/src/astro/Document.astro +44 -0
  15. package/src/astro/Image.astro +101 -0
  16. package/src/astro/MetaTags.astro +30 -40
  17. package/src/astro/RichText.astro +88 -0
  18. package/src/astro/Zoom.astro +61 -0
  19. package/src/astro/client.ts +18 -9
  20. package/src/astro/consent.ts +20 -0
  21. package/src/astro/dev-log.ts +8 -14
  22. package/src/astro/element.ts +76 -112
  23. package/src/astro/filters-view.ts +48 -64
  24. package/src/astro/filters.ts +42 -35
  25. package/src/astro/index.ts +2 -9
  26. package/src/astro/ref.ts +46 -0
  27. package/src/astro/site-routes.ts +9 -15
  28. package/src/config.ts +23 -36
  29. package/src/content/index.ts +1 -1
  30. package/src/content/marks.ts +13 -13
  31. package/src/content/rich.ts +26 -42
  32. package/src/hours.ts +48 -11
  33. package/src/i18n/define.ts +14 -74
  34. package/src/index.ts +40 -57
  35. package/src/meta/index.ts +7 -13
  36. package/src/meta/share-image.ts +2 -26
  37. package/src/meta/tag.ts +1 -45
  38. package/src/money.ts +161 -6
  39. package/src/project.ts +84 -73
  40. package/src/routes/define.ts +8 -44
  41. package/src/routes/resolve.ts +1 -1
  42. package/src/site/api.ts +7 -33
  43. package/src/site/create.ts +6 -10
  44. package/src/site/define.ts +120 -0
  45. package/src/site/index.ts +2 -5
  46. package/src/site/page.ts +4 -2
  47. package/src/sitemap.ts +2 -35
  48. package/src/warn.ts +16 -17
  49. package/src/astro/dom.ts +0 -35
  50. package/src/astro/markup.ts +0 -656
@@ -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", ({ host, signal }) => {
77
+ ref(host, "[data-thing-button]").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,147 +361,72 @@ 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
- A script that enhances server-rendered markup has to agree with the template on
386
- attribute names, and nothing checks that agreement. `data-filter-regoin` in the
387
- 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.
366
+ A custom element that enhances server-rendered markup: `element` registers the
367
+ behaviour and runs it for as long as the element is on the page, and `ref` and
368
+ `refs` find what it works on by plain attribute selectors.
391
369
 
392
370
  ```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 },
371
+ // faq.ts — imported by the template for the tag, and loaded by the page's script
372
+ export const faq = element("go-faq", ({ host, signal }) => {
373
+ const input = ref<HTMLInputElement>(host, "[data-faq-search]");
374
+ for (const row of refs<HTMLDetailsElement>(host, "[data-faq-row]")) {
375
+ row.dataset.key;
376
+ }
398
377
  });
399
- export const search = markup("network-search");
400
378
  ```
401
379
 
402
380
  ```astro
403
- <a {...row.attrs({ key, status: "open", state: store.state })}>
404
- <input {...search.attrs()} type="search">
405
- ```
406
-
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;
412
- ```
413
-
414
- - **Names are derived, never spelled.** `data-network-row` marks the element and
415
- `data-network-row-status` holds a field. A field that does not exist is a
416
- compile error in the template and in the script.
417
- - **Values are typed on the way in.** `attrs` takes each field's type: a missing
418
- required field, or a status outside its list, fails `astro check`. It also
419
- checks at run time, because this runs at build time and a value can arrive as
420
- a plain `string` from an env var or a cast; a throw there fails the build
421
- rather than shipping a page whose script throws on every visit.
422
- - **And parsed on the way out.** `read`, `all`, `one` and `require` parse every
423
- field and throw, naming the element and the attribute, when one is missing or
424
- malformed. A malformed match fails the whole `all` rather than being skipped:
425
- a list that quietly loses a row is the failure this exists to prevent.
426
-
427
- The kinds are data, not builders, for the reason `filters` gives:
428
- `text`, `number`, `list`, `template`, `choice` and `flag`, all but `flag`
429
- optionally `optional`. A `flag` is off by being absent, never `"false"`, so a
430
- stylesheet can select it with a bare attribute.
431
-
432
- A `template` is the one that keeps a sentence out of the script. It is written
433
- as the translated string with its placeholders left in, and read back as the
434
- function that fills them:
381
+ ---
382
+ import AtlasElement from "@escape-game-over/atlas/astro/element";
383
+ ---
435
384
 
436
- ```astro
437
- <p {...faq.line.attrs({ sentence: t("faq.matched", { count: "{count}" }) })}></p>
438
- ```
385
+ <script src="./faq.ts"></script>
439
386
 
440
- ```ts
441
- line.element.textContent = line.values.sentence({ count: String(hits.size) });
387
+ <AtlasElement of={faq} class="…">
388
+ <input data-faq-search type="search">
389
+ <details data-faq-row data-key={key}>…</details>
390
+ </AtlasElement>
442
391
  ```
443
392
 
444
- So no script spells `"{count}"`, and a sentence that never says one of its own
445
- placeholders fails the build rather than shipping a brace to a reader.
446
-
447
- `write` sets fields back onto an element, for state a stylesheet reads — a
448
- dropdown that is blocked — so the script uses the names the template did.
449
-
450
- **What it still cannot reach is the stylesheet.** Tailwind generates CSS from
451
- class names it finds as literals in the source, so a variant keyed on one of
452
- these attributes has to spell it: `group-data-network-dropdown-blocked/dd:`.
453
- That is the one place a rename is not a compile error. It is also the one place
454
- a drift is visible, since the style stops applying, where a script reading the
455
- wrong attribute fails silently. `attribute(field)` returns the name for the
456
- consumers that can take a computed one, such as an `attributeFilter`.
457
-
458
- It keeps the rule at the top of this page in the only reading that admits a
459
- lookup: it finds only elements carrying its own marker, writes only its own
460
- attributes, and sets no class, `aria` or `hidden`. The names are the project's,
461
- so nothing in this package has to be matched.
462
-
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.
393
+ It registers itself when loaded in a browser and does nothing in Node, so the
394
+ template imports the same file for the tag.
395
+
396
+ - **What the script asks for is what devtools shows.** A selector is a string
397
+ the template wrote as an attribute; there is no naming scheme to decode.
398
+ Prefix the names per element (`data-faq-…`) so two elements sharing a
399
+ subtree cannot pick up each other's.
400
+ - **Missing is loud in dev.** `ref` throws naming the root and the selector;
401
+ `refs` returns what it found, none included. A throw inside `connect` leaves
402
+ that element inert and lands in the dev panel, as does any uncaught error on
403
+ the page — a failing listener, a rejected `await`.
404
+ - **Values are the DOM's.** `data(row, "key")` reads `data-key` and throws, as
405
+ `ref` does, when it is missing; an optional one is `row.dataset.state`. Parse
406
+ where the script needs a number or a list. State a stylesheet reads is a plain attribute the
407
+ script sets — `data-blocked` — which Tailwind selects as `group-data-blocked:`.
408
+ - **The root says it is one.** `AtlasElement` renders the tag with
409
+ `data-atlas-root`, so a project gives every root a display in one stylesheet
410
+ rule. A root rendered by hand says so on the dev server.
411
+
412
+ A sentence the script updates keeps its words in the page. The copy wraps the
413
+ changing part in a variant run, `RichText`'s `attrs` gives that run an
414
+ attribute, and the script writes only that run:
469
415
 
470
416
  ```ts
471
- export const faq = component("go-faq", {
472
- row: { key: { kind: "text" }, text: { kind: "text" } },
473
- search: {},
474
- });
417
+ "faq.matched": { "en-US": "[v:count]{count}[/v] answers found" },
475
418
  ```
476
419
 
477
420
  ```astro
478
- <faq.tag {...faq.root}>
479
- <input {...faq.search.attrs()} type="search">
480
- <details {...faq.row.attrs({ key, text })}>…</details>
481
- </faq.tag>
421
+ <RichText spans={rich("faq.matched", { count: "" })} attrs={{ count: { "data-faq-count": "" } }} />
482
422
  ```
483
423
 
484
424
  ```ts
485
- defineElement(faq, (host, signal, { row }) => {
486
- for (const { element, values } of row.all()) { … }
487
- });
425
+ for (const each of refs(host, "[data-faq-count]")) each.textContent = String(hits.size);
488
426
  ```
489
427
 
490
- - **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.
517
- - **The root says it is one.** `{...faq.root}` writes `data-atlas-root`, so a
518
- project gives every root a display in one stylesheet rule rather than a class
519
- per component. Forget it and the element says so on the dev server.
520
-
521
- `tag` and `root` are the component's own keys, so no role may take them. A role
522
- that lives outside every instance, such as a footer button that reopens a
523
- banner, is still plain `markup`.
428
+ Marks are parsed before placeholders are filled, so a run filled with `""`
429
+ still renders as an empty element for the script to write into.
524
430
 
525
431
  ## `youtube`
526
432
 
@@ -655,16 +561,13 @@ right properties is a faithful stand-in.
655
561
  `matches` read live as a browser's is, and fakes the `<video>` down to
656
562
  `paused`, `currentSrc`, `networkState`, `autoplay`, `play`, `pause` and `load`,
657
563
  including what resource selection does after a reload.
658
- - `tests/markup.test.ts` fakes elements down to four attribute methods and a
659
- root that answers bare attribute selectors, and round-trips every field kind
660
- through `attrs` and `read`. `type-tests/markup.ts` pins the half that is a
661
- compile error.
662
- - `tests/component.test.ts` builds a small element tree with parents and
663
- `closest`, and pins the scoping: a nested instance, two side by side, a root
664
- inside the instance, and a broken nested element that must not be read.
665
- `type-tests/component.ts` pins the role types and the reserved keys.
666
-
667
- `carousel`, `consent`, `element` and `dom` have none. They need a real DOM and
564
+ - `tests/ref.test.ts` fakes a root down to its two query methods, and pins
565
+ that `ref` throws naming the root and the selector.
566
+ - `tests/element.test.ts` stubs `customElements` to upgrade on `define`, as a
567
+ browser does, and pins what `connect` is handed.
568
+
569
+ `carousel` and `consent` have none, and `element` has no lifecycle test — moves
570
+ and teardown. They need a real DOM and
668
571
  this package carries no environment for one; adding `happy-dom` as a dev
669
572
  dependency and setting `environment` in `vitest.config.ts` is what that would
670
573
  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,20 @@ 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, and only the runs:
238
+ the caller writes the element around them. The site supplies the look — a class
239
+ per `[v:name]` and one for links — usually in a small wrapper
240
+ ([`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)):
242
241
 
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.
242
+ ```astro
243
+ <p class="…">
244
+ <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
245
+ </p>
246
+ ```
247
247
 
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.
248
+ `attrs` puts attributes on a variant's run, so a script can find it and rewrite
249
+ its words — see [client-scripts.md](client-scripts.md). A variant with neither a
250
+ class nor attrs still renders its words, and warns at build.
253
251
 
254
252
  ## What fails, and where
255
253
 
@@ -270,4 +268,4 @@ variant it does not know — losing the sentence is the worse failure.
270
268
  | `[tel]` around a local number | build error — write it with a country code |
271
269
  | `http://` as a link target | build error — an `http://` link is a downgrade |
272
270
  | `"contact#"` — a fragment that names nothing | build error |
273
- | A marked message read with `t()` | build error, pointing at `rich()` and `plain()` |
271
+ | A marked message read with `t()` | build error, pointing at `rich()` and `site.plain()` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -15,18 +15,12 @@
15
15
  ".": "./src/index.ts",
16
16
  "./astro": "./src/astro/index.ts",
17
17
  "./astro/images": "./src/astro/images.ts",
18
- "./astro/background-video": "./src/astro/background-video.ts",
19
- "./astro/carousel": "./src/astro/carousel.ts",
20
18
  "./client": "./src/astro/client.ts",
21
- "./astro/consent": "./src/astro/consent.ts",
22
- "./astro/dev-log": "./src/astro/dev-log.ts",
23
- "./astro/dom": "./src/astro/dom.ts",
24
- "./astro/element": "./src/astro/element.ts",
25
- "./astro/filters": "./src/astro/filters.ts",
26
- "./astro/filters-view": "./src/astro/filters-view.ts",
27
- "./astro/markup": "./src/astro/markup.ts",
28
- "./astro/meta-tags": "./src/astro/MetaTags.astro",
29
- "./astro/youtube": "./src/astro/youtube.ts"
19
+ "./astro/consent-banner": "./src/astro/ConsentBanner.astro",
20
+ "./astro/document": "./src/astro/Document.astro",
21
+ "./astro/element": "./src/astro/AtlasElement.astro",
22
+ "./astro/image": "./src/astro/Image.astro",
23
+ "./astro/rich-text": "./src/astro/RichText.astro"
30
24
  },
31
25
  "bin": {
32
26
  "atlas": "bin/use-project.mjs"
@@ -57,10 +51,10 @@
57
51
  "typescript": ">=5"
58
52
  },
59
53
  "devDependencies": {
60
- "@biomejs/biome": "2.5.13",
61
- "@types/node": "26.5.1",
54
+ "@biomejs/biome": "2.5.14",
55
+ "@types/node": "26.6.2",
62
56
  "@vitest/coverage-istanbul": "5.0.1",
63
- "astro": "7.3.2",
57
+ "astro": "7.3.3",
64
58
  "typescript": "6.0.3",
65
59
  "vitest": "5.0.1"
66
60
  }
@@ -1,5 +1,5 @@
1
1
  import { warn } from "../warn.ts";
2
- import { type AnalyticsTags, literal } from "./tags.ts";
2
+ import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
3
3
 
4
4
  /**
5
5
  * Google's tags — Analytics and Tag Manager — and the consent state they read.
@@ -366,16 +366,16 @@ export function googleScripts(
366
366
  // Ahead of the inline block, which is the only position that buys
367
367
  // anything: for a container the loader's URL is written *by* that
368
368
  // block, so this is the only mention of the origin the browser can
369
- // act on before the script has run. See `AnalyticsTag`.
370
- { kind: "preconnect", origin: TAG_ORIGIN },
371
- { kind: "inline", content: inline },
369
+ // act on before the script has run.
370
+ preconnect(TAG_ORIGIN),
371
+ { kind: "script", content: inline },
372
372
  ...(first === undefined
373
373
  ? []
374
374
  : [
375
375
  {
376
- kind: "external" as const,
376
+ kind: "externalScript" as const,
377
377
  src: `${TAG_ORIGIN}/gtag/js?id=${encodeURIComponent(first)}`,
378
- attributes: {},
378
+ attrs: {},
379
379
  },
380
380
  ]),
381
381
  ],
@@ -1,6 +1,7 @@
1
+ import type { MetaTag } from "../meta/tag.ts";
1
2
  import type { HttpsUrl } from "../url.ts";
2
3
  import { type GoogleSettings, googleEmits, googleScripts } from "./google.ts";
3
- import type { AnalyticsTag, AnalyticsTags } from "./tags.ts";
4
+ import type { AnalyticsTags } from "./tags.ts";
4
5
  import {
5
6
  checkUmamiDomains,
6
7
  type UmamiSettings,
@@ -28,7 +29,7 @@ export {
28
29
  type ConsentState,
29
30
  type GoogleSettings,
30
31
  } from "./google.ts";
31
- export type { AnalyticsTag, AnalyticsTags } from "./tags.ts";
32
+ export type { AnalyticsTags } from "./tags.ts";
32
33
  export type {
33
34
  UmamiReplay,
34
35
  UmamiSettings,
@@ -134,6 +135,6 @@ export const NOT_FOUND_TAG = "404";
134
135
  */
135
136
  export function notFoundAnalytics(
136
137
  analytics: AnalyticsSettings | undefined
137
- ): readonly AnalyticsTag[] {
138
+ ): readonly MetaTag[] {
138
139
  return umamiScripts(analytics?.umami, NOT_FOUND_TAG);
139
140
  }