jamdesk 1.1.146 → 1.1.148
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/package.json +1 -1
- package/vendored/components/ConsentGate.tsx +87 -0
- package/vendored/components/mdx/NativeSubscribeForm.tsx +20 -4
- package/vendored/lib/consent-gating.ts +119 -0
- package/vendored/lib/docs-types.ts +5 -1
- package/vendored/lib/layout-helpers.tsx +43 -6
- package/vendored/lib/validate-config.ts +3 -0
- package/vendored/schema/docs-schema.json +21 -2
- package/vendored/workspace-package-lock.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.148",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// builder/build-service/components/ConsentGate.tsx
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { useEffect, useRef } from 'react';
|
|
5
|
+
import type { GatedScript } from '../lib/consent-gating';
|
|
6
|
+
|
|
7
|
+
const POLL_MS = 1000;
|
|
8
|
+
|
|
9
|
+
function hasConsent(key: string, value: string): boolean {
|
|
10
|
+
try {
|
|
11
|
+
return window.localStorage.getItem(key) === value;
|
|
12
|
+
} catch {
|
|
13
|
+
// Storage unavailable (private mode / blocked iframe) → treat as no consent
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function injectScripts(scripts: GatedScript[]): void {
|
|
19
|
+
for (const s of scripts) {
|
|
20
|
+
const el = document.createElement('script');
|
|
21
|
+
el.setAttribute('data-jd-consent-script', s.id);
|
|
22
|
+
if (s.kind === 'src' && s.src) {
|
|
23
|
+
el.src = s.src;
|
|
24
|
+
for (const [name, val] of Object.entries(s.attrs ?? {})) el.setAttribute(name, val);
|
|
25
|
+
} else if (s.kind === 'inline' && s.code) {
|
|
26
|
+
el.textContent = s.code;
|
|
27
|
+
} else {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
(s.appendTo === 'head' ? document.head : document.body).appendChild(el);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Consent gate for analytics/tracking scripts (integrations.cookies).
|
|
36
|
+
* Renders nothing. Injects the given scripts once localStorage[consentKey]
|
|
37
|
+
* equals consentValue — immediately on mount if consent pre-exists, else via
|
|
38
|
+
* a storage listener (cross-tab) + poll (same-tab CMP writes don't fire
|
|
39
|
+
* storage events). Scripts are injected at most once per page load; consent
|
|
40
|
+
* revocation takes effect on the next page load.
|
|
41
|
+
*/
|
|
42
|
+
export function ConsentGate({
|
|
43
|
+
consentKey,
|
|
44
|
+
consentValue,
|
|
45
|
+
scripts,
|
|
46
|
+
}: {
|
|
47
|
+
consentKey: string;
|
|
48
|
+
consentValue: string;
|
|
49
|
+
scripts: GatedScript[];
|
|
50
|
+
}) {
|
|
51
|
+
// scripts is a fresh array identity each render (built server-side, serialized
|
|
52
|
+
// across the RSC boundary). Hold it in a ref so the effect doesn't list it as
|
|
53
|
+
// a dependency — otherwise a parent re-render before consent arrives would
|
|
54
|
+
// tear down and restart the polling window. Content is stable per page.
|
|
55
|
+
const scriptsRef = useRef(scripts);
|
|
56
|
+
scriptsRef.current = scripts;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if ((window as any).__jdConsentInjected) return;
|
|
60
|
+
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
61
|
+
|
|
62
|
+
const stop = () => {
|
|
63
|
+
if (intervalId !== undefined) clearInterval(intervalId);
|
|
64
|
+
window.removeEventListener('storage', check);
|
|
65
|
+
};
|
|
66
|
+
const check = () => {
|
|
67
|
+
if ((window as any).__jdConsentInjected) { stop(); return; }
|
|
68
|
+
if (!hasConsent(consentKey, consentValue)) return;
|
|
69
|
+
(window as any).__jdConsentInjected = true;
|
|
70
|
+
stop();
|
|
71
|
+
injectScripts(scriptsRef.current);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
check();
|
|
75
|
+
if (!(window as any).__jdConsentInjected) {
|
|
76
|
+
// Diagnostics: wrong key/value otherwise fails silently forever.
|
|
77
|
+
console.debug(
|
|
78
|
+
`[Jamdesk] Consent gating active — analytics withheld until localStorage["${consentKey}"] equals the configured value.`,
|
|
79
|
+
);
|
|
80
|
+
window.addEventListener('storage', check);
|
|
81
|
+
intervalId = setInterval(check, POLL_MS);
|
|
82
|
+
}
|
|
83
|
+
return stop;
|
|
84
|
+
}, [consentKey, consentValue]);
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
@@ -12,11 +12,21 @@ export interface NativeSubscribeFormProps {
|
|
|
12
12
|
className?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Shared control height for the email field + Subscribe button so they line up
|
|
16
|
+
// exactly. The input must carry 16px text (iOS no-zoom rule) which otherwise
|
|
17
|
+
// makes it taller than the smaller-font button; pinning both to one height with
|
|
18
|
+
// border-box collapses the mismatch. 2rem (32px) stays compact yet clears the
|
|
19
|
+
// WCAG 24px minimum target.
|
|
20
|
+
const CONTROL_HEIGHT = '2rem';
|
|
21
|
+
|
|
15
22
|
// Solid primary button (the collapsed trigger and the form's submit share it).
|
|
16
23
|
// Kept compact — the iOS 16px-min rule is for inputs, not buttons. Radius tracks
|
|
17
24
|
// the theme's button token (--radius-md) so it matches the docs' own buttons.
|
|
25
|
+
// inline-flex centers the label inside the fixed CONTROL_HEIGHT box.
|
|
18
26
|
const PRIMARY_BUTTON: CSSProperties = {
|
|
19
|
-
|
|
27
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
28
|
+
height: CONTROL_HEIGHT, boxSizing: 'border-box',
|
|
29
|
+
fontSize: '0.8125rem', padding: '0 0.85rem', border: 0,
|
|
20
30
|
borderRadius: 'var(--radius-md, 0.375rem)', cursor: 'pointer',
|
|
21
31
|
background: 'var(--color-primary, #2563eb)', color: '#fff',
|
|
22
32
|
};
|
|
@@ -189,6 +199,11 @@ export function NativeSubscribeForm({ provider, title, description, collapsed, c
|
|
|
189
199
|
<Shell className={className}>
|
|
190
200
|
{titleEl}
|
|
191
201
|
{description && <p className="mb-3 text-sm text-theme-text-secondary">{description}</p>}
|
|
202
|
+
{/* The field keeps 16px text (iOS no-zoom), but the placeholder reads a
|
|
203
|
+
touch smaller — inline styles can't reach ::placeholder, so scope it by
|
|
204
|
+
the stable jd-emailsubscribe hook. Smaller ::placeholder font doesn't
|
|
205
|
+
re-trigger iOS zoom (that keys off the input's own font-size). */}
|
|
206
|
+
<style>{`.jd-emailsubscribe input[type="email"]::placeholder{font-size:0.875rem}`}</style>
|
|
192
207
|
<form onSubmit={onSubmit} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center', maxWidth: '28rem' }}>
|
|
193
208
|
{/* Honeypot: a deliberately odd name (NOT website/url/email, which password
|
|
194
209
|
managers autofill — a filled honeypot silently drops a real user). Bots
|
|
@@ -196,11 +211,12 @@ export function NativeSubscribeForm({ provider, title, description, collapsed, c
|
|
|
196
211
|
<input type="text" name="jd_hp" tabIndex={-1} autoComplete="off" aria-hidden="true"
|
|
197
212
|
defaultValue="" style={{ position: 'absolute', left: '-5000px' }} />
|
|
198
213
|
{/* font-size:16px avoids iOS auto-zoom (monorepo UI rule). primary-bg field
|
|
199
|
-
pops against the card's secondary-bg surface; radius tracks the theme.
|
|
214
|
+
pops against the card's secondary-bg surface; radius tracks the theme.
|
|
215
|
+
height+border-box matches the Subscribe button exactly. */}
|
|
200
216
|
<input type="email" name="email" required value={email}
|
|
201
217
|
onChange={(e) => { setEmail(e.target.value); if (status === 'error') setStatus('idle'); }}
|
|
202
|
-
disabled={status === 'submitting'} placeholder="you@
|
|
203
|
-
style={{ flex: 1, minWidth: 0, fontSize: '16px', padding: '0
|
|
218
|
+
disabled={status === 'submitting'} placeholder="you@email.com" aria-label="Email address"
|
|
219
|
+
style={{ flex: 1, minWidth: 0, height: CONTROL_HEIGHT, boxSizing: 'border-box', fontSize: '16px', padding: '0 0.7rem', border: 'var(--border-width, 1px) solid var(--color-border, #d4d4d8)', borderRadius: 'var(--radius-md, 0.375rem)', background: 'var(--color-bg-primary, #fff)', color: 'var(--color-text-primary, #18181b)' }} />
|
|
204
220
|
<button type="submit" disabled={status === 'submitting'} style={PRIMARY_BUTTON}>
|
|
205
221
|
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
|
206
222
|
</button>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// builder/build-service/lib/consent-gating.ts
|
|
2
|
+
// Consent gating for analytics/tracking scripts (integrations.cookies) and
|
|
3
|
+
// CMP loader validation (integrations.osano / integrations.termly).
|
|
4
|
+
// Pure module — no DOM, no React — so the server layout and tests share it.
|
|
5
|
+
import type { IntegrationsConfig } from './docs-types';
|
|
6
|
+
|
|
7
|
+
export interface GatedScript {
|
|
8
|
+
/** Stable id — used as DOM marker (data-jd-consent-script) and React key */
|
|
9
|
+
id: string;
|
|
10
|
+
kind: 'inline' | 'src';
|
|
11
|
+
/** Inline JS source (kind: 'inline') */
|
|
12
|
+
code?: string;
|
|
13
|
+
/** External src (kind: 'src') */
|
|
14
|
+
src?: string;
|
|
15
|
+
/** Extra attributes set via setAttribute (kind: 'src'); '' = boolean attr */
|
|
16
|
+
attrs?: Record<string, string>;
|
|
17
|
+
appendTo: 'head' | 'body';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Tracking-ID guards: these values get interpolated into inline JS / URLs, so
|
|
21
|
+
// they must be strictly validated (same posture as the existing Crisp guard).
|
|
22
|
+
// /i is intentional: tolerate lowercase-typed IDs (alphanumerics + hyphen
|
|
23
|
+
// can't break out of the inline JS string regardless of case).
|
|
24
|
+
const GA4_ID_RE = /^G-[A-Z0-9]+$/i;
|
|
25
|
+
const GTM_ID_RE = /^GTM-[A-Z0-9]+$/i;
|
|
26
|
+
const CRISP_ID_RE = /^[a-f0-9-]{36}$/; // mirrors layout-helpers.tsx guard
|
|
27
|
+
|
|
28
|
+
// CMP loader URL pins. Defense-in-depth: schema validates these too, but the
|
|
29
|
+
// renderer must not trust config blobs from R2. Path charset includes '.' so
|
|
30
|
+
// every schema-valid Osano URL also passes here (schema only pins prefix/suffix).
|
|
31
|
+
const OSANO_SRC_RE = /^https:\/\/cmp\.osano\.com\/[A-Za-z0-9._/-]+\/osano\.js$/;
|
|
32
|
+
const TERMLY_SRC_RE =
|
|
33
|
+
/^https:\/\/app\.termly\.io\/resource-blocker\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\?autoBlock=(on|off))?$/;
|
|
34
|
+
|
|
35
|
+
export function isConsentGatingEnabled(integrations: IntegrationsConfig | undefined): boolean {
|
|
36
|
+
const c = integrations?.cookies;
|
|
37
|
+
return typeof c?.key === 'string' && c.key.length > 0 &&
|
|
38
|
+
typeof c?.value === 'string' && c.value.length > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validated CMP script srcs to render ungated in <head> (Osano first). */
|
|
42
|
+
export function getCmpScriptSrcs(integrations: IntegrationsConfig | undefined): string[] {
|
|
43
|
+
const srcs: string[] = [];
|
|
44
|
+
const osano = integrations?.osano?.scriptSource;
|
|
45
|
+
if (osano && OSANO_SRC_RE.test(osano)) srcs.push(osano);
|
|
46
|
+
const termly = integrations?.termly?.scriptSource;
|
|
47
|
+
if (termly && TERMLY_SRC_RE.test(termly)) srcs.push(termly);
|
|
48
|
+
return srcs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build serializable descriptors for every analytics/tracking script.
|
|
53
|
+
* Emitted unconditionally — the caller is responsible for the prod gate:
|
|
54
|
+
* layout-helpers renders <ConsentGate> (and thus calls this) only when
|
|
55
|
+
* NODE_ENV === 'production', matching the dev-suppression the direct render
|
|
56
|
+
* paths already apply to Plausible/Crisp/customJs/first-party analytics.
|
|
57
|
+
*/
|
|
58
|
+
export function buildGatedScripts({
|
|
59
|
+
integrations,
|
|
60
|
+
customJs,
|
|
61
|
+
analyticsScript,
|
|
62
|
+
}: {
|
|
63
|
+
integrations: IntegrationsConfig | undefined;
|
|
64
|
+
customJs: string | null;
|
|
65
|
+
analyticsScript: string | null;
|
|
66
|
+
}): GatedScript[] {
|
|
67
|
+
const scripts: GatedScript[] = [];
|
|
68
|
+
const i = integrations ?? {};
|
|
69
|
+
|
|
70
|
+
if (i.plausible?.scriptUrl) {
|
|
71
|
+
// Proxy mode — mirrors PlausibleScript's scriptUrl branch in layout-helpers
|
|
72
|
+
scripts.push({ id: 'plausible-proxy', kind: 'src', src: i.plausible.scriptUrl, attrs: { async: '' }, appendTo: 'head' });
|
|
73
|
+
scripts.push({
|
|
74
|
+
id: 'plausible-proxy-init', kind: 'inline', appendTo: 'head',
|
|
75
|
+
code: 'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()',
|
|
76
|
+
});
|
|
77
|
+
} else if (i.plausible?.domain) {
|
|
78
|
+
const baseServer = (i.plausible.server || 'https://plausible.io').replace(/\/+$/, '');
|
|
79
|
+
const attrs: Record<string, string> = { defer: '', 'data-domain': i.plausible.domain };
|
|
80
|
+
if (i.plausible.server) attrs['data-api'] = `${baseServer}/api/event`;
|
|
81
|
+
scripts.push({ id: 'plausible', kind: 'src', src: `${baseServer}/js/script.js`, attrs, appendTo: 'head' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// GTM/GA4 here are raw inline equivalents of the standard snippets. The
|
|
85
|
+
// ungated direct renders use @next/third-parties (ConditionalGTM/ConditionalGA)
|
|
86
|
+
// instead; these descriptors deliberately bypass it because they're injected
|
|
87
|
+
// client-side at consent time, not at SSR. Functionally equivalent for tracking,
|
|
88
|
+
// but they carry NO Next.js CSP nonce and GTM omits the <noscript> iframe
|
|
89
|
+
// fallback (irrelevant — no-JS clients can't trigger the consent gate anyway).
|
|
90
|
+
if (i.gtm?.tagId && GTM_ID_RE.test(i.gtm.tagId)) {
|
|
91
|
+
scripts.push({
|
|
92
|
+
id: 'gtm', kind: 'inline', appendTo: 'head',
|
|
93
|
+
code: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${i.gtm.tagId}');`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (i.ga4?.measurementId && GA4_ID_RE.test(i.ga4.measurementId)) {
|
|
98
|
+
scripts.push({
|
|
99
|
+
id: 'ga4-loader', kind: 'src', appendTo: 'head', attrs: { async: '' },
|
|
100
|
+
src: `https://www.googletagmanager.com/gtag/js?id=${i.ga4.measurementId}`,
|
|
101
|
+
});
|
|
102
|
+
scripts.push({
|
|
103
|
+
id: 'ga4-init', kind: 'inline', appendTo: 'head',
|
|
104
|
+
code: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${i.ga4.measurementId}');`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (i.crisp?.websiteId && CRISP_ID_RE.test(i.crisp.websiteId)) {
|
|
109
|
+
scripts.push({
|
|
110
|
+
id: 'crisp', kind: 'inline', appendTo: 'body',
|
|
111
|
+
code: `window.$crisp=[];window.CRISP_WEBSITE_ID="${i.crisp.websiteId}";(function(){var d=document;var s=d.createElement("script");s.src="https://client.crisp.chat/l.js";s.async=1;d.getElementsByTagName("head")[0].appendChild(s)})();`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (customJs) scripts.push({ id: 'custom-js', kind: 'inline', code: customJs, appendTo: 'body' });
|
|
116
|
+
if (analyticsScript) scripts.push({ id: 'jd-analytics', kind: 'inline', code: analyticsScript, appendTo: 'body' });
|
|
117
|
+
|
|
118
|
+
return scripts;
|
|
119
|
+
}
|
|
@@ -644,7 +644,10 @@ export interface NewsletterConfig {
|
|
|
644
644
|
}
|
|
645
645
|
|
|
646
646
|
/**
|
|
647
|
-
* Analytics and integration configurations
|
|
647
|
+
* Analytics and integration configurations.
|
|
648
|
+
* Implemented: ga4, gtm, plausible, crisp, osano, termly, cookies (consent
|
|
649
|
+
* gating), analytics opt-out via AnalyticsConfig. Others remain config-only
|
|
650
|
+
* stubs accepted for Mintlify compatibility.
|
|
648
651
|
*/
|
|
649
652
|
export interface IntegrationsConfig {
|
|
650
653
|
ga4?: { measurementId: string };
|
|
@@ -664,6 +667,7 @@ export interface IntegrationsConfig {
|
|
|
664
667
|
logrocket?: { appId: string };
|
|
665
668
|
mixpanel?: { projectToken: string };
|
|
666
669
|
osano?: { scriptSource: string };
|
|
670
|
+
termly?: { scriptSource: string };
|
|
667
671
|
pirsch?: { id: string };
|
|
668
672
|
posthog?: { apiKey: string; apiHost?: string };
|
|
669
673
|
plausible?: { domain?: string; server?: string; scriptUrl?: string };
|
|
@@ -25,6 +25,8 @@ import type { BackgroundConfig, DocsConfig, FontConfig, LanguageCode } from '@/l
|
|
|
25
25
|
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
26
26
|
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
27
|
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
|
+
import { isConsentGatingEnabled, buildGatedScripts, getCmpScriptSrcs } from '@/lib/consent-gating';
|
|
29
|
+
import { ConsentGate } from '@/components/ConsentGate';
|
|
28
30
|
import { AgentDirective } from './agent-directive';
|
|
29
31
|
import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
30
32
|
import { toHreflang } from '@/lib/language-utils';
|
|
@@ -429,6 +431,21 @@ export async function DocsChrome({
|
|
|
429
431
|
? getAnalyticsScript(resolvedProjectSlug)
|
|
430
432
|
: null;
|
|
431
433
|
|
|
434
|
+
// integrations.cookies consent gating: when key+value are configured, all
|
|
435
|
+
// analytics/tracking scripts are withheld until the CMP writes
|
|
436
|
+
// localStorage[key] === value (ConsentGate injects them client-side).
|
|
437
|
+
const consentGating = isConsentGatingEnabled(config.integrations);
|
|
438
|
+
// CMP loaders (Osano/Termly) are NEVER gated — they ARE the consent banner.
|
|
439
|
+
const cmpScriptSrcs = process.env.NODE_ENV === 'production'
|
|
440
|
+
? getCmpScriptSrcs(config.integrations)
|
|
441
|
+
: [];
|
|
442
|
+
// Preconnect origins are best-effort: getCmpScriptSrcs only returns
|
|
443
|
+
// regex-validated absolute https URLs (so new URL can't throw today), but
|
|
444
|
+
// guard anyway — a malformed preconnect must never 500 the whole render.
|
|
445
|
+
const cmpPreconnectOrigins = cmpScriptSrcs
|
|
446
|
+
.map((src) => { try { return new URL(src).origin; } catch { return null; } })
|
|
447
|
+
.filter((origin): origin is string => origin !== null);
|
|
448
|
+
|
|
432
449
|
// Global banner: only emit the pre-paint no-flash guard for a dismissible
|
|
433
450
|
// banner (a non-dismissible banner is never hidden, so the guard would risk
|
|
434
451
|
// hiding a mandatory message via a stale dismissal hash). Trimmed content
|
|
@@ -457,6 +474,15 @@ export async function DocsChrome({
|
|
|
457
474
|
return (
|
|
458
475
|
<html lang={toHreflang(lang)} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
|
|
459
476
|
<head>
|
|
477
|
+
{/* CMP scripts must be first + synchronous: auto-blocking CMPs
|
|
478
|
+
(Osano/Termly) need to intercept later scripts. Never gated.
|
|
479
|
+
preconnect first: the sync fetch is render-blocking. */}
|
|
480
|
+
{cmpPreconnectOrigins.map((origin) => (
|
|
481
|
+
<link key={`pc-${origin}`} rel="preconnect" href={origin} />
|
|
482
|
+
))}
|
|
483
|
+
{cmpScriptSrcs.map((src) => (
|
|
484
|
+
<script key={src} src={src} />
|
|
485
|
+
))}
|
|
460
486
|
{/*
|
|
461
487
|
SSR scroll lock — prevents Chrome's same-tab cross-origin "preserve
|
|
462
488
|
scroll" heuristic from scrolling #content-scroll-container on first
|
|
@@ -674,7 +700,7 @@ export async function DocsChrome({
|
|
|
674
700
|
{customCss}
|
|
675
701
|
</style>
|
|
676
702
|
)}
|
|
677
|
-
{process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
|
|
703
|
+
{!consentGating && process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
|
|
678
704
|
<PlausibleScript
|
|
679
705
|
domain={config.integrations.plausible.domain}
|
|
680
706
|
server={config.integrations.plausible.server}
|
|
@@ -684,7 +710,7 @@ export async function DocsChrome({
|
|
|
684
710
|
</head>
|
|
685
711
|
<body className={fontClassName} data-theme={themeName || 'jam'} data-decoration={decoration || undefined} suppressHydrationWarning>
|
|
686
712
|
{!embed && <AgentDirective hostAtDocs={!!config.hostAtDocs} />}
|
|
687
|
-
{config.integrations?.gtm?.tagId && (
|
|
713
|
+
{!consentGating && config.integrations?.gtm?.tagId && (
|
|
688
714
|
<ConditionalGTM gtmId={config.integrations.gtm.tagId} />
|
|
689
715
|
)}
|
|
690
716
|
<ThemeProvider
|
|
@@ -712,7 +738,7 @@ export async function DocsChrome({
|
|
|
712
738
|
client" warning in `jamdesk dev`. Same pattern as the
|
|
713
739
|
project-subdomain hydration suppressor in <head>.
|
|
714
740
|
*/}
|
|
715
|
-
{process.env.NODE_ENV === 'production' && config.integrations?.crisp?.websiteId &&
|
|
741
|
+
{!consentGating && process.env.NODE_ENV === 'production' && config.integrations?.crisp?.websiteId &&
|
|
716
742
|
/^[a-f0-9-]{36}$/.test(config.integrations.crisp.websiteId) && (
|
|
717
743
|
<script
|
|
718
744
|
dangerouslySetInnerHTML={{
|
|
@@ -720,15 +746,26 @@ export async function DocsChrome({
|
|
|
720
746
|
}}
|
|
721
747
|
/>
|
|
722
748
|
)}
|
|
723
|
-
{process.env.NODE_ENV === 'production' && customJs && (
|
|
749
|
+
{!consentGating && process.env.NODE_ENV === 'production' && customJs && (
|
|
724
750
|
<script dangerouslySetInnerHTML={{ __html: customJs }} />
|
|
725
751
|
)}
|
|
726
|
-
{process.env.NODE_ENV === 'production' && analyticsScript && (
|
|
752
|
+
{!consentGating && process.env.NODE_ENV === 'production' && analyticsScript && (
|
|
727
753
|
<script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
|
|
728
754
|
)}
|
|
729
|
-
{config.integrations?.ga4?.measurementId && (
|
|
755
|
+
{!consentGating && config.integrations?.ga4?.measurementId && (
|
|
730
756
|
<ConditionalGA gaId={config.integrations.ga4.measurementId} />
|
|
731
757
|
)}
|
|
758
|
+
{consentGating && process.env.NODE_ENV === 'production' && (
|
|
759
|
+
<ConsentGate
|
|
760
|
+
consentKey={config.integrations!.cookies!.key!}
|
|
761
|
+
consentValue={config.integrations!.cookies!.value!}
|
|
762
|
+
scripts={buildGatedScripts({
|
|
763
|
+
integrations: config.integrations,
|
|
764
|
+
customJs,
|
|
765
|
+
analyticsScript,
|
|
766
|
+
})}
|
|
767
|
+
/>
|
|
768
|
+
)}
|
|
732
769
|
<JdReadySentinel />
|
|
733
770
|
</body>
|
|
734
771
|
</html>
|
|
@@ -36,6 +36,9 @@ export interface IntegrationsConfig {
|
|
|
36
36
|
ga4?: { measurementId: string };
|
|
37
37
|
gtm?: { tagId: string };
|
|
38
38
|
plausible?: { domain?: string; server?: string; scriptUrl?: string };
|
|
39
|
+
osano?: { scriptSource: string };
|
|
40
|
+
termly?: { scriptSource: string };
|
|
41
|
+
cookies?: { key?: string; value?: string };
|
|
39
42
|
[key: string]: unknown;
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -1258,6 +1258,19 @@
|
|
|
1258
1258
|
],
|
|
1259
1259
|
"additionalProperties": false
|
|
1260
1260
|
},
|
|
1261
|
+
"termly": {
|
|
1262
|
+
"type": "object",
|
|
1263
|
+
"properties": {
|
|
1264
|
+
"scriptSource": {
|
|
1265
|
+
"type": "string",
|
|
1266
|
+
"pattern": "^https://app\\.termly\\.io/resource-blocker/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\\?autoBlock=(on|off))?$"
|
|
1267
|
+
}
|
|
1268
|
+
},
|
|
1269
|
+
"required": [
|
|
1270
|
+
"scriptSource"
|
|
1271
|
+
],
|
|
1272
|
+
"additionalProperties": false
|
|
1273
|
+
},
|
|
1261
1274
|
"pirsch": {
|
|
1262
1275
|
"type": "object",
|
|
1263
1276
|
"properties": {
|
|
@@ -1345,12 +1358,18 @@
|
|
|
1345
1358
|
"type": "object",
|
|
1346
1359
|
"properties": {
|
|
1347
1360
|
"key": {
|
|
1348
|
-
"type": "string"
|
|
1361
|
+
"type": "string",
|
|
1362
|
+
"minLength": 1
|
|
1349
1363
|
},
|
|
1350
1364
|
"value": {
|
|
1351
|
-
"type": "string"
|
|
1365
|
+
"type": "string",
|
|
1366
|
+
"minLength": 1
|
|
1352
1367
|
}
|
|
1353
1368
|
},
|
|
1369
|
+
"required": [
|
|
1370
|
+
"key",
|
|
1371
|
+
"value"
|
|
1372
|
+
],
|
|
1354
1373
|
"additionalProperties": false
|
|
1355
1374
|
}
|
|
1356
1375
|
},
|
|
@@ -2939,9 +2939,9 @@
|
|
|
2939
2939
|
"license": "MIT"
|
|
2940
2940
|
},
|
|
2941
2941
|
"node_modules/electron-to-chromium": {
|
|
2942
|
-
"version": "1.5.
|
|
2943
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2944
|
-
"integrity": "sha512-
|
|
2942
|
+
"version": "1.5.374",
|
|
2943
|
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.374.tgz",
|
|
2944
|
+
"integrity": "sha512-HCF5i7izveksHSGqa7mhDh6tr3Uz9Dar2RAjwuh69bw3QGPVObjQIgLwQWeO/Rxp9/r0KdboKy9RbpQDl97fjg==",
|
|
2945
2945
|
"license": "ISC"
|
|
2946
2946
|
},
|
|
2947
2947
|
"node_modules/enhanced-resolve": {
|