@getvitops/astro 8.1.0 → 8.2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +824 -33
  2. package/dist/config-adapters-CbTywgEY.mjs +71 -0
  3. package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
  4. package/dist/exports/tracking.d.mts +1 -1
  5. package/dist/exports/tracking.mjs +1 -1
  6. package/dist/index.d.mts +65 -48
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +240 -32
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/middleware.d.mts +23 -0
  11. package/dist/middleware.d.mts.map +1 -0
  12. package/dist/middleware.mjs +34 -0
  13. package/dist/middleware.mjs.map +1 -0
  14. package/dist/routes/conversion.d.mts +131 -16
  15. package/dist/routes/conversion.d.mts.map +1 -1
  16. package/dist/routes/conversion.mjs +133 -28
  17. package/dist/routes/conversion.mjs.map +1 -1
  18. package/dist/{tracking-CmSfxWC3.mjs → tracking-Cb3A0BrC.mjs} +3 -4
  19. package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
  20. package/dist/{tracking-DdEKExlE.d.mts → tracking-D2xOzjFD.d.mts} +6 -26
  21. package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
  22. package/package.json +12 -8
  23. package/src/ads.ts +52 -16
  24. package/src/analytics.ts +98 -11
  25. package/src/components/Analytics.astro +5 -1
  26. package/src/components/CookieConsent.astro +2 -1
  27. package/src/components/Icon.astro +8 -1
  28. package/src/components/ReviewCardBody.astro +5 -2
  29. package/src/components/Tracking.astro +2 -1
  30. package/src/schemas/Article.astro +2 -1
  31. package/src/schemas/BookActions.astro +2 -1
  32. package/src/schemas/Breadcrumb.astro +2 -1
  33. package/src/schemas/Carousel.astro +2 -1
  34. package/src/schemas/CourseList.astro +2 -1
  35. package/src/schemas/Dataset.astro +2 -1
  36. package/src/schemas/DiscussionForum.astro +2 -1
  37. package/src/schemas/EducationQA.astro +2 -1
  38. package/src/schemas/EmployerAggregateRating.astro +2 -1
  39. package/src/schemas/Event.astro +2 -1
  40. package/src/schemas/FAQ.astro +2 -1
  41. package/src/schemas/FactCheck.astro +2 -1
  42. package/src/schemas/ImageMetadata.astro +2 -1
  43. package/src/schemas/JobPosting.astro +2 -1
  44. package/src/schemas/LocalBusiness.astro +2 -1
  45. package/src/schemas/MathSolver.astro +2 -1
  46. package/src/schemas/MovieCarousel.astro +2 -1
  47. package/src/schemas/Organization.astro +2 -1
  48. package/src/schemas/Product.astro +2 -1
  49. package/src/schemas/ProfilePage.astro +2 -1
  50. package/src/schemas/QAPage.astro +2 -1
  51. package/src/schemas/Recipe.astro +2 -1
  52. package/src/schemas/Review.astro +2 -1
  53. package/src/schemas/SoftwareApp.astro +2 -1
  54. package/src/scripts/tracking.ts +114 -21
  55. package/src/seo.ts +6 -5
  56. package/src/tracking.ts +6 -10
  57. package/virtual.d.ts +17 -1
  58. package/dist/tracking-CmSfxWC3.mjs.map +0 -1
  59. package/dist/tracking-DdEKExlE.d.mts.map +0 -1
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  import { articleGraph } from '@getvitops/utils/schema';
3
4
  import type { ArticleGraphOptions } from '@getvitops/utils/schema';
4
5
 
@@ -7,5 +8,5 @@ export type Props = ArticleGraphOptions;
7
8
  const jsonLd = articleGraph(Astro.props);
8
9
  ---
9
10
 
10
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
11
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
11
12
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  author: string | string[];
@@ -77,5 +78,5 @@ const jsonLd = {
77
78
  };
78
79
  ---
79
80
 
80
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
81
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
81
82
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  import { breadcrumbGraph } from '@getvitops/utils/schema';
3
4
  import type { BreadcrumbGraphOptions, BreadcrumbItem } from '@getvitops/utils/schema';
4
5
 
@@ -8,5 +9,5 @@ export type Props = BreadcrumbGraphOptions;
8
9
  const jsonLd = breadcrumbGraph(Astro.props);
9
10
  ---
10
11
 
11
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
12
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
12
13
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  import { itemListGraph } from '@getvitops/utils/schema';
3
4
  import type { ItemListEntry, ItemListGraphOptions } from '@getvitops/utils/schema';
4
5
 
@@ -9,5 +10,5 @@ export type Props = ItemListGraphOptions;
9
10
  const jsonLd = itemListGraph(Astro.props);
10
11
  ---
11
12
 
12
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
13
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
13
14
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface CourseInstance {
3
4
  courseMode: 'online' | 'onsite' | 'blended';
4
5
  startDate?: string;
@@ -118,5 +119,5 @@ const jsonLd = {
118
119
  };
119
120
  ---
120
121
 
121
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
122
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
122
123
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  description: string;
@@ -123,5 +124,5 @@ const jsonLd = {
123
124
  };
124
125
  ---
125
126
 
126
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
127
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
127
128
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface ForumPost {
3
4
  headline: string;
4
5
  text: string;
@@ -78,5 +79,5 @@ const jsonLd = {
78
79
  };
79
80
  ---
80
81
 
81
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
82
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
82
83
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  mainEntity: {
4
5
  question: string;
@@ -59,5 +60,5 @@ const jsonLd = {
59
60
  };
60
61
  ---
61
62
 
62
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
63
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
63
64
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  url?: string;
@@ -50,5 +51,5 @@ const jsonLd = {
50
51
  };
51
52
  ---
52
53
 
53
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
54
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
54
55
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  description?: string;
@@ -111,5 +112,5 @@ const jsonLd = {
111
112
  };
112
113
  ---
113
114
 
114
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
115
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
115
116
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  /**
3
4
  * FAQ Schema Component
4
5
  *
@@ -14,5 +15,5 @@ export type Props = FAQGraphOptions;
14
15
  const jsonLd = faqGraph(Astro.props);
15
16
  ---
16
17
 
17
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
18
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
18
19
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  claimReviewed: string;
4
5
  url: string;
@@ -80,5 +81,5 @@ const jsonLd = {
80
81
  };
81
82
  ---
82
83
 
83
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
84
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
84
85
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  contentUrl: string;
4
5
  creditText?: string;
@@ -52,5 +53,5 @@ const jsonLd = {
52
53
  };
53
54
  ---
54
55
 
55
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
56
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
56
57
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  title: string;
4
5
  description: string;
@@ -134,5 +135,5 @@ const jsonLd = {
134
135
  };
135
136
  ---
136
137
 
137
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
138
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
138
139
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  import { localBusinessGraph } from '@getvitops/utils/schema';
3
4
  import type { LocalBusinessGraphOptions } from '@getvitops/utils/schema';
4
5
 
@@ -7,5 +8,5 @@ export type Props = LocalBusinessGraphOptions;
7
8
  const jsonLd = localBusinessGraph(Astro.props);
8
9
  ---
9
10
 
10
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
11
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
11
12
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  url: string;
@@ -50,5 +51,5 @@ const jsonLd = {
50
51
  };
51
52
  ---
52
53
 
53
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
54
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
54
55
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Movie {
3
4
  name: string;
4
5
  url: string;
@@ -88,5 +89,5 @@ const jsonLd = {
88
89
  };
89
90
  ---
90
91
 
91
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
92
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
92
93
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  import { organizationGraph } from '@getvitops/utils/schema';
3
4
  import type { OrganizationGraphOptions } from '@getvitops/utils/schema';
4
5
 
@@ -7,5 +8,5 @@ export type Props = OrganizationGraphOptions;
7
8
  const jsonLd = organizationGraph(Astro.props);
8
9
  ---
9
10
 
10
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
11
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
11
12
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  description?: string;
@@ -246,5 +247,5 @@ const jsonLd = {
246
247
  };
247
248
  ---
248
249
 
249
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
250
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
250
251
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  alternateName?: string;
@@ -143,5 +144,5 @@ const jsonLd = {
143
144
  };
144
145
  ---
145
146
 
146
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
147
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
147
148
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Answer {
3
4
  text: string;
4
5
  dateCreated: string;
@@ -67,5 +68,5 @@ const jsonLd = {
67
68
  };
68
69
  ---
69
70
 
70
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
71
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
71
72
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  description?: string;
@@ -174,5 +175,5 @@ const jsonLd = {
174
175
  };
175
176
  ---
176
177
 
177
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
178
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
178
179
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  itemReviewed: {
4
5
  type: 'Product' | 'LocalBusiness' | 'Organization' | 'Book' | 'Movie' | 'Restaurant' | 'Event' | 'CreativeWork' | 'SoftwareApplication' | 'MusicRecording' | 'Recipe';
@@ -134,5 +135,5 @@ const jsonLd = {
134
135
  };
135
136
  ---
136
137
 
137
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
138
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
138
139
  <slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { jsonLdScript } from '../json-ld.ts';
2
3
  export interface Props {
3
4
  name: string;
4
5
  description?: string;
@@ -168,5 +169,5 @@ const jsonLd = {
168
169
  };
169
170
  ---
170
171
 
171
- <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
172
+ <script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
172
173
  <slot />
@@ -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 `createConversionRoute()`
3
- // (`@getvitops/astro/routes`), which handles both the no-JS `<form
4
- // method="post">` path and a fetch-based one — there is no separate
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
- const AB_COOKIE = '_ab';
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 beacon goes. Mirrors TRACKING_ENDPOINT in
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
- // Include A/B variant if assigned by the router Worker. A cookie present but
74
- // valueless yields undefined, which must not be written into the record.
75
- const abMatch = document.cookie.split(';').find((c) => c.trim().startsWith(`${AB_COOKIE}=`));
76
- const abVariant = abMatch?.split('=')[1]?.trim();
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 match = document.cookie.split(';').find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
81
- if (!match) return null;
103
+ const raw = rawCookie(COOKIE_NAME);
104
+ if (!raw) return null;
82
105
  try {
83
- const data: unknown = JSON.parse(decodeURIComponent(match.split('=').slice(1).join('=')));
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, which also carries
125
- // `data-consent-cookies` so revoking clears `_ac`.
126
- const marker = document.querySelector('[data-vitops-tracking]');
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?.('a[href^="tel:"]') as HTMLAnchorElement | null;
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
- // Fire-and-forget beacon; don't block the tel: navigation
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
- navigator.sendBeacon(TRACK_ENDPOINT, JSON.stringify({ event: 'call', phone: link.href }));
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/seo.ts CHANGED
@@ -20,11 +20,12 @@ export type SeoOgType = 'website' | 'article' | 'profile';
20
20
  * Site-level SEO defaults, passed to `vitops({ seo })` and baked into the
21
21
  * `virtual:getvitops/head` module. Per-page `<Seo />` props override these.
22
22
  *
23
- * The vocabulary mirrors the site-config schema's `seo` block
24
- * (`@getvitops/generator`, `src/config.ts`) so a `Config` → these adapter would
25
- * be a flat field map — but it is not imported from there. This is the
26
- * integration's own option surface, and it must stay JSON-serialisable: it is
27
- * `JSON.stringify`d into the virtual module, so no functions, no `URL`s, no dates.
23
+ * `site.seo` in the site config is the source (`@getvitops/config`'s `SeoSchema`);
24
+ * `resolveSeoDefaults()` turns it into this shape for a build locale, and `vitops({ seo })` is merged
25
+ * over the result as an override. This interface is therefore the *resolved* shape — plain strings,
26
+ * `Localizable` already picked — and must stay JSON-serialisable: it is `JSON.stringify`d into the
27
+ * virtual module, so no functions, no `URL`s, no dates. `seo-defaults.test.ts` fails when a
28
+ * `site.seo` field is added to the schema and not read here (ADR-0002).
28
29
  */
29
30
  export interface GetvitopsSeoOptions {
30
31
  /** `og:site_name`, and the `<title>` when a page passes none. */
package/src/tracking.ts CHANGED
@@ -12,23 +12,19 @@
12
12
  * under `analytics`, and that is the consumer's call to make, not ours to guess.
13
13
  */
14
14
 
15
+ import type { TrackingConfig } from '@getvitops/config';
16
+
15
17
  /** Which consent category the attribution cookie waits on. */
16
- export type TrackingCategory = 'marketing' | 'analytics';
18
+ export type TrackingCategory = NonNullable<TrackingConfig['category']>;
17
19
 
18
- export interface GetvitopsTrackingOptions {
19
- /** Capture ad click IDs and UTMs into `_ac`. */
20
- enabled?: boolean;
21
- /** Default `marketing`. */
22
- category?: TrackingCategory;
23
- /** Informational; capture recognises every known click-ID parameter regardless. */
24
- platforms?: string[];
25
- }
20
+ /** The `site.tracking` block — the schema's own type from `@getvitops/config`, not a copy. */
21
+ export type GetvitopsTrackingOptions = TrackingConfig;
26
22
 
27
23
  /** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
28
24
  export const TRACKING_COOKIE = '_ac';
29
25
 
30
26
  /**
31
- * Where the capture script sends its `tel:` conversion beacon.
27
+ * Where the capture script sends its `tel:`/`mailto:` conversion beacons.
32
28
  *
33
29
  * The route itself is the consumer's to write (`createConversionRoute()` in
34
30
  * `@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: { enabled: boolean; category: string; cookies: string[] } | null;
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"}