diff --git a/packages/cookie-banner/src/activation.ts b/packages/cookie-banner/src/activation.ts new file mode 100644 index 000000000..7ae70cd79 --- /dev/null +++ b/packages/cookie-banner/src/activation.ts @@ -0,0 +1,133 @@ +// 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. + +const ATTR_CATEGORY = "data-cookie-consent"; +const ATTR_SRC = "data-src"; +const ATTR_HREF = "data-href"; +const ATTR_ACTIVATED = "data-cookie-consent-activated"; + +const ACTIVATABLE_TAGS = new Set([ + "SCRIPT", + "IFRAME", + "IMG", + "VIDEO", + "AUDIO", + "EMBED", + "OBJECT", + "LINK", +]); + +function activateScript(el: HTMLScriptElement): void { + const replacement = document.createElement("script"); + + for (const attr of el.attributes) { + if (attr.name === "type" || attr.name === ATTR_CATEGORY) { + continue; + } + if (attr.name === ATTR_SRC) { + replacement.setAttribute("src", attr.value); + continue; + } + replacement.setAttribute(attr.name, attr.value); + } + + replacement.setAttribute("type", "text/javascript"); + replacement.setAttribute(ATTR_ACTIVATED, ""); + + if (el.textContent) { + replacement.textContent = el.textContent; + } + + el.parentNode!.replaceChild(replacement, el); +} + +function activateElement(el: Element): void { + const src = el.getAttribute(ATTR_SRC); + if (src) { + el.setAttribute("src", src); + el.removeAttribute(ATTR_SRC); + } + + const href = el.getAttribute(ATTR_HREF); + if (href) { + el.setAttribute("href", href); + el.removeAttribute(ATTR_HREF); + } + + el.removeAttribute(ATTR_CATEGORY); + el.setAttribute(ATTR_ACTIVATED, ""); +} + +function tryActivate( + el: Element, + consentData: Record, +): void { + if (el.hasAttribute(ATTR_ACTIVATED)) { + return; + } + + if (!ACTIVATABLE_TAGS.has(el.tagName)) { + return; + } + + const category = el.getAttribute(ATTR_CATEGORY); + if (!category || !consentData[category]) { + return; + } + + if (el instanceof HTMLScriptElement) { + activateScript(el); + } else { + activateElement(el); + } +} + +export function activateElements( + consentData: Record, +): void { + const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`); + for (const el of elements) { + tryActivate(el, consentData); + } +} + +export function observeAndActivate( + consentData: Record, +): MutationObserver { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (!(node instanceof Element)) { + continue; + } + + if (node.hasAttribute(ATTR_CATEGORY)) { + tryActivate(node, consentData); + } + + const nested = node.querySelectorAll(`[${ATTR_CATEGORY}]`); + for (const el of nested) { + tryActivate(el, consentData); + } + } + } + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + + return observer; +} diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index 1066eb99d..6161c1c5d 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { activateElements, observeAndActivate } from "./activation"; import { getConsentCookie, setConsentCookie } from "./cookie"; import { NotFoundError } from "./errors"; import { fetchJSON } from "./http"; @@ -68,6 +69,7 @@ export class CookieBannerClient { private bannerConfig: BannerConfig | null = null; private consent: VisitorConsent | null = null; + private observer: MutationObserver | null = null; constructor(config: CookieBannerClientOptions) { this.baseUrl = config.baseUrl.replace(/\/+$/, ""); @@ -89,6 +91,7 @@ export class CookieBannerClient { consent_data: cookie.data, created_at: "", }; + this.activate(cookie.data); return; } @@ -113,6 +116,7 @@ export class CookieBannerClient { }, config.consent_expiry_days, ); + this.activate(apiConsent.consent_data); } else { this.consent = null; } @@ -203,6 +207,22 @@ export class CookieBannerClient { cfg.consent_expiry_days, ); + this.activate(consentData); + return record; } + + private activate(consentData: Record): void { + activateElements(consentData); + if (!this.observer) { + this.observer = observeAndActivate(consentData); + } + } + + destroy(): void { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + } } diff --git a/packages/cookie-banner/src/index.ts b/packages/cookie-banner/src/index.ts index bf1456402..1df40df3d 100644 --- a/packages/cookie-banner/src/index.ts +++ b/packages/cookie-banner/src/index.ts @@ -14,6 +14,7 @@ export const VERSION = "0.0.0"; +export { activateElements, observeAndActivate } from "./activation"; export { CookieBannerClient } from "./client"; export type { BannerConfig,