@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.
Files changed (51) hide show
  1. package/README.md +29 -45
  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 +25 -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 +14 -60
  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 +46 -37
  24. package/src/astro/images.ts +27 -26
  25. package/src/astro/index.ts +2 -9
  26. package/src/astro/markup.ts +6 -6
  27. package/src/astro/site-routes.ts +10 -15
  28. package/src/config.ts +23 -36
  29. package/src/content/index.ts +2 -1
  30. package/src/content/marks.ts +13 -13
  31. package/src/content/rich.ts +58 -29
  32. package/src/hours.ts +48 -11
  33. package/src/i18n/define.ts +14 -74
  34. package/src/index.ts +41 -57
  35. package/src/jsonld/faq.ts +2 -1
  36. package/src/jsonld/node.ts +4 -14
  37. package/src/meta/index.ts +7 -13
  38. package/src/meta/share-image.ts +6 -19
  39. package/src/meta/tag.ts +1 -45
  40. package/src/money.ts +161 -6
  41. package/src/project.ts +84 -73
  42. package/src/routes/define.ts +8 -44
  43. package/src/routes/resolve.ts +1 -1
  44. package/src/site/api.ts +7 -33
  45. package/src/site/create.ts +6 -10
  46. package/src/site/define.ts +120 -0
  47. package/src/site/index.ts +2 -5
  48. package/src/site/page.ts +4 -2
  49. package/src/sitemap.ts +2 -35
  50. package/src/warn.ts +16 -17
  51. package/src/astro/dom.ts +0 -35
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,29 +215,34 @@ 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
 
223
+ ## The same sentence as inline HTML
224
+
225
+ `html(rich(…))` is for a structured-data field that accepts a little markup — a
226
+ `FAQPage` answer's `text`. It emits `<a>`, `<strong>` and `<br>`, links by the
227
+ absolute `url` because nothing reading it has a page to resolve a path against,
228
+ and escapes all the copy. A `[v:]` role and an in-page anchor come out as their
229
+ words.
230
+
231
+ ```ts
232
+ faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }], at)
233
+ ```
234
+
225
235
  ## The renderer half
226
236
 
227
- lib decides which runs exist and what they say; the project decides what they
228
- look like. A renderer is a `switch` over `kind` and nothing else — every string
229
- 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)):
230
240
 
231
- Make the `switch` exhaustive. `Span` is a closed union, so `satisfies never` on
232
- the fallthrough turns a run type added in lib into a compile error in the
233
- project, which is the failure a `default: return null` cannot have — a new kind
234
- rendering as nothing at all, on every page, silently.
241
+ ```astro
242
+ <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
243
+ ```
235
244
 
236
- [`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)
237
- is one to copy and restyle. The part worth keeping is its `VARIANTS` map: it is
238
- the only place a role becomes a colour, so a rebrand is that object rather than a
239
- sweep through every deployment's sentences. It falls back to no classes for a
240
- 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.
241
246
 
242
247
  ## What fails, and where
243
248
 
@@ -258,4 +263,4 @@ variant it does not know — losing the sentence is the worse failure.
258
263
  | `[tel]` around a local number | build error — write it with a country code |
259
264
  | `http://` as a link target | build error — an `http://` link is a downgrade |
260
265
  | `"contact#"` — a fragment that names nothing | build error |
261
- | 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()` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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,11 @@
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/image": "./src/astro/Image.astro",
22
+ "./astro/rich-text": "./src/astro/RichText.astro"
30
23
  },
31
24
  "bin": {
32
25
  "atlas": "bin/use-project.mjs"
@@ -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
  }
@@ -1,62 +1,16 @@
1
+ import { link, type MetaTag } from "../meta/tag.ts";
2
+
1
3
  /**
2
- * What an analytics vendor comes to, as data.
4
+ * An origin to open a connection to before anything asks it for bytes: every
5
+ * vendor here is served from a host that is not the site's.
3
6
  *
4
- * A list rather than a set, because **order is part of the answer**. Google's
5
- * consent defaults have to be queued before any tag that reads them, and a tag
6
- * that fires first does not error — it simply applies the permissive implicit
7
- * default and sets cookies nobody agreed to. Returning a sequence makes that
8
- * ordering something lib decides once, rather than something a layout could
9
- * shuffle.
7
+ * **No `crossorigin`, and that is load-bearing.** A preconnect is reused only by
8
+ * a request whose CORS mode matches, and every script here is a classic
9
+ * `<script src>`. One carrying `crossorigin` would open a second connection
10
+ * that nothing uses.
10
11
  */
11
- export type AnalyticsTag =
12
- | {
13
- readonly kind: "external";
14
- readonly src: string;
15
- readonly attributes: Readonly<Record<string, string>>;
16
- }
17
- | {
18
- readonly kind: "inline";
19
- readonly content: string;
20
- }
21
- /**
22
- * A hidden iframe inside `<noscript>` — Tag Manager's fallback, and so far
23
- * only that.
24
- *
25
- * Structured rather than a string of HTML, for the reason `externalScript`
26
- * is: a field holding markup is a passthrough, and lib does not have those.
27
- * A `src` is all this shape can carry, so it is all anyone can put in it.
28
- */
29
- | {
30
- readonly kind: "noscriptFrame";
31
- readonly src: string;
32
- }
33
- /**
34
- * An origin to open a connection to before anything asks it for bytes.
35
- *
36
- * Every vendor here is served from a host that is not the site's, so the
37
- * first tag to load pays a DNS lookup, a TCP handshake and a TLS
38
- * negotiation before a single byte of script arrives. Tag Manager is the
39
- * worst case: its loader is written by the inline block above, so the
40
- * origin appears nowhere a preload scanner can read it and the connection
41
- * cannot begin until that script has run.
42
- *
43
- * An origin rather than a URL, and `preconnect` rather than `preload`,
44
- * because the handshake is the part worth moving and the fetch is not.
45
- * Preloading a tag would raise a third-party analytics script to the
46
- * priority of the things the page is drawn from — it would arrive sooner
47
- * by making the image beside it arrive later.
48
- *
49
- * **No `crossorigin`, and that is load-bearing.** A preconnect is reused
50
- * only by a request whose CORS mode matches it, and every script here is
51
- * fetched as a classic `<script src>`, which is not a CORS request. One
52
- * carrying `crossorigin` would open a second connection that nothing uses,
53
- * warm nothing, and look entirely correct in the markup. Fonts are the
54
- * opposite case and do need it, which is why this is worth stating.
55
- */
56
- | {
57
- readonly kind: "preconnect";
58
- readonly origin: string;
59
- };
12
+ export const preconnect = (origin: string): MetaTag =>
13
+ link({ rel: "preconnect", href: origin });
60
14
 
61
15
  /**
62
16
  * Where a tag goes. Two channels, because two tags genuinely differ.
@@ -67,8 +21,9 @@ export type AnalyticsTag =
67
21
  * cannot put one where the other goes.
68
22
  */
69
23
  export interface AnalyticsTags {
70
- readonly head: readonly AnalyticsTag[];
71
- readonly body: readonly AnalyticsTag[];
24
+ /** In order: Google's consent defaults must be queued before any tag reads them. */
25
+ readonly head: readonly MetaTag[];
26
+ readonly body: readonly MetaTag[];
72
27
  }
73
28
 
74
29
  /**
@@ -77,8 +32,7 @@ export interface AnalyticsTags {
77
32
  * `JSON.stringify` handles quotes and backslashes. The `<` escape handles the
78
33
  * one thing it cannot: a `</script` anywhere in the text ends the element,
79
34
  * whatever JavaScript makes of it. `<` is a valid escape inside a JS
80
- * string and invisible to anything reading the value — the same bargain
81
- * `serializeJsonLd` strikes, for the same reason.
35
+ * string and invisible to anything reading the value, JSON parsers included.
82
36
  */
83
37
  export const literal = (value: unknown): string =>
84
38
  JSON.stringify(value).replaceAll("<", "\\u003c");
@@ -1,6 +1,7 @@
1
+ import type { MetaTag } from "../meta/tag.ts";
1
2
  import { type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
2
3
  import { warn } from "../warn.ts";
3
- import type { AnalyticsTag } from "./tags.ts";
4
+ import { preconnect } from "./tags.ts";
4
5
 
5
6
  /**
6
7
  * Umami's tracker — the script that counts pageviews.
@@ -225,7 +226,7 @@ export function checkUmamiDomains(
225
226
  export function umamiScripts(
226
227
  umami: UmamiSettings | undefined,
227
228
  tag?: string
228
- ): readonly AnalyticsTag[] {
229
+ ): readonly MetaTag[] {
229
230
  if (umami === undefined) return [];
230
231
 
231
232
  // Through `joinUrl`, not concatenation. `HttpsUrl` cannot express "without
@@ -251,12 +252,11 @@ export function umamiScripts(
251
252
  // Before the script that needs it. A `src.` subdomain reads as
252
253
  // first-party and is not one to the network stack: it is a separate
253
254
  // origin, and the tracker pays its full handshake before it can start.
254
- // See `AnalyticsTag` for why this is a preconnect and not a preload.
255
- { kind: "preconnect", origin: umami.host },
255
+ preconnect(umami.host),
256
256
  {
257
- kind: "external",
257
+ kind: "externalScript",
258
258
  src: at(umami.tracker?.path ?? "/script.js"),
259
- attributes: stated({
259
+ attrs: stated({
260
260
  ...common,
261
261
  // The one default lib overrides — see `UmamiTracker`. The
262
262
  // others are left to Umami: `auto-track` and `auto-pageview`
@@ -281,9 +281,9 @@ export function umamiScripts(
281
281
  ? []
282
282
  : [
283
283
  {
284
- kind: "external" as const,
284
+ kind: "externalScript" as const,
285
285
  src: at(umami.replay.path ?? "/recorder.js"),
286
- attributes: stated(common),
286
+ attrs: stated(common),
287
287
  },
288
288
  ]),
289
289
  ];
@@ -0,0 +1,25 @@
1
+ ---
2
+ /**
3
+ * The consent banner's behaviour; the site supplies its markup and copy as the
4
+ * children, with `consent.accept` and `consent.decline` on its buttons.
5
+ *
6
+ * Renders nothing — and ships no script — when this site's analytics need no
7
+ * permission. Otherwise the banner starts hidden and shows only to a visitor
8
+ * with no answer on record, or when `consent.reopen` is pressed.
9
+ */
10
+ import type { HTMLAttributes } from "astro/types";
11
+ import { type AnalyticsSettings, consentRequired } from "../analytics/index.ts";
12
+ import ConsentElement from "./ConsentElement.astro";
13
+
14
+ type Props = HTMLAttributes<"div"> & {
15
+ readonly analytics: AnalyticsSettings | undefined;
16
+ };
17
+
18
+ const { analytics, ...attrs } = Astro.props;
19
+ ---
20
+
21
+ {consentRequired(analytics) && (
22
+ <ConsentElement {...attrs}>
23
+ <slot />
24
+ </ConsentElement>
25
+ )}
@@ -0,0 +1,61 @@
1
+ ---
2
+ /** Internal to `ConsentBanner.astro`, so its script ships only where it renders. */
3
+ import type { HTMLAttributes } from "astro/types";
4
+ import { consentBanner } from "./consent.ts";
5
+
6
+ type Props = HTMLAttributes<"div">;
7
+ ---
8
+
9
+ <atlas-consent
10
+ {...Astro.props}
11
+ {...consentBanner.root}
12
+ hidden
13
+ role="dialog"
14
+ aria-modal="false"
15
+ >
16
+ <slot />
17
+ </atlas-consent>
18
+
19
+ <script>
20
+ import {
21
+ applyConsent,
22
+ CONSENT_ROLES,
23
+ consentApplies,
24
+ consentReopen,
25
+ consentStore,
26
+ } from "./consent.ts";
27
+ import { element } from "./element.ts";
28
+
29
+ element("atlas-consent", CONSENT_ROLES, (host, { answer }, signal) => {
30
+ // The head script left no consent hook: nothing here sets a cookie.
31
+ if (!consentApplies()) return;
32
+
33
+ const store = consentStore();
34
+ const show = (): void => {
35
+ host.hidden = false;
36
+ };
37
+
38
+ for (const { element, values } of answer.all()) {
39
+ element.addEventListener(
40
+ "click",
41
+ () => {
42
+ store.record(values.choice);
43
+ host.hidden = true;
44
+ },
45
+ { signal }
46
+ );
47
+ }
48
+
49
+ // Outside the banner — withdrawing must be as easy as granting.
50
+ for (const { element } of consentReopen.all(document)) {
51
+ element.hidden = false;
52
+ element.addEventListener("click", show, { signal });
53
+ }
54
+
55
+ // Applied, not re-recorded: recording stamps today's date, and an answer
56
+ // renewed on every page view never expires.
57
+ const stored = store.read();
58
+ if (stored === undefined) show();
59
+ else applyConsent(stored.choice);
60
+ });
61
+ </script>
@@ -0,0 +1,44 @@
1
+ ---
2
+ /**
3
+ * The page document: `<html lang dir>`, every head tag `metaFor` produced, and
4
+ * the body tags first thing in `<body>`, so no layout can forget one.
5
+ * `head-start` comes before every tag Atlas writes — a consent manager that must
6
+ * load ahead of the analytics goes there; `head` comes after.
7
+ *
8
+ * ```astro
9
+ * <Document meta={meta} html={{ class: "scroll-smooth" }} body={{ class: "…" }}>
10
+ * <Fragment slot="head"><link rel="preload" … /></Fragment>
11
+ * …page…
12
+ * </Document>
13
+ * ```
14
+ */
15
+ import type { HTMLAttributes } from "astro/types";
16
+ import type { PageMeta } from "../site/index.ts";
17
+ import MetaTags from "./MetaTags.astro";
18
+
19
+ interface Props {
20
+ readonly meta: PageMeta;
21
+ /** `lang` and `dir` are the page's locale's, from `meta`. */
22
+ readonly html?: Omit<HTMLAttributes<"html">, "lang" | "dir">;
23
+ readonly body?: HTMLAttributes<"body">;
24
+ }
25
+
26
+ const { meta, html, body } = Astro.props;
27
+ ---
28
+
29
+ <!doctype html>
30
+ <html
31
+ {...html}
32
+ lang={meta.lang}
33
+ dir={meta.dir}
34
+ >
35
+ <head>
36
+ <slot name="head-start" />
37
+ <MetaTags tags={meta.tags} />
38
+ <slot name="head" />
39
+ </head>
40
+ <body {...body}>
41
+ <MetaTags tags={meta.bodyTags} />
42
+ <slot />
43
+ </body>
44
+ </html>
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * An imported image, as AVIF with a WebP fallback.
4
+ *
5
+ * - **Fixed size** — a logo, a flag: give the `width` or `height` it is drawn
6
+ * at. Served at 1× and 2×.
7
+ * - **Follows the layout** — a photo, a card, a background: give `sizes`. Files
8
+ * are made at standard device widths up to the source's own. A lazy image
9
+ * gets `auto` in front, so browsers that can measure the real width do.
10
+ *
11
+ * `zoom` opens the image larger in a dialog.
12
+ */
13
+ import { Picture } from "astro:assets";
14
+ import type { ComponentProps } from "astro/types";
15
+ import Zoom from "./Zoom.astro";
16
+
17
+ interface Base {
18
+ readonly src: ImageMetadata;
19
+ /** Required; `""` only for decorative artwork. */
20
+ readonly alt: string;
21
+ /** Above the fold: loads eagerly, at high priority. */
22
+ readonly priority?: boolean;
23
+ readonly class?: string;
24
+ readonly zoom?: { readonly closeLabel: string; readonly class?: string };
25
+ }
26
+
27
+ type Props = Base &
28
+ (
29
+ | {
30
+ readonly width: number;
31
+ readonly height?: never;
32
+ readonly sizes?: never;
33
+ }
34
+ | {
35
+ readonly height: number;
36
+ readonly width?: never;
37
+ readonly sizes?: never;
38
+ }
39
+ | {
40
+ readonly sizes: string;
41
+ readonly width?: never;
42
+ readonly height?: never;
43
+ }
44
+ );
45
+
46
+ /** Common device widths, as Astro uses for its own responsive images. */
47
+ const DEVICE_WIDTHS = [640, 750, 828, 1080, 1280, 1668, 1920, 2048, 2560, 3840];
48
+
49
+ const given = Astro.props;
50
+ const { src, alt, priority = false, zoom } = given;
51
+
52
+ const shape =
53
+ given.sizes !== undefined
54
+ ? (() => {
55
+ // Up to the source, and the source itself as the widest.
56
+ const widths = [
57
+ ...DEVICE_WIDTHS.filter((w) => w < src.width),
58
+ src.width,
59
+ ];
60
+ return {
61
+ width: src.width,
62
+ height: src.height,
63
+ widths,
64
+ sizes: priority ? given.sizes : `auto, ${given.sizes}`,
65
+ };
66
+ })()
67
+ : (() => {
68
+ const aspect = src.height / src.width;
69
+ const width =
70
+ given.width ?? Math.round((given.height ?? 0) / aspect);
71
+ return {
72
+ width,
73
+ height: Math.round(width * aspect),
74
+ // Nothing wider than the source: Astro would only upscale it.
75
+ densities: width * 2 <= src.width ? [1, 2] : [1],
76
+ };
77
+ })();
78
+
79
+ const picture: ComponentProps<typeof Picture> = {
80
+ src,
81
+ alt,
82
+ ...shape,
83
+ formats: ["avif", "webp"],
84
+ loading: priority ? "eager" : "lazy",
85
+ decoding: priority ? "sync" : "async",
86
+ fetchpriority: priority ? "high" : "auto",
87
+ class: given.class,
88
+ };
89
+ ---
90
+
91
+ {zoom === undefined ? (
92
+ <Picture {...picture} />
93
+ ) : (
94
+ <Zoom
95
+ src={src}
96
+ alt={alt}
97
+ closeLabel={zoom.closeLabel}
98
+ class={zoom.class}
99
+ >
100
+ <Picture {...picture} />
101
+ </Zoom>
102
+ )}
@@ -1,31 +1,8 @@
1
1
  ---
2
2
  /**
3
- * Renders what `metaFor` described, one element per `MetaTag`.
4
- *
5
- * The other half of `asMetaTag` in `lib/meta/tag.ts`: that one converts *into*
6
- * the union, this one out of it. Both enumerate the same kinds, and keeping
7
- * them in different repositories is what let them drift — the converter had an
8
- * exhaustiveness guard from the day it was written and this file did not, so a
9
- * kind added to `MetaTag` would have rendered here as a bare `<meta>`.
10
- *
11
- * In `lib/astro/` rather than a project's `src/`, unlike every other component
12
- * in this template, because it carries no design and no copy: no classes, no
13
- * translated strings, nothing from `@config`. It is a `kind` → element switch,
14
- * and the four things it knows are each a decision worth making once —
15
- * `set:html` for pre-escaped JSON, `is:inline` so the bundler leaves a
16
- * third-party URL alone, `defer` so measuring a page does not cost what it
17
- * measures, and the zero-sized frame Tag Manager wants. Four chances for a
18
- * repository to get one of them wrong.
19
- *
20
- * ```astro
21
- * import MetaTags from "@escape-game-over/atlas/astro/meta-tags";
22
- *
23
- * <head><MetaTags tags={meta.tags} /></head>
24
- * <body><MetaTags tags={meta.bodyTags} /> …
25
- * ```
26
- *
27
- * Both lists, and in those places: `bodyTags` is usually empty and holds the
28
- * one tag the head would ignore.
3
+ * One element per `MetaTag`. Internal to `Document.astro`: `set:html` for
4
+ * pre-escaped JSON, `is:inline` so the bundler leaves a third-party URL alone,
5
+ * `defer` so measuring a page does not cost what it measures.
29
6
  */
30
7
  // Relative, like every other file in the package: the bare specifier is the
31
8
  // consumer's name for this package, and depending on it here would make the
@@ -0,0 +1,71 @@
1
+ ---
2
+ /**
3
+ * Renders what `site.rich()` returns. The site supplies the look: a class per
4
+ * `[v:name]` variant and one for links; everything else is decided here.
5
+ *
6
+ * ```astro
7
+ * <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} />
8
+ * ```
9
+ */
10
+ import type { HTMLTag } from "astro/types";
11
+ import type { RichText } from "../content/index.ts";
12
+ import { warn } from "../warn.ts";
13
+
14
+ interface Props {
15
+ readonly spans: RichText;
16
+ /** The element wrapping the runs. Defaults to `p`. */
17
+ readonly as?: HTMLTag;
18
+ readonly class?: string;
19
+ /** `[v:name]` → classes. */
20
+ readonly variants?: Readonly<Record<string, string>>;
21
+ /** Classes on every link: `[a:]`, `[mail]` and `[tel]`. */
22
+ readonly link?: string;
23
+ }
24
+
25
+ const {
26
+ spans,
27
+ as: Tag = "p",
28
+ class: className,
29
+ variants = {},
30
+ link,
31
+ }: Props = Astro.props;
32
+
33
+ // The words still render: losing the sentence is worse than losing its style.
34
+ const variantClass = (variant: string): string | undefined => {
35
+ if (!Object.hasOwn(variants, variant)) {
36
+ warn(
37
+ Astro.url.pathname,
38
+ `[v:${variant}] has no class in RichText's variants.`
39
+ );
40
+ }
41
+ return variants[variant];
42
+ };
43
+ ---
44
+
45
+ <Tag class={className}>
46
+ {spans.map((span) => {
47
+ switch (span.kind) {
48
+ case "text":
49
+ return span.text;
50
+ case "bold":
51
+ return <strong>{span.text}</strong>;
52
+ case "variant":
53
+ return <span class={variantClass(span.variant)}>{span.text}</span>;
54
+ case "break":
55
+ return <br />;
56
+ case "link":
57
+ return span.to === "external" ? (
58
+ <a href={span.href} class={link} target="_blank" rel="noopener noreferrer">
59
+ {span.text}
60
+ </a>
61
+ ) : (
62
+ <a href={span.href} class={link}>{span.text}</a>
63
+ );
64
+ case "mail":
65
+ case "tel":
66
+ return <a href={span.href} class={link}>{span.text}</a>;
67
+ default:
68
+ return span satisfies never;
69
+ }
70
+ })}
71
+ </Tag>