Aller au contenu

omnibase/consent

The cookie answer of every omnibase site, in one place: a small circle, a panel, the features the visitor answers for and what each keeps, a countdown, on @glitchr/flowjs/cookie.

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/consent:dev-main

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.

Proposer une modification 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.

Proposer une modification 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\*.

Proposer une modification 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:

  1. the bundle's (unless defaults: false);
  2. the site's groups (consent.groups, [USER] by default): implicit unless said otherwise, never on demand; the first is the site's own feature (what Consent.refused() answers for, the circle's colour), and erase_on_refuse adds to its erase;
  3. 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: false takes one out; its erase adds to the bundle's; a storage entry replaces the bundle's of the same type and name;
  4. 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.

Proposer une modification 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.

Proposer une modification 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.)

Proposer une modification 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...).

Proposer une modification 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.

Proposer une modification docs/testing.md