util/consent.js

/* eslint-env browser */
/**
 * Consent boundary for the theme's explicitly classified analytics integrations.
 * @module util/consent
 */
const { toJs } = require('./script');

// Kept self-contained because this function is emitted into the generated page.
function installConsent(type) {
  if (window.hexoComponentConsent) {
    return;
  }
  const integrations = Object.create(null);
  let started = false;
  const status = (document.cookie.match(/(?:^|;\s*)cookieconsent_status=([^;]*)/) || [])[1];
  let allowed = type === 'opt-out' ? status !== 'deny' : status === 'allow';
  let popup = null;
  function run() {
    Object.keys(integrations).forEach((id) => {
      const integration = integrations[id];
      if (allowed && !integration.started) {
        integration.started = true;
        started = true;
        integration.start();
      }
    });
  }
  window.hexoComponentConsent = {
    allowed: function () {
      return allowed;
    },
    register: function (id, start, stop) {
      if (!integrations[id]) {
        integrations[id] = { start: start, stop: stop, started: false };
      }
      run();
    },
    setPopup: function (value) {
      popup = value;
    },
    setStatus: function (value) {
      const previous = allowed;
      allowed = type === 'opt-out' ? value !== 'deny' : value === 'allow';
      if (previous && !allowed) {
        try {
          Object.keys(integrations).forEach((id) => {
            const integration = integrations[id];
            if (integration.started && integration.stop) {
              integration.stop();
            }
          });
        } finally {
          // A loaded vendor SDK may own timers/listeners that cannot safely be unloaded.
          // A new document, with the saved denial, is the generic stop boundary.
          if (started) {
            window.location.reload();
          }
        }
      } else {
        run();
      }
    },
    revoke: function () {
      if (popup) {
        popup.setStatus('deny');
      } else {
        document.cookie = 'cookieconsent_status=deny; Path=/; Max-Age=31536000; SameSite=Lax';
      }
      // setStatus on the popup invokes this too; a repeated denial is harmless.
      this.setStatus('deny');
    },
    open: function () {
      if (popup) {
        popup.open();
      }
    },
  };
}

/**
 * Build the idempotent browser-side consent controller. Info is deliberately not gated.
 * @param {string} type CookieConsent mode.
 * @returns {string} Inline JavaScript.
 */
function consentRuntime(type) {
  return `(${installConsent.toString()})(${toJs(type)});`;
}

/**
 * Defer all scripts (including inline SDK bootstrap code) until this integration is authorized.
 * Noscript pixels are intentionally not restored: without JavaScript consent cannot be obtained.
 * @param {string} id Stable integration ID, shared across PJAX navigations.
 * @param {string} markup Rendered integration markup containing scripts.
 * @param {string} type CookieConsent mode.
 * @returns {string} Inline JavaScript without active external resources before permission.
 */
function gateMarkup(id, markup, type) {
  return `${consentRuntime(type)}
    window.hexoComponentConsent.register(${toJs(id)}, function () {
      var template = document.createElement('template');
      template.innerHTML = ${toJs(markup)};
      template.content.querySelectorAll('script').forEach(function (source) {
        if (!window.hexoComponentConsent.allowed()) return;
        var script = document.createElement('script');
        Array.prototype.forEach.call(source.attributes, function (attr) {
          script.setAttribute(attr.name, attr.value);
        });
        script.textContent = source.textContent;
        document.head.appendChild(script);
      });
    });`;
}

module.exports = { consentRuntime, gateMarkup };