docs: plan privacy-first Matomo integration
This commit is contained in:
parent
f007aad4c1
commit
8395e5b934
1 changed files with 672 additions and 0 deletions
672
docs/superpowers/plans/2026-08-15-gdpr-matomo-tracking.md
Normal file
672
docs/superpowers/plans/2026-08-15-gdpr-matomo-tracking.md
Normal file
|
|
@ -0,0 +1,672 @@
|
||||||
|
# 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.js` or create `window._paq` before affirmative consent.
|
||||||
|
- Use tracker endpoint `https://analytics.msvincognito.nl/matomo.php` and site ID `1`.
|
||||||
|
- Call `requireConsent`, `disableCookies`, and `setConsentGiven` before `trackPageView`.
|
||||||
|
- 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 configured `BASE` subpath.
|
||||||
|
- 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 Starlight `head` and 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-effort `window.localStorage`.
|
||||||
|
- Produces: storage key `incognito.analytics-consent.v1` with values `accepted` or `declined`; DOM IDs `incognito-analytics-consent`, `incognito-analytics-accept`, `incognito-analytics-decline`, and `incognito-privacy-settings`; guarded globals `window.__incognitoMatomoConsentController` and `window.__incognitoMatomoInitialized`.
|
||||||
|
- Loads: `https://analytics.msvincognito.nl/matomo.js` with element ID `incognito-matomo-script` only 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`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test tests/matomo-consent.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS with 8 tests and no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit the independently working controller**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.js` and 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`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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"
|
||||||
|
```
|
||||||
Loading…
Add table
Reference in a new issue