Skip to content

Commit 634eba0

Browse files
committed
feat: include public dev docs in consented journeys
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent 95c9614 commit 634eba0

9 files changed

Lines changed: 194 additions & 86 deletions

File tree

‎docs/site/README.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,21 @@ not depend on unreleased `/dev/` content.
9494

9595
## Optional visit tracking
9696

97-
Canonical released pages offer optional tracking shared with the main website.
97+
Canonical released pages and the public `/dev/` tree offer optional tracking shared
98+
with the main website.
9899
The hidden main-origin bridge owns the version-2 consent choice and native Mautic
99100
identity. Documentation sends minimized public page views and cannot request an
100101
identifier. Rejection, expiry, privacy signals, or a blocked bridge keep optional
101-
tracking off. Development pages, versioned archives, and error pages do not take
102-
part. Tracking acceptance does not subscribe anyone to marketing email.
102+
tracking off. Ordinary local builds, versioned archives, and error pages do not take
103+
part. Aggregate Umami analytics remains limited to canonical released pages.
104+
Tracking acceptance does not subscribe anyone to marketing email.
103105

104106
The website and docs copies of `src/lib/shared-tracking-client.mjs` and
105107
`src/lib/tracking-protocol.mjs` must stay identical. Coordinate changes with the
106-
website's bridge before publishing a new docs-bearing release. Merging docs
107-
source changes alone cannot change the sealed canonical-root release asset.
108+
website's bridge before publishing a new docs-bearing release. The Pages workflow
109+
updates `/dev/` when docs source changes merge. It cannot change the sealed
110+
canonical-root release asset. The `build:dev` command checks the built
111+
tracking controls and 404 output, requiring shared tracking and forbidding Umami.
108112

109113
Run the docs tests and build checks, then the website repository's
110114
`scripts/check-marketing-tracking.mjs` with `DOCS_TRACKING_DIST` set to the

‎docs/site/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"scripts": {
1010
"dev": "npm run generate && astro dev",
1111
"build": "npm run generate && astro check && astro build && node scripts/apply-archive-seo.mjs dist",
12-
"build:dev": "npm run generate && DOCS_DOCSET=latest DOCS_BASE=/dev/ astro check && DOCS_DOCSET=latest DOCS_BASE=/dev/ astro build --outDir dist/dev && node scripts/apply-archive-seo.mjs dist/dev",
12+
"build:dev": "npm run generate && DOCS_DOCSET=latest DOCS_BASE=/dev/ astro check && DOCS_DOCSET=latest DOCS_BASE=/dev/ astro build --outDir dist/dev && node scripts/apply-archive-seo.mjs dist/dev && node scripts/check-built-analytics.mjs dist/dev --profile development",
1313
"build:archive": "node scripts/build-archive.mjs",
1414
"build:archives": "node scripts/build-archives.mjs",
1515
"build:production-search": "node scripts/build-production-search.mjs",

‎docs/site/scripts/analytics.test.mjs‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from 'node:assert/strict';
2+
import { execFileSync } from 'node:child_process';
23
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
34
import { tmpdir } from 'node:os';
45
import { resolve } from 'node:path';
@@ -117,6 +118,54 @@ test('noncanonical builds reject shared tracking controls', async (t) => {
117118
const root = await builtRoot(t, '', trackingPreferences);
118119
await assert.rejects(
119120
checkBuiltAnalytics(root, { enabled: false }),
120-
/must not contain shared tracking outside the canonical released tree/,
121+
/must not contain shared tracking in this build/,
121122
);
122123
});
124+
125+
const developmentTracking = { enabled: false, sharedTrackingEnabled: true };
126+
127+
test('public development output requires shared controls while keeping Umami off', async (t) => {
128+
const root = await builtRoot(t, '', trackingPreferences);
129+
await checkBuiltAnalytics(root, developmentTracking);
130+
});
131+
132+
test('public development output rejects Umami', async (t) => {
133+
const root = await builtRoot(
134+
t,
135+
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="${DOCS_UMAMI_WEBSITE_ID}"></script>`,
136+
trackingPreferences,
137+
);
138+
await assert.rejects(checkBuiltAnalytics(root, developmentTracking), /must not contain analytics/);
139+
});
140+
141+
test('public development output rejects missing, duplicate and untrusted shared controls', async (t) => {
142+
for (const body of [
143+
'',
144+
trackingPreferences + trackingPreferences,
145+
'<aside data-registry-shared-tracking data-shared-tracking-bridge-url="https://other.example.org/"></aside>',
146+
]) {
147+
const root = await builtRoot(t, '', body);
148+
await assert.rejects(checkBuiltAnalytics(root, developmentTracking), /shared tracking/);
149+
}
150+
});
151+
152+
test('public development output rejects a direct native tracker', async (t) => {
153+
const root = await builtRoot(t, '<script src="https://mautic.example.org/mtc.js"></script>', trackingPreferences);
154+
await assert.rejects(checkBuiltAnalytics(root, developmentTracking), /must not load Mautic directly/);
155+
});
156+
157+
test('error output cannot offer shared tracking even in an eligible tree', async (t) => {
158+
const root = await builtRoot(t, '', trackingPreferences);
159+
await writeFile(resolve(root, '404.html'), '<html><body></body></html>');
160+
await checkBuiltAnalytics(root, developmentTracking);
161+
await writeFile(resolve(root, '404.html'), `<html><body>${trackingPreferences}</body></html>`);
162+
await assert.rejects(checkBuiltAnalytics(root, developmentTracking), /404.html.*must not contain shared tracking/);
163+
});
164+
165+
test('development build command verifies the built tracking profile', async (t) => {
166+
const root = await builtRoot(t, '', trackingPreferences);
167+
const checker = new URL('./check-built-analytics.mjs', import.meta.url);
168+
execFileSync(process.execPath, [checker.pathname, root, '--profile', 'development']);
169+
await writeFile(resolve(root, 'index.html'), '<html><body></body></html>');
170+
assert.throws(() => execFileSync(process.execPath, [checker.pathname, root, '--profile', 'development'], { stdio: 'pipe' }));
171+
});
Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from 'node:assert/strict';
22
import { readFile } from 'node:fs/promises';
33
import { resolve } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
45

56
import { parse } from 'parse5';
67

@@ -28,9 +29,8 @@ function elementAttributes(node, attribute, found = []) {
2829
return found;
2930
}
3031

31-
export async function checkBuiltAnalytics(root, { enabled }) {
32-
const indexPath = resolve(root, 'index.html');
33-
const document = parse(await readFile(indexPath, 'utf8'));
32+
function checkPage(html, indexPath, { enabled, sharedTrackingEnabled }) {
33+
const document = parse(html);
3434
const analyticsScripts = scriptAttributes(document).filter(
3535
(attrs) => attrs.src === DOCS_UMAMI_SCRIPT_SRC || attrs['data-website-id'],
3636
);
@@ -48,43 +48,75 @@ export async function checkBuiltAnalytics(root, { enabled }) {
4848
`${indexPath} must not load Mautic directly`,
4949
);
5050

51-
if (!enabled) {
51+
if (enabled) {
52+
assert.equal(
53+
analyticsScripts.length,
54+
1,
55+
`${indexPath} must contain exactly one Umami tracker`,
56+
);
57+
assert.deepEqual(
58+
analyticsScripts[0],
59+
{
60+
defer: '',
61+
src: DOCS_UMAMI_SCRIPT_SRC,
62+
'data-website-id': DOCS_UMAMI_WEBSITE_ID,
63+
'data-domains': DOCS_UMAMI_DOMAINS,
64+
},
65+
`${indexPath} must contain the source-controlled Registry Docs tracker`,
66+
);
67+
} else {
5268
assert.deepEqual(
5369
analyticsScripts,
5470
[],
5571
`${indexPath} must not contain analytics outside the canonical released tree`,
5672
);
73+
}
74+
75+
if (sharedTrackingEnabled) {
76+
assert.equal(
77+
sharedTrackingControls.length,
78+
1,
79+
`${indexPath} must contain exactly one shared tracking control`,
80+
);
81+
assert.equal(
82+
sharedTrackingControls[0]['data-shared-tracking-bridge-url'],
83+
SHARED_TRACKING_BRIDGE_URL,
84+
`${indexPath} must contain the source-controlled shared tracking bridge`,
85+
);
86+
} else {
5787
assert.deepEqual(
5888
sharedTrackingControls,
5989
[],
60-
`${indexPath} must not contain shared tracking outside the canonical released tree`,
90+
`${indexPath} must not contain shared tracking in this build`,
6191
);
62-
return;
6392
}
93+
}
6494

65-
assert.equal(
66-
analyticsScripts.length,
67-
1,
68-
`${indexPath} must contain exactly one Umami tracker`,
69-
);
70-
assert.deepEqual(
71-
analyticsScripts[0],
72-
{
73-
defer: '',
74-
src: DOCS_UMAMI_SCRIPT_SRC,
75-
'data-website-id': DOCS_UMAMI_WEBSITE_ID,
76-
'data-domains': DOCS_UMAMI_DOMAINS,
77-
},
78-
`${indexPath} must contain the source-controlled Registry Docs tracker`,
79-
);
80-
assert.equal(
81-
sharedTrackingControls.length,
82-
1,
83-
`${indexPath} must contain exactly one shared tracking control`,
84-
);
85-
assert.equal(
86-
sharedTrackingControls[0]['data-shared-tracking-bridge-url'],
87-
SHARED_TRACKING_BRIDGE_URL,
88-
`${indexPath} must contain the source-controlled shared tracking bridge`,
89-
);
95+
export async function checkBuiltAnalytics(root, { enabled, sharedTrackingEnabled = enabled }) {
96+
const indexPath = resolve(root, 'index.html');
97+
checkPage(await readFile(indexPath, 'utf8'), indexPath, { enabled, sharedTrackingEnabled });
98+
99+
const notFoundPath = resolve(root, '404.html');
100+
let notFound;
101+
try {
102+
notFound = await readFile(notFoundPath, 'utf8');
103+
} catch (error) {
104+
if (error.code !== 'ENOENT') throw error;
105+
}
106+
if (notFound !== undefined) {
107+
checkPage(notFound, notFoundPath, { enabled, sharedTrackingEnabled: false });
108+
}
109+
}
110+
111+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
112+
const [root, option, profile] = process.argv.slice(2);
113+
const profiles = {
114+
development: { enabled: false, sharedTrackingEnabled: true },
115+
canonical: { enabled: true, sharedTrackingEnabled: true },
116+
excluded: { enabled: false, sharedTrackingEnabled: false },
117+
};
118+
if (process.argv.length !== 5 || !root || option !== '--profile' || !Object.hasOwn(profiles, profile)) {
119+
throw new Error('usage: check-built-analytics.mjs <root> --profile development|canonical|excluded');
120+
}
121+
await checkBuiltAnalytics(root, profiles[profile]);
90122
}

‎docs/site/scripts/shared-tracking.test.mjs‎

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,46 @@ const footerUrl = new URL('src/components/RegistryFooter.astro', root);
1111
const headUrl = new URL('src/components/RegistryHead.astro', root);
1212
const privacyUrl = new URL('src/content/docs/privacy.mdx', root);
1313

14-
test('shared tracking is enabled only for canonical released documentation pages', () => {
15-
assert.equal(
16-
docsSharedTrackingEnabled({ isReleasedArchiveBuild: true, base: '/', path: '/' }),
17-
true,
18-
);
19-
assert.equal(
20-
docsSharedTrackingEnabled({
21-
isReleasedArchiveBuild: true,
22-
base: '/',
23-
path: '/products/base-registry/',
24-
}),
25-
true,
26-
);
27-
assert.equal(
28-
docsSharedTrackingEnabled({ isReleasedArchiveBuild: false, base: '/dev/', path: '/' }),
29-
false,
30-
);
31-
assert.equal(
32-
docsSharedTrackingEnabled({
33-
isReleasedArchiveBuild: true,
34-
base: '/v/0.43.0/',
35-
path: '/',
14+
test('shared tracking is enabled for released-root and current public development pages', () => {
15+
for (const build of [
16+
{ isReleasedArchiveBuild: true, base: '/' },
17+
{ isCurrentDocsetBuild: true, base: '/dev/' },
18+
]) {
19+
for (const path of ['/', '/products/base-registry/']) {
20+
assert.equal(docsSharedTrackingEnabled({ ...build, path }), true);
21+
}
22+
for (const path of ['/404', '/404/']) {
23+
assert.equal(docsSharedTrackingEnabled({ ...build, path }), false);
24+
}
25+
}
26+
});
27+
28+
test('local, historical and arbitrary-base builds do not offer shared tracking', () => {
29+
for (const build of [
30+
{ isCurrentDocsetBuild: true, base: '/' },
31+
{ isReleasedArchiveBuild: false, base: '/dev/' },
32+
{ isReleasedArchiveBuild: true, base: '/dev/' },
33+
{ isCurrentDocsetBuild: true, base: '/preview/' },
34+
{ isReleasedArchiveBuild: true, base: '/v/0.43.0/' },
35+
{ isCurrentDocsetBuild: true, base: '/v/0.43.0/' },
36+
]) {
37+
assert.equal(docsSharedTrackingEnabled({ ...build, path: '/' }), false);
38+
}
39+
});
40+
41+
test('development page views retain the public development path without query or fragment data', () => {
42+
assert.deepEqual(
43+
buildMauticPageview({
44+
canonicalUrl: 'https://docs.registrystack.org/dev/start/concepts/?private=yes#record',
45+
currentUrl: 'https://docs.registrystack.org/dev/start/concepts/?email=person%40example.org#record',
46+
pageTitle: 'How the products fit together',
47+
referrer: 'https://registrystack.org/starters/?email=person%40example.org',
3648
}),
37-
false,
38-
);
39-
assert.equal(
40-
docsSharedTrackingEnabled({ isReleasedArchiveBuild: true, base: '/', path: '/404/' }),
41-
false,
42-
);
43-
assert.equal(
44-
docsSharedTrackingEnabled({ isReleasedArchiveBuild: true, base: '/', path: '/404' }),
45-
false,
49+
{
50+
page_url: 'https://docs.registrystack.org/dev/start/concepts/',
51+
page_title: 'How the products fit together',
52+
page_referrer: 'https://registrystack.org',
53+
},
4654
);
4755
});
4856

@@ -98,6 +106,9 @@ test('tracking controls use the shared bridge and remain available after failure
98106
assert.match(component, /new URL\(window\.location\.pathname, canonicalOrigin\)/);
99107
assert.match(component, /Accept optional cookies/);
100108
assert.match(component, /Reject optional cookies/);
109+
assert.match(component, /href=\{privacyUrl\}/);
110+
assert.doesNotMatch(component, /href="\/privacy\/"/);
111+
assert.doesNotMatch(component, /client\.associate/);
101112
assert.match(component, /main Registry Stack website and this\s+documentation/i);
102113
assert.match(component, /data-tracking-error[^>]*role="alert"/);
103114
assert.match(component, /client\.pageview\(pageview\)/);
@@ -114,8 +125,10 @@ test('tracking controls use the shared bridge and remain available after failure
114125

115126
assert.match(footer, /<RegistryTrackingPreferences/);
116127
assert.match(footer, /canonicalOrigin=\{canonicalOrigin\}/);
117-
assert.match(footer, /route\.entry\.id === '404'/);
118-
assert.match(footer, /route\.entry\.id\.endsWith\('\/404'\)/);
128+
assert.match(footer, /privacyUrl=\{`\$\{base\}privacy\/`\}/);
129+
assert.match(footer, /isCurrentDocsetBuild/);
130+
assert.match(footer, /route\.entry\?\.id === '404'/);
131+
assert.match(footer, /route\.entry\?\.id\.endsWith\('\/404'\)/);
119132
assert.match(footer, /path: '\/'/);
120133
assert.match(footer, /data-tracking-preferences-open/);
121134
assert.match(footer, /data-tracking-preferences-open hidden/);
@@ -133,5 +146,8 @@ test('tracking controls use the shared bridge and remain available after failure
133146
assert.match(privacy, /tracking bridge/i);
134147
assert.match(privacy, /six months/i);
135148
assert.match(privacy, /Do Not Track and Global Privacy Control/i);
136-
assert.match(privacy, /\/dev\/[\s\S]*\/v\/<version>\/[\s\S]*do not load/i);
149+
assert.match(privacy, /\/dev\/[\s\S]*\/v\/<version>\/[\s\S]*do not load Umami/i);
150+
assert.match(privacy, /optional tracking is available on[\s\S]*\/dev\//i);
151+
assert.match(privacy, /versioned archives and error pages do not offer optional tracking/i);
152+
assert.match(privacy, /does not send[\s\S]*marketing email/i);
137153
});

‎docs/site/src/components/RegistryFooter.astro‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ const hasPagination = Boolean(prev || next);
3131
3232
const pageTitle = entryData?.title ?? 'this page';
3333
const pagePath = Astro.url.pathname;
34-
const isNotFound = route.entry.id === '404' || route.entry.id.endsWith('/404');
34+
const isNotFound = route.entry?.id === '404' || route.entry?.id.endsWith('/404');
3535
const showSharedTracking =
3636
!isNotFound &&
3737
docsSharedTrackingEnabled({
3838
isReleasedArchiveBuild,
39+
isCurrentDocsetBuild: activeDocset?.id === docsetsManifest.current,
3940
base,
4041
path: '/',
4142
});
@@ -127,7 +128,7 @@ const showPageFooter = hasPagination || hasMeta || showFeedback;
127128
</nav>
128129
</footer>
129130

130-
{showSharedTracking && <RegistryTrackingPreferences canonicalOrigin={canonicalOrigin} />}
131+
{showSharedTracking && <RegistryTrackingPreferences canonicalOrigin={canonicalOrigin} privacyUrl={`${base}privacy/`} />}
131132

132133
<script>
133134
// Wire the per-page "Was this helpful?" controls on each full page load.

‎docs/site/src/components/RegistryTrackingPreferences.astro‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { SHARED_TRACKING_BRIDGE_URL } from '../lib/tracking-protocol.mjs';
33
44
interface Props {
55
canonicalOrigin: string;
6+
privacyUrl: string;
67
}
78
8-
const { canonicalOrigin } = Astro.props;
9+
const { canonicalOrigin, privacyUrl } = Astro.props;
910
---
1011

1112
<aside
@@ -21,7 +22,7 @@ const { canonicalOrigin } = Astro.props;
2122
<p>
2223
We use optional cookies to understand how you use the main Registry Stack website and this
2324
documentation. If you contact us, we may link this activity to your enquiry. You can change
24-
your choice at any time. <a href="/privacy/">Read the privacy policy</a>.
25+
your choice at any time. <a href={privacyUrl}>Read the privacy policy</a>.
2526
</p>
2627
<p data-tracking-signal hidden>
2728
Your browser asks sites not to track you, so optional tracking stays off.

0 commit comments

Comments
 (0)