@haystackeditor/cli 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
- package/dist/assets/capture/release.json +4 -0
- package/dist/assets/telemetry/runtime.cjs +829 -1254
- package/dist/capture/adapters/client-routes.js +383 -0
- package/dist/capture/adapters/django.js +134 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +74 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +494 -0
- package/dist/capture/adapters/nuxt.js +199 -0
- package/dist/capture/adapters/rails.js +178 -0
- package/dist/capture/adapters/react-router.js +439 -0
- package/dist/capture/adapters/sveltekit.js +109 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +135 -0
- package/dist/capture/app-config.js +107 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +332 -0
- package/dist/capture/html.js +74 -0
- package/dist/capture/js-ast.js +400 -0
- package/dist/capture/manifest.js +95 -0
- package/dist/capture/project.js +177 -0
- package/dist/capture/route-pattern.js +119 -0
- package/dist/capture/script-release.js +47 -0
- package/dist/capture/tag.js +74 -0
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +56 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +86 -0
- package/dist/commands/init-capture.js +426 -0
- package/dist/commands/init-telemetry.js +1028 -0
- package/dist/commands/init.js +78 -5
- package/dist/commands/server-telemetry-contract.d.ts +66 -0
- package/dist/commands/server-telemetry-contract.js +127 -0
- package/dist/commands/telemetry-token.js +238 -0
- package/dist/commands/telemetry.d.ts +161 -8
- package/dist/commands/telemetry.js +940 -158
- package/dist/commands/verify-onboarding.js +21 -1
- package/dist/commands/verify.js +56 -9
- package/dist/index.js +85 -6
- package/dist/schema.js +2 -2
- package/dist/telemetry/next-loader.cjs +66 -9
- package/dist/telemetry/next.d.ts +11 -3
- package/dist/telemetry/next.js +95 -15
- package/dist/telemetry/typed-source.d.ts +47 -0
- package/dist/telemetry/typed-source.js +379 -0
- package/package.json +4 -2
- package/schemas/init.v1.json +63 -4
- package/schemas/pre-verify.v1.json +60 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite single-page apps and static HTML sites (CAPTURE-V1 rule 8b).
|
|
3
|
+
*
|
|
4
|
+
* Vite: the tag goes into <head> of the app's one index.html (an async classic script, which Vite leaves alone because
|
|
5
|
+
* the file is in publicDir); the files go into publicDir, which Vite copies to the build output. Routes come from the
|
|
6
|
+
* app's router (React Router library mode or Vue Router, client-routes.ts); an app with no router is one page, `/`; a
|
|
7
|
+
* router init cannot read is a manual step. Chunks: the JS of build.outDir. A base other than `/`, several HTML entry
|
|
8
|
+
* points, or a library build are manual steps (the manifest is published at the origin root, and the tag needs one
|
|
9
|
+
* integration point).
|
|
10
|
+
*
|
|
11
|
+
* Static HTML: a directory served as is with exactly one HTML page (index.html) gets the tag and the files beside it, and
|
|
12
|
+
* init writes the manifest itself (one route, no build).
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join, posix } from 'node:path';
|
|
16
|
+
import { withHeadTags } from '../html.js';
|
|
17
|
+
import { declaredRange, repoPath, resolveVersion, toPosix, appFile } from '../project.js';
|
|
18
|
+
import { htmlTags } from '../tag.js';
|
|
19
|
+
import { reactRouterLibraryRoutes, vueRouterRoutes } from './client-routes.js';
|
|
20
|
+
import { chunkIds, configFile, configValue, filesUnder, isDirectory, isFile } from './files.js';
|
|
21
|
+
const CONFIG_NAMES = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.mts', 'vite.config.cjs', 'vite.config.cts'];
|
|
22
|
+
/** Routers whose routes live in generated code or conventions init does not read. */
|
|
23
|
+
const UNREAD_ROUTERS = ['@tanstack/react-router', '@tanstack/router', 'wouter', 'preact-router', '@solidjs/router', 'svelte-routing',
|
|
24
|
+
'svelte-spa-router', '@reach/router', 'react-location'];
|
|
25
|
+
function htmlEdit(file, tags, where) {
|
|
26
|
+
const code = readFileSync(file, 'utf8');
|
|
27
|
+
const edit = withHeadTags(code, htmlTags(tags));
|
|
28
|
+
return 'manual' in edit ? { manual: `${where}: ${edit.manual}` } : [{ target: file, content: edit.content }];
|
|
29
|
+
}
|
|
30
|
+
export async function detectVite(ctx) {
|
|
31
|
+
const version = resolveVersion(ctx, 'vite');
|
|
32
|
+
if (!version)
|
|
33
|
+
return null;
|
|
34
|
+
const config = configFile(ctx.appDir, CONFIG_NAMES);
|
|
35
|
+
const where = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'vite.config.*');
|
|
36
|
+
// A library build is no app; a config init cannot read may or may not be one, so it is a manual step.
|
|
37
|
+
const library = configValue(config, ['build', 'lib']);
|
|
38
|
+
if (library.state === 'value')
|
|
39
|
+
return null;
|
|
40
|
+
const blocked = library.state === 'unreadable' ? [`${where} cannot be read to tell whether it builds an app or a library`] : [];
|
|
41
|
+
const readString = (path, fallback) => {
|
|
42
|
+
const value = configValue(config, path);
|
|
43
|
+
if (value.state === 'unreadable') {
|
|
44
|
+
blocked.push(`${where} sets ${path.join('.')} in a way init cannot read`);
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
if (value.state === 'value' && typeof value.value !== 'string') {
|
|
48
|
+
blocked.push(`${where} sets ${path.join('.')} to something other than a path`);
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
return value.state === 'value' ? value.value : fallback;
|
|
52
|
+
};
|
|
53
|
+
const root = readString(['root'], '.');
|
|
54
|
+
const index = join(ctx.appDir, root, 'index.html');
|
|
55
|
+
if (!isFile(index))
|
|
56
|
+
return null;
|
|
57
|
+
const base = readString(['base'], '/');
|
|
58
|
+
const publicDir = readString(['publicDir'], 'public');
|
|
59
|
+
const outDir = readString(['build', 'outDir'], 'dist');
|
|
60
|
+
if (base !== '/' && base !== '')
|
|
61
|
+
blocked.push(`${where} sets base ${base}: the route manifest must be published at the origin root, outside it`);
|
|
62
|
+
const input = configValue(config, ['build', 'rollupOptions', 'input']);
|
|
63
|
+
if (input.state === 'unreadable' || (input.state === 'value' && typeof input.value === 'object' && input.value !== null
|
|
64
|
+
&& Object.keys(input.value).length > 1)) {
|
|
65
|
+
blocked.push(`${where} builds several HTML entry points (build.rollupOptions.input), each needing the tag`);
|
|
66
|
+
}
|
|
67
|
+
if (version.major === null || version.major < 2 || version.major > 8)
|
|
68
|
+
blocked.push(`init knows Vite 2–8, and this app's version is ${version.version} (${version.from})`);
|
|
69
|
+
const srcDir = isDirectory(join(ctx.appDir, root, 'src')) ? join(ctx.appDir, root, 'src') : join(ctx.appDir, root);
|
|
70
|
+
const reactRouter = declaredRange(ctx.pkg, 'react-router-dom') !== null || declaredRange(ctx.pkg, 'react-router') !== null;
|
|
71
|
+
const vueRouter = declaredRange(ctx.pkg, 'vue-router') !== null;
|
|
72
|
+
const unread = UNREAD_ROUTERS.filter(name => declaredRange(ctx.pkg, name) !== null);
|
|
73
|
+
const router = reactRouter ? 'React Router' : vueRouter ? 'Vue Router' : unread[0] ?? null;
|
|
74
|
+
const indexPath = repoPath(ctx, index);
|
|
75
|
+
const outPath = toPosix(posix.join(toPosix(root), toPosix(outDir)));
|
|
76
|
+
const scripts = ctx.pkg?.scripts ?? {};
|
|
77
|
+
return {
|
|
78
|
+
id: 'vite',
|
|
79
|
+
label: `Vite ${version.version}${router ? ` with ${router}` : ''}`,
|
|
80
|
+
manifestAdapter: `vite@${version.version}`,
|
|
81
|
+
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
82
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
83
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
84
|
+
fromBuild: false,
|
|
85
|
+
rewriting: null,
|
|
86
|
+
publishNote: null,
|
|
87
|
+
buildPublishDirs: () => [],
|
|
88
|
+
buildNotes: () => [],
|
|
89
|
+
looked: [indexPath, where, repoPath(ctx, srcDir)],
|
|
90
|
+
staticDir: toPosix(posix.join(toPosix(root), toPosix(publicDir))),
|
|
91
|
+
integrate: async (tags) => htmlEdit(index, tags, indexPath),
|
|
92
|
+
manualTag: tags => [`In ${indexPath}, inside <head>:`, ...htmlTags(tags)],
|
|
93
|
+
build: typeof scripts.build === 'string' ? { kind: 'package-script', script: 'build', phase: 'after' }
|
|
94
|
+
: { manual: `${appFile(ctx, 'package.json')} has no build script to publish the route manifest after` },
|
|
95
|
+
async routes() {
|
|
96
|
+
if (unread.length > 0 && !reactRouter && !vueRouter)
|
|
97
|
+
return { manual: `the app routes with ${unread.join(', ')}, whose routes init cannot read` };
|
|
98
|
+
if (reactRouter)
|
|
99
|
+
return reactRouterLibraryRoutes(ctx, srcDir);
|
|
100
|
+
if (vueRouter)
|
|
101
|
+
return vueRouterRoutes(ctx, srcDir, base === '' ? '/' : base);
|
|
102
|
+
return [{ match: '/', source: indexPath }];
|
|
103
|
+
},
|
|
104
|
+
chunks: () => chunkIds(join(ctx.appDir, root, outDir), '/').filter(id => !id.startsWith('/_haystack/')),
|
|
105
|
+
publishDirs: [outPath],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export async function detectStaticHtml(ctx) {
|
|
109
|
+
const index = join(ctx.appDir, 'index.html');
|
|
110
|
+
if (!isFile(index))
|
|
111
|
+
return null;
|
|
112
|
+
const pages = filesUnder(ctx.appDir).filter(file => file.endsWith('.html') && !file.startsWith('_haystack/'));
|
|
113
|
+
const indexPath = repoPath(ctx, index);
|
|
114
|
+
return {
|
|
115
|
+
id: 'static-html',
|
|
116
|
+
label: 'a static HTML site',
|
|
117
|
+
manifestAdapter: 'static-html',
|
|
118
|
+
blocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
|
|
119
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
120
|
+
manifestBlocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
|
|
121
|
+
fromBuild: false,
|
|
122
|
+
rewriting: null,
|
|
123
|
+
publishNote: null,
|
|
124
|
+
buildPublishDirs: () => [],
|
|
125
|
+
buildNotes: () => [],
|
|
126
|
+
looked: [indexPath],
|
|
127
|
+
staticDir: '.',
|
|
128
|
+
integrate: async (tags) => htmlEdit(index, tags, indexPath),
|
|
129
|
+
manualTag: tags => ['In every HTML page, inside <head>:', ...htmlTags(tags)],
|
|
130
|
+
build: { kind: 'init-writes' },
|
|
131
|
+
routes: async () => [{ match: '/', source: indexPath }],
|
|
132
|
+
chunks: () => [],
|
|
133
|
+
publishDirs: ['.'],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.haystack/capture.json` in the app's directory (CAPTURE-V1 rule 8b(ii)): the application's identity, committed by
|
|
3
|
+
* init and read by the build step that publishes the route manifest. It lives beside the app's package manifest, not at
|
|
4
|
+
* the repository root, so a build that sees only the app's directory (a Docker context, a monorepo deploy) still finds
|
|
5
|
+
* it, and a repository with several apps keeps one per app.
|
|
6
|
+
*
|
|
7
|
+
* The contract names key, repository id, application id and adapter. Init also keeps:
|
|
8
|
+
* app the app's directory in the repository ('.' at the root), because manifest sources are repository paths and
|
|
9
|
+
* a build often has no .git to find the root from;
|
|
10
|
+
* origins the production origins the user gave (public, and registered with the key), and
|
|
11
|
+
* consent the user's consent answer, so a rerun without flags (a newer script version) keeps both instead of asking;
|
|
12
|
+
* publish the directories (relative to the app) the build step publishes the manifest into, so haystack-capture-step,
|
|
13
|
+
* which loads nothing of the CLI, can withdraw a stale current.json there when the manifest was not made.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { CAPTURE_KEY_PATTERN, CAPTURE_STATUS_PATH, CAPTURE_VERSION } from '../commands/capture-contract.js';
|
|
18
|
+
import { haystackFetch, haystackJson } from '../utils/haystack-api.js';
|
|
19
|
+
export const CAPTURE_CONFIG_PATH = '.haystack/capture.json';
|
|
20
|
+
export function captureConfigFile(appDir) {
|
|
21
|
+
return join(appDir, CAPTURE_CONFIG_PATH);
|
|
22
|
+
}
|
|
23
|
+
/** The committed identity, or null when init has not set capture up here. Throws on a file that is not one. */
|
|
24
|
+
export function readCaptureConfig(appDir) {
|
|
25
|
+
const file = captureConfigFile(appDir);
|
|
26
|
+
if (!existsSync(file))
|
|
27
|
+
return null;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new Error(`${CAPTURE_CONFIG_PATH} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
|
|
34
|
+
}
|
|
35
|
+
const strings = ['key', 'repositoryId', 'applicationId', 'app', 'consent'];
|
|
36
|
+
if (parsed.version !== CAPTURE_VERSION || strings.some(name => typeof parsed[name] !== 'string')
|
|
37
|
+
|| !CAPTURE_KEY_PATTERN.test(parsed.key) || !(parsed.adapter === null || typeof parsed.adapter === 'string')
|
|
38
|
+
|| !Array.isArray(parsed.origins) || !parsed.origins.every(origin => typeof origin === 'string')
|
|
39
|
+
|| !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
|
|
40
|
+
|| !(parsed.urlRewrites === undefined || parsed.urlRewrites === null || typeof parsed.urlRewrites === 'string')) {
|
|
41
|
+
throw new Error(`${CAPTURE_CONFIG_PATH} is not a capture configuration init wrote; remove it and run \`haystack init\` again.`);
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
export function formatCaptureConfig(config) {
|
|
46
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
47
|
+
}
|
|
48
|
+
/** Production origins exactly as the user gave them, one per --origin (rule 8b: never inferred): https, no path, no
|
|
49
|
+
* wildcard, no local host. Throws with the reason for the first one that is not. */
|
|
50
|
+
export function parseOrigins(given) {
|
|
51
|
+
const origins = given.map(origin => origin.trim()).filter(origin => origin !== '');
|
|
52
|
+
if (origins.length === 0)
|
|
53
|
+
throw new Error('--origin needs a production origin, e.g. --origin https://app.example.com.');
|
|
54
|
+
return [...new Set(origins.map(origin => {
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(origin);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(`--origin ${origin} is not a URL; give the production origin, e.g. https://app.example.com.`);
|
|
61
|
+
}
|
|
62
|
+
if (origin.includes('*'))
|
|
63
|
+
throw new Error(`--origin ${origin} has a wildcard; name each production origin.`);
|
|
64
|
+
if (url.protocol !== 'https:')
|
|
65
|
+
throw new Error(`--origin ${origin} is not https; production origins are https.`);
|
|
66
|
+
if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) {
|
|
67
|
+
throw new Error(`--origin ${origin} is not an origin (scheme and host only, no path).`);
|
|
68
|
+
}
|
|
69
|
+
const host = url.hostname;
|
|
70
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host === '127.0.0.1' || host === '[::1]') {
|
|
71
|
+
throw new Error(`--origin ${origin} is a local address; production origins only (CAPTURE-V1 rule 5).`);
|
|
72
|
+
}
|
|
73
|
+
return url.origin;
|
|
74
|
+
}))].sort();
|
|
75
|
+
}
|
|
76
|
+
/** Registers the application and its production origins (rule 8b): POST /api/agent/cloud-verifier/capture/key, a
|
|
77
|
+
* repository member's call, idempotent (the same application gets the same key; new origins replace the old). */
|
|
78
|
+
export async function registerCaptureApp(token, repository, app, origins) {
|
|
79
|
+
const query = new URLSearchParams({ repository, app });
|
|
80
|
+
const response = await haystackJson(`/api/agent/cloud-verifier/capture/key?${query}`, token, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ origins }),
|
|
84
|
+
});
|
|
85
|
+
if (typeof response.key !== 'string' || !CAPTURE_KEY_PATTERN.test(response.key)
|
|
86
|
+
|| typeof response.repositoryId !== 'string' || response.repositoryId === ''
|
|
87
|
+
|| typeof response.applicationId !== 'string' || response.applicationId === '') {
|
|
88
|
+
throw new Error('Haystack answered the capture registration without a key, repository id and application id.');
|
|
89
|
+
}
|
|
90
|
+
return { key: response.key, repositoryId: response.repositoryId, applicationId: response.applicationId };
|
|
91
|
+
}
|
|
92
|
+
/** The application's registration as Haystack holds it now (GET /api/agent/cloud-verifier/capture/status, lane A): its key
|
|
93
|
+
* and production origins, or null when the application is not registered (404). */
|
|
94
|
+
export async function readCaptureRegistration(token, repository, app) {
|
|
95
|
+
const query = new URLSearchParams({ repository, app });
|
|
96
|
+
const response = await haystackFetch(`${CAPTURE_STATUS_PATH}?${query}`, token, {}, [404]);
|
|
97
|
+
if (response.status === 404) {
|
|
98
|
+
await response.body?.cancel();
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const status = await response.json();
|
|
102
|
+
if (typeof status.key !== 'string' || !CAPTURE_KEY_PATTERN.test(status.key) || !Array.isArray(status.origins)
|
|
103
|
+
|| !status.origins.every(origin => typeof origin === 'string')) {
|
|
104
|
+
throw new Error('Haystack answered the capture status without a key and its origins.');
|
|
105
|
+
}
|
|
106
|
+
return { key: status.key, origins: [...status.origins].sort() };
|
|
107
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent wiring (CAPTURE-V1 rules 2 and 8b(i)). The coding agent passes its user's answer (--consent); init never assumes one.
|
|
3
|
+
*
|
|
4
|
+
* not-required the site's owner decided consent is not required for this measurement: data-consent="not-required"
|
|
5
|
+
* manual the tag stays gated; the site's own banner calls HaystackCapture.consent(true|false)
|
|
6
|
+
* cookiebot Cookiebot's `statistics` category
|
|
7
|
+
* onetrust:<group> OneTrust's category id for performance/analytics cookies (C0002 in OneTrust's default taxonomy, but
|
|
8
|
+
* sites rename and renumber categories, so the user names it; init never picks one)
|
|
9
|
+
* anything else an unknown platform: a manual step, the tag stays gated
|
|
10
|
+
*
|
|
11
|
+
* Why these two platforms: OneTrust is the most deployed consent platform on large sites and Cookiebot (Usercentrics) on
|
|
12
|
+
* small and mid-size ones, and both expose a documented CURRENT state (Cookiebot.consent, OnetrustActiveGroups) and a
|
|
13
|
+
* change event on window (CookiebotOnConsentReady/OnAccept/OnDecline, OneTrustGroupsUpdated), so the integration can
|
|
14
|
+
* apply the platform's state whenever it changes without wrapping or replacing any of the platform's globals or
|
|
15
|
+
* callbacks (OneTrust's OptanonWrapper belongs to the site). Platforms whose consent is per vendor (Usercentrics'
|
|
16
|
+
* services, IAB TCF vendor lists) need a Haystack entry configured in the platform first, which init cannot check.
|
|
17
|
+
*
|
|
18
|
+
* The integration is a static file next to the tag (CSP 'self', no inline script). It reads the platform's current
|
|
19
|
+
* state at load and on every change event and passes it on: HaystackCapture.consent(granted) once the tag is ready, or
|
|
20
|
+
* into the tag's pre-load queue (`HaystackCapture.q.push(['consent', granted])`) before it is, in order, so a grant made
|
|
21
|
+
* before the tag loads is not lost and a withdrawal is never followed by a stale grant. It calls only on a change from
|
|
22
|
+
* the tag's default (no consent), never throws (rule 13a), writes nothing to the page and keeps no state but the last
|
|
23
|
+
* value it passed on.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { sha384Integrity } from './script-release.js';
|
|
27
|
+
const ONETRUST_GROUP = /^[A-Za-z0-9_-]{1,32}$/;
|
|
28
|
+
const PLATFORM_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
29
|
+
export function parseConsentChoice(raw) {
|
|
30
|
+
const value = raw.trim().toLowerCase();
|
|
31
|
+
if (value === 'not-required')
|
|
32
|
+
return { kind: 'not-required' };
|
|
33
|
+
if (value === 'manual')
|
|
34
|
+
return { kind: 'manual' };
|
|
35
|
+
if (value === 'cookiebot')
|
|
36
|
+
return { kind: 'cookiebot' };
|
|
37
|
+
if (value === 'onetrust' || value.startsWith('onetrust:')) {
|
|
38
|
+
const group = raw.trim().slice('onetrust:'.length);
|
|
39
|
+
if (value === 'onetrust' || !ONETRUST_GROUP.test(group)) {
|
|
40
|
+
throw new Error('--consent onetrust needs the OneTrust category id that covers analytics, e.g. `onetrust:C0002`'
|
|
41
|
+
+ ' (OneTrust\'s default id for performance cookies; ask your user which category this site uses).');
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'onetrust', group };
|
|
44
|
+
}
|
|
45
|
+
if (!PLATFORM_NAME.test(value))
|
|
46
|
+
throw new Error(`--consent ${raw} is not a consent answer: use not-required, manual, cookiebot or onetrust:<category>.`);
|
|
47
|
+
return { kind: 'unknown-platform', name: value };
|
|
48
|
+
}
|
|
49
|
+
export function formatConsentChoice(choice) {
|
|
50
|
+
switch (choice.kind) {
|
|
51
|
+
case 'onetrust': return `onetrust:${choice.group}`;
|
|
52
|
+
case 'unknown-platform': return choice.name;
|
|
53
|
+
default: return choice.kind;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The answer as people read it: `Cookiebot (statistics)`, `OneTrust (category C0002)`. */
|
|
57
|
+
export function consentLabel(choice) {
|
|
58
|
+
switch (choice.kind) {
|
|
59
|
+
case 'cookiebot': return 'Cookiebot (statistics)';
|
|
60
|
+
case 'onetrust': return `OneTrust (category ${choice.group})`;
|
|
61
|
+
case 'unknown-platform': return choice.name;
|
|
62
|
+
default: return choice.kind;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The pass-on shared by both integrations; `READ` is the platform-specific expression for the current state, or null
|
|
66
|
+
* while the platform has none. */
|
|
67
|
+
function integration(platform, events, read) {
|
|
68
|
+
return `/* Haystack capture consent: ${platform} (CAPTURE-V1 rule 2). Written by \`haystack init\`; immutable (its name carries its hash). */
|
|
69
|
+
(function () {
|
|
70
|
+
try {
|
|
71
|
+
var w = window;
|
|
72
|
+
var last = false;
|
|
73
|
+
var apply = function () {
|
|
74
|
+
try {
|
|
75
|
+
var granted = ${read};
|
|
76
|
+
if (granted === null || granted === last) return;
|
|
77
|
+
last = granted;
|
|
78
|
+
var capture = w.HaystackCapture;
|
|
79
|
+
if (capture && typeof capture.consent === 'function') capture.consent(granted);
|
|
80
|
+
else {
|
|
81
|
+
capture = w.HaystackCapture = capture || { q: [] };
|
|
82
|
+
capture.q.push(['consent', granted]);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) { /* telemetry never reaches the page (rule 13a) */ }
|
|
85
|
+
};
|
|
86
|
+
var events = ${JSON.stringify(events)};
|
|
87
|
+
for (var i = 0; i < events.length; i += 1) w.addEventListener(events[i], apply, { passive: true });
|
|
88
|
+
apply();
|
|
89
|
+
} catch (error) { /* telemetry never reaches the page (rule 13a) */ }
|
|
90
|
+
})();
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
/** The integration file for a supported platform; null for every other answer. */
|
|
94
|
+
export function consentScript(choice) {
|
|
95
|
+
let platform;
|
|
96
|
+
let source;
|
|
97
|
+
if (choice.kind === 'cookiebot') {
|
|
98
|
+
platform = 'cookiebot';
|
|
99
|
+
source = integration('Cookiebot, statistics category', ['CookiebotOnConsentReady', 'CookiebotOnLoad', 'CookiebotOnAccept', 'CookiebotOnDecline'], '(w.Cookiebot && w.Cookiebot.consent) ? w.Cookiebot.consent.statistics === true : null');
|
|
100
|
+
}
|
|
101
|
+
else if (choice.kind === 'onetrust') {
|
|
102
|
+
platform = 'onetrust';
|
|
103
|
+
source = integration(`OneTrust, category ${choice.group}`, ['OneTrustGroupsUpdated'], `typeof w.OnetrustActiveGroups === 'string' ? w.OnetrustActiveGroups.split(',').indexOf(${JSON.stringify(choice.group)}) !== -1 : null`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const bytes = Buffer.from(source, 'utf8');
|
|
109
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 12);
|
|
110
|
+
return { file: `consent-${platform}.${hash}.js`, integrity: sha384Integrity(bytes), bytes };
|
|
111
|
+
}
|
|
112
|
+
const MANUAL_CALL = [
|
|
113
|
+
' window.HaystackCapture = window.HaystackCapture || { q: [] };',
|
|
114
|
+
' if (typeof HaystackCapture.consent === \'function\') HaystackCapture.consent(granted);',
|
|
115
|
+
' else HaystackCapture.q.push([\'consent\', granted]);',
|
|
116
|
+
].join('\n');
|
|
117
|
+
/** The manual consent step for answers init does not wire itself. */
|
|
118
|
+
export function manualConsentStep(choice) {
|
|
119
|
+
const how = 'From your consent banner, with the visitor\'s current choice as `granted` (true or false), when the page loads'
|
|
120
|
+
+ ` and whenever it changes, run:\n${MANUAL_CALL}\nUntil then the tag records nothing.`;
|
|
121
|
+
if (choice.kind === 'manual')
|
|
122
|
+
return `Wire consent. ${how}`;
|
|
123
|
+
if (choice.kind === 'unknown-platform') {
|
|
124
|
+
return `Wire consent: init has no integration for ${choice.name} (it has cookiebot and onetrust:<category>), so the tag stays gated. ${how}`;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|