consently 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e94002d1ea10654641d354b24f94f18c97a106e71cf737a9fede52de32b6141
4
- data.tar.gz: 5dd2300f3e1b7e8f7c696dd8dc86d5cdd098c7889e6a070f00b379b2f546b105
3
+ metadata.gz: b563d7b5602bb17c10a2ac0ee0b04c6c59642df56920a36e292d808bfef5c89b
4
+ data.tar.gz: 0ae29edf9f32feb8fc79cbb7e2717e0a68fee7bef0dafdb38b9e4e5f1523b754
5
5
  SHA512:
6
- metadata.gz: 5d47ad72af0dd8538c124c383ebe809605675a05f68ab496a97e88a1f089d9eca4e1e1f779617ffb3361c4d5a0bc7a9eb8dc2ba1b13799b67aeb3e32a6b0856d
7
- data.tar.gz: c56ac8259f0b480dd6d7c34c0649ca2ac02b365cb3e32d894600d8692918bd21d4c76f32cfc537e30481ebe8c16bf65890ceebc1fa7983e0673d4c0184e632df
6
+ metadata.gz: e71581b2492efb315e7679f1a5d534dd45d8771fe6b552d75b946e763678bad95fccf4bffaa9f03371500ffafbb237a520d5e0c7e4552a616ba18b320fbf6b8a
7
+ data.tar.gz: a108dbed5dd145646f649807a953693963d6da9c4eb2ec64eca666301b5dd6e569a1699d71c4000dc879348242dbe547eb17da87558b9bd6deea2e34461ebaca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-08-13)
4
+
5
+ - `consently_embed` for videos and maps: a YouTube or Vimeo iframe, or a
6
+ Google map, waits behind a placeholder until its category is granted and
7
+ then appears without a reload.
8
+ - `consently_ecommerce` builds GA4 ecommerce events from your own line items
9
+ or products, clearing the previous `ecommerce` object the way Google asks.
10
+ - `google_consent_mode` now takes `:basic` (the previous behaviour) or
11
+ `:advanced`, which lets Google's own tags load denied so Ads can model the
12
+ conversions of visitors who refused.
13
+ - `cookie_domain`, so one consent covers every subdomain.
14
+ - `consent_max_age`, to ask again after a year without bumping the policy
15
+ version.
16
+
3
17
  ## 0.1.0 (2026-08-12)
4
18
 
5
19
  First release.
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Consently
2
2
 
3
+ [![Gem](https://img.shields.io/gem/v/consently?color=%23e9573f)](https://rubygems.org/gems/consently)
4
+ [![Downloads](https://img.shields.io/gem/dt/consently)](https://rubygems.org/gems/consently)
5
+ [![CI](https://github.com/Xeross99/consently/actions/workflows/ci.yml/badge.svg)](https://github.com/Xeross99/consently/actions/workflows/ci.yml)
6
+ [![MIT](https://img.shields.io/badge/licence-MIT-blue)](MIT-LICENSE)
7
+
3
8
  **GDPR cookie consent for Rails that actually blocks your tags.**
4
9
 
5
10
  Most banners ask for consent and load Google Analytics anyway. Consently is
@@ -12,12 +17,24 @@ pageview.
12
17
  | --- | --- |
13
18
  | **Blocks** | GA4, Google Tag Manager, Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible, anything custom |
14
19
  | **Google Consent Mode v2** | denied by default before any Google tag, updated on the click |
15
- | **Banner** | plain CSS, no Tailwind, no build step, one Stimulus controller, ten languages |
20
+ | **Banner** | plain CSS, no Tailwind, no build step, ten languages |
21
+ | **Embeds** | YouTube, Vimeo and Google Maps iframes wait behind a placeholder too |
22
+ | **Ecommerce** | GA4 `purchase`, `add_to_cart` and friends, built from your own line items |
16
23
  | **Cookie policy** | generated from the same config - every vendor, every cookie, every duration |
17
24
  | **Multi-tenant** | different tags per domain or shop from one initializer |
18
25
  | **Proof of consent** | optional log in your own database; no third-party service, nothing leaves your servers |
19
26
  | **Install** | one initializer, three helpers in your layout |
20
27
 
28
+ Before the click, and right after it - same page, no reload in between:
29
+
30
+ | Blocked | Running |
31
+ | --- | --- |
32
+ | ![The banner, with every optional tag blocked](docs/banner.png) | ![The same page a click later, every tag running](docs/running.png) |
33
+
34
+ The preferences panel, one category at a time:
35
+
36
+ ![The preferences panel with a checkbox per category](docs/preferences.png)
37
+
21
38
  ```ruby
22
39
  # config/initializers/consently.rb
23
40
  Consently.configure do |c|
@@ -52,10 +69,12 @@ bundle install
52
69
  rails g consently:install
53
70
  ```
54
71
 
55
- The generator writes the initializer and registers the Stimulus controller in
56
- `app/javascript/controllers/index.js`. Nothing else to set up: the banner
57
- brings its own plain CSS, so there is no Tailwind, no build step and no
58
- config file to keep in sync.
72
+ On RubyGems: <https://rubygems.org/gems/consently>
73
+
74
+ The generator writes the initializer and registers the two Stimulus controllers
75
+ in `app/javascript/controllers/index.js`. Nothing else to set up: the banner
76
+ brings its own plain CSS, so there is no Tailwind, no build step and no config
77
+ file to keep in sync.
59
78
 
60
79
  ## What is a tag
61
80
 
@@ -91,7 +110,19 @@ c.category :personalization
91
110
  visitor agrees, and the choice is kept in a `consently` cookie for six months.
92
111
 
93
112
  Change your policy? Bump `c.consent_version` and every older consent stops
94
- counting; the banner asks again.
113
+ counting; the banner asks again. To ask again on a schedule as well - the
114
+ guidance across the EU converges on about a year - set an age:
115
+
116
+ ```ruby
117
+ c.consent_max_age = 12.months
118
+ ```
119
+
120
+ Spanning subdomains? Say so, or a consent given on `www` will not count on
121
+ `shop`:
122
+
123
+ ```ruby
124
+ c.cookie_domain = ".example.com"
125
+ ```
95
126
 
96
127
  ## Per-domain, per-tenant
97
128
 
@@ -100,12 +131,12 @@ One initializer, different tags per host or shop:
100
131
  ```ruby
101
132
  c.scope_resolver = -> (request) { request.host }
102
133
 
103
- c.scope "trixbrix.eu" do |s|
104
- s.tag :google_analytics, id: "G-TRIX"
134
+ c.scope "shop.example.com" do |s|
135
+ s.tag :google_analytics, id: "G-SHOP00001"
105
136
  end
106
137
 
107
- c.scope "pixelpicture.eu" do |s|
108
- s.tag :google_analytics, id: "G-PIXEL"
138
+ c.scope "blog.example.com" do |s|
139
+ s.tag :google_analytics, id: "G-BLOG00001"
109
140
  end
110
141
  ```
111
142
 
@@ -113,6 +144,22 @@ Scopes inherit the tags declared outside them and override by declaring the
113
144
  same provider again. The resolver can return anything - `Current.shop&.name`
114
145
  works just as well as a host.
115
146
 
147
+ ## The cookie policy writes itself
148
+
149
+ ```erb
150
+ <h1>Cookie policy</h1>
151
+ <p>Your own legal text.</p>
152
+
153
+ <%= consently_policy %>
154
+ ```
155
+
156
+ Every category, the vendors in it, the cookies each one sets and how long they
157
+ last - rendered from the configuration your tags come from, so it cannot drift
158
+ out of date. Add a tag to the initializer and it appears here, in the right
159
+ category, with its cookies.
160
+
161
+ ![The generated cookie policy: categories, vendors, cookies and durations](docs/policy.png)
162
+
116
163
  ## Proof of consent
117
164
 
118
165
  ```bash
@@ -187,13 +234,88 @@ button carries `aria-expanded` and `aria-controls`, reopening the panel moves
187
234
  focus into it, and the animation gives way to `prefers-reduced-motion`. Nobody
188
235
  is trapped in a focus cycle they did not ask for.
189
236
 
190
- ## Events
237
+ ## Ecommerce events
238
+
239
+ GA4 wants a particular shape, and your models are not it. Hand the helper
240
+ whatever you have:
241
+
242
+ ```erb
243
+ <%= consently_ecommerce "purchase", items: @order.line_items,
244
+ value: @order.total, currency: "EUR", transaction_id: @order.number %>
245
+ ```
246
+
247
+ Items may be hashes already in GA4 shape, or any object answering to
248
+ `sku`/`id`, `name`, `price`, `quantity`, `category`, `brand`, `variant` - a
249
+ line item or a product usually does. The previous `ecommerce` object is
250
+ cleared first, as Google asks, so two events on one page cannot bleed into
251
+ each other.
252
+
253
+ Anything else:
191
254
 
192
255
  ```erb
193
- <%= consently_data_layer_push("purchase", value: 120, currency: "EUR") %>
256
+ <%= consently_data_layer_push "newsletter_signup", source: "footer" %>
194
257
  ```
195
258
 
196
- Renders nothing when analytics consent is missing.
259
+ Both render nothing at all when analytics consent is missing.
260
+
261
+ ## Embedded videos and maps
262
+
263
+ Blocking scripts is half the job: a YouTube iframe sets cookies on its own.
264
+
265
+ ```erb
266
+ <%= consently_embed :youtube, "dQw4w9WgXcQ" %>
267
+ <%= consently_embed :vimeo, "76979871", category: :analytics %>
268
+ <%= consently_embed :google_maps, "Bahnhofstrasse 12, Berlin" %>
269
+ <%= consently_embed :custom, "https://example.com/widget", title: "Widget", ratio: "4 / 3" %>
270
+ ```
271
+
272
+ Until the category is granted the visitor gets a placeholder the same size as
273
+ the embed - so nothing jumps - with a button that opens the preferences panel.
274
+ The iframe appears the moment they agree, without a reload.
275
+
276
+ | Waiting for consent | After the click |
277
+ | --- | --- |
278
+ | ![A placeholder where the video will be](docs/embed-blocked.png) | ![The same box, now playing the video](docs/embed-released.png) |
279
+
280
+ What the page holds until then is only the address:
281
+
282
+ ```html
283
+ <div class="consently-embed"
284
+ data-controller="consently-embed"
285
+ data-consently-embed-category-value="marketing"
286
+ data-consently-embed-src-value="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ">
287
+ ```
288
+
289
+ No iframe, no request to YouTube, no cookie - and `youtube-nocookie.com` is
290
+ what gets embedded once there is consent. `ratio:` sets the box (`16 / 9` by
291
+ default), `category:` decides which consent releases it, and any other option
292
+ is passed straight to the iframe:
293
+
294
+ ```erb
295
+ <%= consently_embed :youtube, "dQw4w9WgXcQ", ratio: "21 / 9", allow: "autoplay; picture-in-picture" %>
296
+ ```
297
+
298
+ Your own markup can do the same thing without the helper: anything carrying
299
+ `data-consently-open` opens the preferences panel, and the `consently:change`
300
+ event tells you what was granted.
301
+
302
+ ## Google consent mode: basic or advanced
303
+
304
+ ```ruby
305
+ c.google_consent_mode = :basic # default
306
+ c.google_consent_mode = :advanced
307
+ ```
308
+
309
+ **Basic** keeps Google's tags off the page until consent: nothing about the
310
+ visitor reaches Google before they agree.
311
+
312
+ **Advanced** loads them right away with everything denied, so they send
313
+ cookieless pings and Google Ads can model the conversions of visitors who said
314
+ no. More data for you, a request to Google either way. Which one is defensible
315
+ is a legal call, not a technical one - the gem simply does what you set.
316
+
317
+ Either way the defaults are emitted before any Google tag and updated the
318
+ moment the visitor chooses.
197
319
 
198
320
  ## Styling
199
321
 
@@ -251,7 +373,9 @@ Helpers:
251
373
  | `consently_banner` | the banner, the panel, and the JavaScript that releases blocked tags |
252
374
  | `consently_policy` | the generated cookie policy: categories, vendors, cookies, durations |
253
375
  | `consently_preferences_link` | "Cookie settings" link; anything with `data-consently-open` reopens the panel |
254
- | `consently_data_layer_push(event, **payload)` | a dataLayer event, rendered only with analytics consent |
376
+ | `consently_ecommerce(event, items:, **params)` | a GA4 ecommerce event, items mapped from your own objects |
377
+ | `consently_data_layer_push(event, **payload)` | any other dataLayer event, rendered only with analytics consent |
378
+ | `consently_embed(kind, id, category:, ratio:)` | a video or map that waits for consent |
255
379
  | `consently_consent` | the current `Consently::Consent`; `granted?(:analytics)` in your own views |
256
380
 
257
381
  Configuration:
@@ -264,10 +388,11 @@ Configuration:
264
388
  | `c.consent_version` | bump it and every older consent stops counting |
265
389
  | `c.enabled` | `true`, `false`, or a callable taking the request |
266
390
  | `c.reload_after_choice` | reload once a choice is made; off by default |
267
- | `c.google_consent_mode` | consent mode v2 defaults and updates; on by default |
391
+ | `c.google_consent_mode` | `:basic` (default), `:advanced`, or `false` |
268
392
  | `c.log_consents`, `c.consent_subject` | store proof of each decision, optionally naming who |
269
393
  | `c.stylesheet` | link the banner's CSS; off if you style it yourself |
270
- | `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path` | where the choice is kept |
394
+ | `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path`, `c.cookie_domain` | where the choice is kept |
395
+ | `c.consent_max_age` | ask again after this long, whatever the cookie says |
271
396
  | `c.respect_do_not_track`, `c.respect_global_privacy_control` | treat an opt-out signal as a rejection |
272
397
  | `c.consent_required` | who has to be asked at all; false means no banner and everything granted |
273
398
 
@@ -146,6 +146,44 @@
146
146
  display: none !important;
147
147
  }
148
148
 
149
+ /* Embedded videos and maps. The placeholder keeps the same box as the iframe
150
+ it replaces, so nothing on the page jumps when consent arrives. */
151
+ .consently-embed {
152
+ position: relative;
153
+ aspect-ratio: var(--consently-embed-ratio, 16 / 9);
154
+ width: 100%;
155
+ border-radius: 0.5rem;
156
+ overflow: hidden;
157
+ background: #f3f4f6;
158
+ }
159
+
160
+ .consently-embed__frame {
161
+ width: 100%;
162
+ height: 100%;
163
+ border: 0;
164
+ display: block;
165
+ }
166
+
167
+ .consently-embed__placeholder {
168
+ position: absolute;
169
+ inset: 0;
170
+ display: flex;
171
+ flex-direction: column;
172
+ align-items: center;
173
+ justify-content: center;
174
+ gap: 0.75rem;
175
+ padding: 1.5rem;
176
+ text-align: center;
177
+ }
178
+
179
+ .consently-embed__text {
180
+ margin: 0;
181
+ max-width: 28rem;
182
+ font-size: 0.875rem;
183
+ line-height: 1.5rem;
184
+ color: #4b5563;
185
+ }
186
+
149
187
  /* The generated cookie policy. Deliberately quiet: it lands inside someone
150
188
  else's page and should inherit its typography. */
151
189
  .consently-policy {
@@ -13,8 +13,7 @@ module Consently
13
13
  parts << consently_stylesheet_tag if Consently.config.stylesheet
14
14
  parts << consently_consent_mode_tag if Consently.config.google_consent_mode
15
15
  Consently.tags_for(request).each do |provider|
16
- granted = consently_consent.granted?(provider.category)
17
- provider.scripts.each { |script| parts << consently_script_tag(script, provider, granted) }
16
+ provider.scripts.each { |script| parts << consently_script_tag(script, provider, consently_release?(provider)) }
18
17
  end
19
18
 
20
19
  safe_join(parts, "\n")
@@ -65,7 +64,11 @@ module Consently
65
64
 
66
65
  def consently_consent
67
66
  @consently_consent ||= if Consently.consent_required?(request)
68
- Consent.from_cookie(cookies[Consently.config.cookie_name], version: Consently.config.consent_version)
67
+ Consent.from_cookie(
68
+ cookies[Consently.config.cookie_name],
69
+ version: Consently.config.consent_version,
70
+ max_age: Consently.config.consent_max_age
71
+ )
69
72
  else
70
73
  # Nobody to ask, so nothing is held back.
71
74
  Consent.new(categories: Consently.config.categories, version: Consently.config.consent_version)
@@ -75,7 +78,7 @@ module Consently
75
78
  # Push an event onto the dataLayer from a view, respecting consent: with
76
79
  # no analytics consent the event is simply not emitted.
77
80
  #
78
- # <%= consently_data_layer_push("purchase", value: 120, currency: "EUR") %>
81
+ # <%= consently_data_layer_push("newsletter_signup", source: "footer") %>
79
82
  def consently_data_layer_push(event, category: :analytics, **payload)
80
83
  return "".html_safe unless consently_enabled? && consently_consent.granted?(category)
81
84
 
@@ -83,6 +86,31 @@ module Consently
83
86
  consently_inline_script "window.dataLayer = window.dataLayer || []; window.dataLayer.push(#{payload.to_json});"
84
87
  end
85
88
 
89
+ # A GA4 ecommerce event in the shape Google expects, from whatever your
90
+ # models happen to look like:
91
+ #
92
+ # <%= consently_ecommerce("purchase", items: @order.line_items,
93
+ # value: @order.total, currency: "EUR", transaction_id: @order.number) %>
94
+ #
95
+ # Items may be hashes already in GA4 shape, or any object answering to a
96
+ # few obvious names (id/sku, name, price, quantity, category, brand,
97
+ # variant) - a LineItem or a Product usually does.
98
+ #
99
+ # The previous ecommerce object is cleared first, as Google asks, so two
100
+ # events on one page cannot bleed into each other.
101
+ def consently_ecommerce(event, items: [], category: :analytics, **params)
102
+ return "".html_safe unless consently_enabled? && consently_consent.granted?(category)
103
+
104
+ ecommerce = params.merge(items: Array(items).map { |item| consently_ecommerce_item(item) })
105
+ payload = { event: event, ecommerce: ecommerce.compact }
106
+
107
+ consently_inline_script <<~JS.strip
108
+ window.dataLayer = window.dataLayer || [];
109
+ window.dataLayer.push({ ecommerce: null });
110
+ window.dataLayer.push(#{payload.to_json});
111
+ JS
112
+ end
113
+
86
114
  # A complete cookie policy for the tags this request would load: every
87
115
  # category, every vendor, every cookie it sets and for how long, plus
88
116
  # whether the visitor has agreed to it right now.
@@ -92,6 +120,30 @@ module Consently
92
120
  render "consently/policy", tags: Consently.tags_for(request), consent: consently_consent
93
121
  end
94
122
 
123
+ # An embedded video or map that waits for consent. Blocking scripts is
124
+ # only half the job: a YouTube iframe sets cookies all by itself.
125
+ #
126
+ # <%= consently_embed :youtube, "dQw4w9WgXcQ" %>
127
+ # <%= consently_embed :vimeo, "76979871", category: :analytics %>
128
+ # <%= consently_embed :google_maps, "Bahnhofstrasse 12, Berlin" %>
129
+ # <%= consently_embed :custom, "https://example.com/widget", title: "Widget" %>
130
+ #
131
+ # Until the category is granted the visitor sees a placeholder with a
132
+ # button that opens the preferences panel; the iframe appears the moment
133
+ # they agree, without a reload.
134
+ def consently_embed(kind, identifier, category: :marketing, title: nil, ratio: "16 / 9", **iframe_options)
135
+ src = consently_embed_src(kind, identifier)
136
+ return "".html_safe if src.blank?
137
+
138
+ render "consently/embed",
139
+ src: src,
140
+ title: title || t("consently.embed.title_#{kind}", default: t("consently.embed.title_default")),
141
+ category: category.to_sym,
142
+ ratio: ratio,
143
+ granted: consently_consent.granted?(category),
144
+ iframe_options: iframe_options
145
+ end
146
+
95
147
  # The banner brings its own plain CSS - no framework, no build step. The
96
148
  # look is driven by custom properties, so overriding a few variables is
97
149
  # usually enough; `rails g consently:views` is there for the rest.
@@ -115,6 +167,15 @@ module Consently
115
167
  Consently.enabled?(request)
116
168
  end
117
169
 
170
+ # Whether this tag may run now. Normally that means consent; under
171
+ # advanced consent mode Google's own tags also load before it, having
172
+ # been told to store nothing until it arrives.
173
+ def consently_release?(provider)
174
+ return true if consently_consent.granted?(provider.category)
175
+
176
+ Consently.config.advanced_google_consent_mode? && provider.google?
177
+ end
178
+
118
179
  # Google's consent mode v2 defaults. This one is never blocked: its whole
119
180
  # job is to tell Google's tags that they may not store anything yet, and
120
181
  # it has to be on the page before them.
@@ -138,6 +199,47 @@ module Consently
138
199
  JS
139
200
  end
140
201
 
202
+ # A bare id is enough for the vendors people embed most; anything else
203
+ # takes a full URL.
204
+ def consently_embed_src(kind, identifier)
205
+ identifier = identifier.to_s
206
+ return identifier if identifier.start_with?("http")
207
+
208
+ case kind.to_sym
209
+ when :youtube then "https://www.youtube-nocookie.com/embed/#{u(identifier)}"
210
+ when :vimeo then "https://player.vimeo.com/video/#{u(identifier)}"
211
+ when :google_maps then "https://www.google.com/maps?q=#{u(identifier)}&output=embed"
212
+ end
213
+ end
214
+
215
+ # Whatever the application handed us, in the shape GA4 reads. Hashes pass
216
+ # through (with symbol keys stringified by to_json anyway); objects are
217
+ # asked for the handful of names that carry the same meaning everywhere.
218
+ def consently_ecommerce_item(item)
219
+ return item.compact if item.is_a?(Hash)
220
+
221
+ {
222
+ item_id: consently_first_value(item, :item_id, :sku, :id),
223
+ item_name: consently_first_value(item, :item_name, :name, :title),
224
+ price: consently_first_value(item, :price, :unit_price, :price_eur),
225
+ quantity: consently_first_value(item, :quantity, :qty),
226
+ item_category: consently_first_value(item, :item_category, :category),
227
+ item_brand: consently_first_value(item, :item_brand, :brand),
228
+ item_variant: consently_first_value(item, :item_variant, :variant)
229
+ }.compact
230
+ end
231
+
232
+ def consently_first_value(item, *names)
233
+ names.each do |name|
234
+ next unless item.respond_to?(name)
235
+
236
+ value = item.public_send(name)
237
+ return value.respond_to?(:name) ? value.name : value if value.present?
238
+ end
239
+
240
+ nil
241
+ end
242
+
141
243
  # A plain <script> with the JS as written. javascript_tag would wrap it in
142
244
  # a CDATA comment nobody has needed since XHTML.
143
245
  def consently_inline_script(javascript)
@@ -16,6 +16,7 @@ export default class extends Controller {
16
16
  version: { type: String, default: "1" },
17
17
  maxAge: { type: Number, default: 60 * 60 * 24 * 180 },
18
18
  path: { type: String, default: "/" },
19
+ domain: String,
19
20
  categories: Array,
20
21
  googleConsentMode: { type: Boolean, default: true },
21
22
  respectDoNotTrack: { type: Boolean, default: false },
@@ -105,8 +106,10 @@ export default class extends Controller {
105
106
  #writeCookie(categories) {
106
107
  const value = JSON.stringify({ v: this.versionValue, c: categories, t: new Date().toISOString() })
107
108
  const secure = window.location.protocol === "https:" ? "; Secure" : ""
109
+ // A domain of ".example.com" is what carries the choice across subdomains.
110
+ const domain = this.domainValue ? `; domain=${this.domainValue}` : ""
108
111
 
109
- document.cookie = `${this.cookieValue}=${encodeURIComponent(value)}; path=${this.pathValue}; max-age=${this.maxAgeValue}; SameSite=Lax${secure}`
112
+ document.cookie = `${this.cookieValue}=${encodeURIComponent(value)}; path=${this.pathValue}${domain}; max-age=${this.maxAgeValue}; SameSite=Lax${secure}`
110
113
  }
111
114
 
112
115
  #activateScripts(categories) {
@@ -0,0 +1,40 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // A video or map that appears the moment its category is granted. The banner
4
+ // announces every decision on `document`, so this controller only has to
5
+ // listen and build the iframe the server did not render.
6
+ export default class extends Controller {
7
+ static targets = ["placeholder"]
8
+ static classes = ["hidden"]
9
+
10
+ static values = {
11
+ category: String,
12
+ src: String,
13
+ title: String,
14
+ attributes: { type: Object, default: {} }
15
+ }
16
+
17
+ // The event carries the categories, so no cookie parsing here.
18
+ granted({ detail }) {
19
+ if (!detail.categories.includes(this.categoryValue)) return
20
+
21
+ this.#insertFrame()
22
+ }
23
+
24
+ #insertFrame() {
25
+ if (this.element.querySelector("iframe")) return
26
+
27
+ const frame = document.createElement("iframe")
28
+ frame.src = this.srcValue
29
+ frame.title = this.titleValue
30
+ frame.loading = "lazy"
31
+ frame.allowFullscreen = true
32
+ frame.className = "consently-embed__frame"
33
+ for (const [name, value] of Object.entries(this.attributesValue)) {
34
+ frame.setAttribute(name, value)
35
+ }
36
+
37
+ this.element.prepend(frame)
38
+ this.placeholderTarget.classList.add(...this.hiddenClasses)
39
+ }
40
+ }
@@ -13,6 +13,7 @@
13
13
  data-consently-banner-version-value="<%= Consently.config.consent_version %>"
14
14
  data-consently-banner-max-age-value="<%= Consently.config.cookie_max_age %>"
15
15
  data-consently-banner-path-value="<%= Consently.config.cookie_path %>"
16
+ data-consently-banner-domain-value="<%= Consently.config.cookie_domain %>"
16
17
  data-consently-banner-categories-value="<%= categories.map(&:to_s).to_json %>"
17
18
  data-consently-banner-google-consent-mode-value="<%= Consently.config.google_consent_mode %>"
18
19
  data-consently-banner-respect-do-not-track-value="<%= Consently.config.respect_do_not_track %>"
@@ -0,0 +1,24 @@
1
+ <%# An iframe that waits for consent. The src lives in a data attribute until
2
+ then, so nothing is requested from the vendor and no cookie is set. %>
3
+ <div class="consently-embed"
4
+ style="--consently-embed-ratio: <%= ratio %>"
5
+ data-controller="consently-embed"
6
+ data-consently-embed-category-value="<%= category %>"
7
+ data-consently-embed-src-value="<%= src %>"
8
+ data-consently-embed-title-value="<%= title %>"
9
+ data-consently-embed-attributes-value="<%= iframe_options.to_json %>"
10
+ data-consently-embed-hidden-class="consently-hidden"
11
+ data-action="consently:change@document->consently-embed#granted">
12
+ <% if granted %>
13
+ <%= tag.iframe src: src, title: title, loading: "lazy", allowfullscreen: true,
14
+ class: "consently-embed__frame", **iframe_options %>
15
+ <% end %>
16
+
17
+ <div class="consently-embed__placeholder <%= "consently-hidden" if granted %>"
18
+ data-consently-embed-target="placeholder">
19
+ <p class="consently-embed__text"><%= t("consently.embed.blocked", category: t("consently.categories.#{category}.name", default: category.to_s.humanize)) %></p>
20
+ <button type="button" data-consently-open class="consently__button consently__button--primary">
21
+ <%= t("consently.embed.allow") %>
22
+ </button>
23
+ </div>
24
+ </div>
data/config/importmap.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  # Pinned for the host application, so `import "consently/banner_controller"`
2
- # resolves without anyone editing their own importmap.
2
+ # resolves without anyone editing config/importmap.rb.
3
3
  pin "consently/banner_controller", to: "consently/banner_controller.js", preload: true
4
+ pin "consently/embed_controller", to: "consently/embed_controller.js", preload: true
@@ -9,6 +9,13 @@ cs:
9
9
  save: "Uložit volbu"
10
10
  cancel: "Zrušit"
11
11
  preferences_link: "Nastavení cookies"
12
+ embed:
13
+ blocked: "Tento obsah se načítá z jiného webu a vyžaduje váš souhlas pro kategorii %{category}."
14
+ allow: "Povolit a zobrazit"
15
+ title_youtube: "Video"
16
+ title_vimeo: "Video"
17
+ title_google_maps: "Mapa"
18
+ title_default: "Vložený obsah"
12
19
  policy:
13
20
  granted: "souhlas udělen"
14
21
  denied: "bez souhlasu"
@@ -9,6 +9,13 @@ de:
9
9
  save: "Auswahl speichern"
10
10
  cancel: "Abbrechen"
11
11
  preferences_link: "Cookie-Einstellungen"
12
+ embed:
13
+ blocked: "Dieser Inhalt wird von einer anderen Website geladen und braucht Ihre Einwilligung für %{category}."
14
+ allow: "Erlauben und anzeigen"
15
+ title_youtube: "Video"
16
+ title_vimeo: "Video"
17
+ title_google_maps: "Karte"
18
+ title_default: "Eingebetteter Inhalt"
12
19
  policy:
13
20
  granted: "eingewilligt"
14
21
  denied: "nicht eingewilligt"
@@ -9,6 +9,13 @@ en:
9
9
  save: "Save choices"
10
10
  cancel: "Cancel"
11
11
  preferences_link: "Cookie settings"
12
+ embed:
13
+ blocked: "This content is loaded from another site and needs your %{category} consent."
14
+ allow: "Allow and show"
15
+ title_youtube: "Video"
16
+ title_vimeo: "Video"
17
+ title_google_maps: "Map"
18
+ title_default: "Embedded content"
12
19
  policy:
13
20
  granted: "you agreed"
14
21
  denied: "not agreed"
@@ -9,6 +9,13 @@ es:
9
9
  save: "Guardar selección"
10
10
  cancel: "Cancelar"
11
11
  preferences_link: "Ajustes de cookies"
12
+ embed:
13
+ blocked: "Este contenido se carga desde otro sitio y necesita tu consentimiento de %{category}."
14
+ allow: "Permitir y mostrar"
15
+ title_youtube: "Vídeo"
16
+ title_vimeo: "Vídeo"
17
+ title_google_maps: "Mapa"
18
+ title_default: "Contenido incrustado"
12
19
  policy:
13
20
  granted: "consentido"
14
21
  denied: "sin consentimiento"
@@ -9,6 +9,13 @@ fr:
9
9
  save: "Enregistrer mes choix"
10
10
  cancel: "Annuler"
11
11
  preferences_link: "Paramètres des cookies"
12
+ embed:
13
+ blocked: "Ce contenu provient d'un autre site et nécessite votre accord pour %{category}."
14
+ allow: "Autoriser et afficher"
15
+ title_youtube: "Vidéo"
16
+ title_vimeo: "Vidéo"
17
+ title_google_maps: "Carte"
18
+ title_default: "Contenu intégré"
12
19
  policy:
13
20
  granted: "accepté"
14
21
  denied: "non accepté"
@@ -9,6 +9,13 @@ hu:
9
9
  save: "Választás mentése"
10
10
  cancel: "Mégse"
11
11
  preferences_link: "Süti beállítások"
12
+ embed:
13
+ blocked: "Ez a tartalom másik oldalról töltődik be, és a(z) %{category} kategóriához adott hozzájárulásod kell hozzá."
14
+ allow: "Engedélyezés és megjelenítés"
15
+ title_youtube: "Videó"
16
+ title_vimeo: "Videó"
17
+ title_google_maps: "Térkép"
18
+ title_default: "Beágyazott tartalom"
12
19
  policy:
13
20
  granted: "hozzájárultál"
14
21
  denied: "nincs hozzájárulás"
@@ -9,6 +9,13 @@ it:
9
9
  save: "Salva le scelte"
10
10
  cancel: "Annulla"
11
11
  preferences_link: "Impostazioni cookie"
12
+ embed:
13
+ blocked: "Questo contenuto viene caricato da un altro sito e richiede il tuo consenso per %{category}."
14
+ allow: "Consenti e mostra"
15
+ title_youtube: "Video"
16
+ title_vimeo: "Video"
17
+ title_google_maps: "Mappa"
18
+ title_default: "Contenuto incorporato"
12
19
  policy:
13
20
  granted: "consenso dato"
14
21
  denied: "nessun consenso"
@@ -9,6 +9,13 @@ nl:
9
9
  save: "Keuze opslaan"
10
10
  cancel: "Annuleren"
11
11
  preferences_link: "Cookie-instellingen"
12
+ embed:
13
+ blocked: "Deze inhoud komt van een andere site en heeft je toestemming voor %{category} nodig."
14
+ allow: "Toestaan en tonen"
15
+ title_youtube: "Video"
16
+ title_vimeo: "Video"
17
+ title_google_maps: "Kaart"
18
+ title_default: "Ingesloten inhoud"
12
19
  policy:
13
20
  granted: "toegestaan"
14
21
  denied: "niet toegestaan"
@@ -9,6 +9,13 @@ pl:
9
9
  save: "Zapisz wybór"
10
10
  cancel: "Anuluj"
11
11
  preferences_link: "Ustawienia cookies"
12
+ embed:
13
+ blocked: "Ta treść ładuje się z innej strony i wymaga Twojej zgody na kategorię %{category}."
14
+ allow: "Zezwól i pokaż"
15
+ title_youtube: "Wideo"
16
+ title_vimeo: "Wideo"
17
+ title_google_maps: "Mapa"
18
+ title_default: "Osadzona treść"
12
19
  policy:
13
20
  granted: "zgoda udzielona"
14
21
  denied: "brak zgody"
@@ -9,6 +9,13 @@ sk:
9
9
  save: "Uložiť voľbu"
10
10
  cancel: "Zrušiť"
11
11
  preferences_link: "Nastavenia cookies"
12
+ embed:
13
+ blocked: "Tento obsah sa načítava z inej stránky a vyžaduje váš súhlas pre kategóriu %{category}."
14
+ allow: "Povoliť a zobraziť"
15
+ title_youtube: "Video"
16
+ title_vimeo: "Video"
17
+ title_google_maps: "Mapa"
18
+ title_default: "Vložený obsah"
12
19
  policy:
13
20
  granted: "súhlas udelený"
14
21
  denied: "bez súhlasu"
@@ -5,7 +5,15 @@ module Consently
5
5
 
6
6
  # Where the visitor's choice is kept. It is read by JavaScript, so it is a
7
7
  # plain cookie rather than a signed one.
8
- attr_accessor :cookie_name, :cookie_max_age, :cookie_path
8
+ #
9
+ # Set cookie_domain to ".example.com" when the site spans subdomains -
10
+ # without it a consent given on www does not count on shop.
11
+ attr_accessor :cookie_name, :cookie_max_age, :cookie_path, :cookie_domain
12
+
13
+ # How long a consent stays valid, regardless of the cookie's own lifetime.
14
+ # Guidance across the EU converges on asking again about once a year; nil
15
+ # leaves the cookie to expire on its own.
16
+ attr_accessor :consent_max_age
9
17
 
10
18
  # Bump this whenever the policy changes: an older consent stops counting
11
19
  # and the banner asks again.
@@ -15,10 +23,32 @@ module Consently
15
23
  # c.enabled = ->(request) { Rails.env.production? }
16
24
  attr_accessor :enabled
17
25
 
18
- # Emits Google's consent mode v2 defaults (everything denied) before any
19
- # Google tag, and updates them when the visitor chooses. Leave it on if
20
- # you use any Google product.
21
- attr_accessor :google_consent_mode
26
+ # Google's consent mode v2. Three settings:
27
+ #
28
+ # :basic - defaults denied, and Google's own tags stay blocked until
29
+ # the visitor agrees. Nothing about them reaches Google
30
+ # before consent. The default, and the strict reading.
31
+ # :advanced - defaults denied, but Google's tags load right away and
32
+ # send cookieless pings, which is what lets Google Ads
33
+ # model the conversions of visitors who said no. More data,
34
+ # and a request to Google either way - ask your lawyer.
35
+ # false - no consent mode at all.
36
+ #
37
+ # `true` is read as :basic.
38
+ attr_reader :google_consent_mode
39
+
40
+ def google_consent_mode=(mode)
41
+ @google_consent_mode = case mode
42
+ when true, :basic then :basic
43
+ when :advanced then :advanced
44
+ when false, nil then false
45
+ else raise ArgumentError, "google_consent_mode must be :basic, :advanced or false"
46
+ end
47
+ end
48
+
49
+ def advanced_google_consent_mode?
50
+ google_consent_mode == :advanced
51
+ end
22
52
 
23
53
  # Store a row per decision, as proof of consent. Needs the engine mounted
24
54
  # and the migration from `rails g consently:consent_log`.
@@ -74,9 +104,11 @@ module Consently
74
104
  @cookie_name = "consently"
75
105
  @cookie_max_age = 60 * 60 * 24 * 180 # six months, the usual guidance
76
106
  @cookie_path = "/"
107
+ @cookie_domain = nil
108
+ @consent_max_age = nil
77
109
  @consent_version = 1
78
110
  @enabled = true
79
- @google_consent_mode = true
111
+ @google_consent_mode = :basic
80
112
  @stylesheet = true
81
113
  @log_consents = false
82
114
  @respect_do_not_track = false
@@ -111,8 +143,8 @@ module Consently
111
143
  # back to the tags declared outside any scope, and may override them by
112
144
  # declaring the same provider again.
113
145
  #
114
- # c.scope "trixbrix" do |s|
115
- # s.tag :google_analytics, id: "G-TRIX"
146
+ # c.scope "shop.example.com" do |s|
147
+ # s.tag :google_analytics, id: "G-SHOP00001"
116
148
  # end
117
149
  def scope(name)
118
150
  scope = (@scopes[name.to_s] ||= Scope.new)
@@ -17,7 +17,7 @@ module Consently
17
17
 
18
18
  # Never raises: a cookie can be truncated, hand-edited or left over from an
19
19
  # older format, and none of that should take a page down.
20
- def self.from_cookie(raw, version:)
20
+ def self.from_cookie(raw, version:, max_age: nil)
21
21
  return none if raw.blank?
22
22
 
23
23
  data = begin
@@ -30,6 +30,7 @@ module Consently
30
30
  # A consent given against an older policy version counts as no consent:
31
31
  # the banner asks again and nothing runs in the meantime.
32
32
  return none unless data["v"].to_s == version.to_s
33
+ return none if expired?(data["t"], max_age)
33
34
 
34
35
  new(
35
36
  categories: Array(data["c"]).map { |category| category.to_s.to_sym },
@@ -38,6 +39,18 @@ module Consently
38
39
  )
39
40
  end
40
41
 
42
+ # A consent older than max_age is asked for again, whatever the cookie's
43
+ # own expiry says. An unreadable timestamp is treated as too old: the only
44
+ # safe reading when we cannot tell when it was given.
45
+ def self.expired?(recorded_at, max_age)
46
+ return false if max_age.nil?
47
+ return true if recorded_at.blank?
48
+
49
+ Time.parse(recorded_at.to_s) < Time.now.utc - max_age.to_i
50
+ rescue ArgumentError
51
+ true
52
+ end
53
+
41
54
  def initialize(categories: [], version: nil, recorded_at: nil, given: true)
42
55
  @categories = Array(categories).map(&:to_sym).freeze
43
56
  @version = version
@@ -14,7 +14,7 @@ module Consently
14
14
  if app.config.respond_to?(:assets)
15
15
  app.config.assets.paths << root.join("app/javascript")
16
16
  if app.config.assets.respond_to?(:precompile)
17
- app.config.assets.precompile += %w[consently.css consently/banner_controller.js]
17
+ app.config.assets.precompile += %w[consently.css consently/banner_controller.js consently/embed_controller.js]
18
18
  end
19
19
  end
20
20
  end
@@ -22,6 +22,15 @@ module Consently
22
22
  klass.new(**options)
23
23
  end
24
24
 
25
+ # Whether this vendor is one of Google's, which is what advanced
26
+ # consent mode is about: those tags may load before consent because
27
+ # they respect the denied defaults themselves.
28
+ attr_writer :google
29
+
30
+ def google?
31
+ !!@google
32
+ end
33
+
25
34
  # Set by each subclass; where a vendor is unambiguous (Plausible does
26
35
  # not touch cookies) it can say :necessary and load right away.
27
36
  attr_accessor :default_category
@@ -60,6 +69,10 @@ module Consently
60
69
  []
61
70
  end
62
71
 
72
+ def google?
73
+ self.class.google?
74
+ end
75
+
63
76
  # Array<Consently::Cookie> - what this tag leaves in the browser.
64
77
  def cookies
65
78
  self.class.cookie_manifest.map { |attributes| Cookie.new(**attributes, provider: key) }
@@ -3,6 +3,7 @@ module Consently
3
3
  class GoogleAds < Base
4
4
  self.provider_key = :google_ads
5
5
  self.default_category = :marketing
6
+ self.google = true
6
7
 
7
8
  cookie "_gcl_au", days: 90
8
9
 
@@ -5,6 +5,7 @@ module Consently
5
5
  class GoogleAnalytics < Base
6
6
  self.provider_key = :google_analytics
7
7
  self.default_category = :analytics
8
+ self.google = true
8
9
 
9
10
  # What GA4 leaves behind. The container-scoped one is written per
10
11
  # measurement id, hence the wildcard.
@@ -3,6 +3,7 @@ module Consently
3
3
  class GoogleTagManager < Base
4
4
  self.provider_key = :google_tag_manager
5
5
  self.default_category = :analytics
6
+ self.google = true
6
7
 
7
8
  # None of its own: whatever it loads brings its own cookies, so list
8
9
  # those tags here as well if you manage them through the container.
@@ -1,3 +1,3 @@
1
1
  module Consently
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/consently.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "time"
2
3
  require "active_support"
3
4
  require "active_support/core_ext/object/blank"
4
5
 
@@ -21,7 +21,9 @@ module Consently
21
21
  append_to_file index, <<~JS
22
22
 
23
23
  import ConsentlyBannerController from "consently/banner_controller"
24
+ import ConsentlyEmbedController from "consently/embed_controller"
24
25
  application.register("consently-banner", ConsentlyBannerController)
26
+ application.register("consently-embed", ConsentlyEmbedController)
25
27
  JS
26
28
  end
27
29
 
@@ -25,9 +25,16 @@ Consently.configure do |c|
25
25
  # not - switch it on if you want it treated the same way.
26
26
  # c.respect_do_not_track = true
27
27
 
28
- # Google consent mode v2: defaults denied before any Google tag, updated
29
- # when the visitor chooses. Leave on if you use anything Google.
30
- c.google_consent_mode = true
28
+ # Google consent mode v2. :basic keeps Google's tags off the page until the
29
+ # visitor agrees; :advanced loads them denied, so Ads can model refused
30
+ # conversions - more data, and a request to Google either way.
31
+ c.google_consent_mode = :basic
32
+
33
+ # One consent across subdomains needs the domain spelled out.
34
+ # c.cookie_domain = ".example.com"
35
+
36
+ # Ask again after a while, whatever the cookie's own lifetime says.
37
+ # c.consent_max_age = 12.months
31
38
 
32
39
  # Store a row per decision as proof of consent. Needs the migration from
33
40
  # `rails g consently:consent_log` and the engine mounted in routes.rb:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: consently
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michał Krzysteczko
@@ -28,15 +28,19 @@ description: 'Most Rails cookie banners ask for consent and then load Google Ana
28
28
  Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible or anything custom
29
29
  - and it renders every non-essential one as an inert script the browser will not
30
30
  even fetch, then turns them into live scripts the instant the visitor agrees, with
31
- no page reload. Google Consent Mode v2 defaults are emitted before any Google tag
32
- and updated on the click. The banner and its preferences panel ship translated into
33
- ten European languages, styled in plain CSS with no Tailwind and no build step,
34
- and driven by one Stimulus controller. Your cookie policy page is generated from
35
- the same configuration - every vendor, every cookie it sets and for how long - so
36
- it cannot drift out of date. Multi-tenant applications get per-domain or per-shop
37
- tag sets from a single initializer, and an optional consent log stores proof of
38
- each decision in your own database, with no third-party service and nothing leaving
39
- your infrastructure.'
31
+ no page reload. Embedded YouTube, Vimeo and Google Maps iframes wait behind a placeholder
32
+ of the same size and appear on consent too, because an iframe sets cookies with
33
+ no script of yours. Google Consent Mode v2 defaults are emitted before any Google
34
+ tag and updated on the click, in basic mode or in advanced mode where Google''s
35
+ own tags load denied so Ads can model refused conversions. GA4 ecommerce events
36
+ are built from your own line items rather than by hand. The banner and its preferences
37
+ panel ship translated into ten European languages, styled in plain CSS with no Tailwind
38
+ and no build step. Your cookie policy page is generated from the same configuration
39
+ - every vendor, every cookie it sets and for how long - so it cannot drift out of
40
+ date. Multi-tenant applications get per-domain or per-shop tag sets from a single
41
+ initializer, one consent can span subdomains, and an optional consent log stores
42
+ proof of each decision in your own database, with no third-party service and nothing
43
+ leaving your infrastructure.'
40
44
  email:
41
45
  - m.krzysteczko@icloud.com
42
46
  executables: []
@@ -53,9 +57,11 @@ files:
53
57
  - app/helpers/consently/application_helper.rb
54
58
  - app/helpers/consently/tags_helper.rb
55
59
  - app/javascript/consently/banner_controller.js
60
+ - app/javascript/consently/embed_controller.js
56
61
  - app/models/consently/application_record.rb
57
62
  - app/models/consently/consent_record.rb
58
63
  - app/views/consently/_banner.html.erb
64
+ - app/views/consently/_embed.html.erb
59
65
  - app/views/consently/_policy.html.erb
60
66
  - config/importmap.rb
61
67
  - config/locales/cs.yml