consently 0.1.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +279 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/consently.css +252 -0
  7. data/app/controllers/consently/application_controller.rb +4 -0
  8. data/app/controllers/consently/consents_controller.rb +44 -0
  9. data/app/helpers/consently/application_helper.rb +4 -0
  10. data/app/helpers/consently/tags_helper.rb +176 -0
  11. data/app/javascript/consently/banner_controller.js +190 -0
  12. data/app/models/consently/application_record.rb +5 -0
  13. data/app/models/consently/consent_record.rb +18 -0
  14. data/app/views/consently/_banner.html.erb +93 -0
  15. data/app/views/consently/_policy.html.erb +85 -0
  16. data/config/importmap.rb +3 -0
  17. data/config/locales/cs.yml +33 -0
  18. data/config/locales/de.yml +33 -0
  19. data/config/locales/en.yml +33 -0
  20. data/config/locales/es.yml +33 -0
  21. data/config/locales/fr.yml +33 -0
  22. data/config/locales/hu.yml +33 -0
  23. data/config/locales/it.yml +33 -0
  24. data/config/locales/nl.yml +33 -0
  25. data/config/locales/pl.yml +33 -0
  26. data/config/locales/sk.yml +33 -0
  27. data/config/routes.rb +5 -0
  28. data/lib/consently/configuration.rb +150 -0
  29. data/lib/consently/consent.rb +62 -0
  30. data/lib/consently/cookie.rb +12 -0
  31. data/lib/consently/engine.rb +33 -0
  32. data/lib/consently/providers/base.rb +95 -0
  33. data/lib/consently/providers/clarity.rb +34 -0
  34. data/lib/consently/providers/custom.rb +34 -0
  35. data/lib/consently/providers/google_ads.rb +32 -0
  36. data/lib/consently/providers/google_analytics.rb +45 -0
  37. data/lib/consently/providers/google_tag_manager.rb +34 -0
  38. data/lib/consently/providers/hotjar.rb +39 -0
  39. data/lib/consently/providers/meta_pixel.rb +37 -0
  40. data/lib/consently/providers/plausible.rb +30 -0
  41. data/lib/consently/providers.rb +9 -0
  42. data/lib/consently/script.rb +17 -0
  43. data/lib/consently/version.rb +3 -0
  44. data/lib/consently.rb +74 -0
  45. data/lib/generators/consently/consent_log/consent_log_generator.rb +28 -0
  46. data/lib/generators/consently/consent_log/templates/create_consently_consent_records.rb +19 -0
  47. data/lib/generators/consently/install/install_generator.rb +44 -0
  48. data/lib/generators/consently/install/templates/consently.rb +63 -0
  49. data/lib/generators/consently/views/views_generator.rb +15 -0
  50. data/lib/tasks/consently_tasks.rake +4 -0
  51. metadata +121 -0
@@ -0,0 +1,33 @@
1
+ en:
2
+ consently:
3
+ banner:
4
+ message: "We use cookies to run this site, and - with your consent - to understand how it is used and to show you relevant offers."
5
+ policy_link: "Read our cookie policy."
6
+ accept_all: "Accept all"
7
+ reject_all: "Reject all"
8
+ preferences: "Settings"
9
+ save: "Save choices"
10
+ cancel: "Cancel"
11
+ preferences_link: "Cookie settings"
12
+ policy:
13
+ granted: "you agreed"
14
+ denied: "not agreed"
15
+ no_tags: "Nothing in this category."
16
+ no_cookies: "Sets no cookies."
17
+ cookie_name: "Cookie"
18
+ cookie_duration: "Kept for"
19
+ duration_days: "%{count} days"
20
+ duration_session: "until you close the browser"
21
+ own_heading: "The consent cookie itself"
22
+ own_body: "Your choice is kept in one cookie in your browser: the categories you agreed to, the version of this policy and the time. Nothing about you."
23
+ own_version: "Policy version"
24
+ categories:
25
+ necessary:
26
+ name: "Necessary"
27
+ description: "Needed for the site to work. They cannot be switched off."
28
+ analytics:
29
+ name: "Analytics"
30
+ description: "Help us see how the site is used, so we can improve it."
31
+ marketing:
32
+ name: "Marketing"
33
+ description: "Let us show you offers that fit what you are interested in."
@@ -0,0 +1,33 @@
1
+ es:
2
+ consently:
3
+ banner:
4
+ message: "Usamos cookies para que este sitio funcione y, con tu consentimiento, para entender cómo se usa y mostrarte ofertas relevantes."
5
+ policy_link: "Lee nuestra política de cookies."
6
+ accept_all: "Aceptar todo"
7
+ reject_all: "Rechazar todo"
8
+ preferences: "Ajustes"
9
+ save: "Guardar selección"
10
+ cancel: "Cancelar"
11
+ preferences_link: "Ajustes de cookies"
12
+ policy:
13
+ granted: "consentido"
14
+ denied: "sin consentimiento"
15
+ no_tags: "Nada en esta categoría."
16
+ no_cookies: "No instala cookies."
17
+ cookie_name: "Cookie"
18
+ cookie_duration: "Se conserva"
19
+ duration_days: "%{count} días"
20
+ duration_session: "hasta que cierres el navegador"
21
+ own_heading: "La propia cookie de consentimiento"
22
+ own_body: "Tu elección se guarda en una cookie de tu navegador: las categorías que aceptaste, la versión de esta política y la hora. Nada sobre ti."
23
+ own_version: "Versión de la política"
24
+ categories:
25
+ necessary:
26
+ name: "Necesarias"
27
+ description: "Imprescindibles para que el sitio funcione. No se pueden desactivar."
28
+ analytics:
29
+ name: "Analíticas"
30
+ description: "Nos muestran cómo se usa el sitio para poder mejorarlo."
31
+ marketing:
32
+ name: "Marketing"
33
+ description: "Nos permiten mostrarte ofertas acordes con tus intereses."
@@ -0,0 +1,33 @@
1
+ fr:
2
+ consently:
3
+ banner:
4
+ message: "Nous utilisons des cookies pour faire fonctionner ce site et, avec votre accord, pour comprendre comment il est utilisé et vous proposer des offres pertinentes."
5
+ policy_link: "Consulter notre politique de cookies."
6
+ accept_all: "Tout accepter"
7
+ reject_all: "Tout refuser"
8
+ preferences: "Paramètres"
9
+ save: "Enregistrer mes choix"
10
+ cancel: "Annuler"
11
+ preferences_link: "Paramètres des cookies"
12
+ policy:
13
+ granted: "accepté"
14
+ denied: "non accepté"
15
+ no_tags: "Rien dans cette catégorie."
16
+ no_cookies: "Ne dépose aucun cookie."
17
+ cookie_name: "Cookie"
18
+ cookie_duration: "Conservé"
19
+ duration_days: "%{count} jours"
20
+ duration_session: "jusqu'à la fermeture du navigateur"
21
+ own_heading: "Le cookie de consentement lui-même"
22
+ own_body: "Votre choix tient dans un cookie de votre navigateur : les catégories acceptées, la version de cette politique et l'heure. Rien sur vous."
23
+ own_version: "Version de la politique"
24
+ categories:
25
+ necessary:
26
+ name: "Nécessaires"
27
+ description: "Indispensables au fonctionnement du site. Ils ne peuvent pas être désactivés."
28
+ analytics:
29
+ name: "Mesure d'audience"
30
+ description: "Nous montrent comment le site est utilisé, afin de l'améliorer."
31
+ marketing:
32
+ name: "Marketing"
33
+ description: "Nous permettent de vous proposer des offres correspondant à vos centres d'intérêt."
@@ -0,0 +1,33 @@
1
+ hu:
2
+ consently:
3
+ banner:
4
+ message: "Sütiket használunk, hogy az oldal működjön, és - a hozzájárulásoddal - hogy megértsük, hogyan használod, illetve releváns ajánlatokat mutassunk."
5
+ policy_link: "Olvasd el a süti szabályzatunkat."
6
+ accept_all: "Összes elfogadása"
7
+ reject_all: "Összes elutasítása"
8
+ preferences: "Beállítások"
9
+ save: "Választás mentése"
10
+ cancel: "Mégse"
11
+ preferences_link: "Süti beállítások"
12
+ policy:
13
+ granted: "hozzájárultál"
14
+ denied: "nincs hozzájárulás"
15
+ no_tags: "Ebben a kategóriában nincs semmi."
16
+ no_cookies: "Nem helyez el sütit."
17
+ cookie_name: "Süti"
18
+ cookie_duration: "Megőrzés"
19
+ duration_days: "%{count} nap"
20
+ duration_session: "a böngésző bezárásáig"
21
+ own_heading: "Maga a hozzájárulási süti"
22
+ own_body: "A választásod egyetlen sütiben van a böngésződben: az elfogadott kategóriák, e szabályzat verziója és az idő. Rólad semmi."
23
+ own_version: "Szabályzat verziója"
24
+ categories:
25
+ necessary:
26
+ name: "Szükséges"
27
+ description: "Az oldal működéséhez kellenek. Nem kapcsolhatók ki."
28
+ analytics:
29
+ name: "Statisztikai"
30
+ description: "Megmutatják, hogyan használod az oldalt, hogy fejleszthessük."
31
+ marketing:
32
+ name: "Marketing"
33
+ description: "Segítenek olyan ajánlatokat mutatni, amelyek érdekelhetnek."
@@ -0,0 +1,33 @@
1
+ it:
2
+ consently:
3
+ banner:
4
+ message: "Usiamo i cookie per far funzionare questo sito e, con il tuo consenso, per capire come viene usato e mostrarti offerte pertinenti."
5
+ policy_link: "Leggi la nostra cookie policy."
6
+ accept_all: "Accetta tutto"
7
+ reject_all: "Rifiuta tutto"
8
+ preferences: "Impostazioni"
9
+ save: "Salva le scelte"
10
+ cancel: "Annulla"
11
+ preferences_link: "Impostazioni cookie"
12
+ policy:
13
+ granted: "consenso dato"
14
+ denied: "nessun consenso"
15
+ no_tags: "Niente in questa categoria."
16
+ no_cookies: "Non imposta cookie."
17
+ cookie_name: "Cookie"
18
+ cookie_duration: "Conservato"
19
+ duration_days: "%{count} giorni"
20
+ duration_session: "fino alla chiusura del browser"
21
+ own_heading: "Il cookie di consenso"
22
+ own_body: "La tua scelta sta in un cookie nel tuo browser: le categorie accettate, la versione di questa policy e l'ora. Nulla su di te."
23
+ own_version: "Versione della policy"
24
+ categories:
25
+ necessary:
26
+ name: "Necessari"
27
+ description: "Servono al funzionamento del sito. Non possono essere disattivati."
28
+ analytics:
29
+ name: "Statistici"
30
+ description: "Ci mostrano come viene usato il sito, così possiamo migliorarlo."
31
+ marketing:
32
+ name: "Marketing"
33
+ description: "Ci permettono di mostrarti offerte in linea con i tuoi interessi."
@@ -0,0 +1,33 @@
1
+ nl:
2
+ consently:
3
+ banner:
4
+ message: "We gebruiken cookies om deze site te laten werken en, met jouw toestemming, om te begrijpen hoe de site gebruikt wordt en je relevante aanbiedingen te tonen."
5
+ policy_link: "Lees ons cookiebeleid."
6
+ accept_all: "Alles accepteren"
7
+ reject_all: "Alles weigeren"
8
+ preferences: "Instellingen"
9
+ save: "Keuze opslaan"
10
+ cancel: "Annuleren"
11
+ preferences_link: "Cookie-instellingen"
12
+ policy:
13
+ granted: "toegestaan"
14
+ denied: "niet toegestaan"
15
+ no_tags: "Niets in deze categorie."
16
+ no_cookies: "Plaatst geen cookies."
17
+ cookie_name: "Cookie"
18
+ cookie_duration: "Bewaard"
19
+ duration_days: "%{count} dagen"
20
+ duration_session: "tot je de browser sluit"
21
+ own_heading: "De toestemmingscookie zelf"
22
+ own_body: "Je keuze staat in één cookie in je browser: de categorieën die je toestond, de versie van dit beleid en het tijdstip. Niets over jou."
23
+ own_version: "Versie van het beleid"
24
+ categories:
25
+ necessary:
26
+ name: "Noodzakelijk"
27
+ description: "Nodig om de site te laten werken. Deze kun je niet uitzetten."
28
+ analytics:
29
+ name: "Statistieken"
30
+ description: "Laten ons zien hoe de site gebruikt wordt, zodat we hem kunnen verbeteren."
31
+ marketing:
32
+ name: "Marketing"
33
+ description: "Hiermee kunnen we je aanbiedingen tonen die bij je interesses passen."
@@ -0,0 +1,33 @@
1
+ pl:
2
+ consently:
3
+ banner:
4
+ message: "Używamy plików cookie, żeby ta strona działała, a za Twoją zgodą także po to, by rozumieć, jak z niej korzystasz, i pokazywać Ci trafne oferty."
5
+ policy_link: "Przeczytaj naszą politykę cookies."
6
+ accept_all: "Akceptuję wszystkie"
7
+ reject_all: "Odrzuć wszystkie"
8
+ preferences: "Ustawienia"
9
+ save: "Zapisz wybór"
10
+ cancel: "Anuluj"
11
+ preferences_link: "Ustawienia cookies"
12
+ policy:
13
+ granted: "zgoda udzielona"
14
+ denied: "brak zgody"
15
+ no_tags: "Nic w tej kategorii."
16
+ no_cookies: "Nie ustawia ciasteczek."
17
+ cookie_name: "Ciasteczko"
18
+ cookie_duration: "Przechowywane"
19
+ duration_days: "%{count} dni"
20
+ duration_session: "do zamknięcia przeglądarki"
21
+ own_heading: "Samo ciasteczko zgody"
22
+ own_body: "Twój wybór trzymamy w jednym ciasteczku w Twojej przeglądarce: kategorie, na które się zgodziłeś, wersja tej polityki i czas. Nic o Tobie."
23
+ own_version: "Wersja polityki"
24
+ categories:
25
+ necessary:
26
+ name: "Niezbędne"
27
+ description: "Potrzebne do działania strony. Nie można ich wyłączyć."
28
+ analytics:
29
+ name: "Analityczne"
30
+ description: "Pokazują nam, jak korzystasz ze strony, dzięki czemu możemy ją ulepszać."
31
+ marketing:
32
+ name: "Marketingowe"
33
+ description: "Pozwalają pokazywać Ci oferty dopasowane do Twoich zainteresowań."
@@ -0,0 +1,33 @@
1
+ sk:
2
+ consently:
3
+ banner:
4
+ message: "Používame súbory cookie, aby táto stránka fungovala, a s vaším súhlasom aj na to, aby sme rozumeli, ako ju používate, a mohli vám ukazovať relevantné ponuky."
5
+ policy_link: "Prečítajte si naše zásady cookies."
6
+ accept_all: "Prijať všetko"
7
+ reject_all: "Odmietnuť všetko"
8
+ preferences: "Nastavenia"
9
+ save: "Uložiť voľbu"
10
+ cancel: "Zrušiť"
11
+ preferences_link: "Nastavenia cookies"
12
+ policy:
13
+ granted: "súhlas udelený"
14
+ denied: "bez súhlasu"
15
+ no_tags: "V tejto kategórii nič nie je."
16
+ no_cookies: "Nenastavuje žiadne cookies."
17
+ cookie_name: "Cookie"
18
+ cookie_duration: "Doba uloženia"
19
+ duration_days: "%{count} dní"
20
+ duration_session: "do zatvorenia prehliadača"
21
+ own_heading: "Samotná cookie so súhlasom"
22
+ own_body: "Vaša voľba je uložená v jednej cookie vo vašom prehliadači: kategórie, s ktorými ste súhlasili, verzia týchto zásad a čas. Nič o vás."
23
+ own_version: "Verzia zásad"
24
+ categories:
25
+ necessary:
26
+ name: "Nevyhnutné"
27
+ description: "Potrebné na fungovanie stránky. Nedajú sa vypnúť."
28
+ analytics:
29
+ name: "Analytické"
30
+ description: "Ukazujú nám, ako stránku používate, aby sme ju mohli zlepšovať."
31
+ marketing:
32
+ name: "Marketingové"
33
+ description: "Umožňujú nám ukazovať vám ponuky, ktoré zodpovedajú vašim záujmom."
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Consently::Engine.routes.draw do
2
+ # Only needed when config.log_consents is on. Mount the engine and the
3
+ # banner will POST each decision here as proof of consent.
4
+ resource :consents, only: :create
5
+ end
@@ -0,0 +1,150 @@
1
+ module Consently
2
+ # Everything the host application sets in config/initializers/consently.rb.
3
+ class Configuration
4
+ DEFAULT_CATEGORIES = %i[necessary analytics marketing].freeze
5
+
6
+ # Where the visitor's choice is kept. It is read by JavaScript, so it is a
7
+ # plain cookie rather than a signed one.
8
+ attr_accessor :cookie_name, :cookie_max_age, :cookie_path
9
+
10
+ # Bump this whenever the policy changes: an older consent stops counting
11
+ # and the banner asks again.
12
+ attr_accessor :consent_version
13
+
14
+ # true, false, or a callable taking the request - e.g.
15
+ # c.enabled = ->(request) { Rails.env.production? }
16
+ attr_accessor :enabled
17
+
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
22
+
23
+ # Store a row per decision, as proof of consent. Needs the engine mounted
24
+ # and the migration from `rails g consently:consent_log`.
25
+ attr_accessor :log_consents
26
+
27
+ # Whether consently_tags links the banner's stylesheet. Turn it off if
28
+ # you have taken the views over and styled them yourself.
29
+ attr_accessor :stylesheet
30
+
31
+ # Honour the browser's Do Not Track header as a rejection. Off by default:
32
+ # DNT is advisory and widely ignored, so treating it as a legal signal is
33
+ # your call, not the gem's.
34
+ attr_accessor :respect_do_not_track
35
+
36
+ # Global Privacy Control, which - unlike DNT - is a binding opt-out signal
37
+ # in California and Colorado. On by default, and a visitor who sends it is
38
+ # never shown the banner: their answer already arrived.
39
+ attr_accessor :respect_global_privacy_control
40
+
41
+ # Whether this visitor has to be asked at all. false means no banner and
42
+ # everything runs - the answer for traffic outside the EU when your legal
43
+ # advice says so:
44
+ #
45
+ # c.consent_required = ->(request) { EU_COUNTRIES.include?(request.headers["CF-IPCountry"]) }
46
+ #
47
+ # Careful: this switches tags on without asking, so it is opt-in.
48
+ attr_accessor :consent_required
49
+
50
+ # Reload the page once a choice is made. Off by default - releasing the
51
+ # blocked tags in place is the whole point, and a reload throws away
52
+ # whatever the visitor was doing. Turn it on when the page itself renders
53
+ # differently depending on consent (an embedded map, a video, a status
54
+ # list) and you would rather let the server decide again.
55
+ attr_accessor :reload_after_choice
56
+
57
+ # Who made the decision, for the consent log. A callable taking the
58
+ # request; return anything that identifies the visitor in your own system
59
+ # (a global id, "User#42", an account number). Left nil the log stays
60
+ # anonymous, which is the right default for a public site.
61
+ attr_accessor :consent_subject
62
+
63
+ # Where the cookie policy lives. A string, or a callable taking the view
64
+ # context - handy when the URL is locale dependent.
65
+ attr_accessor :policy_url
66
+
67
+ # Which scope a request belongs to, e.g. ->(request) { request.host }.
68
+ # Nil means every request sees the default configuration.
69
+ attr_accessor :scope_resolver
70
+
71
+ attr_reader :categories
72
+
73
+ def initialize
74
+ @cookie_name = "consently"
75
+ @cookie_max_age = 60 * 60 * 24 * 180 # six months, the usual guidance
76
+ @cookie_path = "/"
77
+ @consent_version = 1
78
+ @enabled = true
79
+ @google_consent_mode = true
80
+ @stylesheet = true
81
+ @log_consents = false
82
+ @respect_do_not_track = false
83
+ @respect_global_privacy_control = true
84
+ @consent_required = true
85
+ @reload_after_choice = false
86
+ @consent_subject = nil
87
+ @policy_url = nil
88
+ @scope_resolver = nil
89
+ @categories = DEFAULT_CATEGORIES.dup
90
+ @scopes = {}
91
+ end
92
+
93
+ # A category of your own, shown in the preferences panel alongside the
94
+ # built-in ones. Give it a name in your locale file.
95
+ def category(name)
96
+ name = name.to_sym
97
+ @categories << name unless @categories.include?(name)
98
+ name
99
+ end
100
+
101
+ def optional_categories
102
+ categories - [ Consent::NECESSARY ]
103
+ end
104
+
105
+ # c.tag :google_analytics, id: "G-XXXX"
106
+ def tag(key, **options)
107
+ default_scope.tag(key, **options)
108
+ end
109
+
110
+ # Tags for one shop, host or whatever your scope_resolver returns. Falls
111
+ # back to the tags declared outside any scope, and may override them by
112
+ # declaring the same provider again.
113
+ #
114
+ # c.scope "trixbrix" do |s|
115
+ # s.tag :google_analytics, id: "G-TRIX"
116
+ # end
117
+ def scope(name)
118
+ scope = (@scopes[name.to_s] ||= Scope.new)
119
+ yield scope if block_given?
120
+ scope
121
+ end
122
+
123
+ def tags_for(scope_name = nil)
124
+ tags = default_scope.tags.dup
125
+ tags.merge!(@scopes[scope_name.to_s].tags) if scope_name && @scopes.key?(scope_name.to_s)
126
+ tags.values
127
+ end
128
+
129
+ def default_scope
130
+ @default_scope ||= Scope.new
131
+ end
132
+
133
+ # A named bundle of tags. Same API as the top-level `tag`, which is what
134
+ # makes the block form read the way it does.
135
+ class Scope
136
+ def initialize
137
+ @tags = {}
138
+ end
139
+
140
+ def tag(key, **options)
141
+ provider = Providers::Base.build(key, **options)
142
+ @tags[provider.key] = provider
143
+ end
144
+
145
+ def tags
146
+ @tags
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,62 @@
1
+ module Consently
2
+ # What the visitor has agreed to, as read from the cookie.
3
+ #
4
+ # The cookie is deliberately tiny and readable by JavaScript - the banner
5
+ # writes it in the browser and the server only reads it, so a page rendered
6
+ # after the choice can emit the real tags instead of blocked ones.
7
+ #
8
+ # {"v":1,"c":["analytics"],"t":"2026-08-12T10:00:00Z"}
9
+ class Consent
10
+ NECESSARY = :necessary
11
+
12
+ attr_reader :categories, :version, :recorded_at
13
+
14
+ def self.none
15
+ new(given: false)
16
+ end
17
+
18
+ # Never raises: a cookie can be truncated, hand-edited or left over from an
19
+ # older format, and none of that should take a page down.
20
+ def self.from_cookie(raw, version:)
21
+ return none if raw.blank?
22
+
23
+ data = begin
24
+ JSON.parse(raw.to_s)
25
+ rescue JSON::ParserError
26
+ nil
27
+ end
28
+ return none unless data.is_a?(Hash)
29
+
30
+ # A consent given against an older policy version counts as no consent:
31
+ # the banner asks again and nothing runs in the meantime.
32
+ return none unless data["v"].to_s == version.to_s
33
+
34
+ new(
35
+ categories: Array(data["c"]).map { |category| category.to_s.to_sym },
36
+ version: data["v"],
37
+ recorded_at: data["t"]
38
+ )
39
+ end
40
+
41
+ def initialize(categories: [], version: nil, recorded_at: nil, given: true)
42
+ @categories = Array(categories).map(&:to_sym).freeze
43
+ @version = version
44
+ @recorded_at = recorded_at
45
+ @given = given
46
+ end
47
+
48
+ def given?
49
+ @given
50
+ end
51
+
52
+ # Necessary tags are the ones the site cannot work without, so they never
53
+ # wait for a click.
54
+ def granted?(category)
55
+ category.to_sym == NECESSARY || categories.include?(category.to_sym)
56
+ end
57
+
58
+ def to_h
59
+ { "v" => version, "c" => categories.map(&:to_s), "t" => recorded_at }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,12 @@
1
+ module Consently
2
+ # One cookie a tag is known to set. A cookie policy has to name them and say
3
+ # how long they last, which is exactly the part everyone copies from a
4
+ # competitor's site and gets wrong.
5
+ #
6
+ # `days` is nil for a session cookie.
7
+ Cookie = Struct.new(:name, :days, :provider, keyword_init: true) do
8
+ def session?
9
+ days.nil?
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ module Consently
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Consently
4
+
5
+ initializer "consently.helpers" do
6
+ ActiveSupport.on_load(:action_view) do
7
+ include Consently::TagsHelper
8
+ end
9
+ end
10
+
11
+ # The engine's app/assets comes along on its own; app/javascript does not,
12
+ # and Sprockets needs the two entries named before it will compile them.
13
+ initializer "consently.assets" do |app|
14
+ if app.config.respond_to?(:assets)
15
+ app.config.assets.paths << root.join("app/javascript")
16
+ if app.config.assets.respond_to?(:precompile)
17
+ app.config.assets.precompile += %w[consently.css consently/banner_controller.js]
18
+ end
19
+ end
20
+ end
21
+
22
+ # The banner controller gets pinned for the host application, so
23
+ # `import "consently/banner_controller"` resolves without anyone editing
24
+ # config/importmap.rb. Our paths have to be in place before importmap-rails
25
+ # draws them, hence `before:`.
26
+ initializer "consently.importmap", before: "importmap" do |app|
27
+ if app.config.respond_to?(:importmap)
28
+ app.config.importmap.paths << root.join("config/importmap.rb")
29
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,95 @@
1
+ module Consently
2
+ module Providers
3
+ # A tag vendor: what scripts it needs and which consent category it falls
4
+ # under. Subclasses describe the snippet as data (see Consently::Script)
5
+ # so the view can render it live or blocked from the same description.
6
+ class Base
7
+ # Ids are interpolated straight into JavaScript, so they are held to a
8
+ # shape that cannot carry a quote or a bracket out of the string.
9
+ SAFE_OPTION = /\A[\w\-.:\/]+\z/
10
+
11
+ class << self
12
+ def registry
13
+ @@registry ||= {}
14
+ end
15
+
16
+ def register(key, klass)
17
+ registry[key.to_sym] = klass
18
+ end
19
+
20
+ def build(key, **options)
21
+ klass = registry[key.to_sym] or raise UnknownProvider, key
22
+ klass.new(**options)
23
+ end
24
+
25
+ # Set by each subclass; where a vendor is unambiguous (Plausible does
26
+ # not touch cookies) it can say :necessary and load right away.
27
+ attr_accessor :default_category
28
+
29
+ def provider_key
30
+ @provider_key
31
+ end
32
+
33
+ def provider_key=(key)
34
+ @provider_key = key.to_sym
35
+ register(@provider_key, self)
36
+ end
37
+
38
+ # What this vendor stores in the visitor's browser, for the policy
39
+ # page. `days: nil` means a session cookie.
40
+ def cookie(name, days: nil)
41
+ cookie_manifest << { name: name, days: days }
42
+ end
43
+
44
+ def cookie_manifest
45
+ @cookie_manifest ||= []
46
+ end
47
+ end
48
+
49
+ attr_reader :options, :category, :key
50
+
51
+ def initialize(**options)
52
+ @options = options
53
+ @category = (options[:category] || self.class.default_category).to_sym
54
+ @key = (options[:as] || self.class.provider_key).to_sym
55
+ validate!
56
+ end
57
+
58
+ # Array<Consently::Script>
59
+ def scripts
60
+ []
61
+ end
62
+
63
+ # Array<Consently::Cookie> - what this tag leaves in the browser.
64
+ def cookies
65
+ self.class.cookie_manifest.map { |attributes| Cookie.new(**attributes, provider: key) }
66
+ end
67
+
68
+ # Rendered inside <body>, for vendors that still ship a <noscript>
69
+ # fallback (Google Tag Manager, Meta). HTML string or nil.
70
+ def noscript
71
+ nil
72
+ end
73
+
74
+ def to_s
75
+ "#{key} (#{category})"
76
+ end
77
+
78
+ private
79
+
80
+ def validate!
81
+ # Subclasses call require_option for what they cannot work without.
82
+ end
83
+
84
+ def require_option(name)
85
+ value = options[name]
86
+ raise MissingOption, "Consently: #{self.class.provider_key} needs a #{name}:" if value.blank?
87
+ unless value.to_s.match?(SAFE_OPTION)
88
+ raise MissingOption, "Consently: #{name}: #{value.inspect} for #{self.class.provider_key} contains characters that are not allowed in a tag id"
89
+ end
90
+
91
+ value.to_s
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,34 @@
1
+ module Consently
2
+ module Providers
3
+ # Microsoft Clarity: session recordings and heatmaps.
4
+ class Clarity < Base
5
+ self.provider_key = :clarity
6
+ self.default_category = :analytics
7
+
8
+ cookie "_clck", days: 365
9
+ cookie "_clsk", days: 1
10
+ cookie "CLID", days: 365
11
+ cookie "MUID", days: 390
12
+ cookie "ANONCHK", days: nil
13
+
14
+ def scripts
15
+ [ Script.new(inline: <<~JS.strip) ]
16
+ (function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
17
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
18
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
19
+ })(window, document, "clarity", "script", "#{id}");
20
+ JS
21
+ end
22
+
23
+ private
24
+
25
+ def id
26
+ @id ||= require_option(:id)
27
+ end
28
+
29
+ def validate!
30
+ id
31
+ end
32
+ end
33
+ end
34
+ end