Add i18n support to cookie banner JS client

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 10:34:49 +04:00
parent 3a3335f28a
commit ef60f080f1
9 changed files with 203 additions and 60 deletions

View File

@@ -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 <strong>${escapeHtml(displayLabel)}</strong> cookies.`;
const phButton = texts?.placeholder_button ?? "Manage cookie preferences";
placeholder.innerHTML = [
`<span class="probo-ph-icon">${LOCK_ICON}</span>`,
`<p class="probo-ph-text">This content requires <strong>${escapeHtml(displayLabel)}</strong> cookies.</p>`,
`<button type="button" class="probo-ph-link">Manage cookie preferences</button>`,
`<p class="probo-ph-text">${phText}</p>`,
`<button type="button" class="probo-ph-link">${escapeHtml(phButton)}</button>`,
].join("");
placeholder.querySelector(".probo-ph-link")!.addEventListener("click", () => {
@@ -362,13 +370,14 @@ export function activateElements(
export function addPlaceholders(
consentData: Record<string, boolean>,
categoryLabels: Record<string, string>,
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<string, boolean>,
categoryLabels: Record<string, string>,
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<string, boolean>,
categoryLabels: Record<string, string>,
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);
}
}
}

View File

@@ -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<void> {
const configUrl = new URL(`${this.bannerId}/config`, this.baseUrl);
if (this.lang) {
configUrl.searchParams.set("lang", this.lang);
}
const config = await fetchJSON<BannerConfig>(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 {

View File

@@ -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();

View File

@@ -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<ProboCookieBannerRoot>("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<string, string>,
): 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 {}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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, string>,
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
}

View File

@@ -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);
};

View File

@@ -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 = `
<style>${THEMED_STYLES}</style>
<probo-cookie-banner-root banner-id="${this.esc(bannerId)}" base-url="${this.esc(baseUrl)}"${reopenAttr}>
<probo-cookie-banner-root banner-id="${this.esc(bannerId)}" base-url="${this.esc(baseUrl)}"${reopenAttr}${langAttr}>
<probo-banner>
<div class="floating" data-position="${this.esc(position)}">
<div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-banner-title" aria-describedby="probo-banner-desc">
<p class="title" id="probo-banner-title">Cookie Preferences</p>
<p class="description" id="probo-banner-desc" data-description>
We use cookies to improve your experience and analyze site traffic.
</p>
<p class="title" id="probo-banner-title" data-text="banner_title"></p>
<p class="description" id="probo-banner-desc" data-text="banner_description"></p>
<div class="buttons">
<probo-accept-button><button class="btn btn-primary">Accept all</button></probo-accept-button>
<probo-reject-button><button class="btn">Reject all</button></probo-reject-button>
<probo-customize-button><button class="btn btn-link">Customize</button></probo-customize-button>
<probo-accept-button><button class="btn btn-primary" data-text="button_accept_all"></button></probo-accept-button>
<probo-reject-button><button class="btn" data-text="button_reject_all"></button></probo-reject-button>
<probo-customize-button><button class="btn btn-link" data-text="button_customize"></button></probo-customize-button>
</div>
${BRANDING}
</div>
@@ -70,16 +71,16 @@ export class ProboThemedBanner extends HTMLElement {
<div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-panel-title" aria-describedby="probo-panel-desc">
<div class="panel-header">
<div class="panel-header-title">
<p class="title" id="probo-panel-title" style="margin:0">Customise Preferences</p>
<button class="panel-close" data-action="back" aria-label="Close">
<p class="title" id="probo-panel-title" style="margin:0" data-text="panel_title"></p>
<button class="panel-close" data-action="back" data-aria-text="aria_close">
${CLOSE_ICON}
</button>
</div>
<p class="description" id="probo-panel-desc">Choose which cookie categories to allow. Necessary cookies are always active as they are needed for the site to work.</p>
<p class="description" id="probo-panel-desc" data-text="panel_description"></p>
</div>
<probo-category-list>
<template>
<button class="cookie-toggle" data-action="toggle-cookies" aria-expanded="false" aria-label="Show cookie details">
<button class="cookie-toggle" data-action="toggle-cookies" aria-expanded="false" data-aria-text="aria_show_details">
${CHEVRON_DOWN}
</button>
<div class="category-header">
@@ -98,8 +99,8 @@ export class ProboThemedBanner extends HTMLElement {
<template>
<div class="cookie-item">
<span class="cookie-name" data-slot="name"></span>
<span class="cookie-detail"><span class="cookie-label">Description:</span> <span data-slot="description"></span></span>
<span class="cookie-detail"><span class="cookie-label">Duration:</span> <span data-slot="duration"></span></span>
<span class="cookie-detail" data-label="label_description"><span data-slot="description"></span></span>
<span class="cookie-detail" data-label="label_duration"><span data-slot="duration"></span></span>
</div>
</template>
</probo-cookie-list>
@@ -107,10 +108,10 @@ export class ProboThemedBanner extends HTMLElement {
</probo-category-list>
<div class="footer">
<div class="buttons">
<probo-accept-button><button class="btn btn-primary">Accept all</button></probo-accept-button>
<probo-reject-button><button class="btn">Reject all</button></probo-reject-button>
<probo-accept-button><button class="btn btn-primary" data-text="button_accept_all"></button></probo-accept-button>
<probo-reject-button><button class="btn" data-text="button_reject_all"></button></probo-reject-button>
<probo-save-button>
<button class="btn btn-link" style="flex:1">Save preferences</button>
<button class="btn btn-link" style="flex:1" data-text="button_save"></button>
</probo-save-button>
</div>
${BRANDING}
@@ -127,7 +128,7 @@ export class ProboThemedBanner extends HTMLElement {
root.addEventListener("probo-ready", (e: Event) => {
const config = (e as CustomEvent).detail.config as BannerConfig;
this.updateDescription(config);
this.applyTexts(config);
if (!config.show_branding) {
this.shadow.querySelectorAll("[data-branding]").forEach(el => {
(el as HTMLElement).setAttribute("hidden", "");
@@ -154,19 +155,51 @@ export class ProboThemedBanner extends HTMLElement {
btn.classList.remove("open");
}
btn.setAttribute("aria-expanded", String(open));
btn.setAttribute("aria-label", open ? "Hide cookie details" : "Show cookie details");
const texts = root.bannerConfig?.texts;
const showLabel = texts?.aria_show_details ?? "Show cookie details";
const hideLabel = texts?.aria_hide_details ?? "Hide cookie details";
btn.setAttribute("aria-label", open ? hideLabel : showLabel);
});
}
private updateDescription(config: BannerConfig): void {
const el = this.shadow.querySelector("[data-description]");
if (!el) return;
private applyTexts(config: BannerConfig): void {
const texts = config.texts ?? {};
let html = "We use cookies to improve your experience and analyze site traffic.";
if (config.privacy_policy_url) {
html += ` <a href="${this.esc(config.privacy_policy_url)}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`;
const necessaryCategory = config.categories.find(c => c.kind === "NECESSARY");
const necessaryCategoryName = necessaryCategory?.name ?? "Necessary";
this.shadow.querySelectorAll("[data-text]").forEach(el => {
const key = el.getAttribute("data-text")!;
const raw = texts[key];
if (!raw) return;
if (key === "banner_description") {
let link = "";
if (config.privacy_policy_url) {
const linkText = this.esc(texts.privacy_policy_link_text ?? "Privacy Policy");
link = `<a href="${this.esc(config.privacy_policy_url)}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
}
el.innerHTML = interpolate(raw, { privacy_policy_link: link });
} else if (key === "panel_description") {
el.textContent = interpolate(raw, { necessary_category: necessaryCategoryName });
} else {
el.textContent = raw;
}
});
this.shadow.querySelectorAll("[data-aria-text]").forEach(el => {
const key = el.getAttribute("data-aria-text")!;
const raw = texts[key];
if (raw) el.setAttribute("aria-label", raw);
});
const settingsBtn = this.shadow.querySelector("probo-settings-button");
if (settingsBtn) {
const ariaText = texts.aria_cookie_settings;
if (ariaText) {
settingsBtn.setAttribute("aria-settings-label", ariaText);
}
}
el.innerHTML = html;
}
private esc(str: string): string {