Enabling cookie consent on a Jekyll Minimal Mistakes site

20 minute read

It turns out that my Jekyll-based static site was storing cookies, of which I wasn’t aware. Oops! Sorry! Cookie opt-in is required by law in Germany and I’ve now implemented this. Here are the gory details of how I set it up.

All apologies!

While working on a completely separate project, I happened to stumble on the fact that Google Analytics cookies were being saved in my browser. Suddenly a penny dropped and I realised that even my static blog must be saving cookies. Doh! It’s actually quite embarrassing to have to admit that, but it honestly never occurred to me.

Therefore, I’d like to most humbly apologise to previous visitors to this blog. I had enabled Google Analytics as part of the Jekyll theme configuration to get an idea of how many people had been visiting the site and to see which topics seemed to be of most interest. Beyond that, I have not used the data collected by Google Analytics for any other purpose. I have now created a Terms and Privacy Policy page outlining the information that is collected and how it is used. I am very sorry about this oversight and will strive to be more diligent about this topic in the future.

Implementing cookie consent with Minimal Mistakes

Although turning Google Analytics on is very easy in the Jekyll theme that I use (Minimal Mistakes); it turns out it’s not so easy to turn it off, at least, not when one wants the cookie state to depend upon the wishes of the user visiting the site. Nevertheless, I’ve managed to implement opt-in of cookie consent and I thought it might be interesting (especially for other Jekyll users) to show how I did it.

So, where to start? After a bit of searching I found a very good outline of the required process written by the Minimal Mistakes theme author. Another user of the Minimal Mistakes theme provided more information about implementing cookie consent in a ticket in the Minimal Mistakes issue tracker. Although the advice wasn’t a complete guide, the information in those GitHub issues put me on the right path to getting everything to work. Minimal Mistakes also provides a very good text for the terms of use and the privacy policy that a blog site can use, and this is what I adapted for my own Terms and Privacy Policy.

A good idea starting out is to have a tool which can check for violations of the issue you’re trying to fix. This is sort of like writing a test before writing the production code. I found Cookiebot to be quite useful in checking my site for GDPR compliance. It’s possible to specify your website address and your email address (so that they can send you the compliance report), and then within about 10–20 minutes you’ll get an email with a report of the GDPR issues on your website. You won’t get a complete report if you don’t register for the service, which is free to use for monthly checks of your site. After using the service for the initial check of my site, I finally succumbed and registered so that I could make a more thorough check of my site, which will hopefully reduce such problems in the future.

The first check showed 7 issues on my site: 6 of which were related to Google Analytics; the remaining one was from Disqus (a website comment platform). Here’s an excerpt from the first report (it’s in German, possibly because Cookiebot noticed my location and the location of where my site runs from):

Cookiebot initial GDPR compliance report

The report is quite detailed and tells you the name of a cookie, where it’s used on the site, where it comes from, and what the cookie’s description is. It also tells you if the country the data is sent to is adequate for the terms of the GDPR. All of this information is very handy when trying to work out if a site is GDPR-conformant.

Give users the chance to opt-in to cookies

A recent legal decision by the German Federal High Court confirmed an earlier decision by the European High Court that users must explicitly opt-in for cookies in order for the website to use such things as tracking information. More information than you probably want to know is available in the article BGH-Urteil: Opt-In-Pflicht für Werbe- und Marketing-Cookies (FAQ mit Anleitung und Checkliste).

The Minimal Mistakes author recommends using Cookie Consent by Osano (a data privacy platform to aid sites become compliant with data protection laws); Cookie Consent is also an Open Source project on GitHub, thus one can peruse the code if so inclined.

I decided to use the Cookie Consent service on my site; the service can be configured by visiting the Cookie Consent download page. Upon visiting the page, I chose to use Open Source Edition option; the configuration process starts after one clicks on the “Start Coding” button.

If you’re following along, you should now see the configuration wizard appear, which will look something like this:

Cookie Consent configuration wizard

In my case many of the default options were appropriate for what I wanted to do, hence I filled out the form elements like so:

  1. Position: banner bottom (the default option)
  2. Layout: block (the default option)
  3. Palette: black with yellow “accept” button (the default option); this also looked to fit the colour scheme used on my blog site
  4. Learn more link: here I overrode the default option to link to my Terms and Privacy Policy page
  5. Compliance type: due to the legal requirement for users to opt-in to cookies, it was necessary to select the “Ask users to opt into cookies” option. The configuration wizard is very helpful here as it then tells you that your site needs to be modified for this option to work and advises you to read the disabling cookies documentation.
  6. Custom text: here I simplified the default message a bit so that it is more specific to my site.

Once the form is filled out, the “Copy the code” section on the right contains the CSS code which needs to be added to the <head> section of every page on your site and then the JavaScript code needed at the end of the <body> of all pages on your site. In my case, this was:

<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.css" />

and

<script src="https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.js" data-cfasync="false"></script>
<script>
window.cookieconsent.initialise({
  "palette": {
    "popup": {
      "background": "#000"
    },
    "button": {
      "background": "#f1d600"
    }
  },
  "type": "opt-in",
  "content": {
    "message": "This website uses cookies to ensure you get the best experience here.",
    "href": "https://link-to-your-own-terms-page"
  }
});
</script>

respectively.

The next question that we’re confronted with is: where do we put this code within a Jekyll site? And more particularly: is there anything we have to do specifically for the theme that we’re using?

A diversion into Jekyll-land

A Jekyll-based site contains several directories with different functions. For instance, _pages are for normal pages, _posts are for blog posts, and _drafts are for draft posts. The style and layout of the site is usually controlled by a theme of some description; layout information is specified within the _layouts directory, various extra files which need to be included are in the _includes directory and various data files are found in _data. To change the theme’s behaviour slightly, one copies the relevant file or files from the theme into the equivalent directory structure in one’s project and then changes the file appropriately. This then overrides the original behaviour and–depending upon the theme–is usually quite a small change thus allowing easy extension of a theme while minimising the risk of breaking anything when upgrading the theme or Jekyll itself.

In my particular installation of Minimal Mistakes, I’d installed the Ruby gem version of the theme, hence it’s fairly easy to find out where the theme files have been installed on the filesystem. Just run:

$ bundle info minimal-mistakes-jekyll

from the base directory of your Jekyll site (the directory containing the Gemfile). The bundle info command gives output similar to this:

  * minimal-mistakes-jekyll (4.19.1)
        Summary: A flexible two-column Jekyll theme.
        Homepage: https://github.com/mmistakes/minimal-mistakes
        Path: /home/<username>/.rvm/gems/ruby-2.5.3/gems/minimal-mistakes-jekyll-4.19.1

The contents of this directory are:

assets/  CHANGELOG.md  _data/  _includes/  _layouts/  LICENSE  README.md  _sass/

To solve the current problem, we’re interested mostly in the contents of the _includes/ directory, however there are more things that one could tinker with if one wanted to and that’s outlined nicely in the Minimal Mistakes documentation for Overriding Theme Defaults.

Setting up the CSS

Now that we know where everything is, we can start to look for files to override.

It is customary to customise a custom.html file. To add the required Cookie Consent CSS code to the <head> section of the document, we simply copy the custom.html file from the theme’s _includes/head/ directory into the local project’s _includes/head/ directory, which we need to create beforehand:

$ mkdir -p _includes/head
$ cp /path/to/gems/minimal-mistakes-jekyll-4.19.1/_includes/head/custom.html _includes/head

In the default Minimal Mistakes setup, this file only contains comments:

<!-- start custom head snippets -->

<!-- insert favicons. use https://realfavicongenerator.net/ -->

<!-- end custom head snippets -->

Pasting the CSS code from the Cookie Consent site in between the start and end comments, we get this:

<!-- start custom head snippets -->

<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.css" />

<!-- insert favicons. use https://realfavicongenerator.net/ -->

<!-- end custom head snippets -->

Now it’s just a matter of adding the provided Cookie Consent JavaScript code. However, due to the explicit opt-in requirement this step is a bit more involved.

Adding the JavaScript

Let’s quickly review the JavaScript code we have to add.

<script src="https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.js" data-cfasync="false"></script>
<script>
window.cookieconsent.initialise({
  "palette": {
    "popup": {
      "background": "#000"
    },
    "button": {
      "background": "#f1d600"
    }
  },
  "type": "opt-in",
  "content": {
    "message": "This website uses cookies to ensure you get the best experience here.",
    "href": "https://link-to-your-own-terms-page"
  }
});
</script>

Note that it’s split into two separate chunks:

  1. the Cookie Consent code loaded from a CDN.
  2. the initialisation code required for the local site.

What we’ll need to do is to put the Cookie Consent CDN-based code into a custom footer, then put the local initialisation code into a local JavaScript file under assets/js/ which we then include by adding it to the list of scripts in the after_footer_scripts value in the Jekyll _config.yml file. Thereafter, we’re going to have to override (and encapsulate) the included the Google Analytics and Disqus code so that we can activate or deactivate it according to the user’s cookie wishes. That, at least, is the plan.

What you might be wondering right now is: “Hang on, how did he work that out? How was that even obvious?” It wasn’t. This is the third or fourth (or so) time that I’ve been through this process (and this post) in order to get the implementation right. I considered showing all of my wild goose chases, but then decided it was a much better idea to just introduce the “happy path” as that would cause much less confusion for anyone who was brave enough to follow the plan I outline here.

If you’re wondering how I came up with the after_footer_scripts solution, the reason is this: the custom footer code is included before any analytics-provider or comments-provider code is included, however we need to put the Cookie Consent initialisation code after the analytics and comments code so that we can dynamically enable/disable it. Therefore, putting all of the Cookie Consent code into the custom footer is insufficient. Note that this is slightly different to the recommendations put forward by the Minimal Mistakes author where he only mentions the custom footer step. My guess is that given the opt-in requirement that we have, the final solution ends up being more involved.

Initial preparations

First, let’s put the Cookie Consent CDN link into the custom header. We need to create the _includes/footer/ directory

$ mkdir -p _includes/footer

then copy the default custom footer file into this newly-created directory

$ cp /path/to/gems/minimal-mistakes-jekyll-4.19.1/_includes/footer/custom.html _includes/footer/

and finally copy the Cookie Consent CDN script link into this file, which will now look like this:

<!-- start custom footer snippets -->

<script src="https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.js"
        data-cfasync="false"></script>

<!-- end custom footer snippets -->

Now we create the file containing the Cookie Consent initialisation code. Create the assets/js/ directory

$ mkdir -p assets/js

and create a file in this directory called cookie-consent.js with the initialisation code mentioned above:

window.cookieconsent.initialise({
  "palette": {
    "popup": {
      "background": "#000"
    },
    "button": {
      "background": "#f1d600"
    }
  },
  "type": "opt-in",
  "content": {
    "message": "This website uses cookies to ensure you get the best experience here.",
    "href": "https://link-to-your-own-terms-page"
  }
});

To ensure that this code is included in our site, we need to set the after_footer_scripts variable in the base _config.yml file. This variable doesn’t exist by default, so I added it to the end of the # Site Settings section of my config file. Adding the following snippet is therefore sufficient to include the Cookie Consent initialisation code (see the Minimal Mistakes JavaScript docs for more information):

after_footer_scripts     :
  - /assets/js/cookie-consent.js

Note that this only sets the stage for the remaining steps necessary to implement cookie opt-in on our site. To get the ball rolling, let’s implement opt-in for the Google Analytics tracking stuff.

Opting-in to Google Analytics tracking

Having a look through the code for the Minimal Mistakes theme, I found that the Google Analytics code is included as part of the general “analytics” include file, which itself is included as part of scripts.html, which is part of the default layout in _layouts/default.html. A diagram makes this structure a bit clearer.

_layouts/default.html
  -> _includes/scripts.html
    -> _includes/analytics.html
      -> _includes/analytics-providers/google.html

The content of google.html looks like this:

<script>
  var _gaq = _gaq || [];
  _gaq.push(['_setAccount', '']);
  
  _gaq.push(['_trackPageview']);

  (function() {
    var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
    ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
  })();
</script>

and means that the code (and hence the tracking) will be active whenever this code is included.

So how can we allow the user to toggle the activity of this code? One solution I found wrapped the Google Analytics code in a function which was then activated depending upon the user’s choice1. The user’s opt-in choice then calls this function via the Cookie Consent cookie toggle callback hook. This seemed like a good idea and hence I decided to copy the idea for my site.

The plan to ensure we only call the Google Analytics code when the user has accepted cookies then looks like this:

  • Override google.html with a version that wraps the tracker activation code in a function.
  • Extend the Cookie Consent initialisation code in cookie-consent.js with the Cookie Consent toggle callback hook code.
  • Call the newly-wrapped function within the relevant section of the callback hook code.

Ok let’s do this. Create a directory in the local project matching the path from the _includes directory in the Minimal Mistakes theme:

$ mkdir -p _includes/analytics-providers

and copy the theme’s google.html into the new directory:

$ cp /path/to/gems/minimal-mistakes-jekyll-4.19.1/_includes/analytics-providers/google.html _includes/analytics-providers

The google.html file currently looks like this:

<script>
  var _gaq = _gaq || [];
  _gaq.push(['_setAccount', '']);
  
  _gaq.push(['_trackPageview']);

  (function() {
    var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
    ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
  })();
</script>

Now we wrap this entire block of code in a function called loadGAonConsent(). Note that it was necessary to explicitly set the _gaq variable as a property on the window object (as it would have been in the previous scope) so that the tracking code works as normal. The google.html file now looks like this:

<script>
  function loadGAonConsent() {
    window._gaq = window._gaq || [];
    _gaq.push(['_setAccount', '']);
    
    _gaq.push(['_trackPageview']);

    (function() {
      var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
      ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
      var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
    })();
  }
</script>

This code will be included in the HTML provided to the user, however it won’t be active. To be able to make this active, we add the Cookie Consent callback hook code to the object passed into the window.cookieconsent.initialise() function in cookie-consent.js.

Pasting this code into cookie-consent.js and adding loadGAonConsent() to the if blocks marked with // enable cookies (and hence following the advice in step 6 of DSGVO: Google Analytics mit Opt-In implementieren) gives us:

window.cookieconsent.initialise({
  "palette": {
    "popup": {
      "background": "#000"
    },
    "button": {
      "background": "#f1d600"
    }
  },
  "type": "opt-in",
  "content": {
    "message": "This website uses cookies to ensure you get the best experience here.",
    "href": "https://link-to-your-own-terms-page"
  },
  onInitialise: function (status) {
    var type = this.options.type;
    var didConsent = this.hasConsented();
    if (type == 'opt-in' && didConsent) {
      // enable cookies
      loadGAonConsent();
    }
    if (type == 'opt-out' && !didConsent) {
      // disable cookies
    }
  },
  onStatusChange: function(status, chosenBefore) {
    var type = this.options.type;
    var didConsent = this.hasConsented();
    if (type == 'opt-in' && didConsent) {
      // enable cookies
      loadGAonConsent();
    }
    if (type == 'opt-out' && !didConsent) {
      // disable cookies
    }
  },
  onRevokeChoice: function() {
    var type = this.options.type;
    if (type == 'opt-in') {
      // disable cookies
    }
    if (type == 'opt-out') {
      // enable cookies
      loadGAonConsent();
    }
  }
});

Rolling this out to production2 allows us to see if this all works. First, I cleared the cookies for my site3 and checked in the “Storage” tab of the developer tools to ensure that the cookies were, in fact, gone.

No cookies associated with site

As we can see, there aren’t any cookies on the peateasea.de domain, which was the goal in this step. Also, we see that the cookie opt-in/opt-out bar appears automatically and that no cookies are saved before this is allowed

Note, however, that a cookie slot for disqus.com appears in the list of cookies. As we will see below, this cookie is empty, however we still get problems with its existence.

Clicking on the “Allow cookies” button we see that the Google Analytics cookies are set (the __utm* cookies are the Google Analytics cookies):

Cookies are set when explicitly allowed

In an ironic twist, it turns out that Cookie Consent also saves a cookie in order to save the cookie consent status. Although this seems somewhat odd, I think this makes sense and is probably the only straightforward solution for the library to implement.

Deleting the cookies again and this time selecting “Decline”, we see that only the Cookie Consent status cookie is set and that its value is deny as we would expect:

Cookies aren't set when explicitly disallowed

I also checked the behaviour in the Google Analytics real time report while running the above tests and was able to verify that with cookies denied, no usage was registered, whereas with cookies allowed, there was activity. This is the behaviour one would expect.

The local tests we can perform are effectively exhausted at this point, hence it’s a good idea to check via a third party. I ended up signing up to Cookiebot so that they would automatically check my site once a month and so that they’d check more than a single page (as is done in the semi-anonymous compliance check I made initially). We see now that the scan report shows only the one necessary cookie:

Cookiebot scan report with cookies disabled

Although this is a good sign, I decided to try the initial compliance check again, just to see what its output is. After entering the website address as well as my email address, confirming my email address and waiting the necessary 10–20 minutes for the report to be generated, I got this output (truncated for brevity):

Cookiebot GDPR compliance report without Google Analytics

which shows that there are Disqus cookies being set without the user’s consent. This is … weird. It’s weird because this output is at odds with the supposedly more thorough check made for subscribed users. What’s also weird is that when the cookies are cleared, there is a slot set for the Disqus cookies, but the cookie is empty:

Empty Disqus cookie storage slot with cookies disallowed

I admit to being confused about this state, however I’m making the guess that Cookiebot checks my site more thoroughly for GDPR compliance than I can (even if there are differences in the subscribed and unsubscribed reports). If anyone can explain these results to me, I’d be really happy to know what’s going on!

All this means is that we also have to deactivate the Disqus cookies, which we thought was necessary anyway. So let’s do that.

Opting-in to Disqus cookies

We now just need to follow the pattern outlined in the previous section: we override the appropriate include file, wrap its JavaScript code in a function and call this function only if the user has opted-in for cookies. We start by creating an _includes directory for comments providers include files as is the pattern in the Minimal Mistakes theme

$ mkdir -p _includes/comments-providers

and copying the disqus.html file into this directory

$ cp /path/to/gems/minimal-mistakes-jekyll-4.19.1/_includes/comments-providers/disqus.html _includes/comments-providers

The disqus.html file currently looks like this:

{% if site.comments.disqus.shortname %}
  <script>
    var disqus_config = function () {
      this.page.url = "{{ page.url | absolute_url }}";  /* Replace PAGE_URL with your page's canonical URL variable */
      this.page.identifier = "{{ page.id }}"; /* Replace PAGE_IDENTIFIER with your page's unique identifier variable */
    };
    (function() { /* DON'T EDIT BELOW THIS LINE */
      var d = document, s = d.createElement('script');
      s.src = 'https://{{ site.comments.disqus.shortname }}.disqus.com/embed.js';
      s.setAttribute('data-timestamp', +new Date());
      (d.head || d.body).appendChild(s);
    })();
  </script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
{% endif %}

Wrapping the JavaScript code into a function called loadDisqusOnConsent() we get:

{% if site.comments.disqus.shortname %}
  <script>
    function loadDisqusOnConsent() {
      var disqus_config = function () {
        this.page.url = "{{ page.url | absolute_url }}";  /* Replace PAGE_URL with your page's canonical URL variable */
        this.page.identifier = "{{ page.id }}"; /* Replace PAGE_IDENTIFIER with your page's unique identifier variable */
      };
      (function() { /* DON'T EDIT BELOW THIS LINE */
        var d = document, s = d.createElement('script');
        s.src = 'https://{{ site.comments.disqus.shortname }}.disqus.com/embed.js';
        s.setAttribute('data-timestamp', +new Date());
        (d.head || d.body).appendChild(s);
      })();
    }
  </script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
{% endif %}

Now we just need to add this function after loadGAonConsent() in cookie-consent.js:

window.cookieconsent.initialise({
  "palette": {
    "popup": {
      "background": "#000"
    },
    "button": {
      "background": "#f1d600"
    }
  },
  "type": "opt-in",
  "content": {
    "message": "This website uses cookies to ensure you get the best experience here.",
    "href": "https://link-to-your-own-terms-page"
  },
  onInitialise: function (status) {
    var type = this.options.type;
    var didConsent = this.hasConsented();
    if (type == 'opt-in' && didConsent) {
      // enable cookies
      loadGAonConsent();
      loadDisqusOnConsent();
    }
    if (type == 'opt-out' && !didConsent) {
      // disable cookies
    }
  },
  onStatusChange: function(status, chosenBefore) {
    var type = this.options.type;
    var didConsent = this.hasConsented();
    if (type == 'opt-in' && didConsent) {
      // enable cookies
      loadGAonConsent();
      loadDisqusOnConsent();
    }
    if (type == 'opt-out' && !didConsent) {
      // disable cookies
    }
  },
  onRevokeChoice: function() {
    var type = this.options.type;
    if (type == 'opt-in') {
      // disable cookies
    }
    if (type == 'opt-out') {
      // enable cookies
      loadGAonConsent();
      loadDisqusOnConsent();
    }
  }
});

Rolling this out to production and running the tests mentioned in the previous section showed that the Disqus cookie slot is no longer set when cookies have been deleted and the page reloaded:

Disqus cookie slot no longer set

This is a good sign. However, what does the Cookiebot compliance report say? I requested the basic report for unsubscribed users again and got this result:

Cookiebot GDPR compliance report without Disqus

Now that’s what I like to see! There aren’t any cookies identified at all! Visitors to the site now have to explicitly opt-in to cookies as is required by law. Phew! That was a fair bit of work, but we got there in the end.

Wrapping up

Cookie consent opt-in is a requirement of European law and websites (no matter how simple) need to ensure that they are GDPR compliant. Finding out that your site is non-compliant can be a surprise, but there is plenty of help online to ensure everything is in order to protect the data of visitors to your site.

Is there anything that I’ve missed? Was this post helpful? How could I make it better? Let me know in the comments section (you’ll need to enable cookies!) or simply drop me a line via email or ping me on Mastodon.

  1. See the section “Schritt 6: Der komplette Code” for the code that I based my solution upon. 

  2. We can’t check this in the development environment because all of the analytics tracking and comments stuff is disabled; otherwise any work in the development environment would appear in the analytics output and thus give incorrect results. 

  3. In Firefox got to the “Preferences” page then “Privacy & Security” and select the “Manage Data…” button in the “Cookies and Site Data” section. Search for the site cookies, select the respective entry, click the “Remove Selected” button and then press the “Save Changes” button. 

Categories:

Updated: