@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.
- package/docs/client-scripts.md +6 -0
- package/package.json +4 -3
- package/src/astro/Document.astro +9 -0
- package/src/astro/GoogleMap.astro +20 -1
- package/src/astro/consent-pages.ts +24 -0
- package/src/astro/google-map.ts +20 -5
- package/src/astro/youtube.ts +19 -3
package/docs/client-scripts.md
CHANGED
|
@@ -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.
|
|
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.
|
|
65
|
+
"astro": "7.4.7",
|
|
65
66
|
"typescript": "6.0.3",
|
|
66
67
|
"vitest": "5.1.0"
|
|
67
68
|
}
|
package/src/astro/Document.astro
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/astro/google-map.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
)
|
|
145
|
+
);
|
|
131
146
|
});
|
package/src/astro/youtube.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
152
|
+
iframe.src = url.href;
|
|
137
153
|
iframe.title = title;
|
|
138
154
|
iframe.allow = ALLOW;
|
|
139
155
|
iframe.allowFullscreen = true;
|