@getvitops/astro 3.0.0 → 4.1.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 +237 -0
- package/README.md +1 -1
- package/dist/index.d.mts +132 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +193 -7
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +45 -0
- package/dist/routes/conversion.d.mts.map +1 -0
- package/dist/routes/conversion.mjs +95 -0
- package/dist/routes/conversion.mjs.map +1 -0
- package/package.json +23 -6
- package/src/ads.ts +156 -0
- package/src/analytics.ts +22 -4
- package/src/components/Ads.astro +47 -0
- package/src/components/Analytics.astro +7 -30
- package/src/components/CookieConsent.astro +23 -15
- package/src/components/GatedTags.astro +46 -0
- package/src/components/Head.astro +39 -1
- package/src/components/Tracking.astro +43 -0
- package/src/components/Tree.astro +82 -0
- package/src/components/TreeNode.astro +63 -0
- package/src/components/WebComponentLoader.astro +1 -1
- package/src/components/tree.ts +19 -0
- package/src/scripts/tracking.ts +149 -0
- package/src/tracking.ts +82 -0
- package/virtual.d.ts +11 -0
|
@@ -41,6 +41,33 @@ const THEME_SCRIPT =
|
|
|
41
41
|
`if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;` +
|
|
42
42
|
`document.documentElement.style.colorScheme=s+' only'}}catch(e){}`;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The consent gate's build-time signal: "something on this page may need
|
|
46
|
+
* permission, and the real gate hasn't loaded yet."
|
|
47
|
+
*
|
|
48
|
+
* `consent.js` and `elements.js` are both deferred modules with no ordering
|
|
49
|
+
* guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
|
|
50
|
+
* before the gate exists. Without this, a caller probing for
|
|
51
|
+
* `window.vitopsConsent` in that window would see nothing, correctly conclude
|
|
52
|
+
* that sites without the gate should just store — and store. The stub closes the
|
|
53
|
+
* window by making the gate's *existence* known synchronously in `<head>`, which
|
|
54
|
+
* is the only place that beats every deferred bundle.
|
|
55
|
+
*
|
|
56
|
+
* It answers `false` to everything, because nothing is granted until the store
|
|
57
|
+
* has been read, and queues what it was asked so `consent/runtime.ts` can replay
|
|
58
|
+
* it on evaluation. `q` entries are `[category]` for `require()` and
|
|
59
|
+
* `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
|
|
60
|
+
* stub in `analytics.ts` and for the same reason.
|
|
61
|
+
*
|
|
62
|
+
* Emitted only when the gate is on: its absence is what tells a caller the site
|
|
63
|
+
* has no gate at all.
|
|
64
|
+
*/
|
|
65
|
+
const CONSENT_STUB =
|
|
66
|
+
`window.vitopsConsent=window.vitopsConsent||{q:[],` +
|
|
67
|
+
`require:function(c){this.q.push([c]);return false},` +
|
|
68
|
+
`request:function(c){var s=this;return new Promise(function(r){s.q.push([c,r])})},` +
|
|
69
|
+
`granted:function(){return false},needed:function(){return false}};`;
|
|
70
|
+
|
|
44
71
|
/*
|
|
45
72
|
* No `<html>` class flips here on purpose. `animation.css` used to need `no-js`
|
|
46
73
|
* and `.no-scroll-timeline` set before first paint; it now asks the platform
|
|
@@ -80,12 +107,23 @@ const THEME_SCRIPT =
|
|
|
80
107
|
{/* Set by vitops({ sitemap }); null when no sitemap was registered. */}
|
|
81
108
|
{head.sitemap && <link rel="sitemap" type="application/xml" href={head.sitemap} />}
|
|
82
109
|
|
|
110
|
+
{/*
|
|
111
|
+
The consent stub — synchronous, and ahead of every module script on purpose.
|
|
112
|
+
See CONSENT_STUB above: it exists so that code which loads before the gate can
|
|
113
|
+
tell "the gate isn't here yet" from "this site has no gate", and queue rather
|
|
114
|
+
than assume it may store. Keyed on `head.consent` (is the gate on?) rather than
|
|
115
|
+
`head.consentRuntime` (does anything need scheduling?) — a site whose only tag
|
|
116
|
+
is cookieless loads the runtime to schedule it but gates nothing, so there is
|
|
117
|
+
nothing for a caller to queue behind.
|
|
118
|
+
*/}
|
|
119
|
+
{head.consent && <script is:inline set:html={CONSENT_STUB} />}
|
|
120
|
+
|
|
83
121
|
{webComponents && (
|
|
84
122
|
<>
|
|
85
123
|
{/*
|
|
86
124
|
Apply the remembered colour scheme before first paint.
|
|
87
125
|
|
|
88
|
-
`<color-scheme-toggle>` persists the choice to localStorage, but it loads
|
|
126
|
+
`<wc-color-scheme-toggle>` persists the choice to localStorage, but it loads
|
|
89
127
|
as a deferred module — so without this every navigation would paint the
|
|
90
128
|
light token set and flip to dark once the element upgraded. Synchronous and
|
|
91
129
|
inline on purpose: it has to run before the body renders.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <Tracking /> — ad-click attribution capture. Place in your layout's <head>;
|
|
4
|
+
* requires the vitops() integration, which supplies `virtual:getvitops/head`.
|
|
5
|
+
*
|
|
6
|
+
* A dumb renderer over `resolveTracking()` (src/tracking.ts) — the one decision,
|
|
7
|
+
* which consent category `_ac` waits on, lives there and is unit-tested.
|
|
8
|
+
*
|
|
9
|
+
* Two things it emits, and the marker is the interesting one:
|
|
10
|
+
*
|
|
11
|
+
* - **The marker** carries `data-consent` (which category) and
|
|
12
|
+
* `data-consent-cookies` (what to clear). It deliberately does *not* carry
|
|
13
|
+
* `data-vitops-tag`, so the consent runtime's `scan()` never tries to activate
|
|
14
|
+
* it — this script is ungated by design, because reading the query string is
|
|
15
|
+
* not storage and the click ID is only in the URL on the landing page. But
|
|
16
|
+
* `clearCookies()` queries `[data-consent="<category>"]` and reads
|
|
17
|
+
* `data-consent-cookies` off whatever it finds, so revoking consent clears
|
|
18
|
+
* `_ac`. That cleanup did not exist before.
|
|
19
|
+
* - **The script**, which captures synchronously and then waits on consent for
|
|
20
|
+
* the *write* only. See src/scripts/tracking.ts.
|
|
21
|
+
*
|
|
22
|
+
* `<meta>` rather than a `<span>` so it is valid in `<head>`, where this
|
|
23
|
+
* component belongs — the capture wants to run before the visitor can navigate.
|
|
24
|
+
*/
|
|
25
|
+
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
26
|
+
import head from 'virtual:getvitops/head';
|
|
27
|
+
|
|
28
|
+
const { enabled, category, cookies } = head.tracking ?? { enabled: false };
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
{enabled && (
|
|
32
|
+
<>
|
|
33
|
+
<meta
|
|
34
|
+
name="vitops-tracking"
|
|
35
|
+
data-vitops-tracking
|
|
36
|
+
data-consent={category}
|
|
37
|
+
data-consent-cookies={cookies?.length ? cookies.join(',') : undefined}
|
|
38
|
+
/>
|
|
39
|
+
<script>
|
|
40
|
+
import '../scripts/tracking.ts';
|
|
41
|
+
</script>
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* An accessible disclosure tree, progressively enhanced.
|
|
4
|
+
*
|
|
5
|
+
* Tier-3 in the framework's terms — an authoring convenience that emits the right
|
|
6
|
+
* `.tree` markup and requires **no runtime JS**. What it renders is the whole
|
|
7
|
+
* content, and it stands on its own:
|
|
8
|
+
*
|
|
9
|
+
* - structure is real `<ul>`/`<li>` nesting, so assistive tech announces list
|
|
10
|
+
* position and depth. A `<div>` soup looks identical and tells a screen-reader
|
|
11
|
+
* user nothing about where they are;
|
|
12
|
+
* - every branch is a native `<details>`/`<summary>`, so expanded state and
|
|
13
|
+
* keyboard operation come from the platform. No `role="tree"`, no
|
|
14
|
+
* `aria-expanded` bookkeeping — a hand-rolled ARIA tree that loses its JS is
|
|
15
|
+
* worse than no ARIA at all;
|
|
16
|
+
* - nodes carry ids, so any node is linkable without JS.
|
|
17
|
+
*
|
|
18
|
+
* **This component emits its own `<wc-tree>`** — do NOT wrap it in one. That is the
|
|
19
|
+
* tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible fallback
|
|
20
|
+
* inside"), and `CookieConsent.astro` does the same with `<wc-consent>`.
|
|
21
|
+
* `<wc-tree><Tree /></wc-tree>` nests two elements binding the same tree; the
|
|
22
|
+
* element now defers to an enclosing one, so it degrades to a no-op rather than two
|
|
23
|
+
* duelling toolbars, but the single call is the intended shape.
|
|
24
|
+
*
|
|
25
|
+
* `<wc-tree>` upgrades it with filter, expand/collapse-all and deep-linking. If
|
|
26
|
+
* that never loads, only those three conveniences are missing — which is why
|
|
27
|
+
* `enhance={false}` is a supported way to get the bare fragment.
|
|
28
|
+
*/
|
|
29
|
+
import type { TreeItem } from './tree.ts';
|
|
30
|
+
import TreeNode from './TreeNode.astro';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
items: TreeItem[];
|
|
34
|
+
/** Placeholder + aria-label for the filter `<wc-tree>` generates. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** Accessible name for the list itself. */
|
|
37
|
+
ariaLabel?: string;
|
|
38
|
+
/** Branches shallower than this render open. 0 = everything closed. */
|
|
39
|
+
openDepth?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Emit the `<wc-tree>` wrapper (default `true`).
|
|
42
|
+
*
|
|
43
|
+
* Set false when the fragment is going somewhere the element cannot run or is
|
|
44
|
+
* unwanted — an email, a PDF, a server-rendered block on a page that doesn't load
|
|
45
|
+
* `elements.js`. The tree itself is unchanged; only the enhancement layer is
|
|
46
|
+
* omitted, so nothing is lost but filter/expand-all/deep-link.
|
|
47
|
+
*/
|
|
48
|
+
enhance?: boolean;
|
|
49
|
+
}
|
|
50
|
+
const {
|
|
51
|
+
items,
|
|
52
|
+
label = 'Filter',
|
|
53
|
+
ariaLabel,
|
|
54
|
+
openDepth = 0,
|
|
55
|
+
enhance = true,
|
|
56
|
+
// Anything else is passed through to `<wc-tree>`, so the element can grow
|
|
57
|
+
// attributes without this component's prop surface tracking them.
|
|
58
|
+
...rest
|
|
59
|
+
} = Astro.props;
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
{/* One `<ul>`, rendered once, wrapped or not — duplicating the tree markup per
|
|
63
|
+
branch is how the two paths would drift. */}
|
|
64
|
+
<Fragment>
|
|
65
|
+
{
|
|
66
|
+
enhance ? (
|
|
67
|
+
<wc-tree label={label} {...rest}>
|
|
68
|
+
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
69
|
+
{items.map((item) => (
|
|
70
|
+
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</wc-tree>
|
|
74
|
+
) : (
|
|
75
|
+
<ul class="tree tree--lines" aria-label={ariaLabel}>
|
|
76
|
+
{items.map((item) => (
|
|
77
|
+
<TreeNode item={item} depth={0} openDepth={openDepth} />
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
</Fragment>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One node of a `<Tree />`, recursing through `Astro.self`.
|
|
4
|
+
*
|
|
5
|
+
* Two shapes, and the choice is semantic rather than cosmetic:
|
|
6
|
+
*
|
|
7
|
+
* - a **branch** wraps its label in `<details>`/`<summary>` — a native
|
|
8
|
+
* disclosure button, so expanded state is announced and keyboard operation
|
|
9
|
+
* is free. The child `<ul>` lives inside the `<details>`, which is what makes
|
|
10
|
+
* it collapse with no JS.
|
|
11
|
+
* - a **leaf** is a plain `.tree__content` row. A `<details>` with nothing to
|
|
12
|
+
* disclose would still render a control and announce as collapsible.
|
|
13
|
+
*
|
|
14
|
+
* The item class sits on the `<li>` in both cases, so `<wc-tree>`'s filter treats
|
|
15
|
+
* leaves and branches as one kind of thing and hiding one hides its whole row.
|
|
16
|
+
*/
|
|
17
|
+
import type { TreeItem } from './tree.ts';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
item: TreeItem;
|
|
21
|
+
depth: number;
|
|
22
|
+
/** Branches at a depth below this render open. */
|
|
23
|
+
openDepth: number;
|
|
24
|
+
}
|
|
25
|
+
const { item, depth, openDepth } = Astro.props;
|
|
26
|
+
|
|
27
|
+
const kids = item.children ?? [];
|
|
28
|
+
const isBranch = kids.length > 0;
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<li class="tree__item" id={item.id}>
|
|
32
|
+
{
|
|
33
|
+
isBranch ? (
|
|
34
|
+
<details open={depth < openDepth}>
|
|
35
|
+
<summary>
|
|
36
|
+
<span class="tree__toggle" aria-hidden="true" />
|
|
37
|
+
<span class="tree__label">
|
|
38
|
+
{item.unnamed ? <em>{item.label}</em> : <code>{item.label}</code>}
|
|
39
|
+
{item.badge && <span class="badge">{item.badge}</span>}
|
|
40
|
+
{item.flag && <span class="badge badge-danger">{item.flag}</span>}
|
|
41
|
+
</span>
|
|
42
|
+
</summary>
|
|
43
|
+
{item.descriptionHtml && <p class="tree__desc" set:html={item.descriptionHtml} />}
|
|
44
|
+
<ul class="tree">
|
|
45
|
+
{kids.map((kid) => (
|
|
46
|
+
<Astro.self item={kid} depth={depth + 1} openDepth={openDepth} />
|
|
47
|
+
))}
|
|
48
|
+
</ul>
|
|
49
|
+
</details>
|
|
50
|
+
) : (
|
|
51
|
+
<Fragment>
|
|
52
|
+
<span class="tree__content">
|
|
53
|
+
<span class="tree__label">
|
|
54
|
+
{item.unnamed ? <em>{item.label}</em> : <code>{item.label}</code>}
|
|
55
|
+
{item.badge && <span class="badge">{item.badge}</span>}
|
|
56
|
+
{item.flag && <span class="badge badge-danger">{item.flag}</span>}
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
{item.descriptionHtml && <p class="tree__desc" set:html={item.descriptionHtml} />}
|
|
60
|
+
</Fragment>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
</li>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One node of a `<Tree />`. See `Tree.astro` for the rendering contract. */
|
|
2
|
+
export interface TreeItem {
|
|
3
|
+
label: string;
|
|
4
|
+
/** Small type/meta chip after the label. */
|
|
5
|
+
badge?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Rendered with `set:html`, so it MUST already be escaped. For schema text use
|
|
8
|
+
* the generator's `renderInlineMarkdown()` — one tested escaper beats each
|
|
9
|
+
* caller doing its own, which is how one of them eventually doesn't.
|
|
10
|
+
*/
|
|
11
|
+
descriptionHtml?: string;
|
|
12
|
+
/** Extra chip, e.g. "required". */
|
|
13
|
+
flag?: string;
|
|
14
|
+
/** Anchor id, so the node is linkable with no JS. */
|
|
15
|
+
id?: string;
|
|
16
|
+
/** An unnamed group (a union branch) — label renders as emphasis, not code. */
|
|
17
|
+
unnamed?: boolean;
|
|
18
|
+
children?: TreeItem[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Ad conversion tracking: capture click IDs from the landing URL, track tel:
|
|
2
|
+
// clicks. Form enhancement is in form-enhance.ts.
|
|
3
|
+
//
|
|
4
|
+
// Runs early and ungated on purpose. Reading the query string is not storage and
|
|
5
|
+
// needs nobody's permission; *keeping* it does. So the capture is synchronous —
|
|
6
|
+
// it has to be, the click ID is only in the URL on the landing page — and only
|
|
7
|
+
// the write to `_ac` waits on consent.
|
|
8
|
+
//
|
|
9
|
+
// The `_ac` cookie is a 90-day identifier tying a visitor to the ad that brought
|
|
10
|
+
// them, which is what `marketing` covers. Two things follow, and both matter:
|
|
11
|
+
//
|
|
12
|
+
// - It **demands** the category rather than passively reading it. Nothing else
|
|
13
|
+
// on a page would ask for `marketing`, so a passive `granted()` check is a
|
|
14
|
+
// permanent no-op: the banner never offers it, so it is never granted, so
|
|
15
|
+
// `_ac` is never written and no attribution ever reaches a notification —
|
|
16
|
+
// silently, on every site that enabled the gate.
|
|
17
|
+
// - It only demands when the URL actually carried something. A visitor arriving
|
|
18
|
+
// organically has nothing to attribute, so they are never asked. That is the
|
|
19
|
+
// whole point of a demand-driven banner applied to attribution: the one
|
|
20
|
+
// arrival that needs the permission is the one that requests it.
|
|
21
|
+
//
|
|
22
|
+
// With no gate on the page it behaves as it always has — a site that has not
|
|
23
|
+
// adopted consent has made no promise for this to break.
|
|
24
|
+
|
|
25
|
+
const COOKIE_NAME = '_ac';
|
|
26
|
+
const COOKIE_DAYS = 90;
|
|
27
|
+
const AB_COOKIE = '_ab';
|
|
28
|
+
|
|
29
|
+
// Mirrors CLICK_ID_PARAMS/UTM_PARAMS in @getvitops/utils/tracking, deliberately
|
|
30
|
+
// copied rather than imported: this file is inlined into the document by
|
|
31
|
+
// <Tracking />, so a bare import would either fail or drag a module graph into
|
|
32
|
+
// the critical path. `tracking.test.ts` in this package pins the two lists.
|
|
33
|
+
const CLICK_ID_PARAMS = [
|
|
34
|
+
'gclid',
|
|
35
|
+
'gbraid',
|
|
36
|
+
'wbraid', // Google Ads
|
|
37
|
+
'fbclid', // Meta (Facebook/Instagram)
|
|
38
|
+
'ttclid', // TikTok
|
|
39
|
+
'rdt_cid', // Reddit
|
|
40
|
+
'ScCid', // Snapchat
|
|
41
|
+
'msclkid', // Microsoft/Bing
|
|
42
|
+
'li_fat_id', // LinkedIn
|
|
43
|
+
'epik', // Pinterest
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// 1. Capture click IDs + UTMs from URL → cookie
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const params = new URLSearchParams(location.search);
|
|
53
|
+
const trackingData: Record<string, string> = {};
|
|
54
|
+
|
|
55
|
+
for (const key of [...CLICK_ID_PARAMS, ...UTM_PARAMS]) {
|
|
56
|
+
const value = params.get(key);
|
|
57
|
+
if (value) trackingData[key] = value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Include A/B variant if assigned by the router Worker. A cookie present but
|
|
61
|
+
// valueless yields undefined, which must not be written into the record.
|
|
62
|
+
const abMatch = document.cookie.split(';').find((c) => c.trim().startsWith(`${AB_COOKIE}=`));
|
|
63
|
+
const abVariant = abMatch?.split('=')[1]?.trim();
|
|
64
|
+
if (abVariant) trackingData['ab_variant'] = abVariant;
|
|
65
|
+
|
|
66
|
+
function readCookie(): Record<string, unknown> | null {
|
|
67
|
+
const match = document.cookie.split(';').find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
|
|
68
|
+
if (!match) return null;
|
|
69
|
+
try {
|
|
70
|
+
const data: unknown = JSON.parse(decodeURIComponent(match.split('=').slice(1).join('=')));
|
|
71
|
+
return typeof data === 'object' && data !== null && !Array.isArray(data)
|
|
72
|
+
? (data as Record<string, unknown>)
|
|
73
|
+
: null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function persist(): void {
|
|
80
|
+
// Merge with any existing cookie data (don't overwrite prior click IDs from a
|
|
81
|
+
// different session unless new ones are present). `ts` keeps the FIRST
|
|
82
|
+
// capture: the 90-day window is measured from the original click, and taking
|
|
83
|
+
// the latest would restart it on every visit.
|
|
84
|
+
const existing = readCookie();
|
|
85
|
+
const merged = {
|
|
86
|
+
...existing,
|
|
87
|
+
...trackingData,
|
|
88
|
+
landingPage: location.pathname,
|
|
89
|
+
referrer: document.referrer || undefined,
|
|
90
|
+
ts: existing?.['ts'] ?? Date.now(),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Remove undefined values before serializing
|
|
94
|
+
const clean = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
|
|
95
|
+
|
|
96
|
+
const expires = new Date(Date.now() + COOKIE_DAYS * 864e5).toUTCString();
|
|
97
|
+
const secure = location.protocol === 'https:' ? ';Secure' : '';
|
|
98
|
+
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(
|
|
99
|
+
JSON.stringify(clean),
|
|
100
|
+
)};expires=${expires};path=/;SameSite=Lax${secure}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Typed locally: a global augmentation here would widen `Window` package-wide. */
|
|
104
|
+
interface ConsentLike {
|
|
105
|
+
granted(c: string): boolean;
|
|
106
|
+
require(c: string): boolean;
|
|
107
|
+
}
|
|
108
|
+
const gate = (): ConsentLike | undefined =>
|
|
109
|
+
(window as unknown as { vitopsConsent?: ConsentLike }).vitopsConsent;
|
|
110
|
+
|
|
111
|
+
if (Object.keys(trackingData).length > 0) {
|
|
112
|
+
// <Tracking /> writes the category onto this marker, which also carries
|
|
113
|
+
// `data-consent-cookies` so revoking clears `_ac`.
|
|
114
|
+
const marker = document.querySelector('[data-vitops-tracking]');
|
|
115
|
+
const category = marker?.getAttribute('data-consent') ?? 'marketing';
|
|
116
|
+
|
|
117
|
+
if (!gate()) {
|
|
118
|
+
// No gate on this site: behave as it always has.
|
|
119
|
+
persist();
|
|
120
|
+
} else {
|
|
121
|
+
// Ask. This is what raises the banner — without it nothing on the page ever
|
|
122
|
+
// demands `marketing`, so it is never granted and `_ac` is never written.
|
|
123
|
+
if (gate()?.require(category)) persist();
|
|
124
|
+
|
|
125
|
+
// Then keep listening. A standing listener rather than acting on the one-shot
|
|
126
|
+
// answer above, for two reasons that both bite: `window.vitopsConsent` may
|
|
127
|
+
// still be the inline stub <Head /> emits — which queues `require()` and has
|
|
128
|
+
// no `subscribe` at all — and the visitor may accept a moment later, in this
|
|
129
|
+
// same page view, with the click ID still in the URL. The event fires either
|
|
130
|
+
// way, and the runtime publishes once on startup after draining the stub.
|
|
131
|
+
document.addEventListener('vitops:consent', () => {
|
|
132
|
+
if (gate()?.granted(category)) persist();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// 2. Tel: click tracking via sendBeacon
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
document.addEventListener('click', (e) => {
|
|
142
|
+
const link = (e.target as Element).closest?.('a[href^="tel:"]') as HTMLAnchorElement | null;
|
|
143
|
+
if (!link) return;
|
|
144
|
+
|
|
145
|
+
// Fire-and-forget beacon; don't block the tel: navigation
|
|
146
|
+
if (navigator.sendBeacon) {
|
|
147
|
+
navigator.sendBeacon('/api/track', JSON.stringify({ event: 'call', phone: link.href }));
|
|
148
|
+
}
|
|
149
|
+
});
|
package/src/tracking.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<Tracking />` resolution layer — tracking config in, what to emit out.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `Tracking.astro` for the same reason `analytics.ts` is separate
|
|
5
|
+
* from `Analytics.astro`: everything decidable is a pure function asserted in
|
|
6
|
+
* `tracking.test.ts`, and the component only renders what this returns.
|
|
7
|
+
*
|
|
8
|
+
* The one decision here is **which consent category the `_ac` cookie waits on**,
|
|
9
|
+
* and it is stated rather than inferred. `_ac` is a 90-day identifier tying a
|
|
10
|
+
* visitor to the ad that brought them, so `marketing` is the default — but a site
|
|
11
|
+
* using click IDs purely for its own campaign reporting may reasonably file it
|
|
12
|
+
* under `analytics`, and that is the consumer's call to make, not ours to guess.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Which consent category the attribution cookie waits on. */
|
|
16
|
+
export type TrackingCategory = 'marketing' | 'analytics';
|
|
17
|
+
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
28
|
+
export const TRACKING_COOKIE = '_ac';
|
|
29
|
+
|
|
30
|
+
export interface ResolvedTracking {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
category: TrackingCategory;
|
|
33
|
+
/** Cookie names to clear on revoke — rides on `data-consent-cookies`. */
|
|
34
|
+
cookies: string[];
|
|
35
|
+
/** Configuration problems, as prose. The integration logs these. */
|
|
36
|
+
warnings: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolveTrackingContext {
|
|
40
|
+
/** Is the consent gate enabled on this site (`vitops({ consent })`)? */
|
|
41
|
+
consent?: boolean;
|
|
42
|
+
/** Categories the banner offers, so we can catch a demand with no row. */
|
|
43
|
+
consentCategories?: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the tracking config into what the component emits.
|
|
48
|
+
*
|
|
49
|
+
* Two warnings, both about the same failure — a persistent identifier written
|
|
50
|
+
* with no way for the visitor to decline, or a permission asked for with no way
|
|
51
|
+
* to answer. Returned as data rather than logged here so the check is a test
|
|
52
|
+
* rather than a line someone might scroll past, exactly as `resolveAnalytics`
|
|
53
|
+
* does.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveTracking(
|
|
56
|
+
opts: GetvitopsTrackingOptions | undefined,
|
|
57
|
+
ctx: ResolveTrackingContext = {},
|
|
58
|
+
): ResolvedTracking {
|
|
59
|
+
const warnings: string[] = [];
|
|
60
|
+
const category: TrackingCategory = opts?.category ?? 'marketing';
|
|
61
|
+
const enabled = opts?.enabled === true;
|
|
62
|
+
|
|
63
|
+
if (!enabled) return { enabled: false, category, cookies: [], warnings };
|
|
64
|
+
|
|
65
|
+
if (!ctx.consent) {
|
|
66
|
+
warnings.push(
|
|
67
|
+
`tracking is on, so this site writes the \`${TRACKING_COOKIE}\` cookie — a 90-day ` +
|
|
68
|
+
'identifier tying a visitor to the ad that brought them — and `consent` is off, so it ' +
|
|
69
|
+
'is written for every visitor with no way to decline. Set `consent: true`.',
|
|
70
|
+
);
|
|
71
|
+
} else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) {
|
|
72
|
+
// The banner would be raised for a category it renders no row for: the
|
|
73
|
+
// visitor is asked a question the form does not show them.
|
|
74
|
+
warnings.push(
|
|
75
|
+
`tracking demands the \`${category}\` consent category, but the banner offers ` +
|
|
76
|
+
`${ctx.consentCategories.join(', ') || 'nothing'}. Add \`${category}\` to ` +
|
|
77
|
+
'`consent.categories`, or the visitor is asked about something the banner never names.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { enabled, category, cookies: [TRACKING_COOKIE], warnings };
|
|
82
|
+
}
|
package/virtual.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ declare module 'virtual:getvitops/head' {
|
|
|
47
47
|
consentCategories: string[];
|
|
48
48
|
consentPolicyUrl: string | null;
|
|
49
49
|
consentRuntime: boolean;
|
|
50
|
+
// Structurally `ResolvedTracking` (src/tracking.ts) — resolved in the
|
|
51
|
+
// integration rather than the component because its warnings belong beside
|
|
52
|
+
// the others, and the offered consent categories it checks against are known
|
|
53
|
+
// only there.
|
|
54
|
+
tracking: { enabled: boolean; category: string; cookies: string[] } | null;
|
|
55
|
+
// The site config's `site.ads` block plus this environment's switch —
|
|
56
|
+
// structurally `{ enabled, properties }` (src/ads.ts), re-typed by <Ads /> on
|
|
57
|
+
// the way into `resolveAds`. Read in the integration because that is where the
|
|
58
|
+
// site config is already parsed, and because a pixel is a `marketing` demand
|
|
59
|
+
// the banner must have a row for.
|
|
60
|
+
ads: { enabled: boolean; properties: Record<string, unknown> } | null;
|
|
50
61
|
}
|
|
51
62
|
const data: HeadData;
|
|
52
63
|
export default data;
|