Enabling cookie consent on a Jekyll Minimal Mistakes site
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.
Check the site for cookie consent violations
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):
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:
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:
- Position: banner bottom (the default option)
- Layout: block (the default option)
- Palette: black with yellow “accept” button (the default option); this also looked to fit the colour scheme used on my blog site
- Learn more link: here I overrode the default option to link to my Terms and Privacy Policy page
- 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.
- 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:
- the Cookie Consent code loaded from a CDN.
- 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.
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):
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:
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:
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):
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:
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:
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:
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 Twitter.
-
See the section “Schritt 6: Der komplette Code” for the code that I based my solution upon. ↩
-
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. ↩