@escape-game-over/atlas 0.1.76 → 0.1.78
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 +51 -4
- 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 +363 -0
- package/src/astro/RememberLocale.astro +2 -1
- package/src/astro/attribution.ts +6 -5
- package/src/astro/filters-view.ts +77 -6
- package/src/astro/inline-script.ts +13 -2
- package/src/astro/remember-locale.ts +6 -3
- package/src/index.ts +11 -0
package/docs/client-scripts.md
CHANGED
|
@@ -36,7 +36,7 @@ finding them.
|
|
|
36
36
|
| ------------------ | -------------------------------------------------------------- | ----------------------------------------------- |
|
|
37
37
|
| `carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
|
|
38
38
|
| `filters` | which items match, and what the address bar says | every DOM read and write |
|
|
39
|
-
| `filters-view` | the
|
|
39
|
+
| `filters-view` | the DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
|
|
40
40
|
| `youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
|
|
41
41
|
| `background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
|
|
42
42
|
| `consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
|
|
@@ -249,11 +249,12 @@ re-clicking the current tab does not add a history step to walk back through.
|
|
|
249
249
|
|
|
250
250
|
## `filters-view`
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
Three helpers, and the bar for being here is not "a list might want this" but
|
|
253
253
|
"every list we have already written did, character for character, and getting it
|
|
254
254
|
wrong was silent". Three filtered lists in one consuming project — a catalogue, a
|
|
255
|
-
location directory and an FAQ — agree on
|
|
256
|
-
everything else they draw
|
|
255
|
+
location directory and an FAQ — agree on `searchBox` and `hideEmpty` and
|
|
256
|
+
disagree about everything else they draw; `filtering` was copied between two
|
|
257
|
+
sites before it came here.
|
|
257
258
|
|
|
258
259
|
### `searchBox`
|
|
259
260
|
|
|
@@ -334,6 +335,49 @@ recorded as behaviour rather than only as prose.
|
|
|
334
335
|
A group with no children at all is hidden, by the same rule: there is nothing
|
|
335
336
|
visible in it.
|
|
336
337
|
|
|
338
|
+
### `filtering`
|
|
339
|
+
|
|
340
|
+
A chip that narrows a grid in place, animated: what stays slides to its new
|
|
341
|
+
place, what leaves or arrives fades. It wraps the `set` or `reset` the chip does
|
|
342
|
+
in a view transition, and runs it plainly where the API is missing.
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
const items = refs(root, "[data-filter-item]");
|
|
346
|
+
chip.addEventListener(
|
|
347
|
+
"click",
|
|
348
|
+
() => filtering(items, () => list.set("category", data(chip, "category"))),
|
|
349
|
+
{ signal }
|
|
350
|
+
);
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Every item needs its own view-transition name for the move to animate, and
|
|
354
|
+
`match-element` gives each one without the page minting ids. **Only while it
|
|
355
|
+
runs**: named for good, every card would also pair up across page navigations,
|
|
356
|
+
and one with no partner on the next page would linger over it. So the helper
|
|
357
|
+
writes the name inline, and puts back whatever inline name the item had when
|
|
358
|
+
the last transition ends.
|
|
359
|
+
|
|
360
|
+
**The last, not each.** A second click skips the first transition, and the
|
|
361
|
+
first one's end would otherwise strip the names out from under the one still
|
|
362
|
+
running, which then animates nothing. The helper counts. `tests/filtering.test.ts`
|
|
363
|
+
pins the overlap, including an item that had a name of its own.
|
|
364
|
+
|
|
365
|
+
**One rule stays the project's.** A site that stops unpaired names animating
|
|
366
|
+
across pages also stops filtering's fades, since a card hidden or shown has no
|
|
367
|
+
partner either. The helper marks `<html>` with `data-atlas-filtering`
|
|
368
|
+
(`FILTERING_ATTRIBUTE`) while it runs, for the site to scope that rule out:
|
|
369
|
+
|
|
370
|
+
```css
|
|
371
|
+
:root:not([data-atlas-filtering])::view-transition-new(*):only-child {
|
|
372
|
+
animation: none;
|
|
373
|
+
}
|
|
374
|
+
:root:not([data-atlas-filtering])::view-transition-old(*):only-child {
|
|
375
|
+
display: none;
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
A site without such a rule needs no CSS at all.
|
|
380
|
+
|
|
337
381
|
### Why these write instead of calling back
|
|
338
382
|
|
|
339
383
|
`filters.onChange` is already the callback that hands a consumer the answer and
|
|
@@ -632,6 +676,9 @@ right properties is a faithful stand-in.
|
|
|
632
676
|
pins the write-back guard by counting *writes* — so deleting the guard fails
|
|
633
677
|
rather than passing on an identical value — and pins the nesting order in
|
|
634
678
|
both directions.
|
|
679
|
+
- `tests/filtering.test.ts` stubs `document` with a `startViewTransition` whose
|
|
680
|
+
transitions finish when the test says, so two overlapping runs can be ended
|
|
681
|
+
in order.
|
|
635
682
|
- `tests/youtube.test.ts` fakes the trigger, and a container whose
|
|
636
683
|
`ownerDocument` makes the iframe — which is why the module reaches the
|
|
637
684
|
document through the container rather than the global.
|
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.78",
|
|
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,363 @@
|
|
|
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
|
+
* Everything this deployment keeps on a device: Atlas's own keys, then each
|
|
294
|
+
* loaded vendor's.
|
|
295
|
+
*
|
|
296
|
+
* Atlas's rows follow what the page renders, which `Document` decides from
|
|
297
|
+
* its props, so pass the same `rememberLocale` and `rememberCampaign` here.
|
|
298
|
+
* The consent record is listed whenever there is a banner to answer.
|
|
299
|
+
*/
|
|
300
|
+
export function storedItems(
|
|
301
|
+
analytics: AnalyticsSettings | undefined,
|
|
302
|
+
options: StoredItemsOptions = {}
|
|
303
|
+
): readonly StoredItem[] {
|
|
304
|
+
const {
|
|
305
|
+
rememberLocale = true,
|
|
306
|
+
rememberCampaign = false,
|
|
307
|
+
sort = false,
|
|
308
|
+
} = options;
|
|
309
|
+
const asked = consentVendors(analytics).length > 0;
|
|
310
|
+
|
|
311
|
+
const rows: StoredItem[] = [];
|
|
312
|
+
if (rememberLocale) {
|
|
313
|
+
rows.push({
|
|
314
|
+
id: "atlas-locale",
|
|
315
|
+
names: [LOCALE_KEY],
|
|
316
|
+
provider: "site",
|
|
317
|
+
lasts: "kept",
|
|
318
|
+
consent: "none",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Kept only once analytics is granted, so only where it can be.
|
|
322
|
+
if (rememberCampaign && asked) {
|
|
323
|
+
rows.push({
|
|
324
|
+
id: "atlas-campaign",
|
|
325
|
+
names: [CAMPAIGN_KEY],
|
|
326
|
+
provider: "site",
|
|
327
|
+
lasts: "tab",
|
|
328
|
+
consent: "analytics",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (asked) {
|
|
332
|
+
rows.push({
|
|
333
|
+
id: "atlas-consent",
|
|
334
|
+
names: [CONSENT_KEY],
|
|
335
|
+
provider: "site",
|
|
336
|
+
lasts: month(CONSENT_MONTHS),
|
|
337
|
+
consent: "none",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const vendor of loadedVendors(analytics)) {
|
|
342
|
+
const entry: VendorStorage = CATALOG[vendor];
|
|
343
|
+
rows.push({
|
|
344
|
+
id: vendor,
|
|
345
|
+
names: entry.site.names,
|
|
346
|
+
provider: vendor,
|
|
347
|
+
lasts: entry.site.lasts,
|
|
348
|
+
consent: entry.consent,
|
|
349
|
+
});
|
|
350
|
+
if (entry.own !== undefined) {
|
|
351
|
+
rows.push({
|
|
352
|
+
id: `${vendor}-own` as OwnRow,
|
|
353
|
+
names: entry.own.names,
|
|
354
|
+
provider: vendor,
|
|
355
|
+
domains: entry.own.domains,
|
|
356
|
+
lasts: entry.own.lasts,
|
|
357
|
+
consent: entry.consent,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return sort ? rows.toSorted(compareStoredItems) : rows;
|
|
363
|
+
}
|
|
@@ -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) {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The DOM writes every filtered list turned out to share.
|
|
3
3
|
*
|
|
4
4
|
* `filters` decides which items match; what a page does about that is its own.
|
|
5
|
-
* These are the parts
|
|
6
|
-
* silent.
|
|
5
|
+
* These are the parts the lists wrote identically, and getting wrong was
|
|
6
|
+
* silent. All take elements, never selectors, so no attribute name in this
|
|
7
7
|
* package has to be matched by a consumer.
|
|
8
8
|
*
|
|
9
|
-
* They write only what is binary and derived — an input's `value`,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* They write only what is binary and derived — an input's `value`, `hidden` on
|
|
10
|
+
* a clear button, an empty message or a section with nothing left, and a
|
|
11
|
+
* view-transition name while a filter animates. No class or `aria`: that is
|
|
12
|
+
* where the choices live.
|
|
12
13
|
*
|
|
13
14
|
* **`hidden` needs help in a grid.** Any author rule setting `display` beats it.
|
|
14
15
|
* Tailwind v4's preflight ships `[hidden] { display: none !important }`; without
|
|
@@ -124,3 +125,73 @@ export function hideEmpty<T extends HTMLElement>(
|
|
|
124
125
|
group.hidden = !visible;
|
|
125
126
|
}
|
|
126
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* On `<html>` while a `filtering` transition runs: what a stylesheet that
|
|
131
|
+
* treats unpaired view-transition names one way across pages tells the two
|
|
132
|
+
* cases apart by.
|
|
133
|
+
*/
|
|
134
|
+
export const FILTERING_ATTRIBUTE = "data-atlas-filtering";
|
|
135
|
+
|
|
136
|
+
/** Each named item, and the inline name it had before, to put back after. */
|
|
137
|
+
const named = new Map<HTMLElement, string>();
|
|
138
|
+
let running = 0;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Runs `update` — the `set` or `reset` a chip or tab does — as a view
|
|
142
|
+
* transition, so what stays slides into place and what leaves or arrives
|
|
143
|
+
* fades. Without the API, `update` just runs.
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* chip.addEventListener("click", () =>
|
|
147
|
+
* filtering(items, () => list.set("category", data(chip, "category")))
|
|
148
|
+
* );
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* **`items` are named only while it runs**, `match-element` each, and put
|
|
152
|
+
* back after. Named for good, every one of them would be a pair across pages
|
|
153
|
+
* too, and linger over the next page on navigation.
|
|
154
|
+
*
|
|
155
|
+
* **Counted, not just started**: a second click skips the first transition,
|
|
156
|
+
* and that one's end would otherwise strip the names from the one still
|
|
157
|
+
* running.
|
|
158
|
+
*
|
|
159
|
+
* **One rule is the project's**, because only the project knows it wrote the
|
|
160
|
+
* other half. A site that stops unpaired names animating across pages — so a
|
|
161
|
+
* card on one page does not fade over the next — stops filtering's fades with
|
|
162
|
+
* it, since an item hidden or shown is unpaired too. Scope it out:
|
|
163
|
+
*
|
|
164
|
+
* ```css
|
|
165
|
+
* :root:not([data-atlas-filtering])::view-transition-new(*):only-child {
|
|
166
|
+
* animation: none;
|
|
167
|
+
* }
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function filtering(
|
|
171
|
+
items: Iterable<HTMLElement>,
|
|
172
|
+
update: () => void
|
|
173
|
+
): void {
|
|
174
|
+
const doc = globalThis.document;
|
|
175
|
+
if (typeof doc?.startViewTransition !== "function") {
|
|
176
|
+
update();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const item of items) {
|
|
181
|
+
if (!named.has(item)) named.set(item, item.style.viewTransitionName);
|
|
182
|
+
item.style.viewTransitionName = "match-element";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const root = doc.documentElement;
|
|
186
|
+
running += 1;
|
|
187
|
+
root.setAttribute(FILTERING_ATTRIBUTE, "");
|
|
188
|
+
doc.startViewTransition(update).finished.finally(() => {
|
|
189
|
+
running -= 1;
|
|
190
|
+
if (running > 0) return;
|
|
191
|
+
root.removeAttribute(FILTERING_ATTRIBUTE);
|
|
192
|
+
for (const [item, before] of named) {
|
|
193
|
+
item.style.viewTransitionName = before;
|
|
194
|
+
}
|
|
195
|
+
named.clear();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -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.
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,17 @@ 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
|
+
} from "./analytics/storage.ts";
|
|
46
57
|
export type {
|
|
47
58
|
LanguageTag,
|
|
48
59
|
LocaleMeta,
|