From ef60f080f1ce08d9347288a6e7a88544bbf60597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 23 Apr 2026 10:34:49 +0400 Subject: [PATCH] Add i18n support to cookie banner JS client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all hardcoded user-facing strings with server-driven texts from config.texts. Add language detection chain (data-lang > html lang > navigator.language), pass ?lang query param on config fetch, and use {{variable}} interpolation for dynamic content in templates. Signed-off-by: Émile Ré --- packages/cookie-banner/src/activation.ts | 23 +++-- packages/cookie-banner/src/client.ts | 20 ++++- .../src/components/cookie-banner-root.ts | 6 +- .../src/components/cookie-list.ts | 32 ++++++- .../src/components/settings-button.ts | 7 +- packages/cookie-banner/src/cookie-utils.ts | 46 +++++----- packages/cookie-banner/src/i18n.ts | 37 ++++++++ .../cookie-banner/src/themed-banner/iife.ts | 5 ++ .../src/themed-banner/themed-banner.ts | 87 +++++++++++++------ 9 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 packages/cookie-banner/src/i18n.ts diff --git a/packages/cookie-banner/src/activation.ts b/packages/cookie-banner/src/activation.ts index e3bb899dc..47c5754d9 100644 --- a/packages/cookie-banner/src/activation.ts +++ b/packages/cookie-banner/src/activation.ts @@ -12,6 +12,8 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import type { BannerTexts } from "./i18n"; +import { interpolate } from "./i18n"; import { removeCookies } from "./cookie-utils"; import { LOCK_ICON } from "./html"; @@ -111,6 +113,7 @@ function createPlaceholder( el: Element, category: string, label?: string, + texts?: BannerTexts, ): void { if (el.hasAttribute(ATTR_HIDDEN)) return; @@ -152,10 +155,15 @@ function createPlaceholder( } } + const phText = texts?.placeholder_text + ? interpolate(texts.placeholder_text, { category: escapeHtml(displayLabel) }) + : `This content requires ${escapeHtml(displayLabel)} cookies.`; + const phButton = texts?.placeholder_button ?? "Manage cookie preferences"; + placeholder.innerHTML = [ `${LOCK_ICON}`, - `

This content requires ${escapeHtml(displayLabel)} cookies.

`, - ``, + `

${phText}

`, + ``, ].join(""); placeholder.querySelector(".probo-ph-link")!.addEventListener("click", () => { @@ -362,13 +370,14 @@ export function activateElements( export function addPlaceholders( consentData: Record, categoryLabels: Record, + texts?: BannerTexts, ): void { const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`); for (const el of elements) { const category = el.getAttribute(ATTR_CATEGORY); if (!category || consentData[category]) continue; if (!VISUAL_TAGS.has(el.tagName)) continue; - createPlaceholder(el, category, categoryLabels[category]); + createPlaceholder(el, category, categoryLabels[category], texts); } } @@ -376,16 +385,18 @@ function tryPlaceholder( el: Element, consentData: Record, categoryLabels: Record, + texts?: BannerTexts, ): void { const category = el.getAttribute(ATTR_CATEGORY); if (!category || consentData[category]) return; if (!VISUAL_TAGS.has(el.tagName)) return; - createPlaceholder(el, category, categoryLabels[category]); + createPlaceholder(el, category, categoryLabels[category], texts); } export function observeAndActivate( consentData: Record, categoryLabels: Record, + texts?: BannerTexts, ): MutationObserver { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { @@ -396,13 +407,13 @@ export function observeAndActivate( if (node.hasAttribute(ATTR_CATEGORY)) { tryActivate(node, consentData); - tryPlaceholder(node, consentData, categoryLabels); + tryPlaceholder(node, consentData, categoryLabels, texts); } const nested = node.querySelectorAll(`[${ATTR_CATEGORY}]`); for (const el of nested) { tryActivate(el, consentData); - tryPlaceholder(el, consentData, categoryLabels); + tryPlaceholder(el, consentData, categoryLabels, texts); } } } diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index ddb5503fb..de6bf7b54 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -22,9 +22,14 @@ import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie"; import { CookieDetector } from "./detector"; import { NotFoundError } from "./errors"; import { fetchJSON } from "./http"; +import type { BannerTexts } from "./i18n"; +import { detectLanguage } from "./i18n"; import { enqueue, flush } from "./queue"; import { getOrCreateVisitorId } from "./visitor"; +export type { BannerTexts } from "./i18n"; +export { interpolate } from "./i18n"; + export interface CookieItem { name: string; duration: string; @@ -41,11 +46,15 @@ export interface Category { export interface BannerConfig { banner_id: string; version: number; + language: string; + default_language: string; + available_languages: string[]; privacy_policy_url: string; consent_expiry_days: number; consent_mode: "OPT_IN" | "OPT_OUT"; show_branding: boolean; categories: Category[]; + texts: BannerTexts; } export type ConsentAction = "ACCEPT_ALL" | "REJECT_ALL" | "CUSTOMIZE" | "GPC"; @@ -68,12 +77,14 @@ export interface ConsentRecord { export interface CookieBannerClientOptions { bannerId: string; baseUrl: string; + lang?: string; } export class CookieBannerClient { private readonly baseUrl: URL; private readonly bannerId: string; private readonly visitorId: string; + private readonly lang: string; private bannerConfig: BannerConfig | null = null; private consent: VisitorConsent | null = null; @@ -88,10 +99,14 @@ export class CookieBannerClient { this.baseUrl = new URL(base); this.bannerId = config.bannerId; this.visitorId = getOrCreateVisitorId(config.bannerId); + this.lang = detectLanguage(config.lang); } async load(): Promise { const configUrl = new URL(`${this.bannerId}/config`, this.baseUrl); + if (this.lang) { + configUrl.searchParams.set("lang", this.lang); + } const config = await fetchJSON(configUrl); this.bannerConfig = config; @@ -237,13 +252,14 @@ export class CookieBannerClient { categoryLabels[cat.name] = cat.name; } + const texts = this.config.texts; deactivateElements(consentData, categoryCookies, categoryLabels); activateElements(consentData); - addPlaceholders(consentData, categoryLabels); + addPlaceholders(consentData, categoryLabels, texts); if (this.observer) { this.observer.disconnect(); } - this.observer = observeAndActivate(consentData, categoryLabels); + this.observer = observeAndActivate(consentData, categoryLabels, texts); } private startDetector(config: BannerConfig): void { diff --git a/packages/cookie-banner/src/components/cookie-banner-root.ts b/packages/cookie-banner/src/components/cookie-banner-root.ts index 08462bd48..127d612ef 100644 --- a/packages/cookie-banner/src/components/cookie-banner-root.ts +++ b/packages/cookie-banner/src/components/cookie-banner-root.ts @@ -24,7 +24,7 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem private _draft: ConsentDraft = {}; static get observedAttributes(): string[] { - return ["banner-id", "base-url", "reopen-widget"]; + return ["banner-id", "base-url", "reopen-widget", "lang"]; } get client(): CookieBannerClient { @@ -129,7 +129,9 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem return; } - this._client = new CookieBannerClient({ bannerId, baseUrl }); + const lang = this.getAttribute("lang") ?? undefined; + + this._client = new CookieBannerClient({ bannerId, baseUrl, lang }); try { await this._client.load(); diff --git a/packages/cookie-banner/src/components/cookie-list.ts b/packages/cookie-banner/src/components/cookie-list.ts index adec94e70..29b170784 100644 --- a/packages/cookie-banner/src/components/cookie-list.ts +++ b/packages/cookie-banner/src/components/cookie-list.ts @@ -13,8 +13,10 @@ // PERFORMANCE OF THIS SOFTWARE. import type { CookieItem } from "../client"; +import { type BannerTexts, interpolate } from "../i18n"; import { ProboElement } from "./base"; import type { ProboCategory } from "./category"; +import type { ProboCookieBannerRoot } from "./cookie-banner-root"; export class ProboCookieList extends ProboElement { private category: ProboCategory | null = null; @@ -35,13 +37,16 @@ export class ProboCookieList extends ProboElement { private stamp(): void { if (!this.template || !this.category) return; + const root = this.findAncestor("probo-cookie-banner-root"); + const texts = root?.bannerConfig?.texts; + const cookies = this.category.cookies; for (const cookie of cookies) { - this.stampCookie(cookie); + this.stampCookie(cookie, texts); } } - private stampCookie(cookie: CookieItem): void { + private stampCookie(cookie: CookieItem, texts?: BannerTexts): void { if (!this.template) return; const wrapper = document.createElement("probo-cookie"); @@ -52,6 +57,10 @@ export class ProboCookieList extends ProboElement { duration: cookie.duration, description: cookie.description, }); + this.fillLabels(clone, texts, { + description: cookie.description, + duration: cookie.duration, + }); wrapper.appendChild(clone); this.appendChild(wrapper); @@ -68,6 +77,25 @@ export class ProboCookieList extends ProboElement { } } } + + private fillLabels( + fragment: DocumentFragment, + texts: BannerTexts | undefined, + values: Record, + ): void { + const labels = fragment.querySelectorAll("[data-label]"); + for (const el of labels) { + const key = el.getAttribute("data-label")!; + const slotName = key.replace("label_", ""); + const value = values[slotName] ?? ""; + const tpl = texts?.[key]; + if (tpl) { + el.textContent = interpolate(tpl, { value }); + const slot = el.querySelector(`[data-slot="${slotName}"]`); + if (slot) slot.remove(); + } + } + } } export class ProboCookie extends ProboElement {} diff --git a/packages/cookie-banner/src/components/settings-button.ts b/packages/cookie-banner/src/components/settings-button.ts index 70e22bb0c..551109960 100644 --- a/packages/cookie-banner/src/components/settings-button.ts +++ b/packages/cookie-banner/src/components/settings-button.ts @@ -26,7 +26,7 @@ export class ProboSettingsButton extends HTMLElement { } static get observedAttributes(): string[] { - return ["position"]; + return ["position", "aria-settings-label"]; } private get position(): string { @@ -89,6 +89,11 @@ export class ProboSettingsButton extends HTMLElement { const btn = this.shadow.querySelector("button"); btn?.addEventListener("click", this.handleClick); + + const ariaLabel = this.getAttribute("aria-settings-label"); + if (ariaLabel && btn) { + btn.setAttribute("aria-label", ariaLabel); + } } disconnectedCallback(): void { diff --git a/packages/cookie-banner/src/cookie-utils.ts b/packages/cookie-banner/src/cookie-utils.ts index 218eac380..07018be43 100644 --- a/packages/cookie-banner/src/cookie-utils.ts +++ b/packages/cookie-banner/src/cookie-utils.ts @@ -12,25 +12,28 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -// [unitSeconds, singular, plural, snapBuffer] +import type { BannerTexts } from "./i18n"; +import { interpolate } from "./i18n"; + +// [unitSeconds, textKey, snapBuffer] // snapBuffer: if the remainder is within this many seconds of the next // whole unit, round up instead of carrying into smaller units. -const DURATION_UNITS: [number, string, string, number][] = [ - [365 * 24 * 3600, "year", "years", 21 * 24 * 3600], - [30 * 24 * 3600, "month", "months", 2 * 24 * 3600], - [7 * 24 * 3600, "week", "weeks", 12 * 3600], - [24 * 3600, "day", "days", 0], - [3600, "hour", "hours", 5 * 60], - [60, "minute", "minutes", 15], +const DURATION_UNITS: [number, string, number][] = [ + [365 * 24 * 3600, "duration_year", 21 * 24 * 3600], + [30 * 24 * 3600, "duration_month", 2 * 24 * 3600], + [7 * 24 * 3600, "duration_week", 12 * 3600], + [24 * 3600, "duration_day", 0], + [3600, "duration_hour", 5 * 60], + [60, "duration_minute", 15], ]; -function humanizeDuration(seconds: number): string { - if (seconds <= 0) return "session"; +function humanizeDuration(seconds: number, texts?: BannerTexts): string { + if (seconds <= 0) return texts?.duration_session ?? "session"; let remaining = seconds; const parts: string[] = []; - for (const [unit, singular, plural, snap] of DURATION_UNITS) { + for (const [unit, key, snap] of DURATION_UNITS) { if (remaining >= unit) { let count = Math.floor(remaining / unit); const leftover = remaining - count * unit; @@ -44,11 +47,13 @@ function humanizeDuration(seconds: number): string { remaining = leftover; } - parts.push(count === 1 ? `1 ${singular}` : `${count} ${plural}`); + const tplKey = count === 1 ? `${key}_one` : `${key}_other`; + const tpl = texts?.[tplKey] ?? (count === 1 ? `{{count}} ${key.replace("duration_", "")}` : `{{count}} ${key.replace("duration_", "")}s`); + parts.push(interpolate(tpl, { count: String(count) })); } } - return parts.length > 0 ? parts.join(", ") : "session"; + return parts.length > 0 ? parts.join(", ") : texts?.duration_session ?? "session"; } export function parseCookieName(raw: string): string { @@ -57,15 +62,16 @@ export function parseCookieName(raw: string): string { return raw.substring(0, eqIdx).trim(); } -export function parseDuration(raw: string): string { +export function parseDuration(raw: string, texts?: BannerTexts): string { + const session = texts?.duration_session ?? "session"; const parts = raw.split(";").map((s) => s.trim()); for (const part of parts) { const lower = part.toLowerCase(); if (lower.startsWith("max-age=")) { const val = parseInt(part.substring(8), 10); - if (isNaN(val) || val <= 0) return "session"; - return humanizeDuration(val); + if (isNaN(val) || val <= 0) return session; + return humanizeDuration(val, texts); } } @@ -74,16 +80,16 @@ export function parseDuration(raw: string): string { if (lower.startsWith("expires=")) { const dateStr = part.substring(8); const expires = new Date(dateStr); - if (isNaN(expires.getTime())) return "session"; + if (isNaN(expires.getTime())) return session; const deltaSeconds = Math.round( (expires.getTime() - Date.now()) / 1000, ); - if (deltaSeconds <= 0) return "session"; - return humanizeDuration(deltaSeconds); + if (deltaSeconds <= 0) return session; + return humanizeDuration(deltaSeconds, texts); } } - return "session"; + return session; } export function isDeletion(raw: string): boolean { diff --git a/packages/cookie-banner/src/i18n.ts b/packages/cookie-banner/src/i18n.ts new file mode 100644 index 000000000..fd3bd0953 --- /dev/null +++ b/packages/cookie-banner/src/i18n.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +export interface BannerTexts { + [key: string]: string; +} + +export function detectLanguage(explicit?: string): string { + if (explicit) return explicit; + + const htmlLang = document.documentElement.lang; + if (htmlLang) return htmlLang.split("-")[0]; + + if (typeof navigator !== "undefined" && navigator.language) { + return navigator.language.split("-")[0]; + } + + return ""; +} + +export function interpolate( + template: string, + vars: Record, +): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? ""); +} diff --git a/packages/cookie-banner/src/themed-banner/iife.ts b/packages/cookie-banner/src/themed-banner/iife.ts index 1a0c283ba..2f0e9a287 100644 --- a/packages/cookie-banner/src/themed-banner/iife.ts +++ b/packages/cookie-banner/src/themed-banner/iife.ts @@ -38,6 +38,11 @@ if (script) { el.setAttribute("reopen-widget", reopenWidget); } + const lang = script.getAttribute("data-lang"); + if (lang) { + el.setAttribute("lang", lang); + } + document.body.appendChild(el); }; diff --git a/packages/cookie-banner/src/themed-banner/themed-banner.ts b/packages/cookie-banner/src/themed-banner/themed-banner.ts index f9c603072..9e2b3d250 100644 --- a/packages/cookie-banner/src/themed-banner/themed-banner.ts +++ b/packages/cookie-banner/src/themed-banner/themed-banner.ts @@ -15,6 +15,7 @@ import { registerComponents } from "../components"; import type { ProboCookieBannerRoot } from "../components/cookie-banner-root"; import type { BannerConfig } from "../client"; +import { interpolate } from "../i18n"; import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html"; import { THEMED_STYLES } from "./styles"; @@ -27,7 +28,7 @@ export class ProboThemedBanner extends HTMLElement { } static get observedAttributes(): string[] { - return ["banner-id", "base-url", "reopen-widget"]; + return ["banner-id", "base-url", "reopen-widget", "lang"]; } connectedCallback(): void { @@ -44,21 +45,21 @@ export class ProboThemedBanner extends HTMLElement { const position = this.getAttribute("position") ?? "bottom-left"; const reopenWidget = this.getAttribute("reopen-widget"); const reopenAttr = reopenWidget ? ` reopen-widget="${this.esc(reopenWidget)}"` : ""; + const lang = this.getAttribute("lang"); + const langAttr = lang ? ` lang="${this.esc(lang)}"` : ""; this.shadow.innerHTML = ` - +
@@ -70,16 +71,16 @@ export class ProboThemedBanner extends HTMLElement {