@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/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.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
  /**
@@ -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>
@@ -0,0 +1,61 @@
1
+ ---
2
+ /**
3
+ * Internal to `Image.astro`. No script: `command="show-modal"` opens the dialog
4
+ * and `closedby="any"` lets a click outside close it. The large image is lazy
5
+ * inside a closed dialog, so it is fetched on open.
6
+ */
7
+
8
+ import { getImage } from "astro:assets";
9
+ import type { ImageMetadata } from "astro";
10
+
11
+ interface Props {
12
+ readonly src: ImageMetadata;
13
+ readonly alt: string;
14
+ readonly closeLabel: string;
15
+ /** On the `<dialog>`. */
16
+ readonly class?: string;
17
+ }
18
+
19
+ const { src, alt, closeLabel, class: dialogClass } = Astro.props;
20
+ const large = await getImage({
21
+ src,
22
+ width: Math.min(src.width, 2048),
23
+ format: "webp",
24
+ });
25
+ // The file's URL carries Astro's content hash, so the id is the same every
26
+ // build. The same image zoomed twice on a page opening one dialog is harmless.
27
+ const id = `atlas-zoom${large.src.replace(/[^\w-]/g, "-")}`;
28
+ ---
29
+
30
+ <button
31
+ type="button"
32
+ commandfor={id}
33
+ command="show-modal"
34
+ style="padding:0;border:0;background:none;cursor:zoom-in"
35
+ >
36
+ <slot />
37
+ </button>
38
+ <dialog
39
+ id={id}
40
+ closedby="any"
41
+ class={dialogClass}
42
+ aria-label={alt}
43
+ >
44
+ <button
45
+ type="button"
46
+ commandfor={id}
47
+ command="close"
48
+ aria-label={closeLabel}
49
+ >
50
+ ×
51
+ </button>
52
+ <img
53
+ src={large.src}
54
+ width={large.attributes.width}
55
+ height={large.attributes.height}
56
+ alt={alt}
57
+ loading="lazy"
58
+ decoding="async"
59
+ style="display:block;max-width:100%;max-height:85vh;height:auto"
60
+ >
61
+ </dialog>
@@ -5,19 +5,29 @@
5
5
  * import { filters, searchBox } from "@escape-game-over/atlas/client";
6
6
  * ```
7
7
  *
8
- * **For the browser.** Everything here exists to touch a live document, and is
9
- * meant for a `<script src>`, not for frontmatter or `astro.config.ts`. A
10
- * component's contract is the one thing both halves need, and it stays on
11
- * `astro/markup`, which touches nothing.
8
+ * The only import path for browser code. Nothing here touches the DOM at
9
+ * import, so a component's contract can be imported from frontmatter too.
12
10
  */
13
11
 
14
12
  export * from "./background-video.ts";
15
13
  export * from "./carousel.ts";
16
- export * from "./consent.ts";
17
- export * from "./dev-log.ts";
18
- export * from "./dom.ts";
19
- export * from "./element.ts";
14
+ export {
15
+ applyConsent,
16
+ type ConsentChoice,
17
+ type ConsentRecord,
18
+ type ConsentStoreOptions,
19
+ consent,
20
+ consentApplies,
21
+ consentStore,
22
+ } from "./consent.ts";
23
+ export { type Connect, type ElementRoles, element } from "./element.ts";
20
24
  export * from "./filters.ts";
21
25
  export * from "./filters-view.ts";
22
- export * from "./markup.ts";
26
+ export {
27
+ type Component,
28
+ type Marked,
29
+ type Markup,
30
+ marker,
31
+ markup,
32
+ } from "./markup.ts";
23
33
  export * from "./youtube.ts";