@getvitops/astro 8.1.0 → 8.2.0-alpha.0
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/CHANGELOG.md +361 -29
- package/dist/exports/tracking.d.mts +1 -1
- package/dist/exports/tracking.mjs +1 -1
- package/dist/index.d.mts +45 -18
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +153 -17
- package/dist/index.mjs.map +1 -1
- package/dist/middleware.d.mts +25 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +35 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/routes/conversion.d.mts +70 -11
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +114 -20
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/{tracking-DdEKExlE.d.mts → tracking-BNKKBqOe.d.mts} +2 -2
- package/dist/tracking-BNKKBqOe.d.mts.map +1 -0
- package/dist/{tracking-CmSfxWC3.mjs → tracking-M2PqlMpw.mjs} +2 -2
- package/dist/tracking-M2PqlMpw.mjs.map +1 -0
- package/package.json +10 -7
- package/src/ads.ts +46 -3
- package/src/analytics.ts +98 -11
- package/src/components/Analytics.astro +5 -1
- package/src/components/Icon.astro +8 -1
- package/src/components/Tracking.astro +2 -1
- package/src/scripts/tracking.ts +114 -21
- package/src/tracking.ts +1 -1
- package/virtual.d.ts +17 -1
- package/dist/tracking-CmSfxWC3.mjs.map +0 -1
- package/dist/tracking-DdEKExlE.d.mts.map +0 -1
package/src/ads.ts
CHANGED
|
@@ -25,7 +25,13 @@
|
|
|
25
25
|
* Everything decidable is here and unit-tested; `Ads.astro` renders what this
|
|
26
26
|
* returns and chooses nothing.
|
|
27
27
|
*/
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
AD_PLATFORMS,
|
|
30
|
+
AD_PROVIDER_KEYS,
|
|
31
|
+
normalizeConversionLabels,
|
|
32
|
+
type AdProvider,
|
|
33
|
+
type ConversionLabelMap,
|
|
34
|
+
} from '@getvitops/utils/ads';
|
|
29
35
|
import type { AnalyticsStrategy, ConsentCategory, GatedTag } from './analytics.ts';
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -38,7 +44,15 @@ import type { AnalyticsStrategy, ConsentCategory, GatedTag } from './analytics.t
|
|
|
38
44
|
export interface GetvitopsAdProperty {
|
|
39
45
|
accountId?: string;
|
|
40
46
|
pixelId?: string;
|
|
41
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Google Ads only: the conversion action label(s) paired with `pixelId` (the
|
|
49
|
+
* `AW-…` conversion id) — a bare string (back-compat, treated as `call`) or a
|
|
50
|
+
* map of action → label. `<Tracking />`'s tel:/mailto: click handler and
|
|
51
|
+
* `vitopsFormConversion()` each fire `gtag('event', 'conversion', …)` for
|
|
52
|
+
* their own action at the moment of the interaction — see `gtagBootstrap` in
|
|
53
|
+
* `@getvitops/utils/ads`.
|
|
54
|
+
*/
|
|
55
|
+
conversionLabel?: string | ConversionLabelMap;
|
|
42
56
|
/** Default `marketing`. `analytics` for a property used purely for measurement. */
|
|
43
57
|
category?: 'marketing' | 'analytics';
|
|
44
58
|
/** False keeps the property on record without emitting its tag. */
|
|
@@ -113,6 +127,35 @@ export function resolveAds(
|
|
|
113
127
|
continue;
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
// `conversionLabel` lands inside the same inline `<script>` as `id` (Google's
|
|
131
|
+
// bootstrap embeds it to expose a conversion `send_to` target per action) —
|
|
132
|
+
// same injection surface, so every configured label gets the same check.
|
|
133
|
+
// Unlike an invalid `id`, an invalid label doesn't sink the whole tag: it
|
|
134
|
+
// just means that one action's conversion doesn't fire, which is worth a
|
|
135
|
+
// warning rather than dropping every other configured action too.
|
|
136
|
+
let safeEntry = entry;
|
|
137
|
+
const labels = normalizeConversionLabels(entry.conversionLabel);
|
|
138
|
+
const invalidActions = Object.entries(labels)
|
|
139
|
+
.filter(([, label]) => label && !SAFE_ID.test(label))
|
|
140
|
+
.map(([action]) => action);
|
|
141
|
+
if (invalidActions.length) {
|
|
142
|
+
for (const action of invalidActions) {
|
|
143
|
+
warnings.push(
|
|
144
|
+
`ads: ${provider} conversionLabel.${action} "${labels[action as keyof ConversionLabelMap]}" ` +
|
|
145
|
+
'is not a valid label. Conversion firing skipped for that action.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const cleanLabels = Object.fromEntries(
|
|
149
|
+
Object.entries(labels).filter(([action]) => !invalidActions.includes(action)),
|
|
150
|
+
);
|
|
151
|
+
// Omit the key entirely rather than setting it `undefined` —
|
|
152
|
+
// `exactOptionalPropertyTypes` treats the two differently.
|
|
153
|
+
const { conversionLabel: _drop, ...rest } = entry;
|
|
154
|
+
safeEntry = Object.keys(cleanLabels).length
|
|
155
|
+
? { ...rest, conversionLabel: cleanLabels }
|
|
156
|
+
: rest;
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
const category: ConsentCategory = entry.category ?? 'marketing';
|
|
117
160
|
tags.push({
|
|
118
161
|
key: provider,
|
|
@@ -125,7 +168,7 @@ export function resolveAds(
|
|
|
125
168
|
cookies: platform.tag.cookies,
|
|
126
169
|
strategy,
|
|
127
170
|
gated: ctx.consent === true,
|
|
128
|
-
inline: platform.tag.bootstrap(id, { provider, ...
|
|
171
|
+
inline: platform.tag.bootstrap(id, { provider, ...safeEntry }),
|
|
129
172
|
src: platform.tag.src ? platform.tag.src(id) : null,
|
|
130
173
|
attrs: {},
|
|
131
174
|
});
|
package/src/analytics.ts
CHANGED
|
@@ -156,6 +156,39 @@ export interface ResolvedAnalytics {
|
|
|
156
156
|
export interface ResolveAnalyticsContext {
|
|
157
157
|
/** Is the consent gate enabled on this site (`vitops({ consent })`)? */
|
|
158
158
|
consent?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Enabled `site.experiments` entries, keyed the same way, each carrying the
|
|
161
|
+
* resolved cookie name `vitopsAbTesting()`'s middleware assigns
|
|
162
|
+
* (`_ab_<key>` unless overridden). When set, each provider's bootstrap
|
|
163
|
+
* reads these cookies client-side and reports the assignment via that
|
|
164
|
+
* provider's own custom-dimension/property mechanism — a no-op when this
|
|
165
|
+
* is empty, so a site not using experiments sees no behavior change.
|
|
166
|
+
*/
|
|
167
|
+
experiments?: Record<string, { cookieName: string }>;
|
|
168
|
+
/** `site.analytics.matomo.abVariantDimensions` — Matomo's pre-created custom-dimension index per experiment key. */
|
|
169
|
+
matomoAbVariantDimensions?: Record<string, number>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A small inline snippet reading each experiment's cookie into a plain
|
|
174
|
+
* object, skipping any experiment whose cookie isn't set (visitor not yet
|
|
175
|
+
* assigned, or the experiment disabled after this page was cached). Shared
|
|
176
|
+
* across providers so the cookie-parsing logic — and its `SAFE_ID` guard on
|
|
177
|
+
* both the experiment key and cookie name, the same discipline `bad()`
|
|
178
|
+
* already applies to provider IDs landing in an inline `<script>` — exists
|
|
179
|
+
* exactly once.
|
|
180
|
+
*/
|
|
181
|
+
function experimentCookieReads(experiments: Record<string, { cookieName: string }>): string {
|
|
182
|
+
const entries = Object.entries(experiments).filter(([key, e]) => !bad(key) && !bad(e.cookieName));
|
|
183
|
+
if (!entries.length) return '';
|
|
184
|
+
return (
|
|
185
|
+
`function _abrc(n){var m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null;}` +
|
|
186
|
+
`var _ab={};` +
|
|
187
|
+
entries
|
|
188
|
+
.map(([key, e]) => `_ab[${JSON.stringify(key)}]=_abrc(${JSON.stringify(e.cookieName)});`)
|
|
189
|
+
.join('') +
|
|
190
|
+
`for(var _k in _ab){if(_ab[_k]==null)delete _ab[_k];}`
|
|
191
|
+
);
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
/**
|
|
@@ -175,10 +208,23 @@ function literal(value: Record<string, string | number | boolean>): string {
|
|
|
175
208
|
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
176
209
|
}
|
|
177
210
|
|
|
178
|
-
function googleAnalytics(
|
|
211
|
+
function googleAnalytics(
|
|
212
|
+
input: string | GoogleAnalyticsOptions,
|
|
213
|
+
experiments: Record<string, { cookieName: string }> = {},
|
|
214
|
+
): ResolvedTag | string {
|
|
179
215
|
const opts = typeof input === 'string' ? { id: input } : input;
|
|
180
216
|
if (bad(opts.id)) return `googleAnalytics: "${opts.id}" is not a valid measurement ID. Skipped.`;
|
|
181
|
-
const
|
|
217
|
+
const staticConfig = opts.config ?? {};
|
|
218
|
+
const reads = experimentCookieReads(experiments);
|
|
219
|
+
// With no experiments configured, the config object is static, exactly as
|
|
220
|
+
// before — same output byte-for-byte, no runtime cost added. With
|
|
221
|
+
// experiments, the automatic pageview's `gtag('config', …)` call is built
|
|
222
|
+
// at runtime instead, merging each assigned variant in under its
|
|
223
|
+
// experiment key so it reaches GA4 as a custom parameter on that same hit
|
|
224
|
+
// (registered as a custom dimension in GA4 Admin by parameter name).
|
|
225
|
+
const gtagConfig = reads
|
|
226
|
+
? `${reads}gtag('config','${opts.id}',Object.assign(${literal(staticConfig)},_ab));`
|
|
227
|
+
: `gtag('config','${opts.id}'${opts.config ? `,${literal(staticConfig)}` : ''});`;
|
|
182
228
|
return {
|
|
183
229
|
key: 'googleAnalytics',
|
|
184
230
|
provider: 'Google Analytics',
|
|
@@ -191,7 +237,7 @@ function googleAnalytics(input: string | GoogleAnalyticsOptions): ResolvedTag |
|
|
|
191
237
|
`window.dataLayer=window.dataLayer||[];` +
|
|
192
238
|
`function gtag(){dataLayer.push(arguments)}` +
|
|
193
239
|
`gtag('js',new Date());` +
|
|
194
|
-
|
|
240
|
+
gtagConfig,
|
|
195
241
|
src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(opts.id)}`,
|
|
196
242
|
attrs: {},
|
|
197
243
|
};
|
|
@@ -230,7 +276,11 @@ function clarity(input: string | ClarityOptions): ResolvedTag | string {
|
|
|
230
276
|
};
|
|
231
277
|
}
|
|
232
278
|
|
|
233
|
-
function matomo(
|
|
279
|
+
function matomo(
|
|
280
|
+
opts: MatomoOptions,
|
|
281
|
+
experiments: Record<string, { cookieName: string }> = {},
|
|
282
|
+
dimensions: Record<string, number> = {},
|
|
283
|
+
): ResolvedTag | string {
|
|
234
284
|
const siteId = String(opts.siteId);
|
|
235
285
|
if (bad(siteId)) return `matomo: "${siteId}" is not a valid site ID. Skipped.`;
|
|
236
286
|
let base: URL;
|
|
@@ -242,8 +292,26 @@ function matomo(opts: MatomoOptions): ResolvedTag | string {
|
|
|
242
292
|
const url = `${base.origin}${base.pathname.replace(/\/*$/, '')}/`;
|
|
243
293
|
const cookies = opts.cookies === true;
|
|
244
294
|
|
|
245
|
-
//
|
|
246
|
-
//
|
|
295
|
+
// Only experiments with a recorded dimension index push anything — one
|
|
296
|
+
// with no index is silently skipped here (validateConfig's
|
|
297
|
+
// `experimentDimensionWarnings` is what surfaces that as a build-time
|
|
298
|
+
// warning, not a hard error: the pageview still ships correctly).
|
|
299
|
+
const dimensionEntries = Object.entries(experiments).filter(([key]) => dimensions[key] != null);
|
|
300
|
+
const reads = experimentCookieReads(Object.fromEntries(dimensionEntries));
|
|
301
|
+
const dimensionPushes = reads
|
|
302
|
+
? reads +
|
|
303
|
+
dimensionEntries
|
|
304
|
+
.map(
|
|
305
|
+
([key]) =>
|
|
306
|
+
`if(_ab[${JSON.stringify(key)}]!=null)_paq.push(['setCustomDimension',${dimensions[key]},_ab[${JSON.stringify(key)}]]);`,
|
|
307
|
+
)
|
|
308
|
+
.join('')
|
|
309
|
+
: '';
|
|
310
|
+
|
|
311
|
+
// `disableCookies` and the experiment dimension pushes must both land
|
|
312
|
+
// *before* trackPageView — the former so the first request of the visit
|
|
313
|
+
// doesn't set the cookies it exists to avoid, the latter because a
|
|
314
|
+
// dimension pushed after trackPageView misses that pageview entirely.
|
|
247
315
|
return {
|
|
248
316
|
key: 'matomo',
|
|
249
317
|
provider: 'Matomo',
|
|
@@ -255,6 +323,7 @@ function matomo(opts: MatomoOptions): ResolvedTag | string {
|
|
|
255
323
|
inline:
|
|
256
324
|
`var _paq=window._paq=window._paq||[];` +
|
|
257
325
|
(cookies ? '' : `_paq.push(['disableCookies']);`) +
|
|
326
|
+
dimensionPushes +
|
|
258
327
|
`_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);` +
|
|
259
328
|
`(function(){var u="${url}";` +
|
|
260
329
|
`_paq.push(['setTrackerUrl',u+'matomo.php']);_paq.push(['setSiteId','${siteId}']);` +
|
|
@@ -265,9 +334,24 @@ function matomo(opts: MatomoOptions): ResolvedTag | string {
|
|
|
265
334
|
};
|
|
266
335
|
}
|
|
267
336
|
|
|
268
|
-
function plausible(
|
|
337
|
+
function plausible(
|
|
338
|
+
input: string | PlausibleOptions,
|
|
339
|
+
experiments: Record<string, { cookieName: string }> = {},
|
|
340
|
+
): ResolvedTag | string {
|
|
269
341
|
const opts = typeof input === 'string' ? { domain: input } : input;
|
|
270
342
|
if (bad(opts.domain)) return `plausible: "${opts.domain}" is not a valid domain. Skipped.`;
|
|
343
|
+
// Plausible's current API (plausible.init({customProperties})) needs no
|
|
344
|
+
// special script variant and no manual-pageview mode — customProperties
|
|
345
|
+
// attaches to every tracked event, including the automatic pageview, so
|
|
346
|
+
// there's no double-counting risk to design around. The queueing stub
|
|
347
|
+
// mirrors gtag's own inline bootstrap above: the real script (loaded via
|
|
348
|
+
// `src`, async) picks up this call once it's ready, regardless of which
|
|
349
|
+
// one finishes loading first.
|
|
350
|
+
const reads = experimentCookieReads(experiments);
|
|
351
|
+
const inline = reads
|
|
352
|
+
? `window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};` +
|
|
353
|
+
`plausible.init({customProperties:function(){${reads}return _ab;}});`
|
|
354
|
+
: '';
|
|
271
355
|
return {
|
|
272
356
|
key: 'plausible',
|
|
273
357
|
provider: 'Plausible Analytics',
|
|
@@ -278,7 +362,7 @@ function plausible(input: string | PlausibleOptions): ResolvedTag | string {
|
|
|
278
362
|
cookies: [],
|
|
279
363
|
strategy: 'idle',
|
|
280
364
|
gated: true,
|
|
281
|
-
inline
|
|
365
|
+
inline,
|
|
282
366
|
src: opts.src ?? 'https://plausible.io/js/script.js',
|
|
283
367
|
attrs: { 'data-domain': opts.domain },
|
|
284
368
|
};
|
|
@@ -300,11 +384,14 @@ export function resolveAnalytics(
|
|
|
300
384
|
if (!opts) return { tags, needsRuntime: false, warnings };
|
|
301
385
|
|
|
302
386
|
const strategy = opts.strategy ?? 'idle';
|
|
387
|
+
const experiments = ctx.experiments ?? {};
|
|
303
388
|
const results = [
|
|
304
|
-
opts.googleAnalytics === undefined ? null : googleAnalytics(opts.googleAnalytics),
|
|
389
|
+
opts.googleAnalytics === undefined ? null : googleAnalytics(opts.googleAnalytics, experiments),
|
|
305
390
|
opts.clarity === undefined ? null : clarity(opts.clarity),
|
|
306
|
-
opts.matomo === undefined
|
|
307
|
-
|
|
391
|
+
opts.matomo === undefined
|
|
392
|
+
? null
|
|
393
|
+
: matomo(opts.matomo, experiments, ctx.matomoAbVariantDimensions ?? {}),
|
|
394
|
+
opts.plausible === undefined ? null : plausible(opts.plausible, experiments),
|
|
308
395
|
];
|
|
309
396
|
|
|
310
397
|
for (const result of results) {
|
|
@@ -29,7 +29,11 @@ const { enabled = true, strategy } = Astro.props;
|
|
|
29
29
|
|
|
30
30
|
const options = strategy ? { ...head.analytics, strategy } : head.analytics;
|
|
31
31
|
const { tags } = enabled
|
|
32
|
-
? resolveAnalytics(options, {
|
|
32
|
+
? resolveAnalytics(options, {
|
|
33
|
+
consent: head.consent,
|
|
34
|
+
experiments: head.experiments,
|
|
35
|
+
matomoAbVariantDimensions: head.matomoAbVariantDimensions,
|
|
36
|
+
})
|
|
33
37
|
: { tags: [] };
|
|
34
38
|
---
|
|
35
39
|
|
|
@@ -27,8 +27,15 @@
|
|
|
27
27
|
* `inline-flex` with a `gap` — is a matter of child order, not a new class.
|
|
28
28
|
* Don't confuse this with `.icon-mask`, the CSS-only adornment path (see
|
|
29
29
|
* `.link--icon::before` in anchor-link.css).
|
|
30
|
+
*
|
|
31
|
+
* **Imports from `@getvitops/generator/icons-sprite`, never the root entry.**
|
|
32
|
+
* The root entry also re-exports `generate.ts`, which reaches lightningcss —
|
|
33
|
+
* a native binary Cloudflare Workers cannot `require()`. A *static* import of
|
|
34
|
+
* the root here dragged that into every SSR bundle rendering this component,
|
|
35
|
+
* regardless of `icons.engine`, and 500'd every request on a real consumer.
|
|
36
|
+
* The `icons-sprite` subpath has no such dependency — see its own docblock.
|
|
30
37
|
*/
|
|
31
|
-
import { loadIconSvg, spriteId } from '@getvitops/generator';
|
|
38
|
+
import { loadIconSvg, spriteId } from '@getvitops/generator/icons-sprite';
|
|
32
39
|
import { resolveIcon } from '@getvitops/utils';
|
|
33
40
|
import icons from 'virtual:getvitops/icons';
|
|
34
41
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
26
26
|
import head from 'virtual:getvitops/head';
|
|
27
27
|
|
|
28
|
-
const { enabled, category, cookies } = head.tracking ?? { enabled: false };
|
|
28
|
+
const { enabled, category, cookies, abVariantCookie } = head.tracking ?? { enabled: false };
|
|
29
29
|
---
|
|
30
30
|
|
|
31
31
|
{enabled && (
|
|
@@ -35,6 +35,7 @@ const { enabled, category, cookies } = head.tracking ?? { enabled: false };
|
|
|
35
35
|
data-vitops-tracking
|
|
36
36
|
data-consent={category}
|
|
37
37
|
data-consent-cookies={cookies?.length ? cookies.join(',') : undefined}
|
|
38
|
+
data-ab-cookie={abVariantCookie ?? undefined}
|
|
38
39
|
/>
|
|
39
40
|
<script>
|
|
40
41
|
import '../scripts/tracking.ts';
|
package/src/scripts/tracking.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Ad conversion tracking: capture click IDs from the landing URL, track tel:
|
|
2
|
-
// clicks. Form submission itself goes through
|
|
3
|
-
// (`@getvitops/astro/routes`), which handles both
|
|
4
|
-
// method="post">` path and a fetch-based one — there is no
|
|
5
|
-
// form-enhancement script here.
|
|
2
|
+
// and mailto: clicks. Form submission itself goes through
|
|
3
|
+
// `createConversionRoute()` (`@getvitops/astro/routes`), which handles both
|
|
4
|
+
// the no-JS `<form method="post">` path and a fetch-based one — there is no
|
|
5
|
+
// form-enhancement script here, only `window.vitopsFormConversion()` (below),
|
|
6
|
+
// which a consumer's own fetch-based submit handler may call once that POST
|
|
7
|
+
// succeeds, to report the `form` Google Ads conversion action client-side.
|
|
6
8
|
//
|
|
7
9
|
// Runs early and ungated on purpose. Reading the query string is not storage and
|
|
8
10
|
// needs nobody's permission; *keeping* it does. So the capture is synchronous —
|
|
@@ -27,9 +29,12 @@
|
|
|
27
29
|
|
|
28
30
|
const COOKIE_NAME = '_ac';
|
|
29
31
|
const COOKIE_DAYS = 90;
|
|
30
|
-
|
|
32
|
+
// Default when no experiment is configured, or the marker (below) carries no
|
|
33
|
+
// `data-ab-cookie` override — kept as a literal fallback so a site with no
|
|
34
|
+
// `site.experiments` behaves exactly as before.
|
|
35
|
+
const DEFAULT_AB_COOKIE = '_ab';
|
|
31
36
|
|
|
32
|
-
// Where the `tel:` conversion
|
|
37
|
+
// Where the `tel:`/`mailto:` conversion beacons go. Mirrors TRACKING_ENDPOINT in
|
|
33
38
|
// ../tracking.ts (pinned by `tracking.test.ts`), copied for the same reason as
|
|
34
39
|
// the parameter lists below — this file is inlined into the document.
|
|
35
40
|
//
|
|
@@ -70,17 +75,35 @@ for (const key of [...CLICK_ID_PARAMS, ...UTM_PARAMS]) {
|
|
|
70
75
|
if (value) trackingData[key] = value;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Reads a raw cookie value by name. Splits on the *first* `=` only (a prior
|
|
80
|
+
* version split on every `=`, silently truncating any value containing one)
|
|
81
|
+
* and URL-decodes the result — a prior version didn't, so a variant name or
|
|
82
|
+
* attribution value containing a reserved character came back mangled.
|
|
83
|
+
*/
|
|
84
|
+
function rawCookie(name: string): string | undefined {
|
|
85
|
+
const match = document.cookie.split(';').find((c) => c.trim().startsWith(`${name}=`));
|
|
86
|
+
if (!match) return undefined;
|
|
87
|
+
const eq = match.indexOf('=');
|
|
88
|
+
const value = match.slice(eq + 1).trim();
|
|
89
|
+
return value ? decodeURIComponent(value) : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// <Tracking /> writes the experiment cookie name onto this marker (the first
|
|
93
|
+
// *enabled* `site.experiments` entry — see `readSiteExperiments`'s own
|
|
94
|
+
// docblock for why only one reaches the lead-attribution pipeline), so a
|
|
95
|
+
// site with no experiments configured falls back to the same default this
|
|
96
|
+
// always used.
|
|
97
|
+
const trackingMarker = document.querySelector('[data-vitops-tracking]');
|
|
98
|
+
const abCookieName = trackingMarker?.getAttribute('data-ab-cookie') || DEFAULT_AB_COOKIE;
|
|
99
|
+
const abVariant = rawCookie(abCookieName);
|
|
77
100
|
if (abVariant) trackingData['ab_variant'] = abVariant;
|
|
78
101
|
|
|
79
102
|
function readCookie(): Record<string, unknown> | null {
|
|
80
|
-
const
|
|
81
|
-
if (!
|
|
103
|
+
const raw = rawCookie(COOKIE_NAME);
|
|
104
|
+
if (!raw) return null;
|
|
82
105
|
try {
|
|
83
|
-
const data: unknown = JSON.parse(
|
|
106
|
+
const data: unknown = JSON.parse(raw);
|
|
84
107
|
return typeof data === 'object' && data !== null && !Array.isArray(data)
|
|
85
108
|
? (data as Record<string, unknown>)
|
|
86
109
|
: null;
|
|
@@ -121,10 +144,9 @@ const gate = (): ConsentLike | undefined =>
|
|
|
121
144
|
(window as unknown as { vitopsConsent?: ConsentLike }).vitopsConsent;
|
|
122
145
|
|
|
123
146
|
if (Object.keys(trackingData).length > 0) {
|
|
124
|
-
// <Tracking /> writes the category onto this marker
|
|
125
|
-
// `data-consent-cookies` so revoking clears `_ac`.
|
|
126
|
-
const
|
|
127
|
-
const category = marker?.getAttribute('data-consent') ?? 'marketing';
|
|
147
|
+
// <Tracking /> writes the category onto this marker (looked up once,
|
|
148
|
+
// above), which also carries `data-consent-cookies` so revoking clears `_ac`.
|
|
149
|
+
const category = trackingMarker?.getAttribute('data-consent') ?? 'marketing';
|
|
128
150
|
|
|
129
151
|
if (!gate()) {
|
|
130
152
|
// No gate on this site: behave as it always has.
|
|
@@ -147,15 +169,86 @@ if (Object.keys(trackingData).length > 0) {
|
|
|
147
169
|
}
|
|
148
170
|
|
|
149
171
|
// ---------------------------------------------------------------------------
|
|
150
|
-
// 2. Tel: click tracking via sendBeacon
|
|
172
|
+
// 2. Tel:/mailto: click tracking via sendBeacon, plus a Google Ads conversion
|
|
151
173
|
// ---------------------------------------------------------------------------
|
|
152
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Typed locally, matching `ConsentLike` above: a global augmentation here
|
|
177
|
+
* would widen `Window` package-wide for a shape only this file uses.
|
|
178
|
+
*
|
|
179
|
+
* `gtag` is a plain function global — it's declared inside the classic
|
|
180
|
+
* (non-module) inline `<script>` `gtagBootstrap` emits (`@getvitops/utils/ads`),
|
|
181
|
+
* which attaches it to `window`. This file runs as a module (`<Tracking />`
|
|
182
|
+
* imports it), so it must go through `window.gtag`, not a bare `gtag(...)` call.
|
|
183
|
+
* `vitopsAdsConversions.google` is the same bootstrap's per-action `send_to`
|
|
184
|
+
* map (`{call?, email?, form?}`) — each action carries its own target, so a
|
|
185
|
+
* call, an email click and a form submission report as distinct Google Ads
|
|
186
|
+
* conversion actions instead of all three sharing one.
|
|
187
|
+
*/
|
|
188
|
+
interface AdsGlobal {
|
|
189
|
+
gtag?: (...args: unknown[]) => void;
|
|
190
|
+
vitopsAdsConversions?: Record<string, Partial<Record<'call' | 'email' | 'form', string>>>;
|
|
191
|
+
}
|
|
192
|
+
const adsGlobal = (): AdsGlobal => window as unknown as AdsGlobal;
|
|
193
|
+
|
|
153
194
|
document.addEventListener('click', (e) => {
|
|
154
|
-
const link = (e.target as Element).closest?.(
|
|
195
|
+
const link = (e.target as Element).closest?.(
|
|
196
|
+
'a[href^="tel:"], a[href^="mailto:"]',
|
|
197
|
+
) as HTMLAnchorElement | null;
|
|
155
198
|
if (!link) return;
|
|
156
199
|
|
|
157
|
-
|
|
200
|
+
const isEmail = link.href.startsWith('mailto:');
|
|
201
|
+
|
|
202
|
+
// Fire-and-forget beacon; don't block the tel:/mailto: navigation.
|
|
158
203
|
if (navigator.sendBeacon) {
|
|
159
|
-
|
|
204
|
+
const body = isEmail
|
|
205
|
+
? { event: 'email', email: link.href }
|
|
206
|
+
: { event: 'call', phone: link.href };
|
|
207
|
+
navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify(body));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Report the same click as a Google Ads conversion, when that action's
|
|
211
|
+
// `conversionLabel` is configured. `sendTo` is absent whenever the tag never
|
|
212
|
+
// loaded (gated and undecided, or `site.ads.google` unset) or that specific
|
|
213
|
+
// action has no label — silently skipped, same as every other
|
|
214
|
+
// optional-runtime check in this file.
|
|
215
|
+
const sendTo = adsGlobal().vitopsAdsConversions?.['google']?.[isEmail ? 'email' : 'call'];
|
|
216
|
+
if (sendTo && typeof adsGlobal().gtag === 'function') {
|
|
217
|
+
adsGlobal().gtag!('event', 'conversion', { send_to: sendTo });
|
|
160
218
|
}
|
|
161
219
|
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// 3. Form-submission conversion, for a consumer's own fetch-based submit handler
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const CONVERTED_ATTR = 'data-vitops-converted';
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Fires the Google Ads `form` conversion action (see `AdsGlobal` above) for a
|
|
229
|
+
* consumer's own fetch-based submit handler to call once its own POST to
|
|
230
|
+
* `createConversionRoute()` has succeeded — mirroring the tel:/mailto: click
|
|
231
|
+
* handler above, but for a form, which has no click to hook.
|
|
232
|
+
*
|
|
233
|
+
* Idempotent per element (`data-vitops-converted`): a handler that fires this
|
|
234
|
+
* more than once for the same submission (a retried network call, a
|
|
235
|
+
* double-invoked effect) reports the conversion once, not twice.
|
|
236
|
+
*
|
|
237
|
+
* There is deliberately no server-side equivalent for a no-JS
|
|
238
|
+
* `<form method="post">` submission — that path is a full-page redirect with no
|
|
239
|
+
* script execution to fire from, and reporting a Google-Ads-labelled
|
|
240
|
+
* conversion from the server needs the Google Ads API's click-conversion
|
|
241
|
+
* upload (developer token + MCC access), not a client-side pixel call. See the
|
|
242
|
+
* vNext backlog's §16 for that follow-up.
|
|
243
|
+
*/
|
|
244
|
+
function vitopsFormConversion(formEl: HTMLFormElement): void {
|
|
245
|
+
if (formEl.hasAttribute(CONVERTED_ATTR)) return;
|
|
246
|
+
formEl.setAttribute(CONVERTED_ATTR, '');
|
|
247
|
+
const sendTo = adsGlobal().vitopsAdsConversions?.['google']?.['form'];
|
|
248
|
+
if (sendTo && typeof adsGlobal().gtag === 'function') {
|
|
249
|
+
adsGlobal().gtag!('event', 'conversion', { send_to: sendTo });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
(window as unknown as { vitopsFormConversion?: typeof vitopsFormConversion }).vitopsFormConversion =
|
|
254
|
+
vitopsFormConversion;
|
package/src/tracking.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface GetvitopsTrackingOptions {
|
|
|
28
28
|
export const TRACKING_COOKIE = '_ac';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Where the capture script sends its `tel:` conversion
|
|
31
|
+
* Where the capture script sends its `tel:`/`mailto:` conversion beacons.
|
|
32
32
|
*
|
|
33
33
|
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
34
34
|
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
package/virtual.d.ts
CHANGED
|
@@ -11,6 +11,17 @@ declare module 'virtual:getvitops/head' {
|
|
|
11
11
|
cssVariable: string;
|
|
12
12
|
preload: boolean | { weight?: string | number; style?: string }[];
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors `HeadTrackingData` in src/integration.ts. Its own named interface
|
|
16
|
+
* (not inlined into `HeadData` below) so the drift guard's line-by-line key
|
|
17
|
+
* regex doesn't pick up its fields as false top-level `HeadData` matches.
|
|
18
|
+
*/
|
|
19
|
+
interface HeadTrackingData {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
category: string;
|
|
22
|
+
cookies: string[];
|
|
23
|
+
abVariantCookie: string | null;
|
|
24
|
+
}
|
|
14
25
|
// Keep these keys in step with `HeadData` in src/integration.ts — the two are
|
|
15
26
|
// hand-duplicated (an ambient `declare module` in a shipped .d.ts cannot import
|
|
16
27
|
// from src), and `head.test.ts` asserts the key sets match.
|
|
@@ -40,7 +51,12 @@ declare module 'virtual:getvitops/head' {
|
|
|
40
51
|
// integration rather than the component because its warnings belong beside
|
|
41
52
|
// the others, and the offered consent categories it checks against are known
|
|
42
53
|
// only there.
|
|
43
|
-
tracking:
|
|
54
|
+
tracking: HeadTrackingData | null;
|
|
55
|
+
// Enabled `site.experiments`, keyed the same way, resolved to their cookie
|
|
56
|
+
// names — re-typed by <Analytics /> on the way into `resolveAnalytics`.
|
|
57
|
+
experiments: Record<string, { cookieName: string }>;
|
|
58
|
+
// `site.analytics.matomo.abVariantDimensions`, passed through unchanged.
|
|
59
|
+
matomoAbVariantDimensions: Record<string, number>;
|
|
44
60
|
// The site config's `site.ads` block plus this environment's switch —
|
|
45
61
|
// structurally `{ enabled, properties }` (src/ads.ts), re-typed by <Ads /> on
|
|
46
62
|
// the way into `resolveAds`. Read in the integration because that is where the
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tracking-CmSfxWC3.mjs","names":[],"sources":["../src/tracking.ts"],"sourcesContent":["/**\n * The `<Tracking />` resolution layer — tracking config in, what to emit out.\n *\n * Separate from `Tracking.astro` for the same reason `analytics.ts` is separate\n * from `Analytics.astro`: everything decidable is a pure function asserted in\n * `tracking.test.ts`, and the component only renders what this returns.\n *\n * The one decision here is **which consent category the `_ac` cookie waits on**,\n * and it is stated rather than inferred. `_ac` is a 90-day identifier tying a\n * visitor to the ad that brought them, so `marketing` is the default — but a site\n * using click IDs purely for its own campaign reporting may reasonably file it\n * under `analytics`, and that is the consumer's call to make, not ours to guess.\n */\n\n/** Which consent category the attribution cookie waits on. */\nexport type TrackingCategory = 'marketing' | 'analytics';\n\nexport interface GetvitopsTrackingOptions {\n /** Capture ad click IDs and UTMs into `_ac`. */\n enabled?: boolean;\n /** Default `marketing`. */\n category?: TrackingCategory;\n /** Informational; capture recognises every known click-ID parameter regardless. */\n platforms?: string[];\n}\n\n/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */\nexport const TRACKING_COOKIE = '_ac';\n\n/**\n * Where the capture script sends its `tel:` conversion beacon.\n *\n * The route itself is the consumer's to write (`createConversionRoute()` in\n * `@getvitops/astro/routes`), so this is the one place the two halves agree on\n * a path. It was a bare literal inside the inlined script: a consumer who named\n * the file anything else got a silent 404 on every beacon and lost conversions\n * with no error anywhere to explain it. Exported so a consumer can assert\n * against it, mirrored in `scripts/tracking.ts` because that file is inlined\n * into the document and cannot import, and pinned by `tracking.test.ts`.\n */\nexport const TRACKING_ENDPOINT = '/api/track';\n\nexport interface ResolvedTracking {\n enabled: boolean;\n category: TrackingCategory;\n /** Cookie names to clear on revoke — rides on `data-consent-cookies`. */\n cookies: string[];\n /** Configuration problems, as prose. The integration logs these. */\n warnings: string[];\n}\n\nexport interface ResolveTrackingContext {\n /** Is the consent gate enabled on this site (`vitops({ consent })`)? */\n consent?: boolean;\n /** Categories the banner offers, so we can catch a demand with no row. */\n consentCategories?: readonly string[];\n}\n\n/**\n * Resolve the tracking config into what the component emits.\n *\n * Two warnings, both about the same failure — a persistent identifier written\n * with no way for the visitor to decline, or a permission asked for with no way\n * to answer. Returned as data rather than logged here so the check is a test\n * rather than a line someone might scroll past, exactly as `resolveAnalytics`\n * does.\n */\nexport function resolveTracking(\n opts: GetvitopsTrackingOptions | undefined,\n ctx: ResolveTrackingContext = {},\n): ResolvedTracking {\n const warnings: string[] = [];\n const category: TrackingCategory = opts?.category ?? 'marketing';\n const enabled = opts?.enabled === true;\n\n if (!enabled) return { enabled: false, category, cookies: [], warnings };\n\n if (!ctx.consent) {\n warnings.push(\n `tracking is on, so this site writes the \\`${TRACKING_COOKIE}\\` cookie — a 90-day ` +\n 'identifier tying a visitor to the ad that brought them — and `consent` is off, so it ' +\n 'is written for every visitor with no way to decline. Set `consent: true`.',\n );\n } else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) {\n // The banner would be raised for a category it renders no row for: the\n // visitor is asked a question the form does not show them.\n warnings.push(\n `tracking demands the \\`${category}\\` consent category, but the banner offers ` +\n `${ctx.consentCategories.join(', ') || 'nothing'}. Add \\`${category}\\` to ` +\n '`consent.categories`, or the visitor is asked about something the banner never names.',\n );\n }\n\n return { enabled, category, cookies: [TRACKING_COOKIE], warnings };\n}\n"],"mappings":";;AA2BA,MAAa,kBAAkB;;;;;;;;;;;;AAa/B,MAAa,oBAAoB;;;;;;;;;;AA2BjC,SAAgB,gBACd,MACA,MAA8B,CAAC,GACb;CAClB,MAAM,WAAqB,CAAC;CAC5B,MAAM,WAA6B,MAAM,YAAY;CACrD,MAAM,UAAU,MAAM,YAAY;CAElC,IAAI,CAAC,SAAS,OAAO;EAAE,SAAS;EAAO;EAAU,SAAS,CAAC;EAAG;CAAS;CAEvE,IAAI,CAAC,IAAI,SACP,SAAS,KACP,mDAA6D,wLAG/D;MACK,IAAI,IAAI,qBAAqB,CAAC,IAAI,kBAAkB,SAAS,QAAQ,GAG1E,SAAS,KACP,0BAA0B,SAAS,6CAC9B,IAAI,kBAAkB,KAAK,IAAI,KAAK,UAAU,UAAU,SAAS,8FAExE;CAGF,OAAO;EAAE;EAAS;EAAU,SAAS,MAAgB;EAAG;CAAS;AACnE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tracking-DdEKExlE.d.mts","names":[],"sources":["../src/tracking.ts"],"mappings":";;AAeA;;;;AAA4B;AAE5B;;;;;;;;KAFY,gBAAA;AAAA,UAEK,wBAAA;EAUJ;EARX,OAAA;;EAEA,QAAA,GAAW,gBAAgB;EAMD;EAJ1B,SAAA;AAAA;;cAIW,eAAA;AAaiB;AAE9B;;;;;;;;;;AAF8B,cAAjB,iBAAA;AAAA,UAEI,gBAAA;EACf,OAAA;EACA,QAAA,EAAU,gBAAgB;EAS1B;EAPA,OAAA;EAqBc;EAnBd,QAAA;AAAA;AAAA,UAGe,sBAAA;EAkBV;EAhBL,OAAA;EAiBiB;EAfjB,iBAAiB;AAAA;;;;;;AAeA;;;;iBAHH,eAAA,CACd,IAAA,EAAM,wBAAA,cACN,GAAA,GAAK,sBAAA,GACJ,gBAAA"}
|