23 KiB
GDPR-Gated Matomo Tracking Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add consent-gated, cookieless Matomo analytics to every Starlight page without contacting the analytics origin before an affirmative visitor choice.
Architecture: A dependency-free classic browser script in public/ owns consent persistence, the accessible consent UI, and conditional Matomo loading. Starlight's supported global head option loads only that local controller, with an Astro-base-aware URL; the existing custom stylesheet owns the UI presentation.
Tech Stack: Astro 7.1.6, Starlight 0.41.6, browser DOM APIs, Matomo JavaScript tracker API, Node's built-in test runner, linkedom 0.18.13.
Global Constraints
- Do not request
https://analytics.msvincognito.nl/matomo.jsor createwindow._paqbefore affirmative consent. - Use tracker endpoint
https://analytics.msvincognito.nl/matomo.phpand site ID1. - Call
requireConsent,disableCookies, andsetConsentGivenbeforetrackPageView. - Declining analytics must not reduce wiki functionality or be visually harder than accepting.
- Keep a persistent Privacy settings control so consent can be changed or withdrawn.
- Link consent information to
https://msvincognito.nl/privacy-policy. - Add no third-party consent-management or analytics package.
- Preserve deployments at
/and at a configuredBASEsubpath. - Treat this frontend as one part of compliance; document the required Matomo server and privacy-notice follow-up.
Execution refinement
During inline execution, Task 2's planned source-text assertions in tests/project-structure.test.mjs were replaced by tests/matomo-build.test.mjs. The replacement runs a real Astro production build with BASE=/Incognito-Wiki and asserts on the generated HTML, copied local controller, and emitted CSS. This follows the test-quality requirement to verify observable behavior rather than grep implementation text; tests/project-structure.test.mjs therefore remains unchanged.
File map
- Create
public/matomo-consent.js: dependency-free browser controller for consent storage, accessible UI, Matomo initialization, duplicate guards, and withdrawal. - Create
tests/matomo-consent.test.mjs: behavioral tests that execute the real controller in a lightweight DOM. - Modify
astro.config.mjs: register the local controller globally using Starlightheadand a normalized Astro base path. - Modify
src/styles/incognito.css: style the consent banner, equal-choice actions, and persistent settings control. - Modify
tests/project-structure.test.mjs: lock down global registration, base-path handling, privacy link, and styling hooks. - Modify
README.md: document analytics behavior and the server-side deployment checklist.
Task 1: Consent controller behavior
Files:
- Create:
public/matomo-consent.js - Create:
tests/matomo-consent.test.mjs
Interfaces:
-
Consumes: browser
window,document, and best-effortwindow.localStorage. -
Produces: storage key
incognito.analytics-consent.v1with valuesacceptedordeclined; DOM IDsincognito-analytics-consent,incognito-analytics-accept,incognito-analytics-decline, andincognito-privacy-settings; guarded globalswindow.__incognitoMatomoConsentControllerandwindow.__incognitoMatomoInitialized. -
Loads:
https://analytics.msvincognito.nl/matomo.jswith element IDincognito-matomo-scriptonly after consent. -
Step 1: Write the failing first-visit and acceptance tests
Create tests/matomo-consent.test.mjs with a harness that runs the real classic script in linkedom:
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import vm from 'node:vm';
import { parseHTML } from 'linkedom';
const controllerSource = await readFile('public/matomo-consent.js', 'utf8');
const storageKey = 'incognito.analytics-consent.v1';
function runController({ decision, storageError = false } = {}) {
const { window } = parseHTML('<!doctype html><html><head></head><body></body></html>');
const values = new Map(decision ? [[storageKey, decision]] : []);
const localStorage = {
getItem(key) {
if (storageError) throw new Error('storage unavailable');
return values.get(key) ?? null;
},
setItem(key, value) {
if (storageError) throw new Error('storage unavailable');
values.set(key, value);
},
};
Object.defineProperty(window, 'localStorage', { value: localStorage });
vm.runInNewContext(controllerSource, { window, document: window.document });
return { window, document: window.document, values };
}
test('first visit offers equal choices without initializing Matomo', () => {
const { window, document } = runController();
assert.equal(window._paq, undefined);
assert.equal(document.querySelector('script[src*="analytics.msvincognito.nl"]'), null);
assert.equal(document.getElementById('incognito-analytics-consent').hidden, false);
assert.equal(document.getElementById('incognito-analytics-accept').className,
document.getElementById('incognito-analytics-decline').className);
assert.equal(
document.querySelector('#incognito-analytics-consent a').href,
'https://msvincognito.nl/privacy-policy',
);
});
test('accepting persists consent and initializes cookieless Matomo once', () => {
const { window, document, values } = runController();
document.getElementById('incognito-analytics-accept').click();
document.getElementById('incognito-analytics-accept').click();
assert.equal(values.get(storageKey), 'accepted');
assert.deepEqual(Array.from(window._paq), [
['requireConsent'],
['disableCookies'],
['setConsentGiven'],
['setTrackerUrl', 'https://analytics.msvincognito.nl/matomo.php'],
['setSiteId', '1'],
['trackPageView'],
['enableLinkTracking'],
]);
const scripts = document.querySelectorAll('#incognito-matomo-script');
assert.equal(scripts.length, 1);
assert.equal(scripts[0].src, 'https://analytics.msvincognito.nl/matomo.js');
assert.equal(scripts[0].async, true);
});
- Step 2: Run the new test to verify RED
Run:
node --test tests/matomo-consent.test.mjs
Expected: FAIL because public/matomo-consent.js does not exist.
- Step 3: Add the minimal controller that passes first-visit and acceptance behavior
Create public/matomo-consent.js as a classic script. Keep all implementation inside an IIFE so only the two duplicate-guard flags and Matomo's _paq are global:
(function initializeAnalyticsConsent(window, document) {
'use strict';
var storageKey = 'incognito.analytics-consent.v1';
var accepted = 'accepted';
var declined = 'declined';
var trackerOrigin = 'https://analytics.msvincognito.nl/';
if (window.__incognitoMatomoConsentController) return;
window.__incognitoMatomoConsentController = true;
function readDecision() {
try {
var value = window.localStorage.getItem(storageKey);
return value === accepted || value === declined ? value : null;
} catch (_error) {
return null;
}
}
function writeDecision(value) {
try {
window.localStorage.setItem(storageKey, value);
return true;
} catch (_error) {
return false;
}
}
function initializeMatomo() {
var queue = window._paq = window._paq || [];
if (window.__incognitoMatomoInitialized) return queue;
window.__incognitoMatomoInitialized = true;
queue.push(['requireConsent']);
queue.push(['disableCookies']);
queue.push(['setConsentGiven']);
queue.push(['setTrackerUrl', trackerOrigin + 'matomo.php']);
queue.push(['setSiteId', '1']);
queue.push(['trackPageView']);
queue.push(['enableLinkTracking']);
var tracker = document.createElement('script');
tracker.id = 'incognito-matomo-script';
tracker.async = true;
tracker.src = trackerOrigin + 'matomo.js';
document.head.appendChild(tracker);
return queue;
}
function createButton(id, label) {
var button = document.createElement('button');
button.id = id;
button.className = 'incognito-consent-action';
button.type = 'button';
button.textContent = label;
return button;
}
var banner = document.createElement('section');
banner.id = 'incognito-analytics-consent';
banner.setAttribute('role', 'dialog');
banner.setAttribute('aria-labelledby', 'incognito-analytics-consent-title');
banner.innerHTML =
'<div class="incognito-consent-copy">' +
'<h2 id="incognito-analytics-consent-title">Privacy-friendly analytics</h2>' +
'<p>MSV Incognito would like to use cookieless Matomo analytics to understand how the wiki is used. Nothing is sent to Matomo unless you accept. You can change your choice at any time.</p>' +
'<a href="https://msvincognito.nl/privacy-policy">Read our privacy policy</a>' +
'</div>';
var actions = document.createElement('div');
actions.className = 'incognito-consent-actions';
var acceptButton = createButton('incognito-analytics-accept', 'Accept analytics');
var declineButton = createButton('incognito-analytics-decline', 'Decline');
actions.append(acceptButton, declineButton);
banner.appendChild(actions);
var settingsButton = document.createElement('button');
settingsButton.id = 'incognito-privacy-settings';
settingsButton.type = 'button';
settingsButton.textContent = 'Privacy settings';
function showBanner() {
banner.hidden = false;
settingsButton.hidden = true;
acceptButton.focus();
}
function hideBanner() {
banner.hidden = true;
settingsButton.hidden = false;
settingsButton.focus();
}
acceptButton.addEventListener('click', function acceptAnalytics() {
writeDecision(accepted);
if (window.__incognitoMatomoInitialized) {
window._paq.push(['setConsentGiven']);
window._paq.push(['trackPageView']);
} else {
initializeMatomo();
}
hideBanner();
});
declineButton.addEventListener('click', function declineAnalytics() {
writeDecision(declined);
if (window._paq) {
window._paq.push(['forgetConsentGiven']);
window._paq.push(['deleteCookies']);
}
hideBanner();
});
settingsButton.addEventListener('click', showBanner);
document.body.append(banner, settingsButton);
if (readDecision() === accepted) {
banner.hidden = true;
settingsButton.hidden = false;
initializeMatomo();
} else if (readDecision() === declined) {
banner.hidden = true;
settingsButton.hidden = false;
} else {
showBanner();
}
})(window, document);
During implementation, avoid calling readDecision() twice: store it in a local variable before the final branch. The duplicate click in the test must not generate a second page view; make hideBanner() or the accept handler ignore clicks while the banner is hidden.
- Step 4: Run the focused test and make the minimal corrections required for GREEN
Run:
node --test tests/matomo-consent.test.mjs
Expected: PASS with 2 tests and no warnings.
- Step 5: Add decline, storage-failure, remembered-consent, and withdrawal tests
Append these behaviors to tests/matomo-consent.test.mjs:
test('declining persists the choice without creating Matomo state', () => {
const { window, document, values } = runController();
document.getElementById('incognito-analytics-decline').click();
assert.equal(values.get(storageKey), 'declined');
assert.equal(window._paq, undefined);
assert.equal(document.getElementById('incognito-matomo-script'), null);
assert.equal(document.getElementById('incognito-privacy-settings').hidden, false);
});
test('remembered acceptance tracks on a later page load', () => {
const { window, document } = runController({ decision: 'accepted' });
assert.ok(window._paq.some(([method]) => method === 'trackPageView'));
assert.equal(document.querySelectorAll('#incognito-matomo-script').length, 1);
assert.equal(document.getElementById('incognito-analytics-consent').hidden, true);
});
test('remembered decline never initializes Matomo', () => {
const { window, document } = runController({ decision: 'declined' });
assert.equal(window._paq, undefined);
assert.equal(document.getElementById('incognito-matomo-script'), null);
assert.equal(document.getElementById('incognito-privacy-settings').hidden, false);
});
test('storage failure defaults to no tracking and keeps consent available', () => {
const { window, document } = runController({ storageError: true });
assert.equal(window._paq, undefined);
assert.equal(document.getElementById('incognito-analytics-consent').hidden, false);
});
test('privacy settings allow an accepted visitor to withdraw consent', () => {
const { window, document, values } = runController({ decision: 'accepted' });
document.getElementById('incognito-privacy-settings').click();
document.getElementById('incognito-analytics-decline').click();
assert.equal(values.get(storageKey), 'declined');
assert.deepEqual(Array.from(window._paq).slice(-2), [
['forgetConsentGiven'],
['deleteCookies'],
]);
const later = runController({ decision: values.get(storageKey) });
assert.equal(later.window._paq, undefined);
assert.equal(later.document.getElementById('incognito-matomo-script'), null);
});
test('running the local controller twice does not duplicate UI or tracking', () => {
const state = runController({ decision: 'accepted' });
vm.runInNewContext(controllerSource, { window: state.window, document: state.document });
assert.equal(state.document.querySelectorAll('#incognito-analytics-consent').length, 1);
assert.equal(state.document.querySelectorAll('#incognito-matomo-script').length, 1);
assert.equal(state.window._paq.filter(([method]) => method === 'trackPageView').length, 1);
});
- Step 6: Run the expanded focused test to verify RED
Run:
node --test tests/matomo-consent.test.mjs
Expected: at least the withdrawal or duplicate-initialization assertion FAILS until the controller fully guards state transitions.
- Step 7: Refine the controller minimally to make every state transition pass
Make the following exact corrections in public/matomo-consent.js:
var decision = readDecision();
acceptButton.addEventListener('click', function acceptAnalytics() {
if (banner.hidden) return;
writeDecision(accepted);
if (window.__incognitoMatomoInitialized) {
window._paq.push(['setConsentGiven']);
window._paq.push(['trackPageView']);
} else {
initializeMatomo();
}
hideBanner();
});
declineButton.addEventListener('click', function declineAnalytics() {
if (banner.hidden) return;
writeDecision(declined);
if (window._paq) {
window._paq.push(['forgetConsentGiven']);
window._paq.push(['deleteCookies']);
}
hideBanner();
});
if (decision === accepted) {
banner.hidden = true;
settingsButton.hidden = false;
initializeMatomo();
} else if (decision === declined) {
banner.hidden = true;
settingsButton.hidden = false;
} else {
showBanner();
}
Keep the identical incognito-consent-action class on both consent buttons. Do not add an automatic timeout, scroll consent, geolocation exception, or remote request in the no-decision/declined branches.
- Step 8: Run the focused controller tests to verify GREEN
Run:
node --test tests/matomo-consent.test.mjs
Expected: PASS with 8 tests and no warnings.
- Step 9: Commit the independently working controller
git add public/matomo-consent.js tests/matomo-consent.test.mjs
git commit -m "feat: add consent-gated Matomo controller"
Task 2: Starlight integration, presentation, and operations
Files:
- Modify:
astro.config.mjs - Modify:
src/styles/incognito.css - Modify:
tests/project-structure.test.mjs - Modify:
README.md
Interfaces:
-
Consumes:
public/matomo-consent.jsand its DOM IDs/classes from Task 1. -
Produces: one deferred
<script src="{BASE}/matomo-consent.js">on every generated Starlight page and responsive consent UI styles. -
Step 1: Write failing structure tests for global, base-aware integration and privacy UI assets
Append to tests/project-structure.test.mjs:
test('Matomo consent controller is registered globally with base-path support', async () => {
const controller = await stat('public/matomo-consent.js');
assert.ok(controller.size > 0, 'public/matomo-consent.js should not be empty');
const config = await readFile('astro.config.mjs', 'utf8');
assert.match(config, /const normalizedBase/);
assert.match(config, /head:\s*\[/);
assert.match(config, /matomo-consent\.js/);
assert.match(config, /defer:\s*true/);
});
test('Matomo consent controls have shared actions and persistent settings styles', async () => {
const css = await readFile('src/styles/incognito.css', 'utf8');
assert.match(css, /#incognito-analytics-consent/);
assert.match(css, /\.incognito-consent-action/);
assert.match(css, /#incognito-privacy-settings/);
const controller = await readFile('public/matomo-consent.js', 'utf8');
assert.match(controller, /https:\/\/msvincognito\.nl\/privacy-policy/);
assert.doesNotMatch(controller, /['"]\/\/analytics\.msvincognito\.nl/);
});
test('analytics operations document server-side privacy requirements', async () => {
const readme = await readFile('README.md', 'utf8');
assert.match(readme, /Matomo analytics/i);
assert.match(readme, /IP anonymization/i);
assert.match(readme, /retention/i);
assert.match(readme, /privacy policy/i);
});
- Step 2: Run the structure tests to verify RED
Run:
node --test tests/project-structure.test.mjs
Expected: FAIL because astro.config.mjs, the stylesheet, and README do not yet contain the required integration and documentation.
- Step 3: Register the local controller in Starlight's global head
Modify the configuration prelude in astro.config.mjs:
const site = process.env.SITE || 'http://localhost:4321';
const base = process.env.BASE || '/';
const normalizedBase = base === '/'
? ''
: `/${base.replace(/^\/+|\/+$/g, '')}`;
const matomoConsentScript = `${normalizedBase}/matomo-consent.js`;
Add this option inside the existing starlight({ ... }) call, next to customCss:
head: [
{
tag: 'script',
attrs: {
src: matomoConsentScript,
defer: true,
},
},
],
Do not add the remote Matomo origin to astro.config.mjs; the local controller remains the only global script.
- Step 4: Add responsive, accessible consent presentation
Append to src/styles/incognito.css:
#incognito-analytics-consent {
position: fixed;
z-index: 1000;
inset-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right));
inset-block-end: max(1rem, env(safe-area-inset-bottom));
display: grid;
gap: 1rem;
width: min(44rem, calc(100% - 2rem));
margin-inline: auto;
padding: 1.25rem;
color: var(--sl-color-white);
background: var(--incognito-navy);
border: 1px solid color-mix(in srgb, var(--incognito-bright) 45%, transparent);
border-radius: 0.75rem;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%);
}
#incognito-analytics-consent[hidden],
#incognito-privacy-settings[hidden] {
display: none;
}
.incognito-consent-copy h2 {
margin: 0 0 0.5rem;
color: inherit;
font-size: 1.125rem;
}
.incognito-consent-copy p {
margin: 0 0 0.5rem;
line-height: 1.55;
}
.incognito-consent-copy a {
color: var(--incognito-light);
}
.incognito-consent-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.incognito-consent-action,
#incognito-privacy-settings {
min-height: 2.75rem;
border: 2px solid var(--incognito-light);
border-radius: 0.5rem;
color: var(--incognito-navy);
background: var(--incognito-light);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.incognito-consent-action:focus-visible,
#incognito-privacy-settings:focus-visible {
outline: 3px solid var(--incognito-pink);
outline-offset: 3px;
}
#incognito-privacy-settings {
position: fixed;
z-index: 999;
inset-inline-end: max(1rem, env(safe-area-inset-right));
inset-block-end: max(1rem, env(safe-area-inset-bottom));
min-height: 2.25rem;
padding-inline: 0.75rem;
border-width: 1px;
font-size: 0.8125rem;
}
@media (min-width: 50rem) {
#incognito-analytics-consent {
grid-template-columns: minmax(0, 1fr) 18rem;
align-items: end;
}
}
@media (max-width: 30rem) {
.incognito-consent-actions {
grid-template-columns: 1fr;
}
}
The two decision buttons intentionally share one class and identical presentation. Do not introduce color, order, or size differences that nudge acceptance.
- Step 5: Document operational privacy requirements
Add this section to README.md after Deployment:
## Matomo analytics
The wiki uses self-hosted Matomo only after a visitor explicitly accepts analytics. The client is loaded from `https://analytics.msvincognito.nl/`, uses site ID `1`, and disables analytics cookies. Visitors can decline without losing functionality and can reopen **Privacy settings** to withdraw consent.
Before deploying analytics changes, the Matomo administrator must verify:
- IP anonymization is enabled;
- raw logs and analytics reports use documented, proportionate retention periods;
- administrator access is restricted and reviewed;
- analytics data is not reused for advertising or cross-site profiling; and
- the [MSV Incognito privacy policy](https://msvincognito.nl/privacy-policy) accurately states the controller, purpose, data categories, retention, withdrawal process, and data-subject rights.
The frontend consent gate is only one part of GDPR and ePrivacy compliance. Revisit the legal and server configuration when Matomo features or processing purposes change.
- Step 6: Run focused tests to verify GREEN
Run:
node --test tests/matomo-consent.test.mjs tests/project-structure.test.mjs
Expected: PASS with all controller and structure tests and no warnings.
- Step 7: Build at the root and inspect rendered integration
Run:
npm run build
rg -n 'src="/matomo-consent\.js"' dist/index.html
rg -n 'incognito-analytics-consent|incognito-privacy-settings' dist/_astro/*.css
Expected: build exits 0; dist/index.html contains exactly one deferred local controller URL; generated CSS contains both consent UI selectors.
- Step 8: Build with a subpath and verify the controller URL is base-aware
Run:
SITE=https://example.github.io BASE=/Incognito-Wiki npm run build
rg -n 'src="/Incognito-Wiki/matomo-consent\.js"' dist/index.html
Expected: build exits 0 and the generated script URL begins with /Incognito-Wiki/.
- Step 9: Run the complete project verification from a fresh root build
Run:
npm run verify
Expected: astro check, every Node test, content audit, production build, rendered-output check, and internal-link check all exit 0.
- Step 10: Inspect the final diff and commit the integration
Run:
git diff --check
git status --short
git diff -- astro.config.mjs src/styles/incognito.css tests/project-structure.test.mjs README.md
Confirm that the diff contains no remote Matomo <script> in Astro configuration, no tracking-before-consent path, and no unrelated changes. Then commit:
git add astro.config.mjs src/styles/incognito.css tests/project-structure.test.mjs README.md
git commit -m "feat: integrate privacy-first Matomo analytics"