@owlmeans/web-gtm 0.1.18-rc.25 → 0.1.18-rc.26
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/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-gtm/SKILL.md +54 -13
- package/build/index.d.ts +45 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +86 -13
- package/build/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +104 -15
- package/tests/head.spec.ts +36 -10
- package/tests/tag.spec.ts +90 -18
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
6
6
|
your project's skill store (`.agents/skills/`):
|
|
7
7
|
|
|
8
8
|
```sh
|
|
9
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
9
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.33
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/web-gtm",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.26",
|
|
5
|
+
"generatedAt": "2026-09-22T17:37:10.898Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/web-gtm
|
|
9
9
|
|
|
10
10
|
**Layer:** Web
|
|
11
|
-
**Install:** `"@owlmeans/web-gtm": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/web-gtm": "^0.1.18-rc.26"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
The tag half of the consent set. It emits **strings and data**, not components, and it holds no
|
|
14
14
|
state — the decision lives in `@owlmeans/consent`, which this package reads through
|
|
@@ -25,20 +25,51 @@ for the window that matters*, and nothing in the page reports it.
|
|
|
25
25
|
So every head snippet here emits the consent bootstrap first and the loader after it, as ONE
|
|
26
26
|
inline script. Everything else in this package exists to serve that.
|
|
27
27
|
|
|
28
|
+
## Loading mode: gated by default
|
|
29
|
+
|
|
30
|
+
`googleTagHeadScript`, `gtmHeadScript` and `loadGtm` all take an optional `mode:
|
|
31
|
+
'basic' | 'advanced'`, defaulting to `GOOGLE_TAG_DEFAULT_MODE` — currently `'basic'`, one named
|
|
32
|
+
constant so flipping the platform default later is a one-line change:
|
|
33
|
+
|
|
34
|
+
| `mode` | What runs, and when |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `'basic'` (default) | The bootstrap and the ads-redaction flags still run unconditionally — Consent Mode's own denied-by-default signals are declared either way. The LOADER itself — `gtm.js`, or `gtag/js` + `js` + `config` — is wrapped in `@owlmeans/consent`'s `consentGateScript`: it runs immediately if the visitor's stored record already `trackingGranted`, and otherwise waits for a later `CONSENT_EVENT` (from `applyConsent`, i.e. an in-page consent decision) that does. Nothing — not even Google's own IP receipt — reaches the tag before a signal-bearing category (`analytics` or `marketing` by default; a `required` or signal-less category never counts) is granted. |
|
|
37
|
+
| `'advanced'` | The original, only behavior before this mode existed: the loader runs immediately, Consent Mode signals denied by default, so the tag itself starts receiving traffic cookielessly from first paint. This is Google's own recommended default for its conversion modeling, and remains available for a site that has decided that tradeoff is acceptable. |
|
|
38
|
+
|
|
39
|
+
Read `'basic'`'s default as the EU/DE worst-case reading of ePrivacy Art. 5(3): no third party
|
|
40
|
+
receives a connection before consent.
|
|
41
|
+
|
|
42
|
+
`gtmNoscriptFrame` returns `''` in `'basic'` mode rather than an iframe — a browser with JavaScript
|
|
43
|
+
disabled cannot have granted anything, so an unauthenticated `<noscript>` frame would defeat the
|
|
44
|
+
whole point of gating. It keeps emitting the iframe in `'advanced'` mode, unchanged.
|
|
45
|
+
|
|
46
|
+
`loadGtm` gates the same way at the DOM level: it still calls `consentStore.init(opts)` first, but
|
|
47
|
+
only appends the container's `<script>` element once `trackingGranted(consentStore.get().record,
|
|
48
|
+
opts.categories)` is true — immediately if already granted, or via a one-shot `consentStore.subscribe`
|
|
49
|
+
that unsubscribes itself on the first update where it becomes true. The anti-double-load guard (the
|
|
50
|
+
element's own id) is unchanged.
|
|
51
|
+
|
|
52
|
+
None of this is new state: it is one more consumer of `@owlmeans/consent`'s existing surface —
|
|
53
|
+
`trackingGranted(record, categories)` (whether a stored/applied record grants a signal-bearing,
|
|
54
|
+
non-required category) and `consentGateScript(loaderExpr, opts)` (the inline script implementing the
|
|
55
|
+
"run now or wait for `CONSENT_EVENT`" branch). Both live in `@owlmeans/consent`, not here, because a
|
|
56
|
+
target project's own custom loader can reuse the same gate without depending on this package.
|
|
57
|
+
|
|
28
58
|
## Key Exports
|
|
29
59
|
|
|
30
60
|
| Export | Description |
|
|
31
61
|
|--------|-------------|
|
|
32
|
-
| `googleTagHeadScript(opts)` | The inline `<head>` script for any Google id: consent bootstrap, ads redaction, then the loader the prefix calls for. **The default for new code** |
|
|
62
|
+
| `googleTagHeadScript(opts)` | The inline `<head>` script for any Google id: consent bootstrap, ads redaction, then the loader the prefix calls for — gated behind `consentGateScript` in `opts.mode`'s default, `'basic'`. **The default for new code** |
|
|
33
63
|
| `isGoogleTagId(id)` / `googleTagKind(id)` | Whether an id is loadable, and whether it takes `gtm` (container) or `gtag` (gtag.js); `null` for anything else |
|
|
34
|
-
| `GoogleTagOptions` / `GoogleTagKind` | `ConsentOptions` plus `id` and
|
|
64
|
+
| `GoogleTagOptions` / `GoogleTagKind` | `ConsentOptions` plus `id`, optional `dataLayerName` and `mode`; `'gtm' \| 'gtag'` |
|
|
65
|
+
| `GoogleTagMode` / `GOOGLE_TAG_DEFAULT_MODE` | `'basic' \| 'advanced'`; the platform default, `'basic'` — see **Loading mode** below |
|
|
35
66
|
| `GOOGLE_TAG_CSP_SOURCES` | The hosts GA4, Tag Manager and Google Ads need in `script-src`, `connect-src` and `img-src` (7, frozen) |
|
|
36
67
|
| `GOOGLE_TAG_FRAME_SOURCES` | The hosts they frame, for `frame-src`: `https://www.googletagmanager.com`, `https://td.doubleclick.net` |
|
|
37
68
|
| `googleTagServices(id)` | The `ConsentService[]` a cookie policy discloses for that tag — pass to `CookiePolicy`'s `services` |
|
|
38
|
-
| `gtmHeadScript(opts)` | The container-only snippet: consent defaults, stored decision, then `gtm.js` |
|
|
39
|
-
| `gtmNoscriptFrame(opts)` | The hidden `<noscript>` iframe, for the top of `<body>` |
|
|
40
|
-
| `loadGtm(opts)` | Script-side container load, for a host that cannot emit into its own head |
|
|
41
|
-
| `GtmOptions` | `ConsentOptions` plus `id` (e.g. `GTM-XXXXXXX`)
|
|
69
|
+
| `gtmHeadScript(opts)` | The container-only snippet: consent defaults, stored decision, then `gtm.js` — gated the same way as `googleTagHeadScript` |
|
|
70
|
+
| `gtmNoscriptFrame(opts)` | The hidden `<noscript>` iframe, for the top of `<body>` — `''` in `'basic'` mode |
|
|
71
|
+
| `loadGtm(opts)` | Script-side container load, for a host that cannot emit into its own head — gated the same way |
|
|
72
|
+
| `GtmOptions` | `ConsentOptions` plus `id` (e.g. `GTM-XXXXXXX`), optional `dataLayerName` and `mode` |
|
|
42
73
|
|
|
43
74
|
## Ids
|
|
44
75
|
|
|
@@ -64,11 +95,14 @@ Lower case is rejected rather than folded; normalise in the form if you want to
|
|
|
64
95
|
owns it, so app code can send events), `js`, `config`, and the gtag.js `<script async>`
|
|
65
96
|
element created from the inline script — one inline script rather than Google's two elements,
|
|
66
97
|
so a build stamps one thing. The element carries id `owl-gtag-<id>`, so running the snippet
|
|
67
|
-
twice configures the tag once instead of double-counting page views.
|
|
98
|
+
twice configures the tag once instead of double-counting page views. In `mode`'s default,
|
|
99
|
+
`'basic'`, this whole step 3 is what `consentGateScript` withholds — steps 1 and 2 always run;
|
|
100
|
+
pass `mode: 'advanced'` to run step 3 unconditionally too, as before this mode existed.
|
|
68
101
|
|
|
69
|
-
An **invalid id yields the consent bootstrap alone
|
|
70
|
-
consent defaults. So a host may stamp `googleTagHeadScript({ id: configured
|
|
71
|
-
or stamp `consentBootstrapScript()` when no tag is configured; both are
|
|
102
|
+
An **invalid id yields the consent bootstrap alone, regardless of `mode`** — a mistyped tag must
|
|
103
|
+
never cost a page its consent defaults. So a host may stamp `googleTagHeadScript({ id: configured
|
|
104
|
+
})` unconditionally, or stamp `consentBootstrapScript()` when no tag is configured; both are
|
|
105
|
+
correct.
|
|
72
106
|
|
|
73
107
|
**The output is safe to place inline in HTML as-is.** JSON encoding keeps configured values inside
|
|
74
108
|
their string literals, but the HTML parser ends a script element at the first `</script` wherever
|
|
@@ -148,7 +182,13 @@ cookie page does so when a Google tag is set. The strings are English data, not
|
|
|
148
182
|
- **`loadGtm` is the fallback, not the default.** It is for a single-page app whose HTML is not
|
|
149
183
|
yours to edit. It still pushes the defaults first (through `consentStore.init`) and refuses to
|
|
150
184
|
load a second time — it marks its own `<script>` element with an id derived from the container —
|
|
151
|
-
but it runs after the bundle, which is exactly the window the head snippet closes.
|
|
185
|
+
but it runs after the bundle, which is exactly the window the head snippet closes. In `'basic'`
|
|
186
|
+
mode it additionally withholds the element itself until `trackingGranted`, same as the head
|
|
187
|
+
snippets.
|
|
188
|
+
- **The default flipped to `'basic'`.** Code written before this mode existed called
|
|
189
|
+
`googleTagHeadScript({ id })` / `gtmHeadScript({ id })` expecting the tag to load unconditionally.
|
|
190
|
+
It now gates by default; pass `mode: 'advanced'` explicitly to keep the old behavior for a site
|
|
191
|
+
that has made that call deliberately.
|
|
152
192
|
- **A second `consent/default` after a tag has loaded can WIDEN what was already narrowed**, so the
|
|
153
193
|
bootstrap is idempotent through a window flag. A page may therefore carry the call twice — once
|
|
154
194
|
inline, once from the bundle that mounts the dialog — without harm. Do not defeat the flag.
|
|
@@ -158,7 +198,8 @@ cookie page does so when a Google tag is set. The strings are English data, not
|
|
|
158
198
|
## Depends On
|
|
159
199
|
|
|
160
200
|
- `@owlmeans/consent` — `consentBootstrapScript`, `consentStore`, `ConsentOptions`,
|
|
161
|
-
`ConsentService`, the category keys and the Consent Mode signal mapping
|
|
201
|
+
`ConsentService`, the category keys and the Consent Mode signal mapping, plus (for the `'basic'`
|
|
202
|
+
gated mode) `consentGateScript` and `trackingGranted`
|
|
162
203
|
|
|
163
204
|
## Related
|
|
164
205
|
|
package/build/index.d.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import type { ConsentOptions, ConsentService } from '@owlmeans/consent';
|
|
2
|
+
/** `'gtm'` container / `'gtag'` — see {@link GtmOptions.mode} / {@link GoogleTagOptions.mode}. */
|
|
3
|
+
export type GoogleTagMode = 'basic' | 'advanced';
|
|
4
|
+
/**
|
|
5
|
+
* The platform default for {@link GtmOptions.mode} / {@link GoogleTagOptions.mode}.
|
|
6
|
+
*
|
|
7
|
+
* One named constant so flipping it later is a one-line change.
|
|
8
|
+
*
|
|
9
|
+
* - `'basic'` withholds the loader until a signal-bearing category is granted — which holds back
|
|
10
|
+
* Google's own IP receipt too, not only what a granted tag may then do with it.
|
|
11
|
+
* - `'advanced'` loads immediately with Consent Mode signals denied — today's only behavior, and
|
|
12
|
+
* Google's own recommended default for its own conversion modeling.
|
|
13
|
+
*
|
|
14
|
+
* Default is `'basic'`: read it as the EU/DE worst-case reading of ePrivacy Art. 5(3) — no third
|
|
15
|
+
* party receives a connection before consent.
|
|
16
|
+
*/
|
|
17
|
+
export declare const GOOGLE_TAG_DEFAULT_MODE: GoogleTagMode;
|
|
2
18
|
export interface GtmOptions extends ConsentOptions {
|
|
3
19
|
/** Container id, e.g. `GTM-XXXXXXX`. */
|
|
4
20
|
id: string;
|
|
5
21
|
/** The queue name, when a page runs more than one container. */
|
|
6
22
|
dataLayerName?: string;
|
|
23
|
+
/** See {@link GOOGLE_TAG_DEFAULT_MODE}. Defaults to `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`). */
|
|
24
|
+
mode?: GoogleTagMode;
|
|
7
25
|
}
|
|
8
26
|
/**
|
|
9
27
|
* The inline `<head>` snippet, consent first and the container second.
|
|
@@ -15,9 +33,20 @@ export interface GtmOptions extends ConsentOptions {
|
|
|
15
33
|
* the window that matters, and nothing in the page reports it.
|
|
16
34
|
*
|
|
17
35
|
* Emit this into the document head, above everything else, as an inline script.
|
|
36
|
+
*
|
|
37
|
+
* In `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`) the container is not requested at all until a
|
|
38
|
+
* signal-bearing category is granted — {@link consentGateScript} wraps it, reading any stored
|
|
39
|
+
* decision immediately and otherwise waiting for `@owlmeans/consent`'s `CONSENT_EVENT`. Pass
|
|
40
|
+
* `mode: 'advanced'` for the original, unconditional load.
|
|
18
41
|
*/
|
|
19
42
|
export declare const gtmHeadScript: (opts: GtmOptions) => string;
|
|
20
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The `<noscript>` iframe, for the body.
|
|
45
|
+
*
|
|
46
|
+
* In `'basic'` mode this is an empty string: the whole point of gating is that an unauthenticated
|
|
47
|
+
* `<noscript>` iframe would defeat it — a browser with JavaScript disabled cannot have granted
|
|
48
|
+
* anything, so there is nothing lawful left to render. `'advanced'` keeps the frame.
|
|
49
|
+
*/
|
|
21
50
|
export declare const gtmNoscriptFrame: (opts: GtmOptions) => string;
|
|
22
51
|
/**
|
|
23
52
|
* Load the container from script, for a host that cannot emit into its own head.
|
|
@@ -25,6 +54,11 @@ export declare const gtmNoscriptFrame: (opts: GtmOptions) => string;
|
|
|
25
54
|
* The head snippet is strictly better and should be preferred; this exists for a single-page app
|
|
26
55
|
* whose HTML is not ours to edit. It still pushes the defaults first, and it still refuses to load
|
|
27
56
|
* a second time.
|
|
57
|
+
*
|
|
58
|
+
* In `'basic'` mode the container element is not appended until `trackingGranted` is true: either
|
|
59
|
+
* the stored record already grants it, or — since this runs after the bundle mounted, past the
|
|
60
|
+
* window the head snippet exists to close — a subscription to `consentStore` catches the first
|
|
61
|
+
* update where it becomes true and unsubscribes. `'advanced'` appends it immediately, as before.
|
|
28
62
|
*/
|
|
29
63
|
export declare const loadGtm: (opts: GtmOptions) => void;
|
|
30
64
|
/**
|
|
@@ -46,6 +80,8 @@ export interface GoogleTagOptions extends ConsentOptions {
|
|
|
46
80
|
* given another queue never hears a consent command. Leave it unset on a consent-gated page.
|
|
47
81
|
*/
|
|
48
82
|
dataLayerName?: string;
|
|
83
|
+
/** See {@link GOOGLE_TAG_DEFAULT_MODE}. Defaults to `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`). */
|
|
84
|
+
mode?: GoogleTagMode;
|
|
49
85
|
}
|
|
50
86
|
/**
|
|
51
87
|
* The one inline `<head>` script for a Google tag of any kind: the consent bootstrap FIRST, then
|
|
@@ -60,6 +96,14 @@ export interface GoogleTagOptions extends ConsentOptions {
|
|
|
60
96
|
* An id that is not a loadable id ({@link isGoogleTagId}) yields the consent bootstrap alone — a
|
|
61
97
|
* page must never lose its consent defaults because someone mistyped a tag. The result is safe to
|
|
62
98
|
* place inline in HTML as-is: `</` and `<!--` inside any configured value are escaped.
|
|
99
|
+
*
|
|
100
|
+
* In `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`) the loader itself — the last piece — is wrapped in
|
|
101
|
+
* `consentGateScript`: it runs immediately for a returning visitor whose stored record already
|
|
102
|
+
* grants a signal-bearing category, and otherwise waits for `@owlmeans/consent`'s `CONSENT_EVENT`
|
|
103
|
+
* before ever requesting the tag. The bootstrap and the redaction flags are unaffected and still
|
|
104
|
+
* run unconditionally — Consent Mode's OWN denied-by-default signals are declared either way, so a
|
|
105
|
+
* tag that later does load still starts from `ad_storage`/`analytics_storage` denied. Pass
|
|
106
|
+
* `mode: 'advanced'` for the original, unconditional load.
|
|
63
107
|
*/
|
|
64
108
|
export declare const googleTagHeadScript: (opts: GoogleTagOptions) => string;
|
|
65
109
|
/**
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAEvE,kGAAkG;AAClG,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,UAAU,CAAA;AAEhD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,aAAuB,CAAA;AAE7D,MAAM,WAAW,UAAW,SAAQ,cAAc;IAChD,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,8FAA8F;IAC9F,IAAI,CAAC,EAAE,aAAa,CAAA;CACrB;AAYD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa,SAAU,UAAU,KAAG,MAShD,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,SAAU,UAAU,KAAG,MAOnD,CAAA;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,SAAU,UAAU,KAAG,IA0C1C,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,MAAM,CAAA;AAc1C,sFAAsF;AACtF,eAAO,MAAM,aAAa,OAAQ,MAAM,KAAG,OACO,CAAA;AAElD,4EAA4E;AAC5E,eAAO,MAAM,aAAa,OAAQ,MAAM,KAAG,aAAa,GAAG,IACS,CAAA;AAEpE,MAAM,WAAW,gBAAiB,SAAQ,cAAc;IACtD,4EAA4E;IAC5E,EAAE,EAAE,MAAM,CAAA;IACV;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,8FAA8F;IAC9F,IAAI,CAAC,EAAE,aAAa,CAAA;CACrB;AAyDD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,mBAAmB,SAAU,gBAAgB,KAAG,MAe5D,CAAA;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAQlD,CAAA;AAEF;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAGpD,CAAA;AAkBF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,OAAQ,MAAM,KAAG,cAAc,EA8C5D,CAAA"}
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import { CONSENT_ANALYTICS, CONSENT_MARKETING, consentBootstrapScript, consentStore, } from '@owlmeans/consent';
|
|
1
|
+
import { CONSENT_ANALYTICS, CONSENT_MARKETING, consentBootstrapScript, consentGateScript, consentStore, trackingGranted, } from '@owlmeans/consent';
|
|
2
|
+
/**
|
|
3
|
+
* The platform default for {@link GtmOptions.mode} / {@link GoogleTagOptions.mode}.
|
|
4
|
+
*
|
|
5
|
+
* One named constant so flipping it later is a one-line change.
|
|
6
|
+
*
|
|
7
|
+
* - `'basic'` withholds the loader until a signal-bearing category is granted — which holds back
|
|
8
|
+
* Google's own IP receipt too, not only what a granted tag may then do with it.
|
|
9
|
+
* - `'advanced'` loads immediately with Consent Mode signals denied — today's only behavior, and
|
|
10
|
+
* Google's own recommended default for its own conversion modeling.
|
|
11
|
+
*
|
|
12
|
+
* Default is `'basic'`: read it as the EU/DE worst-case reading of ePrivacy Art. 5(3) — no third
|
|
13
|
+
* party receives a connection before consent.
|
|
14
|
+
*/
|
|
15
|
+
export const GOOGLE_TAG_DEFAULT_MODE = 'basic';
|
|
2
16
|
/** Google's own container IIFE, the id and queue name injected as JSON data. */
|
|
3
17
|
const gtmContainerScript = (layer, id) => `(function(w,d,s,l,i){w[l]=w[l]||[];` +
|
|
4
18
|
`w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});` +
|
|
@@ -17,17 +31,45 @@ const gtmContainerScript = (layer, id) => `(function(w,d,s,l,i){w[l]=w[l]||[];`
|
|
|
17
31
|
* the window that matters, and nothing in the page reports it.
|
|
18
32
|
*
|
|
19
33
|
* Emit this into the document head, above everything else, as an inline script.
|
|
34
|
+
*
|
|
35
|
+
* In `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`) the container is not requested at all until a
|
|
36
|
+
* signal-bearing category is granted — {@link consentGateScript} wraps it, reading any stored
|
|
37
|
+
* decision immediately and otherwise waiting for `@owlmeans/consent`'s `CONSENT_EVENT`. Pass
|
|
38
|
+
* `mode: 'advanced'` for the original, unconditional load.
|
|
20
39
|
*/
|
|
21
|
-
export const gtmHeadScript = (opts) =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
export const gtmHeadScript = (opts) => {
|
|
41
|
+
const bootstrap = consentBootstrapScript(opts);
|
|
42
|
+
const loader = gtmContainerScript(opts.dataLayerName ?? 'dataLayer', opts.id);
|
|
43
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) === 'basic') {
|
|
44
|
+
return `${bootstrap};${consentGateScript(loader, opts)}`;
|
|
45
|
+
}
|
|
46
|
+
return `${bootstrap};${loader}`;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The `<noscript>` iframe, for the body.
|
|
50
|
+
*
|
|
51
|
+
* In `'basic'` mode this is an empty string: the whole point of gating is that an unauthenticated
|
|
52
|
+
* `<noscript>` iframe would defeat it — a browser with JavaScript disabled cannot have granted
|
|
53
|
+
* anything, so there is nothing lawful left to render. `'advanced'` keeps the frame.
|
|
54
|
+
*/
|
|
55
|
+
export const gtmNoscriptFrame = (opts) => {
|
|
56
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) === 'basic') {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
return `<iframe src="https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(opts.id)}"` +
|
|
60
|
+
` height="0" width="0" style="display:none;visibility:hidden"></iframe>`;
|
|
61
|
+
};
|
|
25
62
|
/**
|
|
26
63
|
* Load the container from script, for a host that cannot emit into its own head.
|
|
27
64
|
*
|
|
28
65
|
* The head snippet is strictly better and should be preferred; this exists for a single-page app
|
|
29
66
|
* whose HTML is not ours to edit. It still pushes the defaults first, and it still refuses to load
|
|
30
67
|
* a second time.
|
|
68
|
+
*
|
|
69
|
+
* In `'basic'` mode the container element is not appended until `trackingGranted` is true: either
|
|
70
|
+
* the stored record already grants it, or — since this runs after the bundle mounted, past the
|
|
71
|
+
* window the head snippet exists to close — a subscription to `consentStore` catches the first
|
|
72
|
+
* update where it becomes true and unsubscribes. `'advanced'` appends it immediately, as before.
|
|
31
73
|
*/
|
|
32
74
|
export const loadGtm = (opts) => {
|
|
33
75
|
if (typeof document === 'undefined') {
|
|
@@ -39,13 +81,32 @@ export const loadGtm = (opts) => {
|
|
|
39
81
|
}
|
|
40
82
|
// Defaults before the container, every time — the same rule the head snippet exists to keep.
|
|
41
83
|
consentStore.init(opts);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
84
|
+
const inject = () => {
|
|
85
|
+
if (document.getElementById(marker) != null) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const layer = opts.dataLayerName ?? 'dataLayer';
|
|
89
|
+
const script = document.createElement('script');
|
|
90
|
+
script.id = marker;
|
|
91
|
+
script.async = true;
|
|
92
|
+
script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(opts.id)}`
|
|
93
|
+
+ (layer !== 'dataLayer' ? `&l=${encodeURIComponent(layer)}` : '');
|
|
94
|
+
document.head.appendChild(script);
|
|
95
|
+
};
|
|
96
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) !== 'basic') {
|
|
97
|
+
inject();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (trackingGranted(consentStore.get().record, opts.categories)) {
|
|
101
|
+
inject();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const unsubscribe = consentStore.subscribe(state => {
|
|
105
|
+
if (trackingGranted(state.record, opts.categories)) {
|
|
106
|
+
unsubscribe();
|
|
107
|
+
inject();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
49
110
|
};
|
|
50
111
|
/**
|
|
51
112
|
* The id shapes Google issues today, and nothing else.
|
|
@@ -121,6 +182,14 @@ const gtagScript = (layer, id) => `(function(w,d,l,i){var m='owl-gtag-'+i;if(d.g
|
|
|
121
182
|
* An id that is not a loadable id ({@link isGoogleTagId}) yields the consent bootstrap alone — a
|
|
122
183
|
* page must never lose its consent defaults because someone mistyped a tag. The result is safe to
|
|
123
184
|
* place inline in HTML as-is: `</` and `<!--` inside any configured value are escaped.
|
|
185
|
+
*
|
|
186
|
+
* In `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`) the loader itself — the last piece — is wrapped in
|
|
187
|
+
* `consentGateScript`: it runs immediately for a returning visitor whose stored record already
|
|
188
|
+
* grants a signal-bearing category, and otherwise waits for `@owlmeans/consent`'s `CONSENT_EVENT`
|
|
189
|
+
* before ever requesting the tag. The bootstrap and the redaction flags are unaffected and still
|
|
190
|
+
* run unconditionally — Consent Mode's OWN denied-by-default signals are declared either way, so a
|
|
191
|
+
* tag that later does load still starts from `ad_storage`/`analytics_storage` denied. Pass
|
|
192
|
+
* `mode: 'advanced'` for the original, unconditional load.
|
|
124
193
|
*/
|
|
125
194
|
export const googleTagHeadScript = (opts) => {
|
|
126
195
|
const bootstrap = consentBootstrapScript(opts);
|
|
@@ -130,7 +199,11 @@ export const googleTagHeadScript = (opts) => {
|
|
|
130
199
|
}
|
|
131
200
|
const layer = layerOf(opts);
|
|
132
201
|
const loader = kind === 'gtm' ? gtmContainerScript(layer, opts.id) : gtagScript(layer, opts.id);
|
|
133
|
-
|
|
202
|
+
const redaction = redactionScript(layer);
|
|
203
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) === 'basic') {
|
|
204
|
+
return inlineSafe(`${bootstrap};${redaction};${consentGateScript(loader, opts)}`);
|
|
205
|
+
}
|
|
206
|
+
return inlineSafe(`${bootstrap};${redaction};${loader}`);
|
|
134
207
|
};
|
|
135
208
|
/**
|
|
136
209
|
* The hosts a page running a Google tag has to allow in `script-src`, `connect-src` and
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,YAAY,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/web-gtm",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.26",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@owlmeans/consent": "^0.1.18-rc.
|
|
25
|
+
"@owlmeans/consent": "^0.1.18-rc.6"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@owlmeans/dep-config": "workspace:*",
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
|
-
CONSENT_ANALYTICS, CONSENT_MARKETING, consentBootstrapScript, consentStore,
|
|
2
|
+
CONSENT_ANALYTICS, CONSENT_MARKETING, consentBootstrapScript, consentGateScript, consentStore,
|
|
3
|
+
trackingGranted,
|
|
3
4
|
} from '@owlmeans/consent'
|
|
4
5
|
import type { ConsentOptions, ConsentService } from '@owlmeans/consent'
|
|
5
6
|
|
|
7
|
+
/** `'gtm'` container / `'gtag'` — see {@link GtmOptions.mode} / {@link GoogleTagOptions.mode}. */
|
|
8
|
+
export type GoogleTagMode = 'basic' | 'advanced'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The platform default for {@link GtmOptions.mode} / {@link GoogleTagOptions.mode}.
|
|
12
|
+
*
|
|
13
|
+
* One named constant so flipping it later is a one-line change.
|
|
14
|
+
*
|
|
15
|
+
* - `'basic'` withholds the loader until a signal-bearing category is granted — which holds back
|
|
16
|
+
* Google's own IP receipt too, not only what a granted tag may then do with it.
|
|
17
|
+
* - `'advanced'` loads immediately with Consent Mode signals denied — today's only behavior, and
|
|
18
|
+
* Google's own recommended default for its own conversion modeling.
|
|
19
|
+
*
|
|
20
|
+
* Default is `'basic'`: read it as the EU/DE worst-case reading of ePrivacy Art. 5(3) — no third
|
|
21
|
+
* party receives a connection before consent.
|
|
22
|
+
*/
|
|
23
|
+
export const GOOGLE_TAG_DEFAULT_MODE: GoogleTagMode = 'basic'
|
|
24
|
+
|
|
6
25
|
export interface GtmOptions extends ConsentOptions {
|
|
7
26
|
/** Container id, e.g. `GTM-XXXXXXX`. */
|
|
8
27
|
id: string
|
|
9
28
|
/** The queue name, when a page runs more than one container. */
|
|
10
29
|
dataLayerName?: string
|
|
30
|
+
/** See {@link GOOGLE_TAG_DEFAULT_MODE}. Defaults to `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`). */
|
|
31
|
+
mode?: GoogleTagMode
|
|
11
32
|
}
|
|
12
33
|
|
|
13
34
|
/** Google's own container IIFE, the id and queue name injected as JSON data. */
|
|
@@ -30,14 +51,38 @@ const gtmContainerScript = (layer: string, id: string): string =>
|
|
|
30
51
|
* the window that matters, and nothing in the page reports it.
|
|
31
52
|
*
|
|
32
53
|
* Emit this into the document head, above everything else, as an inline script.
|
|
54
|
+
*
|
|
55
|
+
* In `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`) the container is not requested at all until a
|
|
56
|
+
* signal-bearing category is granted — {@link consentGateScript} wraps it, reading any stored
|
|
57
|
+
* decision immediately and otherwise waiting for `@owlmeans/consent`'s `CONSENT_EVENT`. Pass
|
|
58
|
+
* `mode: 'advanced'` for the original, unconditional load.
|
|
33
59
|
*/
|
|
34
|
-
export const gtmHeadScript = (opts: GtmOptions): string =>
|
|
35
|
-
|
|
60
|
+
export const gtmHeadScript = (opts: GtmOptions): string => {
|
|
61
|
+
const bootstrap = consentBootstrapScript(opts)
|
|
62
|
+
const loader = gtmContainerScript(opts.dataLayerName ?? 'dataLayer', opts.id)
|
|
36
63
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) === 'basic') {
|
|
65
|
+
return `${bootstrap};${consentGateScript(loader, opts)}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return `${bootstrap};${loader}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `<noscript>` iframe, for the body.
|
|
73
|
+
*
|
|
74
|
+
* In `'basic'` mode this is an empty string: the whole point of gating is that an unauthenticated
|
|
75
|
+
* `<noscript>` iframe would defeat it — a browser with JavaScript disabled cannot have granted
|
|
76
|
+
* anything, so there is nothing lawful left to render. `'advanced'` keeps the frame.
|
|
77
|
+
*/
|
|
78
|
+
export const gtmNoscriptFrame = (opts: GtmOptions): string => {
|
|
79
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) === 'basic') {
|
|
80
|
+
return ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return `<iframe src="https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(opts.id)}"` +
|
|
84
|
+
` height="0" width="0" style="display:none;visibility:hidden"></iframe>`
|
|
85
|
+
}
|
|
41
86
|
|
|
42
87
|
/**
|
|
43
88
|
* Load the container from script, for a host that cannot emit into its own head.
|
|
@@ -45,6 +90,11 @@ export const gtmNoscriptFrame = (opts: GtmOptions): string =>
|
|
|
45
90
|
* The head snippet is strictly better and should be preferred; this exists for a single-page app
|
|
46
91
|
* whose HTML is not ours to edit. It still pushes the defaults first, and it still refuses to load
|
|
47
92
|
* a second time.
|
|
93
|
+
*
|
|
94
|
+
* In `'basic'` mode the container element is not appended until `trackingGranted` is true: either
|
|
95
|
+
* the stored record already grants it, or — since this runs after the bundle mounted, past the
|
|
96
|
+
* window the head snippet exists to close — a subscription to `consentStore` catches the first
|
|
97
|
+
* update where it becomes true and unsubscribes. `'advanced'` appends it immediately, as before.
|
|
48
98
|
*/
|
|
49
99
|
export const loadGtm = (opts: GtmOptions): void => {
|
|
50
100
|
if (typeof document === 'undefined') {
|
|
@@ -57,13 +107,37 @@ export const loadGtm = (opts: GtmOptions): void => {
|
|
|
57
107
|
// Defaults before the container, every time — the same rule the head snippet exists to keep.
|
|
58
108
|
consentStore.init(opts)
|
|
59
109
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
110
|
+
const inject = (): void => {
|
|
111
|
+
if (document.getElementById(marker) != null) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const layer = opts.dataLayerName ?? 'dataLayer'
|
|
115
|
+
const script = document.createElement('script')
|
|
116
|
+
script.id = marker
|
|
117
|
+
script.async = true
|
|
118
|
+
script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(opts.id)}`
|
|
119
|
+
+ (layer !== 'dataLayer' ? `&l=${encodeURIComponent(layer)}` : '')
|
|
120
|
+
document.head.appendChild(script)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) !== 'basic') {
|
|
124
|
+
inject()
|
|
125
|
+
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (trackingGranted(consentStore.get().record, opts.categories)) {
|
|
130
|
+
inject()
|
|
131
|
+
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const unsubscribe = consentStore.subscribe(state => {
|
|
136
|
+
if (trackingGranted(state.record, opts.categories)) {
|
|
137
|
+
unsubscribe()
|
|
138
|
+
inject()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
67
141
|
}
|
|
68
142
|
|
|
69
143
|
/**
|
|
@@ -102,6 +176,8 @@ export interface GoogleTagOptions extends ConsentOptions {
|
|
|
102
176
|
* given another queue never hears a consent command. Leave it unset on a consent-gated page.
|
|
103
177
|
*/
|
|
104
178
|
dataLayerName?: string
|
|
179
|
+
/** See {@link GOOGLE_TAG_DEFAULT_MODE}. Defaults to `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`). */
|
|
180
|
+
mode?: GoogleTagMode
|
|
105
181
|
}
|
|
106
182
|
|
|
107
183
|
/** A queue name has to be a plain identifier: it becomes `window[name]` in the emitted script. */
|
|
@@ -172,6 +248,14 @@ const gtagScript = (layer: string, id: string): string =>
|
|
|
172
248
|
* An id that is not a loadable id ({@link isGoogleTagId}) yields the consent bootstrap alone — a
|
|
173
249
|
* page must never lose its consent defaults because someone mistyped a tag. The result is safe to
|
|
174
250
|
* place inline in HTML as-is: `</` and `<!--` inside any configured value are escaped.
|
|
251
|
+
*
|
|
252
|
+
* In `GOOGLE_TAG_DEFAULT_MODE` (`'basic'`) the loader itself — the last piece — is wrapped in
|
|
253
|
+
* `consentGateScript`: it runs immediately for a returning visitor whose stored record already
|
|
254
|
+
* grants a signal-bearing category, and otherwise waits for `@owlmeans/consent`'s `CONSENT_EVENT`
|
|
255
|
+
* before ever requesting the tag. The bootstrap and the redaction flags are unaffected and still
|
|
256
|
+
* run unconditionally — Consent Mode's OWN denied-by-default signals are declared either way, so a
|
|
257
|
+
* tag that later does load still starts from `ad_storage`/`analytics_storage` denied. Pass
|
|
258
|
+
* `mode: 'advanced'` for the original, unconditional load.
|
|
175
259
|
*/
|
|
176
260
|
export const googleTagHeadScript = (opts: GoogleTagOptions): string => {
|
|
177
261
|
const bootstrap = consentBootstrapScript(opts)
|
|
@@ -181,8 +265,13 @@ export const googleTagHeadScript = (opts: GoogleTagOptions): string => {
|
|
|
181
265
|
}
|
|
182
266
|
const layer = layerOf(opts)
|
|
183
267
|
const loader = kind === 'gtm' ? gtmContainerScript(layer, opts.id) : gtagScript(layer, opts.id)
|
|
268
|
+
const redaction = redactionScript(layer)
|
|
269
|
+
|
|
270
|
+
if ((opts.mode ?? GOOGLE_TAG_DEFAULT_MODE) === 'basic') {
|
|
271
|
+
return inlineSafe(`${bootstrap};${redaction};${consentGateScript(loader, opts)}`)
|
|
272
|
+
}
|
|
184
273
|
|
|
185
|
-
return inlineSafe(`${bootstrap};${
|
|
274
|
+
return inlineSafe(`${bootstrap};${redaction};${loader}`)
|
|
186
275
|
}
|
|
187
276
|
|
|
188
277
|
/**
|
package/tests/head.spec.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { CONSENT_KEY } from '@owlmeans/consent'
|
|
2
|
+
import { CONSENT_EVENT, CONSENT_KEY } from '@owlmeans/consent'
|
|
3
3
|
import { gtmHeadScript, gtmNoscriptFrame } from '../src/index.js'
|
|
4
4
|
|
|
5
5
|
const ID = 'GTM-TESTID1'
|
|
6
6
|
|
|
7
|
-
describe('@owlmeans/web-gtm — the head snippet', () => {
|
|
7
|
+
describe('@owlmeans/web-gtm — the head snippet ("advanced" mode — the original, unconditional load)', () => {
|
|
8
8
|
test('the consent defaults are declared BEFORE the container is requested', async () => {
|
|
9
9
|
// The entire reason this package exists. Consent Mode decides what a tag may do from the state
|
|
10
10
|
// present when the container loads, so a `consent/default` that arrives after `gtm.js` leaves
|
|
11
11
|
// the page unconfigured for the window that matters — and nothing reports it. Asserted on
|
|
12
12
|
// offsets rather than on presence, because both strings are always present and only the order
|
|
13
13
|
// has ever been wrong.
|
|
14
|
-
const script = gtmHeadScript({ id: ID })
|
|
14
|
+
const script = gtmHeadScript({ id: ID, mode: 'advanced' })
|
|
15
15
|
|
|
16
16
|
const defaults = script.indexOf("'consent','default'")
|
|
17
17
|
const container = script.indexOf('gtm.js')
|
|
@@ -22,7 +22,7 @@ describe('@owlmeans/web-gtm — the head snippet', () => {
|
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
test('it reads a stored decision, so a returning visitor is not treated as denied', async () => {
|
|
25
|
-
const script = gtmHeadScript({ id: ID })
|
|
25
|
+
const script = gtmHeadScript({ id: ID, mode: 'advanced' })
|
|
26
26
|
|
|
27
27
|
expect(script).toContain(JSON.stringify(CONSENT_KEY))
|
|
28
28
|
expect(script).toContain("'consent','update'")
|
|
@@ -36,7 +36,7 @@ describe('@owlmeans/web-gtm — the head snippet', () => {
|
|
|
36
36
|
// encoding is what keeps a stray quote from closing the literal and turning the rest of an id
|
|
37
37
|
// into executable script — the value still appears verbatim, and that is fine; what matters is
|
|
38
38
|
// that a quote inside it is ESCAPED rather than terminating the string.
|
|
39
|
-
const script = gtmHeadScript({ id: `${ID}"+alert(1)+"`, dataLayerName: 'owlLayer' })
|
|
39
|
+
const script = gtmHeadScript({ id: `${ID}"+alert(1)+"`, dataLayerName: 'owlLayer', mode: 'advanced' })
|
|
40
40
|
|
|
41
41
|
expect(script).toContain(JSON.stringify(`${ID}"+alert(1)+"`))
|
|
42
42
|
expect(script).toContain(JSON.stringify('owlLayer'))
|
|
@@ -46,20 +46,46 @@ describe('@owlmeans/web-gtm — the head snippet', () => {
|
|
|
46
46
|
})
|
|
47
47
|
|
|
48
48
|
test('a custom queue name is passed to the container', async () => {
|
|
49
|
-
expect(gtmHeadScript({ id: ID, dataLayerName: 'owlLayer' }))
|
|
50
|
-
|
|
49
|
+
expect(gtmHeadScript({ id: ID, dataLayerName: 'owlLayer', mode: 'advanced' }))
|
|
50
|
+
.toContain("l!='dataLayer'")
|
|
51
|
+
expect(gtmHeadScript({ id: ID, mode: 'advanced' })).toContain(JSON.stringify('dataLayer'))
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('@owlmeans/web-gtm — the head snippet ("basic" mode — the default)', () => {
|
|
56
|
+
test('gtmHeadScript gates the container behind consentGateScript by default', async () => {
|
|
57
|
+
const script = gtmHeadScript({ id: ID })
|
|
58
|
+
const advanced = gtmHeadScript({ id: ID, mode: 'advanced' })
|
|
59
|
+
|
|
60
|
+
// The container is still IN the script — it's the loader `consentGateScript` withholds, not a
|
|
61
|
+
// fact removed from the page — but it now sits behind the gate's own listener wiring.
|
|
62
|
+
expect(script).toContain('gtm.js')
|
|
63
|
+
expect(script).toContain(JSON.stringify(CONSENT_EVENT))
|
|
64
|
+
expect(script).toContain('addEventListener')
|
|
65
|
+
expect(script).not.toEqual(advanced)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('mode: "basic" is the same as omitting mode entirely', async () => {
|
|
69
|
+
expect(gtmHeadScript({ id: ID })).toBe(gtmHeadScript({ id: ID, mode: 'basic' }))
|
|
51
70
|
})
|
|
52
71
|
})
|
|
53
72
|
|
|
54
73
|
describe('@owlmeans/web-gtm — the noscript frame', () => {
|
|
55
|
-
test('
|
|
56
|
-
const frame = gtmNoscriptFrame({ id: ID })
|
|
74
|
+
test('"advanced" mode names the container and stays invisible', async () => {
|
|
75
|
+
const frame = gtmNoscriptFrame({ id: ID, mode: 'advanced' })
|
|
57
76
|
|
|
58
77
|
expect(frame).toContain(`id=${ID}`)
|
|
59
78
|
expect(frame).toContain('display:none')
|
|
60
79
|
})
|
|
61
80
|
|
|
62
81
|
test('an id is URL-encoded, so it cannot break out of the attribute', async () => {
|
|
63
|
-
expect(gtmNoscriptFrame({ id: 'a"b' })).not.toContain('a"b')
|
|
82
|
+
expect(gtmNoscriptFrame({ id: 'a"b', mode: 'advanced' })).not.toContain('a"b')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('"basic" mode (the default) renders nothing at all', async () => {
|
|
86
|
+
// The point of gating is defeated by an unauthenticated `<noscript>` iframe: a browser with
|
|
87
|
+
// JavaScript disabled cannot have granted anything, so there is nothing lawful to frame.
|
|
88
|
+
expect(gtmNoscriptFrame({ id: ID })).toBe('')
|
|
89
|
+
expect(gtmNoscriptFrame({ id: ID, mode: 'basic' })).toBe('')
|
|
64
90
|
})
|
|
65
91
|
})
|
package/tests/tag.spec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
-
CONSENT_KEY, DEFAULT_CONSENT_CATEGORIES, consentBootstrapScript,
|
|
3
|
+
CONSENT_EVENT, CONSENT_KEY, DEFAULT_CONSENT_CATEGORIES, consentBootstrapScript,
|
|
4
4
|
} from '@owlmeans/consent'
|
|
5
5
|
import type { ConsentCategory } from '@owlmeans/consent'
|
|
6
6
|
import {
|
|
@@ -35,8 +35,18 @@ const browser = (stored?: { key?: string, record: Record<string, unknown> }) =>
|
|
|
35
35
|
loaded.push(element)
|
|
36
36
|
if (element.id != null) byId.set(element.id, element)
|
|
37
37
|
}
|
|
38
|
-
const
|
|
38
|
+
const listeners = new Map<string, ((event: unknown) => void)[]>()
|
|
39
|
+
const win: Record<string, unknown> & {
|
|
40
|
+
addEventListener: (type: string, cb: (event: unknown) => void) => void
|
|
41
|
+
removeEventListener: (type: string, cb: (event: unknown) => void) => void
|
|
42
|
+
} = {
|
|
39
43
|
localStorage: { getItem: (key: string) => storage.get(key) ?? null },
|
|
44
|
+
addEventListener: (type, cb) => {
|
|
45
|
+
listeners.set(type, [...(listeners.get(type) ?? []), cb])
|
|
46
|
+
},
|
|
47
|
+
removeEventListener: (type, cb) => {
|
|
48
|
+
listeners.set(type, (listeners.get(type) ?? []).filter(listener => listener !== cb))
|
|
49
|
+
},
|
|
40
50
|
}
|
|
41
51
|
const doc = {
|
|
42
52
|
cookie: '',
|
|
@@ -53,6 +63,13 @@ const browser = (stored?: { key?: string, record: Record<string, unknown> }) =>
|
|
|
53
63
|
// eslint-disable-next-line no-new-func
|
|
54
64
|
new Function('window', 'document', script)(win, doc)
|
|
55
65
|
},
|
|
66
|
+
/** Fire a `CONSENT_EVENT` the way `applyConsent` does, for the gated ("basic" mode) loader. */
|
|
67
|
+
grant: (record: Record<string, unknown>) => {
|
|
68
|
+
for (const cb of listeners.get(CONSENT_EVENT) ?? []) {
|
|
69
|
+
cb({ detail: { record } })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
listenerCount: (type = CONSENT_EVENT) => listeners.get(type)?.length ?? 0,
|
|
56
73
|
/** The queue as `command:arg` strings; gtm.js's own object entry shows as `event:gtm.js`. */
|
|
57
74
|
queue: (name = 'dataLayer'): string[] =>
|
|
58
75
|
((win[name] as unknown[] | undefined) ?? []).map(entry => {
|
|
@@ -108,9 +125,9 @@ describe('isGoogleTagId / googleTagKind', () => {
|
|
|
108
125
|
})
|
|
109
126
|
})
|
|
110
127
|
|
|
111
|
-
describe('googleTagHeadScript — the order', () => {
|
|
128
|
+
describe('googleTagHeadScript — the order ("advanced" mode — the original, unconditional load)', () => {
|
|
112
129
|
test('gtag.js: consent default, then redaction, then js/config, then the library', () => {
|
|
113
|
-
const script = googleTagHeadScript({ id: 'G-ABCD1234' })
|
|
130
|
+
const script = googleTagHeadScript({ id: 'G-ABCD1234', mode: 'advanced' })
|
|
114
131
|
const page = browser()
|
|
115
132
|
page.run(script)
|
|
116
133
|
|
|
@@ -133,7 +150,7 @@ describe('googleTagHeadScript — the order', () => {
|
|
|
133
150
|
|
|
134
151
|
test('the redaction flags carry the right values', () => {
|
|
135
152
|
const page = browser()
|
|
136
|
-
page.run(googleTagHeadScript({ id: 'AW-123456789' }))
|
|
153
|
+
page.run(googleTagHeadScript({ id: 'AW-123456789', mode: 'advanced' }))
|
|
137
154
|
const sets = ((page.win.dataLayer as ArrayLike<unknown>[]) ?? [])
|
|
138
155
|
.map(entry => Array.from(entry))
|
|
139
156
|
.filter(args => args[0] === 'set')
|
|
@@ -142,7 +159,7 @@ describe('googleTagHeadScript — the order', () => {
|
|
|
142
159
|
})
|
|
143
160
|
|
|
144
161
|
test('Tag Manager: consent default, then redaction, then the container', () => {
|
|
145
|
-
const script = googleTagHeadScript({ id: 'GTM-ABC1234' })
|
|
162
|
+
const script = googleTagHeadScript({ id: 'GTM-ABC1234', mode: 'advanced' })
|
|
146
163
|
const page = browser()
|
|
147
164
|
page.run(script)
|
|
148
165
|
|
|
@@ -172,7 +189,7 @@ describe('googleTagHeadScript — the order', () => {
|
|
|
172
189
|
test('running it twice configures the tag once', () => {
|
|
173
190
|
// Two stampings or a hot reload must not double-count every page view.
|
|
174
191
|
const page = browser()
|
|
175
|
-
const script = googleTagHeadScript({ id: 'G-ABCD1234' })
|
|
192
|
+
const script = googleTagHeadScript({ id: 'G-ABCD1234', mode: 'advanced' })
|
|
176
193
|
page.run(script)
|
|
177
194
|
page.run(script)
|
|
178
195
|
|
|
@@ -183,19 +200,19 @@ describe('googleTagHeadScript — the order', () => {
|
|
|
183
200
|
|
|
184
201
|
test('window.gtag is published for application events, and never replaced', () => {
|
|
185
202
|
const page = browser()
|
|
186
|
-
page.run(googleTagHeadScript({ id: 'G-ABCD1234' }))
|
|
203
|
+
page.run(googleTagHeadScript({ id: 'G-ABCD1234', mode: 'advanced' }))
|
|
187
204
|
expect(typeof page.win.gtag).toBe('function')
|
|
188
205
|
|
|
189
206
|
const owned = () => undefined
|
|
190
207
|
const other = browser()
|
|
191
208
|
other.win.gtag = owned
|
|
192
|
-
other.run(googleTagHeadScript({ id: 'G-ABCD1234' }))
|
|
209
|
+
other.run(googleTagHeadScript({ id: 'G-ABCD1234', mode: 'advanced' }))
|
|
193
210
|
expect(other.win.gtag).toBe(owned)
|
|
194
211
|
})
|
|
195
212
|
|
|
196
213
|
test('a custom queue is used by the tag and passed to the library', () => {
|
|
197
214
|
const page = browser()
|
|
198
|
-
page.run(googleTagHeadScript({ id: 'G-ABCD1234', dataLayerName: 'owlLayer' }))
|
|
215
|
+
page.run(googleTagHeadScript({ id: 'G-ABCD1234', dataLayerName: 'owlLayer', mode: 'advanced' }))
|
|
199
216
|
|
|
200
217
|
expect(page.queue('owlLayer')).toContain('config:G-ABCD1234')
|
|
201
218
|
expect(page.loaded[0].src).toBe('https://www.googletagmanager.com/gtag/js?id=G-ABCD1234&l=owlLayer')
|
|
@@ -203,23 +220,78 @@ describe('googleTagHeadScript — the order', () => {
|
|
|
203
220
|
|
|
204
221
|
test('a queue name that is not an identifier falls back to dataLayer', () => {
|
|
205
222
|
const page = browser()
|
|
206
|
-
page.run(googleTagHeadScript({ id: 'G-ABCD1234', dataLayerName: 'a-b' }))
|
|
223
|
+
page.run(googleTagHeadScript({ id: 'G-ABCD1234', dataLayerName: 'a-b', mode: 'advanced' }))
|
|
207
224
|
|
|
208
225
|
expect(page.queue()).toContain('config:G-ABCD1234')
|
|
209
226
|
expect(page.loaded[0].src).toBe('https://www.googletagmanager.com/gtag/js?id=G-ABCD1234')
|
|
210
227
|
})
|
|
211
228
|
})
|
|
212
229
|
|
|
213
|
-
describe('googleTagHeadScript —
|
|
214
|
-
test('yields the
|
|
215
|
-
// A mistyped tag must never cost the page its consent defaults
|
|
230
|
+
describe('googleTagHeadScript — "basic" mode (the default)', () => {
|
|
231
|
+
test('an invalid id still yields only the bootstrap, regardless of mode', () => {
|
|
232
|
+
// A mistyped tag must never cost the page its consent defaults — in either mode, since an id
|
|
233
|
+
// this invalid never reaches the gate at all.
|
|
216
234
|
for (const id of ['', 'UA-1234-1', 'G-x"+alert(1)']) {
|
|
217
|
-
const
|
|
235
|
+
for (const mode of ['basic', 'advanced'] as const) {
|
|
236
|
+
const script = googleTagHeadScript({ id, mode })
|
|
218
237
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
238
|
+
expect(script).toBe(consentBootstrapScript({ id } as never))
|
|
239
|
+
expect(script).not.toContain('googletagmanager')
|
|
240
|
+
expect(script).not.toContain('ads_data_redaction')
|
|
241
|
+
}
|
|
222
242
|
}
|
|
243
|
+
// And omitting `mode` altogether is the same as the default, `'basic'`.
|
|
244
|
+
expect(googleTagHeadScript({ id: '' })).toBe(googleTagHeadScript({ id: '', mode: 'basic' }))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('a valid id with no stored consent does not load the tag synchronously', () => {
|
|
248
|
+
const page = browser()
|
|
249
|
+
page.run(googleTagHeadScript({ id: 'G-ABCD1234' }))
|
|
250
|
+
|
|
251
|
+
// The consent bootstrap and the redaction flags still run unconditionally — Consent Mode's
|
|
252
|
+
// OWN denied-by-default signals are declared either way — but nothing asked for the tag itself.
|
|
253
|
+
expect(page.queue()).toEqual(['consent:default', 'set:ads_data_redaction', 'set:url_passthrough'])
|
|
254
|
+
expect(page.loaded).toHaveLength(0)
|
|
255
|
+
expect(page.listenerCount()).toBe(1)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('it loads once the visitor grants a signal-bearing category, and not before', () => {
|
|
259
|
+
const page = browser()
|
|
260
|
+
page.run(googleTagHeadScript({ id: 'G-ABCD1234' }))
|
|
261
|
+
|
|
262
|
+
// Denying, or granting only a category with no Consent Mode signal, changes nothing.
|
|
263
|
+
page.grant({ essential: true, analytics: false, marketing: false })
|
|
264
|
+
expect(page.loaded).toHaveLength(0)
|
|
265
|
+
|
|
266
|
+
page.grant({ essential: true, analytics: true, marketing: false })
|
|
267
|
+
expect(page.loaded).toHaveLength(1)
|
|
268
|
+
expect(page.queue().filter(entry => entry.startsWith('config:'))).toEqual(['config:G-ABCD1234'])
|
|
269
|
+
expect(page.listenerCount()).toBe(0)
|
|
270
|
+
|
|
271
|
+
// A later grant does nothing further — the listener already removed itself, and the loader's
|
|
272
|
+
// own anti-double-load guard would refuse a second run in any case.
|
|
273
|
+
page.grant({ essential: true, analytics: true, marketing: true })
|
|
274
|
+
expect(page.loaded).toHaveLength(1)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('a returning visitor with a stored grant loads immediately, with no listener left behind', () => {
|
|
278
|
+
const page = browser({ record: { essential: true, analytics: true, marketing: false, v: 2 } })
|
|
279
|
+
page.run(googleTagHeadScript({ id: 'G-ABCD1234' }))
|
|
280
|
+
|
|
281
|
+
expect(page.loaded).toHaveLength(1)
|
|
282
|
+
expect(page.queue().filter(entry => entry.startsWith('config:'))).toEqual(['config:G-ABCD1234'])
|
|
283
|
+
expect(page.listenerCount()).toBe(0)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('the Tag Manager container is gated the same way', () => {
|
|
287
|
+
const page = browser()
|
|
288
|
+
page.run(googleTagHeadScript({ id: 'GTM-ABC1234' }))
|
|
289
|
+
|
|
290
|
+
expect(page.loaded).toHaveLength(0)
|
|
291
|
+
|
|
292
|
+
page.grant({ essential: true, marketing: true })
|
|
293
|
+
expect(page.loaded).toHaveLength(1)
|
|
294
|
+
expect(page.loaded[0].src).toBe('https://www.googletagmanager.com/gtm.js?id=GTM-ABC1234')
|
|
223
295
|
})
|
|
224
296
|
})
|
|
225
297
|
|