@escape-game-over/atlas 0.1.77 → 0.1.79
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/package.json +3 -3
- package/src/analytics/google.ts +11 -0
- package/src/analytics/storage-keys.ts +14 -0
- package/src/analytics/storage.ts +386 -0
- package/src/astro/Document.astro +6 -0
- package/src/astro/RememberLocale.astro +2 -1
- package/src/astro/attribution.ts +6 -5
- package/src/astro/background-video.ts +43 -3
- package/src/astro/inline-script.ts +13 -2
- package/src/astro/remember-locale.ts +6 -3
- package/src/astro/site-icons.ts +61 -0
- package/src/index.ts +12 -0
- package/src/meta/index.ts +9 -11
- package/src/meta/share-image.ts +26 -13
- package/src/site/create.ts +6 -7
- package/src/site/page.ts +7 -0
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.79",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"check-fmt": "biome check . --error-on-warnings"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@types/google.maps": "3.66.
|
|
51
|
+
"@types/google.maps": "3.66.5"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@types/node": ">=22",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@biomejs/biome": "2.5.15",
|
|
61
61
|
"@types/node": "26.6.4",
|
|
62
62
|
"@vitest/coverage-istanbul": "5.0.3",
|
|
63
|
-
"astro": "7.3.
|
|
63
|
+
"astro": "7.3.6",
|
|
64
64
|
"typescript": "6.0.3",
|
|
65
65
|
"vitest": "5.0.3"
|
|
66
66
|
}
|
package/src/analytics/google.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./consent-storage.ts";
|
|
9
9
|
import googleTag from "./google-tag.ts?raw";
|
|
10
10
|
import { type GoogleTagId, invalidId, type TagManagerId } from "./ids.ts";
|
|
11
|
+
import type { StorageVendor } from "./storage.ts";
|
|
11
12
|
import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -150,6 +151,16 @@ export interface GoogleSettings {
|
|
|
150
151
|
* ads has no click id to carry.
|
|
151
152
|
*/
|
|
152
153
|
readonly urlPassthrough?: true;
|
|
154
|
+
/**
|
|
155
|
+
* The vendors these tags bring that their ids do not name: whatever a
|
|
156
|
+
* Tag Manager container fires, or a `GT-` tag's destinations. Lib cannot
|
|
157
|
+
* see inside either, so `storedItems` lists only what is stated here — a
|
|
158
|
+
* `G-` id already implies Google Analytics and an `AW-` id Google Ads.
|
|
159
|
+
*
|
|
160
|
+
* A statement about someone else's configuration, so it goes stale the day
|
|
161
|
+
* they add a tag: whoever runs the container should know it is here.
|
|
162
|
+
*/
|
|
163
|
+
readonly alsoLoads?: readonly StorageVendor[];
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
/**
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys Atlas keeps on a visitor's device besides the consent record
|
|
3
|
+
* (`CONSENT_KEY`). On their own so the browser scripts that use them import
|
|
4
|
+
* two strings, not the catalog `storedItems` lists them from.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `localStorage` key `RememberLocale` keeps the chosen locale under. Its
|
|
9
|
+
* script is inline and imports nothing; `inlineScript` declares this around it.
|
|
10
|
+
*/
|
|
11
|
+
export const LOCALE_KEY = "atlas-locale";
|
|
12
|
+
|
|
13
|
+
/** The `sessionStorage` key `rememberCampaign` keeps a visit's `utm_*` under. */
|
|
14
|
+
export const CAMPAIGN_KEY = "atlas-campaign";
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONSENT_KEY,
|
|
3
|
+
CONSENT_MONTHS,
|
|
4
|
+
type ConsentCategory,
|
|
5
|
+
} from "./consent-storage.ts";
|
|
6
|
+
import { type AnalyticsSettings, consentVendors } from "./index.ts";
|
|
7
|
+
import { CAMPAIGN_KEY, LOCALE_KEY } from "./storage-keys.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a deployment keeps on a visitor's device, row by row, for the table a
|
|
11
|
+
* privacy notice owes them: each name, who sets it, how long it stays, and
|
|
12
|
+
* what has to be granted first.
|
|
13
|
+
*
|
|
14
|
+
* Here because the facts are the vendors' and the library's, not a site's.
|
|
15
|
+
* Cookie names and lifetimes change when a vendor changes its tag, and the
|
|
16
|
+
* three keys Atlas sets itself change when Atlas does; a table copied into
|
|
17
|
+
* each site is a table that is wrong in each site on a different day. The
|
|
18
|
+
* words — what each row is *for*, in each language — stay with the project,
|
|
19
|
+
* keyed by `StoredItem.id`.
|
|
20
|
+
*
|
|
21
|
+
* Built from the same settings that load the tags, so a vendor cannot run
|
|
22
|
+
* without a row. What lib cannot see — the tags inside a Tag Manager
|
|
23
|
+
* container — comes from `GoogleSettings.alsoLoads`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A vendor whose tag sets storage, by product rather than by company. */
|
|
27
|
+
export type StorageVendor =
|
|
28
|
+
| "google-analytics"
|
|
29
|
+
| "google-ads"
|
|
30
|
+
| "linkedin"
|
|
31
|
+
| "meta"
|
|
32
|
+
| "tiktok"
|
|
33
|
+
| "snapchat"
|
|
34
|
+
| "axon"
|
|
35
|
+
| "drip"
|
|
36
|
+
| "clarity";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Each vendor's product as it calls itself, for a table's "set by" column.
|
|
40
|
+
* Names, not copy: the same in every language, so a project translates only
|
|
41
|
+
* the site's own label.
|
|
42
|
+
*/
|
|
43
|
+
export const STORAGE_VENDOR_NAMES = {
|
|
44
|
+
"google-analytics": "Google Analytics",
|
|
45
|
+
"google-ads": "Google Ads",
|
|
46
|
+
linkedin: "LinkedIn",
|
|
47
|
+
meta: "Meta",
|
|
48
|
+
tiktok: "TikTok",
|
|
49
|
+
snapchat: "Snapchat",
|
|
50
|
+
axon: "AppLovin Axon",
|
|
51
|
+
drip: "Drip",
|
|
52
|
+
clarity: "Microsoft Clarity",
|
|
53
|
+
} as const satisfies Record<StorageVendor, string>;
|
|
54
|
+
|
|
55
|
+
/** A span `Intl.NumberFormat` can word in any locale: `{ 2, "year" }`. */
|
|
56
|
+
export interface Lifetime {
|
|
57
|
+
readonly count: number;
|
|
58
|
+
readonly unit: "day" | "month" | "year";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The vendor-domain row of a vendor that has one. */
|
|
62
|
+
type OwnRow = `${VendorWithOwn}-own`;
|
|
63
|
+
|
|
64
|
+
/** The keys Atlas itself sets. */
|
|
65
|
+
export type AtlasStoredId = "atlas-locale" | "atlas-campaign" | "atlas-consent";
|
|
66
|
+
|
|
67
|
+
/** One row's id: what a project keys the row's wording by. */
|
|
68
|
+
export type StoredId = AtlasStoredId | StorageVendor | OwnRow;
|
|
69
|
+
|
|
70
|
+
export interface StoredItem {
|
|
71
|
+
readonly id: StoredId;
|
|
72
|
+
/** As the browser shows them; a pattern like `_ga_<id>` where the vendor appends one. */
|
|
73
|
+
readonly names: readonly string[];
|
|
74
|
+
/** Who sets it: this site, or the vendor. */
|
|
75
|
+
readonly provider: "site" | StorageVendor;
|
|
76
|
+
/** Set on the vendor's own domains rather than the site's; absent for the site's. */
|
|
77
|
+
readonly domains?: readonly string[];
|
|
78
|
+
/** A span; `tab` for session storage; `kept` until the visitor clears it. */
|
|
79
|
+
readonly lasts: Lifetime | "tab" | "kept";
|
|
80
|
+
/** What has to be granted first; `none` is kept without asking. */
|
|
81
|
+
readonly consent: ConsentCategory | "none";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface StoredItemsOptions {
|
|
85
|
+
/** As passed to `Document`, and with its default: `true`. */
|
|
86
|
+
readonly rememberLocale?: boolean;
|
|
87
|
+
/** As passed to `Document`, and with its default: `false`. */
|
|
88
|
+
readonly rememberCampaign?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Rows the site sets first, then by what has to be granted — nothing,
|
|
91
|
+
* analytics, marketing — and otherwise in catalog order. Off, the rows
|
|
92
|
+
* come in catalog order: Atlas's own, then each vendor's site row before
|
|
93
|
+
* its vendor-domain one.
|
|
94
|
+
*/
|
|
95
|
+
readonly sort?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Stored {
|
|
99
|
+
readonly names: readonly string[];
|
|
100
|
+
/** The longest of the group's. */
|
|
101
|
+
readonly lasts: Lifetime;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface VendorStorage {
|
|
105
|
+
readonly consent: ConsentCategory;
|
|
106
|
+
/** On the site's domain. */
|
|
107
|
+
readonly site: Stored;
|
|
108
|
+
/** On the vendor's, when its tag sets any there. */
|
|
109
|
+
readonly own?: Stored & { readonly domains: readonly string[] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const day = (count: number): Lifetime => ({ count, unit: "day" });
|
|
113
|
+
const month = (count: number): Lifetime => ({ count, unit: "month" });
|
|
114
|
+
const year = (count: number): Lifetime => ({ count, unit: "year" });
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Each vendor's storage, from its own cookie documentation, read 2026-10-06.
|
|
118
|
+
* These go stale without anything failing, so each entry names its source;
|
|
119
|
+
* where the vendor's docs were silent, the source is its tag's own code.
|
|
120
|
+
*
|
|
121
|
+
* What a tag sets only in a mode Atlas does not configure — server-side
|
|
122
|
+
* tagging's `FPID`, Drip's pop-ups — is left out, and so is what a vendor
|
|
123
|
+
* documents only for its own sites or never names.
|
|
124
|
+
*/
|
|
125
|
+
const CATALOG = {
|
|
126
|
+
// support.google.com/analytics/answer/11397207. Two years as Google
|
|
127
|
+
// states it; Chrome caps any cookie at 400 days.
|
|
128
|
+
"google-analytics": {
|
|
129
|
+
consent: "analytics",
|
|
130
|
+
site: { names: ["_ga", "_ga_<id>"], lasts: year(2) },
|
|
131
|
+
},
|
|
132
|
+
// business.safety.google/adscookies, support.google.com/tagmanager/answer/7549390.
|
|
133
|
+
// `IDE` is 13 months in the EEA and UK, 24 elsewhere: the EEA's, since a
|
|
134
|
+
// notice that needs this table is written for it. Seen live 2026-10-06.
|
|
135
|
+
"google-ads": {
|
|
136
|
+
consent: "marketing",
|
|
137
|
+
site: {
|
|
138
|
+
names: [
|
|
139
|
+
"_gcl_au",
|
|
140
|
+
"_gcl_aw",
|
|
141
|
+
"_gcl_gs",
|
|
142
|
+
"_gcl_dc",
|
|
143
|
+
"_gcl_gb",
|
|
144
|
+
"_gcl_ag",
|
|
145
|
+
"_gcl_ls",
|
|
146
|
+
],
|
|
147
|
+
lasts: day(90),
|
|
148
|
+
},
|
|
149
|
+
own: {
|
|
150
|
+
names: ["IDE", "GCL_AW_P", "DSID", "test_cookie", "ar_debug"],
|
|
151
|
+
lasts: month(13),
|
|
152
|
+
domains: ["doubleclick.net", "googleadservices.com", "google.com"],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
// linkedin.com/legal/l/cookie-table. Seen live 2026-10-06.
|
|
156
|
+
linkedin: {
|
|
157
|
+
consent: "marketing",
|
|
158
|
+
site: { names: ["li_fat_id", "li_giant", "ln_or"], lasts: day(30) },
|
|
159
|
+
own: {
|
|
160
|
+
names: [
|
|
161
|
+
"bcookie",
|
|
162
|
+
"bscookie",
|
|
163
|
+
"li_gc",
|
|
164
|
+
"lidc",
|
|
165
|
+
"li_sugr",
|
|
166
|
+
"UserMatchHistory",
|
|
167
|
+
"AnalyticsSyncHistory",
|
|
168
|
+
"lms_ads",
|
|
169
|
+
"lms_analytics",
|
|
170
|
+
"_guid",
|
|
171
|
+
],
|
|
172
|
+
lasts: year(1),
|
|
173
|
+
domains: ["linkedin.com"],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
// developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc,
|
|
177
|
+
// facebook.com/privacy/policies/cookies. `datr` is facebook.com's own,
|
|
178
|
+
// there only for someone who has visited it; the pixel does not set it.
|
|
179
|
+
meta: {
|
|
180
|
+
consent: "marketing",
|
|
181
|
+
site: { names: ["_fbp", "_fbc"], lasts: day(90) },
|
|
182
|
+
own: { names: ["fr"], lasts: day(90), domains: ["facebook.com"] },
|
|
183
|
+
},
|
|
184
|
+
// ads.tiktok.com/help/article/using-cookies-with-tiktok-pixel.
|
|
185
|
+
tiktok: {
|
|
186
|
+
consent: "marketing",
|
|
187
|
+
site: {
|
|
188
|
+
names: [
|
|
189
|
+
"_ttp",
|
|
190
|
+
"_tt_enable_cookie",
|
|
191
|
+
"ttcsid",
|
|
192
|
+
"ttcsid_<id>",
|
|
193
|
+
"ttclid",
|
|
194
|
+
],
|
|
195
|
+
lasts: month(13),
|
|
196
|
+
},
|
|
197
|
+
own: { names: ["_ttp"], lasts: month(13), domains: ["tiktok.com"] },
|
|
198
|
+
},
|
|
199
|
+
// snap.com/en-US/privacy/cookie-information gives `_scid` and `_sctr` a
|
|
200
|
+
// year; the pixel's own code sets 13 months, which is what lands.
|
|
201
|
+
snapchat: {
|
|
202
|
+
consent: "marketing",
|
|
203
|
+
site: {
|
|
204
|
+
names: ["_scid", "_sctr", "u_sclid", "u_scsid"],
|
|
205
|
+
lasts: month(13),
|
|
206
|
+
},
|
|
207
|
+
own: { names: ["sc_at"], lasts: year(1), domains: ["snapchat.com"] },
|
|
208
|
+
},
|
|
209
|
+
// support.applovin.com/en/ecommerce/google-tag-manager names `_axwrt`;
|
|
210
|
+
// the rest and the 365 days are from s.axon.ai/pixel.js. AppLovin lists
|
|
211
|
+
// no cookies of its own domains.
|
|
212
|
+
axon: {
|
|
213
|
+
consent: "marketing",
|
|
214
|
+
site: {
|
|
215
|
+
names: ["_axwrt", "_axeid", "_axart", "ax_visitor", "_axsid"],
|
|
216
|
+
lasts: day(365),
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
// help.drip.com/hc/en-us/articles/4424702605453 gives the year but not
|
|
220
|
+
// the name, which is cookiedatabase.org's.
|
|
221
|
+
drip: {
|
|
222
|
+
consent: "marketing",
|
|
223
|
+
site: { names: ["_drip_client_<id>"], lasts: year(1) },
|
|
224
|
+
},
|
|
225
|
+
// learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-cookies
|
|
226
|
+
// names them; the lifetimes are from microsoft/clarity's source, and
|
|
227
|
+
// `MUID`'s year from cookiedatabase.org.
|
|
228
|
+
clarity: {
|
|
229
|
+
consent: "analytics",
|
|
230
|
+
site: { names: ["_clck", "_clsk", "_cltk"], lasts: year(1) },
|
|
231
|
+
own: {
|
|
232
|
+
names: ["CLID", "MUID", "ANONCHK", "MR", "SM"],
|
|
233
|
+
lasts: year(1),
|
|
234
|
+
domains: ["clarity.ms"],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
} as const satisfies Record<StorageVendor, VendorStorage>;
|
|
238
|
+
|
|
239
|
+
type VendorWithOwn = {
|
|
240
|
+
[V in keyof typeof CATALOG]: (typeof CATALOG)[V] extends { own: object }
|
|
241
|
+
? V
|
|
242
|
+
: never;
|
|
243
|
+
}[keyof typeof CATALOG];
|
|
244
|
+
|
|
245
|
+
/** Catalog order, which is also the order rows come in unsorted. */
|
|
246
|
+
const VENDOR_ORDER: readonly StorageVendor[] = [
|
|
247
|
+
"google-analytics",
|
|
248
|
+
"google-ads",
|
|
249
|
+
"linkedin",
|
|
250
|
+
"meta",
|
|
251
|
+
"tiktok",
|
|
252
|
+
"snapchat",
|
|
253
|
+
"axon",
|
|
254
|
+
"drip",
|
|
255
|
+
"clarity",
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
/** The vendors a deployment's settings load, in catalog order. */
|
|
259
|
+
function loadedVendors(
|
|
260
|
+
analytics: AnalyticsSettings | undefined
|
|
261
|
+
): readonly StorageVendor[] {
|
|
262
|
+
const loaded = new Set<StorageVendor>();
|
|
263
|
+
const google = analytics?.google;
|
|
264
|
+
for (const id of google?.tagIds ?? []) {
|
|
265
|
+
if (id.startsWith("G-")) loaded.add("google-analytics");
|
|
266
|
+
if (id.startsWith("AW-")) loaded.add("google-ads");
|
|
267
|
+
}
|
|
268
|
+
for (const vendor of google?.alsoLoads ?? []) loaded.add(vendor);
|
|
269
|
+
if (analytics?.meta !== undefined) loaded.add("meta");
|
|
270
|
+
if (analytics?.tiktok !== undefined) loaded.add("tiktok");
|
|
271
|
+
if (analytics?.snapchat !== undefined) loaded.add("snapchat");
|
|
272
|
+
if (analytics?.axon !== undefined) loaded.add("axon");
|
|
273
|
+
if (analytics?.drip !== undefined) loaded.add("drip");
|
|
274
|
+
if (analytics?.clarity !== undefined) loaded.add("clarity");
|
|
275
|
+
return VENDOR_ORDER.filter((vendor) => loaded.has(vendor));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const CONSENT_ORDER: readonly StoredItem["consent"][] = [
|
|
279
|
+
"none",
|
|
280
|
+
"analytics",
|
|
281
|
+
"marketing",
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
/** The rows the `sort` option puts first come first; ties keep their order. */
|
|
285
|
+
export function compareStoredItems(a: StoredItem, b: StoredItem): number {
|
|
286
|
+
return (
|
|
287
|
+
Number(b.provider === "site") - Number(a.provider === "site") ||
|
|
288
|
+
CONSENT_ORDER.indexOf(a.consent) - CONSENT_ORDER.indexOf(b.consent)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Each row with what the project says it is for — a message key, usually,
|
|
294
|
+
* since the words are the project's and in every locale.
|
|
295
|
+
*
|
|
296
|
+
* Throws, naming every row without one, so a vendor switched on, or a row a
|
|
297
|
+
* new Atlas adds, fails the build until the site has said what it is for: a
|
|
298
|
+
* notice that lists a cookie without a purpose is the one thing it must not
|
|
299
|
+
* publish. `purposes` may name rows this deployment does not have; a site
|
|
300
|
+
* keeps its wording when a vendor is switched off.
|
|
301
|
+
*/
|
|
302
|
+
export function withPurposes<K>(
|
|
303
|
+
rows: readonly StoredItem[],
|
|
304
|
+
purposes: Partial<Record<StoredId, K>>
|
|
305
|
+
): readonly (StoredItem & { readonly purpose: K })[] {
|
|
306
|
+
const missing = rows.filter((row) => purposes[row.id] === undefined);
|
|
307
|
+
if (missing.length > 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to the purposes given to withPurposes.`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return rows.map((row) => ({ ...row, purpose: purposes[row.id] as K }));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Everything this deployment keeps on a device: Atlas's own keys, then each
|
|
317
|
+
* loaded vendor's.
|
|
318
|
+
*
|
|
319
|
+
* Atlas's rows follow what the page renders, which `Document` decides from
|
|
320
|
+
* its props, so pass the same `rememberLocale` and `rememberCampaign` here.
|
|
321
|
+
* The consent record is listed whenever there is a banner to answer.
|
|
322
|
+
*/
|
|
323
|
+
export function storedItems(
|
|
324
|
+
analytics: AnalyticsSettings | undefined,
|
|
325
|
+
options: StoredItemsOptions = {}
|
|
326
|
+
): readonly StoredItem[] {
|
|
327
|
+
const {
|
|
328
|
+
rememberLocale = true,
|
|
329
|
+
rememberCampaign = false,
|
|
330
|
+
sort = false,
|
|
331
|
+
} = options;
|
|
332
|
+
const asked = consentVendors(analytics).length > 0;
|
|
333
|
+
|
|
334
|
+
const rows: StoredItem[] = [];
|
|
335
|
+
if (rememberLocale) {
|
|
336
|
+
rows.push({
|
|
337
|
+
id: "atlas-locale",
|
|
338
|
+
names: [LOCALE_KEY],
|
|
339
|
+
provider: "site",
|
|
340
|
+
lasts: "kept",
|
|
341
|
+
consent: "none",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Kept only once analytics is granted, so only where it can be.
|
|
345
|
+
if (rememberCampaign && asked) {
|
|
346
|
+
rows.push({
|
|
347
|
+
id: "atlas-campaign",
|
|
348
|
+
names: [CAMPAIGN_KEY],
|
|
349
|
+
provider: "site",
|
|
350
|
+
lasts: "tab",
|
|
351
|
+
consent: "analytics",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (asked) {
|
|
355
|
+
rows.push({
|
|
356
|
+
id: "atlas-consent",
|
|
357
|
+
names: [CONSENT_KEY],
|
|
358
|
+
provider: "site",
|
|
359
|
+
lasts: month(CONSENT_MONTHS),
|
|
360
|
+
consent: "none",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const vendor of loadedVendors(analytics)) {
|
|
365
|
+
const entry: VendorStorage = CATALOG[vendor];
|
|
366
|
+
rows.push({
|
|
367
|
+
id: vendor,
|
|
368
|
+
names: entry.site.names,
|
|
369
|
+
provider: vendor,
|
|
370
|
+
lasts: entry.site.lasts,
|
|
371
|
+
consent: entry.consent,
|
|
372
|
+
});
|
|
373
|
+
if (entry.own !== undefined) {
|
|
374
|
+
rows.push({
|
|
375
|
+
id: `${vendor}-own` as OwnRow,
|
|
376
|
+
names: entry.own.names,
|
|
377
|
+
provider: vendor,
|
|
378
|
+
domains: entry.own.domains,
|
|
379
|
+
lasts: entry.own.lasts,
|
|
380
|
+
consent: entry.consent,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return sort ? rows.toSorted(compareStoredItems) : rows;
|
|
386
|
+
}
|
package/src/astro/Document.astro
CHANGED
|
@@ -30,11 +30,13 @@
|
|
|
30
30
|
import { Font } from "astro:assets";
|
|
31
31
|
import type { HTMLAttributes } from "astro/types";
|
|
32
32
|
import type { Fonts } from "../fonts.ts";
|
|
33
|
+
import { iconLinks } from "../meta/index.ts";
|
|
33
34
|
import type { PageMeta } from "../site/index.ts";
|
|
34
35
|
import MetaTags from "./MetaTags.astro";
|
|
35
36
|
import RememberCampaign from "./RememberCampaign.astro";
|
|
36
37
|
import RememberLocale from "./RememberLocale.astro";
|
|
37
38
|
import ScrollRestore from "./ScrollRestore.astro";
|
|
39
|
+
import { siteIcons } from "./site-icons.ts";
|
|
38
40
|
|
|
39
41
|
interface Props {
|
|
40
42
|
readonly meta: PageMeta;
|
|
@@ -65,6 +67,9 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
|
|
|
65
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" …>.`
|
|
66
68
|
);
|
|
67
69
|
}
|
|
70
|
+
|
|
71
|
+
// Here rather than in `meta.tags`: cutting the icon to size needs the build.
|
|
72
|
+
const icons = iconLinks(await siteIcons(meta.icon));
|
|
68
73
|
---
|
|
69
74
|
|
|
70
75
|
<!doctype html>
|
|
@@ -76,6 +81,7 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
|
|
|
76
81
|
<head>
|
|
77
82
|
<slot name="head-start" />
|
|
78
83
|
<MetaTags tags={meta.tags} />
|
|
84
|
+
<MetaTags tags={icons} />
|
|
79
85
|
{rememberLocale && <RememberLocale />}
|
|
80
86
|
{Object.entries(fonts).map(([key, font]) => (
|
|
81
87
|
<Font
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* Rendered by `Document` after the head tags, whose alternates it reads.
|
|
12
12
|
* Inline and blocking, so the wrong language is never painted.
|
|
13
13
|
*/
|
|
14
|
+
import { LOCALE_KEY } from "../analytics/storage-keys.ts";
|
|
14
15
|
import { inlineScript } from "./inline-script.ts";
|
|
15
16
|
import source from "./remember-locale.ts?raw";
|
|
16
17
|
|
|
17
|
-
const code = await inlineScript(source);
|
|
18
|
+
const code = await inlineScript(source, { LOCALE_KEY });
|
|
18
19
|
---
|
|
19
20
|
|
|
20
21
|
<script
|
package/src/astro/attribution.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* in its own `_gcl_aw` cookie once granted. `adClick()` reads it from there.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { CAMPAIGN_KEY } from "../analytics/storage-keys.ts";
|
|
23
|
+
|
|
22
24
|
/** The campaign tags a link can carry. */
|
|
23
25
|
export type CampaignTag =
|
|
24
26
|
| "utm_source"
|
|
@@ -43,9 +45,6 @@ const AD_CLICK_IDS: readonly AdClickId[] = ["gclid", "gbraid", "wbraid"];
|
|
|
43
45
|
export type Campaign = Partial<Record<CampaignTag, string>>;
|
|
44
46
|
export type AdClick = Partial<Record<AdClickId, string>>;
|
|
45
47
|
|
|
46
|
-
/** The `sessionStorage` key the tags are kept under. */
|
|
47
|
-
const KEY = "atlas-campaign";
|
|
48
|
-
|
|
49
48
|
/** The given parameters present on this page's URL. */
|
|
50
49
|
function fromUrl<K extends string>(
|
|
51
50
|
names: readonly K[]
|
|
@@ -64,7 +63,7 @@ export function rememberCampaign(): void {
|
|
|
64
63
|
const landed = fromUrl(CAMPAIGN_TAGS);
|
|
65
64
|
if (Object.keys(landed).length === 0) return;
|
|
66
65
|
try {
|
|
67
|
-
sessionStorage.setItem(
|
|
66
|
+
sessionStorage.setItem(CAMPAIGN_KEY, JSON.stringify(landed));
|
|
68
67
|
} catch {
|
|
69
68
|
// Storage refused: the tags still count on the page they arrived on.
|
|
70
69
|
}
|
|
@@ -75,7 +74,9 @@ export function campaign(): Campaign {
|
|
|
75
74
|
const here = fromUrl(CAMPAIGN_TAGS);
|
|
76
75
|
if (Object.keys(here).length > 0) return here;
|
|
77
76
|
try {
|
|
78
|
-
const kept: unknown = JSON.parse(
|
|
77
|
+
const kept: unknown = JSON.parse(
|
|
78
|
+
sessionStorage.getItem(CAMPAIGN_KEY) ?? "{}"
|
|
79
|
+
);
|
|
79
80
|
if (typeof kept !== "object" || kept === null) return {};
|
|
80
81
|
const found: Campaign = {};
|
|
81
82
|
for (const tag of CAMPAIGN_TAGS) {
|
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
* quietly wrong:
|
|
27
27
|
*
|
|
28
28
|
* - **Reduced motion starts it paused.** A reader who asked their system for
|
|
29
|
-
* less motion should not get a full-width loop behind the headline.
|
|
29
|
+
* less motion should not get a full-width loop behind the headline. So does
|
|
30
|
+
* Data Saver, where the browser says so: a loop is megabytes nobody asked for.
|
|
31
|
+
* - **Not before the page has loaded.** A hero loop is the heaviest thing on
|
|
32
|
+
* the page, and started at once it shares the connection with the poster —
|
|
33
|
+
* usually the largest paint — and with everything else the page needs first.
|
|
34
|
+
* It starts on `load`, unless the reader pressed play before that.
|
|
30
35
|
* - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for
|
|
31
36
|
* more than five seconds, and a loop moves forever. The control is the
|
|
32
37
|
* project's; the state it shows is this.
|
|
@@ -45,7 +50,9 @@
|
|
|
45
50
|
* `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
|
|
46
51
|
* starts the video itself, and without this script there is no control to stop
|
|
47
52
|
* it with, while with the attribute a reduced-motion reader sees the first
|
|
48
|
-
* frames move before this gets to pause them
|
|
53
|
+
* frames move before this gets to pause them, and the download starts before
|
|
54
|
+
* the page has loaded. Left in, everything else still works. `preload="none"`
|
|
55
|
+
* or `"metadata"` keeps the browser from fetching the loop on its own.
|
|
49
56
|
*/
|
|
50
57
|
|
|
51
58
|
export interface BackgroundVideoState {
|
|
@@ -99,6 +106,27 @@ export interface BackgroundVideo {
|
|
|
99
106
|
|
|
100
107
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
|
101
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Whether the video starts paused when the reader has not chosen: they asked
|
|
111
|
+
* their system for less motion, or for less data. Data Saver is Chromium's,
|
|
112
|
+
* the only engine that says; elsewhere this is reduced motion alone.
|
|
113
|
+
*/
|
|
114
|
+
function stillByDefault(): boolean {
|
|
115
|
+
const connection = (
|
|
116
|
+
navigator as Navigator & { connection?: { saveData?: boolean } }
|
|
117
|
+
).connection;
|
|
118
|
+
return matchMedia(REDUCED_MOTION).matches || connection?.saveData === true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Runs `then` once the page has loaded: now if it has, else on `load`. */
|
|
122
|
+
function whenLoaded(then: () => void, signal: AbortSignal): void {
|
|
123
|
+
if (document.readyState === "complete") {
|
|
124
|
+
then();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
addEventListener("load", then, { once: true, signal });
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
/**
|
|
103
131
|
* `HTMLMediaElement.NETWORK_NO_SOURCE`, as a literal.
|
|
104
132
|
*
|
|
@@ -278,7 +306,19 @@ export function backgroundVideo(
|
|
|
278
306
|
);
|
|
279
307
|
}
|
|
280
308
|
|
|
281
|
-
|
|
309
|
+
if (chosen !== undefined) {
|
|
310
|
+
apply(chosen);
|
|
311
|
+
} else if (stillByDefault()) {
|
|
312
|
+
apply(false);
|
|
313
|
+
} else {
|
|
314
|
+
// Not before the page has loaded: a loop of a few megabytes
|
|
315
|
+
// started now shares the connection with the poster the reader
|
|
316
|
+
// is waiting for, and on a phone the poster loses.
|
|
317
|
+
whenLoaded(() => {
|
|
318
|
+
// Unless the reader chose meanwhile, or it moved on.
|
|
319
|
+
if (video === element && chosen === undefined) apply(true);
|
|
320
|
+
}, signal);
|
|
321
|
+
}
|
|
282
322
|
sync();
|
|
283
323
|
|
|
284
324
|
return () => {
|
|
@@ -7,9 +7,20 @@ import { minifySync, transformWithOxc } from "vite";
|
|
|
7
7
|
* Astro writes an inline script exactly as written, comments and all. Both
|
|
8
8
|
* steps are needed — the minifier accepts TypeScript and silently keeps its
|
|
9
9
|
* annotations, which a browser then fails to parse.
|
|
10
|
+
*
|
|
11
|
+
* `constants` are declared around the source, which `declare const`s them: a
|
|
12
|
+
* classic script imports nothing, so this is how a value spelled once in the
|
|
13
|
+
* library reaches it. The outer block keeps them out of the page's globals.
|
|
10
14
|
*/
|
|
11
|
-
export async function inlineScript(
|
|
12
|
-
|
|
15
|
+
export async function inlineScript(
|
|
16
|
+
source: string,
|
|
17
|
+
constants: Readonly<Record<string, unknown>> = {}
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const declared = Object.entries(constants)
|
|
20
|
+
.map(([name, value]) => `const ${name}=${JSON.stringify(value)};`)
|
|
21
|
+
.join("");
|
|
22
|
+
const wrapped = declared === "" ? source : `{${declared}{\n${source}\n}}`;
|
|
23
|
+
const { code } = await transformWithOxc(wrapped, "inline.ts");
|
|
13
24
|
// A classic script, not a module: its top-level names are globals other
|
|
14
25
|
// scripts call — `gtag` above all — and a module's may be renamed.
|
|
15
26
|
const minified = minifySync("inline.js", code, { module: false });
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The body of `RememberLocale.astro`'s inline script. A classic script: no
|
|
2
2
|
// imports or exports, and the block keeps its names out of the global scope.
|
|
3
|
+
// `LOCALE_KEY` is declared around it by `inlineScript`, from `storage-keys.ts`.
|
|
4
|
+
|
|
5
|
+
declare const LOCALE_KEY: typeof import("../analytics/storage-keys.ts").LOCALE_KEY;
|
|
6
|
+
|
|
3
7
|
{
|
|
4
|
-
const KEY = "atlas-locale";
|
|
5
8
|
const current = document.documentElement.lang;
|
|
6
9
|
|
|
7
10
|
const remember = () => {
|
|
8
11
|
// Came from this site: the visitor is browsing, or just switched
|
|
9
12
|
// language. Either way, this locale is their choice.
|
|
10
13
|
if (document.referrer.startsWith(`${location.origin}/`)) {
|
|
11
|
-
localStorage.setItem(
|
|
14
|
+
localStorage.setItem(LOCALE_KEY, current);
|
|
12
15
|
return;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
const chosen = localStorage.getItem(
|
|
18
|
+
const chosen = localStorage.getItem(LOCALE_KEY);
|
|
16
19
|
if (chosen === null || chosen === current) return;
|
|
17
20
|
|
|
18
21
|
// No alternate means this page is not built in that locale.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site icon, cut to size. `Document`'s, and in `astro/` because cutting
|
|
3
|
+
* needs `astro:assets`, which only exists inside the build.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getImage } from "astro:assets";
|
|
7
|
+
import type { ImageAsset } from "../image.ts";
|
|
8
|
+
import type { SiteIcons } from "../meta/index.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The favicon sizes, for `rel="icon"`.
|
|
12
|
+
*
|
|
13
|
+
* 48 for the tab: a browser scales it down to 16 or 32 itself, sharply, and 48
|
|
14
|
+
* is also the smallest Google takes for a search result. 192 for everything
|
|
15
|
+
* that shows the icon large — a search result on a dense screen, an Android
|
|
16
|
+
* home screen. A browser picks between them from `sizes`, fetching one.
|
|
17
|
+
*/
|
|
18
|
+
const FAVICON_SIZES = [48, 192] as const;
|
|
19
|
+
|
|
20
|
+
/** Apple's size for `apple-touch-icon`, which it scales down from. */
|
|
21
|
+
const TOUCH_SIZE = 180;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `icon` at `size` square, as PNG.
|
|
25
|
+
*
|
|
26
|
+
* Never larger than the source: Astro's image service does not enlarge, so a
|
|
27
|
+
* size past the source's comes back as the source, and is reported at its
|
|
28
|
+
* own width rather than the one asked for.
|
|
29
|
+
*/
|
|
30
|
+
async function cut(icon: ImageAsset, size: number): Promise<ImageAsset> {
|
|
31
|
+
if (size >= icon.width) return icon;
|
|
32
|
+
const image = await getImage({
|
|
33
|
+
src: icon as ImageMetadata,
|
|
34
|
+
width: size,
|
|
35
|
+
height: size,
|
|
36
|
+
format: "png",
|
|
37
|
+
});
|
|
38
|
+
return { src: image.src, width: size, height: size, format: "png" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every icon a page links, from the project's one square source.
|
|
43
|
+
*
|
|
44
|
+
* Cut here rather than linked as given because the source is sized for the
|
|
45
|
+
* largest use — 480px is common — and a browser fetches a favicon on every
|
|
46
|
+
* first visit, before anything it actually shows. Linked as-is, that was the
|
|
47
|
+
* same tens-of-kilobytes PNG for a 16px tab.
|
|
48
|
+
*/
|
|
49
|
+
export async function siteIcons(icon: ImageAsset): Promise<SiteIcons> {
|
|
50
|
+
const favicons = await Promise.all(
|
|
51
|
+
FAVICON_SIZES.map((size) => cut(icon, size))
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
// A small source makes two sizes one; linking it twice says nothing.
|
|
55
|
+
favicons: favicons.filter(
|
|
56
|
+
(each, index) =>
|
|
57
|
+
favicons.findIndex((other) => other.src === each.src) === index
|
|
58
|
+
),
|
|
59
|
+
touch: await cut(icon, TOUCH_SIZE),
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,18 @@ export {
|
|
|
43
43
|
type UmamiTracker,
|
|
44
44
|
type Uuid,
|
|
45
45
|
} from "./analytics/index.ts";
|
|
46
|
+
export {
|
|
47
|
+
type AtlasStoredId,
|
|
48
|
+
compareStoredItems,
|
|
49
|
+
type Lifetime,
|
|
50
|
+
STORAGE_VENDOR_NAMES,
|
|
51
|
+
type StorageVendor,
|
|
52
|
+
type StoredId,
|
|
53
|
+
type StoredItem,
|
|
54
|
+
type StoredItemsOptions,
|
|
55
|
+
storedItems,
|
|
56
|
+
withPurposes,
|
|
57
|
+
} from "./analytics/storage.ts";
|
|
46
58
|
export type {
|
|
47
59
|
LanguageTag,
|
|
48
60
|
LocaleMeta,
|
package/src/meta/index.ts
CHANGED
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
notFoundAnalytics,
|
|
30
30
|
} from "../analytics/index.ts";
|
|
31
31
|
import type { ResolvedLocaleMeta } from "../config.ts";
|
|
32
|
-
import type { ImageAsset } from "../image.ts";
|
|
33
32
|
import {
|
|
34
33
|
article,
|
|
35
34
|
type BreadcrumbStep,
|
|
@@ -41,7 +40,6 @@ import type { HttpsUrl } from "../url.ts";
|
|
|
41
40
|
import type { MetaContentBase, PageKind, ThemeColor } from "./content.ts";
|
|
42
41
|
import { robotsContent, warnAboutRobots } from "./robots.ts";
|
|
43
42
|
import {
|
|
44
|
-
iconLinks,
|
|
45
43
|
type OgImage,
|
|
46
44
|
warnAboutShareImage,
|
|
47
45
|
warnIfClipped,
|
|
@@ -60,8 +58,10 @@ export type { RobotsDirective, RobotsPolicy } from "./robots.ts";
|
|
|
60
58
|
export {
|
|
61
59
|
fitDescription,
|
|
62
60
|
fitTitle,
|
|
61
|
+
iconLinks,
|
|
63
62
|
resolveShareImage,
|
|
64
63
|
type ShareImage,
|
|
64
|
+
type SiteIcons,
|
|
65
65
|
} from "./share-image.ts";
|
|
66
66
|
export type { MetaTag } from "./tag.ts";
|
|
67
67
|
export type { SiteVerification } from "./verification.ts";
|
|
@@ -153,8 +153,6 @@ interface MetaInputDerived<L extends string> extends ChromeInput {
|
|
|
153
153
|
* from the chrome that they left.
|
|
154
154
|
*/
|
|
155
155
|
export interface ChromeInput {
|
|
156
|
-
/** The site's square icon, used for every icon link. */
|
|
157
|
-
readonly icon: ImageAsset;
|
|
158
156
|
/**
|
|
159
157
|
* Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
|
|
160
158
|
* Two values emit one tag per `prefers-color-scheme`.
|
|
@@ -184,18 +182,18 @@ export interface DocumentTags {
|
|
|
184
182
|
}
|
|
185
183
|
|
|
186
184
|
/**
|
|
187
|
-
* What the browser dresses its own furniture with: the
|
|
188
|
-
*
|
|
185
|
+
* What the browser dresses its own furniture with: the tint of the address
|
|
186
|
+
* bar, and which schemes the page renders form controls for. The tab's icon is
|
|
187
|
+
* furniture too, but it has to be cut to size first, which only the build can
|
|
188
|
+
* do: `Document` emits it, from `PageMeta.icon`.
|
|
189
189
|
*
|
|
190
190
|
* Pulled out of `buildMeta` because the 404 needs exactly this and nothing else
|
|
191
191
|
* around it. Sharing the code is the point rather than a convenience: these are
|
|
192
|
-
* the tags a reader sees *as* the site
|
|
193
|
-
*
|
|
194
|
-
* and two copies of a four-branch block is how one of them quietly stops
|
|
195
|
-
* matching the other.
|
|
192
|
+
* the tags a reader sees *as* the site, and two copies of a four-branch block
|
|
193
|
+
* is how one of them quietly stops matching the other.
|
|
196
194
|
*/
|
|
197
195
|
function chromeTags(input: ChromeInput): MetaTag[] {
|
|
198
|
-
const tags: MetaTag[] = [
|
|
196
|
+
const tags: MetaTag[] = [];
|
|
199
197
|
|
|
200
198
|
if (input.colorScheme !== undefined) {
|
|
201
199
|
tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
|
package/src/meta/share-image.ts
CHANGED
|
@@ -222,26 +222,39 @@ export function resolveShareImage(
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
/**
|
|
225
|
-
* The icon
|
|
225
|
+
* The site icon, cut to the sizes its readers ask for — see `siteIcons` in
|
|
226
|
+
* `astro/`, which cuts them from the one square source a project gives.
|
|
227
|
+
*/
|
|
228
|
+
export interface SiteIcons {
|
|
229
|
+
/** For `rel="icon"`, smallest first: the tab's, and the search result's. */
|
|
230
|
+
readonly favicons: readonly ImageAsset[];
|
|
231
|
+
/** For `apple-touch-icon`, which Apple scales from 180. */
|
|
232
|
+
readonly touch: ImageAsset;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The icon links: each favicon, and the touch icon.
|
|
226
237
|
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
238
|
+
* Built together rather than left to a layout to remember the second `rel`.
|
|
239
|
+
* `sizes` is stated so a browser picks without fetching each file to find out
|
|
240
|
+
* what it is choosing between — which, with the source served as-is, was a
|
|
241
|
+
* 480px PNG of tens of kilobytes for a 16px tab.
|
|
231
242
|
*
|
|
232
243
|
* The href stays as written. Unlike `og:image` these are read by a browser that
|
|
233
244
|
* already has the page, so root-relative is correct and making it absolute would
|
|
234
245
|
* only hard-code the origin.
|
|
235
246
|
*/
|
|
236
|
-
export function iconLinks(
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
export function iconLinks(icons: SiteIcons): MetaTag[] {
|
|
248
|
+
const attrs = (icon: ImageAsset) => {
|
|
249
|
+
const mime = imageMime(icon.format);
|
|
250
|
+
return {
|
|
251
|
+
href: icon.src,
|
|
252
|
+
sizes: `${icon.width}x${icon.height}`,
|
|
253
|
+
...(mime !== undefined ? { type: mime } : {}),
|
|
254
|
+
};
|
|
242
255
|
};
|
|
243
256
|
return [
|
|
244
|
-
link({ rel: "icon", ...attrs }),
|
|
245
|
-
link({ rel: "apple-touch-icon", ...attrs }),
|
|
257
|
+
...icons.favicons.map((icon) => link({ rel: "icon", ...attrs(icon) })),
|
|
258
|
+
link({ rel: "apple-touch-icon", ...attrs(icons.touch) }),
|
|
246
259
|
];
|
|
247
260
|
}
|
package/src/site/create.ts
CHANGED
|
@@ -326,18 +326,17 @@ export function createSite<
|
|
|
326
326
|
// Umami only, tagged so the misses read on their own — the point
|
|
327
327
|
// of measuring this page is finding a redirect somebody forgot,
|
|
328
328
|
// not counting the people who mistype. See `notFoundAnalytics`.
|
|
329
|
-
//
|
|
330
|
-
// `checkedIcon()` rather than `project.icon`: the 404 is held to the
|
|
331
|
-
// same square-and-PNG rules as every other page. It is also the one
|
|
332
|
-
// page that could plausibly render before any other, so letting it
|
|
333
|
-
// read the icon unchecked would move where a bad one is caught.
|
|
334
329
|
tags: buildNotFoundMeta({
|
|
335
330
|
title,
|
|
336
331
|
analytics: project.analytics,
|
|
337
|
-
icon: checkedIcon(),
|
|
338
332
|
themeColor: project.themeColor,
|
|
339
333
|
colorScheme: project.colorScheme,
|
|
340
334
|
}),
|
|
335
|
+
// `checkedIcon()` rather than `project.icon`: the 404 is held to the
|
|
336
|
+
// same square-and-PNG rules as every other page. It is also the one
|
|
337
|
+
// page that could plausibly render before any other, so letting it
|
|
338
|
+
// read the icon unchecked would move where a bad one is caught.
|
|
339
|
+
icon: checkedIcon(),
|
|
341
340
|
// Nothing. The body channel exists for Tag Manager's `<noscript>`,
|
|
342
341
|
// and no Google tag reaches this page.
|
|
343
342
|
bodyTags: [],
|
|
@@ -407,7 +406,6 @@ export function createSite<
|
|
|
407
406
|
),
|
|
408
407
|
siteName: project.siteName,
|
|
409
408
|
twitterSite: project.twitterSite,
|
|
410
|
-
icon: checkedIcon(),
|
|
411
409
|
verification: project.verification,
|
|
412
410
|
analytics: project.analytics,
|
|
413
411
|
llmsUrl,
|
|
@@ -419,6 +417,7 @@ export function createSite<
|
|
|
419
417
|
dir: localeMeta[locale].dir,
|
|
420
418
|
tags: document.head,
|
|
421
419
|
bodyTags: document.body,
|
|
420
|
+
icon: checkedIcon(),
|
|
422
421
|
consentVendors: consentVendors(project.analytics),
|
|
423
422
|
};
|
|
424
423
|
}
|
package/src/site/page.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ImageAsset } from "../image.ts";
|
|
1
2
|
import type { MetaContent, MetaTag } from "../meta/index.ts";
|
|
2
3
|
import type { HttpsUrl, UrlPath } from "../url.ts";
|
|
3
4
|
|
|
@@ -65,6 +66,12 @@ export interface PageMeta {
|
|
|
65
66
|
* both, and cannot put either in the other's place.
|
|
66
67
|
*/
|
|
67
68
|
readonly bodyTags: MetaTag[];
|
|
69
|
+
/**
|
|
70
|
+
* The site's square icon, as the project gave it. `Document` cuts it to
|
|
71
|
+
* each size a browser asks for and links those; the source itself, often
|
|
72
|
+
* hundreds of pixels across, is never linked.
|
|
73
|
+
*/
|
|
74
|
+
readonly icon: ImageAsset;
|
|
68
75
|
/** The vendors that need a consent banner on this page; `Document` fails without one. */
|
|
69
76
|
readonly consentVendors: readonly string[];
|
|
70
77
|
}
|