diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 5d674b13b..f0edacd25 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -584,6 +584,7 @@ export default defineConfig({ ], }, { label: 'Changelog', slug: 'changelog' }, + { label: 'Privacy', slug: 'privacy' }, { label: 'Accessibility', slug: 'accessibility' }, ], }, diff --git a/docs/site/scripts/analytics.test.mjs b/docs/site/scripts/analytics.test.mjs new file mode 100644 index 000000000..f3e7d08e1 --- /dev/null +++ b/docs/site/scripts/analytics.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { test } from 'node:test'; + +import { checkBuiltAnalytics } from './check-built-analytics.mjs'; +import { + DOCS_UMAMI_DOMAINS, + DOCS_UMAMI_SCRIPT_SRC, + DOCS_UMAMI_WEBSITE_ID, + docsAnalyticsConfig, +} from '../src/lib/analytics.mjs'; + +test('analytics stays disabled outside the canonical released tree', () => { + assert.equal( + docsAnalyticsConfig({ + enabled: false, + websiteId: 'deployment-id', + scriptSrc: 'https://stats.example.invalid/script.js', + domains: 'example.invalid', + }), + null, + ); +}); + +test('canonical release analytics uses source-controlled defaults', () => { + assert.deepEqual(docsAnalyticsConfig({ enabled: true }), { + websiteId: DOCS_UMAMI_WEBSITE_ID, + scriptSrc: DOCS_UMAMI_SCRIPT_SRC, + domains: DOCS_UMAMI_DOMAINS, + }); + assert.deepEqual( + docsAnalyticsConfig({ + enabled: true, + websiteId: ' ', + scriptSrc: '', + domains: ' ', + }), + { + websiteId: DOCS_UMAMI_WEBSITE_ID, + scriptSrc: DOCS_UMAMI_SCRIPT_SRC, + domains: DOCS_UMAMI_DOMAINS, + }, + ); +}); + +async function builtRoot(t, body) { + const root = await mkdtemp(resolve(tmpdir(), 'registry-docs-analytics-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(root, { recursive: true }); + await writeFile(resolve(root, 'index.html'), `${body}`); + return root; +} + +test('built canonical root contains the exact Registry Docs tracker', async (t) => { + const root = await builtRoot( + t, + ``, + ); + await checkBuiltAnalytics(root, { enabled: true }); +}); + +test('built canonical root rejects a different website identity', async (t) => { + const root = await builtRoot( + t, + ``, + ); + await assert.rejects( + checkBuiltAnalytics(root, { enabled: true }), + /source-controlled Registry Docs tracker/, + ); +}); + +test('noncanonical builds reject analytics', async (t) => { + const root = await builtRoot( + t, + ``, + ); + await assert.rejects( + checkBuiltAnalytics(root, { enabled: false }), + /must not contain analytics outside the canonical released tree/, + ); +}); diff --git a/docs/site/scripts/build-archives.mjs b/docs/site/scripts/build-archives.mjs index d2585c507..6cc6fe1cd 100644 --- a/docs/site/scripts/build-archives.mjs +++ b/docs/site/scripts/build-archives.mjs @@ -12,6 +12,7 @@ import { gunzipSync, } from 'node:zlib'; import { applyArchiveSeo } from './apply-archive-seo.mjs'; +import { checkBuiltAnalytics } from './check-built-analytics.mjs'; import { archiveOutputDirectory, releaseRootOutputDirectory, @@ -376,6 +377,7 @@ export async function buildDocsetArchive(docset, { environment = process.env, runCommand = run, applySeo = applyArchiveSeo, + verifyAnalytics = checkBuiltAnalytics, normalizePagefind = normalizePagefindGzipMetadata, stageGeneratedArtifacts = stagePinnedGeneratedArtifacts, allowUnpublishedCandidate = false, @@ -444,6 +446,8 @@ export async function buildDocsetArchive(docset, { await normalizePagefind(versionOutDir); await applySeo(rootOutDir, { indexable }); await applySeo(versionOutDir, { indexable: false }); + await verifyAnalytics(rootOutDir, { enabled: indexable }); + await verifyAnalytics(versionOutDir, { enabled: false }); } finally { try { await restoreGeneratedArtifacts(); diff --git a/docs/site/scripts/build-archives.test.mjs b/docs/site/scripts/build-archives.test.mjs index 76dd94d97..4bbc6fa6a 100644 --- a/docs/site/scripts/build-archives.test.mjs +++ b/docs/site/scripts/build-archives.test.mjs @@ -365,6 +365,7 @@ test('archived docset builds use isolated generation with release-bound environm const calls = []; const normalizationCalls = []; const seoCalls = []; + const analyticsCalls = []; const environment = { BASE_URL: '/mutable-deployment/', CI: 'false', @@ -404,6 +405,9 @@ test('archived docset builds use isolated generation with release-bound environm applySeo: async (path, options) => { seoCalls.push([path, options]); }, + verifyAnalytics: async (path, options) => { + analyticsCalls.push([path, options]); + }, }); assert.deepEqual( @@ -479,12 +483,17 @@ test('archived docset builds use isolated generation with release-bound environm ], [resolve(root, 'dist/v/1.2.3'), { indexable: false }], ]); + assert.deepEqual(analyticsCalls, [ + [resolve(root, '.release-docsets/v1.2.3/root'), { enabled: false }], + [resolve(root, 'dist/v/1.2.3'), { enabled: false }], + ]); }); test('selected released archive builds at the canonical root with release discovery', async (t) => { const root = await mkdtemp(resolve(tmpdir(), 'registry-docs-released-build-')); t.after(() => rm(root, { recursive: true, force: true })); const calls = []; + const analyticsCalls = []; const rootOutDir = resolve(root, '.release-docsets/v1.2.3/root'); const stalePagefind = resolve(rootOutDir, 'pagefind/stale-index'); @@ -503,6 +512,9 @@ test('selected released archive builds at the canonical root with release discov } }, applySeo: async () => {}, + verifyAnalytics: async (path, options) => { + analyticsCalls.push([path, options]); + }, }); assert.equal(calls.length, 5); @@ -523,6 +535,10 @@ test('selected released archive builds at the canonical root with release discov } assert.equal(calls.at(-1).env.DOCS_BASE, '/v/1.2.3/'); assert.equal(calls.at(-1).env.DOCS_RELEASED_ARCHIVE, ''); + assert.deepEqual(analyticsCalls, [ + [rootOutDir, { enabled: true }], + [resolve(root, 'dist/v/1.2.3'), { enabled: false }], + ]); }); test('archive output uses pinned generated artifacts and restores current files', async (t) => { @@ -574,6 +590,7 @@ test('archive output uses pinned generated artifacts and restores current files' } }, applySeo: async () => {}, + verifyAnalytics: async () => {}, }, ); diff --git a/docs/site/scripts/check-built-analytics.mjs b/docs/site/scripts/check-built-analytics.mjs new file mode 100644 index 000000000..23a74b240 --- /dev/null +++ b/docs/site/scripts/check-built-analytics.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { parse } from 'parse5'; + +import { + DOCS_UMAMI_DOMAINS, + DOCS_UMAMI_SCRIPT_SRC, + DOCS_UMAMI_WEBSITE_ID, +} from '../src/lib/analytics.mjs'; + +function attributes(node) { + return Object.fromEntries((node.attrs ?? []).map(({ name, value }) => [name, value])); +} + +function scriptAttributes(node, found = []) { + if (node.nodeName === 'script') found.push(attributes(node)); + for (const child of node.childNodes ?? []) scriptAttributes(child, found); + return found; +} + +export async function checkBuiltAnalytics(root, { enabled }) { + const indexPath = resolve(root, 'index.html'); + const document = parse(await readFile(indexPath, 'utf8')); + const analyticsScripts = scriptAttributes(document).filter( + (attrs) => attrs.src === DOCS_UMAMI_SCRIPT_SRC || attrs['data-website-id'], + ); + + if (!enabled) { + assert.deepEqual( + analyticsScripts, + [], + `${indexPath} must not contain analytics outside the canonical released tree`, + ); + return; + } + + assert.equal( + analyticsScripts.length, + 1, + `${indexPath} must contain exactly one Umami tracker`, + ); + assert.deepEqual( + analyticsScripts[0], + { + defer: '', + src: DOCS_UMAMI_SCRIPT_SRC, + 'data-website-id': DOCS_UMAMI_WEBSITE_ID, + 'data-domains': DOCS_UMAMI_DOMAINS, + }, + `${indexPath} must contain the source-controlled Registry Docs tracker`, + ); +} diff --git a/docs/site/src/components/RegistryFooter.astro b/docs/site/src/components/RegistryFooter.astro index b4647f296..1cac0dea9 100644 --- a/docs/site/src/components/RegistryFooter.astro +++ b/docs/site/src/components/RegistryFooter.astro @@ -101,6 +101,7 @@ const showPageFooter = hasPagination || hasMeta || showFeedback;