@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 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.32
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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-gtm",
4
- "version": "0.1.18-rc.25",
5
- "generatedAt": "2026-09-22T12:36:21.790Z",
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.25"` in `dependencies`
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 optional `dataLayerName`; `'gtm' \| 'gtag'` |
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`) and optional `dataLayerName` |
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** — a mistyped tag must never cost a page its
70
- consent defaults. So a host may stamp `googleTagHeadScript({ id: configured })` unconditionally,
71
- or stamp `consentBootstrapScript()` when no tag is configured; both are correct.
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
- /** The `<noscript>` iframe, for the body. */
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
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAEvE,MAAM,WAAW,UAAW,SAAQ,cAAc;IAChD,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAA;IACV,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAYD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,SAAU,UAAU,KAAG,MACoD,CAAA;AAErG,6CAA6C;AAC7C,eAAO,MAAM,gBAAgB,SAAU,UAAU,KAAG,MAEsB,CAAA;AAE1E;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,SAAU,UAAU,KAAG,IAkB1C,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;CACvB;AAyDD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,SAAU,gBAAgB,KAAG,MAU5D,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"}
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) => `${consentBootstrapScript(opts)};${gtmContainerScript(opts.dataLayerName ?? 'dataLayer', opts.id)}`;
22
- /** The `<noscript>` iframe, for the body. */
23
- export const gtmNoscriptFrame = (opts) => `<iframe src="https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(opts.id)}"` +
24
- ` height="0" width="0" style="display:none;visibility:hidden"></iframe>`;
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 layer = opts.dataLayerName ?? 'dataLayer';
43
- const script = document.createElement('script');
44
- script.id = marker;
45
- script.async = true;
46
- script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(opts.id)}`
47
- + (layer !== 'dataLayer' ? `&l=${encodeURIComponent(layer)}` : '');
48
- document.head.appendChild(script);
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
- return inlineSafe(`${bootstrap};${redactionScript(layer)};${loader}`);
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
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.25",
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.5"
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
- `${consentBootstrapScript(opts)};${gtmContainerScript(opts.dataLayerName ?? 'dataLayer', opts.id)}`
60
+ export const gtmHeadScript = (opts: GtmOptions): string => {
61
+ const bootstrap = consentBootstrapScript(opts)
62
+ const loader = gtmContainerScript(opts.dataLayerName ?? 'dataLayer', opts.id)
36
63
 
37
- /** The `<noscript>` iframe, for the body. */
38
- export const gtmNoscriptFrame = (opts: GtmOptions): string =>
39
- `<iframe src="https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(opts.id)}"` +
40
- ` height="0" width="0" style="display:none;visibility:hidden"></iframe>`
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 layer = opts.dataLayerName ?? 'dataLayer'
61
- const script = document.createElement('script')
62
- script.id = marker
63
- script.async = true
64
- script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(opts.id)}`
65
- + (layer !== 'dataLayer' ? `&l=${encodeURIComponent(layer)}` : '')
66
- document.head.appendChild(script)
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};${redactionScript(layer)};${loader}`)
274
+ return inlineSafe(`${bootstrap};${redaction};${loader}`)
186
275
  }
187
276
 
188
277
  /**
@@ -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' })).toContain("l!='dataLayer'")
50
- expect(gtmHeadScript({ id: ID })).toContain(JSON.stringify('dataLayer'))
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('it names the container and stays invisible', async () => {
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 win: Record<string, unknown> = {
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 — an invalid id', () => {
214
- test('yields the consent bootstrap alone', () => {
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 script = googleTagHeadScript({ id })
235
+ for (const mode of ['basic', 'advanced'] as const) {
236
+ const script = googleTagHeadScript({ id, mode })
218
237
 
219
- expect(script).toBe(consentBootstrapScript({ id } as never))
220
- expect(script).not.toContain('googletagmanager')
221
- expect(script).not.toContain('ads_data_redaction')
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