mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Handle cookie consent for integrations and hidden pages
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user