@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.
- package/CHANGELOG.md +824 -33
- package/dist/config-adapters-CbTywgEY.mjs +71 -0
- package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
- package/dist/exports/tracking.d.mts +1 -1
- package/dist/exports/tracking.mjs +1 -1
- package/dist/index.d.mts +65 -48
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +240 -32
- package/dist/index.mjs.map +1 -1
- package/dist/middleware.d.mts +23 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +34 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/routes/conversion.d.mts +131 -16
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +133 -28
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/{tracking-CmSfxWC3.mjs → tracking-Cb3A0BrC.mjs} +3 -4
- package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
- package/dist/{tracking-DdEKExlE.d.mts → tracking-D2xOzjFD.d.mts} +6 -26
- package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
- package/package.json +12 -8
- package/src/ads.ts +52 -16
- package/src/analytics.ts +98 -11
- package/src/components/Analytics.astro +5 -1
- package/src/components/CookieConsent.astro +2 -1
- package/src/components/Icon.astro +8 -1
- package/src/components/ReviewCardBody.astro +5 -2
- package/src/components/Tracking.astro +2 -1
- package/src/schemas/Article.astro +2 -1
- package/src/schemas/BookActions.astro +2 -1
- package/src/schemas/Breadcrumb.astro +2 -1
- package/src/schemas/Carousel.astro +2 -1
- package/src/schemas/CourseList.astro +2 -1
- package/src/schemas/Dataset.astro +2 -1
- package/src/schemas/DiscussionForum.astro +2 -1
- package/src/schemas/EducationQA.astro +2 -1
- package/src/schemas/EmployerAggregateRating.astro +2 -1
- package/src/schemas/Event.astro +2 -1
- package/src/schemas/FAQ.astro +2 -1
- package/src/schemas/FactCheck.astro +2 -1
- package/src/schemas/ImageMetadata.astro +2 -1
- package/src/schemas/JobPosting.astro +2 -1
- package/src/schemas/LocalBusiness.astro +2 -1
- package/src/schemas/MathSolver.astro +2 -1
- package/src/schemas/MovieCarousel.astro +2 -1
- package/src/schemas/Organization.astro +2 -1
- package/src/schemas/Product.astro +2 -1
- package/src/schemas/ProfilePage.astro +2 -1
- package/src/schemas/QAPage.astro +2 -1
- package/src/schemas/Recipe.astro +2 -1
- package/src/schemas/Review.astro +2 -1
- package/src/schemas/SoftwareApp.astro +2 -1
- package/src/scripts/tracking.ts +114 -21
- package/src/seo.ts +6 -5
- package/src/tracking.ts +6 -10
- package/virtual.d.ts +17 -1
- package/dist/tracking-CmSfxWC3.mjs.map +0 -1
- 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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
54
|
+
<script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
|
|
54
55
|
<slot />
|
package/src/schemas/Event.astro
CHANGED
|
@@ -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={
|
|
115
|
+
<script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
|
|
115
116
|
<slot />
|
package/src/schemas/FAQ.astro
CHANGED
|
@@ -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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
147
|
+
<script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
|
|
147
148
|
<slot />
|
package/src/schemas/QAPage.astro
CHANGED
|
@@ -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={
|
|
71
|
+
<script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
|
|
71
72
|
<slot />
|
package/src/schemas/Recipe.astro
CHANGED
|
@@ -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={
|
|
178
|
+
<script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
|
|
178
179
|
<slot />
|
package/src/schemas/Review.astro
CHANGED
|
@@ -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={
|
|
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={
|
|
172
|
+
<script is:inline type="application/ld+json" set:html={jsonLdScript(jsonLd)} />
|
|
172
173
|
<slot />
|
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/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
|
-
*
|
|
24
|
-
* (
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 = '
|
|
18
|
+
export type TrackingCategory = NonNullable<TrackingConfig['category']>;
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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:
|
|
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"}
|