Add consent-based element activation
Unblock scripts, iframes, images, and other resource-loading elements marked with data-cookie-consent when their category is consented. Uses MutationObserver for dynamically added elements. Restricted to known resource tags with data-href support for link elements. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
133
packages/cookie-banner/src/activation.ts
Normal file
133
packages/cookie-banner/src/activation.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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.
|
||||
|
||||
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<string, boolean>,
|
||||
): 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<string, boolean>,
|
||||
): void {
|
||||
const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`);
|
||||
for (const el of elements) {
|
||||
tryActivate(el, consentData);
|
||||
}
|
||||
}
|
||||
|
||||
export function observeAndActivate(
|
||||
consentData: Record<string, boolean>,
|
||||
): 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;
|
||||
}
|
||||
@@ -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<string, boolean>): void {
|
||||
activateElements(consentData);
|
||||
if (!this.observer) {
|
||||
this.observer = observeAndActivate(consentData);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
export const VERSION = "0.0.0";
|
||||
|
||||
export { activateElements, observeAndActivate } from "./activation";
|
||||
export { CookieBannerClient } from "./client";
|
||||
export type {
|
||||
BannerConfig,
|
||||
|
||||
Reference in New Issue
Block a user