├── .prettierignore ├── .npmignore ├── .husky └── pre-commit ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── site ├── _redirects ├── assets │ ├── favicon.ico │ ├── logo-sne.png │ ├── logo-epf-occitanie.png │ ├── logo-luxembourg-gov.png │ ├── logo-service-public.png │ ├── outfit-variable.woff2 │ ├── logo-student-at-work.png │ ├── logo-autorite-protection-donnees.svg │ ├── favicon.svg │ ├── style.css │ ├── logo-chu-liege.svg │ ├── logo-senat.svg │ ├── logo-boscop.svg │ └── logo-luxembourg-city.svg ├── features │ ├── styling.css │ ├── grouping.js │ ├── i18n.js │ ├── styling.js │ ├── dsfr.js │ ├── purposes.js │ ├── orejime.html │ ├── wcag.html │ ├── contextual.html │ └── dsfr.html ├── services.json ├── themes │ └── boscop-light-soft-color-theme.json └── accessibility.html ├── src ├── ui │ ├── components │ │ ├── types │ │ │ ├── ConsentState.ts │ │ │ ├── ModalBanner.ts │ │ │ ├── PurposeList.ts │ │ │ ├── GlobalConsent.ts │ │ │ ├── Modal.ts │ │ │ ├── Purpose.ts │ │ │ ├── Banner.ts │ │ │ ├── ContextualNotice.ts │ │ │ └── Theme.ts │ │ ├── Context.tsx │ │ ├── GlobalConsentContainer.tsx │ │ ├── PoweredByLink.tsx │ │ ├── PurposeContainer.tsx │ │ ├── PurposeTree.tsx │ │ ├── PurposeGroupContainer.tsx │ │ ├── StubManagerProvider.tsx │ │ ├── ContextualNoticeContainer.tsx │ │ ├── Main.tsx │ │ └── Dialog.tsx │ ├── themes │ │ ├── dsfr │ │ │ ├── PurposeList.tsx │ │ │ ├── index.ts │ │ │ ├── ModalBanner.tsx │ │ │ ├── GlobalConsent.tsx │ │ │ ├── ContextualNotice.tsx │ │ │ ├── Banner.tsx │ │ │ ├── Modal.tsx │ │ │ └── Purpose.tsx │ │ └── standard │ │ │ ├── PurposeList.tsx │ │ │ ├── Icons.tsx │ │ │ ├── index.ts │ │ │ ├── ModalBanner.tsx │ │ │ ├── GlobalConsent.tsx │ │ │ ├── ContextualNotice.tsx │ │ │ ├── Purpose.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Banner.tsx │ │ │ └── index.css │ ├── utils │ │ ├── functions.ts │ │ ├── template.ts │ │ ├── objects.ts │ │ ├── template.test.ts │ │ ├── objects.test.ts │ │ ├── config.ts │ │ ├── hooks.ts │ │ └── dom.ts │ ├── setup.tsx │ ├── ContextualConsentsEffect.tsx │ └── types.ts ├── core │ ├── utils │ │ ├── escapeRegex.ts │ │ ├── types.ts │ │ ├── arrays.ts │ │ ├── objects.ts │ │ ├── objects.test.ts │ │ ├── deletePurposeCookies.ts │ │ ├── arrays.test.ts │ │ ├── updatePurposeElements.test.ts │ │ ├── updatePurposeElements.ts │ │ ├── cookies.ts │ │ └── purposes.ts │ ├── ConsentsEffect.ts │ ├── ConsentsRepository.ts │ ├── EventEmitter.test.ts │ ├── CookiesConsentsEffect.ts │ ├── types.ts │ ├── DomConsentsEffect.test.ts │ ├── CookieConsentsRepository.ts │ ├── DomConsentsEffect.ts │ ├── setup.ts │ ├── EventEmitter.ts │ ├── Manager.ts │ └── Manager.test.ts ├── migrations │ ├── index.ts │ ├── serialization.ts │ ├── v3 │ │ ├── index.ts │ │ ├── translations.ts │ │ ├── config.ts │ │ └── config.test.ts │ └── v2 │ │ └── types.ts ├── uneval.d.ts ├── setup.ts ├── umd.ts └── translations │ ├── fi.ts │ ├── en.ts │ ├── sv.ts │ ├── it.ts │ ├── et.ts │ ├── nb.ts │ ├── nl.ts │ ├── ro.ts │ ├── ca.ts │ ├── hu.ts │ ├── oc.ts │ ├── es.ts │ ├── de.ts │ └── fr.ts ├── .gitignore ├── jest.config.js ├── adr ├── 000-README.md ├── 003-purpose-templates.md ├── 002-standalone-bundles.md └── 001-distribution-formats.md ├── tsconfig.json ├── .github └── workflows │ ├── test-unit.yml │ └── test-e2e.yml ├── SECURITY.md ├── .prettierrc.json ├── playwright.config.ts ├── LICENSE ├── package.json ├── rspack.config.js └── e2e └── OrejimePage.ts /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !/dist/orejime* 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | npm test 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /site/_redirects: -------------------------------------------------------------------------------- 1 | https://orejime.empreintedigitale.fr/* https://orejime.boscop.fr/:splat 301! 2 | -------------------------------------------------------------------------------- /site/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/favicon.ico -------------------------------------------------------------------------------- /site/assets/logo-sne.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/logo-sne.png -------------------------------------------------------------------------------- /site/assets/logo-epf-occitanie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/logo-epf-occitanie.png -------------------------------------------------------------------------------- /site/assets/logo-luxembourg-gov.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/logo-luxembourg-gov.png -------------------------------------------------------------------------------- /site/assets/logo-service-public.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/logo-service-public.png -------------------------------------------------------------------------------- /site/assets/outfit-variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/outfit-variable.woff2 -------------------------------------------------------------------------------- /src/ui/components/types/ConsentState.ts: -------------------------------------------------------------------------------- 1 | export enum ConsentState { 2 | declined, 3 | accepted, 4 | partial 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /site/assets/logo-student-at-work.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscop-fr/orejime/HEAD/site/assets/logo-student-at-work.png -------------------------------------------------------------------------------- /src/core/utils/escapeRegex.ts: -------------------------------------------------------------------------------- 1 | export default (regex: string) => 2 | regex.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist/* 3 | !/dist/site 4 | /test-results/ 5 | /playwright-report/ 6 | /blob-report/ 7 | /playwright/.cache/ 8 | -------------------------------------------------------------------------------- /src/core/ConsentsEffect.ts: -------------------------------------------------------------------------------- 1 | import {ConsentsMap} from './types'; 2 | 3 | export default interface ConsentsEffect { 4 | apply(consents: ConsentsMap): void; 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extensionsToTreatAsEsm: ['.ts', '.tsx'], 3 | transform: { 4 | '^.+\\.(t|j)sx?$': '@swc/jest' 5 | }, 6 | testEnvironment: 'jsdom' 7 | }; 8 | -------------------------------------------------------------------------------- /src/core/ConsentsRepository.ts: -------------------------------------------------------------------------------- 1 | import {ConsentsMap} from './types'; 2 | 3 | export default interface ConsentsRepository { 4 | read(): ConsentsMap; 5 | write(consents: ConsentsMap): void; 6 | clear(): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import migrateV3 from './v3/index'; 2 | 3 | declare global { 4 | interface Window { 5 | orejimeMigrateV2ToV3: typeof migrateV3; 6 | } 7 | } 8 | 9 | window.orejimeMigrateV2ToV3 = migrateV3; 10 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/PurposeList.tsx: -------------------------------------------------------------------------------- 1 | import {PurposeListComponent} from '../../components/types/PurposeList'; 2 | 3 | const PurposeList: PurposeListComponent = ({children}) => <>{children}; 4 | 5 | export default PurposeList; 6 | -------------------------------------------------------------------------------- /src/ui/components/types/ModalBanner.ts: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from 'preact'; 2 | import {BannerProps} from './Banner'; 3 | 4 | export interface ModalBannerProps extends BannerProps {} 5 | 6 | export type ModalBannerComponent = FunctionComponent; 7 | -------------------------------------------------------------------------------- /src/ui/components/types/PurposeList.ts: -------------------------------------------------------------------------------- 1 | import {ComponentChildren, FunctionComponent} from 'preact'; 2 | 3 | export interface PurposeListProps { 4 | children?: ComponentChildren; 5 | } 6 | 7 | export type PurposeListComponent = FunctionComponent; 8 | -------------------------------------------------------------------------------- /src/uneval.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'uneval.js' { 2 | export type Opts = { 3 | namespace?: Map; 4 | safe?: boolean; 5 | }; 6 | 7 | export default function uneval( 8 | obj: any, 9 | opts?: Opts, 10 | level?: string 11 | ): string; 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/utils/functions.ts: -------------------------------------------------------------------------------- 1 | export const once = (fn: () => T): (() => T) => { 2 | let done = false; 3 | let result: T; 4 | 5 | return () => { 6 | if (!done) { 7 | result = fn(); 8 | done = true; 9 | } 10 | 11 | return result; 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/core/utils/types.ts: -------------------------------------------------------------------------------- 1 | // @see https://stackoverflow.com/a/51365037/2391359 2 | export type RecursivePartial = { 3 | [P in keyof T]?: T[P] extends (infer U)[] 4 | ? RecursivePartial[] 5 | : T[P] extends object | undefined 6 | ? RecursivePartial 7 | : T[P]; 8 | }; 9 | -------------------------------------------------------------------------------- /src/ui/components/Context.tsx: -------------------------------------------------------------------------------- 1 | import {createContext} from 'preact'; 2 | import Manager from '../../core/Manager'; 3 | import type {Config} from '../types'; 4 | 5 | export interface ContextType { 6 | config: Config; 7 | manager: Manager; 8 | } 9 | 10 | export default createContext({} as ContextType); 11 | -------------------------------------------------------------------------------- /src/ui/components/types/GlobalConsent.ts: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from 'preact'; 2 | 3 | export interface GlobalConsentProps { 4 | isEnabled: boolean; 5 | isDisabled: boolean; 6 | acceptAll: () => void; 7 | declineAll: () => void; 8 | } 9 | 10 | export type GlobalConsentComponent = FunctionComponent; 11 | -------------------------------------------------------------------------------- /site/features/styling.css: -------------------------------------------------------------------------------- 1 | /** 2 | * We're only tuning the colors here, but more variables are 3 | * available. 4 | */ 5 | .orejime-Env { 6 | --orejime-color-background: #132d2c; 7 | --orejime-color-text: #fff; 8 | --orejime-color-subdued: #c1d2b5; 9 | --orejime-color-interactive: #f2e747; 10 | --orejime-color-on-interactive: #282827; 11 | } 12 | -------------------------------------------------------------------------------- /site/features/grouping.js: -------------------------------------------------------------------------------- 1 | window.orejimeConfig = { 2 | purposes: [ 3 | { 4 | id: 'group', 5 | title: 'Analytics', 6 | purposes: [ 7 | { 8 | id: 'matomo', 9 | title: 'Matomo' 10 | }, 11 | { 12 | id: 'google-analytics', 13 | title: 'Google Analytics' 14 | } 15 | ] 16 | } 17 | ], 18 | privacyPolicyUrl: '#' 19 | }; 20 | -------------------------------------------------------------------------------- /site/features/i18n.js: -------------------------------------------------------------------------------- 1 | window.orejimeConfig = { 2 | translations: { 3 | banner: { 4 | configure: 'Configurer' 5 | } 6 | }, 7 | purposes: [ 8 | { 9 | id: 'mandatory', 10 | title: 'Cookies techniques', 11 | isMandatory: true 12 | }, 13 | { 14 | id: 'analytics', 15 | title: "Analyse d'audience" 16 | } 17 | ], 18 | privacyPolicyUrl: '#' 19 | }; 20 | -------------------------------------------------------------------------------- /src/ui/components/types/Modal.ts: -------------------------------------------------------------------------------- 1 | import {ComponentChildren, FunctionComponent} from 'preact'; 2 | 3 | export interface ModalProps { 4 | isForced: boolean; 5 | needsUpdate: boolean; 6 | privacyPolicyUrl: string; 7 | onClose: () => void; 8 | onSave: () => void; 9 | children: ComponentChildren; 10 | } 11 | 12 | export type ModalComponent = FunctionComponent; 13 | -------------------------------------------------------------------------------- /site/features/styling.js: -------------------------------------------------------------------------------- 1 | window.orejimeConfig = { 2 | privacyPolicyUrl: '#', 3 | purposes: [ 4 | { 5 | id: 'mandatory', 6 | title: 'Technical cookies', 7 | description: 'Cookies required for the website to function' 8 | }, 9 | { 10 | id: 'analytics', 11 | title: 'Analytics', 12 | description: 'Cookies used to track user navigation' 13 | } 14 | ] 15 | }; 16 | -------------------------------------------------------------------------------- /src/ui/themes/standard/PurposeList.tsx: -------------------------------------------------------------------------------- 1 | import {PurposeListComponent} from '../../components/types/PurposeList'; 2 | 3 | const PurposeList: PurposeListComponent = ({children}) => ( 4 |
    5 | {children.map((child) => ( 6 |
  • {child}
  • 7 | ))} 8 |
9 | ); 10 | 11 | export default PurposeList; 12 | -------------------------------------------------------------------------------- /adr/000-README.md: -------------------------------------------------------------------------------- 1 | # Architectural decision records 2 | 3 | In this folder you'll find records of every impactful architectural decision. 4 | 5 | This helps keeping track of changes for maintainers, and provides guidance for 6 | users to understand the choices made over time. 7 | 8 | The records are loosely based on 9 | [MARP](https://www.ozimmer.ch/practices/2022/11/22/MADRTemplatePrimer.html). 10 | -------------------------------------------------------------------------------- /site/features/dsfr.js: -------------------------------------------------------------------------------- 1 | window.orejimeConfig = { 2 | purposes: [ 3 | { 4 | id: 'mandatory', 5 | title: 'Cookies techniques', 6 | description: 'Cookies nécéssaires au bon fonctionnement du site.', 7 | isMandatory: true 8 | }, 9 | { 10 | id: 'youtube', 11 | title: 'YouTube', 12 | description: 'Vidéos YouTube' 13 | } 14 | ], 15 | privacyPolicyUrl: '#privacyPolicy' 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "preact", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "outDir": "./dist/", 10 | "paths": { 11 | "uneval.js": ["./src/uneval.d.ts"] 12 | }, 13 | "target": "esnext" 14 | }, 15 | "include": ["./src"], 16 | "exclude": ["**/*.test.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/components/types/Purpose.ts: -------------------------------------------------------------------------------- 1 | import {ComponentChildren, FunctionComponent} from 'preact'; 2 | import {Purpose} from '../../types'; 3 | import {ConsentState} from './ConsentState'; 4 | 5 | export interface PurposeProps extends Omit { 6 | consent: ConsentState; 7 | children?: ComponentChildren; 8 | onChange: (checked: boolean) => void; 9 | } 10 | 11 | export type PurposeComponent = FunctionComponent; 12 | -------------------------------------------------------------------------------- /site/features/purposes.js: -------------------------------------------------------------------------------- 1 | window.orejimeConfig = { 2 | purposes: [ 3 | { 4 | id: 'mandatory', 5 | title: 'Technical cookies', 6 | description: 7 | "This can't be disabled as it is required " 8 | + 'for the website to function properly.', 9 | isMandatory: true 10 | }, 11 | { 12 | id: 'analytics', 13 | title: 'Analytics', 14 | description: 'This can be disabled' 15 | } 16 | ], 17 | privacyPolicyUrl: '#' 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/test-unit.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: lts/* 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Run tests 20 | run: npm run test-unit 21 | -------------------------------------------------------------------------------- /src/ui/components/types/Banner.ts: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from 'preact'; 2 | import {ImageDescriptor} from '../../types'; 3 | 4 | export interface BannerProps { 5 | isHidden: boolean; 6 | needsUpdate: boolean; 7 | purposeTitles: string[]; 8 | privacyPolicyUrl: string; 9 | logo?: ImageDescriptor; 10 | onAccept: () => void; 11 | onDecline: () => void; 12 | onConfigure: () => void; 13 | } 14 | 15 | export type BannerComponent = FunctionComponent; 16 | -------------------------------------------------------------------------------- /src/core/utils/arrays.ts: -------------------------------------------------------------------------------- 1 | // Same behavior as `[].every()`, but returns false when the array is empty. 2 | export const every = (array: T[], predicate: (element: T) => boolean) => 3 | array.length ? array.every(predicate) : false; 4 | 5 | export const withoutAll = (array: T[], unwanted: T[]) => 6 | array.filter((value) => !unwanted.includes(value)); 7 | 8 | export const indexBy = (array: T[], key: keyof T) => 9 | Object.fromEntries(array.map((obj) => [obj[key], obj])); 10 | -------------------------------------------------------------------------------- /src/ui/themes/standard/Icons.tsx: -------------------------------------------------------------------------------- 1 | interface CloseProps { 2 | title: string; 3 | } 4 | 5 | export const Close = ({title}: CloseProps) => ( 6 | 14 | {title} 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/ui/components/GlobalConsentContainer.tsx: -------------------------------------------------------------------------------- 1 | import {useManager, useTheme} from '../utils/hooks'; 2 | 3 | const GlobalConsentContainer = () => { 4 | const manager = useManager(); 5 | const {GlobalConsent} = useTheme(); 6 | 7 | return ( 8 | manager.acceptAll()} 12 | declineAll={() => manager.declineAll()} 13 | /> 14 | ); 15 | }; 16 | 17 | export default GlobalConsentContainer; 18 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/index.ts: -------------------------------------------------------------------------------- 1 | import {Theme} from '../../components/types/Theme'; 2 | import Banner from './Banner'; 3 | import ContextualNotice from './ContextualNotice'; 4 | import GlobalConsent from './GlobalConsent'; 5 | import Modal from './Modal'; 6 | import ModalBanner from './ModalBanner'; 7 | import Purpose from './Purpose'; 8 | import PurposeList from './PurposeList'; 9 | 10 | export default { 11 | Banner, 12 | ContextualNotice, 13 | GlobalConsent, 14 | Modal, 15 | ModalBanner, 16 | Purpose, 17 | PurposeList 18 | } as Theme; 19 | -------------------------------------------------------------------------------- /src/ui/components/PoweredByLink.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../utils/hooks'; 2 | 3 | interface PoweredByLinkProps { 4 | className?: string; 5 | } 6 | 7 | const PoweredByLink = ({className}: PoweredByLinkProps) => { 8 | const t = useTranslations(); 9 | 10 | return ( 11 | 17 | {t.misc.poweredBy} 18 | 19 | ); 20 | }; 21 | 22 | export default PoweredByLink; 23 | -------------------------------------------------------------------------------- /src/core/utils/objects.ts: -------------------------------------------------------------------------------- 1 | export const diff = ( 2 | obj1: T, 3 | obj2: T 4 | ): T => 5 | Object.entries(obj2).reduce( 6 | (diff, [key, value]) => 7 | value === obj1?.[key] ? diff : {...diff, [key]: value}, 8 | {} as T 9 | ); 10 | 11 | export const overwrite = ( 12 | defaults: {[key: string]: T}, 13 | values: {[key: string]: T} 14 | ): {[key: string]: T} => 15 | Object.keys(defaults).reduce( 16 | (c, key) => ({ 17 | ...c, 18 | [key]: values[key] ?? defaults[key] 19 | }), 20 | {} 21 | ); 22 | -------------------------------------------------------------------------------- /src/ui/themes/standard/index.ts: -------------------------------------------------------------------------------- 1 | import {Theme} from '../../components/types/Theme'; 2 | import Banner from './Banner'; 3 | import ContextualNotice from './ContextualNotice'; 4 | import GlobalConsent from './GlobalConsent'; 5 | import Modal from './Modal'; 6 | import ModalBanner from './ModalBanner'; 7 | import Purpose from './Purpose'; 8 | import PurposeList from './PurposeList'; 9 | import './index.css'; 10 | 11 | export default { 12 | Banner, 13 | ContextualNotice, 14 | GlobalConsent, 15 | Modal, 16 | ModalBanner, 17 | Purpose, 18 | PurposeList 19 | } as Theme; 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We will fix security issues on the current major version (i.e. on all of its 6 | minor versions). We will also fix the last minor version from the previous major 7 | version. 8 | 9 | | Version | Supported | 10 | | ------- | ------------------ | 11 | | 3.x | :white_check_mark: | 12 | | 2.2.x | :white_check_mark: | 13 | | < 2.2 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Please use the support form to report a security issue privately 18 | https://orejime.boscop.fr/#support. 19 | -------------------------------------------------------------------------------- /src/core/utils/objects.test.ts: -------------------------------------------------------------------------------- 1 | import {diff, overwrite} from './objects'; 2 | 3 | test('diff', () => { 4 | expect(diff({}, {})).toEqual({}); 5 | expect(diff({a: 1, b: 2}, {a: 1, b: 2})).toEqual({}); 6 | expect(diff({a: 1, b: 2}, {a: 1, b: 3})).toEqual({b: 3}); 7 | }); 8 | 9 | test('overwrite', () => { 10 | expect(overwrite({}, {})).toEqual({}); 11 | expect(overwrite({a: 1, b: 2}, {})).toEqual({a: 1, b: 2}); 12 | expect(overwrite({a: 1, b: 2}, {a: 1, b: 3})).toEqual({a: 1, b: 3}); 13 | expect(overwrite({a: 1}, {a: 1})).toEqual({a: 1}); 14 | expect(overwrite({}, {a: 1, b: 3})).toEqual({}); 15 | }); 16 | -------------------------------------------------------------------------------- /src/ui/themes/standard/ModalBanner.tsx: -------------------------------------------------------------------------------- 1 | import Dialog from '../../components/Dialog'; 2 | import Banner from './Banner'; 3 | import {useTranslations} from '../../utils/hooks'; 4 | import {ModalBannerComponent} from '../../components/types/ModalBanner'; 5 | 6 | const ModalBanner: ModalBannerComponent = (props) => { 7 | const t = useTranslations(); 8 | 9 | return ( 10 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default ModalBanner; 22 | -------------------------------------------------------------------------------- /src/ui/components/types/ContextualNotice.ts: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from 'preact'; 2 | import {Purpose} from '../../types'; 3 | 4 | export interface ContextualNoticeOptions extends Record { 5 | titleLevel?: '1' | '2' | '3' | '4' | '5' | '6'; 6 | } 7 | 8 | export interface ContextualNoticeProps { 9 | purpose: Purpose; 10 | data: Data; 11 | privacyPolicyUrl: string; 12 | onAccept: () => void; 13 | } 14 | 15 | export type ContextualNoticeComponent< 16 | Data extends ContextualNoticeOptions = ContextualNoticeOptions 17 | > = FunctionComponent>; 18 | -------------------------------------------------------------------------------- /src/ui/components/PurposeContainer.tsx: -------------------------------------------------------------------------------- 1 | import {useConsent, useTheme} from '../utils/hooks'; 2 | import {ConsentState} from './types/ConsentState'; 3 | import {PurposeProps} from './types/Purpose'; 4 | 5 | export interface PurposeContainerProps 6 | extends Omit {} 7 | 8 | const PurposeContainer = (props: PurposeContainerProps) => { 9 | const [consent, setConsent] = useConsent(props.id); 10 | const {Purpose} = useTheme(); 11 | 12 | return ( 13 | 18 | ); 19 | }; 20 | 21 | export default PurposeContainer; 22 | -------------------------------------------------------------------------------- /src/ui/components/types/Theme.ts: -------------------------------------------------------------------------------- 1 | import {BannerComponent} from './Banner'; 2 | import {ContextualNoticeComponent} from './ContextualNotice'; 3 | import {GlobalConsentComponent} from './GlobalConsent'; 4 | import {ModalComponent} from './Modal'; 5 | import {ModalBannerComponent} from './ModalBanner'; 6 | import {PurposeComponent} from './Purpose'; 7 | import {PurposeListComponent} from './PurposeList'; 8 | 9 | export interface Theme { 10 | Banner: BannerComponent; 11 | ContextualNotice: ContextualNoticeComponent; 12 | GlobalConsent: GlobalConsentComponent; 13 | Modal: ModalComponent; 14 | ModalBanner: ModalBannerComponent; 15 | Purpose: PurposeComponent; 16 | PurposeList: PurposeListComponent; 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/utils/template.ts: -------------------------------------------------------------------------------- 1 | import {JSX} from 'preact'; 2 | 3 | type TemplatePart = string | JSX.Element; 4 | 5 | interface TemplateVars { 6 | [name: string]: TemplatePart; 7 | } 8 | 9 | // Quick but effective implementation. 10 | // It could break if some part of the string were to be 11 | // exactly equal to a variable name, but this shouldn't 12 | // happen any time soon. 13 | export const template = ( 14 | string: string, 15 | vars: TemplateVars 16 | ): TemplatePart[] => { 17 | if (typeof string !== 'string') { 18 | return []; 19 | } 20 | 21 | return string 22 | .split(/\{(?!\{)([\w\d]+)\}(?!\})/gi) 23 | .filter((part) => !!part) 24 | .map((part) => (part in vars ? vars[part] : part)); 25 | }; 26 | -------------------------------------------------------------------------------- /src/core/EventEmitter.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import EventEmitter from './EventEmitter'; 3 | 4 | describe('EventEmitter', () => { 5 | type Events = { 6 | 'foo': () => void; 7 | 'bar': (value: number) => void; 8 | }; 9 | 10 | test('all', () => { 11 | const fooCallback = jest.fn(); 12 | const barCallback = jest.fn(); 13 | const emitter = new EventEmitter(); 14 | 15 | emitter.on('foo', fooCallback); 16 | emitter.on('bar', barCallback); 17 | emitter['emit']('bar', 1); 18 | emitter.off('bar', barCallback); 19 | emitter['emit']('bar', 2); 20 | 21 | expect(fooCallback.mock.calls).toEqual([]); 22 | expect(barCallback.mock.calls).toEqual([[1]]); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/ModalBanner.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../../utils/hooks'; 2 | import Dialog from '../../components/Dialog'; 3 | import type {ModalBannerComponent} from '../../components/types/ModalBanner'; 4 | import Banner from './Banner'; 5 | 6 | const ModalBanner: ModalBannerComponent = ({...props}) => { 7 | const t = useTranslations(); 8 | 9 | return ( 10 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default ModalBanner; 24 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": false, 4 | "experimentalOperatorPosition": "start", 5 | "htmlWhitespaceSensitivity": "ignore", 6 | "jsxSingleQuote": false, 7 | "printWidth": 80, 8 | "proseWrap": "always", 9 | "quoteProps": "preserve", 10 | "semi": true, 11 | "singleQuote": true, 12 | "tabWidth": 3, 13 | "trailingComma": "none", 14 | "useTabs": true, 15 | "overrides": [ 16 | { 17 | "files": ["package.json", "package-lock.json", "*.yml", "*.md"], 18 | "options": { 19 | "tabWidth": 2, 20 | "useTabs": false 21 | } 22 | }, 23 | { 24 | "files": "*.html", 25 | "options": { 26 | "htmlWhitespaceSensitivity": "css" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, devices} from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './e2e', 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 0, 8 | workers: process.env.CI ? 1 : undefined, 9 | reporter: 'list', 10 | use: { 11 | baseURL: 'http://127.0.0.1:3000', 12 | trace: 'on-first-retry' 13 | }, 14 | projects: [ 15 | { 16 | name: 'chromium', 17 | use: {...devices['Desktop Chrome']} 18 | }, 19 | { 20 | name: 'firefox', 21 | use: {...devices['Desktop Firefox']} 22 | } 23 | ], 24 | webServer: { 25 | command: 'npm run serve', 26 | url: 'http://127.0.0.1:3000', 27 | reuseExistingServer: !process.env.CI 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/core/CookiesConsentsEffect.ts: -------------------------------------------------------------------------------- 1 | import ConsentsEffect from './ConsentsEffect'; 2 | import {ConsentsMap, Purpose} from './types'; 3 | import {indexBy} from './utils/arrays'; 4 | import deletePurposeCookies from './utils/deletePurposeCookies'; 5 | 6 | export default class CookiesConsentsEffect implements ConsentsEffect { 7 | readonly #purposes: Record; 8 | 9 | constructor(purposes: Purpose[]) { 10 | this.#purposes = indexBy(purposes, 'id'); 11 | } 12 | 13 | apply(consents: ConsentsMap) { 14 | Object.entries(consents) 15 | .filter(([_, consent]) => !consent) 16 | .map(([id]) => this.#purposes[id].cookies) 17 | .filter((cookies) => cookies?.length) 18 | .forEach(deletePurposeCookies); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import {CookieSameSite} from './utils/cookies'; 2 | 3 | export type PurposeCookieProps = [ 4 | pattern: RegExp, 5 | path: string, 6 | domain: string 7 | ]; 8 | 9 | export type PurposeCookie = string | RegExp | PurposeCookieProps; 10 | 11 | export interface Purpose { 12 | id: string; 13 | isMandatory?: boolean; 14 | isExempt?: boolean; 15 | runsOnce?: boolean; 16 | default?: boolean; 17 | cookies: PurposeCookie[]; 18 | } 19 | 20 | export type ConsentsMap = {[id: Purpose['id']]: boolean}; 21 | 22 | export type CookieOptions = { 23 | name: string; 24 | domain?: string; 25 | duration: number; 26 | sameSite?: CookieSameSite; 27 | parse: (consents: string) => ConsentsMap; 28 | stringify: (consents: ConsentsMap) => string; 29 | }; 30 | -------------------------------------------------------------------------------- /src/core/utils/deletePurposeCookies.ts: -------------------------------------------------------------------------------- 1 | import {PurposeCookie} from '../types'; 2 | import {deleteCookie, getCookieNames} from './cookies'; 3 | import escapeRegex from './escapeRegex'; 4 | 5 | export default (cookies: PurposeCookie[]) => { 6 | const cookieNames = getCookieNames(); 7 | 8 | cookies.forEach((pattern) => { 9 | let path: string; 10 | let domain: string; 11 | 12 | if (pattern instanceof Array) { 13 | [pattern, path, domain] = pattern; 14 | } 15 | 16 | if (!(pattern instanceof RegExp)) { 17 | pattern = new RegExp(`^${escapeRegex(pattern)}$`); 18 | } 19 | 20 | cookieNames 21 | .filter((name) => (pattern as RegExp).test(name)) 22 | .forEach((cookie) => { 23 | deleteCookie(cookie, path, domain); 24 | }); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/migrations/serialization.ts: -------------------------------------------------------------------------------- 1 | import uneval, {type Opts} from 'uneval.js'; 2 | import {V2Config} from './v2/types'; 3 | 4 | export const parse = (code: string) => { 5 | // extracts the outer most object definition, trimming any 6 | // variable assignment or whitespace. 7 | const matches = /^[^{]*(\{.*\})[^}]*$/is.exec(code); 8 | 9 | if (!matches) { 10 | throw new Error(); 11 | } 12 | 13 | const configObject = matches[1]; 14 | let config: Partial = {}; 15 | 16 | // eval() will assign the object to the config variable 17 | // declared above. 18 | eval(`config = ${configObject}`); 19 | 20 | return config; 21 | }; 22 | 23 | export const stringify = (object: object, options: Opts = {}) => 24 | uneval(object, { 25 | safe: false, 26 | ...options 27 | }); 28 | -------------------------------------------------------------------------------- /.github/workflows/test-e2e.yml: -------------------------------------------------------------------------------- 1 | name: End-to-end tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | timeout-minutes: 5 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: lts/* 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Install Playwright browsers 21 | run: npx playwright install --with-deps 22 | - name: Run tests 23 | run: npm run test-e2e 24 | - uses: actions/upload-artifact@v4 25 | if: ${{ !cancelled() }} 26 | with: 27 | name: playwright-report 28 | path: playwright-report/ 29 | retention-days: 30 30 | -------------------------------------------------------------------------------- /src/core/utils/arrays.test.ts: -------------------------------------------------------------------------------- 1 | import {every, indexBy, withoutAll} from './arrays'; 2 | 3 | test('every', () => { 4 | const returnTrue = () => true; 5 | const returnFalse = () => false; 6 | 7 | expect(every([], returnTrue)).toEqual(false); 8 | expect(every([], returnFalse)).toEqual(false); 9 | expect(every([1], returnTrue)).toEqual(true); 10 | expect(every([1], returnFalse)).toEqual(false); 11 | }); 12 | 13 | test('withoutAll', () => { 14 | expect(withoutAll([], [])).toEqual([]); 15 | expect(withoutAll([1, 2], [])).toEqual([1, 2]); 16 | expect(withoutAll([1, 2], [1])).toEqual([2]); 17 | expect(withoutAll([1, 2], [1, 2])).toEqual([]); 18 | }); 19 | 20 | test('indexBy', () => { 21 | const foo = {id: 'foo'}; 22 | const bar = {id: 'bar'}; 23 | 24 | expect(indexBy([foo, bar], 'id')).toEqual({foo, bar}); 25 | }); 26 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/GlobalConsent.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../../utils/hooks'; 2 | import {ConsentState} from '../../components/types/ConsentState'; 3 | import {GlobalConsentComponent} from '../../components/types/GlobalConsent'; 4 | import Purpose from './Purpose'; 5 | 6 | const GlobalConsent: GlobalConsentComponent = ({ 7 | isEnabled, 8 | isDisabled, 9 | acceptAll, 10 | declineAll 11 | }) => { 12 | const t = useTranslations(); 13 | 14 | return ( 15 | 28 | ); 29 | }; 30 | 31 | export default GlobalConsent; 32 | -------------------------------------------------------------------------------- /src/ui/components/PurposeTree.tsx: -------------------------------------------------------------------------------- 1 | import type {PurposeList as PurposeListType} from '../types'; 2 | import PurposeGroupContainer from './PurposeGroupContainer'; 3 | import PurposeContainer from './PurposeContainer'; 4 | import {useTheme} from '../utils/hooks'; 5 | 6 | interface PurposeTreeProps { 7 | purposes: PurposeListType; 8 | } 9 | 10 | const PurposeTree = ({purposes}: PurposeTreeProps) => { 11 | const {PurposeList} = useTheme(); 12 | 13 | return ( 14 | 15 | {purposes.map((purpose) => 16 | 'purposes' in purpose ? ( 17 | 18 | 19 | 20 | ) : ( 21 | 22 | ) 23 | )} 24 | 25 | ); 26 | }; 27 | 28 | export default PurposeTree; 29 | -------------------------------------------------------------------------------- /src/setup.ts: -------------------------------------------------------------------------------- 1 | import Manager from './core/Manager'; 2 | import setupManager from './core/setup'; 3 | import {Config} from './ui/types'; 4 | import setupUi from './ui/setup'; 5 | import { 6 | assertConfigValidity, 7 | DefaultConfig, 8 | purposesOnly 9 | } from './ui/utils/config'; 10 | import {deepMerge} from './ui/utils/objects'; 11 | 12 | export interface OrejimeInstance { 13 | config: Config; 14 | manager: Manager; 15 | prompt: () => void; 16 | } 17 | 18 | export default (partialConfig: Partial): OrejimeInstance => { 19 | const config = deepMerge(DefaultConfig, partialConfig); 20 | assertConfigValidity(config); 21 | 22 | const manager = setupManager(purposesOnly(config.purposes), { 23 | cookie: config.cookie 24 | }); 25 | 26 | const {openModal} = setupUi(config, manager); 27 | 28 | return { 29 | config, 30 | manager, 31 | prompt: openModal 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/ui/utils/objects.ts: -------------------------------------------------------------------------------- 1 | type DeepObject = { 2 | [key: string]: DeepObject | any; 3 | }; 4 | 5 | const isObject = (obj: any) => obj && typeof obj === 'object'; 6 | 7 | // @see https://stackoverflow.com/a/48218209/2391359 8 | export const deepMerge = ( 9 | ...objects: T[] 10 | ): T => { 11 | return objects.reduce((object, current) => { 12 | (Object.keys(current) as K[]).forEach((key) => { 13 | const previousValue = object[key]; 14 | const value = current[key]; 15 | 16 | if (Array.isArray(previousValue) && Array.isArray(value)) { 17 | object[key] = previousValue.concat(...(value as any[])); 18 | } else if (isObject(previousValue) && isObject(value)) { 19 | object[key] = deepMerge(previousValue, value); 20 | } else { 21 | object[key] = value; 22 | } 23 | }); 24 | 25 | return object; 26 | }, {} as T); 27 | }; 28 | -------------------------------------------------------------------------------- /src/core/DomConsentsEffect.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import DomConsentsEffect from './DomConsentsEffect'; 3 | import {Purpose} from './types'; 4 | 5 | describe('DomConsentsEffect', () => { 6 | test('apply', () => { 7 | const purposes: Purpose[] = [ 8 | {id: 'a', cookies: []}, 9 | {id: 'b', cookies: [], runsOnce: true} 10 | ]; 11 | 12 | const apply = jest.fn(); 13 | const effect = new DomConsentsEffect(purposes, apply); 14 | 15 | effect.apply({ 16 | a: true, 17 | b: true 18 | }); 19 | 20 | effect.apply({ 21 | a: false, 22 | b: false 23 | }); 24 | 25 | effect.apply({ 26 | a: true, 27 | b: true 28 | }); 29 | 30 | expect(apply.mock.calls).toEqual([ 31 | ['a', true], 32 | ['b', true], 33 | ['a', false], 34 | ['b', false], 35 | ['a', true] 36 | // `b` is not set to true again because it already ran. 37 | ]); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/core/utils/updatePurposeElements.test.ts: -------------------------------------------------------------------------------- 1 | import updatePurposeElements from './updatePurposeElements'; 2 | 3 | test('updatePurposeElements', () => { 4 | document.body.innerHTML = ` 5 | 8 | `; 9 | 10 | updatePurposeElements('foo', false); 11 | expect(document.getElementById('foo')).toBeNull(); 12 | 13 | updatePurposeElements('foo', true); 14 | const foo2 = document.getElementById('foo')!; 15 | 16 | expect(foo2.getAttribute('id')).toEqual('foo'); 17 | expect(foo2.getAttribute('src')).toEqual('src'); 18 | expect(foo2.getAttribute('crossorigin')).toEqual('anonymous'); 19 | 20 | updatePurposeElements('foo', true); 21 | updatePurposeElements('foo', true); 22 | expect(document.querySelectorAll('script')).toHaveLength(1); 23 | 24 | updatePurposeElements('foo', false); 25 | expect(document.getElementById('foo')).toBeNull(); 26 | }); 27 | -------------------------------------------------------------------------------- /src/ui/components/PurposeGroupContainer.tsx: -------------------------------------------------------------------------------- 1 | import {ComponentChildren} from 'preact'; 2 | import {PurposeGroup as PurposeGroupType} from '../types'; 3 | import {useConsentGroup, useTheme} from '../utils/hooks'; 4 | import {ConsentState} from './types/ConsentState'; 5 | 6 | interface PurposeGroupProps extends PurposeGroupType { 7 | children: ComponentChildren; 8 | } 9 | 10 | const PurposeGroup = ({purposes, children, ...props}: PurposeGroupProps) => { 11 | const [areAllEnabled, areAllDisabled, acceptAll, declineAll] = 12 | useConsentGroup(purposes); 13 | const {Purpose} = useTheme(); 14 | 15 | return ( 16 | (consent ? acceptAll() : declineAll())} 26 | > 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default PurposeGroup; 33 | -------------------------------------------------------------------------------- /src/ui/components/StubManagerProvider.tsx: -------------------------------------------------------------------------------- 1 | import {ComponentChildren} from 'preact'; 2 | import {useContext, useRef} from 'preact/hooks'; 3 | import Context from './Context'; 4 | 5 | interface StubManagerProviderProps { 6 | children: (commit: () => void) => ComponentChildren; 7 | onCommit: () => void; 8 | } 9 | 10 | export default function StubManagerProvider({ 11 | children, 12 | onCommit 13 | }: StubManagerProviderProps) { 14 | const {manager, ...context} = useContext(Context); 15 | 16 | // Child components manipulate this clone as it was the 17 | // real thing, but we're using it as a temporary store. 18 | // Its data is copied into the real one when the user 19 | // explicitly saves his choices. 20 | const {current: deferred} = useRef(manager.clone()); 21 | 22 | const commit = () => { 23 | manager.setConsents(deferred.getAllConsents()); 24 | onCommit(); 25 | }; 26 | 27 | return ( 28 | 29 | {children(commit)} 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/core/CookieConsentsRepository.ts: -------------------------------------------------------------------------------- 1 | import ConsentsRepository from './ConsentsRepository'; 2 | import {ConsentsMap, CookieOptions} from './types'; 3 | import {deleteCookie, getCookie, setCookie} from './utils/cookies'; 4 | 5 | export default class CookieConsentsRepository implements ConsentsRepository { 6 | #options: CookieOptions; 7 | 8 | constructor(options: Partial = {}) { 9 | this.#options = { 10 | name: 'eu-consent', 11 | domain: undefined, 12 | duration: 120, 13 | sameSite: 'strict', 14 | parse: JSON.parse, 15 | stringify: JSON.stringify, 16 | ...options 17 | }; 18 | } 19 | 20 | read() { 21 | const {name, parse} = this.#options; 22 | const cookie = getCookie(name); 23 | return cookie ? parse(cookie) : {}; 24 | } 25 | 26 | write(consents: ConsentsMap) { 27 | const {name, domain, duration, sameSite, stringify} = this.#options; 28 | setCookie(name, stringify(consents), duration, domain, sameSite); 29 | } 30 | 31 | clear() { 32 | deleteCookie(this.#options.name); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/utils/template.test.ts: -------------------------------------------------------------------------------- 1 | import {template} from './template'; 2 | 3 | describe('template', () => { 4 | test('no vars', () => { 5 | expect(template('Bears eat beets', {})).toEqual(['Bears eat beets']); 6 | }); 7 | 8 | test('no match', () => { 9 | expect(template('Bears eat {beets}', {})).toEqual([ 10 | 'Bears eat ', 11 | 'beets' 12 | ]); 13 | }); 14 | 15 | test('vars', () => { 16 | expect( 17 | template('{animal} eat {thing}', { 18 | animal: 'Black bear', 19 | thing: 'Battlestar Galactica' 20 | }) 21 | ).toEqual(['Black bear', ' eat ', 'Battlestar Galactica']); 22 | }); 23 | 24 | // This is here to document the fact that the current 25 | // implementation breaks if some text is exactly the same 26 | // as one of the variable names 27 | test('edge case', () => { 28 | expect(template('thing{thing}', {})).toEqual(['thing', 'thing']); 29 | 30 | expect(template('thing{thing}', {thing: 'Battlestar Galactica'})).toEqual( 31 | ['Battlestar Galactica', 'Battlestar Galactica'] 32 | ); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/core/utils/updatePurposeElements.ts: -------------------------------------------------------------------------------- 1 | export default (id: string, consent: boolean) => { 2 | if (consent) { 3 | document 4 | .querySelectorAll( 5 | `template[data-purpose="${id}"]:not([data-active])` 6 | ) 7 | .forEach((template) => { 8 | template.dataset.active = 'active'; 9 | const content = template.content.cloneNode( 10 | true 11 | ) as DocumentFragment; 12 | 13 | Array.from(content.children).forEach((element: HTMLElement) => { 14 | // We're flagging each children with the 15 | // purpose id so we can find them again 16 | // if needed. 17 | element.dataset.purpose = template.dataset.purpose; 18 | template.insertAdjacentElement('afterend', element); 19 | }); 20 | }); 21 | } else { 22 | document 23 | .querySelectorAll(`[data-purpose="${id}"]`) 24 | .forEach((element) => { 25 | if (element.nodeName === 'TEMPLATE') { 26 | delete element.dataset.active; 27 | } else { 28 | element.remove(); 29 | } 30 | }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/ui/themes/standard/GlobalConsent.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../../utils/hooks'; 2 | import {GlobalConsentComponent} from '../../components/types/GlobalConsent'; 3 | 4 | const GlobalConsent: GlobalConsentComponent = ({ 5 | isEnabled, 6 | isDisabled, 7 | acceptAll, 8 | declineAll 9 | }) => { 10 | const t = useTranslations(); 11 | 12 | return ( 13 |
14 | 22 | 23 | 31 |
32 | ); 33 | }; 34 | 35 | export default GlobalConsent; 36 | -------------------------------------------------------------------------------- /src/core/DomConsentsEffect.ts: -------------------------------------------------------------------------------- 1 | import ConsentsEffect from './ConsentsEffect'; 2 | import {ConsentsMap, Purpose} from './types'; 3 | 4 | export type ApplyConsent = (purposeId: Purpose['id'], state: boolean) => void; 5 | 6 | export default class DomConsentsEffect implements ConsentsEffect { 7 | readonly #apply: ApplyConsent; 8 | readonly #singletonPurposes: ConsentsMap; 9 | readonly #alreadyExecuted: ConsentsMap; 10 | 11 | constructor(purposes: Purpose[], apply: ApplyConsent) { 12 | this.#apply = apply; 13 | this.#singletonPurposes = Object.fromEntries( 14 | purposes.map(({id, runsOnce}) => [id, !!runsOnce]) 15 | ); 16 | 17 | this.#alreadyExecuted = Object.fromEntries( 18 | purposes.map(({id}) => [id, false]) 19 | ); 20 | } 21 | 22 | apply(consents: ConsentsMap) { 23 | Object.entries(consents) 24 | .filter( 25 | ([id, consent]) => 26 | !consent 27 | || !this.#singletonPurposes[id] 28 | || !this.#alreadyExecuted?.[id] 29 | ) 30 | .forEach(([id, consent]) => { 31 | this.#apply(id, consent); 32 | this.#alreadyExecuted[id] = true; 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/core/setup.ts: -------------------------------------------------------------------------------- 1 | import CookieConsentsRepository from './CookieConsentsRepository'; 2 | import CookiesConsentsEffect from './CookiesConsentsEffect'; 3 | import DomConsentsEffect from './DomConsentsEffect'; 4 | import Manager from './Manager'; 5 | import {CookieOptions, Purpose} from './types'; 6 | import updatePurposeElements from './utils/updatePurposeElements'; 7 | 8 | type SetupOptions = { 9 | cookie?: CookieOptions; 10 | }; 11 | 12 | export default (purposes: Purpose[], options: SetupOptions) => { 13 | const domEffect = new DomConsentsEffect(purposes, updatePurposeElements); 14 | const cookiesEffect = new CookiesConsentsEffect(purposes); 15 | const repository = new CookieConsentsRepository(options?.cookie); 16 | const manager = new Manager(purposes, repository.read()); 17 | const consents = manager.getAllConsents(); 18 | 19 | manager.on('update', (diff, all) => { 20 | domEffect.apply(diff); 21 | cookiesEffect.apply(diff); 22 | repository.write(all); 23 | }); 24 | 25 | manager.on('clear', () => { 26 | repository.clear(); 27 | }); 28 | 29 | domEffect.apply(consents); 30 | cookiesEffect.apply(consents); 31 | 32 | return manager; 33 | }; 34 | -------------------------------------------------------------------------------- /site/assets/logo-autorite-protection-donnees.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/core/utils/cookies.ts: -------------------------------------------------------------------------------- 1 | import Cookie from 'js-cookie'; 2 | 3 | export type CookieSameSite = 'strict' | 'lax' | 'none'; 4 | 5 | export const getCookieNames = () => 6 | document.cookie.split(';').reduce((names, cookie) => { 7 | const [name] = cookie.split('=', 2); 8 | return name ? names.concat(name.trim()) : names; 9 | }, [] as string[]); 10 | 11 | export const getCookie = (name: string) => Cookie.get(name); 12 | 13 | export const setCookie = ( 14 | name: string, 15 | value = '', 16 | days = 0, 17 | domain?: string, 18 | sameSite?: CookieSameSite 19 | ) => { 20 | Cookie.set(name, value, { 21 | expires: days, 22 | domain, 23 | sameSite 24 | }); 25 | }; 26 | 27 | export const deleteCookie = (name: string, path?: string, domain?: string) => { 28 | if (domain) { 29 | Cookie.remove(name, { 30 | path, 31 | domain 32 | }); 33 | 34 | return; 35 | } 36 | 37 | // if domain is not defined, try to delete cookie on multiple default domains 38 | Cookie.remove(name, { 39 | path, 40 | domain: location.hostname 41 | }); 42 | 43 | Cookie.remove(name, { 44 | path, 45 | domain: location.hostname.split('.').slice(-2).join('.') 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/ui/utils/objects.test.ts: -------------------------------------------------------------------------------- 1 | import {deepMerge} from './objects'; 2 | 3 | // @see https://github.com/TehShrike/deepmerge#example-usage 4 | test('deepMerge', () => { 5 | const x = { 6 | foo: { 7 | bar: 3 8 | }, 9 | array: [ 10 | { 11 | does: 'work', 12 | too: [1, 2, 3] 13 | } 14 | ], 15 | object: { 16 | key: 'value', 17 | nested: { 18 | key: 'value' 19 | } 20 | } 21 | }; 22 | 23 | const y = { 24 | foo: { 25 | baz: 4 26 | }, 27 | quux: 5, 28 | array: [ 29 | { 30 | does: 'work', 31 | too: [4, 5, 6] 32 | }, 33 | { 34 | really: 'yes' 35 | } 36 | ], 37 | object: { 38 | key: 'override', 39 | nested: { 40 | key: 'override' 41 | } 42 | } 43 | }; 44 | 45 | expect(deepMerge(x, y as object)).toEqual({ 46 | foo: { 47 | bar: 3, 48 | baz: 4 49 | }, 50 | quux: 5, 51 | array: [ 52 | { 53 | does: 'work', 54 | too: [1, 2, 3] 55 | }, 56 | { 57 | does: 'work', 58 | too: [4, 5, 6] 59 | }, 60 | { 61 | really: 'yes' 62 | } 63 | ], 64 | object: { 65 | key: 'override', 66 | nested: { 67 | key: 'override' 68 | } 69 | } 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/core/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | export default class EventEmitter< 2 | Types extends { 3 | [name: string]: (...args: any[]) => void; 4 | } 5 | > { 6 | #listeners: Partial<{ 7 | [Key in keyof Types]: Array; 8 | }>; 9 | 10 | constructor() { 11 | this.#listeners = {}; 12 | } 13 | 14 | on(event: Key, listener: Types[Key]): void { 15 | if (!(event in this.#listeners)) { 16 | this.#listeners[event] = []; 17 | } 18 | 19 | (this.#listeners[event] as Types[Key][]).push(listener); 20 | } 21 | 22 | off(event: Key, listener: Types[Key]): void { 23 | if (!(event in this.#listeners)) { 24 | return; 25 | } 26 | 27 | const index = (this.#listeners[event] as Types[Key][]).findIndex( 28 | (l) => l === listener 29 | ); 30 | 31 | if (index >= 0) { 32 | (this.#listeners[event] as Types[Key][]).splice(index, 1); 33 | } 34 | } 35 | 36 | protected emit( 37 | event: Key, 38 | ...args: Parameters 39 | ): void { 40 | if (!(event in this.#listeners)) { 41 | return; 42 | } 43 | 44 | (this.#listeners[event] as Types[Key][]).forEach((listener) => { 45 | listener(...args); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/utils/purposes.ts: -------------------------------------------------------------------------------- 1 | import {ConsentsMap, Purpose} from '../types'; 2 | import {every} from './arrays'; 3 | 4 | export const isConsentValid = ( 5 | {id, isExempt, isMandatory}: Purpose, 6 | consents: ConsentsMap 7 | ) => (isExempt ? true : isMandatory ? consents?.[id] : id in consents); 8 | 9 | export const defaultConsents = (purposes: Purpose[]): ConsentsMap => 10 | Object.fromEntries( 11 | purposes.map(({id, isMandatory, default: d}) => [id, isMandatory || !!d]) 12 | ); 13 | 14 | export const acceptedConsents = (purposes: Purpose[]): ConsentsMap => 15 | Object.fromEntries(purposes.map(({id}) => [id, true])); 16 | 17 | export const declinedConsents = (purposes: Purpose[]): ConsentsMap => 18 | Object.fromEntries(purposes.map(({id}) => [id, false])); 19 | 20 | export const areAllPurposesMandatory = (purposes: Purpose[]) => 21 | every(purposes, ({isMandatory}) => isMandatory); 22 | 23 | export const areAllPurposesEnabled = ( 24 | purposes: Purpose[], 25 | consents: ConsentsMap 26 | ) => every(purposes, ({id}) => consents?.[id]); 27 | 28 | export const areAllPurposesDisabled = ( 29 | purposes: Purpose[], 30 | consents: ConsentsMap 31 | ) => every(purposes, ({id, isMandatory}) => isMandatory || !consents?.[id]); 32 | -------------------------------------------------------------------------------- /src/ui/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | ImageDescriptor, 4 | Purpose, 5 | PurposeList, 6 | Translations 7 | } from '../types'; 8 | 9 | export const DefaultConfig: Partial = { 10 | privacyPolicyUrl: '', 11 | forceModal: false, 12 | forceBanner: false, 13 | translations: {} as Translations, 14 | purposes: [] 15 | }; 16 | 17 | export function assertConfigValidity( 18 | config: Partial 19 | ): asserts config is Config { 20 | if (!Object.keys(config.purposes).length) { 21 | throw new Error('Orejime config: you must define `purposes`'); 22 | } 23 | 24 | if (!config.privacyPolicyUrl.length) { 25 | throw new Error('Orejime config: you must define `privacyPolicyUrl`'); 26 | } 27 | } 28 | 29 | // Strips groups from a list of purposes and purpose groups. 30 | export const purposesOnly = (purposes: PurposeList): Purpose[] => 31 | purposes.flatMap((purpose) => 32 | 'purposes' in purpose 33 | ? purposesOnly(purpose.purposes) 34 | : [purpose as Purpose] 35 | ); 36 | 37 | export const imageAttributes = (image: ImageDescriptor) => { 38 | if (typeof image === 'string') { 39 | return { 40 | src: image, 41 | alt: '' 42 | }; 43 | } 44 | 45 | return { 46 | src: '', 47 | alt: '', 48 | ...image 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/ContextualNotice.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../../utils/hooks'; 2 | import type { 3 | ContextualNoticeComponent, 4 | ContextualNoticeOptions 5 | } from '../../components/types/ContextualNotice'; 6 | import {template} from '../../utils/template'; 7 | 8 | const ContextualNotice: ContextualNoticeComponent = ({ 9 | purpose, 10 | data, 11 | onAccept 12 | }) => { 13 | const t = useTranslations(); 14 | const {titleLevel} = data; 15 | const TitleTag: `h${ContextualNoticeOptions['titleLevel']}` = titleLevel 16 | ? `h${titleLevel}` 17 | : 'h4'; 18 | 19 | const templateProps = { 20 | purpose: purpose.title 21 | }; 22 | 23 | return ( 24 | 45 | ); 46 | }; 47 | 48 | export default ContextualNotice; 49 | -------------------------------------------------------------------------------- /site/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ui/setup.tsx: -------------------------------------------------------------------------------- 1 | import {createRef, render} from 'preact'; 2 | import Manager from '../core/Manager'; 3 | import Context from './components/Context'; 4 | import Main, {MainApi} from './components/Main'; 5 | import type {Config} from './types'; 6 | import {getRootElement} from './utils/dom'; 7 | import {once} from './utils/functions'; 8 | import ContextualConsentsEffect from './ContextualConsentsEffect'; 9 | 10 | export default (config: Config, manager: Manager) => { 11 | const element = getRootElement(config.orejimeElement); 12 | const apiRef = createRef(); 13 | const show = once(() => { 14 | render( 15 | 21 |
22 | , 23 | element 24 | ); 25 | }); 26 | 27 | const openModal = () => { 28 | show(); 29 | apiRef.current!.openModal(); 30 | }; 31 | 32 | const contextualEffect = new ContextualConsentsEffect(config, manager); 33 | 34 | manager.on('update', (consents) => { 35 | contextualEffect.apply(consents); 36 | }); 37 | 38 | contextualEffect.apply(manager.getAllConsents()); 39 | 40 | manager.on('dirty', (isDirty) => { 41 | if (isDirty) { 42 | show(); 43 | } 44 | }); 45 | 46 | if (manager.isDirty()) { 47 | show(); 48 | } 49 | 50 | return { 51 | openModal 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /adr/003-purpose-templates.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-02-08 3 | status: Accepted 4 | --- 5 | 6 | # Purpose templates 7 | 8 | ## Context 9 | 10 | The current way of setting up third-party scripts is kind of confusing and 11 | requires shaky machanics to enable or disable them. There might be a leaner way 12 | to handle this. 13 | 14 | ## Considerations 15 | 16 | - Having to modify script attributes is tedious, and can be complicated within 17 | some environments. 18 | - We're relying on hacky mechanics, namely the `type="orejime"` attribute. This 19 | makes the implementation in user land hard to explain. 20 | - The current implementation relies on data attributes to "backup" actual 21 | attributes when disabling a script, and tag removal and reinsertion when 22 | enabling it. This leads to all sort of edge cases that are hard to pinpoint. 23 | - The implementation varies depending on the HTML element that must be toggled 24 | (scripts are a special case). 25 | 26 | ## Decision 27 | 28 | Instead of modifying elements, we'll wrap them inside `template` tags. This way 29 | : 30 | 31 | - The original script or element is left untouched. 32 | - This is a native and straighforward functionality. 33 | - One tag and attribute makes for less syntactic bloat than the previous prefix 34 | system. 35 | - With the same amount of code, a single purpose can act on one or many HTML 36 | elements. 37 | -------------------------------------------------------------------------------- /site/features/orejime.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Orejime 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | <% if (css) { %> 18 | 22 | 25 | <% } %> 26 | 27 |
28 |
29 | <% if (css) { %> <%= css.highlightedCode %> <% } %> <% if (!css && 30 | js) { %> <%= js.highlightedCode %> <% } %> 31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 | <% if (js) { %> 39 | 42 | <% } %> 43 | 44 | 45 | 46 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /site/features/wcag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Orejime 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 29 | 30 |
31 | 32 |
33 |
34 | 35 | 46 | 47 | 48 | 49 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/umd.ts: -------------------------------------------------------------------------------- 1 | import setup, {OrejimeInstance} from './setup'; 2 | import type {Config, Translations} from './ui/types'; 3 | import {deepMerge} from './ui/utils/objects'; 4 | import {Theme} from './ui/components/types/Theme'; 5 | 6 | export type LoadOrejime = (config: Partial) => OrejimeInstance; 7 | 8 | declare global { 9 | interface Window { 10 | orejimeConfig: Partial; 11 | orejime: OrejimeInstance; 12 | loadOrejime: LoadOrejime; 13 | } 14 | } 15 | 16 | export const setupUmd = (theme: Theme, translations: Translations) => { 17 | const load: LoadOrejime = (config) => { 18 | const orejime = setup( 19 | deepMerge( 20 | { 21 | translations 22 | }, 23 | { 24 | ...config, 25 | theme 26 | } 27 | ) 28 | ); 29 | 30 | // As Orejime is loaded asynchronously, we're 31 | // emitting an event to let potential listeners 32 | // know when it is done loading. 33 | if (typeof CustomEvent !== 'undefined') { 34 | document.dispatchEvent( 35 | new CustomEvent('orejime.loaded', { 36 | detail: orejime 37 | }) 38 | ); 39 | } 40 | 41 | return orejime; 42 | }; 43 | 44 | const autoload = async () => { 45 | window.loadOrejime = load; 46 | 47 | if (window.orejimeConfig !== undefined && window.orejime === undefined) { 48 | window.orejime = load(window.orejimeConfig); 49 | } 50 | }; 51 | 52 | if (document.readyState === 'loading') { 53 | document.addEventListener('DOMContentLoaded', autoload); 54 | } else { 55 | autoload(); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/ui/themes/standard/ContextualNotice.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../../utils/hooks'; 2 | import type { 3 | ContextualNoticeComponent, 4 | ContextualNoticeOptions 5 | } from '../../components/types/ContextualNotice'; 6 | import {template} from '../../utils/template'; 7 | 8 | const ContextualNotice: ContextualNoticeComponent = ({ 9 | purpose, 10 | data, 11 | onAccept, 12 | privacyPolicyUrl 13 | }) => { 14 | const t = useTranslations(); 15 | const {titleLevel} = data; 16 | const TitleTag: `h${ContextualNoticeOptions['titleLevel']}` | 'strong' = 17 | titleLevel ? `h${titleLevel}` : 'strong'; 18 | const templateProps = { 19 | purpose: purpose.title, 20 | privacyPolicy: ( 21 | 22 | {t.contextual.privacyPolicyLabel} 23 | 24 | ) 25 | }; 26 | 27 | return ( 28 |
29 | 30 | {template(t.contextual.title, templateProps)} 31 | 32 | 33 |

34 | {template(t.contextual.description, templateProps)} 35 |

36 | 37 | 48 |
49 | ); 50 | }; 51 | 52 | export default ContextualNotice; 53 | -------------------------------------------------------------------------------- /site/features/contextual.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Orejime 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 31 | 32 | 33 |
34 | 35 | 50 | 51 | 52 | 53 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /adr/002-standalone-bundles.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-02-07 3 | status: Accepted 4 | --- 5 | 6 | # Standalone bundles 7 | 8 | ## Context 9 | 10 | As of now, Orejime is split into many chunks to isolate the core from the 11 | language and UI. However, it is often used on websites using a single language, 12 | and always a single theme. 13 | 14 | ## Considerations 15 | 16 | - Async loading relies on bundler magic to locate chunks. 17 | - A bundle containing the core, lang and UI is more optimized than when split 18 | and loaded asynchronously, because resources are mutualized and compression 19 | acts on the whole bundle at once. Also, this save the time needed to import 20 | the chunks. 21 | - Loading different versions of a bundle requires no more work than setting 22 | options in the config. In most cases, this will actually be easier. 23 | - The split was meant to reduce network and preocessing usage by loading the 24 | bare minimum code. However, Orejime would pop anytime a user visits a website 25 | for the first time, and the UI would thus be loaded and cached. We might as 26 | well load and cache the whole bundle at once. 27 | - Generating a bundle for each combination of themes and languages could lead to 28 | bloat, but we shouldn't add lots of themes. Anyways, even a large list of 29 | bundles wouldn't really be a problem, as long as each one is optimized. 30 | 31 | ## Decision 32 | 33 | Orejime will now be distributed as many standalone packages, each providing a 34 | single theme and language. For example, `orejime-dsfr-en` would provide orejime 35 | with the DSFR theme and the english translations. 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Original work Copyright (c) 2018, DPKit 4 | Modified work Copyright (c) 2019, Boscop 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /src/migrations/v3/index.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../../ui/types'; 2 | import {parse, stringify} from '../serialization'; 3 | import {migrateConfig} from './config'; 4 | import {migrateTranslations} from './translations'; 5 | 6 | export default (code: string) => { 7 | const v2Config = parse(code); 8 | const v3Config = migrateConfig(v2Config); 9 | const v2Translations = v2Config?.translations ?? {}; 10 | const translationsEntries: [string, Partial][] = 11 | Object.entries(v2Translations).map(([lang, translations]) => [ 12 | lang, 13 | migrateTranslations(translations) 14 | ]); 15 | 16 | switch (translationsEntries.length) { 17 | case 0: 18 | return stringify(v3Config); 19 | 20 | // adds the only language defined to the config 21 | case 1: 22 | return stringify({ 23 | ...v3Config, 24 | translations: translationsEntries[0][1] 25 | }); 26 | 27 | // defines a map of translations, and adds one of them 28 | // to the config at runtime, depending on the current 29 | // language. 30 | default: { 31 | const lang = v2Config?.lang ?? translationsEntries[0][0]; 32 | const translations = Object.fromEntries(translationsEntries); 33 | const placeholder = Symbol(); 34 | v3Config.translations = placeholder as never as Translations; 35 | 36 | return ( 37 | `// replace this with the current language of the page\n` 38 | + `var lang = "${lang}";\n\n` 39 | + `var translations = ${stringify(translations)};\n\n` 40 | + `var config = ${stringify(v3Config, { 41 | namespace: new Map([ 42 | [ 43 | placeholder, 44 | 'translations[lang] // uses translations for the current language' 45 | ] 46 | ]) 47 | })};\n` 48 | ); 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/translations/fi.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Finnish. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Keräämme ja käsittelemme henkilötietoja seuraaviin tarkoituksiin: {purposes}.\nVoit lukea lisätietoja {privacyPolicy}.', 9 | privacyPolicyLabel: 'tietosuojasivultamme', 10 | accept: 'Hyväksy', 11 | acceptTitle: 'Hyväksy kaikki evästeet', 12 | decline: 'Hylkää', 13 | declineTitle: 'Hylkää kaikki valinnaiset evästeet', 14 | configure: 'Konfiguroida', 15 | configureTitle: 'Määritä evästeet' 16 | }, 17 | modal: { 18 | title: 'Keräämämme tiedot', 19 | description: 20 | 'Voit tarkastella ja muokata sinusta keräämiämme tietoja.\nVoit lukea lisätietoja {privacyPolicy}.', 21 | privacyPolicyLabel: 'tietosuojasivultamme', 22 | close: 'Sulje', 23 | closeTitle: 'Sulje asetukset', 24 | globalPreferences: 'Kaikkien palvelujen asetukset', 25 | acceptAll: 'Hyväksyä kaikki', 26 | declineAll: 'Hylkää kaikki', 27 | save: 'Tallenna', 28 | saveTitle: null 29 | }, 30 | contextual: { 31 | title: '"{purpose}" ei ole aktiivinen', 32 | description: 'Salli evästeiden käyttää tätä toimintoa.', 33 | privacyPolicyLabel: 'tietosuojasivultamme', 34 | accept: 'Salli', 35 | accepted: '"{purpose}" on nyt sallittu.' 36 | }, 37 | purpose: { 38 | mandatory: 'vaaditaan', 39 | mandatoryTitle: 'Sivusto vaatii tämän aina', 40 | exempt: 'ladataan oletuksena', 41 | exemptTitle: 'Ladataan oletuksena (mutta voit ottaa sen pois päältä)', 42 | showMore: 'Lue lisää', 43 | accept: 'Hyväksy', 44 | decline: 'Hylkää', 45 | enabled: 'käytössä', 46 | disabled: 'pois käytöstä', 47 | partial: 'osittainen' 48 | }, 49 | misc: { 50 | newWindowTitle: 'uusi ikkuna', 51 | updateNeeded: 52 | 'Olemme tehneet muutoksia ehtoihin viime vierailusi jälkeen, tarkista ehdot.', 53 | poweredBy: 'Palvelun tarjoaa Orejime' 54 | } 55 | } satisfies Translations as Translations; 56 | -------------------------------------------------------------------------------- /src/translations/en.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // English. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'We collect and process your personal information for the following purposes: {purposes}.\nTo learn more, please read our {privacyPolicy}.\n', 9 | privacyPolicyLabel: 'privacy policy', 10 | accept: 'Accept', 11 | acceptTitle: 'Accept all cookies', 12 | decline: 'Decline', 13 | declineTitle: 'Decline optional cookies', 14 | configure: 'Configure', 15 | configureTitle: 'Configure cookies' 16 | }, 17 | modal: { 18 | title: 'Information that we collect', 19 | description: 20 | 'Here you can see and customize the information that we collect about you.\nTo learn more, please read our {privacyPolicy}.\n', 21 | privacyPolicyLabel: 'privacy policy', 22 | close: 'Close', 23 | closeTitle: 'Close preferences', 24 | globalPreferences: 'Global preferences', 25 | acceptAll: 'Accept all apps', 26 | declineAll: 'Decline all apps', 27 | save: 'Save', 28 | saveTitle: 'Save my configuration on collected information' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" is inactive', 32 | description: 'Allow cookies to access this functionality.', 33 | privacyPolicyLabel: 'privacy policy', 34 | accept: 'Allow', 35 | accepted: '"{purpose}" is now allowed.' 36 | }, 37 | purpose: { 38 | mandatory: 'always required', 39 | mandatoryTitle: 'This application is always required', 40 | exempt: 'opt-out', 41 | exemptTitle: 'This app is loaded by default (but you can opt out)', 42 | showMore: 'Show more details', 43 | accept: 'Accept', 44 | decline: 'Decline', 45 | enabled: 'enabled', 46 | disabled: 'disabled', 47 | partial: 'partial' 48 | }, 49 | misc: { 50 | newWindowTitle: 'new window', 51 | updateNeeded: 52 | 'There were changes since your last visit, please update your consent.', 53 | poweredBy: 'Powered by Orejime' 54 | } 55 | } satisfies Translations as Translations; 56 | -------------------------------------------------------------------------------- /src/translations/sv.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Swedish. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Vi samlar och bearbetar din personliga data i följande syften: {purposes}.\nFör att veta mer, läs vår {privacyPolicy}.', 9 | privacyPolicyLabel: 'Integritetspolicy', 10 | accept: 'Acceptera', 11 | acceptTitle: 'Acceptera alla cookies', 12 | decline: 'Avböj', 13 | declineTitle: 'Avvisa alla valfria cookies', 14 | configure: 'Konfigurera', 15 | configureTitle: 'Konfigurera cookies' 16 | }, 17 | modal: { 18 | title: 'Information som vi samlar', 19 | description: 20 | 'Här kan du se och anpassa vilken information vi samlar om dig.\nFör att veta mer, läs vår {privacyPolicy}.', 21 | privacyPolicyLabel: 'Integritetspolicy', 22 | close: 'Stäng', 23 | closeTitle: 'Nära preferenser', 24 | globalPreferences: 'Preferenser för alla tjänster', 25 | acceptAll: 'Acceptera alla', 26 | declineAll: 'Tacka nej till alla', 27 | save: 'Spara', 28 | saveTitle: null 29 | }, 30 | contextual: { 31 | title: '"{purpose}" är inaktiv', 32 | description: 'Tillåt cookies att få tillgång till denna funktionalitet.', 33 | privacyPolicyLabel: 'Integritetspolicy', 34 | accept: 'Tillåta', 35 | accepted: '"{purpose}" är nu tillåtet.' 36 | }, 37 | purpose: { 38 | mandatory: 'Krävs alltid', 39 | mandatoryTitle: 'Den här applikationen krävs alltid', 40 | exempt: 'Avaktivera', 41 | exemptTitle: 42 | 'Den här appen laddas som standardinställning (men du kan avaktivera den)', 43 | showMore: 'Visa mer', 44 | accept: 'Acceptera', 45 | decline: 'Avböj', 46 | enabled: 'aktiverad', 47 | disabled: 'deaktiverad', 48 | partial: 'partiell' 49 | }, 50 | misc: { 51 | newWindowTitle: 'nytt fönster', 52 | updateNeeded: 53 | 'Det har skett förändringar sedan ditt senaste besök, var god uppdatera ditt medgivande.', 54 | poweredBy: 'Körs på Orejime' 55 | } 56 | } satisfies Translations as Translations; 57 | -------------------------------------------------------------------------------- /src/translations/it.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Italian. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Raccogliamo ed elaboriamo le vostre informazioni personali per i seguenti scopi: {purposes}.\nPer saperne di più, leggi la nostra {privacyPolicy}.', 9 | privacyPolicyLabel: 'policy privacy', 10 | accept: 'Accetto', 11 | acceptTitle: 'Accettare tutti i cookie', 12 | decline: 'Rifiuta', 13 | declineTitle: 'Rifiutare tutti i cookie opzionali', 14 | configure: 'Configurare', 15 | configureTitle: 'Configurare i cookie' 16 | }, 17 | modal: { 18 | title: 'Informazioni che raccogliamo', 19 | description: 20 | 'Qui puoi vedere e scegliere le informazioni che raccogliamo su di te.\nPer saperne di più, leggi la nostra {privacyPolicy}.', 21 | privacyPolicyLabel: 'policy privacy', 22 | close: 'Chiudere', 23 | closeTitle: 'Chiudi preferenze', 24 | globalPreferences: 'Preferenze globali', 25 | acceptAll: 'Accettare tutto', 26 | declineAll: 'Rifiuta tutto', 27 | save: 'Salva', 28 | saveTitle: null 29 | }, 30 | contextual: { 31 | title: '"{purpose}" è inattivo', 32 | description: 'Consenti ai cookie di accedere a questa funzionalità.', 33 | privacyPolicyLabel: 'policy privacy', 34 | accept: 'Permettere', 35 | accepted: '"{purpose}" ora è consentito.' 36 | }, 37 | purpose: { 38 | mandatory: 'sempre richiesto', 39 | mandatoryTitle: "Quest'applicazione è sempre richiesta", 40 | exempt: 'opt-out', 41 | exemptTitle: 42 | "Quest'applicazione è caricata di default (ma puoi disattivarla)", 43 | showMore: 'Mostra di più', 44 | accept: 'Accetto', 45 | decline: 'Rifiuta', 46 | enabled: 'abilitata', 47 | disabled: 'disabilitata', 48 | partial: 'parziale' 49 | }, 50 | misc: { 51 | newWindowTitle: 'nuova finestra', 52 | updateNeeded: 53 | 'Ci sono stati cambiamenti dalla tua ultima visita, aggiorna il tuo consenso.', 54 | poweredBy: 'Realizzato da Orejime' 55 | } 56 | } satisfies Translations as Translations; 57 | -------------------------------------------------------------------------------- /src/translations/et.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Estonian. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Me kogume ja töötleme teie isikuandmeid järgmistel eesmärkidel: {purposes}.\nLisateabe saamiseks lugege palun meie {privacyPolicy}', 9 | privacyPolicyLabel: 'privaatsustingimused', 10 | accept: 'Nõustu', 11 | acceptTitle: 'Aktsepteeri kõik küpsised', 12 | decline: 'Keeldu', 13 | declineTitle: 'Keelata kõik valikulised küpsised', 14 | configure: 'Seadistada', 15 | configureTitle: 'Seadistada küpsiseid' 16 | }, 17 | modal: { 18 | title: 'Isikuandmete kogumine', 19 | description: 20 | 'Siit saate vaadata ja hallata teavet, mida me teie kohta kogume.\nLisateabe saamiseks lugege palun meie {privacyPolicy}', 21 | privacyPolicyLabel: 'privaatsustingimused', 22 | close: 'Sulge', 23 | closeTitle: 'Sule eelistused', 24 | globalPreferences: 'Eelistused kõigile teenustele', 25 | acceptAll: 'Nõustu kõigi rakendustega', 26 | declineAll: 'Keela kõik rakendused', 27 | save: 'Salvesta', 28 | saveTitle: 'Salvesta kogutud teabe seadistused' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" on passiivne', 32 | description: 'Luba küpsistele sellele funktsioonile juurde pääseda.', 33 | privacyPolicyLabel: 'privaatsustingimused', 34 | accept: 'Luba', 35 | accepted: '"{purpose}" on nüüd lubatud.' 36 | }, 37 | purpose: { 38 | mandatory: 'alati vajalik', 39 | mandatoryTitle: 'See rakendus on alati vajalik', 40 | exempt: 'Opt-Out', 41 | exemptTitle: 42 | 'See rakendus on vaikimisi lisatud (kuid saate sellest loobuda)', 43 | showMore: 'Näita rohkem', 44 | accept: 'Nõustu', 45 | decline: 'Keeldu', 46 | enabled: 'lubatud', 47 | disabled: 'välja lülitatud', 48 | partial: 'osaline' 49 | }, 50 | misc: { 51 | newWindowTitle: 'uus aken', 52 | updateNeeded: 53 | 'Pärast teie viimast külastust on toimunud muudatusi, palun uuendage oma nõusolekut.', 54 | poweredBy: 'Teenuse pakkuja on Orejime' 55 | } 56 | } satisfies Translations; 57 | -------------------------------------------------------------------------------- /src/translations/nb.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Norwegian. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Vi samler inn og prosesserer din personlige informasjon av følgende årsaker: {purposes}.\nFor å lære mer, vennligst les vår {privacyPolicy}.', 9 | privacyPolicyLabel: 'personvernerklæring', 10 | accept: 'Akseptere', 11 | acceptTitle: 'Godta alle informasjonskapsler', 12 | decline: 'Avslå', 13 | declineTitle: 'Avslå alle valgfrie informasjonskapsler', 14 | configure: 'Konfigurere', 15 | configureTitle: 'Konfigurere informasjonskapsler' 16 | }, 17 | modal: { 18 | title: 'Informasjon vi samler inn', 19 | description: 20 | 'Her kan du se og velge hvilken informasjon vi samler inn om deg.\nFor å lære mer, vennligst les vår {privacyPolicy}.', 21 | privacyPolicyLabel: 'personvernerklæring', 22 | close: 'Lukk', 23 | closeTitle: 'Lukk preferanser', 24 | globalPreferences: 'Preferanser for alle tjenester', 25 | acceptAll: 'Godta alt', 26 | declineAll: 'Avslå alt', 27 | save: 'Opslaan', 28 | saveTitle: null 29 | }, 30 | contextual: { 31 | title: '"{purpose}" er inaktiv', 32 | description: 33 | 'Tillat informasjonskapsler å få tilgang til denne funksjonaliteten.', 34 | privacyPolicyLabel: 'personvernerklæring', 35 | accept: 'Tillate', 36 | accepted: '"{purpose}" er nå tillatt.' 37 | }, 38 | purpose: { 39 | mandatory: 'alltid påkrevd', 40 | mandatoryTitle: 'Denne applikasjonen er alltid påkrevd', 41 | exempt: 'opt-out', 42 | exemptTitle: 43 | 'Denne appen er lastet som standard (men du kan skru det av)', 44 | showMore: 'Vise mer', 45 | accept: 'Akseptere', 46 | decline: 'Avslå', 47 | enabled: 'aktivert', 48 | disabled: 'deaktivert', 49 | partial: 'delvis' 50 | }, 51 | misc: { 52 | newWindowTitle: 'nytt vindu', 53 | updateNeeded: 54 | 'Det har skjedd endringer siden ditt siste besøk, vennligst oppdater ditt samtykke.', 55 | poweredBy: 'Laget med Orejime' 56 | } 57 | } satisfies Translations as Translations; 58 | -------------------------------------------------------------------------------- /adr/001-distribution-formats.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-02-05 3 | status: Accepted 4 | --- 5 | 6 | # Distribution formats 7 | 8 | ## Context 9 | 10 | Orejime can be consumed in different ways : via a CDN, by copying and hosting 11 | the built assets, or by bundling it with client code. This sums up to two 12 | categories : _standalone_ and _bundled_. 13 | 14 | Those categories each impose different constraints to the Orejime's API, the way 15 | it is built and distributed. 16 | 17 | ## Considerations 18 | 19 | - When used as a standalone module, it does not need to wait for the whole 20 | client code to load with it so it can load faster. 21 | - When used as a standalone module, it can be cached separately by the browser. 22 | It is likely that Orejime and the client code won't have the same rate of 23 | update, so Orejime could be cached for longer. 24 | - Providing access to Orejime's source ties client code to it. The build system 25 | has to be able to handle it, and it imposes hard constraints on the 26 | technologies we use. For example, we are using a compatibility layer for React 27 | over Preact for the sole purpose of letting people use Orejime inside React 28 | projects. As we upgrade Orejime over time, we want to be able to change the 29 | whole underlying code (for performance, smaller footprint, ...) without 30 | breaking everyone's builds. 31 | - We're working hard on optimizing every last bit of Orejime, and it has to do 32 | with the technologies we choose, the build pipeline, the way the app is split 33 | into small modules, and so on. Sadly, this can all be lost when the app is 34 | build another way. 35 | 36 | ## Decision 37 | 38 | Given the previous reasons, letting one build Orejime themselves might be a 39 | burden on them and on us, for no added value. 40 | 41 | We will discontinue the distribution of ESM and CJS modules, as to prevent this 42 | use entirely. 43 | 44 | One could still import Orejime from source to bundle it themselves, but it would 45 | now be their own responsibility to maintain it, as we couldn't guarantee not to 46 | introduce breaking changes. 47 | -------------------------------------------------------------------------------- /src/translations/nl.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Dutch. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Wij verzamelen en verwerken uw persoonlijke gegevens voor de volgende doeleinden: {purposes}.\nLees ons privacybeleid voor meer informatie {privacyPolicy}.', 9 | privacyPolicyLabel: 'privacybeleid', 10 | accept: 'Aanvaarden', 11 | acceptTitle: 'Alle cookies accepteren', 12 | decline: 'Afwijzen', 13 | declineTitle: 'Alle optionele cookies weigeren', 14 | configure: 'Configureren', 15 | configureTitle: 'Cookies configureren' 16 | }, 17 | modal: { 18 | title: 'Informatie die we verzamelen', 19 | description: 20 | 'Hier kunt u de informatie bekijken en aanpassen die we over u verzamelen.\nLees ons privacybeleid voor meer informatie {privacyPolicy}.', 21 | privacyPolicyLabel: 'privacybeleid', 22 | close: 'Sluiten', 23 | closeTitle: 'Sluit voorkeuren', 24 | globalPreferences: 'Voorkeuren voor alle services', 25 | acceptAll: 'Alles aanvaarden', 26 | declineAll: 'Alles weigeren', 27 | save: 'Opslaan', 28 | saveTitle: 'Mijn configuratie voor de informatievergaring opslaan' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" is inactief', 32 | description: 33 | 'Sta cookies toe om toegang te krijgen tot deze functionaliteit.', 34 | privacyPolicyLabel: 'privacybeleid', 35 | accept: 'Toestaan', 36 | accepted: '"{purpose}" is nu toegestaan.' 37 | }, 38 | purpose: { 39 | mandatory: 'altijd verplicht', 40 | mandatoryTitle: 'Deze applicatie is altijd vereist', 41 | exempt: 'afmelden', 42 | exemptTitle: 'Deze app is standaard geladen (maar je kunt je afmelden)', 43 | showMore: 'Laat meer zien', 44 | accept: 'Aanvaarden', 45 | decline: 'Afwijzen', 46 | enabled: 'geactiveerd', 47 | disabled: 'gedesactiveerd', 48 | partial: 'gedeeltelijk' 49 | }, 50 | misc: { 51 | newWindowTitle: 'nieuw venster', 52 | updateNeeded: 53 | 'Er waren wijzigingen sinds uw laatste bezoek, werk uw voorkeuren bij.', 54 | poweredBy: 'Aangedreven door Orejime' 55 | } 56 | } satisfies Translations as Translations; 57 | -------------------------------------------------------------------------------- /src/translations/ro.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Romanian. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Colectăm și procesăm informațiile dvs. personale în următoarele scopuri: {purposes}.\nPentru a afla mai multe, vă rugăm să citiți {privacyPolicy}.', 9 | privacyPolicyLabel: 'politica privacy', 10 | accept: 'Accepta', 11 | acceptTitle: 'Acceptați toate modulele cookie', 12 | decline: 'Renunță', 13 | declineTitle: 'Refuzați toate modulele cookie opționale', 14 | configure: 'Configurați', 15 | configureTitle: 'Configura cookie' 16 | }, 17 | modal: { 18 | title: 'Informațiile pe care le colectăm', 19 | description: 20 | 'Aici puteți vedea și personaliza informațiile pe care le colectăm despre dvs.\nPentru a afla mai multe, vă rugăm să citiți {privacyPolicy}.', 21 | privacyPolicyLabel: 'politica privacy', 22 | close: 'Aproape', 23 | closeTitle: 'Închideți preferințele', 24 | globalPreferences: 'Preferințe pentru toate serviciile', 25 | acceptAll: 'Accepta totul', 26 | declineAll: 'Refuză toate', 27 | save: 'Salvează', 28 | saveTitle: null 29 | }, 30 | contextual: { 31 | title: '"{purpose}" este inactiv', 32 | description: 33 | 'Permiteți cookie-urilor să acceseze această funcționalitate.', 34 | privacyPolicyLabel: 'politica privacy', 35 | accept: 'Permite', 36 | accepted: '"{purpose}" este acum permis.' 37 | }, 38 | purpose: { 39 | mandatory: 'întotdeauna necesar', 40 | mandatoryTitle: 'Această aplicație este întotdeauna necesară', 41 | exempt: 'opt-out', 42 | exemptTitle: 43 | 'Această aplicație este încărcată în mod implicit (dar puteți renunța)', 44 | showMore: 'Arata mai mult', 45 | accept: 'Accepta', 46 | decline: 'Renunță', 47 | enabled: 'activat', 48 | disabled: 'dezactivat', 49 | partial: 'parţial' 50 | }, 51 | misc: { 52 | newWindowTitle: 'fereastră nouă', 53 | updateNeeded: 54 | 'Au existat modificări de la ultima vizită, vă rugăm să actualizați consimțământul.', 55 | poweredBy: 'Realizat de Orejime' 56 | } 57 | } satisfies Translations as Translations; 58 | -------------------------------------------------------------------------------- /src/ui/ContextualConsentsEffect.tsx: -------------------------------------------------------------------------------- 1 | import {render} from 'preact'; 2 | import {ConsentsMap} from '../core/types'; 3 | import Manager from '../core/Manager'; 4 | import {Config} from './types'; 5 | import Context from './components/Context'; 6 | import ContextualNoticeContainer from './components/ContextualNoticeContainer'; 7 | import ConsentsEffect from '../core/ConsentsEffect'; 8 | 9 | export default class ContextualConsentsEffect implements ConsentsEffect { 10 | readonly #config: Config; 11 | readonly #manager: Manager; 12 | #containers: WeakMap; 13 | 14 | constructor(config: Config, manager: Manager) { 15 | this.#config = config; 16 | this.#manager = manager; 17 | this.#containers = new WeakMap(); 18 | } 19 | 20 | apply(consents: ConsentsMap) { 21 | Object.entries(consents).forEach(([id, state]) => { 22 | document 23 | .querySelectorAll(`template[data-contextual][data-purpose="${id}"]`) 24 | .forEach((template: HTMLTemplateElement) => { 25 | this.#renderNotice(template, !state); 26 | }); 27 | }); 28 | } 29 | 30 | #renderNotice(template: HTMLTemplateElement, isEnabled: boolean) { 31 | render( 32 | 38 | 43 | , 44 | this.#getNoticeContainer(template) 45 | ); 46 | } 47 | 48 | #getNoticeContainer(template: HTMLTemplateElement) { 49 | if (!this.#containers.has(template)) { 50 | const container = document.createElement('div'); 51 | container.style.display = 'contents'; 52 | 53 | // The container is inserted before the template, so if 54 | // the user allows cookies from inside, the revealed 55 | // content is always after. 56 | template.insertAdjacentElement('beforebegin', container); 57 | this.#containers.set(template, container); 58 | } 59 | 60 | return this.#containers.get(template); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/Banner.tsx: -------------------------------------------------------------------------------- 1 | import {useRef} from 'preact/hooks'; 2 | import type {BannerComponent} from '../../components/types/Banner'; 3 | import {useNonObscuringElement, useTranslations} from '../../utils/hooks'; 4 | import {template} from '../../utils/template'; 5 | 6 | const Banner: BannerComponent = ({ 7 | needsUpdate, 8 | isHidden, 9 | purposeTitles, 10 | privacyPolicyUrl, 11 | onAccept, 12 | onDecline, 13 | onConfigure 14 | }) => { 15 | const ref = useRef(); 16 | const t = useTranslations(); 17 | 18 | useNonObscuringElement(ref); 19 | 20 | return ( 21 |
22 | {t.banner.title ?

{t.banner.title}

: null} 23 | 24 |
25 |

26 | {template(t.banner.description, { 27 | purposes: ( 28 | {purposeTitles.join(', ')} 29 | ), 30 | privacyPolicy: ( 31 | 32 | {t.banner.privacyPolicyLabel} 33 | 34 | ) 35 | })} 36 |

37 | 38 | {needsUpdate && ( 39 |

{t.misc.updateNeeded}

40 | )} 41 |
42 | 43 |
    44 |
  • 45 | 52 |
  • 53 |
  • 54 | 61 |
  • 62 |
  • 63 | 70 |
  • 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default Banner; 77 | -------------------------------------------------------------------------------- /src/translations/ca.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Catalan. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Recopilem i processem la vostra informació personal amb la finalitat següent : {purposes}.\nPer saber-ne més, llegiu la nostre {privacyPolicy}.', 9 | privacyPolicyLabel: 'política de privacitat', 10 | accept: 'Acceptar', 11 | acceptTitle: 'Acceptar les cookies', 12 | decline: 'Refusar', 13 | declineTitle: 'Rebutjar les galetes opcionals', 14 | configure: 'Configurar', 15 | configureTitle: 'Configurar cookies' 16 | }, 17 | modal: { 18 | title: 'La informació que recollim', 19 | description: 20 | 'Aquí podeu veure i personalitzar la informació que recopilem sobre vosaltres.\nPer saber-ne més, llegiu la nostre {privacyPolicy}.', 21 | privacyPolicyLabel: 'política de privacitat', 22 | close: 'Tancar', 23 | closeTitle: 'Tanca les preferències', 24 | globalPreferences: 'Preferències per a tots els serveis', 25 | acceptAll: 'Acceptar-ho tot', 26 | declineAll: 'Rebutja tot', 27 | save: 'Desa', 28 | saveTitle: 'Desa la meva configuració sobre la informació recollida' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" està inactiu', 32 | description: 'Permet que les cookies accedeixin a aquesta funcionalitat.', 33 | privacyPolicyLabel: 'política de privacitat', 34 | accept: 'Permetre', 35 | accepted: '"{purpose}" ara està permès.' 36 | }, 37 | purpose: { 38 | mandatory: 'sempre obligatori', 39 | mandatoryTitle: 'Aquesta aplicació sempre és necessària', 40 | exempt: 'desactivar', 41 | exemptTitle: 42 | 'Aquesta aplicació es carrega de manera predeterminada (però podeu desactivar-la)', 43 | showMore: 'Saber-ne més', 44 | accept: 'Acceptar', 45 | decline: 'Refusar', 46 | enabled: 'actiu', 47 | disabled: 'inactiu', 48 | partial: 'parcial' 49 | }, 50 | misc: { 51 | newWindowTitle: 'finestra nova', 52 | updateNeeded: 53 | "S'han produït canvis des de la vostra última visita, actualitzeu el vostre consentiment.", 54 | poweredBy: 'Desenvolupat per Orejime' 55 | } 56 | } satisfies Translations; 57 | -------------------------------------------------------------------------------- /src/translations/hu.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Hungarian. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Az személyes adataidat összegyűjtjük és feldolgozzuk az alábbi célokra: {purposes}.\nTovábbi információért kérjük, olvassd el az {privacyPolicy}.', 9 | privacyPolicyLabel: 'adatvédelmi irányelveinket', 10 | accept: 'Elfogad', 11 | acceptTitle: 'Minden cookie elfogadása', 12 | decline: 'Elvet', 13 | declineTitle: 'Minden opcionális cookie elutasítása', 14 | configure: 'Konfigurálni', 15 | configureTitle: 'Konfigurálja a cookie-kat' 16 | }, 17 | modal: { 18 | title: 'Információk, amiket gyűjtünk', 19 | description: 20 | 'Itt láthatod és testreszabhatod az rólad gyűjtött információkat.\nTovábbi információért kérjük, olvassd el az {privacyPolicy}.', 21 | privacyPolicyLabel: 'adatvédelmi irányelveinket', 22 | close: 'Elvet', 23 | closeTitle: 'Beállítások bezárása', 24 | globalPreferences: 'Preferenciák az összes szolgáltatáshoz', 25 | acceptAll: 'Fogadj el mindent', 26 | declineAll: 'Elutasít minden', 27 | save: 'Mentés', 28 | saveTitle: null 29 | }, 30 | contextual: { 31 | title: '"{purpose}" inaktív', 32 | description: 33 | 'Engedélyezze a cookie-k számára, hogy hozzáférjenek ehhez a funkcióhoz.', 34 | privacyPolicyLabel: 'adatvédelmi irányelveinket', 35 | accept: 'Engedélyezze', 36 | accepted: 'A {purpose} mostantól engedélyezett.' 37 | }, 38 | purpose: { 39 | mandatory: 'mindig kötelező', 40 | mandatoryTitle: 'Ez az applikáció mindig kötelező', 41 | exempt: 'leiratkozás', 42 | exemptTitle: 43 | 'Ez az alkalmazás alapértelmezés szerint betöltött (de ki lehet kapcsolni)', 44 | showMore: 'Tudjon meg többet', 45 | accept: 'Elfogad', 46 | decline: 'Elvet', 47 | enabled: 'engedélyezve van', 48 | disabled: 'letiltva', 49 | partial: 'részleges' 50 | }, 51 | misc: { 52 | newWindowTitle: 'új ablak', 53 | updateNeeded: 54 | 'Az utolsó látogatás óta változások történtek, kérjük, frissítsd a hozzájárulásodat.', 55 | poweredBy: 'Powered by Orejime' 56 | } 57 | } satisfies Translations as Translations; 58 | -------------------------------------------------------------------------------- /src/translations/oc.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Occitan. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Reculhissèm e tractam vòstras informacions personalas amb la tòca seguenta : {purposes}.\nPer ne saber mai, mercés de legir nòstra {privacyPolicy}.', 9 | privacyPolicyLabel: 'politica de confidencialitat', 10 | accept: 'Acceptar', 11 | acceptTitle: 'Acceptar los cookies', 12 | decline: 'Refusar', 13 | declineTitle: 'Refusar los cookies', 14 | configure: 'Configurar', 15 | configureTitle: 'Configurar los cookies' 16 | }, 17 | modal: { 18 | title: 'Las informacions que reculhissèm', 19 | description: 20 | 'Aicí, podètz veire e personalizar las informacions que reculhissèm vos tocant.\nPer ne saber mai, mercés de legir nòstra {privacyPolicy}.', 21 | privacyPolicyLabel: 'politica de confidencialitat', 22 | close: 'Tancar', 23 | closeTitle: null, 24 | globalPreferences: 'Preferéncias per totes los servicis', 25 | acceptAll: 'Tot acceptar', 26 | declineAll: 'Tot refusar', 27 | save: 'Salvagardar', 28 | saveTitle: 'Salvagardar ma configuracion per las informacions collectadas' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" és inactiu', 32 | description: 'Autorizar los cookies a accedir a aquesta foncionalitat.', 33 | privacyPolicyLabel: 'politica de confidencialitat', 34 | accept: 'Permetre', 35 | accepted: '"{purpose}" es ara autorizat.' 36 | }, 37 | purpose: { 38 | mandatory: 'tojors requerit', 39 | mandatoryTitle: 'Aquesta aplicacion es totjorn requerida', 40 | exempt: 'opt-out', 41 | exemptTitle: 42 | 'Aquesta aplicacion es cargada per defaut (mas la podètz desactivar)', 43 | showMore: 'Mostrar mai', 44 | accept: 'Acceptar', 45 | decline: 'Refusar', 46 | enabled: 'Activat', 47 | disabled: 'Desactivat', 48 | partial: 'parciala' 49 | }, 50 | misc: { 51 | newWindowTitle: 'fenèstra novèla', 52 | updateNeeded: 53 | 'I aguèt de modificacions dempuèi vòstra darrièra visita, mercés d’actualizar vòstre consentiment.', 54 | poweredBy: 'Propulsat per Orejime' 55 | } 56 | } satisfies Translations as Translations; 57 | -------------------------------------------------------------------------------- /src/migrations/v2/types.ts: -------------------------------------------------------------------------------- 1 | import {RecursivePartial} from '../../core/utils/types'; 2 | 3 | export interface V2Translations { 4 | consentModal: { 5 | title: string; 6 | description: string; 7 | privacyPolicy: { 8 | name: string; 9 | text: string; 10 | }; 11 | }; 12 | consentNotice: { 13 | title: string; 14 | description: string; 15 | changeDescription: string; 16 | learnMore: string; 17 | }; 18 | accept: string; 19 | acceptTitle: string; 20 | acceptAll: string; 21 | save: string; 22 | saveData: string; 23 | decline: string; 24 | declineAll: string; 25 | close: string; 26 | enabled: string; 27 | disabled: string; 28 | app: { 29 | optOut: { 30 | title: string; 31 | description: string; 32 | }; 33 | required: { 34 | title: string; 35 | description: string; 36 | }; 37 | purposes: string; 38 | purpose: string; 39 | }; 40 | poweredBy: string; 41 | newWindow: string; 42 | [key: string]: any; 43 | } 44 | 45 | export interface V2Consents { 46 | [name: string]: boolean; 47 | } 48 | 49 | export interface V2App { 50 | name: string; 51 | title: string; 52 | description?: string; 53 | cookies: Array< 54 | string | RegExp | [name: string, path: string, domain: string] 55 | >; 56 | purposes: string[]; 57 | callback?: (consent: boolean, app: V2App) => void; 58 | required?: boolean; 59 | optOut?: boolean; 60 | default?: boolean; 61 | onlyOnce?: boolean; 62 | } 63 | 64 | export interface V2Category { 65 | name: string; 66 | title: string; 67 | description?: string; 68 | apps: string[]; 69 | } 70 | 71 | export interface V2Config { 72 | elementID: string; 73 | appElement: string; 74 | stylePrefix?: string; 75 | cookieName?: string; 76 | cookieExpiresAfterDays?: number; 77 | cookieDomain?: string; 78 | stringifyCookie?: (consents: V2Consents) => string; 79 | parseCookie?: (consents: string) => V2Consents; 80 | privacyPolicy: string; 81 | default?: boolean; 82 | mustConsent?: boolean; 83 | mustNotice?: boolean; 84 | logo?: boolean; 85 | lang: string; 86 | translations?: Record>; 87 | apps: V2App[]; 88 | categories?: V2Category[]; 89 | debug?: boolean; 90 | } 91 | -------------------------------------------------------------------------------- /src/translations/es.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // Spanish. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Recopilamos y procesamos su información personal para los siguientes propósitos : {purposes}.\nPara obtener más información, lea nuestra {privacyPolicy}.', 9 | privacyPolicyLabel: 'política de confidencialidad', 10 | accept: 'Aceptar', 11 | acceptTitle: 'Aceptar todas las cookies', 12 | decline: 'Rechazar', 13 | declineTitle: 'Rechazar todas las cookies opcionales', 14 | configure: 'Configurar', 15 | configureTitle: 'Configurar las cookies' 16 | }, 17 | modal: { 18 | title: 'La información que recopilamos', 19 | description: 20 | 'Aquí puede ver y personalizar la información que recopilamos sobre usted.\nPara obtener más información, lea nuestra {privacyPolicy}.', 21 | privacyPolicyLabel: 'política de confidencialidad', 22 | close: 'Cerrar', 23 | closeTitle: 'Cerrar preferencias', 24 | globalPreferences: 'Preferencias para todos los servicios', 25 | acceptAll: 'Aceptar todas las apps', 26 | declineAll: 'Rechazar todas las apps', 27 | save: 'Guardar', 28 | saveTitle: 'Guardar mi configuración en la información recopilada' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" está inactivo', 32 | description: 'Permitir que las cookies accedan a esta funcionalidad.', 33 | privacyPolicyLabel: 'política de confidencialidad', 34 | accept: 'Permitir', 35 | accepted: '"{purpose}" ahora está permitido.' 36 | }, 37 | purpose: { 38 | mandatory: 'siempre obligatorio', 39 | mandatoryTitle: 'Esta aplicación es siempre obligatoria', 40 | exempt: 'desactivar', 41 | exemptTitle: 42 | 'Esta aplicación se carga por defecto (pero puedes desactivarla)', 43 | showMore: 'Mostrar más', 44 | accept: 'Aceptar', 45 | decline: 'Rechazar', 46 | enabled: 'habilitado', 47 | disabled: 'deshabilitado', 48 | partial: 'parcial' 49 | }, 50 | misc: { 51 | newWindowTitle: 'nueva ventana', 52 | updateNeeded: 53 | 'Se han producido cambios desde su última visita, actualice su consentimiento.', 54 | poweredBy: 'Desarrollado por Orejime' 55 | } 56 | } satisfies Translations; 57 | -------------------------------------------------------------------------------- /src/translations/de.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // German. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Wir speichern und verarbeiten Ihre persönlichen Daten für folgende Zwecke: {purposes}.\nBitte lesen Sie unsere {privacyPolicy} um weitere Details zu erfahren.', 9 | privacyPolicyLabel: 'Datenschutzerklärung', 10 | accept: 'Akzeptieren', 11 | acceptTitle: 'Cookies akzeptieren', 12 | decline: 'Ablehnen', 13 | declineTitle: 'Optionalen Cookies ablehnen', 14 | configure: 'Konfigurieren', 15 | configureTitle: 'Cookies konfigurieren' 16 | }, 17 | modal: { 18 | title: 'Informationen zur Privatsphäre', 19 | description: 20 | 'Hier können Sie einsehen und anpassen, welche persönlichen Daten gespeichert werden.\nBitte lesen Sie unsere {privacyPolicy} um weitere Details zu erfahren.', 21 | privacyPolicyLabel: 'Datenschutzerklärung', 22 | close: 'Schließen', 23 | closeTitle: 'Einstellungen schließen', 24 | globalPreferences: 'globale Vorlieben', 25 | acceptAll: 'Alle Einstellungen akzeptieren', 26 | declineAll: 'Alle Einstellungen ablehnen', 27 | save: 'Speichern', 28 | saveTitle: 'Meine Einstellungen speichern' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" ist inaktiv', 32 | description: 33 | 'Erlauben Sie Cookies, auf diese Funktionalität zuzugreifen.', 34 | privacyPolicyLabel: 'Datenschutzerklärung', 35 | accept: 'Erlauben', 36 | accepted: '"{purpose}" ist jetzt erlaubt.' 37 | }, 38 | purpose: { 39 | mandatory: 'immer notwendig', 40 | mandatoryTitle: 'Diese Einstellungen werden immer benötigt', 41 | exempt: 'Opt-Out', 42 | exemptTitle: 43 | 'Diese Einstellungen als Standard festlegen (sie können jederzeit deaktiviert werden)', 44 | showMore: 'Mehr Details zeigen', 45 | accept: 'Akzeptieren', 46 | decline: 'Ablehnen', 47 | enabled: 'aktiviert', 48 | disabled: 'deaktiviert', 49 | partial: 'teilweise' 50 | }, 51 | misc: { 52 | newWindowTitle: 'Neues Fenster', 53 | updateNeeded: 54 | 'Es gab Änderungen seit Ihrem letzten Besuch, bitte aktualisieren Sie Ihre Auswahl.', 55 | poweredBy: 'Durchgeführt mit Orejime' 56 | } 57 | } satisfies Translations as Translations; 58 | -------------------------------------------------------------------------------- /src/translations/fr.ts: -------------------------------------------------------------------------------- 1 | import {Translations} from '../ui/types'; 2 | 3 | // French. 4 | export default { 5 | banner: { 6 | title: null, 7 | description: 8 | 'Nous collectons et traitons vos informations personnelles dans le but suivant : {purposes}.\nPour en savoir plus, merci de lire notre {privacyPolicy}.\n', 9 | privacyPolicyLabel: 'politique de confidentialité', 10 | accept: 'Accepter', 11 | acceptTitle: 'Accepter tous les cookies', 12 | decline: 'Refuser', 13 | declineTitle: 'Refuser tous les cookies optionnels', 14 | configure: 'Configurer', 15 | configureTitle: 'Configurer les cookies' 16 | }, 17 | modal: { 18 | title: 'Les informations que nous collectons', 19 | description: 20 | 'Ici, vous pouvez voir et personnaliser les informations que nous collectons sur vous.\nPour en savoir plus, merci de lire notre {privacyPolicy}.\n', 21 | privacyPolicyLabel: 'politique de confidentialité', 22 | close: 'Fermer', 23 | closeTitle: 'Fermer les préférences', 24 | globalPreferences: 'Préférences pour tous les services', 25 | acceptAll: 'Tout accepter', 26 | declineAll: 'Tout refuser', 27 | save: 'Sauvegarder', 28 | saveTitle: 'Sauvegarder ma configuration sur les informations collectées' 29 | }, 30 | contextual: { 31 | title: '"{purpose}" est désactivé', 32 | description: 33 | 'Autorisez le dépôt de cookies pour accèder à cette fonctionnalité.', 34 | privacyPolicyLabel: 'politique de confidentialité', 35 | accept: 'Autoriser', 36 | accepted: '"{purpose}" est maintenant autorisé.' 37 | }, 38 | purpose: { 39 | mandatory: 'toujours requis', 40 | mandatoryTitle: 'Cette application est toujours requise', 41 | exempt: 'opt-out', 42 | exemptTitle: 43 | 'Cette application est chargée par défaut (mais vous pouvez la désactiver)', 44 | showMore: 'Voir plus de détails', 45 | accept: 'Accepter', 46 | decline: 'Refuser', 47 | enabled: 'activé', 48 | disabled: 'désactivé', 49 | partial: 'partiel' 50 | }, 51 | misc: { 52 | newWindowTitle: 'nouvelle fenêtre', 53 | updateNeeded: 54 | 'Des modifications ont eu lieu depuis votre dernière visite, merci de mettre à jour votre consentement.', 55 | poweredBy: 'Propulsé par Orejime' 56 | } 57 | } satisfies Translations as Translations; 58 | -------------------------------------------------------------------------------- /src/ui/components/ContextualNoticeContainer.tsx: -------------------------------------------------------------------------------- 1 | import {useState} from 'preact/hooks'; 2 | import {purposesOnly} from '../utils/config'; 3 | import {useConfig, useManager, useTheme, useTranslations} from '../utils/hooks'; 4 | import {template} from '../utils/template'; 5 | import {Purpose} from '../types'; 6 | 7 | interface ContextualNoticeContainerProps { 8 | purposeId: Purpose['id']; 9 | data: Record; 10 | isEnabled: boolean; 11 | } 12 | 13 | const ContextualNoticeContainer = ({ 14 | purposeId, 15 | data, 16 | isEnabled 17 | }: ContextualNoticeContainerProps) => { 18 | const config = useConfig(); 19 | const manager = useManager(); 20 | const t = useTranslations(); 21 | const {ContextualNotice} = useTheme(); 22 | 23 | // There is an intermediate step to notify assistive 24 | // technologies, just after the notice is disabled from 25 | // the inside. 26 | const [isBeingDisabled, setIsBeingDisabled] = useState(false); 27 | 28 | if (!isEnabled && !isBeingDisabled) { 29 | return null; 30 | } 31 | 32 | const purpose = purposesOnly(config.purposes).find( 33 | ({id}) => id === purposeId 34 | ); 35 | 36 | if (!purpose) { 37 | return null; 38 | } 39 | 40 | // When the notice is closed, an invisible placeholder 41 | // is inserted and takes focus to announce the change to 42 | // assistive technologies. 43 | return ( 44 |
45 | {isEnabled ? ( 46 | { 51 | manager.setConsent(purpose.id, true); 52 | setIsBeingDisabled(true); 53 | }} 54 | > 55 | ) : isBeingDisabled ? ( 56 |
{ 64 | self?.focus(); 65 | }} 66 | onFocusOut={() => { 67 | setIsBeingDisabled(false); 68 | }} 69 | /> 70 | ) : null} 71 |
72 | ); 73 | }; 74 | 75 | export default ContextualNoticeContainer; 76 | -------------------------------------------------------------------------------- /src/migrations/v3/translations.ts: -------------------------------------------------------------------------------- 1 | import cleanDeep from 'clean-deep'; 2 | import type {Translations} from '../../ui/types'; 3 | import type {V2Translations} from '../v2/types'; 4 | import {RecursivePartial} from '../../core/utils/types'; 5 | 6 | const join = (strings: string[], separator = ' ') => 7 | strings.filter((string) => !!string).join(separator); 8 | 9 | export const migrateTranslations = ( 10 | translations: RecursivePartial 11 | ): Translations => 12 | cleanDeep({ 13 | banner: { 14 | title: translations?.consentNotice?.title, 15 | description: join([ 16 | translations?.consentNotice?.description, 17 | translations?.consentModal?.privacyPolicy?.text 18 | ]), 19 | privacyPolicyLabel: translations?.consentModal?.privacyPolicy?.name, 20 | accept: translations?.accept, 21 | acceptTitle: translations?.acceptTitle, 22 | decline: translations?.decline, 23 | declineTitle: translations?.decline, 24 | configure: translations?.consentNotice?.learnMore, 25 | configureTitle: translations?.consentNotice?.learnMore 26 | }, 27 | modal: { 28 | title: translations?.consentModal?.title, 29 | description: join([ 30 | translations?.consentModal?.description, 31 | translations?.consentModal?.privacyPolicy?.text 32 | ]), 33 | privacyPolicyLabel: translations?.consentModal?.privacyPolicy?.name, 34 | close: translations?.close, 35 | closeTitle: translations?.close, 36 | globalPreferences: '', 37 | acceptAll: translations?.acceptAll, 38 | declineAll: translations?.declineAll, 39 | save: translations?.save, 40 | saveTitle: translations?.saveData 41 | }, 42 | purpose: { 43 | mandatory: translations?.app?.required?.title, 44 | mandatoryTitle: translations?.app?.required?.description, 45 | exempt: translations?.app?.optOut?.title, 46 | exemptTitle: translations?.app?.optOut?.description, 47 | showMore: '', 48 | accept: translations?.accept, 49 | decline: translations?.decline, 50 | enabled: translations?.enabled, 51 | disabled: translations?.disabled, 52 | partial: '' 53 | }, 54 | misc: { 55 | newWindowTitle: translations?.newWindow, 56 | updateNeeded: translations?.consentNotice?.changeDescription, 57 | poweredBy: translations?.poweredBy 58 | } 59 | }) as Translations; 60 | -------------------------------------------------------------------------------- /site/services.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Service-Public.fr", 4 | "url": "https://www.service-public.fr/", 5 | "logo": "logo-service-public.png", 6 | "lang": "fr" 7 | }, 8 | { 9 | "name": "Sénat", 10 | "url": "https://www.senat.fr/", 11 | "logo": "logo-senat.svg", 12 | "lang": "fr" 13 | }, 14 | { 15 | "name": "Vie publique", 16 | "url": "https://www.vie-publique.fr/", 17 | "logo": "logo-vie-publique.svg", 18 | "lang": "fr" 19 | }, 20 | { 21 | "name": "Défenseur des droits", 22 | "url": "https://www.defenseurdesdroits.fr/", 23 | "logo": "logo-defenseur-des-droits.svg", 24 | "lang": "fr" 25 | }, 26 | { 27 | "name": "Collectivité européenne d'Alsace", 28 | "url": "https://www.alsace.eu/", 29 | "logo": "logo-alsace.svg", 30 | "lang": "fr" 31 | }, 32 | { 33 | "name": "Les Catacombes de Paris", 34 | "url": "https://www.catacombes.paris.fr/", 35 | "logo": "logo-catacombes-paris.svg", 36 | "lang": "fr" 37 | }, 38 | { 39 | "name": "SOS Méditerranée", 40 | "url": "https://www.sosmediterranee.org/", 41 | "logo": "logo-sos-mediterranee.svg", 42 | "lang": "en" 43 | }, 44 | { 45 | "name": "Student at work", 46 | "url": "https://studentatwork.be/en/index.html", 47 | "logo": "logo-student-at-work.png", 48 | "lang": "en" 49 | }, 50 | { 51 | "name": "CHU de Liège", 52 | "url": "https://www.chuliege.be/", 53 | "logo": "logo-chu-liege.svg", 54 | "lang": "fr" 55 | }, 56 | { 57 | "name": "Autorité de protection des données", 58 | "url": "https://www.autoriteprotectiondonnees.be/citoyen", 59 | "logo": "logo-autorite-protection-donnees.svg", 60 | "lang": "fr" 61 | }, 62 | { 63 | "name": "Syndicat national de l'édition", 64 | "url": "https://www.sne.fr/", 65 | "logo": "logo-sne.png", 66 | "lang": "fr" 67 | }, 68 | { 69 | "name": "Établissement Public Foncier d'Occitanie", 70 | "url": "https://www.epf-occitanie.fr/", 71 | "logo": "logo-epf-occitanie.png", 72 | "lang": "fr" 73 | }, 74 | { 75 | "name": "Luxembourg", 76 | "url": "https://luxembourg.public.lu/en.html", 77 | "logo": "logo-luxembourg-city.svg", 78 | "lang": "en" 79 | }, 80 | { 81 | "name": "The Luxembourg Government", 82 | "url": "https://gouvernement.lu/en.html", 83 | "logo": "logo-luxembourg-gov.png", 84 | "lang": "en" 85 | } 86 | ] 87 | -------------------------------------------------------------------------------- /src/migrations/v3/config.ts: -------------------------------------------------------------------------------- 1 | import cleanDeep from 'clean-deep'; 2 | import {Config, Purpose, PurposeList} from '../../ui/types'; 3 | import {V2App, V2Category, V2Config, V2Translations} from '../v2/types'; 4 | 5 | const migrateApp = ( 6 | app: V2App, 7 | translations: Partial 8 | ): Purpose => ({ 9 | id: app?.name, 10 | title: translations?.[app?.name]?.title ?? app?.title, 11 | description: translations?.[app?.name]?.description ?? app?.description, 12 | cookies: app?.cookies, 13 | default: app?.default, 14 | isExempt: app?.optOut, 15 | isMandatory: app?.required, 16 | runsOnce: app?.onlyOnce 17 | }); 18 | 19 | const migrateApps = ( 20 | apps: V2App[], 21 | categories: V2Category[], 22 | translations: Partial 23 | ): PurposeList => { 24 | const purposes = apps.map((app) => migrateApp(app, translations)); 25 | 26 | if (!categories) { 27 | return purposes as PurposeList; 28 | } 29 | 30 | return categories.reduce((p, category) => { 31 | return [ 32 | { 33 | id: category?.name, 34 | title: 35 | translations?.categories?.[category?.name]?.title 36 | ?? category?.title, 37 | description: 38 | translations?.categories?.[category?.name]?.description 39 | ?? category?.description, 40 | purposes: category.apps.map((name) => 41 | migrateApp( 42 | apps.find((app) => app.name === name), 43 | translations 44 | ) 45 | ) 46 | }, 47 | ...p.filter((purpose) => !category.apps.includes(purpose.id)) 48 | ] as PurposeList; 49 | }, purposes as PurposeList); 50 | }; 51 | 52 | export const migrateConfig = (config: Partial): Partial => 53 | cleanDeep({ 54 | orejimeElement: config?.elementID, 55 | logo: 56 | config?.logo && typeof config.logo !== 'boolean' 57 | ? config.logo 58 | : undefined, 59 | forceModal: config?.mustConsent, 60 | forceBanner: config?.mustNotice, 61 | privacyPolicyUrl: config?.privacyPolicy, 62 | cookie: { 63 | name: config?.cookieName, 64 | domain: config?.cookieDomain, 65 | duration: config?.cookieExpiresAfterDays, 66 | parse: config?.parseCookie, 67 | stringify: config?.stringifyCookie 68 | }, 69 | purposes: 70 | 'apps' in config 71 | ? migrateApps(config.apps, config.categories, config.translations) 72 | : undefined 73 | }); 74 | -------------------------------------------------------------------------------- /site/assets/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Outfit Variable; 3 | font-style: normal; 4 | font-display: swap; 5 | font-weight: 200 900; 6 | src: url('./outfit-variable.woff2') format('woff2'); 7 | } 8 | 9 | iframe { 10 | border: 0; 11 | border-radius: var(--radius--m); 12 | width: 100%; 13 | min-height: 25lh; 14 | } 15 | 16 | .Iframe--narrow { 17 | min-height: 12lh; 18 | } 19 | 20 | .is-hidden { 21 | display: none; 22 | } 23 | 24 | .RichText > :first-child { 25 | margin-top: 0; 26 | } 27 | 28 | .ButtonList { 29 | padding: 0; 30 | } 31 | 32 | .LogoList { 33 | padding: 0; 34 | } 35 | 36 | .LogoList img { 37 | height: 2lh; 38 | } 39 | 40 | .Form-error { 41 | display: none; 42 | } 43 | 44 | label:has(.Form-error):has(+ :user-invalid) .Form-error { 45 | display: block; 46 | } 47 | 48 | .MigrationForm-output { 49 | margin-top: var(--spacing-block--l); 50 | } 51 | 52 | .ExamplePage { 53 | min-height: 100%; 54 | background: var(--color-paper--100); 55 | color: var(--color-black--100); 56 | } 57 | 58 | .ExamplePage iframe { 59 | aspect-ratio: 16 / 9; 60 | min-height: 0; 61 | } 62 | 63 | .ExamplePage .orejime-Env { 64 | font-size: 0.875rem; 65 | } 66 | 67 | .ExamplePage .orejime-Purpose-input { 68 | accent-color: var(--orejime-color-interactive); 69 | } 70 | 71 | .ExampleMain { 72 | padding: var(--spacing-block--l) var(--spacing-inline--xl); 73 | } 74 | 75 | .ExampleGrid { 76 | grid-template-columns: repeat(var(--grid-algorithm), minmax(20ch, 1fr)); 77 | gap: var(--spacing-block--l) var(--spacing-inline--xl); 78 | } 79 | 80 | .ExampleReset { 81 | --orejime-color-interactive: royalblue; 82 | --orejime-color-on-interactive: white; 83 | display: none; 84 | position: fixed; 85 | right: 1.4em; 86 | bottom: 1.4em; 87 | } 88 | 89 | body:not(:has(.orejime-Banner)):not(:has(.orejime-Modal)) .ExampleReset { 90 | display: block; 91 | } 92 | 93 | /** 94 | * We're using the actual code as doc by making the script's 95 | * contents visible. 96 | */ 97 | .ExampleCode { 98 | display: block; 99 | line-height: 1.6; 100 | font-family: 101 | Menlo, 102 | Consolas, 103 | Monaco, 104 | Liberation Mono, 105 | Lucida Console, 106 | monospace; 107 | tab-size: 3; 108 | overflow-x: auto; 109 | } 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orejime", 3 | "version": "3.0.2", 4 | "description": "A lightweight and accessible consent manager", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/boscop-fr/orejime.git" 8 | }, 9 | "keywords": [ 10 | "cmp", 11 | "consent management platform", 12 | "cookie", 13 | "consent", 14 | "gdpr", 15 | "compliance", 16 | "accessibility", 17 | "ecofriendly", 18 | "low footprint", 19 | "lightweight" 20 | ], 21 | "scripts": { 22 | "start": "rspack --watch", 23 | "serve": "rspack serve", 24 | "lint": "prettier --check .", 25 | "format": "prettier --write .", 26 | "test-unit": "NODE_OPTIONS=--experimental-vm-modules jest src", 27 | "test-e2e": "playwright test", 28 | "test-e2e-ui": "npm run test-e2e -- --ui", 29 | "test": "npm run test-unit && npm run test-e2e", 30 | "build": "rspack --mode production", 31 | "stats": "rspack build --analyze", 32 | "prepublishOnly": "npm run test && npm run build", 33 | "prepare": "husky" 34 | }, 35 | "contributors": [ 36 | "Emmanuel Pelletier", 37 | "Félix Girault " 38 | ], 39 | "license": "BSD-3-Clause", 40 | "bugs": { 41 | "url": "https://github.com/boscop-fr/orejime/issues" 42 | }, 43 | "homepage": "https://orejime.boscop.fr", 44 | "browserslist": [ 45 | "defaults" 46 | ], 47 | "dependencies": { 48 | "js-cookie": "^3.0.1" 49 | }, 50 | "devDependencies": { 51 | "@playwright/test": "^1.48.0", 52 | "@rspack/cli": "^1.0.3", 53 | "@rspack/core": "^1.0.3", 54 | "@swc/core": "^1.7.23", 55 | "@swc/helpers": "^0.5.13", 56 | "@swc/jest": "^0.2.36", 57 | "@types/jest": "^27.5.0", 58 | "@types/js-cookie": "^3.0.2", 59 | "@types/micromodal": "^0.3.5", 60 | "@types/node": "^22.7.5", 61 | "clean-deep": "^3.4.0", 62 | "cross-env": "^5.2.0", 63 | "css-loader": "^0.28.11", 64 | "husky": "^9.1.7", 65 | "jest": "^28.1.3", 66 | "jest-environment-jsdom": "^28.1.0", 67 | "micromodal": "^0.4.10", 68 | "postcss-loader": "^8.1.1", 69 | "postcss-preset-env": "^10.1.5", 70 | "preact": "^10.23.2", 71 | "prettier": "^3.5.1", 72 | "sharp": "^0.33.5", 73 | "shiki": "^2.2.0", 74 | "shx": "^0.3.4", 75 | "typescript": "^4.6.4", 76 | "uneval.js": "^5.7.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ui/types.ts: -------------------------------------------------------------------------------- 1 | import {CookieOptions, Purpose as CorePurpose} from '../core/types'; 2 | import {Theme} from './components/types/Theme'; 3 | 4 | export interface Purpose extends CorePurpose { 5 | id: string; 6 | title: string; 7 | description?: string; 8 | } 9 | 10 | export interface PurposeGroup { 11 | id: string; 12 | title: string; 13 | description?: string; 14 | purposes: Purpose[]; 15 | } 16 | 17 | export type PurposeList = Array; 18 | 19 | export interface BannerTranslations { 20 | title?: string; 21 | description: string; 22 | privacyPolicyLabel: string; 23 | accept: string; 24 | acceptTitle?: string; 25 | decline: string; 26 | declineTitle?: string; 27 | configure: string; 28 | configureTitle?: string; 29 | } 30 | 31 | export interface ModalTranslations { 32 | title: string; 33 | description: string; 34 | privacyPolicyLabel: string; 35 | close: string; 36 | closeTitle: string; 37 | globalPreferences: string; 38 | acceptAll: string; 39 | declineAll: string; 40 | save: string; 41 | saveTitle: string; 42 | } 43 | 44 | export interface ContextualNoticeTranslations { 45 | title: string; 46 | description: string; 47 | privacyPolicyLabel: string; 48 | accept: string; 49 | acceptTitle?: string; 50 | accepted: string; 51 | } 52 | 53 | export interface PurposeTranslations { 54 | mandatory: string; 55 | mandatoryTitle: string; 56 | exempt: string; 57 | exemptTitle: string; 58 | showMore: string; 59 | accept: string; 60 | decline: string; 61 | enabled: string; 62 | disabled: string; 63 | partial: string; 64 | } 65 | 66 | export interface MiscTranslations { 67 | newWindowTitle: string; 68 | updateNeeded: string; 69 | poweredBy: string; 70 | } 71 | 72 | export interface Translations { 73 | banner: BannerTranslations; 74 | modal: ModalTranslations; 75 | contextual: ContextualNoticeTranslations; 76 | purpose: PurposeTranslations; 77 | misc: MiscTranslations; 78 | } 79 | 80 | export type ElementReference = string | HTMLElement; 81 | 82 | export type ImageAttributes = { 83 | src: string; 84 | alt: string; 85 | }; 86 | 87 | export type ImageDescriptor = string | ImageAttributes; 88 | 89 | export interface Config { 90 | theme: Theme; 91 | orejimeElement?: ElementReference; 92 | purposes: PurposeList; 93 | cookie?: CookieOptions; 94 | lang: string; 95 | logo?: ImageDescriptor; 96 | forceBanner: boolean; 97 | forceModal: boolean; 98 | privacyPolicyUrl: string; 99 | translations: Translations; 100 | } 101 | -------------------------------------------------------------------------------- /site/assets/logo-chu-liege.svg: -------------------------------------------------------------------------------- 1 | Fichier 4 -------------------------------------------------------------------------------- /src/migrations/v3/config.test.ts: -------------------------------------------------------------------------------- 1 | import {migrateConfig} from './config'; 2 | 3 | test('migrateConfig', () => { 4 | const stringify = () => {}; 5 | const parse = () => {}; 6 | 7 | const v2 = { 8 | appElement: '#app', 9 | elementID: 'orejime', 10 | ads: 'orejime', 11 | cookieName: 'orejime', 12 | cookieExpiresAfterDays: 365, 13 | cookieDomain: 'example.org', 14 | stringifyCookie: stringify, 15 | parseCookie: parse, 16 | privacyPolicy: 'http://example.org', 17 | default: true, 18 | mustConsent: false, 19 | mustNotice: false, 20 | lang: 'en', 21 | logo: false, 22 | debug: false, 23 | translations: { 24 | en: { 25 | consentModal: { 26 | description: 'Description' 27 | } 28 | } 29 | }, 30 | apps: [ 31 | { 32 | name: 'analytics-a', 33 | title: 'Tag Manager', 34 | cookies: ['a'], 35 | purposes: ['analytics'], 36 | required: false, 37 | optOut: false, 38 | default: true, 39 | onlyOnce: true, 40 | callback: () => {} 41 | }, 42 | { 43 | name: 'ads', 44 | title: 'Ads', 45 | purposes: ['analytics'], 46 | cookies: [ 47 | 'ads', 48 | ['ads', '/blog', '.' + location.hostname], 49 | ['ads', '/', 'test.example.org'] 50 | ] 51 | }, 52 | { 53 | name: 'analytics-b', 54 | title: 'External Tracker', 55 | purposes: ['analytics', 'security'], 56 | cookies: ['b'], 57 | required: true 58 | } 59 | ], 60 | categories: [ 61 | { 62 | name: 'analytics', 63 | title: 'Analytics', 64 | apps: ['analytics-a', 'analytics-b'] 65 | } 66 | ] 67 | }; 68 | 69 | expect(migrateConfig(v2)).toEqual({ 70 | orejimeElement: 'orejime', 71 | privacyPolicyUrl: 'http://example.org', 72 | forceBanner: false, 73 | forceModal: false, 74 | cookie: { 75 | name: 'orejime', 76 | domain: 'example.org', 77 | duration: 365, 78 | parse, 79 | stringify 80 | }, 81 | purposes: [ 82 | { 83 | id: 'analytics', 84 | title: 'Analytics', 85 | purposes: [ 86 | { 87 | id: 'analytics-a', 88 | title: 'Tag Manager', 89 | cookies: ['a'], 90 | default: true, 91 | isExempt: false, 92 | isMandatory: false, 93 | runsOnce: true 94 | }, 95 | { 96 | id: 'analytics-b', 97 | isMandatory: true, 98 | title: 'External Tracker', 99 | cookies: ['b'] 100 | } 101 | ] 102 | }, 103 | { 104 | id: 'ads', 105 | title: 'Ads', 106 | cookies: [ 107 | 'ads', 108 | ['ads', '/blog', '.localhost'], 109 | ['ads', '/', 'test.example.org'] 110 | ] 111 | } 112 | ] 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/ui/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import {useImperativeHandle, useEffect, useRef} from 'preact/hooks'; 2 | import { 3 | useBannerState, 4 | useConfig, 5 | useManager, 6 | useModalState, 7 | useTheme 8 | } from '../utils/hooks'; 9 | import PurposeTree from './PurposeTree'; 10 | import StubManagerProvider from './StubManagerProvider'; 11 | import GlobalConsentContainer from './GlobalConsentContainer'; 12 | import {findFirstFocusableChild, softFocus} from '../utils/dom'; 13 | 14 | export interface MainApi { 15 | openModal: () => void; 16 | } 17 | 18 | interface MainProps { 19 | apiRef: { 20 | current: MainApi; 21 | }; 22 | } 23 | 24 | const Main = ({apiRef}: MainProps) => { 25 | const config = useConfig(); 26 | const manager = useManager(); 27 | const isBannerOpen = useBannerState(); 28 | const [isModalOpen, openModal, closeModal] = useModalState(); 29 | const {Banner, Modal, ModalBanner} = useTheme(); 30 | const bannerRef = useRef(); 31 | const BannerComponent = config.forceBanner ? ModalBanner : Banner; 32 | 33 | // makes openModal() available from the outside 34 | useImperativeHandle(apiRef, () => ({ 35 | openModal 36 | })); 37 | 38 | // moves focus inside the banner once it opens 39 | useEffect(() => { 40 | if (isBannerOpen && !isModalOpen && bannerRef.current) { 41 | softFocus(findFirstFocusableChild(bannerRef.current)); 42 | } 43 | }, [isBannerOpen]); 44 | 45 | return ( 46 |
47 | {isBannerOpen ? ( 48 |
49 | title)} 54 | privacyPolicyUrl={config.privacyPolicyUrl} 55 | logo={config.logo} 56 | onConfigure={openModal} 57 | onAccept={() => { 58 | manager.acceptAll(); 59 | closeModal(); 60 | }} 61 | onDecline={() => { 62 | manager.declineAll(); 63 | closeModal(); 64 | }} 65 | /> 66 |
67 | ) : null} 68 | 69 | {isModalOpen ? ( 70 | 71 | {(commit) => ( 72 | 80 | {manager.areAllPurposesMandatory() ? null : ( 81 | 82 | )} 83 | 84 | 85 | 86 | )} 87 | 88 | ) : null} 89 |
90 | ); 91 | }; 92 | 93 | export default Main; 94 | -------------------------------------------------------------------------------- /src/ui/components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useId, useLayoutEffect, useState} from 'preact/hooks'; 2 | import MicroModal from 'micromodal'; 3 | 4 | interface DialogProps { 5 | isAlert?: boolean; 6 | label?: string; 7 | labelId?: string; 8 | className?: string; 9 | portalClassName?: string; 10 | overlayClassName?: string; 11 | htmlClassName?: string; 12 | // the scroll position stuff is for iOS to work correctly 13 | // when we want to prevent normal website scrolling with 14 | // the modal opened 15 | // 16 | // /!\ this requires specific CSS to work. For example, 17 | // if `htmlClassName = 'modal-open'`: 18 | // 19 | // ``` 20 | // .modal-open { 21 | // height: 100%; 22 | // } 23 | // 24 | // .modal-open body { 25 | // position: fixed; 26 | // overflow: hidden; 27 | // height: 100%; 28 | // width: 100%; 29 | // } 30 | // ``` 31 | handleScrollPosition?: boolean; 32 | onRequestClose?: () => void; 33 | children: any; 34 | } 35 | 36 | const Dialog = ({ 37 | isAlert = false, 38 | label, 39 | labelId, 40 | className, 41 | portalClassName, 42 | overlayClassName, 43 | htmlClassName, 44 | handleScrollPosition = true, 45 | onRequestClose, 46 | children 47 | }: DialogProps) => { 48 | const id = useId(); 49 | const [scrollPosition, setScrollPosition] = useState(null); 50 | 51 | useLayoutEffect(() => { 52 | if (scrollPosition === null) { 53 | setScrollPosition(window.pageYOffset); 54 | } 55 | }); 56 | 57 | useEffect(() => { 58 | if (scrollPosition !== null) { 59 | // setTimeout() avoids a race condition of some sort 60 | setTimeout(() => { 61 | if (handleScrollPosition) { 62 | window.scrollTo(window.pageXOffset, scrollPosition); 63 | } 64 | 65 | setScrollPosition(null); 66 | }, 0); 67 | } 68 | }); 69 | 70 | useEffect(() => { 71 | if (htmlClassName) { 72 | document.documentElement.classList.add(htmlClassName); 73 | } 74 | 75 | MicroModal.show(id, { 76 | onClose: onRequestClose 77 | }); 78 | 79 | return () => { 80 | MicroModal.close(id); 81 | 82 | if (htmlClassName) { 83 | document.documentElement.classList.remove(htmlClassName); 84 | } 85 | }; 86 | }, []); 87 | 88 | return ( 89 | 106 | ); 107 | }; 108 | 109 | export default Dialog; 110 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/Modal.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslations} from '../../utils/hooks'; 2 | import {template} from '../../utils/template'; 3 | import Dialog from '../../components/Dialog'; 4 | import PoweredByLink from '../../components/PoweredByLink'; 5 | import type {ModalComponent} from '../../components/types/Modal'; 6 | 7 | const Modal: ModalComponent = ({ 8 | isForced, 9 | needsUpdate, 10 | privacyPolicyUrl, 11 | onClose, 12 | onSave, 13 | children 14 | }) => { 15 | const t = useTranslations(); 16 | 17 | return ( 18 | 26 |
27 |
28 |
29 |
30 | {isForced ? null : ( 31 | 39 | )} 40 |
41 | 42 |
43 |

47 | {t.modal.title} 48 |

49 | 50 |
51 | {isForced && needsUpdate ? ( 52 |

53 | {t.misc.updateNeeded} 54 |

55 | ) : null} 56 | 57 |

58 | {template(t.modal.description, { 59 | privacyPolicy: ( 60 | 65 | {t.modal.privacyPolicyLabel} 66 | 67 | ) 68 | })} 69 |

70 |
71 | 72 |
73 | {children} 74 | 75 |
    76 |
  • 77 | 84 |
  • 85 |
86 |
87 | 88 |

92 | 93 |

94 |
95 |
96 |
97 |
98 |
99 | ); 100 | }; 101 | export default Modal; 102 | -------------------------------------------------------------------------------- /src/ui/themes/standard/Purpose.tsx: -------------------------------------------------------------------------------- 1 | import {toChildArray} from 'preact'; 2 | import {useEffect, useRef} from 'preact/hooks'; 3 | import {useTranslations} from '../../utils/hooks'; 4 | import {ConsentState} from '../../components/types/ConsentState'; 5 | import {PurposeComponent} from '../../components/types/Purpose'; 6 | 7 | const Purpose: PurposeComponent = ({ 8 | id, 9 | title, 10 | description, 11 | isMandatory, 12 | isExempt, 13 | consent, 14 | children, 15 | onChange 16 | }) => { 17 | const t = useTranslations(); 18 | const domId = `orejime-purpose-${id}`; 19 | const inputRef = useRef(); 20 | 21 | useEffect(() => { 22 | if (inputRef.current) { 23 | inputRef.current.indeterminate = consent === ConsentState.partial; 24 | } 25 | }, [consent]); 26 | 27 | return ( 28 |
29 | { 38 | onChange((event.target as HTMLInputElement).checked); 39 | }} 40 | /> 41 | 42 | 77 | 78 | {description ? ( 79 |

86 | ) : null} 87 | 88 | {toChildArray(children).length ? ( 89 |

94 | {children} 95 |
96 | ) : null} 97 |
98 | ); 99 | }; 100 | 101 | export default Purpose; 102 | -------------------------------------------------------------------------------- /src/ui/themes/standard/Modal.tsx: -------------------------------------------------------------------------------- 1 | import {Close} from './Icons'; 2 | import Dialog from '../../components/Dialog'; 3 | import {template} from '../../utils/template'; 4 | import {useTranslations} from '../../utils/hooks'; 5 | import {ModalComponent} from '../../components/types/Modal'; 6 | import PoweredByLink from '../../components/PoweredByLink'; 7 | 8 | const Modal: ModalComponent = ({ 9 | isForced, 10 | needsUpdate, 11 | privacyPolicyUrl, 12 | onClose, 13 | onSave, 14 | children 15 | }) => { 16 | const t = useTranslations(); 17 | 18 | return ( 19 | 26 |
27 |
28 | {isForced ? null : ( 29 | 37 | )} 38 | 39 |

40 | {t.modal.title} 41 |

42 | 43 |

44 | {isForced && needsUpdate ? ( 45 |

46 | 47 | {t.misc.updateNeeded} 48 | 49 |

50 | ) : null} 51 | 52 | {template(t.modal.description, { 53 | privacyPolicy: ( 54 | { 58 | onClose(); 59 | }} 60 | href={privacyPolicyUrl} 61 | > 62 | {t.modal.privacyPolicyLabel} 63 | 64 | ) 65 | })} 66 |

67 |
68 | 69 |
{ 72 | event.preventDefault(); 73 | onSave(); 74 | }} 75 | onKeyDown={(event) => { 76 | // Prevents a bug where hitting the `Enter` 77 | // key on a checkbox submits the form. 78 | if ( 79 | event.target.nodeName === 'INPUT' 80 | && event.target.type === 'checkbox' 81 | && event.key === 'Enter' 82 | ) { 83 | event.preventDefault(); 84 | } 85 | }} 86 | > 87 |
{children}
88 |
89 | 95 | 96 | 97 |
98 |
99 |
100 |
101 | ); 102 | }; 103 | 104 | export default Modal; 105 | -------------------------------------------------------------------------------- /site/assets/logo-senat.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ui/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import {MutableRef, useContext, useEffect, useState} from 'preact/hooks'; 2 | import {Purpose} from '../../core/types'; 3 | import { 4 | acceptedConsents, 5 | areAllPurposesDisabled, 6 | areAllPurposesEnabled, 7 | declinedConsents 8 | } from '../../core/utils/purposes'; 9 | import Context from '../components/Context'; 10 | import {resolveCollision} from './dom'; 11 | 12 | export const useConfig = () => { 13 | const {config} = useContext(Context); 14 | return config; 15 | }; 16 | 17 | export const useTranslations = () => { 18 | const {translations} = useConfig(); 19 | return translations; 20 | }; 21 | 22 | export const useTheme = () => { 23 | const {config} = useContext(Context); 24 | return config.theme; 25 | }; 26 | 27 | export const useManager = () => { 28 | const {manager} = useContext(Context); 29 | const [i, refresh] = useState(0); 30 | 31 | // A little hack to rerender the host component whenever 32 | // consents are updated. 33 | useEffect(() => { 34 | const update = () => { 35 | refresh(i + 1); 36 | }; 37 | 38 | manager.on('update', update); 39 | 40 | return () => { 41 | manager.off('update', update); 42 | }; 43 | }); 44 | 45 | return manager; 46 | }; 47 | 48 | export const useBannerState = () => { 49 | const config = useConfig(); 50 | const manager = useManager(); 51 | 52 | if (config.forceModal) { 53 | return false; 54 | } 55 | 56 | if (!manager.isDirty()) { 57 | return false; 58 | } 59 | 60 | return true; 61 | }; 62 | 63 | export const useModalState = (): [ 64 | isOpen: boolean, 65 | open: () => void, 66 | close: () => void 67 | ] => { 68 | const config = useConfig(); 69 | const manager = useManager(); 70 | const mustOpen = () => config.forceModal && manager.isDirty(); 71 | 72 | const [isOpen, setOpen] = useState(mustOpen()); 73 | const open = () => { 74 | setOpen(true); 75 | }; 76 | 77 | const close = () => { 78 | setOpen(mustOpen()); 79 | }; 80 | 81 | return [isOpen, open, close]; 82 | }; 83 | 84 | export const useConsentGroup = ( 85 | purposes: Purpose[] 86 | ): [boolean, boolean, () => void, () => void] => { 87 | const manager = useManager(); 88 | 89 | const acceptAll = () => { 90 | manager.setConsents(acceptedConsents(purposes)); 91 | }; 92 | 93 | const declineAll = () => { 94 | manager.setConsents(declinedConsents(purposes)); 95 | }; 96 | 97 | return [ 98 | areAllPurposesEnabled(purposes, manager.getAllConsents()), 99 | areAllPurposesDisabled(purposes, manager.getAllConsents()), 100 | acceptAll, 101 | declineAll 102 | ]; 103 | }; 104 | 105 | export const useConsent = ( 106 | id: Purpose['id'] 107 | ): [consent: boolean, setConsent: (consent: boolean) => void] => { 108 | const manager = useManager(); 109 | return [manager.getConsent(id), manager.setConsent.bind(manager, id)]; 110 | }; 111 | 112 | export const useNonObscuringElement = (ref: MutableRef): void => { 113 | useEffect(() => { 114 | const resolve = (event: FocusEvent) => { 115 | resolveCollision(event.target as HTMLElement, ref.current); 116 | }; 117 | 118 | document.addEventListener('focusin', resolve); 119 | 120 | return () => { 121 | document.removeEventListener('focusin', resolve); 122 | }; 123 | }, [ref]); 124 | }; 125 | -------------------------------------------------------------------------------- /src/ui/themes/standard/Banner.tsx: -------------------------------------------------------------------------------- 1 | import {useRef} from 'preact/hooks'; 2 | import {BannerComponent} from '../../components/types/Banner'; 3 | import {imageAttributes} from '../../utils/config'; 4 | import {useNonObscuringElement, useTranslations} from '../../utils/hooks'; 5 | import {template} from '../../utils/template'; 6 | 7 | const Banner: BannerComponent = ({ 8 | isHidden, 9 | needsUpdate, 10 | purposeTitles, 11 | privacyPolicyUrl, 12 | logo, 13 | onAccept: onSaveRequest, 14 | onDecline: onDeclineRequest, 15 | onConfigure: onConfigRequest 16 | }) => { 17 | const ref = useRef(); 18 | const t = useTranslations(); 19 | 20 | useNonObscuringElement(ref); 21 | 22 | return ( 23 |
24 |
25 | {logo && ( 26 |
27 | 31 |
32 | )} 33 | 34 |
35 | {t.banner.title && ( 36 |

40 | {t.banner.title} 41 |

42 | )} 43 | 44 |

45 | {template(t.banner.description, { 46 | purposes: ( 47 | 51 | {purposeTitles.join(', ')} 52 | 53 | ), 54 | privacyPolicy: ( 55 | 60 | {t.banner.privacyPolicyLabel} 61 | 62 | ) 63 | })} 64 |

65 |
66 | 67 | {needsUpdate && ( 68 |

{t.misc.updateNeeded}

69 | )} 70 | 71 |
    72 |
  • 73 | 81 |
  • 82 |
  • 83 | 91 |
  • 92 |
  • 93 | 101 |
  • 102 |
103 |
104 |
105 | ); 106 | }; 107 | 108 | export default Banner; 109 | -------------------------------------------------------------------------------- /rspack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const {rspack} = require('@rspack/core'); 3 | const services = require('./site/services.json'); 4 | const package = require('./package.json'); 5 | const {standaloneEntries} = require('./build/entries'); 6 | const { 7 | templatePlugin, 8 | featureTemplatePlugin, 9 | assetsPlugin, 10 | matomoPlugin 11 | } = require('./build/site'); 12 | 13 | const fullPath = path.resolve.bind(path, __dirname); 14 | const isDev = process.env.NODE_ENV === 'development'; 15 | 16 | module.exports = { 17 | mode: isDev ? 'development' : 'production', 18 | devtool: isDev ? 'eval-source-map' : false, 19 | devServer: { 20 | port: 3000, 21 | compress: true, 22 | static: { 23 | directory: fullPath('dist') 24 | } 25 | }, 26 | entry: { 27 | migrations: './src/migrations/index.ts', 28 | ...standaloneEntries() 29 | }, 30 | output: { 31 | filename: '[name].js', 32 | path: fullPath('dist'), 33 | clean: true 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.tsx?$/, 39 | include: fullPath('src'), 40 | type: 'javascript/auto', 41 | use: { 42 | loader: 'builtin:swc-loader', 43 | options: { 44 | env: { 45 | // @see https://github.com/swc-project/swc-loader/issues/37#issuecomment-1233829398 46 | targets: package.browserslist 47 | }, 48 | jsc: { 49 | externalHelpers: true, 50 | preserveAllComments: false, 51 | parser: { 52 | syntax: 'typescript', 53 | tsx: true 54 | }, 55 | transform: { 56 | react: { 57 | runtime: 'automatic', 58 | importSource: 'preact' 59 | } 60 | } 61 | } 62 | } 63 | } 64 | }, 65 | { 66 | test: /\.css$/i, 67 | type: 'javascript/auto', 68 | use: [ 69 | rspack.CssExtractRspackPlugin.loader, 70 | 'css-loader', 71 | { 72 | loader: 'postcss-loader', 73 | options: { 74 | postcssOptions: { 75 | plugins: ['postcss-preset-env'] 76 | } 77 | } 78 | } 79 | ] 80 | } 81 | ] 82 | }, 83 | resolve: { 84 | extensions: ['.js', '.ts', '.tsx'], 85 | fallback: { 86 | fs: false, 87 | // Avoids a warning from uneval. 88 | 'internal-prop': false 89 | } 90 | }, 91 | optimization: { 92 | // Prevents rspack from splitting anything more than 93 | // the explicit chunks created from dynamic imports. 94 | splitChunks: false 95 | }, 96 | plugins: [ 97 | new rspack.CssExtractRspackPlugin({ 98 | filename: 'orejime-standard.css' 99 | }), 100 | templatePlugin({ 101 | title: 'A lightweight and accessible consent manager', 102 | chunks: ['migrations'], 103 | params: { 104 | services 105 | } 106 | }), 107 | templatePlugin({ 108 | title: 'Legal information & privacy policy', 109 | template: 'legal' 110 | }), 111 | templatePlugin({ 112 | title: 'Accessibility statement', 113 | template: 'accessibility' 114 | }), 115 | featureTemplatePlugin({title: 'Purposes', feature: 'purposes'}), 116 | featureTemplatePlugin({title: 'Grouping', feature: 'grouping'}), 117 | featureTemplatePlugin({ 118 | title: 'Internationalization', 119 | feature: 'i18n', 120 | lang: 'fr' 121 | }), 122 | featureTemplatePlugin({title: 'Styling', feature: 'styling'}), 123 | featureTemplatePlugin({ 124 | title: 'Contextual consent', 125 | feature: 'contextual', 126 | template: 'contextual' 127 | }), 128 | featureTemplatePlugin({ 129 | title: "Intégration au système de design de l'état", 130 | feature: 'dsfr', 131 | template: 'dsfr', 132 | chunks: [], 133 | lang: 'fr' 134 | }), 135 | featureTemplatePlugin({ 136 | title: 'WCAG compliance', 137 | feature: 'wcag', 138 | template: 'wcag' 139 | }), 140 | assetsPlugin(), 141 | matomoPlugin() 142 | ] 143 | }; 144 | -------------------------------------------------------------------------------- /src/core/Manager.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from './EventEmitter'; 2 | import type {Purpose, ConsentsMap} from './types'; 3 | import {withoutAll} from './utils/arrays'; 4 | import {diff, overwrite} from './utils/objects'; 5 | import { 6 | acceptedConsents, 7 | areAllPurposesDisabled, 8 | areAllPurposesEnabled, 9 | areAllPurposesMandatory, 10 | declinedConsents, 11 | defaultConsents, 12 | isConsentValid 13 | } from './utils/purposes'; 14 | 15 | type ManagerEvents = { 16 | dirty: (dirty: boolean) => void; 17 | update: (diff: ConsentsMap, all: ConsentsMap) => void; 18 | clear: () => void; 19 | }; 20 | 21 | export default class Manager extends EventEmitter { 22 | readonly #purposes: Purpose[]; 23 | readonly #mandatoryConsents: ConsentsMap; 24 | readonly #defaultConsents: ConsentsMap; 25 | #invalidConsentsIds: Purpose['id'][]; 26 | #isInitiallyInvalid: boolean; 27 | #consents: ConsentsMap; 28 | 29 | constructor(purposes: Purpose[], consents: ConsentsMap = {}) { 30 | super(); 31 | 32 | // The manager will be considered dirty until these 33 | // consents are made valid (i.e. set, and set 34 | // accordingly to their mandatory status) 35 | this.#invalidConsentsIds = purposes 36 | .filter((purpose) => !isConsentValid(purpose, consents)) 37 | .map(({id}) => id); 38 | 39 | this.#defaultConsents = defaultConsents(purposes); 40 | this.#mandatoryConsents = acceptedConsents( 41 | purposes.filter(({isMandatory}) => isMandatory) 42 | ); 43 | 44 | this.#purposes = purposes; 45 | this.#consents = overwrite(this.#defaultConsents, consents); 46 | 47 | this.#isInitiallyInvalid = 48 | Object.keys(consents).length > 0 49 | && this.#invalidConsentsIds.length > 0; 50 | } 51 | 52 | // Clones data, but not event handlers. 53 | clone() { 54 | return new Manager(this.#purposes, this.getAllConsents()); 55 | } 56 | 57 | isDirty() { 58 | return this.#invalidConsentsIds.length > 0; 59 | } 60 | 61 | needsUpdate() { 62 | return this.#isInitiallyInvalid; 63 | } 64 | 65 | areAllPurposesMandatory() { 66 | return areAllPurposesMandatory(this.#purposes); 67 | } 68 | 69 | areAllPurposesEnabled() { 70 | return areAllPurposesEnabled(this.#purposes, this.#consents); 71 | } 72 | 73 | areAllPurposesDisabled() { 74 | return areAllPurposesDisabled(this.#purposes, this.#consents); 75 | } 76 | 77 | getConsent(id: Purpose['id']) { 78 | return this.#consents?.[id]; 79 | } 80 | 81 | getAllConsents() { 82 | return {...this.#consents}; 83 | } 84 | 85 | acceptAll() { 86 | this.setConsents(acceptedConsents(this.#purposes)); 87 | } 88 | 89 | declineAll() { 90 | this.setConsents(declinedConsents(this.#purposes)); 91 | } 92 | 93 | setConsent(id: Purpose['id'], state: boolean) { 94 | this.setConsents({ 95 | [id]: state 96 | }); 97 | } 98 | 99 | setConsents(consents: ConsentsMap) { 100 | this.#updateConsents(consents); 101 | this.#updateInvalidConsents( 102 | withoutAll(this.#invalidConsentsIds, Object.keys(consents)) 103 | ); 104 | } 105 | 106 | clearConsents() { 107 | this.#updateConsents({...this.#defaultConsents}); 108 | this.#updateInvalidConsents( 109 | withoutAll( 110 | this.#purposes.map(({id}) => id), 111 | Object.keys(this.#mandatoryConsents) 112 | ) 113 | ); 114 | 115 | this.emit('clear'); 116 | } 117 | 118 | #updateConsents(consents: ConsentsMap) { 119 | const fixed = overwrite(consents, this.#mandatoryConsents); 120 | const updated = diff(this.#consents, fixed); 121 | 122 | this.#consents = { 123 | ...this.#consents, 124 | ...fixed 125 | }; 126 | 127 | this.emit('update', updated, {...this.#consents}); 128 | } 129 | 130 | #updateInvalidConsents(ids: Purpose['id'][]) { 131 | this.#invalidConsentsIds = ids; 132 | 133 | if (this.#invalidConsentsIds.length === 0) { 134 | this.#isInitiallyInvalid = false; 135 | } 136 | 137 | this.emit('dirty', this.isDirty()); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /site/assets/logo-boscop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /e2e/OrejimePage.ts: -------------------------------------------------------------------------------- 1 | import {expect, BrowserContext, Page, Locator} from '@playwright/test'; 2 | import Cookie from 'js-cookie'; 3 | import {Config} from '../src/ui/types'; 4 | 5 | export class OrejimePage { 6 | constructor( 7 | public readonly page: Page, 8 | public readonly context: BrowserContext 9 | ) {} 10 | 11 | async load(config: Partial, body: string) { 12 | await this.page.route('/', async (route) => { 13 | await route.fulfill({ 14 | body: ` 15 | 16 | 17 | 18 | 19 | Orejime 20 | 21 | 22 | 23 | 24 | ${body} 25 | 26 | 29 | 30 | 31 | 32 | ` 33 | }); 34 | }); 35 | 36 | await this.page.goto('/'); 37 | } 38 | 39 | get banner() { 40 | return this.locator('.orejime-Banner'); 41 | } 42 | 43 | get learnMoreBannerButton() { 44 | return this.locator('.orejime-Banner-learnMoreButton'); 45 | } 46 | 47 | get firstFocusableElementFromBanner() { 48 | return this.locator('.orejime-Banner :is(a, button)').first(); 49 | } 50 | 51 | get modal() { 52 | return this.locator('.orejime-Modal'); 53 | } 54 | 55 | get closeModalButton() { 56 | return this.locator('.orejime-Modal-closeButton'); 57 | } 58 | 59 | get contextualNotice() { 60 | return this.locator('.orejime-ContextualNotice'); 61 | } 62 | 63 | get contextualNoticePlaceholder() { 64 | return this.locator('.orejime-ContextualNotice-placeholder'); 65 | } 66 | 67 | locator(selector: string) { 68 | return this.page.locator(selector); 69 | } 70 | 71 | purposeCheckbox(purposeId: string) { 72 | return this.locator(`#orejime-purpose-${purposeId}`); 73 | } 74 | 75 | async acceptAllFromBanner() { 76 | await this.locator('.orejime-Banner-saveButton').click(); 77 | } 78 | 79 | async declineAllFromBanner() { 80 | await this.locator('.orejime-Banner-declineButton').click(); 81 | } 82 | 83 | async openModalFromBanner() { 84 | await this.learnMoreBannerButton.click(); 85 | } 86 | 87 | async enableAllFromModal() { 88 | await this.locator('.orejime-PurposeToggles-enableAll').click(); 89 | } 90 | 91 | async disableAllFromModal() { 92 | await this.locator('.orejime-PurposeToggles-disableAll').click(); 93 | } 94 | 95 | async saveFromModal() { 96 | await this.locator('.orejime-Modal-saveButton').click(); 97 | } 98 | 99 | async closeModalByClickingButton() { 100 | await this.locator('.orejime-Modal-closeButton').click(); 101 | } 102 | 103 | async closeDialogByClickingOutside() { 104 | // We're clicking in a corner to avoid clicking on the 105 | // modal itself, which has no effect. 106 | await this.locator('body').click({ 107 | position: { 108 | x: 1, 109 | y: 1 110 | } 111 | }); 112 | } 113 | 114 | async closeModalByPressingEscape() { 115 | await this.page.keyboard.press('Escape'); 116 | } 117 | 118 | async acceptContextualNotice() { 119 | await this.locator('.orejime-ContextualNotice-button').click(); 120 | } 121 | 122 | async expectConsents(consents: Record) { 123 | await expect(await this.getConsentsFromCookies()).toEqual(consents); 124 | } 125 | 126 | async getConsentsFromCookies() { 127 | const name = 'eu-consent'; 128 | const cookies = await this.context.cookies(); 129 | const {value} = cookies.find((cookie) => cookie.name === name)!; 130 | return JSON.parse(Cookie.converter.read(value, name)); 131 | } 132 | 133 | // In specific conditions, browser events can get queued 134 | // up and won't be fired until some interaction with the 135 | // page. 136 | // We're using a dummy click to trigger queued events. 137 | // @see https://github.com/microsoft/playwright/issues/979 138 | emptyEventQueue() { 139 | return this.page.mouse.click(0, 0); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/ui/themes/dsfr/Purpose.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from 'preact'; 2 | import {useState} from 'preact/hooks'; 3 | import type {CSSProperties} from 'preact'; 4 | import {ConsentState} from '../../components/types/ConsentState'; 5 | import type {PurposeProps} from '../../components/types/Purpose'; 6 | import {useTranslations} from '../../utils/hooks'; 7 | 8 | interface DsfrPurposeProps extends PurposeProps { 9 | isGlobal: boolean; 10 | } 11 | 12 | type DsfrPurposeComponent = FunctionComponent; 13 | 14 | const Purpose: DsfrPurposeComponent = ({ 15 | id, 16 | title, 17 | description, 18 | isGlobal = false, 19 | isMandatory = false, 20 | isExempt = false, 21 | consent, 22 | children, 23 | onChange 24 | }) => { 25 | const t = useTranslations(); 26 | const [isExpanded, setExpanded] = useState(false); 27 | const domId = `orejime-purpose-${id}`; 28 | 29 | return ( 30 |
37 |
44 | 48 | {title} 49 | 50 | {isMandatory ? ( 51 | <> 52 | {' '} 53 | 54 | {t.purpose.mandatory} 55 | 56 | 57 | ) : null} 58 | 59 | {isExempt ? ( 60 | <> 61 | {' '} 62 | 63 | {t.purpose.exempt} 64 | 65 | 66 | ) : null} 67 | 68 | 69 |
70 |
71 | 78 | 79 | 82 |
83 | 84 |
85 | 93 | 94 | 97 |
98 |
99 | 100 | {description ? ( 101 |

108 | ) : null} 109 | 110 | {children ? ( 111 | <> 112 |
113 | 124 |
125 | 126 |
140 | {children} 141 |
142 | 143 | ) : null} 144 |
145 |
146 | ); 147 | }; 148 | 149 | export default Purpose; 150 | -------------------------------------------------------------------------------- /src/core/Manager.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import Manager from './Manager'; 3 | import type {Purpose} from './types'; 4 | 5 | describe('Manager', () => { 6 | const purpose = (props: Partial = {}): Purpose => { 7 | // https://stackoverflow.com/a/28997977 8 | const id = (Math.random() * 1e20).toString(36); 9 | 10 | return { 11 | id, 12 | cookies: [], 13 | ...props 14 | }; 15 | }; 16 | 17 | test('areAllPurposesMandatory', () => { 18 | const managerA = new Manager([]); 19 | 20 | expect(managerA.areAllPurposesMandatory()).toBeFalsy(); 21 | 22 | const managerB = new Manager([purpose(), purpose({isMandatory: true})]); 23 | 24 | expect(managerB.areAllPurposesMandatory()).toBeFalsy(); 25 | 26 | const managerC = new Manager([purpose({isMandatory: true})]); 27 | 28 | expect(managerC.areAllPurposesMandatory()).toBeTruthy(); 29 | }); 30 | 31 | test('areAllPurposesEnabled', () => { 32 | const managerA = new Manager([]); 33 | 34 | expect(managerA.areAllPurposesEnabled()).toBeFalsy(); 35 | 36 | const managerB = new Manager([purpose(), purpose({default: true})]); 37 | 38 | expect(managerB.areAllPurposesEnabled()).toBeFalsy(); 39 | 40 | const managerC = new Manager([purpose({default: true})]); 41 | 42 | expect(managerC.areAllPurposesEnabled()).toBeTruthy(); 43 | }); 44 | 45 | test('areAllPurposesDisabled', () => { 46 | const managerA = new Manager([]); 47 | 48 | expect(managerA.areAllPurposesDisabled()).toBeFalsy(); 49 | 50 | const managerB = new Manager([purpose(), purpose({default: true})]); 51 | 52 | expect(managerB.areAllPurposesDisabled()).toBeFalsy(); 53 | 54 | const managerC = new Manager([purpose()]); 55 | 56 | expect(managerC.areAllPurposesDisabled()).toBeTruthy(); 57 | }); 58 | 59 | test('getConsent', () => { 60 | const purposeA = purpose(); 61 | const purposeB = purpose(); 62 | const manager = new Manager([purposeA, purposeB], { 63 | [purposeA.id]: true 64 | }); 65 | 66 | expect(manager.getConsent(purposeA.id)).toBeTruthy(); 67 | expect(manager.getConsent(purposeB.id)).toBeFalsy(); 68 | expect(manager.getConsent('unset')).toBeFalsy(); 69 | }); 70 | 71 | test('getAllConsents', () => { 72 | const purposeA = purpose(); 73 | const purposeB = purpose(); 74 | const consents = { 75 | [purposeA.id]: true, 76 | [purposeB.id]: true 77 | }; 78 | 79 | const managerA = new Manager([], consents); 80 | 81 | expect(managerA.getAllConsents()).toEqual({}); 82 | 83 | const managerB = new Manager([purposeA], consents); 84 | 85 | expect(managerB.getAllConsents()).toEqual({ 86 | [purposeA.id]: consents[purposeA.id] 87 | }); 88 | }); 89 | 90 | test('acceptAll', () => { 91 | const purposeA = purpose(); 92 | const purposeB = purpose({ 93 | default: true 94 | }); 95 | 96 | const dirtyCallback = jest.fn(); 97 | const updateCallback = jest.fn(); 98 | const expectedConsents = { 99 | [purposeA.id]: true, 100 | [purposeB.id]: true 101 | }; 102 | 103 | const expectedDiff = { 104 | [purposeA.id]: true 105 | }; 106 | 107 | const manager = new Manager([purposeA, purposeB]); 108 | manager.on('dirty', dirtyCallback); 109 | manager.on('update', updateCallback); 110 | manager.acceptAll(); 111 | 112 | expect(manager.getAllConsents()).toEqual(expectedConsents); 113 | expect(dirtyCallback.mock.calls).toEqual([[false]]); 114 | expect(updateCallback.mock.calls).toEqual([ 115 | [expectedDiff, expectedConsents] 116 | ]); 117 | }); 118 | 119 | test('declineAll', () => { 120 | const purposeA = purpose({ 121 | default: true 122 | }); 123 | 124 | const purposeB = purpose({ 125 | isMandatory: true 126 | }); 127 | 128 | const dirtyCallback = jest.fn(); 129 | const updateCallback = jest.fn(); 130 | const expectedConsents = { 131 | [purposeA.id]: false, 132 | [purposeB.id]: true 133 | }; 134 | 135 | const expectedDiff = { 136 | [purposeA.id]: false 137 | }; 138 | 139 | const manager = new Manager([purposeA, purposeB]); 140 | manager.on('dirty', dirtyCallback); 141 | manager.on('update', updateCallback); 142 | manager.declineAll(); 143 | 144 | expect(manager.getAllConsents()).toEqual(expectedConsents); 145 | expect(dirtyCallback.mock.calls).toEqual([[false]]); 146 | expect(updateCallback.mock.calls).toEqual([ 147 | [expectedDiff, expectedConsents] 148 | ]); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/ui/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import type {ElementReference} from '../types'; 2 | 3 | export const getElement = ( 4 | reference: ElementReference, 5 | defaultElement?: HTMLElement 6 | ) => { 7 | if (!reference) { 8 | return defaultElement; 9 | } 10 | 11 | if (typeof reference === 'string') { 12 | return document.querySelector(reference) as HTMLElement; 13 | } 14 | 15 | return reference; 16 | }; 17 | 18 | export const getRootElement = (reference: ElementReference) => { 19 | const element = getElement(reference) || document.createElement('div'); 20 | 21 | if (!element.classList.contains('orejime-Root')) { 22 | element.classList.add('orejime-Root'); 23 | } 24 | 25 | if (!element.parentNode) { 26 | document.body.insertBefore(element, document.body.firstChild); 27 | } 28 | 29 | return element; 30 | }; 31 | 32 | // @see https://stackoverflow.com/a/326076/2391359 33 | const isTopLevelFrame = () => { 34 | try { 35 | return window.self === window.top; 36 | } catch (e) { 37 | return true; 38 | } 39 | }; 40 | 41 | // On Firefox, iframes can "steal" focus from the top level 42 | // page. We don't want that to happen so we're moving focus 43 | // at the top level only. 44 | export const softFocus = (element?: HTMLElement) => { 45 | if (element && isTopLevelFrame()) { 46 | element?.focus(); 47 | } 48 | }; 49 | 50 | // A very minimal implementation tailored for the kind of 51 | // elements used within the app. 52 | export const findFirstFocusableChild = (element: HTMLElement) => 53 | element.querySelector( 54 | 'a[href], button:not([disabled]):not([aria-hidden]), [tabindex]:not([tabindex^="-"])' 55 | ); 56 | 57 | // Translates an element vertically by a given offset, 58 | // relatively to its current translation. 59 | const translateElementY = ( 60 | element: HTMLElement, 61 | offset: number, 62 | direction?: 1 | -1 63 | ) => { 64 | const base = Number(element.dataset.translation) || 0; 65 | const translation = base + offset * direction; 66 | 67 | // We're only moving the element if the translation has 68 | // the given direction, as we don't want it to move the 69 | // other way. 70 | if (Math.sign(translation) === Math.sign(direction)) { 71 | element.dataset.translation = translation.toString(); 72 | element.style.transform = `translateY(${translation}px)`; 73 | } else { 74 | delete element.dataset.translation; 75 | element.style.transform = ''; 76 | } 77 | }; 78 | 79 | const getCollisionPadding = (element: HTMLElement) => { 80 | const styles = window.getComputedStyle(element); 81 | const padding = styles.getPropertyValue('--orejime-collision-padding'); 82 | return padding.length ? parseInt(padding, 10) : 16; 83 | }; 84 | 85 | const getPaddedBoundingBox = (element: DOMRect, padding: number) => ({ 86 | top: element.top + padding, 87 | right: element.right + padding, 88 | bottom: element.bottom + padding, 89 | left: element.left + padding 90 | }); 91 | 92 | // Resolves a visual collision between two elements, either 93 | // by scrolling the page or moving one of them. 94 | // We're only resolving collisions on the vertical axis, as 95 | // it is the main direction of web pages. 96 | export const resolveCollision = (fixed: HTMLElement, mobile: HTMLElement) => { 97 | if (mobile.contains(fixed)) { 98 | translateElementY(mobile, 0); 99 | return; 100 | } 101 | 102 | // We're padding the fixed element's bounding box to 103 | // avoid snapping the mobile one right on its border. 104 | const fixedRect = getPaddedBoundingBox( 105 | fixed.getBoundingClientRect(), 106 | getCollisionPadding(mobile) 107 | ); 108 | 109 | const mobileRect = mobile.getBoundingClientRect(); 110 | const isCollidingX = 111 | mobileRect.left < fixedRect.right && mobileRect.right > fixedRect.left; 112 | 113 | if (!isCollidingX) { 114 | translateElementY(mobile, 0); 115 | return; 116 | } 117 | 118 | const mobileCenterY = mobileRect.top + mobileRect.height / 2; 119 | const direction = mobileCenterY > window.innerHeight / 2 ? 1 : -1; 120 | const overlap = 121 | direction > 0 122 | ? fixedRect.bottom - mobileRect.top 123 | : mobileRect.bottom - fixedRect.top; 124 | 125 | const isCollidingY = 126 | mobileRect.top < fixedRect.bottom && mobileRect.bottom > fixedRect.top; 127 | 128 | if (!isCollidingY) { 129 | translateElementY(mobile, overlap, direction); 130 | return; 131 | } 132 | 133 | const doc = document.documentElement; 134 | const leeway = 135 | direction > 0 136 | ? Math.abs(doc.scrollHeight - doc.clientHeight - doc.scrollTop) 137 | : doc.scrollTop; 138 | 139 | // We're scrolling as much possible first. 140 | window.scrollBy({ 141 | top: overlap * direction 142 | }); 143 | 144 | // If scrolling isn't enough to get out of trouble, 145 | // we're moving the mobile element out of the way. 146 | if (overlap > leeway) { 147 | translateElementY(mobile, overlap - leeway, direction); 148 | } 149 | }; 150 | -------------------------------------------------------------------------------- /site/themes/boscop-light-soft-color-theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "light", 3 | "colors": { 4 | "editor.background": "#faf5dd", 5 | "editor.foreground": "#282827" 6 | }, 7 | "tokenColors": [ 8 | { 9 | "name": "Defaults", 10 | "settings": { 11 | "foreground": "#282827" 12 | } 13 | }, 14 | { 15 | "name": "Variables", 16 | "scope": "source,meta.block,text,variable,support.other.variable,constant.character,constant.other,variable.parameter,meta.import,variable.other.readwrite.alias,variable.other.property,support.variable.property.dom,meta.method.body.java,variable.parameter.function.swift", 17 | "settings": { 18 | "foreground": "#282827" 19 | } 20 | }, 21 | { 22 | "name": "Punctuation and comments", 23 | "scope": "meta.brace,punctuation,punctuation.section.embedded.end,comment,constant.character.escape,keyword.operator.class,keyword.operator.access.dot.rust,meta.parameter-clause.swift", 24 | "settings": { 25 | "foreground": "#3d7078" 26 | } 27 | }, 28 | { 29 | "name": "Array punctuation", 30 | "scope": "punctuation.section.array", 31 | "settings": { 32 | "foreground": "#3d7078" 33 | } 34 | }, 35 | { 36 | "name": "Constants", 37 | "scope": "constant.other,constant.numeric,variable.other.constant,support.constant.core,support.constant.property-value,meta.property-value,constant.language,support.variable.dom,constant.keyword.clojure,support.constant.swift", 38 | "settings": { 39 | "foreground": "#00747c" 40 | } 41 | }, 42 | { 43 | "name": "Function calls", 44 | "scope": "entity.name.function,support.function,source.sql", 45 | "settings": { 46 | "foreground": "#7f5a9b" 47 | } 48 | }, 49 | { 50 | "name": "Types", 51 | "scope": "storage.type,entity.name.type,support.class.builtin,meta.type_params,meta.type.annotation,support.type.primitive,support.type.builtin,support.class,support.type.core,meta.class,keyword.other.type,meta.function.parameters.php,support.type.swift", 52 | "settings": { 53 | "foreground": "#00747c" 54 | } 55 | }, 56 | { 57 | "name": "This", 58 | "scope": "variable.language.this,variable.language.super,variable.parameter.function.language.special.self.python,variable.language.special.self.python", 59 | "settings": { 60 | "fontStyle": "bold", 61 | "foreground": "#3d7078" 62 | } 63 | }, 64 | { 65 | "name": "Type parameters", 66 | "scope": "meta.type.parameters", 67 | "settings": { 68 | "foreground": "#9d5819" 69 | } 70 | }, 71 | { 72 | "name": "Keywords", 73 | "scope": "storage.type.js,storage.type.ts,storage.type.type.ts,keyword,storage.modifier,storage.type.function,storage.type.class,storage.type.rust,storage.type.struct.swift,storage.type.extension.swift", 74 | "settings": { 75 | "fontStyle": "italic", 76 | "foreground": "#aa4500" 77 | } 78 | }, 79 | { 80 | "name": "Attributes and properties", 81 | "scope": "meta.object-literal.key,meta.structure.dictionary,support.type.property-name,support.type.vendored.property-name,entity.other.attribute-name", 82 | "settings": { 83 | "foreground": "#aa4500" 84 | } 85 | }, 86 | { 87 | "name": "Strings and regular expressions", 88 | "scope": "string,string.regexp,constant.other.character-class,meta.attribute-selector", 89 | "settings": { 90 | "foreground": "#4b733a" 91 | } 92 | }, 93 | { 94 | "name": "String quotes", 95 | "scope": "punctuation.definition.string,constant.character.escape", 96 | "settings": { 97 | "foreground": "#4b733a" 98 | } 99 | }, 100 | { 101 | "name": "DOM Elements", 102 | "scope": "entity.name.tag,support.class.component.js,entity.other.attribute-name.id.css,entity.other.attribute-name.class.css", 103 | "settings": { 104 | "fontStyle": "bold", 105 | "foreground": "#00747c" 106 | } 107 | }, 108 | { 109 | "name": "Operators", 110 | "scope": "keyword.operator,support.variable.object,keyword.control.anchor.regexp,entity.name.operator.cpp", 111 | "settings": { 112 | "fontStyle": "normal", 113 | "foreground": "#9d5819" 114 | } 115 | }, 116 | { 117 | "name": "Markdown headings", 118 | "scope": "markup.heading", 119 | "settings": { 120 | "fontStyle": "bold", 121 | "foreground": "#282827" 122 | } 123 | }, 124 | { 125 | "name": "Markdown punctuation", 126 | "scope": "markup.underline.link,beginning.punctuation.definition,punctuation.definition.string.begin.markdown,punctuation.definition.string.end.markdown", 127 | "settings": { 128 | "foreground": "#3d7078" 129 | } 130 | }, 131 | { 132 | "name": "Markdown link URL", 133 | "scope": "string.other.link.title.markdown", 134 | "settings": { 135 | "foreground": "#aa4500" 136 | } 137 | }, 138 | { 139 | "name": "Markdown bold", 140 | "scope": "markup.bold.markdown", 141 | "settings": { 142 | "fontStyle": "bold" 143 | } 144 | }, 145 | { 146 | "name": "Markdown italic", 147 | "scope": "markup.italic.markdown", 148 | "settings": { 149 | "fontStyle": "italic" 150 | } 151 | } 152 | ] 153 | } 154 | -------------------------------------------------------------------------------- /site/accessibility.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Orejime 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 27 | 28 |
29 |
30 |
31 | 38 |
39 | 40 |
41 |
42 |

Accessibility Statement

43 | 44 |

45 | Boscop is committed to making its websites, intranets, and 46 | extranets accessible in accordance with 47 | Article 47 of Law No. 2005-102 of February 11, 2005. 52 |

53 |

54 | This accessibility declaration applies to 55 | orejime.boscop.fr. 56 |

57 |
58 |
59 |
60 |
61 | 62 |
68 |
69 |
70 |

Conformity status

71 | 72 |

73 | orejime.boscop.fr is fully compliant with the RGAA General 74 | Accessibility Improvement Framework version 4.1.2. 75 |

76 | 77 |

Test results

78 | 79 |

80 | The conformity audit carried out by Boscop reveals that 100% 81 | of RGAA criteria are respected. 82 |

83 | 84 |

Inaccessible content

85 | 86 |

87 | The content listed below is not accessible for the following 88 | reasons. 89 |

90 | 91 |

Non-conformities

92 | 93 |

NA

94 | 95 |

Exemptions for disproportionate burden

96 | 97 |

NA

98 | 99 |

Content not subject to accessibility obligations

100 | 101 |

NA

102 | 103 |

Drafting of this accessibility statement

104 | 105 |

This statement was established on April 15, 2025.

106 | 107 |

Technologies used for website creation

108 | 109 |
    110 |
  • HTML
  • 111 |
  • CSS
  • 112 |
113 | 114 |

Testing environment

115 | 116 |

117 | Content restitution checks were carried out based on the 118 | combination provided by the RGAA reference base, with the 119 | following versions: 120 |

121 | 122 |
    123 |
  • NVDA and Firefox
  • 124 |
  • Jaws and Firefox
  • 125 |
  • Voiceover and Safari
  • 126 |
127 | 128 |

Tools used during the assessment

129 | 130 |
    131 |
  • Colour Contrast Analyser
  • 132 |
  • "Web Developer" extension
  • 133 |
  • 134 | "Assistant RGAA" extension 137 |
  • 138 |
  • "WCAG Contrast checker" extension
  • 139 |
  • "ARC Toolkit" extension
  • 140 |
  • "HeadingsMap" extension
  • 141 |
  • Developer tools integrated into the Firefox browser
  • 142 |
  • W3C HTML validator
  • 143 |
144 | 145 |

Pages of the site that underwent conformity checks

146 | 147 | 151 | 152 |

Feedback and contact

153 | 154 |

155 | If you cannot access a content or service, you can contact the 156 | person in charge of the website to be guided towards an 157 | accessible alternative or obtain the content in another 158 | format: 159 | contact Boscop 162 |

163 | 164 |

Recourse mechanisms

165 | 166 |

167 | This procedure is to be used in the following case.
168 | You have reported an accessibility defect to the website 169 | manager that prevents you from accessing a content or service 170 | of the portal and you have not received a satisfactory 171 | response. 172 |

173 | 174 | 193 |
194 |
195 |
196 | 197 | 222 | 223 | 224 | -------------------------------------------------------------------------------- /site/features/dsfr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DSFR 6 | 7 | 11 | 12 | 35 | 36 | 37 | 38 | 68 | 69 |
70 |
71 |
72 |
73 |

Intégration au système de design de l'État français

74 | 75 |

76 | Orejime propose une version adaptée au système de design de 77 | l'État. 78 |
79 | Cette version est configurable de la même manière que la 80 | version standard, mais utilise la présentation recommandée 81 | du 82 | 85 | gestionnaire de consentement 86 | 87 | . 88 |

89 | 90 |

Politique de confidentialité

91 | 92 |

93 | Cette section tient lieu de page de politique de 94 | confidentialité. 95 |
96 | En contexte réel, ce contenu serait affiché dans une page 97 | dédiée. 98 |
99 | Cette page fournirait toutes les informations relatives à 100 | la gestion des données personnelles des usagers, ainsi que 101 | des boutons permettant à l'usager de configurer ou 102 | réinitialiser son consentement, comme ci-dessous. 103 |

104 | 105 |
    106 |
  • 107 | 110 |
  • 111 |
  • 112 | 118 |
  • 119 |
120 | 121 |

Media

122 | 123 | 137 | 138 |

Configuration

139 | 140 | <%= js.highlightedCode %> 141 |
142 |
143 |
144 |
145 | 146 |
147 |
148 | 174 | 175 | 206 |
207 |
208 | 209 | 210 | 213 | 214 | 231 | 232 | 233 | -------------------------------------------------------------------------------- /site/assets/logo-luxembourg-city.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/ui/themes/standard/index.css: -------------------------------------------------------------------------------- 1 | /* Micro css reset for everything orejime related. */ 2 | [class^='orejime-'] { 3 | margin: 0; 4 | padding: 0; 5 | border: 0; 6 | line-height: var(--orejime-space-m); 7 | font-family: var(--orejime-font-family); 8 | font-size: inherit; 9 | color: inherit; 10 | vertical-align: baseline; 11 | cursor: default; 12 | float: none; 13 | width: auto; 14 | text-align: left; 15 | font-weight: normal; 16 | } 17 | 18 | .orejime-Env { 19 | --orejime-space-m: 1.4em; 20 | --orejime-space-l: calc(2 * var(--orejime-space-m)); 21 | --orejime-space-s: calc(var(--orejime-space-m) / 2); 22 | --orejime-space-xs: calc(var(--orejime-space-m) / 4); 23 | --orejime-font-size-small: 0.8rem; 24 | --orejime-font-family: sans-serif; 25 | --orejime-radius: calc(var(--orejime-space-m) / 4); 26 | --orejime-color-background: #fff; 27 | --orejime-color-text: #222; 28 | --orejime-color-subdued: #666; 29 | --orejime-color-interactive: royalblue; 30 | --orejime-color-on-interactive: #fff; 31 | --orejime-color-backdrop: rgba(0, 0, 0, 0.5); 32 | --orejime-color-shadow: 0, 0, 0; 33 | --orejime-shadow: 34 | 0.1em 0.2em 0.4em rgba(var(--orejime-color-shadow), 0.25), 35 | 0.2em 0.6em 1.5em rgba(var(--orejime-color-shadow), 0.2); 36 | --orejime-banner-max-width: 45ch; 37 | --orejime-modal-max-width: 65ch; 38 | --orejime-collision-padding: 0; 39 | 40 | all: unset; 41 | 42 | p { 43 | margin: 0; 44 | } 45 | 46 | a { 47 | color: var(--orejime-color-interactive); 48 | text-decoration: underline; 49 | cursor: pointer; 50 | } 51 | } 52 | 53 | .orejime-Button { 54 | margin: 0; 55 | border: 0; 56 | border-radius: var(--orejime-radius); 57 | padding: var(--orejime-space-xs) var(--orejime-space-s); 58 | color: var(--orejime-color-on-interactive); 59 | background: var(--orejime-color-interactive); 60 | font: inherit; 61 | cursor: pointer; 62 | } 63 | 64 | .orejime-Button:is([disabled], [aria-disabled='true']) { 65 | background: none; 66 | color: var(--orejime-color-subdued); 67 | cursor: not-allowed; 68 | } 69 | 70 | .orejime-Button:not(:is([disabled], [aria-disabled='true'])):is( 71 | :hover, 72 | :focus 73 | ) { 74 | outline: 2px solid var(--orejime-color-interactive); 75 | outline-offset: 1px; 76 | } 77 | 78 | .orejime-Button:not(:is([disabled], [aria-disabled='true'])):active { 79 | outline-width: 1px; 80 | outline-offset: 2px; 81 | } 82 | 83 | .orejime-ButtonList { 84 | display: flex; 85 | flex-wrap: wrap; 86 | gap: 0.5ch; 87 | } 88 | 89 | .orejime-Banner { 90 | position: fixed; 91 | z-index: 1000; 92 | right: 0; 93 | bottom: 0; 94 | padding: var(--orejime-space-m); 95 | max-width: 100%; 96 | } 97 | 98 | .orejime-Banner-body { 99 | box-shadow: var(--orejime-shadow); 100 | border-radius: var(--orejime-radius); 101 | padding: var(--orejime-space-m); 102 | max-width: var(--orejime-banner-max-width); 103 | background: var(--orejime-color-background); 104 | color: var(--orejime-color-text); 105 | } 106 | 107 | .orejime-Banner-logo { 108 | max-width: 10ch; 109 | } 110 | 111 | .orejime-Banner-title { 112 | margin-bottom: var(--orejime-space-s); 113 | font-weight: bold; 114 | font-size: 1em; 115 | line-height: var(--orejime-space-m); 116 | } 117 | 118 | .orejime-Banner-description { 119 | white-space: pre-line; 120 | } 121 | 122 | .orejime-Banner-purposes { 123 | font-style: italic; 124 | } 125 | 126 | .orejime-Banner-changes { 127 | margin-top: var(--orejime-space-s); 128 | font-weight: bold; 129 | } 130 | 131 | .orejime-Banner-actions { 132 | margin-top: var(--orejime-space-s); 133 | } 134 | 135 | .orejime-Banner-actionItem { 136 | display: inline; 137 | } 138 | 139 | .orejime-Banner-learnMoreButton { 140 | display: inline-block; 141 | } 142 | 143 | .orejimeHtml-WithModalOpen { 144 | height: 100%; 145 | } 146 | 147 | .orejimeHtml-WithModalOpen body { 148 | position: fixed; 149 | overflow: hidden; 150 | height: 100%; 151 | width: 100%; 152 | } 153 | 154 | .orejime-ModalOverlay, 155 | .orejime-BannerOverlay { 156 | z-index: 1000; 157 | background: var(--orejime-color-backdrop); 158 | position: fixed; 159 | top: 0; 160 | left: 0; 161 | right: 0; 162 | bottom: 0; 163 | } 164 | 165 | .orejime-ModalOverlay { 166 | z-index: 1001; 167 | display: flex; 168 | align-items: center; 169 | justify-content: center; 170 | width: 100%; 171 | height: 100%; 172 | } 173 | 174 | .orejime-ModalWrapper { 175 | box-shadow: var(--orejime-shadow); 176 | border-radius: var(--orejime-radius); 177 | max-width: 100%; 178 | max-height: 100%; 179 | overflow: auto; 180 | } 181 | 182 | .orejime-Modal { 183 | position: relative; 184 | padding: var(--orejime-space-l); 185 | max-width: var(--orejime-modal-max-width); 186 | background: var(--orejime-color-background); 187 | color: var(--orejime-color-text); 188 | } 189 | 190 | .orejime-Modal-header { 191 | margin-bottom: calc(var(--orejime-space-m) + var(--orejime-space-s)); 192 | } 193 | 194 | .orejime-Modal-title { 195 | display: block; 196 | margin: 0 0 var(--orejime-space-m) 0; 197 | line-height: 1; 198 | font-size: 2em; 199 | font-weight: bold; 200 | } 201 | 202 | .orejime-Modal-closeButton { 203 | border: none; 204 | background: none; 205 | color: var(--orejime-color-subdued); 206 | position: absolute; 207 | top: var(--orejime-space-s); 208 | right: var(--orejime-space-s); 209 | padding: var(--orejime-space-s); 210 | cursor: pointer; 211 | } 212 | 213 | .orejime-CloseIcon { 214 | display: block; 215 | stroke: currentColor; 216 | stroke-width: 2px; 217 | width: 1em; 218 | height: 1em; 219 | } 220 | 221 | .orejime-Modal-closeButton:is(:hover, :focus) .orejime-CloseIcon { 222 | color: var(--orejime-color-text); 223 | } 224 | 225 | .orejime-Modal-body { 226 | margin-bottom: var(--orejime-space-l); 227 | } 228 | 229 | .orejime-Modal-description { 230 | white-space: pre-line; 231 | } 232 | 233 | .orejime-Modal-footer { 234 | display: flex; 235 | flex-wrap: wrap; 236 | justify-content: space-between; 237 | align-items: flex-end; 238 | gap: 1ch; 239 | } 240 | 241 | .orejime-Modal-poweredByLink { 242 | display: inline-block; 243 | font-size: var(--orejime-font-size-small); 244 | } 245 | 246 | .orejime-PurposeToggles { 247 | margin-bottom: var(--orejime-space-m); 248 | } 249 | 250 | .orejime-PurposeList { 251 | list-style: none; 252 | } 253 | 254 | .orejime-Purpose { 255 | display: grid; 256 | grid-template: 257 | 'input label' 258 | 'empty description' 259 | 'empty children' 260 | / min-content auto; 261 | gap: 0 1ch; 262 | margin-top: var(--orejime-space-s); 263 | } 264 | 265 | .orejime-Purpose-label { 266 | grid-area: label; 267 | } 268 | 269 | .orejime-Purpose-title { 270 | font-weight: bold; 271 | cursor: pointer; 272 | } 273 | 274 | .orejime-Purpose-description { 275 | grid-area: description; 276 | color: var(--orejime-color-subdued); 277 | white-space: pre-line; 278 | } 279 | 280 | .orejime-Purpose-purposes { 281 | font-size: var(--orejime-font-size-small); 282 | color: var(--orejime-color-subdued); 283 | } 284 | 285 | .orejime-Purpose-attribute { 286 | font-size: var(--orejime-font-size-small); 287 | font-style: italic; 288 | color: var(--orejime-color-subdued); 289 | 290 | &:before { 291 | content: '- '; 292 | } 293 | 294 | &[title] { 295 | text-decoration: underline dotted; 296 | } 297 | } 298 | 299 | .orejime-Purpose-input { 300 | grid-area: input; 301 | align-self: center; 302 | width: var(--orejime-space-m); 303 | height: var(--orejime-space-m); 304 | appearance: revert; 305 | background: revert; 306 | accent-color: var(--orejime-color-interactive); 307 | } 308 | 309 | .orejime-Purpose-input:disabled { 310 | accent-color: var(--orejime-color-subdued); 311 | } 312 | 313 | .orejime-Purpose-children { 314 | grid-area: children; 315 | } 316 | 317 | .orejime-ContextualNotice { 318 | border-radius: var(--orejime-radius); 319 | padding: var(--orejime-space-l); 320 | background: var(--orejime-color-background); 321 | color: var(--orejime-color-text); 322 | } 323 | 324 | .orejime-ContextualNotice-title { 325 | font: inherit; 326 | font-weight: bold; 327 | } 328 | 329 | .orejime-ContextualNotice-button { 330 | margin-top: var(--orejime-space-s); 331 | } 332 | --------------------------------------------------------------------------------