@escape-game-over/atlas 0.1.35 → 0.1.37
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/share-images.md +10 -5
- package/package.json +1 -1
- package/src/analytics/consent-storage.ts +26 -0
- package/src/analytics/google.ts +54 -8
- package/src/astro/consent-element.ts +6 -11
- package/src/astro/consent.ts +4 -21
- package/src/astro/images.ts +6 -4
package/docs/share-images.md
CHANGED
|
@@ -22,11 +22,16 @@ So **1200×630 for everything**, and let the square surfaces crop.
|
|
|
22
22
|
|
|
23
23
|
That crop is the whole reason `shareImage` takes a fit at all:
|
|
24
24
|
|
|
25
|
-
| The page's art | `shareImage` call
|
|
26
|
-
| --------------------- |
|
|
27
|
-
| A photograph | `shareImage(src)` — `cover`
|
|
28
|
-
| A logo, square source | `shareImage(src, { fit: "contain", background })` | scaled to 630×630 and centred, so a square crop lands **exactly on the logo** |
|
|
29
|
-
| A logo under ~630px | neither — compose one or get a bigger file
|
|
25
|
+
| The page's art | `shareImage` call | Why |
|
|
26
|
+
| --------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
27
|
+
| A photograph | `shareImage(src)` — `cover`, `jpeg` | edges are scenery; a square crop takes the middle, which is the subject |
|
|
28
|
+
| A logo, square source | `shareImage(src, { fit: "contain", background, format: "png" })` | scaled to 630×630 and centred, so a square crop lands **exactly on the logo** |
|
|
29
|
+
| A logo under ~630px | neither — compose one or get a bigger file | `contain` would enlarge it, and `shareImage` refuses rather than ship a blurred card |
|
|
30
|
+
|
|
31
|
+
The format follows the art, not the surface. A photograph is `jpeg`, the default:
|
|
32
|
+
as `png` it runs to 1–2MB, and WhatsApp shows no preview for an image past
|
|
33
|
+
about 300kB. A logo on a flat field is `png`, which is smaller and sharper for
|
|
34
|
+
flat colour than `jpeg`'s blocks.
|
|
30
35
|
|
|
31
36
|
Both examples use the second row for their catalogue pages: a room's own mark on
|
|
32
37
|
the brand field, so a chat thumbnail shows which room it is rather than a
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a visitor's consent answer is kept, and for how long it counts.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the two halves that read it: the banner's module script
|
|
5
|
+
* (`astro/consent.ts`), and the inline head block Google's tags emit, which
|
|
6
|
+
* restores an answer before any tag fires. Two readers with their own copies of
|
|
7
|
+
* the key or the expiry would disagree the first time either changed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long an answer stands before the question is asked again.
|
|
12
|
+
*
|
|
13
|
+
* Consent is not forever and regulators say so: France's CNIL puts the outside
|
|
14
|
+
* limit at 13 months and recommends six, and the EDPB's position is that a
|
|
15
|
+
* choice made long enough ago is no longer informed. Six is the conservative
|
|
16
|
+
* reading, and the reason the record carries a date at all — without one there
|
|
17
|
+
* is no way to expire it, and no way to answer "when did this visitor agree?",
|
|
18
|
+
* which is a question only ever asked when somebody is already unhappy.
|
|
19
|
+
*/
|
|
20
|
+
export const CONSENT_MONTHS = 6;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `localStorage` key the answer is kept under. Prefixed to keep clear of the
|
|
24
|
+
* bare `"consent"` a third-party widget would reach for.
|
|
25
|
+
*/
|
|
26
|
+
export const CONSENT_KEY = "atlas-consent";
|
package/src/analytics/google.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { NonEmpty } from "../types.ts";
|
|
2
2
|
import { warn } from "../warn.ts";
|
|
3
|
+
import { CONSENT_KEY, CONSENT_MONTHS } from "./consent-storage.ts";
|
|
3
4
|
import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -131,6 +132,17 @@ export interface GoogleSettings {
|
|
|
131
132
|
* unstated case emits.
|
|
132
133
|
*/
|
|
133
134
|
readonly consent?: NonEmpty<ConsentDefaults>;
|
|
135
|
+
/**
|
|
136
|
+
* Whether a visitor's yes also grants the three advertising signals —
|
|
137
|
+
* `ad_storage`, `ad_user_data`, `ad_personalization`. **Off unless stated.**
|
|
138
|
+
*
|
|
139
|
+
* They are for ads and remarketing: showing a visitor ads elsewhere for
|
|
140
|
+
* having been here. A banner that asks about analytics and then grants
|
|
141
|
+
* those as well has not asked about them, which is not consent under the
|
|
142
|
+
* GDPR. So `__consent('granted')` leaves them denied, and a site that does
|
|
143
|
+
* run ads says so here — and says so in its banner.
|
|
144
|
+
*/
|
|
145
|
+
readonly advertising?: true;
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
/**
|
|
@@ -208,22 +220,52 @@ const SIGNAL_KEYS = [
|
|
|
208
220
|
"securityStorage",
|
|
209
221
|
] as const satisfies readonly (keyof ConsentDefaults)[];
|
|
210
222
|
|
|
223
|
+
/** The signals only a site that runs ads may grant — see `advertising`. */
|
|
224
|
+
const AD_KEYS: ReadonlySet<keyof ConsentDefaults> = new Set([
|
|
225
|
+
"adStorage",
|
|
226
|
+
"adUserData",
|
|
227
|
+
"adPersonalization",
|
|
228
|
+
]);
|
|
229
|
+
|
|
211
230
|
/**
|
|
212
231
|
* `window.__consent(state)`, updating precisely what was defaulted.
|
|
213
232
|
*
|
|
214
|
-
* Every signal any default mentioned, and nothing else
|
|
215
|
-
*
|
|
216
|
-
*
|
|
233
|
+
* Every signal any default mentioned, and nothing else — less the advertising
|
|
234
|
+
* ones unless the site runs ads. Updating one that was never defaulted is legal
|
|
235
|
+
* and pointless — Google reads it as a change from its own implicit grant,
|
|
236
|
+
* which was never in force here.
|
|
217
237
|
*/
|
|
218
|
-
function consentUpdater(
|
|
219
|
-
|
|
220
|
-
|
|
238
|
+
function consentUpdater(
|
|
239
|
+
defaults: readonly ConsentDefaults[],
|
|
240
|
+
advertising: boolean
|
|
241
|
+
): string {
|
|
242
|
+
const signals = SIGNAL_KEYS.filter(
|
|
243
|
+
(key) =>
|
|
244
|
+
(advertising || !AD_KEYS.has(key)) &&
|
|
245
|
+
defaults.some((given) => given[key] !== undefined)
|
|
221
246
|
).map((key) => `${literal(CONSENT_KEYS[key])}:s`);
|
|
222
247
|
|
|
223
248
|
if (signals.length === 0) return "";
|
|
224
249
|
return `window.${CONSENT_UPDATE_GLOBAL}=function(s){gtag('consent','update',{${signals.join(",")}})};`;
|
|
225
250
|
}
|
|
226
251
|
|
|
252
|
+
/**
|
|
253
|
+
* A returning visitor's answer, handed to Google before any tag fires.
|
|
254
|
+
*
|
|
255
|
+
* It has to happen here, in the head, ahead of `gtag('config', …)`. Left to the
|
|
256
|
+
* banner's module script it arrives after `gtag.js` has already sent the page
|
|
257
|
+
* view on the defaults — so every page a consenting visitor opened was recorded
|
|
258
|
+
* as denied: no cookie, no returning user, no session. Measured, not supposed:
|
|
259
|
+
* `gcs=G100` on the `page_view`, `G111` only on the hits after it.
|
|
260
|
+
*
|
|
261
|
+
* The same key and expiry `astro/consent.ts` reads, from the one place both
|
|
262
|
+
* take them. Anything unreadable, expired or absent leaves the defaults
|
|
263
|
+
* standing — the banner will ask.
|
|
264
|
+
*/
|
|
265
|
+
function restoredConsent(): string {
|
|
266
|
+
return `try{var c=JSON.parse(localStorage.getItem(${literal(CONSENT_KEY)})),e=new Date(c.at);e.setMonth(e.getMonth()+${CONSENT_MONTHS});if((c.choice==="granted"||c.choice==="denied")&&e>new Date())window.${CONSENT_UPDATE_GLOBAL}(c.choice)}catch(_){}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
227
269
|
/** One `gtag('consent','default',{…})` per stated default, in order. */
|
|
228
270
|
function consentCalls(defaults: readonly ConsentDefaults[]): string {
|
|
229
271
|
return defaults
|
|
@@ -333,13 +375,17 @@ export function googleScripts(
|
|
|
333
375
|
}
|
|
334
376
|
|
|
335
377
|
const consent = google.consent ?? DENIED_BY_DEFAULT;
|
|
378
|
+
// The way back out of the defaults, covering what they denied — the ad
|
|
379
|
+
// signals only for a site that runs ads.
|
|
380
|
+
const updater = consentUpdater(consent, google.advertising === true);
|
|
336
381
|
const inline = [
|
|
337
382
|
"window.dataLayer=window.dataLayer||[];",
|
|
338
383
|
"function gtag(){dataLayer.push(arguments)}",
|
|
339
384
|
// Ahead of everything, which is the entire point of emitting this here.
|
|
340
385
|
consentCalls(consent),
|
|
341
|
-
|
|
342
|
-
|
|
386
|
+
updater,
|
|
387
|
+
// Then a remembered answer, still ahead of the first tag.
|
|
388
|
+
updater === "" ? "" : restoredConsent(),
|
|
343
389
|
tags.length > 0 ? "gtag('js',new Date());" : "",
|
|
344
390
|
...tags.map((id) => `gtag('config',${literal(id)});`),
|
|
345
391
|
// Tag Manager's own loader, once per container. It appends its script
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
applyConsent,
|
|
3
|
-
type ConsentChoice,
|
|
4
|
-
readConsent,
|
|
5
|
-
recordConsent,
|
|
6
|
-
} from "./consent.ts";
|
|
1
|
+
import { type ConsentChoice, readConsent, recordConsent } from "./consent.ts";
|
|
7
2
|
import { element } from "./element.ts";
|
|
8
3
|
import { data, refs } from "./ref.ts";
|
|
9
4
|
|
|
@@ -51,9 +46,9 @@ export const consentBanner = element("atlas-consent", ({ root, signal }) => {
|
|
|
51
46
|
button.addEventListener("click", show, { signal });
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
// A stored answer is already applied: the head block hands it to Google
|
|
50
|
+
// before any tag fires, which this module runs too late to do. Nor is it
|
|
51
|
+
// re-recorded — that stamps today's date, and an answer renewed on every
|
|
52
|
+
// page view never expires.
|
|
53
|
+
if (readConsent() === undefined) show();
|
|
59
54
|
});
|
package/src/astro/consent.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONSENT_KEY, CONSENT_MONTHS } from "../analytics/consent-storage.ts";
|
|
1
2
|
import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -27,24 +28,6 @@ export interface ConsentRecord {
|
|
|
27
28
|
readonly at: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
* How long an answer stands before the question is asked again.
|
|
32
|
-
*
|
|
33
|
-
* Consent is not forever and regulators say so: France's CNIL puts the outside
|
|
34
|
-
* limit at 13 months and recommends six, and the EDPB's position is that a
|
|
35
|
-
* choice made long enough ago is no longer informed. Six is the conservative
|
|
36
|
-
* reading, and the reason the record carries a date at all — without one there
|
|
37
|
-
* is no way to expire it, and no way to answer "when did this visitor agree?",
|
|
38
|
-
* which is a question only ever asked when somebody is already unhappy.
|
|
39
|
-
*/
|
|
40
|
-
const MONTHS = 6;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The `localStorage` key the answer is kept under. Prefixed to keep clear of the
|
|
44
|
-
* bare `"consent"` a third-party widget would reach for.
|
|
45
|
-
*/
|
|
46
|
-
const KEY = "atlas-consent";
|
|
47
|
-
|
|
48
31
|
/**
|
|
49
32
|
* The answer this visitor gave, if it still counts.
|
|
50
33
|
*
|
|
@@ -55,7 +38,7 @@ const KEY = "atlas-consent";
|
|
|
55
38
|
export function readConsent(): ConsentRecord | undefined {
|
|
56
39
|
let raw: string | null = null;
|
|
57
40
|
try {
|
|
58
|
-
raw = localStorage.getItem(
|
|
41
|
+
raw = localStorage.getItem(CONSENT_KEY);
|
|
59
42
|
} catch {
|
|
60
43
|
// Private browsing, or storage disabled. Nothing was remembered, so
|
|
61
44
|
// nothing is assumed.
|
|
@@ -72,7 +55,7 @@ export function readConsent(): ConsentRecord | undefined {
|
|
|
72
55
|
|
|
73
56
|
const expiry = new Date(parsed.at);
|
|
74
57
|
if (Number.isNaN(expiry.getTime())) return undefined;
|
|
75
|
-
expiry.setMonth(expiry.getMonth() +
|
|
58
|
+
expiry.setMonth(expiry.getMonth() + CONSENT_MONTHS);
|
|
76
59
|
if (expiry < new Date()) return undefined;
|
|
77
60
|
|
|
78
61
|
return { choice: parsed.choice, at: parsed.at };
|
|
@@ -88,7 +71,7 @@ export function readConsent(): ConsentRecord | undefined {
|
|
|
88
71
|
export function recordConsent(choice: ConsentChoice): void {
|
|
89
72
|
const record: ConsentRecord = { choice, at: new Date().toISOString() };
|
|
90
73
|
try {
|
|
91
|
-
localStorage.setItem(
|
|
74
|
+
localStorage.setItem(CONSENT_KEY, JSON.stringify(record));
|
|
92
75
|
} catch {
|
|
93
76
|
// Unable to remember it, which is a worse experience and not a wrong
|
|
94
77
|
// one — the choice still applies to this page.
|
package/src/astro/images.ts
CHANGED
|
@@ -30,9 +30,11 @@ interface ShareImageBase {
|
|
|
30
30
|
/** Defaults to 630. */
|
|
31
31
|
readonly height?: number;
|
|
32
32
|
/**
|
|
33
|
-
* Defaults to `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Defaults to `jpeg`: accepted by every scraper, and a 1200×630 photograph
|
|
34
|
+
* lands near 150kB where `png` makes it 1–2MB — past the ~300kB WhatsApp
|
|
35
|
+
* will fetch for a preview at all. Ask for `png` for flat art, a logo on
|
|
36
|
+
* its field, where it is both smaller and sharper. Avoid `avif`, which
|
|
37
|
+
* several scrapers still cannot read.
|
|
36
38
|
*/
|
|
37
39
|
readonly format?: "png" | "jpeg" | "webp";
|
|
38
40
|
/** Ignored by `png`, which is lossless. */
|
|
@@ -146,7 +148,7 @@ export async function shareImage(
|
|
|
146
148
|
): Promise<ImageAsset> {
|
|
147
149
|
const width = options.width ?? SHARE_WIDTH;
|
|
148
150
|
const height = options.height ?? SHARE_HEIGHT;
|
|
149
|
-
const format = options.format ?? "
|
|
151
|
+
const format = options.format ?? "jpeg";
|
|
150
152
|
const fit = options.fit ?? "cover";
|
|
151
153
|
|
|
152
154
|
// Astro's image service never enlarges: ask for a box bigger than the
|