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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { activateElements, observeAndActivate } from "./activation";
|
||||||
import { getConsentCookie, setConsentCookie } from "./cookie";
|
import { getConsentCookie, setConsentCookie } from "./cookie";
|
||||||
import { NotFoundError } from "./errors";
|
import { NotFoundError } from "./errors";
|
||||||
import { fetchJSON } from "./http";
|
import { fetchJSON } from "./http";
|
||||||
@@ -68,6 +69,7 @@ export class CookieBannerClient {
|
|||||||
|
|
||||||
private bannerConfig: BannerConfig | null = null;
|
private bannerConfig: BannerConfig | null = null;
|
||||||
private consent: VisitorConsent | null = null;
|
private consent: VisitorConsent | null = null;
|
||||||
|
private observer: MutationObserver | null = null;
|
||||||
|
|
||||||
constructor(config: CookieBannerClientOptions) {
|
constructor(config: CookieBannerClientOptions) {
|
||||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
@@ -89,6 +91,7 @@ export class CookieBannerClient {
|
|||||||
consent_data: cookie.data,
|
consent_data: cookie.data,
|
||||||
created_at: "",
|
created_at: "",
|
||||||
};
|
};
|
||||||
|
this.activate(cookie.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ export class CookieBannerClient {
|
|||||||
},
|
},
|
||||||
config.consent_expiry_days,
|
config.consent_expiry_days,
|
||||||
);
|
);
|
||||||
|
this.activate(apiConsent.consent_data);
|
||||||
} else {
|
} else {
|
||||||
this.consent = null;
|
this.consent = null;
|
||||||
}
|
}
|
||||||
@@ -203,6 +207,22 @@ export class CookieBannerClient {
|
|||||||
cfg.consent_expiry_days,
|
cfg.consent_expiry_days,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.activate(consentData);
|
||||||
|
|
||||||
return record;
|
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 const VERSION = "0.0.0";
|
||||||
|
|
||||||
|
export { activateElements, observeAndActivate } from "./activation";
|
||||||
export { CookieBannerClient } from "./client";
|
export { CookieBannerClient } from "./client";
|
||||||
export type {
|
export type {
|
||||||
BannerConfig,
|
BannerConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user