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:
co-authored by
delize
Jimmy Cai
parent
3a0d70ebbd
commit
4ad88a327e
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user