fix(web): remove Google Analytics and repoint footer links to the fork #35

Merged
xavierk merged 2 commits from fix/web-remove-analytics into main 2026-11-06 14:28:00 +00:00
9 changed files with 90 additions and 59 deletions
@@ -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([]);
});
});
-13
View File
@@ -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({
/>
</head>
<body>
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-7NBBNJNNQ7"
strategy="lazyOnload"
/>
<Script id="google-analytics" strategy="lazyOnload">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-7NBBNJNNQ7');
`}
</Script>
{children}
</body>
</html>
+4 -11
View File
@@ -141,17 +141,10 @@ export default function OpenSourcePage() {
Free & Open Source Voice Dictation
</h1>
<p className="mb-8 max-w-4xl text-lg text-muted-foreground">
Voculinux is 100% open source under the AGPL-3.0 license, a hard fork of{" "}
<a
href="https://github.com/VocaHQ/vocalinux"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline"
>
Vocalinux (upstream, by VocaHQ)
</a>
. No subscriptions, no vendor lock-in, no hidden costs. Local engines keep
audio on the machine. Remote API is optional.
Voculinux is 100% open source under the AGPL-3.0 license, a hard fork of
Vocalinux (upstream, by VocaHQ). No subscriptions, no vendor lock-in, no
hidden costs. Local engines keep audio on the machine. Remote API is
optional.
</p>
</section>
+4 -8
View File
@@ -986,8 +986,8 @@ export default function HomePage() {
<p className="section-lede">
Voculinux is a hard fork of Vocalinux for Void Linux (glibc,
Wayland-primary under niri). The internal Python module keeps the
upstream name so fixes stay cheap to port by hand; upstream links
below are retained as provenance.
upstream name so fixes stay cheap to port by hand. Upstream is
credited in the footer.
</p>
</div>
<div className="family-grid">
@@ -1010,11 +1010,7 @@ export default function HomePage() {
Linux.
</p>
</div>
<a
className="family-card"
href={UPSTREAM_REPO_URL}
rel="noopener noreferrer"
>
<div className="family-card">
<div className="family-card-top">
<span className="family-platform">
<img
@@ -1032,7 +1028,7 @@ export default function HomePage() {
The upstream Linux dictation app this fork is ported from. Fixes
are taken by hand, one at a time.
</p>
</a>
</div>
<a
className="family-card"
href="https://bongbetic.com"
+8 -9
View File
@@ -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() {
</h2>
<div className="space-y-4 text-muted-foreground">
<p>
voculinux.com loads Google Analytics via the googletagmanager gtag
script for measurement ID{" "}
<code className="rounded bg-muted px-1.5 py-0.5">G-7NBBNJNNQ7</code>{" "}
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.
</p>
<p>
The Bongbetic logos in the header and footer are images served from
bongbetic.com. Loading any page therefore makes a request to that
host.
</p>
</div>
</article>
+5 -4
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useId, useState } from "react";
import Link from "next/link";
import { Code } from "lucide-react";
import { VoculinuxLogo } from "@/components/optimized-image";
import { SOURCE_REPO_URL } from "@/lib/seo";
import { SOURCE_REPO_URL, UPSTREAM_REPO_URL } from "@/lib/seo";
import {
footerGroups,
homeNav,
@@ -250,12 +250,13 @@ export function SiteChrome({
</div>
<div className="footer-base">
<span>
AGPL-3.0. Built in public by{" "}
AGPL-3.0. Based on{" "}
<a
href="https://github.com/jatinkrmalik"
href={UPSTREAM_REPO_URL}
target="_blank"
rel="noopener noreferrer"
>
Jatin K Malik
Vocalinux (upstream, by VocaHQ)
</a>
.
</span>
@@ -134,12 +134,25 @@ describe("fork source and star count (spec16 review)", () => {
expect(chrome).toContain("SOURCE_REPO_URL");
});
it("uses the upstream constant only on labelled upstream surfaces", () => {
it("uses the upstream constant only for the one labelled footer credit", () => {
const chrome = read("src", "components", "site-chrome.tsx");
const nav = read("src", "lib", "site-nav.ts");
expect(chrome).not.toContain("UPSTREAM_REPO_URL");
expect(chrome.match(/href=\{UPSTREAM_REPO_URL\}/g)).toHaveLength(1);
expect(chrome).toContain("Vocalinux (upstream, by VocaHQ)");
expect(nav).not.toContain("UPSTREAM_REPO_URL");
});
it("keeps VocaHQ channels out of the footer social and nav links", () => {
const nav = read("src", "lib", "site-nav.ts");
expect(nav).not.toMatch(/x\.com|discord\.gg|mailto:|vocahq\.com/);
});
it("keeps a single upstream credit link on the about copy and home", () => {
const openSource = readPage("open-source", "page.tsx");
const home = readPage("page.tsx");
expect(openSource).not.toContain("github.com/VocaHQ/vocalinux");
expect(home).not.toContain("href={UPSTREAM_REPO_URL}");
});
});
describe("AUR and privacy honesty (spec16 review)", () => {
-3
View File
@@ -14,9 +14,6 @@ export const SOURCE_REPO_URL = "https://git.bongbetic.com/xavierk/voculinux";
// upstream install script, the AppImage and packages published there, and the
// upstream release history behind the changelog).
export const UPSTREAM_REPO_URL = "https://github.com/VocaHQ/vocalinux";
export const X_URL = "https://x.com/vocahq";
export const DISCORD_URL = "https://discord.gg/t6muquAJbm";
export const EMAIL_URL = "mailto:hello@vocahq.com";
interface PageMetadataOptions {
title: string;
+3 -9
View File
@@ -1,9 +1,4 @@
import {
DISCORD_URL,
EMAIL_URL,
SOURCE_REPO_URL,
X_URL,
} from "@/lib/seo";
import { SOURCE_REPO_URL } from "@/lib/seo";
export type NavLink = {
href: string;
@@ -32,15 +27,14 @@ export const mobileExtraNav: NavLink[] = [
{ href: "/troubleshooting/", label: "Troubleshooting" },
];
// The fork's own channels only. The VocaHQ X account, Discord and mailbox
// belong to upstream, and the fork has none of them, so none are listed.
export const socialLinks: { href: string; label: string; icon: string }[] = [
{ href: X_URL, label: "X", icon: "/brand/social/x.svg" },
{ href: DISCORD_URL, label: "Discord", icon: "/brand/social/discord.svg" },
{
href: SOURCE_REPO_URL,
label: "Voculinux source repository",
icon: "/brand/social/github.svg",
},
{ href: EMAIL_URL, label: "Email hello@vocahq.com", icon: "/brand/social/mail.svg" },
];
export const footerGroups: { title: string; links: NavLink[] }[] = [