@escape-game-over/atlas 0.1.51 → 0.1.54

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.
@@ -43,7 +43,8 @@ finding them.
43
43
  | `element` | registering a custom element, and ending what it started | what the element does while it is on the page |
44
44
 
45
45
  `consent` is the one module that ships its own behaviour: `<ConsentBanner
46
- analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`) renders the
46
+ slot="consent" analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`),
47
+ passed to `Document`, which fails the build when a page needs one and has none. It renders the
47
48
  site's markup as its children, skips itself — script included — when the
48
49
  analytics need no permission, and remembers, expires and applies the answer. The
49
50
  answer is two, one per category: `analytics` (Google's `analytics_storage`) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.51",
3
+ "version": "0.1.54",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -16,9 +16,11 @@
16
16
  "./astro": "./src/astro/index.ts",
17
17
  "./astro/images": "./src/astro/images.ts",
18
18
  "./client": "./src/astro/client.ts",
19
+ "./astro/auto-dialog": "./src/astro/AutoDialog.astro",
19
20
  "./astro/consent-banner": "./src/astro/ConsentBanner.astro",
20
21
  "./astro/document": "./src/astro/Document.astro",
21
22
  "./astro/element": "./src/astro/AtlasElement.astro",
23
+ "./astro/google-map": "./src/astro/GoogleMap.astro",
22
24
  "./astro/image": "./src/astro/Image.astro",
23
25
  "./astro/rich-text": "./src/astro/RichText.astro"
24
26
  },
@@ -45,6 +47,9 @@
45
47
  "fmt": "biome format --write .",
46
48
  "check-fmt": "biome check . --error-on-warnings"
47
49
  },
50
+ "dependencies": {
51
+ "@types/google.maps": "3.66.4"
52
+ },
48
53
  "peerDependencies": {
49
54
  "@types/node": ">=22",
50
55
  "astro": ">=7",
@@ -70,7 +70,7 @@ export function analyticsScripts(
70
70
  }
71
71
 
72
72
  /**
73
- * Whether this deployment loads anything a visitor has to be asked about.
73
+ * The vendors this deployment loads that a visitor has to be asked about.
74
74
  *
75
75
  * The one place that question is answered, and at build time: a project leaves
76
76
  * the banner — script and all — out of the HTML, rather than shipping a script
@@ -88,10 +88,10 @@ export function analyticsScripts(
88
88
  * permission — which is the failure worth having, since the alternative is a
89
89
  * new vendor silently setting cookies behind a banner that never appears.
90
90
  */
91
- export function consentRequired(
91
+ export function consentVendors(
92
92
  analytics: AnalyticsSettings | undefined
93
- ): boolean {
94
- if (analytics === undefined) return false;
93
+ ): readonly (keyof AnalyticsSettings)[] {
94
+ if (analytics === undefined) return [];
95
95
 
96
96
  const VENDORS: Readonly<Record<keyof AnalyticsSettings, boolean>> = {
97
97
  // Cookieless by design — nothing to permit, nothing to withdraw.
@@ -100,7 +100,16 @@ export function consentRequired(
100
100
  google: googleEmits(analytics.google),
101
101
  };
102
102
 
103
- return Object.values(VENDORS).some(Boolean);
103
+ return (Object.keys(VENDORS) as (keyof AnalyticsSettings)[]).filter(
104
+ (vendor) => VENDORS[vendor]
105
+ );
106
+ }
107
+
108
+ /** Whether any vendor this deployment loads needs permission. */
109
+ export function consentRequired(
110
+ analytics: AnalyticsSettings | undefined
111
+ ): boolean {
112
+ return consentVendors(analytics).length > 0;
104
113
  }
105
114
 
106
115
  /**
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * A native `<dialog>` that opens by itself, around the site's own markup. The
4
+ * browser handles focus, Escape, the backdrop and a click outside
5
+ * (`closedby="any"`). Close it with `<form method="dialog"><button>…</button></form>`.
6
+ *
7
+ * ```astro
8
+ * <AutoDialog id="promo" aria-label="…" after={2000} once="session">…</AutoDialog>
9
+ * ```
10
+ *
11
+ * A dialog opened only by a button or a script needs none of this: write
12
+ * `<dialog closedby="any">`.
13
+ */
14
+ import type { HTMLAttributes } from "astro/types";
15
+ import AtlasElement from "./AtlasElement.astro";
16
+ import { type AutoOpen, autoDialog } from "./auto-dialog.ts";
17
+
18
+ type Props = AutoOpen &
19
+ Omit<HTMLAttributes<"dialog">, "id" | "open"> & {
20
+ readonly id: string;
21
+ };
22
+
23
+ const { after, once, waitForConsent, modal, ...attrs } = Astro.props;
24
+ const auto: AutoOpen = { after, once, waitForConsent, modal };
25
+ ---
26
+
27
+ <script src="./auto-dialog.ts" />
28
+
29
+ <AtlasElement
30
+ of={autoDialog}
31
+ data-auto={JSON.stringify(auto)}
32
+ style="display:contents"
33
+ >
34
+ <dialog
35
+ closedby="any"
36
+ {...attrs}
37
+ >
38
+ <slot />
39
+ </dialog>
40
+ </AtlasElement>
@@ -16,6 +16,9 @@
16
16
  * It sends a reader arriving from elsewhere to the page in the language they
17
17
  * last chose; see `RememberLocale.astro`. `rememberLocale={false}` turns it off.
18
18
  *
19
+ * A deployment whose analytics need permission must pass its banner in the
20
+ * `consent` slot — `<ConsentBanner slot="consent" …>` — or the build fails.
21
+ *
19
22
  * `fonts={site.fonts}` writes the `@font-face` rules and preloads for the
20
23
  * faces `siteFonts(site)` registered in the Astro config.
21
24
  *
@@ -58,6 +61,12 @@ const {
58
61
  rememberCampaign = false,
59
62
  rememberLocale = true,
60
63
  } = Astro.props;
64
+
65
+ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
66
+ throw new Error(
67
+ `${meta.consentVendors.join(", ")} ${meta.consentVendors.length === 1 ? "needs" : "need"} permission, but this page has no consent banner. Pass one to Document: <ConsentBanner slot="consent" …>.`
68
+ );
69
+ }
61
70
  ---
62
71
 
63
72
  <!doctype html>
@@ -71,13 +80,17 @@ const {
71
80
  <MetaTags tags={meta.tags} />
72
81
  {rememberLocale && <RememberLocale />}
73
82
  {Object.entries(fonts).map(([key, font]) => (
74
- <Font cssVariable={`--${key}`} preload={font.preload === true} />
75
- ))}
83
+ <Font
84
+ cssVariable={`--${key}`}
85
+ preload={font.preload === true}
86
+ />
87
+ ))}
76
88
  <slot name="head" />
77
89
  </head>
78
90
  <body {...body}>
79
91
  <MetaTags tags={meta.bodyTags} />
80
92
  <slot />
93
+ <slot name="consent" />
81
94
  {restoreScroll && <ScrollRestore />}
82
95
  {rememberCampaign && <RememberCampaign />}
83
96
  </body>
@@ -0,0 +1,76 @@
1
+ ---
2
+ /**
3
+ * A Google map of `places`, loaded only when asked for. Until then the element
4
+ * shows its children: a picture and a button, say. With `load="click"` (the
5
+ * default) a click anywhere on them loads it; with `"visible"`, scrolling near.
6
+ *
7
+ * `mapId` is required: markers are `AdvancedMarkerElement`, which needs one,
8
+ * and its colours are set on the Map ID in the Cloud console. `colorScheme`
9
+ * picks that style's light or dark variant; the site's own, so a dark site
10
+ * never gets Google's default light map.
11
+ *
12
+ * Give the element its size; the map fills it.
13
+ *
14
+ * ```astro
15
+ * <GoogleMap apiKey={key} mapId={id} colorScheme="DARK" places={[{ at, label, href }]} class="block h-96">
16
+ * <button type="button">Show map</button>
17
+ * </GoogleMap>
18
+ * ```
19
+ */
20
+ import type { HTMLAttributes } from "astro/types";
21
+ import { type MapPlace, mapFrame } from "../map.ts";
22
+ import type { NonEmpty } from "../types.ts";
23
+ import AtlasElement from "./AtlasElement.astro";
24
+ import { type GoogleMapConfig, googleMap } from "./google-map.ts";
25
+
26
+ type Props = HTMLAttributes<"div"> & {
27
+ readonly apiKey: string;
28
+ readonly mapId: string;
29
+ readonly places: NonEmpty<MapPlace>;
30
+ readonly load?: GoogleMapConfig["load"];
31
+ readonly colorScheme: GoogleMapConfig["colorScheme"];
32
+ };
33
+
34
+ const {
35
+ apiKey,
36
+ mapId,
37
+ places,
38
+ load = "click",
39
+ colorScheme,
40
+ ...attrs
41
+ } = Astro.props;
42
+
43
+ const config: GoogleMapConfig = {
44
+ apiKey,
45
+ mapId,
46
+ load,
47
+ colorScheme,
48
+ frame: mapFrame(places),
49
+ places: places.map(({ at, label, href, pin }) => ({
50
+ lat: at.latitude,
51
+ lng: at.longitude,
52
+ label,
53
+ href,
54
+ pin:
55
+ pin === undefined
56
+ ? undefined
57
+ : {
58
+ src: pin.image.src,
59
+ width: pin.width,
60
+ height: Math.round(
61
+ (pin.width * pin.image.height) / pin.image.width
62
+ ),
63
+ },
64
+ })),
65
+ };
66
+ ---
67
+
68
+ <script src="./google-map.ts" />
69
+
70
+ <AtlasElement
71
+ of={googleMap}
72
+ {...attrs}
73
+ data-map={JSON.stringify(config)}
74
+ >
75
+ <slot />
76
+ </AtlasElement>
@@ -0,0 +1,72 @@
1
+ import { CONSENT_EVENT, CONSENT_TAG, readConsent } from "./consent.ts";
2
+ import { element } from "./element.ts";
3
+ import { data, ref } from "./ref.ts";
4
+
5
+ /** When an `<AutoDialog>` opens by itself. */
6
+ export interface AutoOpen {
7
+ /** Milliseconds after load, or after the consent answer with `waitForConsent`. */
8
+ readonly after: number;
9
+ /**
10
+ * Once closed, stays closed for the tab (`session`) or for good (`visitor`).
11
+ * Omitted, it opens on every page.
12
+ */
13
+ readonly once?: "session" | "visitor";
14
+ /** Holds the timer until the consent banner is answered, where it asks. */
15
+ readonly waitForConsent?: boolean;
16
+ /** Defaults to `true`; `false` leaves the page usable behind it. */
17
+ readonly modal?: boolean;
18
+ }
19
+
20
+ const storage = (once: "session" | "visitor"): Storage =>
21
+ once === "session" ? sessionStorage : localStorage;
22
+
23
+ /** Opens `dialog` as `options` say, until `signal` aborts. */
24
+ export function autoOpen(
25
+ dialog: HTMLDialogElement,
26
+ { after, once, waitForConsent = false, modal = true }: AutoOpen,
27
+ signal: AbortSignal
28
+ ): void {
29
+ const key = `atlas-dialog:${dialog.id}`;
30
+ // Storage is blocked in some private windows: then it opens every time.
31
+ try {
32
+ if (once !== undefined && storage(once).getItem(key) !== null) return;
33
+ } catch {}
34
+
35
+ if (once !== undefined) {
36
+ dialog.addEventListener(
37
+ "close",
38
+ () => {
39
+ try {
40
+ storage(once).setItem(key, "closed");
41
+ } catch {}
42
+ },
43
+ { signal }
44
+ );
45
+ }
46
+
47
+ const start = () => {
48
+ const timer = setTimeout(() => {
49
+ if (dialog.open) return;
50
+ if (modal) dialog.showModal();
51
+ else dialog.show();
52
+ }, after);
53
+ signal.addEventListener("abort", () => clearTimeout(timer));
54
+ };
55
+
56
+ const asking =
57
+ document.querySelector(CONSENT_TAG) !== null &&
58
+ readConsent() === undefined;
59
+ if (waitForConsent && asking) {
60
+ addEventListener(CONSENT_EVENT, start, { once: true, signal });
61
+ } else {
62
+ start();
63
+ }
64
+ }
65
+
66
+ export const autoDialog = element("atlas-dialog", ({ root, signal }) => {
67
+ autoOpen(
68
+ ref<HTMLDialogElement>(root, "dialog"),
69
+ JSON.parse(data(root, "auto")),
70
+ signal
71
+ );
72
+ });
@@ -1,5 +1,6 @@
1
1
  import { CONSENT_CATEGORIES } from "../analytics/consent-storage.ts";
2
2
  import {
3
+ CONSENT_TAG,
3
4
  type ConsentCategory,
4
5
  type ConsentChoices,
5
6
  everyCategory,
@@ -36,7 +37,7 @@ import { data, ref, refs } from "./ref.ts";
36
37
  * `data-consent-reopen hidden`: it stays hidden until there is an answer to
37
38
  * withdraw.
38
39
  */
39
- export const consentBanner = element("atlas-consent", ({ root, signal }) => {
40
+ export const consentBanner = element(CONSENT_TAG, ({ root, signal }) => {
40
41
  const boxes = refs<HTMLInputElement>(root, "[data-consent-category]").map(
41
42
  (box) => {
42
43
  const category = data(box, "consent-category");
@@ -86,6 +86,9 @@ export function readConsent(): ConsentRecord | undefined {
86
86
  */
87
87
  export const CONSENT_EVENT = "atlas:consent";
88
88
 
89
+ /** The banner's tag: on the page only where this deployment asks for consent. */
90
+ export const CONSENT_TAG = "atlas-consent";
91
+
89
92
  /**
90
93
  * Remembers an answer, dated now, tells Google about it, and announces it — one
91
94
  * call, so the three cannot come apart.
@@ -0,0 +1,135 @@
1
+ /// <reference types="google.maps" />
2
+ import type { MapFrame } from "../map.ts";
3
+ import { reportDevError } from "./dev-log.ts";
4
+ import { element } from "./element.ts";
5
+ import { data } from "./ref.ts";
6
+
7
+ /** What `GoogleMap.astro` hands the element, serialised. */
8
+ export interface GoogleMapConfig {
9
+ readonly apiKey: string;
10
+ readonly mapId: string;
11
+ readonly load: "click" | "visible";
12
+ readonly colorScheme: google.maps.ColorSchemeString;
13
+ readonly frame: MapFrame;
14
+ readonly places: readonly {
15
+ readonly lat: number;
16
+ readonly lng: number;
17
+ readonly label: string;
18
+ readonly href?: string;
19
+ readonly pin?: {
20
+ readonly src: string;
21
+ readonly width: number;
22
+ readonly height: number;
23
+ };
24
+ }[];
25
+ }
26
+
27
+ const CALLBACK = "__atlasGoogleMaps";
28
+
29
+ let loading: Promise<typeof google.maps> | undefined;
30
+
31
+ /** The Maps JavaScript API, fetched once per page however many maps ask. */
32
+ export function loadGoogleMaps(apiKey: string): Promise<typeof google.maps> {
33
+ loading ??= new Promise((resolve, reject) => {
34
+ (window as unknown as Record<string, () => void>)[CALLBACK] = () =>
35
+ resolve(google.maps);
36
+ const script = document.createElement("script");
37
+ script.src = `https://maps.googleapis.com/maps/api/js?${new URLSearchParams(
38
+ { key: apiKey, v: "weekly", loading: "async", callback: CALLBACK }
39
+ )}`;
40
+ script.async = true;
41
+ script.onerror = () => {
42
+ // So the next click tries again.
43
+ loading = undefined;
44
+ reject(new Error("the Maps JavaScript API failed to load"));
45
+ };
46
+ document.head.append(script);
47
+ });
48
+ return loading;
49
+ }
50
+
51
+ async function draw(root: HTMLElement, config: GoogleMapConfig) {
52
+ const maps = await loadGoogleMaps(config.apiKey);
53
+ const [{ Map: GoogleMapClass, InfoWindow }, { AdvancedMarkerElement }] =
54
+ await Promise.all([
55
+ maps.importLibrary("maps"),
56
+ maps.importLibrary("marker"),
57
+ ]);
58
+
59
+ const container = document.createElement("div");
60
+ container.style.width = "100%";
61
+ container.style.height = "100%";
62
+ root.replaceChildren(container);
63
+
64
+ const { frame } = config;
65
+ const map = new GoogleMapClass(container, {
66
+ mapId: config.mapId,
67
+ colorScheme: config.colorScheme,
68
+ ...(frame.kind === "point"
69
+ ? { center: frame.center, zoom: frame.zoom }
70
+ : {
71
+ center: {
72
+ lat: (frame.south + frame.north) / 2,
73
+ lng: (frame.west + frame.east) / 2,
74
+ },
75
+ zoom: 1,
76
+ }),
77
+ });
78
+ if (frame.kind === "bounds") {
79
+ const { south, west, north, east } = frame;
80
+ map.fitBounds({ south, west, north, east }, 50);
81
+ }
82
+
83
+ const info = new InfoWindow();
84
+ for (const place of config.places) {
85
+ let content: HTMLImageElement | undefined;
86
+ if (place.pin !== undefined) {
87
+ content = document.createElement("img");
88
+ content.src = place.pin.src;
89
+ content.alt = "";
90
+ content.width = place.pin.width;
91
+ content.height = place.pin.height;
92
+ }
93
+ const marker = new AdvancedMarkerElement({
94
+ map,
95
+ position: { lat: place.lat, lng: place.lng },
96
+ title: place.label,
97
+ content,
98
+ gmpClickable: place.href !== undefined,
99
+ });
100
+ const { href } = place;
101
+ if (href === undefined) continue;
102
+ marker.addEventListener("gmp-click", () => {
103
+ const link = document.createElement("a");
104
+ link.href = href;
105
+ link.target = "_blank";
106
+ link.rel = "noopener";
107
+ link.textContent = place.label;
108
+ info.setContent(link);
109
+ info.open({ anchor: marker, map });
110
+ });
111
+ }
112
+ }
113
+
114
+ /** Internal to `GoogleMap.astro`. */
115
+ export const googleMap = element("atlas-google-map", ({ root, signal }) => {
116
+ const config: GoogleMapConfig = JSON.parse(data(root, "map"));
117
+ const show = () => {
118
+ draw(root, config).catch((error) => reportDevError("GoogleMap", error));
119
+ };
120
+
121
+ if (config.load === "click") {
122
+ root.addEventListener("click", show, { once: true, signal });
123
+ return;
124
+ }
125
+ const observer = new IntersectionObserver(
126
+ (entries) => {
127
+ if (!entries.some((entry) => entry.isIntersecting)) return;
128
+ observer.disconnect();
129
+ show();
130
+ },
131
+ { rootMargin: "200px" }
132
+ );
133
+ observer.observe(root);
134
+ return () => observer.disconnect();
135
+ });
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  type ConsentDefaults,
27
27
  type ConsentState,
28
28
  consentRequired,
29
+ consentVendors,
29
30
  type GoogleSettings,
30
31
  type UmamiReplay,
31
32
  type UmamiSettings,
@@ -164,6 +165,7 @@ export {
164
165
  type LlmsItem,
165
166
  type LlmsSection,
166
167
  } from "./llms.ts";
168
+ export type { MapPlace } from "./map.ts";
167
169
  export {
168
170
  type ArticleContent,
169
171
  buildMeta,
package/src/map.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { assertCoordinates, type Coordinates } from "./contact.ts";
2
+ import type { ImageAsset } from "./image.ts";
3
+ import type { NonEmpty } from "./types.ts";
4
+ import type { HttpsUrl } from "./url.ts";
5
+
6
+ /** A marker on a map. */
7
+ export interface MapPlace {
8
+ readonly at: Coordinates;
9
+ /** The marker's title, and the info window's text. */
10
+ readonly label: string;
11
+ /** Opened from the info window, usually the place on Google Maps. None: no info window. */
12
+ readonly href?: HttpsUrl;
13
+ /** A custom pin, drawn `width` pixels wide. */
14
+ readonly pin?: { readonly image: ImageAsset; readonly width: number };
15
+ }
16
+
17
+ /** What the map shows first: one place close up, or every place in view. */
18
+ export type MapFrame =
19
+ | {
20
+ readonly kind: "point";
21
+ readonly center: { readonly lat: number; readonly lng: number };
22
+ readonly zoom: number;
23
+ }
24
+ | {
25
+ readonly kind: "bounds";
26
+ readonly south: number;
27
+ readonly west: number;
28
+ readonly north: number;
29
+ readonly east: number;
30
+ };
31
+
32
+ /** Street level: close enough to find the door. */
33
+ const POINT_ZOOM = 15;
34
+
35
+ export function mapFrame(places: NonEmpty<MapPlace>): MapFrame {
36
+ for (const place of places) assertCoordinates(place.at, place.label);
37
+
38
+ const latitudes = places.map((place) => place.at.latitude);
39
+ const longitudes = places.map((place) => place.at.longitude);
40
+ const south = Math.min(...latitudes);
41
+ const north = Math.max(...latitudes);
42
+ const west = Math.min(...longitudes);
43
+ const east = Math.max(...longitudes);
44
+
45
+ // Bounds around one point would zoom in as far as the map goes.
46
+ if (south === north && west === east) {
47
+ return {
48
+ kind: "point",
49
+ center: { lat: south, lng: west },
50
+ zoom: POINT_ZOOM,
51
+ };
52
+ }
53
+ return { kind: "bounds", south, west, north, east };
54
+ }
@@ -1,3 +1,4 @@
1
+ import { consentVendors } from "../analytics/index.ts";
1
2
  import {
2
3
  type LocaleMeta,
3
4
  type LocalesOf,
@@ -342,6 +343,8 @@ export function createSite<
342
343
  // Nothing. The body channel exists for Tag Manager's `<noscript>`,
343
344
  // and no Google tag reaches this page.
344
345
  bodyTags: [],
346
+ // Umami only, which asks nobody.
347
+ consentVendors: [],
345
348
  };
346
349
  }
347
350
 
@@ -418,6 +421,7 @@ export function createSite<
418
421
  dir: localeMeta[locale].dir,
419
422
  tags: document.head,
420
423
  bodyTags: document.body,
424
+ consentVendors: consentVendors(project.analytics),
421
425
  };
422
426
  }
423
427
 
package/src/site/page.ts CHANGED
@@ -65,6 +65,8 @@ export interface PageMeta {
65
65
  * both, and cannot put either in the other's place.
66
66
  */
67
67
  readonly bodyTags: MetaTag[];
68
+ /** The vendors that need a consent banner on this page; `Document` fails without one. */
69
+ readonly consentVendors: readonly string[];
68
70
  }
69
71
 
70
72
  /**