Skip to content

About

Consent bar and analytics loaders: a first-party Plausible count by default; Google Analytics 4, Microsoft Clarity and PostHog each only under a purpose's yes

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@framers/analytics-consent

npm version CI Coverage License

A consent bar and the analytics loaders behind it: a first-party Plausible count by default; Google Analytics 4, Microsoft Clarity and PostHog each only under a purpose's yes, on the device and the account. One package holds the framework-free core, a React renderer, a plain DOM renderer, a static-site entry, server rules with a store on @framers/sql-storage-adapter, and the Playwright suite every consumer runs.

Status

0.0.x versions are the build in progress; 0.1.0 is the first version with the contract below. In a 0.0.x version, an entry whose part has not landed exports VERSION alone.

Entries

Entry What it holds Runs in
. (the core) AnalyticsConfig, the purposes and the device's answer, the runtime, bootstrapSource, the device store, the reconciliation with the account, the account send (sendToAccount), the words' shape and hash rules, and the bar's controller Browser (ES2022), and Node for the string functions
./react AnalyticsLoaders, Pageview, ConsentBar, PurposeRows, AnalyticsChoiceStatus, AnalyticsChoices, taking the product's words, purposes, paths and endpoint as props React 18.2 or later, or React 19
./dom The same bar, status line and choices rendered without a framework on the controller (mountConsentBar, mountChoiceStatus, mountAnalyticsChoices), the markup functions, and the page views for a page without React (watchPageviews, sendPageview) Browser
./static For a generated site, at build time: bootstrapScript (the inline script and its CSP hash), consentBarMarkup, choiceStatusMarkup, optionsElement, and the path of the client file dist/static/consent-client.js (an IIFE bundle of ./dom and the core) Node, at build
./server The account answer's pure rules (requestedPurposes, purposesOfRow, whoOf, deviceHashOf, judge, STALE_SENTENCE, consentAttributes) and a store on @framers/sql-storage-adapter (createConsentChoicesStore); it re-exports ./count Node (it imports node:crypto)
./count The count's forwarding rules and forwarder (pageOf, siteOf, eventToForward, forwardCountEvent) Node and the Workers runtime (Fetch API only, no node: import)
./testing consentSuite(options): the real-path Playwright cases every product runs @playwright/test, in CI
./consent.css The bar's, rows', status line's and choices' stylesheet on --ac-* custom properties Imported once by the product

Configuration

import { bootstrapSource, scopeOf, type AnalyticsConfig } from '@framers/analytics-consent';

const config: AnalyticsConfig = {
  product: 'Example',
  plausible: { domain: 'example.com', script: '/js/count.js', endpoint: '/api/event', where: 'open' },
  ga4: 'G-XXXXXXXXXX',
  clarity: null,
  posthog: { key: 'phc_xxx', host: 'https://us.i.posthog.com', cookieless: true },
  tokenPaths: ['/signin'],
  publicPaths: ['/', '/pricing'],
  scopeBefore: null,
};

// The root layout's inline script. `words` is the product's AnalyticsWords.
const source = bootstrapSource(config, gpcHeader, signedIn, account, scopeOf(config, words));

A layer set to null is off. The purposes that run follow the layers: count where plausible is set, measure where ga4 or posthog is, record where clarity is.

Field What it sets
plausible.endpoint Where the tracker posts: the product's own event route, or the shared instance's event address on a site with no server route.
plausible.where 'everywhere', the default, loads the tracker on every page while count is on. 'open' loads it only on a public path, signed out, off the token paths, which a product that holds health data sets; window.__analytics.count says whether it loaded.
posthog.cookieless true passes cookieless_mode: 'always' and request_batching: false to PostHog's init, for a product that holds health data. It needs the project's cookieless server hash mode (PostHog's config reference).
tokenPaths Paths whose addresses carry a token: the recorders never load on them, and each layer counts them by path alone.
publicPaths The only paths where Google Analytics, Clarity and PostHog may load, for a signed-out visitor, each under its purpose's yes.
scopeBefore The scope an answer stored with no mark was given to, as a literal written once, or null for a new product.

A privacy signal (Global Privacy Control, Do Not Track or a request with Sec-GPC: 1) turns every purpose off, the count included, in every configuration.

The bootstrap decides once per page load, from the address and the session as they stand at that load. A layer it loaded stays loaded across a client-side move, and the runtime keeps PostHog off a token path alone. So every link from a public page to a private page or a token address is a plain anchor, a full load after which the bootstrap decides again.

The scope mark

scopeOf(config, words) writes the running layers in one fixed form: each running purpose in the fixed order, its row's revision, and its vendors by name, sorted.

count:1:plausible;measure:1:ga4,posthog;record:1:clarity

The person's own answer stores that value under analytics_consent_scope beside the purposes. The bootstrap, and scopedPurposes for a caller holding the same values, read it before anything loads:

What moved since the answer What the answer means now
A vendor added under a purpose, or its row's revision raised The yes to that purpose alone is void: nothing under it loads until the next answer.
A vendor removed The yes stands.
A purpose the answer's scope does not list, its layer added after the answer It is unanswered, and nothing under it loads until the next answer.
The answer refused a listed purpose The refusal stands, whatever the scope.
The mark is absent or does not parse The answer is read as given to scopeBefore. Under null its yes is void and its refusals stand.

The count runs without a yes, so a voided or unlisted count stays on as unanswered, unless the answer holds it off. voidedByScope names what an answer leaves unanswered, the count included.

bootstrapSource throws on a scope that parses but lists other purposes, or other vendors under a purpose, than the configuration runs, since a layer the scope leaves out would load under any stored yes to its purpose. Pass scopeOf(config, words) for the same configuration, never a literal.

A row's revision starts at 1 and stays at or below Number.MAX_SAFE_INTEGER; scopeOf throws past it. Raise it by one in the change that alters what a person agrees to under that row: a vendor's role, what it sets, what reaches it, where or how long it runs. A spelling or style edit leaves it. The revision sits outside the words' three hashed texts, so raising it changes no hash.

Contract

What a breaking change is

The contract, whose change is breaking:

  • the purposes (count, measure, record) and their order;
  • the storage keys (analytics_consent_purposes, analytics_consent, analytics_consent_written, plausible_ignore, analytics_consent_at, analytics_consent_who, analytics_consent_words, analytics_consent_carried, analytics_consent_scope);
  • AnalyticsConfig's shape, scopeOf's format, the scope rule and the words' revision;
  • the account route's body and answer;
  • the <html> attributes (data-consent-who, data-consent-purposes, data-consent-choice, data-consent-at, data-consent-version, data-consent-unreadable);
  • the bar's region attribute data-consent-bar and the --consent-bar and --consent-room measures;
  • window.__analytics;
  • the words' shape and hash rules;
  • the ac- class names and --ac-* properties.

Versions

Pin an exact version ("@framers/analytics-consent": "0.1.3", never a caret or a range) and take a newer one at your own release. A breaking change raises the minor while the package is below 1.0.0.

Tokens

A product maps these custom properties once in its own stylesheet. Each fallback is listed here from the version whose stylesheet sets it.

Property What it sets
--ac-panel, --ac-panel-border, --ac-panel-shadow The bar's surface
--ac-ink, --ac-ink-2 Text
--ac-line, --ac-edge, --ac-well Rules, edges and the switch's well
--ac-accent, --ac-on-accent The checked switch's border and fill, and what sits on it
--ac-key-bg The keys' background
--ac-radius-card, --ac-radius-pill Corners
--ac-font-ui Type
--ac-space-4, --ac-space-8, --ac-space-12, --ac-space-16, --ac-space-20 Spacing
--ac-room-above, --ac-room-below The room the bar leaves above and below itself

SQLite

On SQLite, open one handle per database file and send every write of the store through it. A reader holding a read transaction (a backup, an inspection) or a second process can still make a commit fail busy, which the store answers as a failed send: nothing is stored, the device keeps its answer, and the next signed-in load reconciles it.

Development

Install this repository's dev dependencies with npx npm@11.20.0 install: npm 10.9, which Node 22 ships, crashes resolving their peer sets. Installing the published package is not affected; CI installs the packed tarball with Node's own npm.

CI runs npx vitest run --coverage and fails below the thresholds in vitest.config.ts, which hold the measured figure less two points and only move up. Each green run on master writes the figure to the badges branch, which CI alone owns and the coverage badge reads.

Releases

Every push to master that changes what the package ships publishes its next patch to npm with provenance. RELEASING.md holds the rules.

License

MIT

About

Consent bar and analytics loaders: a first-party Plausible count by default; Google Analytics 4, Microsoft Clarity and PostHog each only under a purpose's yes

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages