diff --git a/web/src/app/__tests__/no-analytics.test.ts b/web/src/app/__tests__/no-analytics.test.ts new file mode 100644 index 0000000..ba89312 --- /dev/null +++ b/web/src/app/__tests__/no-analytics.test.ts @@ -0,0 +1,51 @@ +import fs from 'fs'; +import path from 'path'; + +// Ticket #16: the site loads no analytics. Everything the static export is +// built from (web/src, web/public and the next.config files) is scanned byte +// for byte, so a measurement tag cannot return through a component, an asset +// or a config file. Binary assets are read as latin1 so every byte is covered. + +const WEB_ROOT = process.cwd(); +const THIS_FILE = path.join(WEB_ROOT, 'src', 'app', '__tests__', 'no-analytics.test.ts'); +const TREE_ROOTS = [path.join(WEB_ROOT, 'src'), path.join(WEB_ROOT, 'public')]; + +const MARKERS: [string, RegExp][] = [ + ['googletagmanager', /googletagmanager/], + ['google-analytics', /google-analytics/], + ['gtag(', /gtag\(/], + ['G- measurement ID', /G-[A-Z0-9]{8,}/], +]; + +function scanTargets(): string[] { + const targets: string[] = []; + for (const root of TREE_ROOTS) { + for (const rel of fs.readdirSync(root, { encoding: 'utf8', recursive: true })) { + const full = path.join(root, rel); + if (fs.statSync(full).isFile()) targets.push(full); + } + } + for (const name of fs.readdirSync(WEB_ROOT)) { + if (/^next\.config\./.test(name)) targets.push(path.join(WEB_ROOT, name)); + } + return targets.filter((file) => file !== THIS_FILE); +} + +const sources = scanTargets().map((file) => ({ + file: path.relative(WEB_ROOT, file), + text: fs.readFileSync(file, 'latin1'), +})); + +describe('no analytics on the site (ticket #16)', () => { + it('scans the source, public assets and next.config files', () => { + const names = sources.map((source) => source.file); + expect(names).toEqual( + expect.arrayContaining(['src/app/layout.tsx', 'next.config.js', 'public/_headers']), + ); + }); + + it.each(MARKERS)('contains no %s marker', (_label, marker) => { + const offenders = sources.filter((source) => marker.test(source.text)).map((source) => source.file); + expect(offenders).toEqual([]); + }); +}); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index cc4a807..0d69637 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,7 +1,6 @@ import "@/styles/globals.css"; import React from "react"; import { type Metadata } from "next"; -import Script from "next/script"; import { absoluteUrl, DEFAULT_OG_IMAGE_ALT, @@ -173,18 +172,6 @@ export default function RootLayout({ /> - {children} diff --git a/web/src/app/privacy/page.tsx b/web/src/app/privacy/page.tsx index 6083a49..9902a92 100644 --- a/web/src/app/privacy/page.tsx +++ b/web/src/app/privacy/page.tsx @@ -24,7 +24,7 @@ export default function PrivacyPage() { headline: "Voculinux Privacy Policy", description: "How Voculinux handles voice data: local engines on your machine, optional Remote API to a server you configure.", - dateModified: "2026-09-23", + dateModified: "2026-11-06", author: { "@type": "Person", name: "Jatin K Malik", @@ -158,14 +158,13 @@ export default function PrivacyPage() {

- voculinux.com loads Google Analytics via the googletagmanager gtag - script for measurement ID{" "} - G-7NBBNJNNQ7{" "} - and runs gtag config with no IP anonymization and no cookie flags. - That sends page views and a Google Analytics client identifier (a - cookie) to Google. Google processes IP addresses and device and - browser data. The installed Voculinux app does not send usage - telemetry. + voculinux.com has no analytics, measurement or tracking scripts, + and its code sets no cookies. +

+

+ The Bongbetic logos in the header and footer are images served from + bongbetic.com. Loading any page therefore makes a request to that + host.