diff --git a/assets/ts/commentsConsent.ts b/assets/ts/commentsConsent.ts new file mode 100644 index 0000000..a38553f --- /dev/null +++ b/assets/ts/commentsConsent.ts @@ -0,0 +1,141 @@ +/* + * Consent-gated comments bootstrap. + * + * This module controls whether third-party comments are rendered based on + * functional cookie consent, and injects template scripts in sequence to + * avoid eager or duplicate script execution. + */ + +/** + * Consent event payload used by the comments gate. + * It mirrors cookie consent state shape but keeps optional fields for safety. + */ +interface CommentsConsentState { + necessary?: boolean; + analytics?: boolean; + functional?: boolean; + timestamp?: number; +} + +/** + * Resolve functional consent with layered fallback: + * 1) explicit event detail, 2) CookieConsent public API, 3) dataset mirror. + */ +const hasFunctionalConsent = (consentDetail?: CommentsConsentState | null): boolean => { + const cookieConsent = (window as Window & { + cookieConsent?: { + hasConsent?: (category: 'necessary' | 'analytics' | 'functional') => boolean; + }; + }).cookieConsent; + + return ( + consentDetail?.functional ?? + cookieConsent?.hasConsent?.('functional') ?? + document.documentElement.dataset.consentFunctional === 'true' + ); +}; + +interface DeferredScript { + placeholder: Comment; + script: HTMLScriptElement; +} + +const prepareDeferredScripts = (fragment: DocumentFragment): DeferredScript[] => { + return Array.from(fragment.querySelectorAll('script')).map(script => { + const placeholder = document.createComment('comments-script-placeholder'); + script.replaceWith(placeholder); + return { placeholder, script }; + }); +}; + +const activateDeferredScripts = async (scripts: DeferredScript[]): Promise => { + // Inject scripts sequentially so third-party embeds can rely on execution order. + for (const { placeholder, script } of scripts) { + if (!placeholder.parentNode) { + continue; + } + + const newScript = document.createElement('script'); + Array.from(script.attributes).forEach(attr => { + newScript.setAttribute(attr.name, attr.value); + }); + + if (script.textContent) { + newScript.text = script.textContent; + } + + let done: Promise = Promise.resolve(); + if (newScript.src) { + done = new Promise(resolve => { + newScript.onload = newScript.onerror = () => resolve(); + }); + } + + placeholder.replaceWith(newScript); + await done; + } +}; + +const initCommentsConsent = (): void => { + // Main entry: gate comments rendering by functional cookie consent. + const placeholder = document.getElementById('comments-consent-placeholder') as HTMLElement | null; + const container = document.getElementById('comments-container') as HTMLElement | null; + const template = document.getElementById('comments-template') as HTMLTemplateElement | null; + + if (!placeholder || !container || !template) { + return; + } + + let commentsLoaded = false; + let commentsLoading = false; + + const showComments = async (): Promise => { + placeholder.style.display = 'none'; + container.style.display = 'block'; + + if (commentsLoaded || commentsLoading) { + return; + } + + // Prevent duplicate template hydration while scripts are still loading. + commentsLoading = true; + + try { + const clone = template.content.cloneNode(true) as DocumentFragment; + const scripts = prepareDeferredScripts(clone); + + container.appendChild(clone); + await activateDeferredScripts(scripts); + commentsLoaded = true; + } finally { + commentsLoading = false; + } + }; + + const hideComments = (): void => { + placeholder.style.display = 'block'; + container.style.display = 'none'; + }; + + window.addEventListener('onCookieConsentChange', (event: Event) => { + // Cross-module contract: this event is dispatched by cookies.ts. + const customEvent = event as CustomEvent; + if (hasFunctionalConsent(customEvent.detail)) { + showComments(); + } else { + hideComments(); + } + }); + + if (hasFunctionalConsent()) { + showComments(); + } else { + hideComments(); + } +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCommentsConsent, { once: true }); +} else { + initCommentsConsent(); +} diff --git a/assets/ts/cookies.ts b/assets/ts/cookies.ts index a4922bf..830057b 100644 --- a/assets/ts/cookies.ts +++ b/assets/ts/cookies.ts @@ -175,6 +175,7 @@ class CookieConsent { } private dispatchConsentEvent(): void { + // Cross-module event consumed by consent-gated features (for example commentsConsent.ts). const event = new CustomEvent('onCookieConsentChange', { detail: this.state }); diff --git a/layouts/_partials/comments/include.html b/layouts/_partials/comments/include.html index 6fc6cd3..ac329e9 100644 --- a/layouts/_partials/comments/include.html +++ b/layouts/_partials/comments/include.html @@ -1,3 +1,8 @@ +{{/* + Comments include entry. + - If functional cookie consent is required, render a placeholder + template and load commentsConsent.ts. + - Otherwise, render provider partial directly. +*/}} {{ if .Site.Params.comments.enabled }} {{- $needsConsent := and .Site.Params.cookies.enabled .Site.Params.cookies.categories.functional -}} {{- if $needsConsent -}} @@ -8,40 +13,16 @@ {{ T "cookies.managePreferences" }} -