@owlmeans/web-gtm 0.1.18-rc.9 → 0.1.18

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
@@ -1,3 +1,189 @@
1
+ # @owlmeans/web-gtm
2
+
3
+ The Google tag half of OwlMeans cookie consent. It emits the inline `<head>` script for any Google
4
+ id — Tag Manager (`GTM-`), GA4 (`G-`), Google tag (`GT-`), Google Ads (`AW-`), Floodlight (`DC-`) —
5
+ with the Consent Mode defaults declared **before** the loader runs, plus the id validator, the CSP
6
+ host lists, the cookie-policy disclosure for a tag, the `<noscript>` frame and a script-side loader
7
+ for hosts that cannot edit their HTML. It produces strings and data, holds no state, and reads the
8
+ consent decision from `@owlmeans/consent`. A web app or static site uses it whenever it runs a Google
9
+ tag. It does not render the consent dialog (`@owlmeans/web-consent`) and has no event API — events
10
+ reach `dataLayer` through `@owlmeans/log` with `@owlmeans/web-log`'s `gtmAnalyticsPlugin`. The
11
+ `web-gtm` skill covers this package; the `consent` skill covers the model and ordering rule it
12
+ implements.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bun add @owlmeans/web-gtm@^0.1.18
18
+ ```
19
+
20
+ ## Concepts
21
+
22
+ - **Order** — a Google tag decides what it may do from the consent state present when it loads. A
23
+ React bundle always arrives too late, so the head script emits the consent bootstrap first and the
24
+ loader after it, as one inline script stamped from HTML.
25
+ - **Mode** — `'basic'` (the default, `GOOGLE_TAG_DEFAULT_MODE`) withholds the loader until a
26
+ signal-bearing, non-required category is granted, from storage or a later `CONSENT_EVENT`.
27
+ `'advanced'` loads immediately with Consent Mode signals denied.
28
+ - **Bootstrap** — `consentModeHelper.consentBootstrapScript(opts)` from `@owlmeans/consent`: declares
29
+ `consent/default` and applies a stored decision in the same turn. It runs in both modes.
30
+ - **Kind** — `'gtm'` (container, `gtm.js`) for `GTM-`; `'gtag'` (`gtag/js` + `js` + `config`) for
31
+ `G-`, `GT-`, `AW-`, `DC-`.
32
+ - **Disclosure** — `ConsentService` entries describing who receives data, derived from the id prefix
33
+ for `CookiePolicy`'s `services`.
34
+
35
+ ## Usage
36
+
37
+ ### 1. Stamp the head script from HTML
38
+
39
+ Stamp it first in `<head>`, from the HTML build — never from the application bundle. A Vite app does
40
+ it with a `transformIndexHtml` plugin:
41
+
42
+ ```typescript
43
+ import type { Plugin } from 'vite'
44
+ import { googleTagHelper } from '@owlmeans/web-gtm'
45
+
46
+ export const googleTagPlugin = (id: string): Plugin => ({
47
+ name: 'google-tag',
48
+ transformIndexHtml: html => html.replace(
49
+ '<!-- google-tag -->',
50
+ `<script>${googleTagHelper.googleTagHeadScript({ id })}</script>`
51
+ ),
52
+ })
53
+ ```
54
+
55
+ An invalid or empty id yields the consent bootstrap alone, so the call is safe to make
56
+ unconditionally. The output escapes `</` and `<!--`, so it can be placed inline as-is.
57
+
58
+ ### 2. Keep the consent options in parity
59
+
60
+ `GoogleTagOptions` extends `ConsentOptions`. Pass the same `categories`, `storageKey` and `linker`
61
+ here as to `CookieConsent`, or the snippet and the dialog disagree about what was asked.
62
+
63
+ ```typescript
64
+ import { googleTagHelper } from '@owlmeans/web-gtm'
65
+
66
+ const head = googleTagHelper.googleTagHeadScript({
67
+ id: 'GTM-ABC1234',
68
+ categories: appCategories,
69
+ linker: { domains: ['owlmeans.com', 'owlmeans.pl'] },
70
+ })
71
+
72
+ // a site that has decided to load before consent, with signals denied
73
+ const eager = googleTagHelper.googleTagHeadScript({ id: 'G-ABCD1234', mode: 'advanced' })
74
+ ```
75
+
76
+ ### 3. Validate the id where it is stored
77
+
78
+ ```typescript
79
+ import { googleTagHelper } from '@owlmeans/web-gtm'
80
+
81
+ if (!googleTagHelper.isGoogleTagId(form.googleTag)) {
82
+ throw new Error('invalid-google-tag')
83
+ }
84
+ googleTagHelper.googleTagKind('GTM-ABC1234') // 'gtm'
85
+ googleTagHelper.googleTagKind('AW-123456789') // 'gtag'
86
+ googleTagHelper.googleTagKind('UA-1234-1') // null — Universal Analytics is retired
87
+ ```
88
+
89
+ ### 4. Allow the tag in the CSP
90
+
91
+ ```typescript
92
+ import { GOOGLE_TAG_CSP_SOURCES, GOOGLE_TAG_FRAME_SOURCES } from '@owlmeans/web-gtm'
93
+
94
+ const csp = [
95
+ `script-src 'self' ${scriptHash} ${GOOGLE_TAG_CSP_SOURCES.join(' ')}`,
96
+ `connect-src 'self' ${GOOGLE_TAG_CSP_SOURCES.join(' ')}`,
97
+ `img-src 'self' data: ${GOOGLE_TAG_CSP_SOURCES.join(' ')}`,
98
+ `frame-src ${GOOGLE_TAG_FRAME_SOURCES.join(' ')}`,
99
+ ].join('; ')
100
+ ```
101
+
102
+ ### 5. Disclose the tag on the cookie policy
103
+
104
+ ```tsx
105
+ import { CookiePolicy } from '@owlmeans/web-consent'
106
+ import { googleTagHelper } from '@owlmeans/web-gtm'
107
+
108
+ <CookiePolicy services={googleTagHelper.googleTagServices(googleTag)} privacyHref="/legal/privacy" />
109
+ ```
110
+
111
+ ### 6. Load from script when the HTML is not yours
112
+
113
+ For a single-page app that cannot edit its own head. It calls `consentStore.init(opts)` first,
114
+ refuses to load twice, gates the same way in `'basic'` mode, and does nothing without a `document`.
115
+
116
+ ```typescript
117
+ import { googleTagHelper } from '@owlmeans/web-gtm'
118
+
119
+ googleTagHelper.loadGtm({ id: 'GTM-ABC1234', categories: appCategories })
120
+ ```
121
+
122
+ ## API
123
+
124
+ ### `googleTagHelper` (`GoogleTagHelper`, built by `createGoogleTagHelper()`)
125
+
126
+ | Member | Returns | Purpose |
127
+ |---|---|---|
128
+ | `googleTagHeadScript(opts: GoogleTagOptions)` | `string` | Bootstrap, then `ads_data_redaction` on and `url_passthrough` off, then the loader for the id's kind (gated in `'basic'`); HTML-inline-safe. **The default for new code** |
129
+ | `gtmHeadScript(opts: GtmOptions)` | `string` | Older container-only form: bootstrap then `gtm.js`; gated the same way; does not escape `</` |
130
+ | `gtmNoscriptFrame(opts: GtmOptions)` | `string` | Hidden `<noscript>` iframe for `<body>`; `''` in `'basic'` mode |
131
+ | `loadGtm(opts: GtmOptions)` | `void` | Script-side container load (fallback) |
132
+ | `isGoogleTagId(id)` | `boolean` | Strict id check: `GTM-` + 4–12, or `G-`/`GT-`/`AW-`/`DC-` + 4–16 upper-case letters and digits |
133
+ | `googleTagKind(id)` | `'gtm' \| 'gtag' \| null` | Which loader an id takes |
134
+ | `googleTagServices(id)` | `ConsentService[]` | Cookie-policy disclosure read off the prefix |
135
+
136
+ `googleTagServices` returns: `G-` → Google Analytics (analytics; `_ga`, `_ga_<id>`); `AW-` → Google
137
+ Ads (marketing; `_gcl_au`, `_gcl_aw`); `DC-` → Floodlight (marketing; `_gcl_au`, `_gcl_dc`); `GTM-`
138
+ and `GT-` → one analytics and one advertising entry; an invalid id → `[]`.
139
+
140
+ ### Constants
141
+
142
+ | Symbol | Value |
143
+ |---|---|
144
+ | `GOOGLE_TAG_DEFAULT_MODE` | `'basic'` |
145
+ | `GOOGLE_TAG_CSP_SOURCES` | frozen list for `script-src`, `connect-src`, `img-src`: `*.googletagmanager.com`, `*.google-analytics.com`, `*.g.doubleclick.net`, `ad.doubleclick.net`, `www.googleadservices.com`, `pagead2.googlesyndication.com`, `*.google.com` (all `https://`) |
146
+ | `GOOGLE_TAG_FRAME_SOURCES` | frozen list for `frame-src`: `https://www.googletagmanager.com`, `https://td.doubleclick.net` |
147
+
148
+ ### Types
149
+
150
+ | Symbol | Shape |
151
+ |---|---|
152
+ | `GoogleTagOptions` | `ConsentOptions` + `{ id, dataLayerName?, mode? }` |
153
+ | `GtmOptions` | `ConsentOptions` + `{ id, dataLayerName?, mode? }` (container id) |
154
+ | `GoogleTagMode` | `'basic' \| 'advanced'` |
155
+ | `GoogleTagKind` | `'gtm' \| 'gtag'` |
156
+ | `GoogleTagHelper` | the helper interface above |
157
+
158
+ ### Deprecated wrappers
159
+
160
+ `gtmHeadScript`, `gtmNoscriptFrame`, `isGoogleTagId`, `googleTagHeadScript`, `googleTagServices` —
161
+ top-level functions delegating to `googleTagHelper`. Use the helper members.
162
+
163
+ ## Common pitfalls
164
+
165
+ - Loading the tag from the bundle (or with `loadGtm` where the head is editable) lets it run before
166
+ the consent defaults exist; stamp the head script from HTML.
167
+ - `dataLayerName`: Consent Mode always speaks on `window.dataLayer`, so a tag on another queue never
168
+ hears a consent command. Leave it unset on a consent-gated page.
169
+ - Code written before `'basic'` became the default and expects the tag to load unconditionally must
170
+ pass `mode: 'advanced'` explicitly.
171
+ - In `'basic'` mode a page counts as "tagged" in Tag Manager only after a consenting visitor loaded
172
+ it; a URL that redirects before the loader runs stays "not tagged".
173
+ - `isGoogleTagId` rejects lower case and `UA-` ids — normalise in the form, and validate where the
174
+ id is stored, not only where it is emitted.
175
+ - Under a hash-based CSP, Tag Manager Custom HTML tags and Custom JavaScript variables cannot run;
176
+ build containers from Google's built-in tag templates.
177
+ - Never build the snippet or the noscript frame by hand — the helpers JSON- or URL-encode the id and
178
+ queue name.
179
+
180
+ ## Related packages
181
+
182
+ - [`@owlmeans/consent`](../consent) — the bootstrap, `consentGateScript`, `trackingGranted` and `consentStore`
183
+ - [`@owlmeans/web-consent`](../web-consent) — the dialog and `CookiePolicy` that consume `googleTagServices`
184
+ - [`@owlmeans/web-log`](../web-log) — consent-gated analytics events onto `dataLayer`
185
+ - [`@owlmeans/astro`](../astro) — composes these head strings for an Astro layout
186
+
1
187
  <!-- owlmeans:agent-guidance:start -->
2
188
  ## Agent guidance
3
189
 
@@ -6,7 +192,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
6
192
  your project's skill store (`.agents/skills/`):
7
193
 
8
194
  ```sh
9
- npx @owlmeans/agent-skills@^0.1.18-rc.15
195
+ npx @owlmeans/agent-skills@^0.1.18
10
196
  ```
11
197
 
12
198
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/web-gtm",
4
- "version": "0.1.18-rc.9",
5
- "generatedAt": "2026-09-12T08:39:52.716Z",
4
+ "version": "0.1.18",
5
+ "generatedAt": "2026-10-28T00:31:34.712Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: web-gtm
3
- description: How to use @owlmeans/web-gtm — the Google Tag Manager head snippet that declares Consent Mode defaults before the container loads, the noscript frame, and the script-side loader for a host that cannot edit its own HTML. Auto-invoked when adding a tag manager to a page, importing gtmHeadScript, gtmNoscriptFrame or loadGtm, or debugging why a tag ignores a stored consent decision.
3
+ description: How to use @owlmeans/web-gtm — the Google tag head snippet for every Google id (Tag Manager GTM-, GA4 G-, Google tag GT-, Ads AW-, Floodlight DC-) that declares Consent Mode defaults before any loader runs, the id validator, the CSP host lists, the cookie-policy disclosure for a tag, the noscript frame, and the script-side loader for a host that cannot edit its own HTML. Auto-invoked when adding a Google tag or tag manager to a page, importing googleTagHelper (googleTagHeadScript, isGoogleTagId, googleTagServices, gtmHeadScript, gtmNoscriptFrame, loadGtm) or GOOGLE_TAG_CSP_SOURCES, or debugging why a tag ignores a stored consent decision or is blocked by CSP.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,89 +8,230 @@ user-invocable: false
8
8
  # @owlmeans/web-gtm
9
9
 
10
10
  **Layer:** Web
11
- **Install:** `"@owlmeans/web-gtm": "^0.1.18-rc.9"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-gtm": "^0.1.18"` in `dependencies`
12
12
 
13
- The tag-manager half of the consent set. It emits **strings**, not components, and it holds no
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
15
- `consentBootstrapScript` and `consentStore`.
15
+ `consentModeHelper.consentBootstrapScript` and `consentStore`.
16
16
 
17
17
  ## The one idea: order
18
18
 
19
- Google Tag Manager decides what a tag may do from the consent state present **when the container
20
- loads**. A React bundle cannot get there first: by the time an island mounts, `gtm.js` has been
21
- running for hundreds of milliseconds and has already decided. A site whose defaults arrive after
22
- the container is not configured differently — it is *unconfigured for the window that matters*, and
23
- nothing in the page reports it.
19
+ A Google tag — the Tag Manager container or gtag.js alike — decides what it may do from the
20
+ consent state present **when it loads**. A React bundle cannot get there first: by the time an
21
+ island mounts, the tag has been running for hundreds of milliseconds and has already decided. A
22
+ site whose defaults arrive after the loader is not configured differently — it is *unconfigured
23
+ for the window that matters*, and nothing in the page reports it.
24
24
 
25
- So `gtmHeadScript` emits the consent bootstrap and then the container, in that order, as one
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
+ `googleTagHelper.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 `consentModeHelper.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
+ **What `'basic'` does to a tag manager's coverage report.** A page is "Tagged" only after a consenting visitor
43
+ has loaded it; `'advanced'` is what makes every first view count. The report also records URLs it only saw as a
44
+ referrer, so a page the visitor was redirected AWAY from before the loader ran stays "Not tagged" — keep any
45
+ redirect off URLs that already name their locale.
46
+
47
+ `googleTagHelper.gtmNoscriptFrame` returns `''` in `'basic'` mode rather than an iframe — a browser with JavaScript
48
+ disabled cannot have granted anything, so an unauthenticated `<noscript>` frame would defeat the
49
+ whole point of gating. It keeps emitting the iframe in `'advanced'` mode, unchanged.
50
+
51
+ `googleTagHelper.loadGtm` gates the same way at the DOM level: it still calls `consentStore.init(opts)` first, but
52
+ only appends the container's `<script>` element once `consentModeHelper.trackingGranted(consentStore.get().record,
53
+ opts.categories)` is true — immediately if already granted, or via a one-shot `consentStore.subscribe`
54
+ that unsubscribes itself on the first update where it becomes true. The anti-double-load guard (the
55
+ element's own id) is unchanged. Give it the same `geo` the consent UI has: its `init` may run first,
56
+ and the store must know to locate the visitor rather than ask. An automatic decision (outside the
57
+ consent countries) is an ordinary grant here — the tag loads on it — while one past its age counts
58
+ as none, in the head gate as in the store, until the visitor is located again.
59
+
60
+ None of this is new state: it is one more consumer of `@owlmeans/consent`'s existing surface —
61
+ `consentModeHelper.trackingGranted(record, categories)` (whether a stored/applied record grants a signal-bearing,
62
+ non-required category) and `.consentGateScript(loaderExpr, opts)` (the inline script implementing the
63
+ "run now or wait for `CONSENT_EVENT`" branch). Both live in `@owlmeans/consent`, not here, because a
64
+ target project's own custom loader can reuse the same gate without depending on this package.
65
+
28
66
  ## Key Exports
29
67
 
30
68
  | Export | Description |
31
69
  |--------|-------------|
32
- | `gtmHeadScript(opts)` | The inline `<head>` snippet: consent defaults, stored decision, then the container |
33
- | `gtmNoscriptFrame(opts)` | The hidden `<noscript>` iframe, for the top of `<body>` |
34
- | `loadGtm(opts)` | Script-side container load, for a host that cannot emit into its own head |
35
- | `GtmOptions` | `ConsentOptions` plus `id` (e.g. `GTM-XXXXXXX`) and optional `dataLayerName` |
70
+ | `googleTagHelper.googleTagHeadScript(opts)` | The inline `<head>` script for any Google id: consent bootstrap, ads redaction, then the loader the prefix calls for — gated behind `consentModeHelper.consentGateScript` in `opts.mode`'s default, `'basic'`. **The default for new code** |
71
+ | `googleTagHelper.isGoogleTagId(id)` / `.googleTagKind(id)` | Whether an id is loadable, and whether it takes `gtm` (container) or `gtag` (gtag.js); `null` for anything else |
72
+ | `GoogleTagOptions` / `GoogleTagKind` | `ConsentOptions` plus `id`, optional `dataLayerName` and `mode`; `'gtm' \| 'gtag'` |
73
+ | `GoogleTagMode` / `GOOGLE_TAG_DEFAULT_MODE` | `'basic' \| 'advanced'`; the platform default, `'basic'` — see **Loading mode** below |
74
+ | `GOOGLE_TAG_CSP_SOURCES` | The hosts GA4, Tag Manager and Google Ads need in `script-src`, `connect-src` and `img-src` (7, frozen) |
75
+ | `GOOGLE_TAG_FRAME_SOURCES` | The hosts they frame, for `frame-src`: `https://www.googletagmanager.com`, `https://td.doubleclick.net` |
76
+ | `googleTagHelper.googleTagServices(id)` | The `ConsentService[]` a cookie policy discloses for that tag — pass to `CookiePolicy`'s `services` |
77
+ | `googleTagHelper.gtmHeadScript(opts)` | The container-only snippet: consent defaults, stored decision, then `gtm.js` — gated the same way as `.googleTagHeadScript` |
78
+ | `googleTagHelper.gtmNoscriptFrame(opts)` | The hidden `<noscript>` iframe, for the top of `<body>` — `''` in `'basic'` mode |
79
+ | `googleTagHelper.loadGtm(opts)` | Script-side container load, for a host that cannot emit into its own head — gated the same way |
80
+ | `GtmOptions` | `ConsentOptions` plus `id` (e.g. `GTM-XXXXXXX`), optional `dataLayerName` and `mode` |
81
+
82
+ ## Ids
83
+
84
+ `googleTagHelper.isGoogleTagId` accepts exactly `GTM-` + 4–12, or `G-` / `GT-` / `AW-` / `DC-` + 4–16, upper-case
85
+ letters and digits. It is strict on purpose: the id is typed into a settings form and ends up in
86
+ an inline script and a script URL, and a value it accepts cannot carry a quote, a tag or a second
87
+ query parameter — so **validate with it where the id is stored**, not only where it is emitted.
88
+ Lower case is rejected rather than folded; normalise in the form if you want to be forgiving.
89
+ `UA-` (Universal Analytics, retired) is rejected: it would promise measurement that never comes.
90
+
91
+ | Prefix | Kind | Loader |
92
+ |---|---|---|
93
+ | `GTM-` | `gtm` | `gtm.js` — Google's container IIFE |
94
+ | `G-` GA4, `GT-` Google tag, `AW-` Google Ads, `DC-` Floodlight | `gtag` | `gtag/js`, then `js` and `config` |
95
+
96
+ ## `googleTagHelper.googleTagHeadScript` — what it emits
97
+
98
+ 1. `consentModeHelper.consentBootstrapScript(opts)` — `consent/default` (everything denied except
99
+ `security_storage`) and, for a returning visitor, `consent/update` from the stored record.
100
+ 2. `set ads_data_redaction true` and `set url_passthrough false` — both have to precede the tag's
101
+ `config`. Redaction strips ad-click ids and goes cookieless while `ad_storage` is denied.
102
+ 3. The loader. For `gtag`: a queue function (published as `window.gtag` only when nothing else
103
+ owns it, so app code can send events), `js`, `config`, and the gtag.js `<script async>`
104
+ element created from the inline script — one inline script rather than Google's two elements,
105
+ so a build stamps one thing. The element carries id `owl-gtag-<id>`, so running the snippet
106
+ twice configures the tag once instead of double-counting page views. In `mode`'s default,
107
+ `'basic'`, this whole step 3 is what `consentModeHelper.consentGateScript` withholds — steps 1 and 2 always run;
108
+ pass `mode: 'advanced'` to run step 3 unconditionally too, as before this mode existed.
109
+
110
+ An **invalid id yields the consent bootstrap alone, regardless of `mode`** — a mistyped tag must
111
+ never cost a page its consent defaults. So a host may stamp `googleTagHelper.googleTagHeadScript({ id: configured
112
+ })` unconditionally, or stamp `consentModeHelper.consentBootstrapScript()` when no tag is configured; both are
113
+ correct.
114
+
115
+ **The output is safe to place inline in HTML as-is.** JSON encoding keeps configured values inside
116
+ their string literals, but the HTML parser ends a script element at the first `</script` wherever
117
+ it sits, so every `</` and `<!--` in the output is backslash-escaped (`<\/`, `<\!--` — JavaScript
118
+ reads them unchanged). `googleTagHelper.gtmHeadScript` predates this and does not escape; prefer
119
+ `.googleTagHeadScript` wherever category keys or a storage key come from configuration.
36
120
 
37
121
  ## Stamping it
38
122
 
39
123
  ```typescript
40
- import { gtmHeadScript, gtmNoscriptFrame } from '@owlmeans/web-gtm'
124
+ import { googleTagHelper } from '@owlmeans/web-gtm'
41
125
 
42
- const opts = { id: 'GTM-XXXXXXX' }
43
- const head = gtmHeadScript(opts) // inline <script> content for <head>, first
44
- const frame = gtmNoscriptFrame(opts) // <noscript> content for the top of <body>
126
+ const head = googleTagHelper.googleTagHeadScript({ id: 'G-XXXXXXXXXX' }) // inline <script> content, first in <head>
45
127
  ```
46
128
 
47
- The snippet is stamped **from HTML**, never from the application bundle. How each surface does it:
129
+ The snippet is stamped **from HTML**, never from the application bundle:
130
+
131
+ - a Vite app — a `transformIndexHtml` plugin substituting a marker, so the snippet cannot drift
132
+ from the package;
133
+ - an Astro site — `astroHelper.owlHeadScripts()` from `@owlmeans/astro`, `set:html` in the base layout;
134
+ - a **generated Viable app** — its Rollup build writes `index.html` and puts
135
+ `googleTagHelper.googleTagHeadScript({ id })` in the `<head>` when the project owner has set a Google tag
136
+ (`BRANDING_GOOGLE_TAG`), and `consentModeHelper.consentBootstrapScript()` otherwise. The tag is attached in
137
+ **preview and production** alike; the consent default is first in both.
48
138
 
49
- - a Vite app — a `transformIndexHtml` plugin substituting the two markers, so the snippet cannot
50
- drift from the package;
51
- - an Astro site — `owlHeadScripts()` from `@owlmeans/astro`, `set:html` in the base layout.
139
+ The snippet **reads the stored decision**, so the same `ConsentOptions` (categories, `storageKey`)
140
+ must be passed here and to the dialog: two category sets in one page mean the snippet and the
141
+ component disagree about what was asked.
52
142
 
53
- A surface that runs no container of its own stamps only `consentBootstrapScript()` from
54
- `@owlmeans/consent` — that is the case for the bundler-generated `index.html` of a generated
55
- application, which carries the consent bootstrap and no tag manager. The bootstrap and this
56
- snippet are separate stampings: adding a container means adding `gtmHeadScript` /
57
- `gtmNoscriptFrame` beside it.
143
+ **Cross-domain consent needs nothing new here.** `GtmOptions`/`GoogleTagOptions` both extend
144
+ `ConsentOptions`, so `linker` (see the `consent` skill's plugin-seam section) passed to either head
145
+ script reaches `consentModeHelper.consentBootstrapScript` unchanged, which embeds the adopt-and-strip fragment right
146
+ after `consent/default` when it is set:
147
+
148
+ ```typescript
149
+ googleTagHelper.googleTagHeadScript({ id: 'GTM-XXXXXXX', linker: { domains: ['owlmeans.com', 'owlmeans.pl'] } })
150
+ ```
58
151
 
59
- `gtmHeadScript` also **reads the stored decision** and pushes `consent/update` before the
60
- container, so a returning visitor's tags are not denied for the first paint of every page. That
61
- read is why the same `ConsentOptions` (categories, `storageKey`) must be passed here and to the
62
- dialog: two different category sets in one page mean the snippet and the component disagree about
63
- what was asked.
152
+ An app that does not stamp a tag at all still gets the fragment from a bare
153
+ `consentModeHelper.consentBootstrapScript({ linker: { domains } })`.
154
+
155
+ ## Content Security Policy
156
+
157
+ A page served with a CSP must allow the tag's hosts, or the loader is blocked and nothing on the
158
+ page says so.
159
+
160
+ - `GOOGLE_TAG_CSP_SOURCES` goes into `script-src`, `connect-src` and `img-src` — one list for all
161
+ three, folded from Google's CSP guide (developers.google.com/tag-platform/security/guides/csp):
162
+ `*.googletagmanager.com`, `*.google-analytics.com`, `*.g.doubleclick.net`, `ad.doubleclick.net`,
163
+ `www.googleadservices.com`, `pagead2.googlesyndication.com`, `*.google.com`. Each is
164
+ `https://` + optional `*.` + a dotted host, the only shape the Viable publisher's source filter
165
+ admits, and few enough to fit its per-slot cap. Google's country domains (`google.<TLD>`) cannot
166
+ be expressed in that shape; what is lost is a remarketing ping, never the tag.
167
+ - `GOOGLE_TAG_FRAME_SOURCES` goes into `frame-src`.
168
+ - The inline head script itself is covered by the document's own script hash — the Viable
169
+ publisher derives `'sha256-…'` from the document it sends — so nothing else is needed for it.
170
+ - **Tag Manager Custom HTML tags do not run under a hash-based CSP**: they inject inline scripts
171
+ no hash names, and `'unsafe-inline'` is not an option. Custom JavaScript variables need
172
+ `'unsafe-eval'` and fail the same way. Build a container for such a page from Google's
173
+ **built-in tag templates** (GA4, Google Ads, Floodlight, Conversion Linker) and Community
174
+ Gallery templates, which load from the allowed hosts.
175
+
176
+ ## Sending events
177
+
178
+ This package emits the head snippet and decides when the loader may run; it has no event-push API. An
179
+ application sends its own events through `@owlmeans/log` calls that carry an `analytics` option, and
180
+ `@owlmeans/web-log`'s `gtmAnalyticsPlugin` pushes them onto `window.dataLayer` only while analytics
181
+ consent is granted (`/web-log`).
182
+
183
+ ## Disclosing it
184
+
185
+ `googleTagHelper.googleTagServices(id)` returns what the cookie policy lists under each category, read off the
186
+ prefix — the page knows nothing more:
187
+
188
+ | Id | Entries |
189
+ |---|---|
190
+ | `G-` | Google Analytics — `analytics`; `_ga`, `_ga_<id without G->` |
191
+ | `AW-` | Google Ads — `marketing`; `_gcl_au`, `_gcl_aw` |
192
+ | `DC-` | Floodlight — `marketing`; `_gcl_au`, `_gcl_dc` |
193
+ | `GTM-`, `GT-` | one `analytics` AND one `marketing` entry, worded as "configured in the container" / "for it at Google" — the policy may not claim less than the tag can do |
194
+ | invalid | `[]` — it loads nothing, so it discloses nothing |
195
+
196
+ Pass them to `CookiePolicy` (`services`) wherever the tag is stamped; a generated Viable app's
197
+ cookie page does so when a Google tag is set. The strings are English data, not translation keys.
64
198
 
65
199
  ## Gotchas
66
200
 
67
- - **The container id and queue name are injected as data, never interpolated raw.** Both come from
68
- configuration, and configuration reaches these functions as a string. `gtmHeadScript` JSON-encodes
69
- them, so a quote inside an id is escaped rather than closing the literal and turning the rest into
70
- executable script; `gtmNoscriptFrame` URL-encodes the id so it cannot break out of the attribute.
71
- Never build either string by hand.
72
- - **`dataLayerName` is for a page running more than one container.** It is passed through to the
73
- container the way Google's own snippet does (`&l=` when it is not `dataLayer`); leave it unset
74
- otherwise, or tags looking at the default queue see nothing.
75
- - **`loadGtm` is the fallback, not the default.** It is for a single-page app whose HTML is not
201
+ - **Consent Mode speaks on `window.dataLayer`.** The bootstrap and the dialog's later
202
+ `consent/update` both push there, so a tag given another `dataLayerName` never hears a consent
203
+ command. Leave `dataLayerName` unset on any consent-gated page; it exists for a page running a
204
+ second, separately-consented container. `googleTagHelper.googleTagHeadScript` falls back to `dataLayer` when the
205
+ name is not a plain identifier.
206
+ - **Ids and queue names are injected as data, never interpolated raw.** `googleTagHelper.gtmHeadScript` and
207
+ `.googleTagHeadScript` JSON-encode them; `.gtmNoscriptFrame` URL-encodes the id so it cannot break
208
+ out of the attribute. Never build either string by hand.
209
+ - **`googleTagHelper.loadGtm` is the fallback, not the default.** It is for a single-page app whose HTML is not
76
210
  yours to edit. It still pushes the defaults first (through `consentStore.init`) and refuses to
77
211
  load a second time — it marks its own `<script>` element with an id derived from the container —
78
- but it runs after the bundle, which is exactly the window the head snippet closes. Prefer the
79
- head snippet wherever the document can be edited.
212
+ but it runs after the bundle, which is exactly the window the head snippet closes. In `'basic'`
213
+ mode it additionally withholds the element itself until `consentModeHelper.trackingGranted`, same as the head
214
+ snippets.
215
+ - **The default flipped to `'basic'`.** Code written before this mode existed called
216
+ `googleTagHelper.googleTagHeadScript({ id })` / `.gtmHeadScript({ id })` expecting the tag to load unconditionally.
217
+ It now gates by default; pass `mode: 'advanced'` explicitly to keep the old behavior for a site
218
+ that has made that call deliberately.
80
219
  - **A second `consent/default` after a tag has loaded can WIDEN what was already narrowed**, so the
81
220
  bootstrap is idempotent through a window flag. A page may therefore carry the call twice — once
82
221
  inline, once from the bundle that mounts the dialog — without harm. Do not defeat the flag.
83
- - `loadGtm` returns immediately when there is no `document`, so it is safe to call from code that
222
+ - `googleTagHelper.loadGtm` returns immediately when there is no `document`, so it is safe to call from code that
84
223
  also runs during server rendering.
85
224
 
86
225
  ## Depends On
87
226
 
88
- - `@owlmeans/consent` — `consentBootstrapScript`, `consentStore`, `ConsentOptions`, the category
89
- model and the Consent Mode signal mapping
227
+ - `@owlmeans/consent` — `consentModeHelper.consentBootstrapScript`, `consentStore`, `ConsentOptions`,
228
+ `ConsentService`, the category keys and the Consent Mode signal mapping, plus (for the `'basic'`
229
+ gated mode) `.consentGateScript` and `.trackingGranted`
90
230
 
91
231
  ## Related
92
232
 
93
233
  - `consent` — categories, the storage record and its migration, Consent Mode v2 signalling, and the
94
234
  ordering rule this package implements
95
- - `web-consent` — the dialog and the cookie-policy page that produce the decision
96
- - `astro` — `owlHeadScripts()`, which composes both strings for an Astro layout
235
+ - `web-consent` — the dialog and the cookie-policy page (`services`) that produce and disclose the
236
+ decision
237
+ - `astro` — `astroHelper.owlHeadScripts()`, which composes the head strings for an Astro layout
@@ -0,0 +1,43 @@
1
+ import type { GoogleTagMode } from './types.js';
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 declare const GOOGLE_TAG_DEFAULT_MODE: GoogleTagMode;
16
+ /**
17
+ * The hosts a page running a Google tag has to allow in `script-src`, `connect-src` and
18
+ * `img-src`, for Google Analytics 4, Tag Manager and Google Ads together.
19
+ *
20
+ * Taken from Google's CSP guide (developers.google.com/tag-platform/security/guides/csp) and
21
+ * folded into one list, because the platform adds one list to all three directives:
22
+ *
23
+ * - `*.googletagmanager.com` — gtm.js and gtag/js (`www.`), and their measurement pings;
24
+ * - `*.google-analytics.com` — GA4 collection (`region1.`, `www.`);
25
+ * - `*.g.doubleclick.net` — GA4 with Google signals and Ads (`stats.g.`, `googleads.g.`);
26
+ * - `ad.doubleclick.net` — Ads and Floodlight conversions;
27
+ * - `www.googleadservices.com` — the Ads conversion script;
28
+ * - `pagead2.googlesyndication.com` — Ads and GA4-with-ads pings;
29
+ * - `*.google.com` — `www.google.com` (the Ads script, consent-mode pings), `adservice.`, and the
30
+ * `analytics.google.com` collection endpoints.
31
+ *
32
+ * Every entry is an `https://` scheme, an optional `*.` label and a dotted host — the only shape
33
+ * the publisher's CSP filter lets through — and there are few enough to stay inside its per-slot
34
+ * cap. Google's country domains (`google.<TLD>`) cannot be named in that shape and are left out:
35
+ * what is lost is a remarketing ping from visitors on those domains, never the tag itself.
36
+ */
37
+ export declare const GOOGLE_TAG_CSP_SOURCES: readonly string[];
38
+ /**
39
+ * The hosts a Google tag frames: Tag Manager's service-worker and preview frames, and the Ads
40
+ * remarketing frame. For `frame-src`, which otherwise stays `'none'`.
41
+ */
42
+ export declare const GOOGLE_TAG_FRAME_SOURCES: readonly string[];
43
+ //# sourceMappingURL=consts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE/C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,aAAuB,CAAA;AAE7D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAQlD,CAAA;AAEF;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAGpD,CAAA"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The platform default for {@link GtmOptions.mode} / {@link GoogleTagOptions.mode}.
3
+ *
4
+ * One named constant so flipping it later is a one-line change.
5
+ *
6
+ * - `'basic'` withholds the loader until a signal-bearing category is granted — which holds back
7
+ * Google's own IP receipt too, not only what a granted tag may then do with it.
8
+ * - `'advanced'` loads immediately with Consent Mode signals denied — today's only behavior, and
9
+ * Google's own recommended default for its own conversion modeling.
10
+ *
11
+ * Default is `'basic'`: read it as the EU/DE worst-case reading of ePrivacy Art. 5(3) — no third
12
+ * party receives a connection before consent.
13
+ */
14
+ export const GOOGLE_TAG_DEFAULT_MODE = 'basic';
15
+ /**
16
+ * The hosts a page running a Google tag has to allow in `script-src`, `connect-src` and
17
+ * `img-src`, for Google Analytics 4, Tag Manager and Google Ads together.
18
+ *
19
+ * Taken from Google's CSP guide (developers.google.com/tag-platform/security/guides/csp) and
20
+ * folded into one list, because the platform adds one list to all three directives:
21
+ *
22
+ * - `*.googletagmanager.com` — gtm.js and gtag/js (`www.`), and their measurement pings;
23
+ * - `*.google-analytics.com` — GA4 collection (`region1.`, `www.`);
24
+ * - `*.g.doubleclick.net` — GA4 with Google signals and Ads (`stats.g.`, `googleads.g.`);
25
+ * - `ad.doubleclick.net` — Ads and Floodlight conversions;
26
+ * - `www.googleadservices.com` — the Ads conversion script;
27
+ * - `pagead2.googlesyndication.com` — Ads and GA4-with-ads pings;
28
+ * - `*.google.com` — `www.google.com` (the Ads script, consent-mode pings), `adservice.`, and the
29
+ * `analytics.google.com` collection endpoints.
30
+ *
31
+ * Every entry is an `https://` scheme, an optional `*.` label and a dotted host — the only shape
32
+ * the publisher's CSP filter lets through — and there are few enough to stay inside its per-slot
33
+ * cap. Google's country domains (`google.<TLD>`) cannot be named in that shape and are left out:
34
+ * what is lost is a remarketing ping from visitors on those domains, never the tag itself.
35
+ */
36
+ export const GOOGLE_TAG_CSP_SOURCES = Object.freeze([
37
+ 'https://*.googletagmanager.com',
38
+ 'https://*.google-analytics.com',
39
+ 'https://*.g.doubleclick.net',
40
+ 'https://ad.doubleclick.net',
41
+ 'https://www.googleadservices.com',
42
+ 'https://pagead2.googlesyndication.com',
43
+ 'https://*.google.com',
44
+ ]);
45
+ /**
46
+ * The hosts a Google tag frames: Tag Manager's service-worker and preview frames, and the Ads
47
+ * remarketing frame. For `frame-src`, which otherwise stays `'none'`.
48
+ */
49
+ export const GOOGLE_TAG_FRAME_SOURCES = Object.freeze([
50
+ 'https://www.googletagmanager.com',
51
+ 'https://td.doubleclick.net',
52
+ ]);
53
+ //# sourceMappingURL=consts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAkB,OAAO,CAAA;AAE7D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAsB,MAAM,CAAC,MAAM,CAAC;IACrE,gCAAgC;IAChC,gCAAgC;IAChC,6BAA6B;IAC7B,4BAA4B;IAC5B,kCAAkC;IAClC,uCAAuC;IACvC,sBAAsB;CACvB,CAAC,CAAA;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAsB,MAAM,CAAC,MAAM,CAAC;IACvE,kCAAkC;IAClC,4BAA4B;CAC7B,CAAC,CAAA"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The id shapes Google issues today, and nothing else.
3
+ *
4
+ * Deliberately strict — upper case, a known prefix, a bounded run of letters and digits — because
5
+ * the id is typed by a person into a settings form and ends up inside an inline script and a
6
+ * script URL. A value this pattern accepts cannot carry a quote, a tag or a second parameter, so
7
+ * the check is what makes it safe to store, not only to emit. `UA-` is not here: Universal
8
+ * Analytics stopped processing data in 2024, and accepting its ids would promise measurement that
9
+ * never arrives.
10
+ */
11
+ export declare const GOOGLE_TAG_ID: RegExp;
12
+ /** A queue name has to be a plain identifier: it becomes `window[name]` in the emitted script. */
13
+ export declare const JS_IDENTIFIER: RegExp;
14
+ export declare const GOOGLE = "Google LLC";
15
+ export declare const GOOGLE_PRIVACY = "https://policies.google.com/privacy";
16
+ export declare const UNTIL_ANALYTICS: string;
17
+ export declare const UNTIL_MARKETING: string;
18
+ //# sourceMappingURL=consts.local.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.local.d.ts","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,aAAa,QAAuD,CAAA;AAEjF,kGAAkG;AAClG,eAAO,MAAM,aAAa,QAA+B,CAAA;AAEzD,eAAO,MAAM,MAAM,eAAe,CAAA;AAElC,eAAO,MAAM,cAAc,wCAAwC,CAAA;AAEnE,eAAO,MAAM,eAAe,QACS,CAAA;AAErC,eAAO,MAAM,eAAe,QACG,CAAA"}