Handle cookie consent for integrations and hidden pages

This commit is contained in:
Rodrigo Castro
2026-04-28 10:16:40 +01:00
parent be080248b1
commit cb04995def
6 changed files with 207 additions and 18 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test';
import { filterScriptsByConsent } from './scripts';
describe('filterScriptsByConsent', () => {
const scripts = [
{ script: 'https://example.com/no-consent.js', cookies: false },
{ script: 'https://example.com/consent-required.js', cookies: true },
{ script: 'https://example.com/default.js' },
];
it('loads only non-cookie scripts without explicit consent', () => {
expect(filterScriptsByConsent(scripts, false)).toEqual([
{ script: 'https://example.com/no-consent.js', cookies: false },
{ script: 'https://example.com/default.js' },
]);
});
it('loads cookie-dependent scripts after explicit consent', () => {
expect(filterScriptsByConsent(scripts, true)).toEqual(scripts);
});
});
@@ -15,6 +15,7 @@ import type {
import * as React from 'react';
import * as zustand from 'zustand';
import type { Assistant } from '../AI';
import { filterScriptsByConsent, type IntegrationScript } from './scripts';
const events = new Map<GitBookIntegrationEvent, GitBookIntegrationEventCallback[]>();
@@ -140,19 +141,77 @@ export function useCustomCookieBanner(): CustomCookieBannerStore {
}
/**
* Dispatch the `load` event to all integrations.
* Dispatch the `load` event after all eligible integrations have been loaded.
*/
export function LoadIntegrations() {
export function LoadIntegrations(props: { scripts: IntegrationScript[] }) {
const { scripts } = props;
const [hasExplicitCookieConsent, setHasExplicitCookieConsent] = React.useState(false);
const loadedScriptsRef = React.useRef(new Set<string>());
const hasInitializedRef = React.useRef(false);
React.useEffect(() => {
// Only dispatch 'load' event when there are scripts to load
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
setHasExplicitCookieConsent(isCookiesTrackingDisabled() === false);
}, []);
React.useEffect(() => {
const eligibleScripts = filterScriptsByConsent(scripts, hasExplicitCookieConsent);
const pendingScripts = eligibleScripts.filter(
({ script }) => !loadedScriptsRef.current.has(script)
);
if (pendingScripts.length === 0) {
if (!hasInitializedRef.current) {
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
hasInitializedRef.current = true;
}
return;
}
let cancelled = false;
Promise.all(
pendingScripts.map(({ script }) =>
loadIntegrationScript(script).finally(() => {
loadedScriptsRef.current.add(script);
})
)
).then(() => {
if (cancelled) {
return;
}
dispatchGitBookIntegrationEvent('load');
if (!hasInitializedRef.current) {
integrationsStore.setState({ loaded: true });
hasInitializedRef.current = true;
}
});
return () => {
cancelled = true;
};
}, [hasExplicitCookieConsent, scripts]);
return null;
}
function loadIntegrationScript(src: string): Promise<void> {
if (document.querySelector(`script[src="${CSS.escape(src)}"]`)) {
return Promise.resolve();
}
return new Promise((resolve) => {
const element = document.createElement('script');
element.async = true;
element.src = src;
element.onload = () => resolve();
element.onerror = () => resolve();
document.body.appendChild(element);
});
}
/**
* Client function to dispatch a GitBook event.
*/
@@ -0,0 +1,14 @@
export type IntegrationScript = {
script: string;
cookies?: boolean;
};
/**
* Keep only the scripts that are currently allowed to run.
*/
export function filterScriptsByConsent(
scripts: IntegrationScript[],
hasExplicitCookieConsent: boolean
): IntegrationScript[] {
return scripts.filter((script) => !script.cookies || hasExplicitCookieConsent);
}
@@ -32,6 +32,7 @@ export async function SiteLayout(props: {
const { customization } = context;
// Scripts are disabled when tracking is disabled
const scripts = withTracking ? context.scripts : [];
const preloadableScripts = scripts.filter((script) => !script.cookies);
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
ReactDOM.preconnect(GITBOOK_ICONS_URL);
@@ -39,7 +40,7 @@ export async function SiteLayout(props: {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
scripts.forEach(({ script }) => {
preloadableScripts.forEach(({ script }) => {
ReactDOM.preload(script, {
as: 'script',
});
@@ -70,10 +71,7 @@ export async function SiteLayout(props: {
</SpaceLayout>
</AIContextProvider>
<LoadIntegrations />
{scripts.length > 0
? scripts.map(({ script }) => <script key={script} async src={script} />)
: null}
<LoadIntegrations scripts={scripts} />
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPage } from '@gitbook/api';
import { getIndexablePages } from './sitemap';
describe('getIndexablePages', () => {
it('includes hidden pages when they remain indexable', () => {
const pages: RevisionPage[] = [
{
id: 'visible-page',
type: 'document',
title: 'Visible page',
path: 'visible-page',
pages: [],
hidden: false,
},
{
id: 'hidden-page',
type: 'document',
title: 'Hidden page',
path: 'hidden-page',
pages: [],
hidden: true,
},
] as RevisionPage[];
expect(getIndexablePages(pages).map(({ page }) => page.id)).toEqual([
'visible-page',
'hidden-page',
]);
});
it('excludes descendants of pages blocked from indexing', () => {
const pages: RevisionPage[] = [
{
id: 'parent',
type: 'group',
title: 'Parent',
path: 'parent',
hidden: false,
noRobotsIndex: true,
pages: [
{
id: 'child',
type: 'document',
title: 'Child',
path: 'parent/child',
pages: [],
hidden: false,
},
],
},
] as RevisionPage[];
expect(getIndexablePages(pages)).toEqual([]);
});
it('includes documents nested under groups', () => {
const pages: RevisionPage[] = [
{
id: 'outer-group',
type: 'group',
title: 'Outer group',
path: 'outer-group',
hidden: false,
pages: [
{
id: 'inner-group',
type: 'group',
title: 'Inner group',
path: 'outer-group/inner-group',
hidden: false,
pages: [
{
id: 'nested-page',
type: 'document',
title: 'Nested page',
path: 'outer-group/inner-group/nested-page',
pages: [],
hidden: false,
},
],
},
],
},
] as RevisionPage[];
expect(getIndexablePages(pages).map(({ page }) => page.id)).toEqual(['nested-page']);
});
});
+12 -6
View File
@@ -9,13 +9,17 @@ export type FlatPageEntry = { page: RevisionPageDocument; depth: number };
*/
function flattenPages(
rootPages: RevisionPage[],
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean
filter: (
page: RevisionPageDocument | RevisionPageGroup,
ancestors: Array<RevisionPageDocument | RevisionPageGroup>
) => boolean
): FlatPageEntry[] {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number
depth: number,
ancestors: Array<RevisionPageDocument | RevisionPageGroup>
): FlatPageEntry[] => {
const allowed = filter(page);
const allowed = filter(page, ancestors);
if (!allowed) {
return [];
}
@@ -23,13 +27,15 @@ function flattenPages(
return [
...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) =>
child.type === 'document' ? flattenPage(child, depth + 1) : []
child.type === 'document' || child.type === 'group'
? flattenPage(child, depth + 1, [...ancestors, page])
: []
),
];
};
return rootPages.flatMap((page) =>
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : []
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0, []) : []
);
}
@@ -37,5 +43,5 @@ function flattenPages(
* Get all indexable pages from a revision in a flat list.
*/
export function getIndexablePages(rootPages: RevisionPage[]) {
return flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
return flattenPages(rootPages, (page, ancestors) => isPageIndexable(ancestors, page));
}