Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ export default defineConfig({
],
},
{ label: 'Changelog', slug: 'changelog' },
{ label: 'Privacy', slug: 'privacy' },
{ label: 'Accessibility', slug: 'accessibility' },
],
},
Expand Down
84 changes: 84 additions & 0 deletions docs/site/scripts/analytics.test.mjs
Original file line number Diff line number Diff line change
@@ -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'), `<html><head>${body}</head></html>`);
return root;
}

test('built canonical root contains the exact Registry Docs tracker', async (t) => {
const root = await builtRoot(
t,
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="${DOCS_UMAMI_WEBSITE_ID}" data-domains="${DOCS_UMAMI_DOMAINS}"></script>`,
);
await checkBuiltAnalytics(root, { enabled: true });
});

test('built canonical root rejects a different website identity', async (t) => {
const root = await builtRoot(
t,
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="wrong-id" data-domains="${DOCS_UMAMI_DOMAINS}"></script>`,
);
await assert.rejects(
checkBuiltAnalytics(root, { enabled: true }),
/source-controlled Registry Docs tracker/,
);
});

test('noncanonical builds reject analytics', async (t) => {
const root = await builtRoot(
t,
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="${DOCS_UMAMI_WEBSITE_ID}" data-domains="${DOCS_UMAMI_DOMAINS}"></script>`,
);
await assert.rejects(
checkBuiltAnalytics(root, { enabled: false }),
/must not contain analytics outside the canonical released tree/,
);
});
4 changes: 4 additions & 0 deletions docs/site/scripts/build-archives.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -376,6 +377,7 @@ export async function buildDocsetArchive(docset, {
environment = process.env,
runCommand = run,
applySeo = applyArchiveSeo,
verifyAnalytics = checkBuiltAnalytics,
normalizePagefind = normalizePagefindGzipMetadata,
stageGeneratedArtifacts = stagePinnedGeneratedArtifacts,
allowUnpublishedCandidate = false,
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions docs/site/scripts/build-archives.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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');

Expand All @@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -574,6 +590,7 @@ test('archive output uses pinned generated artifacts and restores current files'
}
},
applySeo: async () => {},
verifyAnalytics: async () => {},
},
);

Expand Down
54 changes: 54 additions & 0 deletions docs/site/scripts/check-built-analytics.mjs
Original file line number Diff line number Diff line change
@@ -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`,
);
}
1 change: 1 addition & 0 deletions docs/site/src/components/RegistryFooter.astro
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const showPageFooter = hasPagination || hasMeta || showFeedback;
<footer class="registry-footer">
<p>Registry stack docs · CC BY 4.0</p>
<nav aria-label="Footer">
<a href={`${base}privacy/`}>Privacy</a>
<a href={`${base}accessibility/`}>Accessibility</a>
{!isArchived && <a href={`${base}llms.txt`}>llms.txt</a>}
</nav>
Expand Down
20 changes: 13 additions & 7 deletions docs/site/src/components/RegistryHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import docsetsManifest from '../data/generated/docsets.json';
import { mdHrefForPath } from '../lib/md-href';
import { isGeneratedApiPath } from '../lib/generated-api-bases.mjs';
import { docsAnalyticsConfig } from '../lib/analytics.mjs';

const activeDocsetId = process.env.DOCS_DOCSET || docsetsManifest.current;
const activeDocset =
Expand Down Expand Up @@ -31,22 +32,27 @@ const is404 = path === '/404/' || path === '/404';
// twin, so they must not advertise a (broken) Markdown alternate link.
const isGeneratedApi = isGeneratedApiPath(path);
const mdHref = mdHrefForPath(Astro.url.pathname, import.meta.env.BASE_URL);
const umamiWebsiteId = isSearchExcluded ? '' : import.meta.env.PUBLIC_UMAMI_WEBSITE_ID?.trim();
const umamiScriptSrc =
import.meta.env.PUBLIC_UMAMI_SCRIPT_SRC?.trim() || 'https://stats.registrystack.org/script.js';
const umamiDomains = import.meta.env.PUBLIC_UMAMI_DOMAINS?.trim() || 'docs.registrystack.org';
const analytics = docsAnalyticsConfig({
// Only the exact released tree promoted to the canonical root is measured.
// The public website ID and defaults are source inputs so release archives
// stay reproducible when the hermetic builder clears deployment variables.
enabled: isReleasedArchiveBuild,
websiteId: import.meta.env.PUBLIC_UMAMI_WEBSITE_ID,
scriptSrc: import.meta.env.PUBLIC_UMAMI_SCRIPT_SRC,
domains: import.meta.env.PUBLIC_UMAMI_DOMAINS,
});
---

{head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
{isSearchExcluded && <meta name="robots" content="noindex,follow" />}
{!is404 && !isGeneratedApi && <link rel="alternate" type="text/markdown" href={mdHref} />}
{
umamiWebsiteId && (
<script is:inline defer src={umamiScriptSrc} data-website-id={umamiWebsiteId} data-domains={umamiDomains}></script>
analytics && (
<script is:inline defer src={analytics.scriptSrc} data-website-id={analytics.websiteId} data-domains={analytics.domains}></script>
)
}
{
umamiWebsiteId && (
analytics && (
<script is:inline>
{`
const docsJourneyTargets = new Map([
Expand Down
78 changes: 78 additions & 0 deletions docs/site/src/content/docs/privacy.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: Privacy
description: Data collected when you use Registry Docs and how Registry Stack uses it.
status: current
owner: registry-docs
source_repos:
- registry-stack
last_reviewed: "2026-08-23"
doc_type: reference
locale: en
standards_referenced: []
---

Registry Docs uses self-hosted, cookie-free analytics to understand which documentation pages
help readers and where the documentation needs attention.
This page describes that processing and the choices available to you.

{/* Evidence: `docs/site/src/lib/analytics.mjs`, `DOCS_UMAMI_SCRIPT_SRC`, fixes the self-hosted
tracker endpoint; `docs/site/src/components/RegistryHead.astro`, `docsAnalyticsConfig()`,
includes that tracker in the canonical released tree; `docs/site/scripts/build-archives.mjs`,
`verifyAnalytics`, enforces the disabled versioned tree. Umami's official FAQ documents
cookie-free collection in the [Umami FAQ](https://docs.umami.is/docs/faq). */}

## Who operates Registry Docs

Aubex Consulting LLC, 28 Geary St, Ste 650 #189, San Francisco, CA 94108,
United States, publishes Registry Stack and operates Registry Docs.
Aubex is the controller responsible for this site.
You can contact Aubex at [contact@registrystack.org](mailto:contact@registrystack.org).

## Analytics data

Registry Docs loads self-hosted Umami analytics from `stats.registrystack.org` only on the
canonical documentation at `docs.registrystack.org/`.
The `/dev/` tree and versioned `/v/<version>/` archives do not load analytics.
Umami records page views, including URL query parameters, referral sources, general browser and
device information, approximate location, and selected outbound-link clicks.
The tracker does not set cookies or use a persistent visitor identifier.
Umami uses the request IP address to estimate location but does not store the address.

{/* Evidence: `docs/site/src/components/RegistryHead.astro`, `docsJourneyTargets`, implements the
selected outbound-link events; `docs/site/src/components/RegistryFooter.astro`, `showFeedback`,
contains the only other Umami call; neither component assigns a custom visitor identity.
Umami's official metric definitions document its page, query, referrer, browser, device,
location, IP-address, and rotating-session behavior in the
[Umami metric definitions](https://docs.umami.is/docs/metric-definitions). */}

Registry Stack uses these records to understand page use, improve navigation, and prioritize
documentation work.
The analytics records are not sold, used for advertising, or shared with advertising networks.

## Hosting data

Registry Docs is hosted on GitHub Pages.
GitHub processes request and server-log data to deliver and protect the site under the
[GitHub Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).

{/* Evidence: `.github/workflows/docs-pages.yml` uploads and deploys the static site through the
Pages deployment. */}

## Legal basis and retention

Where the General Data Protection Regulation or UK General Data Protection Regulation applies,
Aubex relies on legitimate interests to operate aggregate documentation analytics.
Analytics records remain in the self-hosted Umami database while they are useful for improving
Registry Docs and are removed when they are no longer needed for that purpose.

## Your choices and rights

You can block the analytics script with browser controls or a content blocker without losing access
to the documentation.
Email [contact@registrystack.org](mailto:contact@registrystack.org) to request access to,
correction of, deletion of, or restriction of personal data, or to object to its processing.
Umami analytics are not connected to a name or email address, so Aubex may not be able to identify
records associated with a particular visitor.
You can also complain to the data protection supervisory authority in your country.

Effective date: August 23, 2026.
30 changes: 30 additions & 0 deletions docs/site/src/lib/analytics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export const DOCS_UMAMI_WEBSITE_ID = '0a8aa090-83c5-4207-8c90-9fcc1e50bb78';
Comment thread
jeremi marked this conversation as resolved.
export const DOCS_UMAMI_SCRIPT_SRC = 'https://stats.registrystack.org/script.js';
export const DOCS_UMAMI_DOMAINS = 'docs.registrystack.org';

function configuredValue(value, fallback) {
return value?.trim() || fallback;
}

/**
* @param {{
* enabled?: boolean,
* websiteId?: string,
* scriptSrc?: string,
* domains?: string,
* }} [options]
*/
export function docsAnalyticsConfig({
enabled = false,
websiteId,
scriptSrc,
domains,
} = {}) {
if (!enabled) return null;

return {
websiteId: configuredValue(websiteId, DOCS_UMAMI_WEBSITE_ID),
scriptSrc: configuredValue(scriptSrc, DOCS_UMAMI_SCRIPT_SRC),
domains: configuredValue(domains, DOCS_UMAMI_DOMAINS),
};
}