From 0fc826492773106329b8624fd215e4cccb199859 Mon Sep 17 00:00:00 2001
From: powerfullz <61956140+powerfullz@users.noreply.github.com>
Date: Mon, 27 Apr 2026 00:51:22 +0800
Subject: [PATCH] fix: comment scripts bypass cookie consent (#1308)
* fix: comment scripts bypass cookie consent
When GDPR cookie consent is enabled, the comments container is simply
hidden with CSS. However, this still allows all third-party script
tags within the container to be evaluated and downloaded by the
browser.
This PR uses the tag which stops inner scripts from evaluating.
Once consent is granted, the template content is cloned and inserted
into the container, and the scripts are manually loaded in order.
* refactor(comments): move consent-gated loader to TS asset
* fix(comments): add integrity attribute to comments consent script
* style: align comments consent docs and naming
* refactor(comments): simplify script handling
---------
Co-authored-by: Jimmy Cai
---
assets/ts/commentsConsent.ts | 141 ++++++++++++++++++++++++
assets/ts/cookies.ts | 1 +
layouts/_partials/comments/include.html | 45 +++-----
3 files changed, 155 insertions(+), 32 deletions(-)
create mode 100644 assets/ts/commentsConsent.ts
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" }}
-
-
+
+ {{- $opts := dict "minify" hugo.IsProduction -}}
+ {{/* commentsConsent.ts is intentionally built and loaded here as an independent entry */}}
+ {{- $commentsScript := resources.Get "ts/commentsConsent.ts" | js.Build $opts | fingerprint -}}
+
{{- else -}}
{{/* No consent required - load comments normally */}}
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
{{- end -}}
-{{ end }}
\ No newline at end of file
+{{ end }}