@owlmeans/web-gtm 0.1.18-rc.35 → 0.1.18-rc.37

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-rc.37
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.49
195
+ npx @owlmeans/agent-skills@^0.1.18-rc.52
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
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-gtm",
4
- "version": "0.1.18-rc.35",
5
- "generatedAt": "2026-10-05T21:03:06.368Z",
4
+ "version": "0.1.18-rc.37",
5
+ "generatedAt": "2026-10-08T21:27:54.286Z",
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.35"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-gtm": "^0.1.18-rc.37"` 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
@@ -39,6 +39,11 @@ constant so flipping the platform default later is a one-line change:
39
39
  Read `'basic'`'s default as the EU/DE worst-case reading of ePrivacy Art. 5(3): no third party
40
40
  receives a connection before consent.
41
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
+
42
47
  `googleTagHelper.gtmNoscriptFrame` returns `''` in `'basic'` mode rather than an iframe — a browser with JavaScript
43
48
  disabled cannot have granted anything, so an unauthenticated `<noscript>` frame would defeat the
44
49
  whole point of gating. It keeps emitting the iframe in `'advanced'` mode, unchanged.
@@ -47,7 +52,10 @@ whole point of gating. It keeps emitting the iframe in `'advanced'` mode, unchan
47
52
  only appends the container's `<script>` element once `consentModeHelper.trackingGranted(consentStore.get().record,
48
53
  opts.categories)` is true — immediately if already granted, or via a one-shot `consentStore.subscribe`
49
54
  that unsubscribes itself on the first update where it becomes true. The anti-double-load guard (the
50
- element's own id) is unchanged.
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.
51
59
 
52
60
  None of this is new state: it is one more consumer of `@owlmeans/consent`'s existing surface —
53
61
  `consentModeHelper.trackingGranted(record, categories)` (whether a stored/applied record grants a signal-bearing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-gtm",
3
- "version": "0.1.18-rc.35",
3
+ "version": "0.1.18-rc.37",
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.11"
25
+ "@owlmeans/consent": "^0.1.18-rc.12"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@owlmeans/dep-config": "workspace:*",