feat: add GDPR cookie consent banner (#1216)

* feat: add GDPR cookie consent banner

Add a GDPR-compliant cookie consent banner that gates analytics (Google
Analytics) and functional cookies (comments) until user consent is given.

Features:
- Theme-consistent styling using CSS variables (light/dark mode)
- Settings panel with category toggles (necessary, analytics, functional)
- Consent stored in cookie for 365 days
- Google Analytics only loads after analytics consent
- Comments only load after functional consent
- Translations for all 32 supported languages
- Accessible with proper ARIA attributes and focus management

Configuration in hugo.yaml:
  params:
    cookies:
      enabled: true
      categories:
        analytics: true
        functional: true

When cookies.enabled is false, scripts load normally without consent gating.

Closes #412

* refactor: move consent-gated GA to cookies/analytics.html

Move Google Analytics consent logic to avoid shadowing Hugo's internal
`_internal/google_analytics.html` template.

Changes:
- Rename google_analytics.html → cookies/analytics.html
- Update head.html to conditionally load:
  - cookies.enabled=true: cookies/analytics.html (consent-gated)
  - cookies.enabled=false: Hugo's _internal/google_analytics.html
- Align cookies/analytics.html with Hugo's internal template:
  - Support Privacy.GoogleAnalytics.Disable setting
  - Support Privacy.GoogleAnalytics.RespectDoNotTrack setting
  - Add UA- prefix deprecation warning

Note: Custom JS is required for consent-gated GA because Hugo templates
render at build time, but consent happens at runtime. We cannot call
Hugo's internal template after the user grants consent - we must
dynamically inject the GA script via JavaScript.

* style: indent cookies/analytics.html

* refactor: don't use default, put default configurations in params.toml

* fix: don't use `description` as translation key because it's reserved and will throw error

* fix: remove footer/components/custom-font.html call

* fix: banner showSettings key

* style: indent comments/include

* Add missing article.alert translation back

* style: format i18n toml files

* style: indent head/head.html

* style adjustment

* style: add missing -

* style: format demo/config/params.toml

* style: remove redundant comments from cookies.scss

* style: update box-shadow for cookie banner content

---------

Co-authored-by: delize <4028612+delize@users.noreply.github.com>
Co-authored-by: Jimmy Cai <jimmy@cai.im>
This commit is contained in:
Andrew Doering
2026-02-19 17:21:25 +01:00
committed by GitHub
co-authored by delize Jimmy Cai
parent 3a0d70ebbd
commit 4ad88a327e
43 changed files with 1393 additions and 8 deletions
+221
View File
@@ -0,0 +1,221 @@
interface ConsentState {
necessary: boolean;
analytics: boolean;
functional: boolean;
timestamp: number;
}
class CookieConsent {
private static COOKIE_NAME = 'cookie_consent';
private static COOKIE_DAYS = 365;
private state: ConsentState | null = null;
private banner: HTMLElement | null = null;
private settingsPanel: HTMLElement | null = null;
constructor() {
this.banner = document.getElementById('cookie-consent-banner');
this.settingsPanel = document.getElementById('cookie-settings-panel');
this.state = this.loadState();
if (!this.state && this.banner) {
this.showBanner();
}
this.bindEvents();
this.dispatchConsentEvent();
}
private loadState(): ConsentState | null {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith(CookieConsent.COOKIE_NAME + '='));
if (!cookie) return null;
try {
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
} catch {
return null;
}
}
private saveState(): void {
if (!this.state) return;
const expires = new Date();
expires.setDate(expires.getDate() + CookieConsent.COOKIE_DAYS);
document.cookie = `${CookieConsent.COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.state))}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
}
private showBanner(): void {
if (this.banner) {
this.banner.removeAttribute('aria-hidden');
}
}
private hideBanner(): void {
if (this.banner) {
// Blur any focused element inside the banner before hiding
const activeElement = document.activeElement as HTMLElement;
if (activeElement && this.banner.contains(activeElement)) {
activeElement.blur();
}
this.banner.setAttribute('aria-hidden', 'true');
}
this.hideSettings();
}
private showSettings(): void {
if (this.settingsPanel) {
this.settingsPanel.removeAttribute('aria-hidden');
// Restore checkbox states from current state or defaults
const checkboxes = this.settingsPanel.querySelectorAll('input[data-cookie-category]');
checkboxes.forEach((cb) => {
const input = cb as HTMLInputElement;
const category = input.dataset.cookieCategory as keyof ConsentState;
if (category && this.state && typeof this.state[category] === 'boolean') {
input.checked = this.state[category] as boolean;
} else {
input.checked = false;
}
});
}
}
private hideSettings(): void {
if (this.settingsPanel) {
// Blur any focused element inside the settings panel before hiding
const activeElement = document.activeElement as HTMLElement;
if (activeElement && this.settingsPanel.contains(activeElement)) {
activeElement.blur();
}
this.settingsPanel.setAttribute('aria-hidden', 'true');
}
}
private bindEvents(): void {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.dataset.cookieAction;
if (!action) return;
switch (action) {
case 'accept':
this.acceptAll();
break;
case 'deny':
this.denyAll();
break;
case 'settings':
this.showSettings();
break;
case 'save':
this.saveSettings();
break;
case 'cancel':
this.hideSettings();
break;
case 'reopen':
this.showBanner();
break;
}
});
}
private acceptAll(): void {
this.state = {
necessary: true,
analytics: true,
functional: true,
timestamp: Date.now()
};
this.saveState();
this.hideBanner();
this.dispatchConsentEvent();
}
private denyAll(): void {
this.state = {
necessary: true,
analytics: false,
functional: false,
timestamp: Date.now()
};
this.saveState();
this.hideBanner();
this.dispatchConsentEvent();
}
private saveSettings(): void {
const checkboxes = document.querySelectorAll('input[data-cookie-category]');
this.state = {
necessary: true,
analytics: false,
functional: false,
timestamp: Date.now()
};
checkboxes.forEach((cb) => {
const input = cb as HTMLInputElement;
const category = input.dataset.cookieCategory as keyof ConsentState;
if (category && category in this.state!) {
(this.state as any)[category] = input.checked;
}
});
this.saveState();
this.hideBanner();
this.dispatchConsentEvent();
}
private dispatchConsentEvent(): void {
const event = new CustomEvent('onCookieConsentChange', {
detail: this.state
});
window.dispatchEvent(event);
// Set data attributes on document for CSS-based control
if (this.state) {
document.documentElement.dataset.consentAnalytics = String(this.state.analytics);
document.documentElement.dataset.consentFunctional = String(this.state.functional);
}
}
// Public API
public hasConsent(category: keyof Omit<ConsentState, 'timestamp'>): boolean {
if (!this.state) return false;
return this.state[category] ?? false;
}
public getState(): ConsentState | null {
return this.state;
}
public reopenBanner(): void {
this.showBanner();
}
}
// Export for module usage
export default CookieConsent;
// Initialize when DOM is ready and expose globally
declare global {
interface Window {
cookieConsent: CookieConsent;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.cookieConsent = new CookieConsent();
});
} else {
window.cookieConsent = new CookieConsent();
}