The cookie answer of the omnibase sites, written once: a small circle that opens a panel - what the site keeps, accept, refuse, customize - instead of a copy of the same controller in every site.
The visitor answers feature by feature. Whatever a page keeps in the browser
belongs to a feature: what the site cannot work without (NECESSARY, always on),
the visitor's preferences (USER), the time zone (LOCATION), a chat, a measure
of audience, Instagram. "Personnaliser" opens the detailed section: one row per
feature, its switch, and what it keeps - each cookie by name, how long, what for.
The answer is enforced: a refused feature writes nothing, what it had kept is
erased, and a tracker never starts without a yes.
The work in the browser is done by
@glitchr/flowjs/cookie
(the Cookie global omnibase loads on every page): the answer and where it is
kept, asking again, the countdown, the detailed section, the erasing. The bundle
gives it the site's configuration and words, the markup, and window.Consent for
the scripts. The PHP side (Base\Consent\ConsentPanel) needs no framework; under
Symfony it is a bundle.
Use it on any omnibase site that keeps something in the visitor's browser beyond what it cannot work without - that is, every site with a preference, a time zone, a chat or a measure of audience.
How this manual is organised
- Installation: requirements, the bundle, the partial, the JavaScript it needs.
- Without Symfony:
ConsentPanel,ConsentConfig, the Twig functions in any environment, the Laravel twin. - Features and the answer: what a feature is, the bundle's own, what each kind does, the countdown, the dismissal, the reopening, the privacy signals, where the answer is kept.
- In a script and in the markup:
window.Consent, the events, the elements that wait for a feature. - Look and words: the stylesheet's properties and states, the translations.
- Configuration reference: every option of
consent:. - Testing: the JavaScript and PHP suites, the harness.
Propose a change docs/index.md
Installation
Requirements
composer.json requires PHP 8.2 or later, omnisense/omnibase 3.0 or 4.0 and
twig/twig 3. Under Symfony the bundle needs omnibridge/symfony (suggested; an
omnibase site has it, BaseBundle refuses to start without it). In the browser,
the page needs @glitchr/flowjs 1.2.0 or later for its Cookie global: omnibase's
base-async script imports it.
Under Symfony
composer require omnibase/consent:dev-main
// config/bundles.php
return [
// ...
Base\Consent\ConsentBundle::class => ['all' => true],
];
Base\Consent\ConsentBundle is the name the sites register; it extends
Base\Consent\Bridge\Symfony\ConsentBundle, whose root is the package's
(templates/ is @Consent, public/ is installed under bundles/consent/,
translations/ holds the consent domain). The extension registers one service,
the Twig extension (Base\Consent\Bridge\Symfony\Twig\ConsentExtension, also known
as Base\Consent\Twig\ConsentExtension), and the parameters consent.config (the
processed tree), consent.defaults (the bundle's features) and one per option
(consent.countdown...).
No configuration is needed: the bundle starts from its own features (Features and the answer).
The partial
Include it where the circle goes - a footer, a tool bar:
{% include '@Consent/_cookie.html.twig' with {place: 'footer'} %}
{# elsewhere on the page #}
<button type="button" data-consent-open>Cookies</button>
| Variable | Default | Effect |
|---|---|---|
place |
'footer' |
Tells two circles of a page apart: the panel's id is ga-cookie-<place> |
floating |
true |
false: this circle never floats by itself (another one of the page does) |
icon |
a cookie drawn in SVG | The circle's content (HTML, printed raw) |
The partial prints the stylesheet (bundles/consent/css/consent.css), the circle
and its panel, the settings as <script type="application/json" data-consent-settings> (consent_settings()), and two deferred scripts,
consent-legacy.js then consent.js. Anything with data-consent-open opens the
panel.
On a page that still packs @glitchr/cookie (an older core) instead of
@glitchr/flowjs, consent.js hands over to consent-legacy.js: the panel as it
was before the detailed section, reading the data-consent-* attributes of the
circle.
Propose a change docs/installation.md
Without Symfony
The answer itself loads without the framework: Base\Consent\ConsentPanel (the
features and what each keeps, the countdown, the words, options(), settings(),
json()), Base\Consent\ConsentConfig (a configuration made whole) and
Base\Consent\Bridge\Twig\ConsentExtension (the partial's two Twig functions).
They need Twig and a translator (symfony/translation's contract), nothing of
Symfony's framework; tests/BareTest.php loads them with Composer's autoloader
alone (tests/Fondation.php).
The panel
use Base\Consent\Bridge\Twig\ConsentExtension;
use Base\Consent\ConsentConfig;
use Base\Consent\ConsentPanel;
$panel = new ConsentPanel(
ConsentConfig::normalize($site), // the site's `consent:` array, made whole
$translator, // the `consent` domain in the visitor's language
ConsentPanel::features(), // the bundle's own features (config/features.php)
fn (string $route, array $parameters) => $router->url($route, $parameters), // the privacy page's address
fn () => session_name(), // the session cookie's name; null: PHP's own
);
$twig->addExtension(new ConsentExtension($panel));
| Constructor argument | Type | Effect |
|---|---|---|
$config |
array | The configuration as ConsentConfiguration processes it, or as ConsentConfig::normalize() makes it whole |
$translator |
Symfony\Contracts\Translation\TranslatorInterface |
The words (domain consent+intl-icu); when it is also a TranslatorBagInterface, the catalogue's names tell which features have words |
$defaults |
array | The bundle's features, laid under the site's (consent.defaults: true) |
$urls |
?Closure(string, array): string |
A route's address; none: no privacy link |
$session |
?Closure(): ?string |
The session cookie's name ({session} in a storage entry) |
| Method | Returns |
|---|---|
ConsentPanel::features() |
The bundle's features (config/features.php) |
options() |
What the partial prints: labels, groups, features, countdown, action, dismiss, ignore, erase, privacy |
settings() |
The configuration of @glitchr/flowjs/cookie: reopen, dismiss, ignore, countdown, record, signals, categories, texts, consent, position, version, and options underneath |
json() |
settings() as JSON with no < left, for a <script type="application/json"> |
ConsentConfig::normalize(array $site) gives what the Symfony tree gives: the keys
written first, then the others with their defaults (ConsentConfig::DEFAULTS,
FEATURE, STORAGE, SIGNALS, the tree's own defaults), a feature written
NAME: ~ or NAME: false made an entry, a storage entry written as a name made
whole. It does not validate (an unknown key, a negative countdown): the Symfony
site's tree does.
Bridge\Twig\ConsentExtension adds consent_options() and consent_settings()
(safe HTML); panel() gives the panel back.
Run in the core's harness
tests/bare/consent-without-framework.php builds the panel on the bundle's French
words (symfony/translation's own Translator, the YAML read by symfony/yaml), a
closure answering /confidentialite for app_privacy and one answering LRSID,
and renders the two functions in a plain Twig environment. Run with the harness's
autoloader and this checkout's classes (tests/bare/autoload.php), it returns:
array (
'categories' =>
array (
0 => 'NECESSARY',
1 => 'USER',
2 => 'LOCATION',
3 => 'ANALYTICS',
4 => 'CHAT',
5 => 'SOCIAL',
),
'necessary' => 'Le fonctionnement du site',
'session' => true,
'countdown' =>
array (
'duration' => 10,
'action' => 'default',
),
'privacy' => true,
'printed' => '/confidentialite|USER|',
'json' => true,
)
and the panel's first category, settings()['categories'][0]:
{
"key": "NECESSARY",
"label": "Le fonctionnement du site",
"description": "Votre session, la langue du site, la protection des formulaires, votre réponse sur les cookies.",
"default": false,
"required": true,
"onDemand": false,
"storage": [
{ "type": "cookie", "name": "LRSID", "duration": "session", "purpose": "Votre session : votre connexion, un formulaire en cours." },
{ "type": "cookie", "name": "LANG", "duration": "session", "purpose": "La langue dans laquelle le site vous est montré." },
{ "type": "cookie", "name": "*csrf-token*", "duration": "session", "purpose": "Un jeton qui protège l'envoi des formulaires." },
{ "type": "cookie", "name": "REMEMBERME", "purpose": "« Rester connecté » : votre compte reconnu d'une visite à l'autre." },
{ "type": "cookie", "name": "cookie-consent", "duration": "180d", "purpose": "Votre réponse à cette question." }
]
}
(the JSON re-indented; {session} became LRSID, {answer} cookie-consent,
{lifetime} 180d).
Laravel
omnibridge/laravel's Omnibridge\Laravel\View\OmnibaseExtensions adds the extension
to the views' Twig environment when omnibridge.consent.config is set (an array,
or the path of the Symfony site's config/packages/consent.yaml, whose consent
key it reads) and the package is installed: ConsentConfig::normalize() on it,
the page's catalogue translator, ConsentPanel::features(), and Laravel's
addresses (Urls::path()) for the privacy page. The session's name is not given
there ({session} is PHP's session_name()).
Under Symfony
src/Bridge/Symfony holds the bundle, its configuration and extension, and the
Twig extension built from the container (the site's consent: tree, the
translator, omnibase's router for the privacy page in the visitor's language, the
main request's session for its cookie's name). The former names still answer:
Base\Consent\ConsentBundle, Base\Consent\Twig\ConsentExtension (the service's
id an alias), Base\Consent\DependencyInjection\*.
Propose a change docs/without-symfony.md
Features and the answer
A feature
A feature is what the visitor answers for, under an upper-case name - a cookie
group (USER, CHAT, ANALYTICS). In the browser it is a category of
@glitchr/flowjs/cookie: one yes or no, a switch in the detailed section, and what
it keeps. A feature has four flags and two lists:
| Field | Effect |
|---|---|
required |
Always on, its switch locked: never asked (the session, a basket) |
implicit |
On until refused, and on when the visitor does not answer. Keep it for what the site needs to work; a tracker waits for a yes |
on_demand |
Known (its words and what it keeps are ready) but listed only once a script or an element of the page uses it, and never a reason to ask again |
inherits |
The feature it was split from: a visitor who answered for that one is not asked again, this one takes the same answer |
erase |
Name prefixes of cookies deleted when it goes off (crisp-client) |
storage |
What it keeps: {name, type, duration, purpose, path, domain} entries, listed in the detailed section, erased at a refusal, written through the answer |
A feature that is none of required, implicit is off until the visitor says yes.
The bundle's features
config/features.php, what every site starts from (consent.defaults: true):
| Feature | Kind | Keeps |
|---|---|---|
NECESSARY |
required | {session} (the session cookie, for the session), LANG (session), *csrf-token* (session), REMEMBERME, {answer} (the answer itself, for {lifetime}) |
USER |
implicit (the site's own group) | USER/INFO (30 days: the browser's language, calendar, light or dark theme), USER/THEME/*, USER/LAST_VISIT |
LOCATION |
implicit, inherits USER |
LOCATION/TIMEZONE (30 days) |
ANALYTICS |
on demand | ANALYTICS/VISITOR_ID (13 months) |
CHAT |
on demand | |
SOCIAL |
on demand |
In a storage name, {session} is the session cookie's name, {answer} the name
the answer is kept under (cookie-consent unless options.record.name), and in a
duration {lifetime} is the answer's (consent.lifetime).
How a site's features are made
ConsentPanel resolves the features in this order:
- the bundle's (unless
defaults: false); - the site's groups (
consent.groups,[USER]by default): implicit unless said otherwise, never on demand; the first is the site's own feature (whatConsent.refused()answers for, the circle's colour), anderase_on_refuseadds to itserase; - the site's
features, laid over the bundle's field by field: a feature the site declares is listed (not on demand, unless it says so);NAME: falsetakes one out; itseraseadds to the bundle's; a storage entry replaces the bundle's of the same type and name; - any feature the translations name (
consent.features.<name>.*) and nobody took out, on demand: a script that declares it later gets its words.
A feature inherits only when the feature it names exists. Scripts add features as
they run (In a script).
The question, the countdown and the dismissal
A visitor who has not answered sees the panel float (at consent.position,
bottom-left by default) with a countdown of consent.countdown seconds (10).
When the time is up, consent.countdown_action answers:
countdown_action |
No answer is |
|---|---|
default |
each feature its default: the implicit ones on, the rest off |
accept |
every feature on |
refuse |
every feature off but the required ones; the panel says so ("c'est refusé dans 10 s", implicit_refuse) |
countdown: 0 waits for an answer. A click elsewhere while the question floats
answers consent.dismiss: none (the default: nothing, the question stays),
default, accept or refuse. A click on Symfony's web debug toolbar
(.sf-toolbar, [id^="sfwdt"], [id^="sfToolbar"]), a dump() (.sf-dump), or
anything marked data-consent-ignore (or the library's data-cookie-ignore) is not
on the page: it neither answers nor closes the panel; consent.ignore adds
selectors.
Once answered
The circle and every data-consent-open element open, with reopen: details (the
default), the detailed section with each switch as the answer was recorded,
"Tout accepter", "Tout refuser", "Enregistrer mes choix" and "Fermer"; with
reopen: banner, the question again. A refused feature writes nothing - through
Cookie.set(), Cookie.cookies, Cookie.local, Cookie.session - and what it had
kept is erased; accepted again, it writes again.
The answer counts for consent.lifetime (180d; 13mo, 1y or seconds are read
too). Changing consent.version asks every visitor again. It is kept in the cookie
cookie-consent (options.record changes its store and name), and in
localStorage cookie/<GROUP> as @glitchr/cookie kept it: the answers given
before the library are taken over, and nobody is asked twice.
The browser's privacy signals
consent.signals.gpc (Global Privacy Control) and .dnt (Do Not Track): true
turns every feature that is not required off without asking when the browser sends
the signal; a list of features limits it to those; false (the default) ignores
it.
Propose a change docs/concepts.md
In a script and in the markup
A script does not decide on cookies itself: it subscribes to its feature, and the
panel decides. public/js/consent.js (deferred, printed by the partial) defines
window.Consent in front of @glitchr/flowjs/cookie.
window.Consent
| Method | Returns, does |
|---|---|
use(name, options, onEnable, onDisable) |
Declares the feature (its switch appears in the panel; options.label, else the configuration's words, else the name), runs onEnable when it is on - now, or the moment the visitor says yes - and onDisable when it goes off. Returns whether it is on now. options may be left out: use(name, onEnable, onDisable) |
state(name) |
true, false, or null (not answered); no name: the site's own feature |
enabled(name) |
What to do now: an implicit feature is on until refused |
cookie(group, name, value, expires) |
Cookie.set() through the answer: nothing is written while the feature is off; returns whether the cookie is there |
features() |
Every feature: {name, label, implicit, required, state, enabled} |
undecided() |
Whether the visitor has not answered yet |
refused(), accepted() |
The site's own feature refused; not refused |
accept(), refuse() |
Answers for the visitor |
open(), close() |
Opens the panel; closes it (not while the question is pending) |
scan(root) |
Looks for the elements that wait for a feature again (after a part of the page was replaced) |
window.Consent.use('CHAT', { label: 'The chat' }, () => loadCrisp(), () => window.$crisp?.push(['do', 'chat:hide']));
// A cookie through the answer: nothing is written while ANALYTICS is off.
window.Consent.cookie('ANALYTICS', 'VISITOR_ID', id, 30 * 24 * 3600);
Cookie.set(group, ...) is superseded the same way: any group a script writes
becomes a feature of the panel, and stays unwritten while it is off - unless the
cookie is declared under a feature (storage), whose answer then decides.
The page's scripts run before the library is there (the bundle's comes first):
until then Consent answers from what the browser already holds
(localStorage cookie/<GROUP>), and use(), cookie(), accept(), refuse(),
open(), close() and scan() are kept and made once the page's scripts have
all run.
Events
| Event | On | When | Detail |
|---|---|---|---|
consent:change |
window |
Every answer | {consent, groups, features}: the site's own feature on or not, the names, {USER: true, CHAT: false...} |
ga:cookie |
window |
Every answer | none (for the sites written before this bundle) |
<name>:consent |
document |
A feature turned on; and for every feature already on, at load and after each page swap (transparent:load, turbo:load) |
none |
window.addEventListener('consent:change', (e) => e.detail.features);
document.addEventListener('chat:consent', () => loadCrisp());
omnibase/social loads Instagram on social:consent.
Elements that wait for a feature
The library's attributes, under the prefix data-consent:
<script type="text/plain" data-consent-script="ANALYTICS" data-src="https://..."></script>
<iframe data-consent-script="SOCIAL" data-consent-src="https://www.instagram.com/p/.../embed"></iframe>
<template data-consent-script="LOCATION"><div id="map"></div></template>
<p data-consent-placeholder="LOCATION">Shown until the feature is on. <button type="button" data-consent-open>Cookies</button></p>
| Element | Once the feature is on |
|---|---|
<script type="text/plain" data-consent-script="X"> |
becomes a script and runs (data-src: loaded from there) |
<iframe data-consent-script="X" data-consent-src="..."> |
gets its src (taken away again when the feature goes off) |
<template data-consent-script="X"> |
its content is put in the page (and taken out when the feature goes off) |
[data-consent-placeholder="X"] |
is shown while the feature is off |
[data-consent-open] (or [data-cookie-open]) |
opens the panel when clicked, at any time |
An element naming a feature nobody declared declares it.
Several circles
Several circles of a page share one panel: it sits under the first the visitor can
see while the question floats, under the one clicked afterwards. A circle may leave
the page with it - a page swap empties the bar it sat in, a script rebuilds the
footer: the next circle's panel takes over, and nothing throws. Wherever a site's
stylesheet places it, the panel stays inside the screen across: it is moved by its
margins until it is --consent-gutter from the left and right edges, and put back
when it closes; opened under a circle far from where the visitor is, it is scrolled
into sight up or down only.
Propose a change docs/usage-scripts.md
Look and words
The stylesheet
bundles/consent/css/consent.css, themed by custom properties set anywhere above
the circle:
:root {
--consent-ink: #1c2b3a; --consent-soft: #66727f;
--consent-bg: #fff; --consent-line: rgba(0, 0, 0, .14);
--consent-accent: #24447c; --consent-on-accent: #fff;
--consent-danger: #c0392b; --consent-hover: rgba(0, 0, 0, .07);
--consent-radius: 14px; --consent-font: inherit;
--consent-shadow: 0 12px 30px rgba(0, 0, 0, .16);
--consent-size: 2rem;
--consent-gutter: 1rem; /* the room the panel keeps from the screen's edges (a phone's safe area if wider) */
}
The states are classes of the circle (.ga-cookie): is-floating (not answered:
the panel at the bottom of the screen), is-choosing (the detailed section),
is-refused. The panel is .ga-cookie-panel; the question .ga-cookie-summary;
the detailed section .ga-cookie-section, its rows .ga-cookie-category.
consent.position (bottom-left, bottom-right, bottom-center, top-left,
top-right, top-center, or a word of the site's stylesheet) is written on the panel
as data-consent-position.
The words
Every word is a translation of the consent domain (translations/consent+intl-icu.<locale>.yaml:
fr, en, de, it, ja), which a site overrides in its own
translations/consent+intl-icu.<locale>.yaml.
| Key | Where |
|---|---|
label, accepted, refused |
The circle's label and titles |
text |
The question ({privacy}: the privacy page's address, or none) |
implicit, implicit_refuse |
The countdown's line ({count}), for countdown_action other than refuse, and for refuse |
accept, refuse, details, choices, accept_all, refuse_all, save, close, required, stored |
The panel's buttons and titles |
duration.session, duration.seconds, .minutes, .hours, .days, .weeks, .months, .years |
The durations in words (30d reads "30 jours") |
storage.<purpose> |
What a storage entry is for (session, language, csrf, remember, answer, info, theme, visit, timezone, visitor; own and third_party for an erase prefix). A purpose that is no key is shown as written |
categories.<name>.label, .description |
A feature's words in the detailed section (the bundle has necessary, user, location) |
features.<name>.label, .description |
A feature's words, read after categories.* (the bundle has user, chat, analytics, social); a feature the translations name is known to the panel even when the configuration does not declare it |
(features.user.* still says "the site's working": what USER means on a page
served by consent-legacy.js, where it is all of the site's cookies.)
Propose a change docs/appearance.md
Configuration reference
config/packages/consent.yaml, processed by
Base\Consent\Bridge\Symfony\DependencyInjection\ConsentConfiguration (its defaults
are Base\Consent\ConsentConfig's, which a host without Symfony reads the same
file with). The tree, dumped with Symfony's reference dumper in the core's harness:
consent:
# The site's own cookie groups (omnibase writes USER/*): on by default, on when the visitor does not answer. Shorthand for features with implicit: true. The first one is the site's own answer (Consent.refused(), the circle's colour).
groups:
# Default:
- USER
# Start from the bundle's own features (config/features.php: NECESSARY, USER, LOCATION and the on-demand ones). false: only what is declared here. One of them is taken out with `features: {NAME: false}`.
defaults: true
# The version of the features: changed, every visitor is asked again (a feature added that does more than the answer covered).
version: null
# How long an answer counts before the visitor is asked again: "180d", "13mo", "1y", seconds.
lifetime: 180d
# Seconds before a visitor who has not answered gets the answer of countdown_action (counted down in the panel). 0: no countdown, the panel waits for an answer.
countdown: 10
# What the countdown answers when the time is up - default: each feature takes its default (the implicit ones on, the rest off); accept: every one on; refuse: every one off but the required ones.
countdown_action: default # One of "default"; "accept"; "refuse"
# What a click elsewhere answers while the question floats - none: nothing, the question stays until the visitor answers or the countdown runs out; default, accept, refuse: that answer, given without a word the visitor sees.
dismiss: none # One of "default"; "accept"; "refuse"; "none"
# Selectors of elements whose clicks are not on the page, added to the bundle's own (Symfony's web debug toolbar, a dump(), [data-consent-ignore]): a click there never answers by dismiss and never closes the panel.
ignore: []
# What the circle and the "Cookies" links show once answered - details: the detailed section, each switch as the answer was recorded (the countdown's included), with "Tout accepter", "Tout refuser" and "Fermer"; banner: the question again, the switches one click further.
reopen: details # One of "banner"; "details"
# Where the question floats: bottom-left, bottom-right, bottom-center, top-left, top-right, top-center - or any word of the site's own stylesheet (data-consent-position).
position: bottom-left
# The privacy signals of the browser the site honours: true (every feature that is not required is off without asking), or the list of the features it covers.
signals:
# Global Privacy Control.
gpc: false
# Do Not Track.
dnt: false
# The route of the privacy page linked from the panel; null, or a route that does not exist, leaves the link out.
privacy_route: app_privacy
# What the visitor has a say over, by name (the cookie group: CHAT, ANALYTICS, SOCIAL...), each with its own switch in the detailed section. Scripts may also declare theirs at run time (window.Consent.use()). Labels: translations consent.features.<name>.label / .description.
features:
# Prototype
name:
enabled: true
# On until refused, and on when the visitor does not answer (the countdown). Keep it for what the site needs to work; a tracker must wait for a yes.
implicit: null
# Always on, its switch locked: the session, a basket.
required: null
# Known (its words and what it stores are ready) but listed only once a script or an element of the page uses it, and never a reason to ask again.
on_demand: null
# The feature it was split from: a visitor who answered for that one is not asked again for this one, which takes the same answer.
inherits: null
# Name prefixes of cookies deleted when it is turned off (e.g. crisp-client). Each is a line of the detailed section unless one of its storage entries takes it in; said to be the site's own when it is a group's (ANALYTICS/) or another feature's entry, a third party's otherwise.
erase: []
# What it keeps in the browser: listed in the detailed section, erased when it is refused, and written through the answer (Cookie.cookies / .local / .session).
storage:
# Prototype
-
# The name, or a pattern with * (USER/*, _pk_*). {session} is the session cookie's name, {answer} the one the answer is kept under.
name: ~ # Required
# cookie, local (localStorage) or session (sessionStorage).
type: cookie # One of "cookie"; "local"; "session"
# How long it is kept: "30d", "12h", "13mo", "1y", "session", seconds; {lifetime} is the answer's.
duration: null
# What it is for: a key of the translations (consent.storage.<purpose>), else the words themselves.
purpose: null
path: null
# Of a third party's cookie, needed to erase it.
domain: null
# Parameters of privacy_route, e.g. {slug: vie-privee} for a page served by a generic route.
privacy_route_parameters:
# Prototype
name: ~
# Name prefixes of cookies also deleted when the site's own feature is refused, e.g. a chat's ("crisp-client"). The groups' own cookies always are.
erase_on_refuse: []
# Anything else @glitchr/flowjs/cookie takes, handed as it is: record: {store, name}, cookie: {sameSite, domain}, undeclared, debug, variables...
options: []
Options
| Option | Type | Default | Effect |
|---|---|---|---|
groups |
list of names | [USER] |
The site's own groups: features implicit unless said otherwise; the first is the site's own feature (Consent.refused(), the circle's colour) |
defaults |
bool | true |
Start from the bundle's features (config/features.php); false: only those declared here |
version |
string | null |
Changed, every visitor is asked again |
lifetime |
string | 180d |
How long an answer counts: 180d, 13mo, 1y, seconds |
countdown |
int, 0 or more | 10 |
Seconds before no answer is the answer of countdown_action; 0: wait for an answer |
countdown_action |
default, accept, refuse |
default |
What the countdown answers (Features and the answer) |
dismiss |
default, accept, refuse, none |
none |
What a click elsewhere answers while the question floats |
ignore |
list of selectors | [] |
More elements whose clicks are not on the page (after Symfony's toolbar, a dump(), [data-consent-ignore], [data-cookie-ignore]) |
reopen |
banner, details |
details |
What the circle and the "Cookies" links show once answered |
position |
string | bottom-left |
Where the question floats |
signals.gpc, signals.dnt |
bool or list of features | false |
The browser's privacy signals honoured |
privacy_route |
string | app_privacy |
The privacy page's route, linked from the question; null or a missing route leaves the link out |
privacy_route_parameters |
map | {} |
Its parameters ({slug: vie-privee}) |
erase_on_refuse |
list of name prefixes | [] |
Cookies also erased when the site's own feature is refused |
features |
map of name => feature | {} |
The site's features, laid over the bundle's (below) |
options |
any | [] |
Anything else @glitchr/flowjs/cookie takes, handed under what the bundle sets: record: {store, name}, cookie: {sameSite, domain}, undeclared, debug, variables... |
A feature
features.<NAME> is false (taken out), ~ or true (declared as it is), or:
| Key | Type | Default | Effect |
|---|---|---|---|
enabled |
bool | true |
false takes the feature out |
implicit |
bool | null (the bundle's, else off) |
On until refused, on without an answer |
required |
bool | null |
Always on, switch locked |
on_demand |
bool | null (a feature the site declares is listed) |
Listed only once used |
inherits |
string | null |
The feature it was split from |
erase |
list of name prefixes | [] |
Cookies deleted when it goes off; added to the bundle's |
storage |
list | [] |
What it keeps; an entry replaces the bundle's of the same type and name |
A storage entry
A bare name, or:
| Key | Type | Default | Effect |
|---|---|---|---|
name |
string, required | The name, or a pattern with * (USER/*, _pk_*); {session}, {answer} |
|
type |
cookie, local, session |
cookie |
A cookie, localStorage or sessionStorage |
duration |
string | null |
30d, 12h, 13mo, 1y, session, seconds; {lifetime} |
purpose |
string | null |
A key of consent.storage.*, else the words themselves |
path |
string | null |
The cookie's path |
domain |
string | null |
A third party's cookie's domain, needed to erase it |
An erase prefix (and erase_on_refuse, the site's own feature's) is a line of the
detailed section, <prefix>*, unless one of the feature's storage entries already
takes it in (crisp-client* for crisp-client). The line says whose cookies they
are: the site's own when the prefix is a feature's group (ANALYTICS/) or a cookie
another feature declares (LANG), a third party's otherwise.
An example
# config/packages/consent.yaml
consent:
countdown: 10
countdown_action: refuse
privacy_route: app_page
privacy_route_parameters: { slug: vie-privee }
features:
LOCATION: false # the time zone then goes with USER
CHAT: { erase: [crisp-client] }
ANALYTICS:
storage:
- { name: '_pk_*', duration: 13mo, purpose: 'Matomo' }
- { name: visits, type: local, purpose: analytics_visits }
BASKET: { required: true, storage: [basket] }
Parameters
Under Symfony the extension sets consent.config (the processed tree, which the Twig
extension reads), consent.defaults (ConsentPanel::features()) and one parameter
per node (consent.countdown, consent.signals.gpc...).
Propose a change docs/configuration.md
Testing
The suites
npm install && npm test # node --test, on jsdom
vendor/bin/phpunit
tests/js/consent.test.mjs runs consent.js on @glitchr/flowjs/cookie in jsdom
(tests/js/page.mjs builds a page with the partial's markup), tests/js/legacy.test.mjs
consent-legacy.js without it. The PHP suite checks the configuration tree
(tests/DependencyInjection/ConsentConfigurationTest.php), ConsentConfig::normalize()
against it (tests/ConsentConfigTest.php), what the partial prints and every
language's words (tests/Twig/ConsentExtensionTest.php), the classes loaded without
the framework (tests/BareTest.php, over tests/Fondation.php) and the former names
(tests/KeptNamesTest.php).
The JavaScript suite, run in a node:22-alpine container on this checkout
(node --test tests/js/*.test.mjs), ends:
1..27
# tests 27
# suites 0
# pass 27
# fail 0
# cancelled 0
# skipped 0
# todo 0
In the core's harness
omnisense/omnibase's harness (docker/) runs the PHP suite with this checkout
mounted in place of the package:
docker compose -f compose.yml run --rm -v /path/to/consent:/srv/demo/vendor/omnibase/consent omnibase test consent
In a site's tests
A functional test of a page that includes the partial finds the circle
(.ga-cookie[data-consent]), the panel (#ga-cookie-<place>) and the settings
(script[data-consent-settings], JSON: categories, countdown, texts...).
What a visitor answers is the browser's: test it in a browser (headless Chrome),
or with the jsdom page of tests/js/page.mjs.