@escape-game-over/atlas 0.1.85 → 0.1.87

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.
@@ -521,6 +521,12 @@ is what every embed needs and has no taste in:
521
521
  - **Focus moves to the player.** The button that had it has just left the page,
522
522
  which would drop a keyboard reader back at the top of the document.
523
523
 
524
+ Two choices stay with the project. `rel` lets the player suggest videos from
525
+ other channels, and is off by default, so `rel=0` keeps the suggestions to the
526
+ channel's own; YouTube has no setting that removes them altogether. `playsInline`
527
+ keeps the video in the page on an iPhone rather than going full screen on the
528
+ press.
529
+
524
530
  `frame` is called after all of that and before the iframe is in the page, so its
525
531
  word is the last one and nothing is drawn unstyled first.
526
532
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -38,9 +38,10 @@
38
38
  "node": ">=24.0.0"
39
39
  },
40
40
  "scripts": {
41
- "test": "npm run test:types && npm run test:unit && npm run test:examples",
41
+ "test": "npm run test:types && npm run test:unit && npm run test:astro && npm run test:examples",
42
42
  "test:types": "tsc --noEmit && astro check --root checks/astro",
43
43
  "test:unit": "vitest run --coverage",
44
+ "test:astro": "vitest run --config vitest.astro.config.ts",
44
45
  "test:watch": "vitest",
45
46
  "test:examples": "npm run test --workspaces",
46
47
  "lint": "biome check .",
@@ -61,7 +62,7 @@
61
62
  "@biomejs/biome": "2.6.5",
62
63
  "@types/node": "26.6.6",
63
64
  "@vitest/coverage-istanbul": "5.1.0",
64
- "astro": "7.4.5",
65
+ "astro": "7.4.7",
65
66
  "typescript": "6.0.3",
66
67
  "vitest": "5.1.0"
67
68
  }
@@ -27,11 +27,13 @@
27
27
  * </Document>
28
28
  * ```
29
29
  */
30
+
30
31
  import { Font } from "astro:assets";
31
32
  import type { HTMLAttributes } from "astro/types";
32
33
  import type { Fonts } from "../fonts.ts";
33
34
  import { iconLinks } from "../meta/index.ts";
34
35
  import type { PageMeta } from "../site/index.ts";
36
+ import { recordConsentBanner } from "./consent-pages.ts";
35
37
  import MetaTags from "./MetaTags.astro";
36
38
  import RememberCampaign from "./RememberCampaign.astro";
37
39
  import RememberLocale from "./RememberLocale.astro";
@@ -62,6 +64,13 @@ const {
62
64
  rememberLocale = true,
63
65
  } = Astro.props;
64
66
 
67
+ // Before the slots render, so a component in the page can tell: `GoogleMap`
68
+ // refuses to load on sight where nobody can be asked.
69
+ recordConsentBanner(
70
+ Astro.request,
71
+ meta.consentVendors.length > 0 && Astro.slots.has("consent")
72
+ );
73
+
65
74
  if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
66
75
  throw new Error(
67
76
  `${meta.consentVendors.join(", ")} ${meta.consentVendors.length === 1 ? "needs" : "need"} permission, but this page has no consent banner. Pass one to Document: <ConsentBanner slot="consent" …>.`
@@ -2,7 +2,19 @@
2
2
  /**
3
3
  * A Google map of `places`, loaded only when asked for. Until then the element
4
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.
5
+ * default) a click anywhere on them loads it, and that click is the reader
6
+ * asking for Google, so say so on the button. With `"visible"`, scrolling near
7
+ * loads it, but only once the reader has granted marketing consent: Google
8
+ * sets its cookies either way, and nobody asked for the map. A page using
9
+ * `"visible"` without a consent banner fails the build.
10
+ *
11
+ * The check trusts that a filled `consent` slot shows a banner. Astro counts the
12
+ * slot as filled whenever an element names it, so one that renders nothing —
13
+ * `{shown && <div slot="consent">}`, an empty element, a component of your own
14
+ * that renders nothing — passes it with no banner on the page. Put the banner
15
+ * in the slot unconditionally: `ConsentBanner` already renders nothing on a
16
+ * site that needs no permission. Until a reader grants marketing, the map's
17
+ * children stay, so they should read sensibly on their own.
6
18
  *
7
19
  * `mapId` is required: markers are `AdvancedMarkerElement`, which needs one,
8
20
  * and its colours are set on the Map ID in the Cloud console. `colorScheme`
@@ -26,6 +38,7 @@ import type { HTMLAttributes } from "astro/types";
26
38
  import { type MapPlace, mapFrame } from "../map.ts";
27
39
  import type { NonEmpty } from "../types.ts";
28
40
  import AtlasElement from "./AtlasElement.astro";
41
+ import { showsConsentBanner } from "./consent-pages.ts";
29
42
  import { type GoogleMapConfig, googleMap } from "./google-map.ts";
30
43
 
31
44
  type Props = HTMLAttributes<"div"> & {
@@ -45,6 +58,12 @@ const {
45
58
  ...attrs
46
59
  } = Astro.props;
47
60
 
61
+ if (load === "visible" && showsConsentBanner(Astro.request) !== true) {
62
+ throw new Error(
63
+ `${Astro.url.pathname}: GoogleMap with load="visible" waits for marketing consent, but this page shows no consent banner to ask for it. Use load="click", where the reader's click is the asking, or give the page a banner: <ConsentBanner slot="consent" …> in Document, with a vendor that needs it.`
64
+ );
65
+ }
66
+
48
67
  const config: GoogleMapConfig = {
49
68
  apiKey,
50
69
  mapId,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Which pages of a build show a consent banner, for a component that must not
3
+ * render without one.
4
+ *
5
+ * `Document` records it from the page's `meta` before it renders its slots, so
6
+ * anything in the page's body can ask. Keyed by the page's request: each page
7
+ * of a static build renders with its own, and a `WeakMap` lets them go.
8
+ *
9
+ * Server-only, like `Document`.
10
+ */
11
+ const pages = new WeakMap<Request, boolean>();
12
+
13
+ /** What `Document` records: whether this page shows a consent banner. */
14
+ export function recordConsentBanner(request: Request, shown: boolean): void {
15
+ pages.set(request, shown);
16
+ }
17
+
18
+ /**
19
+ * Whether this page shows a consent banner. `undefined` when no `Document` has
20
+ * rendered for it, which a caller should treat as no.
21
+ */
22
+ export function showsConsentBanner(request: Request): boolean | undefined {
23
+ return pages.get(request);
24
+ }
@@ -1,6 +1,7 @@
1
1
  /// <reference types="google.maps" />
2
2
  import type { MapFrame } from "../map.ts";
3
3
  import { appendScript } from "./append-script.ts";
4
+ import { whenGranted } from "./consent-gate.ts";
4
5
  import { reportDevError } from "./dev-log.ts";
5
6
  import { element } from "./element.ts";
6
7
  import { triggered } from "./load-script.ts";
@@ -122,10 +123,24 @@ export const googleMap = element("atlas-google-map", ({ root, signal }) => {
122
123
  draw(root, config).catch((error) => reportDevError("GoogleMap", error));
123
124
  };
124
125
 
125
- triggered(
126
- config.load === "click"
127
- ? { on: "click", element: root }
128
- : { on: "visible", element: root },
126
+ if (config.load === "click") {
127
+ triggered({ on: "click", element: root }, signal).then(show);
128
+ return;
129
+ }
130
+
131
+ // On sight, but never before marketing is granted: the build makes sure
132
+ // there is a banner to grant it. Once drawn, a withdrawn grant leaves the
133
+ // map be — Google has already set what it sets.
134
+ let asked = false;
135
+ whenGranted(
136
+ "marketing",
137
+ {
138
+ grant: () => {
139
+ if (asked) return;
140
+ asked = true;
141
+ triggered({ on: "visible", element: root }, signal).then(show);
142
+ },
143
+ },
129
144
  signal
130
- ).then(show);
145
+ );
131
146
  });
@@ -55,6 +55,19 @@ export interface YouTubeEmbedOptions {
55
55
  * player has no way to tell which video it is.
56
56
  */
57
57
  readonly title: string;
58
+ /**
59
+ * Whether the player may suggest videos from other channels. Off unless
60
+ * asked for: with `rel=0` it suggests only the channel's own, so a trailer
61
+ * on a project's page does not end on someone else's video.
62
+ *
63
+ * YouTube has no setting that removes the suggestions altogether.
64
+ */
65
+ readonly rel?: boolean;
66
+ /**
67
+ * Whether an iPhone plays the video in the page rather than taking it
68
+ * full screen on the press.
69
+ */
70
+ readonly playsInline?: boolean;
58
71
  /**
59
72
  * Anything the project wants on the iframe — classes, above all.
60
73
  *
@@ -106,7 +119,7 @@ const ALLOW =
106
119
  "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
107
120
 
108
121
  export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
109
- const { id, title, frame } = options;
122
+ const { id, title, rel = false, playsInline = false, frame } = options;
110
123
 
111
124
  if (!VIDEO_ID.test(id)) {
112
125
  throw new Error(
@@ -122,7 +135,10 @@ export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
122
135
  // `youtube-nocookie.com` rather than `youtube.com`: the same player, which
123
136
  // sets no cookie until the video is played — and here it is only ever
124
137
  // loaded to be played, so nothing is lost by it.
125
- const src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=1`;
138
+ const url = new URL(`https://www.youtube-nocookie.com/embed/${id}`);
139
+ url.searchParams.set("autoplay", "1");
140
+ url.searchParams.set("rel", rel ? "1" : "0");
141
+ if (playsInline) url.searchParams.set("playsinline", "1");
126
142
 
127
143
  return {
128
144
  attach(trigger, container) {
@@ -133,7 +149,7 @@ export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
133
149
  () => {
134
150
  const iframe =
135
151
  container.ownerDocument.createElement("iframe");
136
- iframe.src = src;
152
+ iframe.src = url.href;
137
153
  iframe.title = title;
138
154
  iframe.allow = ALLOW;
139
155
  iframe.allowFullscreen = true;