mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-28 21:18:57 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd9976524 | |||
| 4c36cf9abd | |||
| c1530706d0 | |||
| d4fa433d92 | |||
| a2170a352a | |||
| e05abafddb | |||
| 7e0e76e58a | |||
| 42ab12a387 | |||
| 2251b36673 | |||
| 8ef76487c2 | |||
| e401c4d4c7 | |||
| fb971aefcc | |||
| b6770d959a | |||
| 8d881d88f6 | |||
| 351c4ffa28 | |||
| bd608e6beb | |||
| 825e245703 | |||
| 828ce66cd9 | |||
| 8e1fdedf83 | |||
| 4a88202274 | |||
| d077e1a6b6 | |||
| 7605d03d8c | |||
| 80026d2724 | |||
| b86ee531d4 | |||
| d939d4c1fd | |||
| 0cc9a03add | |||
| 3805bac462 | |||
| 23b438404c | |||
| 40d14b02d8 | |||
| ef0b6f020a | |||
| d6c0d2dbcd | |||
| 125872769f | |||
| a04513f887 | |||
| 7adbb5068f | |||
| 2b146dbc6a | |||
| ea9420817f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Prefix all cache tags with `ppr:` when rendering under the PPR route, so PPR cache entries are partitioned from the static ones.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Require a GitBook-secret signature on the `x-gbo-*` PPR headers, so a client can't opt itself into the PPR route.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Exchange the PPR revalidation token for a content API token scoped to each PPR component, so the API receives claims it understands and the header and table of contents can be cached across pages.
|
||||
@@ -48,6 +48,7 @@ runs:
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
|
||||
@@ -50,6 +50,7 @@ runs:
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
|
||||
@@ -24,6 +24,35 @@ Examples:
|
||||
- `http://localhost:3000/url/gitbook.com/docs`
|
||||
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`
|
||||
|
||||
### PPR routes
|
||||
|
||||
PPR requests are normally resolved upstream and arrive with a large set of `x-gbo-*` headers, so they
|
||||
can't be reproduced by hitting the dev server directly. `bun run dev:ppr` (from `packages/gitbook`)
|
||||
starts a dev-only proxy on port 3001 that resolves the URL, injects those headers and signs them.
|
||||
The app rejects an unsigned set, so `GITBOOK_SECRET` must be set in `.env.local` (any value works
|
||||
locally, as long as both processes read the same one).
|
||||
|
||||
The app also exchanges the PPR token for one scoped to each component, against
|
||||
`GITBOOK_EXCHANGE_TOKEN_URL`. That endpoint only accepts a revalidation token, which the
|
||||
published-URLs lookup never returns, so the proxy mints one with `PPR_DEV_API_TOKEN_SECRET` — a
|
||||
local-only stand-in for the API token secret the cache worker holds in production. It must match the
|
||||
secret the target `/token` endpoint verifies with, so local PPR needs a local gitbook-x sites stack.
|
||||
The secret is read only by the proxy script: never add it to `src/lib/env`, `next.config.mjs` or a
|
||||
deploy workflow.
|
||||
|
||||
```
|
||||
GITBOOK_SECRET=<any value>
|
||||
GITBOOK_EXCHANGE_TOKEN_URL=http://localhost:8788/token
|
||||
PPR_DEV_API_TOKEN_SECRET=<local gitbook-x functionsConfig.api.tokenSecret>
|
||||
```
|
||||
|
||||
```
|
||||
http://localhost:3001/url/<published-gitbook-url>
|
||||
```
|
||||
|
||||
Responses carry `x-gitbook-route-type: ppr` when the PPR route was used. Hot reload doesn't work
|
||||
through the proxy (its websocket can't be forwarded), so keep using port 3000 while iterating.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookStandalone } from '@gitbook/embed';
|
||||
|
||||
import { signPPRRequestHeaders } from '../src/lib/ppr';
|
||||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type Test,
|
||||
@@ -346,6 +347,49 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const PPR_TEST_SITE_URL = 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/';
|
||||
|
||||
/**
|
||||
* The `x-gbo-*` set GBO resolves upstream. `sign` mirrors what GBO does with `GITBOOK_SECRET`;
|
||||
* without it the app must fall back to resolving the URL itself.
|
||||
*/
|
||||
async function getPPRHeaders(options: { sign: string | undefined }) {
|
||||
const data = await getSiteAPIToken(PPR_TEST_SITE_URL);
|
||||
|
||||
if (!data.revision) {
|
||||
throw new Error('PPR test site did not resolve to content with a revision');
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
'x-gbo-site': data.site,
|
||||
'x-gbo-site-section': data.siteSection ?? '',
|
||||
'x-gbo-site-space': data.siteSpace,
|
||||
'x-gbo-space': data.space,
|
||||
'x-gbo-site-base-path': data.siteBasePath,
|
||||
'x-gbo-base-path': data.basePath,
|
||||
'x-gbo-pathname': data.pathname || '/',
|
||||
'x-gbo-organization': data.organization,
|
||||
'x-gbo-share-key': data.shareKey ?? '',
|
||||
'x-gbo-complete': String(data.complete),
|
||||
'x-gbo-context-id': data.contextId ?? '',
|
||||
'x-gbo-canonical-url': data.canonicalUrl,
|
||||
'x-gbo-preview': data.preview === undefined ? '' : String(data.preview),
|
||||
'x-gbo-revision': data.revision ?? '',
|
||||
'x-gbo-change-request': data.changeRequest ?? '',
|
||||
'x-gbo-api-token': data.apiToken,
|
||||
'x-gbo-revalidation-id': 'ppr-e2e-revalidation',
|
||||
'x-gbo-default-site-section': data.siteSection ?? '',
|
||||
'x-gbo-default-site-space': data.siteSpace,
|
||||
'x-gbo-default-space': data.space,
|
||||
});
|
||||
|
||||
if (options.sign) {
|
||||
await signPPRRequestHeaders(headers, options.sign);
|
||||
}
|
||||
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'GitBook Site (Single Variant)',
|
||||
@@ -356,6 +400,37 @@ const testCases: TestsCase[] = [
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'PPR route renders the site shell',
|
||||
url: '',
|
||||
headers: () => getPPRHeaders({ sign: process.env.GITBOOK_SECRET }),
|
||||
screenshot: false,
|
||||
run: async (page, response) => {
|
||||
// The deployment signs with its own `GITBOOK_SECRET`; without it here the
|
||||
// headers can only be tested for rejection (see the test below).
|
||||
test.skip(
|
||||
!process.env.GITBOOK_SECRET,
|
||||
'GITBOOK_SECRET is required to sign PPR headers'
|
||||
);
|
||||
expect(response?.headers()['x-gitbook-route-type']).toBe('ppr');
|
||||
await expect(page.locator('header[data-gb-site-header]')).toBeVisible();
|
||||
await expect(page.getByTestId('table-of-contents')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PPR route ignores an unsigned header set',
|
||||
url: '',
|
||||
headers: () => getPPRHeaders({ sign: undefined }),
|
||||
screenshot: false,
|
||||
run: async (page, response) => {
|
||||
// Anyone can send these headers, so an unsigned set must never take the PPR
|
||||
// path: it skips URL resolution and visitor-auth, and picks the cache key.
|
||||
expect(response?.headers()['x-gitbook-route-type']).not.toBe('ppr');
|
||||
await expect(page.locator('header[data-gb-site-header]')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'No variants dropdown',
|
||||
url: '',
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface Test {
|
||||
*/
|
||||
url: string | (() => string | Promise<string>);
|
||||
cookies?: Parameters<BrowserContext['addCookies']>[0];
|
||||
/** Headers to send with the main document request. */
|
||||
headers?:
|
||||
| Record<string, string>
|
||||
| (() => Record<string, string> | Promise<Record<string, string>>);
|
||||
/**
|
||||
* Test to run
|
||||
*/
|
||||
@@ -279,6 +283,11 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const headers =
|
||||
typeof testEntry.headers === 'function'
|
||||
? await testEntry.headers()
|
||||
: testEntry.headers;
|
||||
|
||||
// Set the header to disable the Vercel toolbar
|
||||
// But only on the main document as it'd cause CORS issues on other resources
|
||||
await page.route('**/*', async (route, request) => {
|
||||
@@ -287,6 +296,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
headers: {
|
||||
...request.headers(),
|
||||
'x-vercel-skip-toolbar': '1',
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -88,6 +88,7 @@ const nextConfig = {
|
||||
GITBOOK_API_TOKEN: process.env.GITBOOK_API_TOKEN,
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||
GITBOOK_EXCHANGE_TOKEN_URL: process.env.GITBOOK_EXCHANGE_TOKEN_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"generate:fonts": "bun ./scripts/generate-font-faces.ts",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math && rm -rf ./public/~gitbook/static/mermaid && rm -rf ./public/~gitbook/static/scalar && rm -rf ./public/~gitbook/static/fonts",
|
||||
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"dev:ppr": "env-cmd --silent -f ../../.env.local bun scripts/ppr-dev-proxy.ts",
|
||||
"build": "bun run generate:assets && next build --webpack",
|
||||
"build:local": "bun run generate:assets && GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"check:css-browser-compatibility": "bun scripts/check-css-browser-compatibility.ts",
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Dev-only proxy that stands in for the upstream layer resolving PPR requests.
|
||||
*
|
||||
* It resolves the incoming URL against the published-URLs API and forwards the request to the local
|
||||
* app with the complete `x-gbo-*` header set, so PPR routes can be exercised locally:
|
||||
*
|
||||
* bun dev # app on :3000
|
||||
* bun run dev:ppr # this proxy on :3001
|
||||
* open http://localhost:3001/url/gitbook.com/docs
|
||||
*
|
||||
* Never deploy this. It trusts its input and caches API tokens in memory.
|
||||
*/
|
||||
import type { PublishedSiteContent, PublishedSiteContentLookup, Space } from '@gitbook/api';
|
||||
|
||||
import { PPRRequestHeaders, signPPRRequestHeaders } from '../src/lib/ppr';
|
||||
|
||||
const PORT = Number(process.env.PPR_PROXY_PORT || 3001);
|
||||
const UPSTREAM = process.env.PPR_UPSTREAM || 'http://localhost:3000';
|
||||
const API_URL = process.env.GITBOOK_API_URL || 'https://api.gitbook.com/cache';
|
||||
const API_TOKEN = process.env.GITBOOK_API_TOKEN;
|
||||
const REVALIDATION_ID = process.env.PPR_REVALIDATION_ID;
|
||||
const LOOKUP_TTL = Number(process.env.PPR_LOOKUP_TTL || 60) * 1000;
|
||||
|
||||
const URL_PREFIX = '/url/';
|
||||
const PASSTHROUGH_PREFIXES = ['/_next/', '/~gitbook/static/'];
|
||||
|
||||
function log(message: string) {
|
||||
// biome-ignore lint/suspicious/noConsole: this is a CLI script
|
||||
console.log(`[ppr-proxy] ${message}`);
|
||||
}
|
||||
|
||||
// The app rejects an unsigned header set, so `bun dev` must run with the same secret.
|
||||
function requireSecret(): string {
|
||||
const secret = process.env.GITBOOK_SECRET;
|
||||
if (!secret) {
|
||||
log(
|
||||
'GITBOOK_SECRET is not set: the app rejects unsigned PPR headers. Add it to .env.local.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
// This proxy mints API tokens, so it must never be reachable outside a developer machine.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
log('This proxy mints API tokens and must never run in production.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SECRET = requireSecret();
|
||||
|
||||
// The app always exchanges the PPR token, and the exchange endpoint only accepts a revalidation
|
||||
// token, which the published-URLs lookup never returns: we have to mint one ourselves.
|
||||
function requireAPITokenSecret(): string {
|
||||
const secret = process.env.PPR_DEV_API_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
log(
|
||||
'PPR_DEV_API_TOKEN_SECRET is not set: the token exchange rejects the lookup token. Add it to .env.local.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
const API_TOKEN_SECRET = requireAPITokenSecret();
|
||||
|
||||
const cache = new Map<string, { value: unknown; expiresAt: number }>();
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Cache and dedupe an API call. A single page load fans out into many requests for the same site,
|
||||
* and the site root lookup is shared by every page of a site.
|
||||
*/
|
||||
function cached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const entry = cache.get(key);
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
return Promise.resolve(entry.value as T);
|
||||
}
|
||||
|
||||
const pending = inflight.get(key);
|
||||
if (pending) {
|
||||
return pending as Promise<T>;
|
||||
}
|
||||
|
||||
const promise = fetcher()
|
||||
.then((value) => {
|
||||
cache.set(key, { value, expiresAt: Date.now() + LOOKUP_TTL });
|
||||
return value;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function api<T>(path: string, init: RequestInit, token = API_TOKEN): Promise<T> {
|
||||
const response = await fetch(`${API_URL}/v1${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function lookupPublishedURL(url: string): Promise<PublishedSiteContentLookup> {
|
||||
return cached(`url:${url}`, () =>
|
||||
api<PublishedSiteContentLookup>('/urls/published', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lookup only carries a revision for preview URLs, so for a regular published URL we read the
|
||||
* space's active revision using the short-lived token the lookup returned.
|
||||
*/
|
||||
function getActiveRevision(content: PublishedSiteContent): Promise<string> {
|
||||
return cached(`revision:${content.space}`, async () => {
|
||||
const space = await api<Space>(`/spaces/${content.space}`, {}, content.apiToken);
|
||||
return space.revision;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `x-gbo-default-*` headers describe the site's default variant, which is the cache key of the
|
||||
* shared header. Resolving the site root is the only way to get it from the published-URLs API.
|
||||
*/
|
||||
async function getDefaults(content: PublishedSiteContent) {
|
||||
const fallback = {
|
||||
siteSection: content.siteSection,
|
||||
siteSpace: content.siteSpace,
|
||||
space: content.space,
|
||||
};
|
||||
|
||||
try {
|
||||
const rootURL = `https://${new URL(content.canonicalUrl).host}${content.siteBasePath}`;
|
||||
const root = await lookupPublishedURL(rootURL);
|
||||
if ('redirect' in root) {
|
||||
return fallback;
|
||||
}
|
||||
return { siteSection: root.siteSection, siteSpace: root.siteSpace, space: root.space };
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the lookup's content token into the revalidation token the cache worker would send: the same
|
||||
* payload, with the claims split into the per-scope buckets the exchange endpoint narrows down.
|
||||
* Reusing the payload keeps `spaces`, `iat` and `exp` valid.
|
||||
*/
|
||||
async function mintRevalidationToken(apiToken: string): Promise<string> {
|
||||
const { claims: _claims, ...payload } = decodeJWTPayload(apiToken);
|
||||
|
||||
const result = signJWT(
|
||||
{
|
||||
...payload,
|
||||
target: 'content',
|
||||
// Empty buckets: local dev has no revalidation run to compute adaptive claims from.
|
||||
siteClaims: {},
|
||||
revisionClaims: {},
|
||||
pageClaims: {},
|
||||
},
|
||||
API_TOKEN_SECRET
|
||||
);
|
||||
console.log('minted revalidation token', await result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeJWTPayload(token: string): Record<string, unknown> {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) {
|
||||
throw new Error('API token is not a JWT');
|
||||
}
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
async function signJWT(payload: Record<string, unknown>, secret: string): Promise<string> {
|
||||
const signingInput = `${base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${base64url(
|
||||
JSON.stringify(payload)
|
||||
)}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
|
||||
|
||||
return `${signingInput}.${base64url(Buffer.from(signature))}`;
|
||||
}
|
||||
|
||||
function base64url(value: string | Buffer): string {
|
||||
return (typeof value === 'string' ? Buffer.from(value, 'utf8') : value).toString('base64url');
|
||||
}
|
||||
|
||||
async function setPPRHeaders(
|
||||
headers: Headers,
|
||||
content: PublishedSiteContent & { revision: string },
|
||||
defaults: { siteSection: string | undefined; siteSpace: string; space: string },
|
||||
secret: string
|
||||
) {
|
||||
headers.set(PPRRequestHeaders.Site, content.site);
|
||||
headers.set(PPRRequestHeaders.SiteSection, content.siteSection ?? '');
|
||||
headers.set(PPRRequestHeaders.SiteSpace, content.siteSpace);
|
||||
headers.set(PPRRequestHeaders.Space, content.space);
|
||||
headers.set(PPRRequestHeaders.SiteBasePath, content.siteBasePath);
|
||||
headers.set(PPRRequestHeaders.BasePath, content.basePath);
|
||||
// An empty pathname is rejected; the root page is `/`.
|
||||
headers.set(PPRRequestHeaders.Pathname, content.pathname || '/');
|
||||
headers.set(PPRRequestHeaders.Organization, content.organization);
|
||||
headers.set(PPRRequestHeaders.ShareKey, content.shareKey ?? '');
|
||||
headers.set(PPRRequestHeaders.Complete, String(content.complete));
|
||||
headers.set(PPRRequestHeaders.ContextID, content.contextId ?? '');
|
||||
headers.set(PPRRequestHeaders.CanonicalURL, content.canonicalUrl);
|
||||
// Anything other than '', 'true' or 'false' rejects the whole PPR request.
|
||||
headers.set(
|
||||
PPRRequestHeaders.Preview,
|
||||
content.preview === undefined ? '' : String(content.preview)
|
||||
);
|
||||
headers.set(PPRRequestHeaders.Revision, content.revision);
|
||||
headers.set(PPRRequestHeaders.ChangeRequest, content.changeRequest ?? '');
|
||||
headers.set(PPRRequestHeaders.APIToken, await mintRevalidationToken(content.apiToken));
|
||||
headers.set(PPRRequestHeaders.RevalidationID, REVALIDATION_ID || content.revision);
|
||||
// Unlike the other optional headers this one is checked with `has()`, so it must be sent even
|
||||
// when the site has no sections.
|
||||
headers.set(PPRRequestHeaders.DefaultSiteSection, defaults.siteSection ?? '');
|
||||
headers.set(PPRRequestHeaders.DefaultSiteSpace, defaults.siteSpace);
|
||||
headers.set(PPRRequestHeaders.DefaultSpace, defaults.space);
|
||||
// Signed last: the signature covers every other PPR header.
|
||||
await signPPRRequestHeaders(headers, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` decodes the response body, so the upstream framing headers no longer describe what we are
|
||||
* about to send. Forwarding `content-encoding: gzip` with plain bytes renders as a blank page.
|
||||
*/
|
||||
const DECODED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding'];
|
||||
|
||||
async function forward(request: Request, url: URL, headers: Headers): Promise<Response> {
|
||||
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
||||
const upstreamURL = new URL(url.pathname + url.search, UPSTREAM);
|
||||
|
||||
// `/url/` mode is only enabled for requests on the app's own host (`GITBOOK_URL`), so the proxy
|
||||
// host must not leak through: the app would treat it as a custom domain and 404.
|
||||
headers.set('host', upstreamURL.host);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstreamURL, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: hasBody ? request.body : undefined,
|
||||
redirect: 'manual',
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
} as RequestInit);
|
||||
} catch (error) {
|
||||
log(`${request.method} ${url.pathname} → upstream unreachable at ${UPSTREAM}`);
|
||||
return new Response(`Upstream ${UPSTREAM} unreachable: ${error}`, { status: 502 });
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
for (const name of DECODED_RESPONSE_HEADERS) {
|
||||
responseHeaders.delete(name);
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
async function handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// `fetch` can't perform an upgrade, so hot reload only works when hitting the app directly.
|
||||
if (request.headers.get('upgrade') === 'websocket') {
|
||||
return new Response('Websocket upgrades are not proxied', { status: 501 });
|
||||
}
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
// Never let a client inject its own PPR headers.
|
||||
for (const name of Object.values(PPRRequestHeaders)) {
|
||||
headers.delete(name);
|
||||
}
|
||||
|
||||
if (
|
||||
!url.pathname.startsWith(URL_PREFIX) ||
|
||||
PASSTHROUGH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))
|
||||
) {
|
||||
return forward(request, url, headers);
|
||||
}
|
||||
|
||||
const publishedURL = `https://${url.pathname.slice(URL_PREFIX.length)}${url.search}`;
|
||||
|
||||
const skip = (reason: string) => {
|
||||
log(`${request.method} ${url.pathname} → no PPR: ${reason}`);
|
||||
return forward(request, url, headers);
|
||||
};
|
||||
|
||||
let content: PublishedSiteContent;
|
||||
try {
|
||||
const result = await lookupPublishedURL(publishedURL);
|
||||
if ('redirect' in result) {
|
||||
return skip(`lookup redirected to ${result.redirect}`);
|
||||
}
|
||||
content = result;
|
||||
} catch (error) {
|
||||
return skip(`lookup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
let revision: string;
|
||||
let defaults: Awaited<ReturnType<typeof getDefaults>>;
|
||||
try {
|
||||
[revision, defaults] = await Promise.all([
|
||||
content.revision ?? getActiveRevision(content),
|
||||
getDefaults(content),
|
||||
]);
|
||||
} catch (error) {
|
||||
return skip(`no revision: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
await setPPRHeaders(headers, { ...content, revision }, defaults, SECRET);
|
||||
|
||||
log(
|
||||
`${request.method} ${url.pathname} → site=${content.site} space=${content.space} revision=${revision}`
|
||||
);
|
||||
|
||||
return forward(request, url, headers);
|
||||
}
|
||||
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
idleTimeout: 60,
|
||||
fetch: handle,
|
||||
});
|
||||
|
||||
log(`listening on http://localhost:${PORT} → ${UPSTREAM}`);
|
||||
log(`try http://localhost:${PORT}/url/gitbook.com/docs`);
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { SitePageNotFound } from '@/components/SitePage';
|
||||
|
||||
export default async function NotFound() {
|
||||
return <SitePageNotFound />;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
|
||||
import { type PPRRouteParams, getPPRPageRouteParams, getPagePathFromParams } from '@/app/utils';
|
||||
import {
|
||||
PPRPageBody,
|
||||
cachedGenerateSitePageMetadata,
|
||||
cachedGenerateSitePageViewport,
|
||||
} from '@/components/SitePage/PPRSitePage';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<PPRRouteParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <PPRPageBody params={await getPPRPageRouteParams(params)} pathname={pathname} />;
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const params = await props.params;
|
||||
return cachedGenerateSitePageViewport(await getPPRPageRouteParams(params));
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
return cachedGenerateSitePageMetadata(await getPPRPageRouteParams(params));
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import type React from 'react';
|
||||
|
||||
import {
|
||||
type PPRRouteLayoutParams,
|
||||
getPPRHeaderRouteParams,
|
||||
getPPRPageRouteParams,
|
||||
getPPRSiteRouteParams,
|
||||
getPPRStaticSiteContext,
|
||||
getPPRStaticSiteScopeContext,
|
||||
getPPRTableOfContentsRouteParams,
|
||||
getPPRVisitorAuthClaims,
|
||||
} from '@/app/utils';
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import {
|
||||
SiteLayout,
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import {
|
||||
PPRAdminToolbar,
|
||||
PPRAnnouncement,
|
||||
PPRFooter,
|
||||
PPRHeader,
|
||||
PPRRevisionIconsProvider,
|
||||
PPRTableOfContents,
|
||||
} from '@/components/SitePage/PPRSitePage';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
|
||||
interface SitePPRLayoutProps {
|
||||
params: Promise<PPRRouteLayoutParams>;
|
||||
}
|
||||
|
||||
export default async function SitePPRLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SitePPRLayoutProps>) {
|
||||
const routeParams = await params;
|
||||
const [siteParams, headerParams, tableOfContentsParams, visitorAuthClaims] = await Promise.all([
|
||||
getPPRSiteRouteParams(routeParams),
|
||||
getPPRHeaderRouteParams(routeParams),
|
||||
getPPRTableOfContentsRouteParams(routeParams),
|
||||
// Each component holds a token narrowed to one scope, so the client claims need their union.
|
||||
getPPRVisitorAuthClaims(routeParams),
|
||||
]);
|
||||
// The layout is rendered on every request, so it only resolves site-level data — under the
|
||||
// scope of the header, whose site fetch it then shares. Everything below the site level is
|
||||
// delegated to the cached components in the slots.
|
||||
const { context } = await getPPRStaticSiteScopeContext(siteParams, 'header');
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout
|
||||
htmlClassName="sheet-open:gutter-stable"
|
||||
bodyClassName="site-background"
|
||||
context={context}
|
||||
>
|
||||
<PPRRevisionIconsProvider params={tableOfContentsParams}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
slots={{
|
||||
announcement: <PPRAnnouncement params={tableOfContentsParams} />,
|
||||
header: <PPRHeader params={headerParams} />,
|
||||
tableOfContents: <PPRTableOfContents params={tableOfContentsParams} />,
|
||||
footer: <PPRFooter params={tableOfContentsParams} />,
|
||||
adminToolbar: <PPRAdminToolbar params={tableOfContentsParams} />,
|
||||
}}
|
||||
// The header and table of contents are cached across pages, so the selection they
|
||||
// were rendered with belongs to another page and has to be resolved on the client.
|
||||
clientNavigationSelection
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
</PPRRevisionIconsProvider>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SitePPRLayoutProps) {
|
||||
const { context } = await getPPRStaticSiteContext(
|
||||
await getPPRPageRouteParams(await params),
|
||||
'header'
|
||||
);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SitePPRLayoutProps) {
|
||||
const { context } = await getPPRStaticSiteContext(
|
||||
await getPPRPageRouteParams(await params),
|
||||
'header'
|
||||
);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import rison from 'rison';
|
||||
|
||||
import * as realContext from '@/lib/context';
|
||||
|
||||
mock.module('server-only', () => ({}));
|
||||
// Only the lookup is stubbed: mocking the whole module would leak into the other test files,
|
||||
// as `mock.module` replaces it for the entire test process.
|
||||
mock.module('@/lib/context', () => ({
|
||||
...realContext,
|
||||
getBaseContext: (input: unknown) => input,
|
||||
fetchSiteContextByURLLookup: async (_baseContext: unknown, data: unknown) => data,
|
||||
fetchSiteScopeContextByURLLookup: async (_baseContext: unknown, data: unknown) => data,
|
||||
}));
|
||||
// Stand in for the exchange endpoint, which is the only thing that can narrow the claims. It is
|
||||
// stubbed at the network boundary rather than with `mock.module`, which would replace
|
||||
// `@/lib/ppr-token` for the entire test process and break its own test file.
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { scope } = JSON.parse(String(init?.body));
|
||||
// Deterministic per scope, so the assertions below can compare tokens across pages.
|
||||
return Response.json({
|
||||
token: jwt.sign(
|
||||
{ exp: Math.floor(Date.now() / 1000) + 3600, claims: { scope } },
|
||||
'secret'
|
||||
),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
const {
|
||||
getPPRHeaderRouteParams,
|
||||
getPPRPageRouteParams,
|
||||
getPPRRouteParams,
|
||||
getPPRSiteRouteParams,
|
||||
getPPRStaticSiteContext,
|
||||
getPPRStaticSiteScopeContext,
|
||||
getPPRTableOfContentsRouteParams,
|
||||
getPPRVisitorAuthClaims,
|
||||
getSiteURLDataFromParams,
|
||||
} = await import('./utils');
|
||||
type PPRRouteParams = import('./utils').PPRRouteParams;
|
||||
|
||||
const apiToken = jwt.sign(
|
||||
{
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
siteClaims: { audience: 'external' },
|
||||
revisionClaims: { account: { tier: 'pro' } },
|
||||
pageClaims: { unsigned: { locale: 'fr' } },
|
||||
},
|
||||
'secret'
|
||||
);
|
||||
|
||||
const routeParams: PPRRouteParams = {
|
||||
mode: 'url',
|
||||
siteURL: 'docs.example.com',
|
||||
siteData: encodeURIComponent(
|
||||
rison.encode({
|
||||
apiToken,
|
||||
site: 'site-id',
|
||||
siteSection: 'page-site-section-id',
|
||||
siteSpace: 'page-site-space-id',
|
||||
space: 'space-id',
|
||||
revision: 'resolved-revision-id',
|
||||
siteBasePath: '/docs/',
|
||||
basePath: '/docs/v/page-variant/',
|
||||
imagesContextId: 'images-context-id',
|
||||
})
|
||||
),
|
||||
revisionId: encodeURIComponent('ppr-revision-id'),
|
||||
revalidationId: encodeURIComponent('revalidation-id'),
|
||||
pprDefaults: encodeURIComponent(
|
||||
rison.encode({
|
||||
siteSection: 'default-site-section-id',
|
||||
siteSpace: 'default-site-space-id',
|
||||
space: 'default-space-id',
|
||||
})
|
||||
),
|
||||
pagePath: 'guide',
|
||||
};
|
||||
|
||||
describe('getPPRRouteParams', () => {
|
||||
it('removes the PPR cache key while preserving resolved content and API token', () => {
|
||||
const params = getPPRRouteParams(routeParams);
|
||||
|
||||
expect(params).toMatchObject({
|
||||
mode: routeParams.mode,
|
||||
siteURL: routeParams.siteURL,
|
||||
pagePath: routeParams.pagePath,
|
||||
});
|
||||
expect(params).not.toHaveProperty('revisionId');
|
||||
expect(params).not.toHaveProperty('revalidationId');
|
||||
expect(params).not.toHaveProperty('pprDefaults');
|
||||
expect(getSiteURLDataFromParams(params)).toMatchObject({
|
||||
apiToken,
|
||||
site: 'site-id',
|
||||
space: 'space-id',
|
||||
revision: 'ppr-revision-id',
|
||||
imagesContextId: 'images-context-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PPR cache region params', () => {
|
||||
const changedRouteParams: PPRRouteParams = {
|
||||
...routeParams,
|
||||
siteData: encodeURIComponent(
|
||||
rison.encode({
|
||||
apiToken: jwt.sign({ siteClaims: {} }, 'other-secret'),
|
||||
site: 'site-id',
|
||||
siteSection: 'new-page-site-section-id',
|
||||
siteSpace: 'new-page-site-space-id',
|
||||
space: 'new-space-id',
|
||||
revision: 'resolved-revision-id',
|
||||
siteBasePath: '/docs/',
|
||||
basePath: '/docs/v/other-variant/',
|
||||
imagesContextId: 'images-context-id',
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
it('keeps header params stable, API token included', async () => {
|
||||
const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams));
|
||||
const changedHeaderData = getSiteURLDataFromParams(
|
||||
await getPPRHeaderRouteParams(changedRouteParams)
|
||||
);
|
||||
|
||||
expect(headerData).toMatchObject({
|
||||
siteSection: 'default-site-section-id',
|
||||
siteSpace: 'default-site-space-id',
|
||||
space: 'default-space-id',
|
||||
// The default variant is served at the site root, so its links can't keep the base
|
||||
// path of the variant the visitor is on.
|
||||
basePath: '/docs/',
|
||||
});
|
||||
// The whole point of the exchange: the site-scoped token no longer varies per page, so the
|
||||
// header can be cached once for the site instead of once per page.
|
||||
expect(headerData).toEqual(changedHeaderData);
|
||||
});
|
||||
|
||||
it('keeps the visited location in the site params, with the header token', async () => {
|
||||
const siteData = getSiteURLDataFromParams(await getPPRSiteRouteParams(routeParams));
|
||||
const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams));
|
||||
|
||||
// The shell renders the variant the visitor is on, it just never reads below the site level.
|
||||
expect(siteData).toMatchObject({
|
||||
siteSection: 'page-site-section-id',
|
||||
siteSpace: 'page-site-space-id',
|
||||
space: 'space-id',
|
||||
basePath: '/docs/v/page-variant/',
|
||||
revision: 'ppr-revision-id',
|
||||
});
|
||||
// Same scope as the header, so they share their site fetch.
|
||||
expect(siteData.apiToken).toBe(headerData.apiToken);
|
||||
});
|
||||
|
||||
it('narrows the header token to the site scope', async () => {
|
||||
const { apiToken: headerToken } = getSiteURLDataFromParams(
|
||||
await getPPRHeaderRouteParams(routeParams)
|
||||
);
|
||||
|
||||
expect(headerToken).not.toBe(apiToken);
|
||||
expect(jwt.decode(headerToken)).toMatchObject({ claims: { scope: 'site' } });
|
||||
});
|
||||
|
||||
it('keeps the visited base path when the defaults point at the visited variant', async () => {
|
||||
const headerData = getSiteURLDataFromParams(
|
||||
await getPPRHeaderRouteParams({
|
||||
...routeParams,
|
||||
pprDefaults: encodeURIComponent(
|
||||
rison.encode({
|
||||
siteSection: 'page-site-section-id',
|
||||
siteSpace: 'page-site-space-id',
|
||||
space: 'space-id',
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
expect(headerData).toMatchObject({
|
||||
siteSpace: 'page-site-space-id',
|
||||
basePath: '/docs/v/page-variant/',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps TOC params stable apart from its current location', async () => {
|
||||
const tocData = getSiteURLDataFromParams(
|
||||
await getPPRTableOfContentsRouteParams(routeParams)
|
||||
);
|
||||
const changedTOCData = getSiteURLDataFromParams(
|
||||
await getPPRTableOfContentsRouteParams(changedRouteParams)
|
||||
);
|
||||
|
||||
expect(tocData).toMatchObject({
|
||||
siteSection: 'page-site-section-id',
|
||||
siteSpace: 'page-site-space-id',
|
||||
space: 'space-id',
|
||||
basePath: '/docs/v/page-variant/',
|
||||
});
|
||||
// The revision-scoped token drops the page claims, so it is shared by every page of the
|
||||
// space; only the location data still varies.
|
||||
expect(tocData.apiToken).toBe(changedTOCData.apiToken);
|
||||
expect({
|
||||
...tocData,
|
||||
siteSection: undefined,
|
||||
siteSpace: undefined,
|
||||
space: undefined,
|
||||
basePath: undefined,
|
||||
}).toEqual({
|
||||
...changedTOCData,
|
||||
siteSection: undefined,
|
||||
siteSpace: undefined,
|
||||
space: undefined,
|
||||
basePath: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the TOC and page tokens to their own scopes', async () => {
|
||||
const tocData = getSiteURLDataFromParams(
|
||||
await getPPRTableOfContentsRouteParams(routeParams)
|
||||
);
|
||||
const pageData = getSiteURLDataFromParams(await getPPRPageRouteParams(routeParams));
|
||||
|
||||
expect(jwt.decode(tocData.apiToken)).toMatchObject({ claims: { scope: 'revision' } });
|
||||
expect(jwt.decode(pageData.apiToken)).toMatchObject({ claims: { scope: 'page' } });
|
||||
expect(tocData.apiToken).not.toBe(pageData.apiToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPPRVisitorAuthClaims', () => {
|
||||
it('resolves every scope at once through a full exchange', async () => {
|
||||
// The scoped tokens each carry one bucket, so the client claims can only come from `full`.
|
||||
expect(await getPPRVisitorAuthClaims(routeParams)).toEqual({ scope: 'full' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPPRStaticSiteScopeContext', () => {
|
||||
it('resolves the site scope from the supplied params', async () => {
|
||||
const { context } = await getPPRStaticSiteScopeContext(
|
||||
getPPRRouteParams(routeParams),
|
||||
'header'
|
||||
);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
apiToken,
|
||||
revision: 'ppr-revision-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPPRStaticSiteContext', () => {
|
||||
it('uses the supplied API token without resolving published content again', async () => {
|
||||
const { context } = await getPPRStaticSiteContext(getPPRRouteParams(routeParams), 'body');
|
||||
|
||||
expect(context).toMatchObject({
|
||||
apiToken,
|
||||
revision: 'ppr-revision-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,20 @@ import rison from 'rison';
|
||||
|
||||
import type { SiteAPIToken } from '@gitbook/api';
|
||||
|
||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context';
|
||||
import {
|
||||
type VisitorAuthClaims,
|
||||
getVisitorAuthClaims,
|
||||
getVisitorAuthClaimsFromToken,
|
||||
} from '@/lib/adaptive';
|
||||
import type { PPRCacheScope } from '@/lib/cache-tags';
|
||||
import {
|
||||
type SiteURLData,
|
||||
fetchSiteContextByURLLookup,
|
||||
fetchSiteScopeContextByURLLookup,
|
||||
getBaseContext,
|
||||
} from '@/lib/context';
|
||||
import { getDynamicCustomizationSettings } from '@/lib/customization';
|
||||
import { PPR_TOKEN_SCOPE, type PPRTokenScope, exchangePPRToken } from '@/lib/ppr-token';
|
||||
|
||||
export type RouteParamMode = 'url-host' | 'url';
|
||||
|
||||
@@ -24,10 +35,50 @@ export type RouteParams = RouteLayoutParams & {
|
||||
pagePath: string;
|
||||
};
|
||||
|
||||
export type PPRRouteLayoutParams = RouteLayoutParams & {
|
||||
revisionId: string;
|
||||
revalidationId: string;
|
||||
pprDefaults: string;
|
||||
};
|
||||
|
||||
export type PPRRouteParams = PPRRouteLayoutParams & {
|
||||
pagePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the static context when rendering statically a site.
|
||||
*/
|
||||
export async function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
export async function getStaticSiteContext(
|
||||
params: RouteLayoutParams,
|
||||
options?: { pprScope?: PPRCacheScope }
|
||||
) {
|
||||
const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options);
|
||||
|
||||
return {
|
||||
context: await fetchSiteContextByURLLookup(baseContext, siteURLData),
|
||||
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site-level part of the static context, without resolving the space and its revision.
|
||||
*/
|
||||
export async function getStaticSiteScopeContext(
|
||||
params: RouteLayoutParams,
|
||||
options?: { pprScope?: PPRCacheScope }
|
||||
) {
|
||||
const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options);
|
||||
|
||||
return {
|
||||
context: await fetchSiteScopeContextByURLLookup(baseContext, siteURLData),
|
||||
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the params of a static route and open a base context for them.
|
||||
*/
|
||||
function getStaticBaseContext(params: RouteLayoutParams, options?: { pprScope?: PPRCacheScope }) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
@@ -38,18 +89,15 @@ export async function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
forbidden();
|
||||
}
|
||||
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
getBaseContext({
|
||||
return {
|
||||
baseContext: getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
...(options?.pprScope ? { pprScope: options.pprScope } : {}),
|
||||
}),
|
||||
siteURLData
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
|
||||
siteURLData,
|
||||
decoded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,3 +180,190 @@ export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
export function getPPRRouteParams(params: PPRRouteParams): RouteParams;
|
||||
export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams;
|
||||
/**
|
||||
* Project PPR route params for the current page, without PPR-only cache inputs.
|
||||
*/
|
||||
export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams {
|
||||
const { revisionId, revalidationId, pprDefaults: _, ...routeParams } = params;
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
return {
|
||||
...routeParams,
|
||||
siteData: encodeURIComponent(
|
||||
rison.encode({
|
||||
...siteURLData,
|
||||
revision: getPPRRouteParam(revisionId, 'revision ID'),
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project PPR params for the current page, with a token scoped to the page claims.
|
||||
*/
|
||||
export function getPPRPageRouteParams(params: PPRRouteParams): Promise<RouteParams>;
|
||||
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams>;
|
||||
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
|
||||
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project PPR params for the site-level shell, with a token scoped to the site claims.
|
||||
* Unlike the header params, they keep the location data of the visited page: the shell renders the
|
||||
* current variant, it just never reads anything below the site level.
|
||||
*/
|
||||
export function getPPRSiteRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
|
||||
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project PPR params for the shared header by replacing page-varying location data.
|
||||
*/
|
||||
export async function getPPRHeaderRouteParams(
|
||||
params: PPRRouteLayoutParams
|
||||
): Promise<RouteLayoutParams> {
|
||||
const routeParams = getPPRRouteParams(params);
|
||||
const { revision, ...siteURLData } = getSiteURLDataFromParams(routeParams);
|
||||
const defaults = getPPRDefaults(params);
|
||||
|
||||
return withExchangedPPRToken(
|
||||
{
|
||||
...routeParams,
|
||||
siteData: encodeSiteData({
|
||||
...siteURLData,
|
||||
// For the header, we keep site section and space data from the PPR defaults, so that the header can be cached across all pages in a site.
|
||||
siteSection: defaults.siteSection ?? undefined,
|
||||
siteSpace: defaults.siteSpace,
|
||||
space: defaults.space,
|
||||
// The base path has to describe the same variant as the ids above, or the header
|
||||
// prefixes one variant's page paths with another variant's base path. The site default
|
||||
// variant is published at the site root; defaults pointing at the visited variant keep
|
||||
// its own base path.
|
||||
basePath:
|
||||
defaults.siteSpace === siteURLData.siteSpace
|
||||
? siteURLData.basePath
|
||||
: siteURLData.siteBasePath,
|
||||
}),
|
||||
},
|
||||
PPR_TOKEN_SCOPE.header
|
||||
);
|
||||
}
|
||||
|
||||
/** rison can't encode undefined values, so they are dropped like the middleware does. */
|
||||
function encodeSiteData(siteURLData: Record<string, unknown>): string {
|
||||
return encodeURIComponent(
|
||||
rison.encode(
|
||||
Object.fromEntries(
|
||||
Object.entries(siteURLData).filter(([_, value]) => typeof value !== 'undefined')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project PPR params for the table of contents, keeping its current location data.
|
||||
* The table of contents depends only on the space you're in and the claims of that revision, not on the page,
|
||||
* and the layout params carry no page path, so only the token has to be narrowed.
|
||||
*/
|
||||
export function getPPRTableOfContentsRouteParams(
|
||||
params: PPRRouteLayoutParams
|
||||
): Promise<RouteLayoutParams> {
|
||||
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.toc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the revalidation token carried by the PPR params with a content API token narrowed to
|
||||
* `scope`. Components sharing a scope then share a token, and with it a cache entry.
|
||||
*/
|
||||
async function withExchangedPPRToken<T extends RouteLayoutParams>(
|
||||
params: T,
|
||||
scope: PPRTokenScope
|
||||
): Promise<T> {
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
return {
|
||||
...params,
|
||||
siteData: encodeSiteData({
|
||||
...siteURLData,
|
||||
apiToken: await exchangePPRToken(siteURLData.apiToken, scope),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the claims the client should resolve adaptive content with. Each component holds a token
|
||||
* narrowed to a single scope, so the union has to come from a `full` exchange.
|
||||
*/
|
||||
export async function getPPRVisitorAuthClaims(
|
||||
params: PPRRouteLayoutParams
|
||||
): Promise<VisitorAuthClaims> {
|
||||
const { apiToken } = getSiteURLDataFromParams(params);
|
||||
const fullToken = await exchangePPRToken(apiToken, 'full');
|
||||
|
||||
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(fullToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the static context for a PPR component. The scope partitions the cache entries and scopes
|
||||
* the tags they emit, so the component and its data are revalidated as one unit.
|
||||
*/
|
||||
export async function getPPRStaticSiteContext(params: RouteLayoutParams, pprScope: PPRCacheScope) {
|
||||
return getStaticSiteContext(params, { pprScope });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site-level context for a PPR component, without resolving the space and its revision.
|
||||
*/
|
||||
export async function getPPRStaticSiteScopeContext(
|
||||
params: RouteLayoutParams,
|
||||
pprScope: PPRCacheScope
|
||||
) {
|
||||
return getStaticSiteScopeContext(params, { pprScope });
|
||||
}
|
||||
|
||||
function getPPRRouteParam(encodedParam: string, name: string): string {
|
||||
try {
|
||||
return decodeURIComponent(encodedParam);
|
||||
} catch (error) {
|
||||
console.error(`Returning 404 after failing to decode PPR ${name}: ${error}`);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
type PPRDefaults = {
|
||||
siteSection: string | null;
|
||||
siteSpace: string;
|
||||
space: string;
|
||||
};
|
||||
|
||||
function getPPRDefaults(params: PPRRouteLayoutParams): PPRDefaults {
|
||||
let defaults: unknown;
|
||||
try {
|
||||
defaults = rison.decode(decodeURIComponent(params.pprDefaults));
|
||||
} catch (error) {
|
||||
console.error(`Returning 404 after failing to decode PPR defaults: ${error}`);
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (
|
||||
!defaults ||
|
||||
typeof defaults !== 'object' ||
|
||||
Array.isArray(defaults) ||
|
||||
!('siteSection' in defaults) ||
|
||||
!('siteSpace' in defaults) ||
|
||||
!('space' in defaults) ||
|
||||
(defaults.siteSection !== null && typeof defaults.siteSection !== 'string') ||
|
||||
typeof defaults.siteSpace !== 'string' ||
|
||||
!defaults.siteSpace ||
|
||||
typeof defaults.space !== 'string' ||
|
||||
!defaults.space
|
||||
) {
|
||||
console.error(`Returning 404 after decoding invalid PPR defaults: ${params.pprDefaults}`);
|
||||
notFound();
|
||||
}
|
||||
|
||||
return defaults as PPRDefaults;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
GITBOOK_EXCHANGE_TOKEN_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
@@ -28,6 +29,7 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_EXCHANGE_TOKEN_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SpacesDropdown(
|
||||
id: siteSp.id,
|
||||
title: getLocalizedTitle(siteSp, currentLanguage),
|
||||
url: getSiteSpaceURL(context, siteSp),
|
||||
isActive: siteSp.id === siteSpace.id,
|
||||
path: siteSp.path,
|
||||
spaceId: siteSp.space.id,
|
||||
}));
|
||||
|
||||
@@ -46,6 +46,7 @@ export function SpacesDropdown(
|
||||
className={className}
|
||||
dropdownClassName={dropdownClassName}
|
||||
slimSpaces={slimSpaces}
|
||||
siteSpaceId={siteSpace.id}
|
||||
curPath={siteSpace.path}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
import { useSelectedSiteSpaceId } from '../hooks';
|
||||
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
|
||||
import { DropdownMenu } from '../primitives/DropdownMenu';
|
||||
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
|
||||
import { SpacesDropdownMenuItems, type VariantSpace } from './SpacesDropdownMenuItem';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
@@ -17,16 +18,16 @@ export function SpacesDropdownClient(props: {
|
||||
variant: ButtonProps['variant'];
|
||||
className?: ClassValue;
|
||||
dropdownClassName: string;
|
||||
slimSpaces: {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
spaceId: string;
|
||||
}[];
|
||||
slimSpaces: VariantSpace[];
|
||||
/** Site space the server rendered as selected, used as a fallback. */
|
||||
siteSpaceId: string;
|
||||
curPath: string;
|
||||
}) {
|
||||
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
|
||||
const { title, icon, variant, className, dropdownClassName, slimSpaces, siteSpaceId, curPath } =
|
||||
props;
|
||||
|
||||
const selectedId = useSelectedSiteSpaceId(siteSpaceId);
|
||||
const selected = selectedId ? slimSpaces.find((space) => space.id === selectedId) : undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
@@ -40,11 +41,15 @@ export function SpacesDropdownClient(props: {
|
||||
trailing={<ToggleChevron />}
|
||||
className={tcls('bg-tint-base', className)}
|
||||
>
|
||||
<span className="button-content">{title}</span>
|
||||
<span className="button-content">{selected?.title ?? title}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
|
||||
<SpacesDropdownMenuItems
|
||||
slimSpaces={slimSpaces}
|
||||
selectedId={selectedId}
|
||||
curPath={selected?.path ?? curPath}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface VariantSpace {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
path: string;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
@@ -66,8 +66,12 @@ export function SpacesDropdownMenuItem(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function SpacesDropdownMenuItems(props: { slimSpaces: VariantSpace[]; curPath: string }) {
|
||||
const { slimSpaces, curPath } = props;
|
||||
export function SpacesDropdownMenuItems(props: {
|
||||
slimSpaces: VariantSpace[];
|
||||
selectedId: string | null;
|
||||
curPath: string;
|
||||
}) {
|
||||
const { slimSpaces, selectedId, curPath } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,7 +79,7 @@ export function SpacesDropdownMenuItems(props: { slimSpaces: VariantSpace[]; cur
|
||||
<SpacesDropdownMenuItem
|
||||
key={space.id}
|
||||
variantSpace={space}
|
||||
active={space.isActive}
|
||||
active={space.id === selectedId}
|
||||
currentSpacePath={curPath}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -123,9 +123,12 @@ export async function PageHeader(props: {
|
||||
id: siteSpace.id,
|
||||
title: getLocalizedTitle(siteSpace, context.locale),
|
||||
url: getSiteSpaceURL(context, siteSpace),
|
||||
isActive: siteSpace.id === currentSiteSpace.id,
|
||||
path: siteSpace.path,
|
||||
spaceId: siteSpace.space.id,
|
||||
})),
|
||||
// The breadcrumbs are rendered with the page's own context, so the selection is
|
||||
// accurate here and doesn't need the client-side patching the shared shell does.
|
||||
selectedId: currentSiteSpace.id,
|
||||
curPath: currentSiteSpace.path,
|
||||
},
|
||||
});
|
||||
@@ -329,7 +332,7 @@ type BreadcrumbContextCrumb = {
|
||||
* Present only for the variant crumb: the header's variant switcher data, whose dropdown entries
|
||||
* resolve to the current page in each variant. Rendered instead of `siblings`.
|
||||
*/
|
||||
variantSwitcher?: { slimSpaces: VariantSpace[]; curPath: string };
|
||||
variantSwitcher?: { slimSpaces: VariantSpace[]; selectedId: string; curPath: string };
|
||||
};
|
||||
|
||||
/** Render a context crumb (section group / section / variant) with its sibling dropdown. */
|
||||
@@ -345,6 +348,7 @@ function ContextCrumb({ crumb }: { crumb: BreadcrumbContextCrumb }) {
|
||||
{crumb.variantSwitcher ? (
|
||||
<SpacesDropdownMenuItems
|
||||
slimSpaces={crumb.variantSwitcher.slimSpaces}
|
||||
selectedId={crumb.variantSwitcher.selectedId}
|
||||
curPath={crumb.variantSwitcher.curPath}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import './globals.css';
|
||||
import { getContentLocale, getSpaceLanguage } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import type { GitBookAnyContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||
import {
|
||||
getContentInlineIconSourceRequests,
|
||||
@@ -73,7 +73,7 @@ export async function CustomizationRootLayout(props: {
|
||||
/** The class name to apply to the body element. */
|
||||
bodyClassName?: string;
|
||||
forcedTheme?: CustomizationDefaultThemeMode | null;
|
||||
context: GitBookAnyContext;
|
||||
context: GitBookAnyContext | GitBookSiteScopeContext;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
|
||||
@@ -111,12 +111,15 @@ export async function CustomizationRootLayout(props: {
|
||||
preloadFont(headingFontData);
|
||||
}
|
||||
const iconStyle = getCustomizationIconStyle(customization);
|
||||
// A site scope context has no revision: its page and tag icons are provided further down the
|
||||
// tree, by a component that resolves the revision under its own cache scope.
|
||||
const revision = 'revision' in context ? context.revision : null;
|
||||
const iconSources = await getInlineIconSources([
|
||||
...getDefaultInlineIconSourceRequests(iconStyle),
|
||||
...getContentInlineIconSourceRequests({
|
||||
iconStyle,
|
||||
pages: context.revision.pages,
|
||||
tags: context.revision.tags,
|
||||
pages: revision?.pages,
|
||||
tags: revision?.tags,
|
||||
sections:
|
||||
'sections' in context
|
||||
? [...(context.sections?.list ?? []), ...(context.visibleSections?.list ?? [])]
|
||||
|
||||
@@ -6,15 +6,17 @@ import * as ReactDOM from 'react-dom';
|
||||
import { CustomizationDefaultThemeMode } from '@gitbook/api';
|
||||
|
||||
import { AIContextProvider } from '../AI';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||
import { SiteLayoutClientContexts } from './SiteLayoutClientContexts';
|
||||
import { AdminToolbar } from '@/components/AdminToolbar';
|
||||
import { CookiesToast } from '@/components/Cookies';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { LoadIntegrations } from '@/components/Integrations';
|
||||
import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import { SpaceHeader, SpaceLayout, SpaceTableOfContents } from '@/components/SpaceLayout';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { isSiteIndexable } from '@/lib/seo';
|
||||
@@ -45,16 +47,67 @@ function isDeferrableScript(script: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout when rendering a site.
|
||||
* Parts of the layout that can only be rendered from a full site context, as they read the
|
||||
* revision. A site scope context has to provide them itself.
|
||||
*/
|
||||
export async function SiteLayout(props: {
|
||||
context: GitBookSiteContext;
|
||||
export type SiteLayoutSlots = {
|
||||
announcement: React.ReactNode;
|
||||
header: React.ReactNode;
|
||||
tableOfContents: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
adminToolbar: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the default slots of the layout from a full site context.
|
||||
*/
|
||||
export function getSiteLayoutSlots(context: GitBookSiteContext): SiteLayoutSlots {
|
||||
return {
|
||||
announcement: <Announcement context={context} />,
|
||||
header: <SpaceHeader context={context} />,
|
||||
tableOfContents: <SpaceTableOfContents context={context} />,
|
||||
footer: <Footer context={context} />,
|
||||
adminToolbar: <AdminToolbar context={context} />,
|
||||
};
|
||||
}
|
||||
|
||||
type SiteLayoutProps = {
|
||||
forcedTheme?: CustomizationDefaultThemeMode | null;
|
||||
withTracking: boolean;
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { context, forcedTheme, withTracking, visitorAuthClaims, children } = props;
|
||||
clientNavigationSelection?: boolean;
|
||||
} & (
|
||||
| {
|
||||
context: GitBookSiteContext;
|
||||
/** Overrides of the slots that would otherwise be rendered from the context. */
|
||||
slots?: Partial<SiteLayoutSlots>;
|
||||
}
|
||||
| {
|
||||
context: GitBookSiteScopeContext;
|
||||
/** A site scope context can't render any of them, so they are all required. */
|
||||
slots: SiteLayoutSlots;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Layout when rendering a site.
|
||||
*/
|
||||
export async function SiteLayout(props: SiteLayoutProps) {
|
||||
const {
|
||||
context,
|
||||
forcedTheme,
|
||||
withTracking,
|
||||
visitorAuthClaims,
|
||||
children,
|
||||
clientNavigationSelection,
|
||||
} = props;
|
||||
|
||||
// The prop type guarantees `slots` is complete whenever the context can't build them itself.
|
||||
const slots = {
|
||||
...('revision' in context ? getSiteLayoutSlots(context) : null),
|
||||
...props.slots,
|
||||
} as SiteLayoutSlots;
|
||||
|
||||
const { customization } = context;
|
||||
const { ai } = customization;
|
||||
@@ -111,6 +164,11 @@ export async function SiteLayout(props: {
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
announcementSlot={slots.announcement}
|
||||
headerSlot={slots.header}
|
||||
tableOfContentsSlot={slots.tableOfContents}
|
||||
footerSlot={slots.footer}
|
||||
clientNavigationSelection={clientNavigationSelection}
|
||||
>
|
||||
{children}
|
||||
</SpaceLayout>
|
||||
@@ -133,12 +191,14 @@ export async function SiteLayout(props: {
|
||||
|
||||
<RocketLoaderDetector />
|
||||
|
||||
<AdminToolbar context={context} />
|
||||
{slots.adminToolbar}
|
||||
</SiteLayoutClientContexts>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateSiteLayoutViewport(context: GitBookSiteContext): Promise<Viewport> {
|
||||
export async function generateSiteLayoutViewport(
|
||||
context: GitBookSiteContext | GitBookSiteScopeContext
|
||||
): Promise<Viewport> {
|
||||
const { customization } = context;
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
@@ -156,7 +216,9 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSiteLayoutMetadata(context: GitBookSiteContext): Promise<Metadata> {
|
||||
export async function generateSiteLayoutMetadata(
|
||||
context: GitBookSiteContext | GitBookSiteScopeContext
|
||||
): Promise<Metadata> {
|
||||
const { site, customization, linker, imageResizer } = context;
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cacheLife } from 'next/cache';
|
||||
|
||||
import { IconsProvider } from '@gitbook/icons';
|
||||
|
||||
import { SitePage, generateSitePageMetadata, generateSitePageViewport } from './SitePage';
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
type RouteParams,
|
||||
getPPRStaticSiteContext,
|
||||
getPagePathFromParams,
|
||||
} from '@/app/utils';
|
||||
import { AdminToolbar } from '@/components/AdminToolbar';
|
||||
import { Announcement } from '@/components/Announcement';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { SpaceHeader, SpaceTableOfContents } from '@/components/SpaceLayout';
|
||||
import {
|
||||
getContentInlineIconSourceRequests,
|
||||
getCustomizationIconStyle,
|
||||
getInlineIconSources,
|
||||
} from '@/lib/icons/inline';
|
||||
|
||||
// Each component below resolves its context under its own PPR cache scope. The scope is part of the
|
||||
// cache key of every data fetcher, so the tags they emit are scoped too and propagate up to the
|
||||
// entry here — making the component and the data it read a single revalidatable unit. No explicit
|
||||
// `cacheTag` is needed, and adding one would only duplicate a propagated tag.
|
||||
|
||||
/**
|
||||
* Render the header from cache without carrying a request-scoped data fetcher into the cache key.
|
||||
*/
|
||||
export async function PPRHeader(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'header');
|
||||
|
||||
return <SpaceHeader context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the table of contents independently so navigation changes do not invalidate the header.
|
||||
*/
|
||||
export async function PPRTableOfContents(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <SpaceTableOfContents context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the announcement banner, which resolves its link against the revision.
|
||||
*/
|
||||
export async function PPRAnnouncement(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <Announcement context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the footer, whose links resolve against the revision.
|
||||
*/
|
||||
export async function PPRFooter(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <Footer context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin toolbar, which reports on the revision and its change request.
|
||||
*/
|
||||
export async function PPRAdminToolbar(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <AdminToolbar context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the icons of the pages and tags of the revision to the tree below.
|
||||
*
|
||||
* The header and the body both render them, so they are resolved once here rather than duplicated
|
||||
* in every per-page cache entry. The provider merges with the one of the root layout, which carries
|
||||
* the site-level icons.
|
||||
*/
|
||||
export async function PPRRevisionIconsProvider(
|
||||
props: React.PropsWithChildren<{ params: RouteLayoutParams }>
|
||||
) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
const iconSources = await getInlineIconSources(
|
||||
getContentInlineIconSourceRequests({
|
||||
iconStyle: getCustomizationIconStyle(context.customization),
|
||||
pages: context.revision.pages,
|
||||
tags: context.revision.tags,
|
||||
})
|
||||
);
|
||||
|
||||
return <IconsProvider iconSources={iconSources}>{props.children}</IconsProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page body independently from the shared navigation shell.
|
||||
*/
|
||||
export async function PPRPageBody(props: { params: RouteParams; pathname: string }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'body');
|
||||
|
||||
return <SitePage context={context} pageParams={{ pathname: props.pathname }} staticRoute />;
|
||||
}
|
||||
|
||||
export async function cachedGenerateSitePageMetadata(routeParams: RouteParams): Promise<Metadata> {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(routeParams, 'body');
|
||||
const pathname = getPagePathFromParams(routeParams);
|
||||
|
||||
return generateSitePageMetadata({ context, pageParams: { pathname } });
|
||||
}
|
||||
|
||||
export async function cachedGenerateSitePageViewport(routeParams: RouteParams): Promise<Viewport> {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(routeParams, 'body');
|
||||
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './SitePageNotFound';
|
||||
export * from './SitePage';
|
||||
export * from './PPRSitePage';
|
||||
export * from './fetch';
|
||||
export * from './SitePageSkeleton';
|
||||
|
||||
@@ -6,12 +6,11 @@ import React from 'react';
|
||||
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
import { useToggleAnimation } from '../hooks';
|
||||
import { useSelectedSiteSectionId, useToggleAnimation } from '../hooks';
|
||||
import { Link, ToggleChevron } from '../primitives';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import type {
|
||||
ClientSiteNavigationItem,
|
||||
ClientSiteSection,
|
||||
ClientSiteSectionGroup,
|
||||
ClientSiteSections,
|
||||
} from './encodeClientSiteSections';
|
||||
@@ -30,6 +29,8 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const currentSectionId = useSelectedSiteSectionId(currentSection.id);
|
||||
|
||||
return (
|
||||
sectionsAndGroups.length > 0 && (
|
||||
<nav
|
||||
@@ -43,7 +44,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
|
||||
orientation="vertical"
|
||||
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
|
||||
className="pb-4"
|
||||
active={`#${currentSection.id}`}
|
||||
active={currentSectionId ? `#${currentSectionId}` : undefined}
|
||||
>
|
||||
<div className="flex w-full flex-col px-2">
|
||||
{sectionsAndGroups.map((item) => {
|
||||
@@ -53,7 +54,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
|
||||
<SiteSectionGroupItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
currentSection={currentSection}
|
||||
currentSectionId={currentSectionId}
|
||||
/>
|
||||
);
|
||||
case 'site-section':
|
||||
@@ -63,7 +64,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
|
||||
item={item}
|
||||
isActive={
|
||||
item.object === 'site-section' &&
|
||||
item.id === currentSection.id
|
||||
item.id === currentSectionId
|
||||
}
|
||||
key={item.id}
|
||||
/>
|
||||
@@ -139,13 +140,14 @@ export function SiteSectionListItem(props: {
|
||||
|
||||
export function SiteSectionGroupItem(props: {
|
||||
group: ClientSiteSectionGroup;
|
||||
currentSection: ClientSiteSection;
|
||||
currentSectionId: string | null;
|
||||
level?: number;
|
||||
}) {
|
||||
const { group, currentSection, level = 0 } = props;
|
||||
const { group, currentSectionId, level = 0 } = props;
|
||||
|
||||
const hasDescendants = group.children.length > 0;
|
||||
const isActiveGroup = Boolean(findSectionInGroup(group, currentSection.id));
|
||||
const isActiveGroup =
|
||||
currentSectionId !== null && Boolean(findSectionInGroup(group, currentSectionId));
|
||||
const shouldOpen = hasDescendants && isActiveGroup;
|
||||
const [isOpen, setIsOpen] = React.useState(shouldOpen);
|
||||
|
||||
@@ -236,7 +238,7 @@ export function SiteSectionGroupItem(props: {
|
||||
item={child}
|
||||
isActive={
|
||||
child.object === 'site-section' &&
|
||||
child.id === currentSection.id
|
||||
child.id === currentSectionId
|
||||
}
|
||||
key={child.id}
|
||||
/>
|
||||
@@ -245,7 +247,7 @@ export function SiteSectionGroupItem(props: {
|
||||
return (
|
||||
<SiteSectionGroupItem
|
||||
group={child}
|
||||
currentSection={currentSection}
|
||||
currentSectionId={currentSectionId}
|
||||
key={child.id}
|
||||
level={level + 1}
|
||||
/>
|
||||
|
||||
@@ -5,13 +5,10 @@ import React from 'react';
|
||||
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
import { useSelectedSiteSectionId } from '../hooks';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import type {
|
||||
ClientSiteSection,
|
||||
ClientSiteSections,
|
||||
ClientSiteStructureNode,
|
||||
} from './encodeClientSiteSections';
|
||||
import type { ClientSiteSections, ClientSiteStructureNode } from './encodeClientSiteSections';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
import { Button, Link, ToggleChevron } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -47,6 +44,8 @@ export function SiteSectionTabs(props: {
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const currentSectionId = useSelectedSiteSectionId(currentSection.id);
|
||||
|
||||
// Portalled into the tabs container rather than <body>, to keep inheriting the header's theming.
|
||||
const containerRef = React.useRef<HTMLElement>(null);
|
||||
|
||||
@@ -79,7 +78,7 @@ export function SiteSectionTabs(props: {
|
||||
? 'md:-mr-8 -mr-4 sm:-mr-6'
|
||||
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
|
||||
)}
|
||||
active={`#${currentSection.id}`}
|
||||
active={currentSectionId ? `#${currentSectionId}` : undefined}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: true,
|
||||
@@ -100,10 +99,11 @@ export function SiteSectionTabs(props: {
|
||||
const isGroup = structureItem.object === 'site-section-group';
|
||||
const isActiveGroup =
|
||||
isGroup &&
|
||||
Boolean(findSectionInGroup(structureItem, currentSection.id));
|
||||
currentSectionId !== null &&
|
||||
Boolean(findSectionInGroup(structureItem, currentSectionId));
|
||||
const isActive =
|
||||
isActiveGroup ||
|
||||
(structureItem.object === 'site-section' && id === currentSection.id);
|
||||
(structureItem.object === 'site-section' && id === currentSectionId);
|
||||
return (
|
||||
<NavigationMenu.Item key={id} value={id} id={id}>
|
||||
{isGroup && structureItem.children.length > 0 ? (
|
||||
@@ -129,7 +129,7 @@ export function SiteSectionTabs(props: {
|
||||
>
|
||||
<SectionGroupTileList
|
||||
items={structureItem.children}
|
||||
currentSection={currentSection}
|
||||
currentSectionId={currentSectionId}
|
||||
/>
|
||||
</NavigationMenu.Content>
|
||||
</>
|
||||
@@ -227,9 +227,9 @@ const SectionTab = React.forwardRef(function SectionTab(
|
||||
*/
|
||||
function SectionGroupTileList(props: {
|
||||
items: ClientSiteStructureNode[];
|
||||
currentSection: ClientSiteSection;
|
||||
currentSectionId: string | null;
|
||||
}) {
|
||||
const { items, currentSection } = props;
|
||||
const { items, currentSectionId } = props;
|
||||
|
||||
// Separate navigable items (sections, external links) from grouped items.
|
||||
const navigableItems = items.filter((item) => item.object !== 'site-section-group');
|
||||
@@ -269,7 +269,7 @@ function SectionGroupTileList(props: {
|
||||
<SectionGroupTile
|
||||
key={item.id}
|
||||
child={item}
|
||||
currentSection={currentSection}
|
||||
currentSectionId={currentSectionId}
|
||||
invertIcon={navigableItemsRecessed}
|
||||
/>
|
||||
))}
|
||||
@@ -309,7 +309,7 @@ function SectionGroupTileList(props: {
|
||||
<SectionGroupTile
|
||||
key={group.id}
|
||||
child={group}
|
||||
currentSection={currentSection}
|
||||
currentSectionId={currentSectionId}
|
||||
isMasonry={isMasonryLayout}
|
||||
invertIcon={groupsRecessed}
|
||||
/>
|
||||
@@ -340,16 +340,16 @@ function SectionGroupTileList(props: {
|
||||
*/
|
||||
function SectionGroupTile(props: {
|
||||
child: ClientSiteStructureNode;
|
||||
currentSection: ClientSiteSection;
|
||||
currentSectionId: string | null;
|
||||
invertIcon?: boolean;
|
||||
/** Whether the tile is a top-level group of the dropdown's masonry layout. */
|
||||
isMasonry?: boolean;
|
||||
}) {
|
||||
const { child, currentSection, invertIcon, isMasonry } = props;
|
||||
const { child, currentSectionId, invertIcon, isMasonry } = props;
|
||||
|
||||
if (child.object !== 'site-section-group') {
|
||||
const { url, icon, title, description } = child;
|
||||
const isActive = child.object === 'site-section' && child.id === currentSection.id;
|
||||
const isActive = child.object === 'site-section' && child.id === currentSectionId;
|
||||
return (
|
||||
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-tile-max-width)]">
|
||||
<Link
|
||||
@@ -429,7 +429,7 @@ function SectionGroupTile(props: {
|
||||
<SectionGroupTile
|
||||
key={nestedChild.id}
|
||||
child={nestedChild}
|
||||
currentSection={currentSection}
|
||||
currentSectionId={currentSectionId}
|
||||
invertIcon={invertIcon}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,9 +6,8 @@ import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { AIChatProvider } from '../AI';
|
||||
import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat, AskAITextSelection } from '../AIChat';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
import { ClientNavigationSelectionProvider, CurrentContentProvider } from '../hooks';
|
||||
import { InsightsProvider, VisitorProvider } from '../Insights';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||
@@ -20,17 +19,16 @@ import {
|
||||
} from '../SiteSections';
|
||||
import { categorizeVariants } from './categorizeVariants';
|
||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Header, HeaderLogo } from '@/components/Header';
|
||||
import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
type SpaceLayoutProps = {
|
||||
context: GitBookSiteContext;
|
||||
context: GitBookSiteScopeContext;
|
||||
|
||||
/** Whether to enable tracking of events into site insights. */
|
||||
withTracking: boolean;
|
||||
@@ -43,14 +41,41 @@ type SpaceLayoutProps = {
|
||||
|
||||
/** The children of the layout. */
|
||||
children: React.ReactNode;
|
||||
|
||||
// The slots below all read the revision, which a site scope context doesn't carry, so they are
|
||||
// rendered by the caller rather than from `context`.
|
||||
|
||||
/** Announcement banner, rendered above the header. */
|
||||
announcementSlot?: React.ReactNode;
|
||||
|
||||
/** Site header. */
|
||||
headerSlot?: React.ReactNode;
|
||||
|
||||
/** Table of contents. */
|
||||
tableOfContentsSlot?: React.ReactNode;
|
||||
|
||||
/** Site footer, rendered only when the customization asks for one. */
|
||||
footerSlot?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Resolve the selected section/space/page on the client instead of trusting the server render.
|
||||
* Set when the navigation shell comes from a cache shared across pages (PPR).
|
||||
*/
|
||||
clientNavigationSelection?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provide all contexts for a space.
|
||||
*/
|
||||
export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
const { context, withTracking, visitorAuthClaims, aiChatRenderMessageOptions, children } =
|
||||
props;
|
||||
const {
|
||||
context,
|
||||
withTracking,
|
||||
visitorAuthClaims,
|
||||
aiChatRenderMessageOptions,
|
||||
clientNavigationSelection = false,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const { customization } = context;
|
||||
const siteAdaptiveAuthLoginHref =
|
||||
@@ -84,7 +109,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
siteSectionId={context.sections?.current?.id ?? null}
|
||||
siteSpaceId={context.siteSpace.id}
|
||||
siteShareKey={context.shareKey ?? null}
|
||||
spaceId={context.space.id}
|
||||
spaceId={context.siteSpace.space.id}
|
||||
revisionId={context.revisionId}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
@@ -97,7 +122,11 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
renderMessageOptions={aiChatRenderMessageOptions}
|
||||
withPageFeedback={customization.feedback.enabled}
|
||||
>
|
||||
{children}
|
||||
<ClientNavigationSelectionProvider
|
||||
enabled={clientNavigationSelection}
|
||||
>
|
||||
{children}
|
||||
</ClientNavigationSelectionProvider>
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
</VisitorProvider>
|
||||
@@ -107,17 +136,118 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the site header from a site context.
|
||||
*/
|
||||
export function SpaceHeader(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const withTopHeader = context.customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
return (
|
||||
<Header
|
||||
withTopHeader={withTopHeader}
|
||||
variants={categorizeVariants(context)}
|
||||
context={context}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the table of contents and its site-specific controls from a site context.
|
||||
*/
|
||||
export function SpaceTableOfContents(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { siteSpace, customization, visibleSections } = context;
|
||||
const searchProps = getSearchBaseProps(context);
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
const withSections = shouldRenderSiteSectionNavigation(visibleSections);
|
||||
const variants = categorizeVariants(context);
|
||||
|
||||
return (
|
||||
<TableOfContents
|
||||
context={context}
|
||||
header={
|
||||
<div
|
||||
className={tcls(
|
||||
'pr-4',
|
||||
'flex',
|
||||
withTopHeader ? 'lg:hidden' : '',
|
||||
'grow-0',
|
||||
'dark:shadow-light/1',
|
||||
'text-base/tight',
|
||||
'items-center',
|
||||
// On bold themes also color the TOC header so the logo looks correct.
|
||||
'site-header:theme-bold:bg-header-background',
|
||||
'site-header:theme-bold:m-[-1.5rem_-1px_-0.5rem_-2rem]',
|
||||
'site-header:theme-bold:p-[1rem_1rem_1rem_2rem]'
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find((space) => space.id === siteSpace.id) ??
|
||||
siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||
variant="header"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
// Displays the search button and/or the space dropdown in the ToC
|
||||
// according to the header/variant settings.
|
||||
// E.g if there is no header, the search button will be displayed in the ToC.
|
||||
innerHeader={
|
||||
!withTopHeader || variants.generic.length > 1 ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
|
||||
variants.generic.length > 1 ? '' : 'max-lg:hidden'
|
||||
)}
|
||||
>
|
||||
{!withTopHeader && (
|
||||
<div className="flex gap-2 max-lg:hidden">
|
||||
<SearchContainer
|
||||
{...searchProps}
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
viewport="desktop"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!withTopHeader && withSections && visibleSections && (
|
||||
<SiteSectionList
|
||||
className="hidden lg:block"
|
||||
sections={encodeClientSiteSections(context, visibleSections)}
|
||||
/>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={variants.generic}
|
||||
className="w-full px-3"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the entire layout of the space (header, table of contents, footer).
|
||||
*/
|
||||
export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
const { context, children } = props;
|
||||
const { siteSpace, customization, visibleSections } = context;
|
||||
const searchProps = getSearchBaseProps(context);
|
||||
const { context, children, headerSlot, tableOfContentsSlot, announcementSlot, footerSlot } =
|
||||
props;
|
||||
const { customization } = context;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const withSections = shouldRenderSiteSectionNavigation(visibleSections);
|
||||
const variants = categorizeVariants(context);
|
||||
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
|
||||
|
||||
@@ -129,9 +259,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
customization.footer.groups?.length;
|
||||
|
||||
return (
|
||||
<SpaceLayoutServerContext {...props}>
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
|
||||
<SpaceLayoutServerContext
|
||||
context={context}
|
||||
withTracking={props.withTracking}
|
||||
visitorAuthClaims={props.visitorAuthClaims}
|
||||
aiChatRenderMessageOptions={props.aiChatRenderMessageOptions}
|
||||
clientNavigationSelection={props.clientNavigationSelection}
|
||||
>
|
||||
{announcementSlot}
|
||||
{headerSlot}
|
||||
<NavigationLoader />
|
||||
{isAIChatEnabled(customization.ai?.mode) ? (
|
||||
<>
|
||||
@@ -164,86 +300,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
: 'lg:min-h-screen'
|
||||
)}
|
||||
>
|
||||
<TableOfContents
|
||||
context={context}
|
||||
header={
|
||||
<div
|
||||
className={tcls(
|
||||
'pr-4',
|
||||
'flex',
|
||||
withTopHeader ? 'lg:hidden' : '',
|
||||
'grow-0',
|
||||
'dark:shadow-light/1',
|
||||
'text-base/tight',
|
||||
'items-center',
|
||||
// On bold themes also color the TOC header so the logo looks correct.
|
||||
'site-header:theme-bold:bg-header-background',
|
||||
'site-header:theme-bold:m-[-1.5rem_-1px_-0.5rem_-2rem]',
|
||||
'site-header:theme-bold:p-[1rem_1rem_1rem_2rem]'
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||
variant="header"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
// Displays the search button and/or the space dropdown in the ToC
|
||||
// according to the header/variant settings.
|
||||
// E.g if there is no header, the search button will be displayed in the ToC.
|
||||
innerHeader={
|
||||
!withTopHeader || variants.generic.length > 1 ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
|
||||
variants.generic.length > 1 ? '' : 'max-lg:hidden'
|
||||
)}
|
||||
>
|
||||
{!withTopHeader && (
|
||||
<div className="flex gap-2 max-lg:hidden">
|
||||
<SearchContainer
|
||||
{...searchProps}
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
viewport="desktop"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!withTopHeader && withSections && visibleSections && (
|
||||
<SiteSectionList
|
||||
className="hidden lg:block"
|
||||
sections={encodeClientSiteSections(
|
||||
context,
|
||||
visibleSections
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={variants.generic}
|
||||
className="w-full px-3"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{tableOfContentsSlot}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withFooter ? <Footer context={context} /> : null}
|
||||
{withFooter ? footerSlot : null}
|
||||
</SpaceLayoutServerContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { languages } from '@/intl/translations';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookSiteScopeContext } from '@/lib/context';
|
||||
import { getSiteSpaceLanguages, normalizeLanguage } from '@/lib/sites';
|
||||
|
||||
/**
|
||||
* Categorize the variants of the space into generic and translation variants.
|
||||
*/
|
||||
export function categorizeVariants(context: GitBookSiteContext) {
|
||||
export function categorizeVariants(context: GitBookSiteScopeContext) {
|
||||
const { siteSpace } = context;
|
||||
|
||||
// By default, variants only include visible spaces.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { useSelectedPagePath } from '../hooks';
|
||||
import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives';
|
||||
|
||||
/**
|
||||
@@ -20,10 +20,13 @@ export function ToggleableLinkItem(
|
||||
) {
|
||||
const { href, children, descendants, pathnames, insights, icon, tag } = props;
|
||||
|
||||
const currentPagePath = useCurrentPagePath();
|
||||
const isActive = pathnames.some((pathname) => pathname === currentPagePath);
|
||||
const currentPagePath = useSelectedPagePath();
|
||||
const isActive =
|
||||
currentPagePath !== null && pathnames.some((pathname) => pathname === currentPagePath);
|
||||
const defaultIsOpen =
|
||||
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
|
||||
isActive ||
|
||||
(currentPagePath !== null &&
|
||||
pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`)));
|
||||
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
|
||||
const hasBeenToggled = useRef(false);
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './useNow';
|
||||
export * from './useListOverflow';
|
||||
export * from './useCurrentPageMetadata';
|
||||
export * from './useBackToSpace';
|
||||
export * from './useSelectedNavigation';
|
||||
|
||||
@@ -20,11 +20,19 @@ export type CurrentContentContext = {
|
||||
|
||||
const ReactCurrentContentContext = React.createContext<CurrentContentContext | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to get the current content, or null outside of a `CurrentContentProvider`.
|
||||
* Some surfaces (embeddable docs, PDF) render navigation components without the provider.
|
||||
*/
|
||||
export function useOptionalCurrentContent(): CurrentContentContext | null {
|
||||
return React.useContext(ReactCurrentContentContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current content.
|
||||
*/
|
||||
export function useCurrentContent(): CurrentContentContext {
|
||||
const context = React.useContext(ReactCurrentContentContext);
|
||||
const context = useOptionalCurrentContent();
|
||||
if (!context) {
|
||||
throw new Error('useCurrentContent must be used within a CurrentContentProvider');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useOptionalCurrentContent } from './useCurrentContent';
|
||||
import { useCurrentPagePath } from './useCurrentPagePath';
|
||||
import { useIsMounted } from './useIsMounted';
|
||||
|
||||
const ClientNavigationSelectionContext = React.createContext(false);
|
||||
|
||||
/**
|
||||
* Mark a subtree whose server-rendered selection cannot be trusted.
|
||||
* Under PPR the header and the table of contents are cached fragments shared across pages, so the
|
||||
* "where am I" state they were rendered with belongs to another page and has to be recomputed here.
|
||||
*/
|
||||
export function ClientNavigationSelectionProvider(
|
||||
props: React.PropsWithChildren<{ enabled: boolean }>
|
||||
) {
|
||||
const { enabled, children } = props;
|
||||
|
||||
return (
|
||||
<ClientNavigationSelectionContext.Provider value={enabled}>
|
||||
{children}
|
||||
</ClientNavigationSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the page path to mark as selected, or null while it hasn't been resolved on the client.
|
||||
*/
|
||||
export function useSelectedPagePath(): string | null {
|
||||
// The page path always comes from the route, it just can't be trusted before hydration.
|
||||
const pagePath = useCurrentPagePath();
|
||||
return useSelected(pagePath, pagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the site section to mark as selected, or null while it hasn't been resolved on the client.
|
||||
*/
|
||||
export function useSelectedSiteSectionId(serverValue: string | null): string | null {
|
||||
return useSelected(serverValue, useOptionalCurrentContent()?.siteSectionId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the site space to mark as selected, or null while it hasn't been resolved on the client.
|
||||
*/
|
||||
export function useSelectedSiteSpaceId(serverValue: string | null): string | null {
|
||||
return useSelected(serverValue, useOptionalCurrentContent()?.siteSpaceId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick between the value the server rendered with and the one resolved on the client.
|
||||
* Nothing is selected until mount, so a stale highlight is never painted and the first client
|
||||
* render still matches the cached markup (which was produced by this same branch).
|
||||
*/
|
||||
function useSelected<T>(serverValue: T, clientValue: T): T | null {
|
||||
const enabled = React.useContext(ClientNavigationSelectionContext);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
if (!enabled) {
|
||||
return serverValue;
|
||||
}
|
||||
|
||||
return isMounted ? clientValue : null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isAvailableLanguage,
|
||||
loadLanguage,
|
||||
} from './translations';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import type { GitBookAnyContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
|
||||
export * from './translate';
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DEFAULT_LOCALE = 'en' satisfies TranslationLocale;
|
||||
* Get the locale to use for the HTML lang attribute.
|
||||
* This returns the actual content language even if we don't have UI translations for it.
|
||||
*/
|
||||
export function getContentLocale(context: GitBookAnyContext): string {
|
||||
export function getContentLocale(context: GitBookAnyContext | GitBookSiteScopeContext): string {
|
||||
if (context.locale) {
|
||||
return context.locale;
|
||||
}
|
||||
@@ -31,7 +31,9 @@ export function getContentLocale(context: GitBookAnyContext): string {
|
||||
/**
|
||||
* Get the locale to use for a space.
|
||||
*/
|
||||
export function getSpaceLocale(context: GitBookAnyContext): TranslationLocale {
|
||||
export function getSpaceLocale(
|
||||
context: GitBookAnyContext | GitBookSiteScopeContext
|
||||
): TranslationLocale {
|
||||
const customization = 'site' in context ? context.customization : null;
|
||||
|
||||
// If the language is configured in the space, use it in priority
|
||||
@@ -55,7 +57,9 @@ export function getSpaceLocale(context: GitBookAnyContext): TranslationLocale {
|
||||
/**
|
||||
* Create the translation context for a space to use in the server components.
|
||||
*/
|
||||
export async function getSpaceLanguage(context: GitBookAnyContext): Promise<TranslationLanguage> {
|
||||
export async function getSpaceLanguage(
|
||||
context: GitBookAnyContext | GitBookSiteScopeContext
|
||||
): Promise<TranslationLanguage> {
|
||||
const locale = getSpaceLocale(context);
|
||||
const language = locale === DEFAULT_LOCALE ? defaultLanguage : await loadLanguage(locale);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Cache tags emitted while rendering the PPR route are prefixed, so that PPR cache entries
|
||||
* live in their own namespace and are invalidated independently from the static ones.
|
||||
*/
|
||||
export const PPR_CACHE_TAG_PREFIX = 'ppr:';
|
||||
|
||||
/**
|
||||
* Independently revalidatable units of the PPR route. The scope is part of the cache key of the
|
||||
* data fetchers, so each component owns its own copy of the data it reads.
|
||||
*/
|
||||
export type PPRCacheScope = 'header' | 'toc' | 'body';
|
||||
|
||||
/**
|
||||
* Scope every cache tag emitted while rendering a PPR component, so the component and the data it
|
||||
* depends on are invalidated as one unit. Tags are left untouched outside the PPR route.
|
||||
*/
|
||||
export function scopeCacheTags(tags: string[], scope: PPRCacheScope | undefined): string[] {
|
||||
return scope ? tags.map((tag) => `${PPR_CACHE_TAG_PREFIX}${scope}:${tag}`) : tags;
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { SiteExternalLink, SiteSection, SiteSectionGroup } from '@gitbook/api';
|
||||
import {
|
||||
type SiteExternalLink,
|
||||
type SiteSection,
|
||||
type SiteSectionGroup,
|
||||
TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { filterSectionsAndGroupsWithHiddenSiteSpaces } from './context';
|
||||
import {
|
||||
type GitBookBaseContext,
|
||||
fetchSiteContextByIds,
|
||||
fetchSiteScopeContextByIds,
|
||||
filterSectionsAndGroupsWithHiddenSiteSpaces,
|
||||
} from './context';
|
||||
import { createLinker } from './links';
|
||||
|
||||
function makeExternalLink(id: string): SiteExternalLink {
|
||||
return {
|
||||
@@ -37,3 +48,105 @@ describe('filterSectionsAndGroupsWithHiddenSiteSpaces', () => {
|
||||
expect(filterSectionsAndGroupsWithHiddenSiteSpaces([hiddenSection, link])).toEqual([link]);
|
||||
});
|
||||
});
|
||||
|
||||
const siteSpace = {
|
||||
object: 'site-space',
|
||||
id: 'site-space-id',
|
||||
title: 'Docs',
|
||||
space: { id: 'space-id', language: TranslationLanguage.Fr, revision: 'space-revision-id' },
|
||||
urls: {},
|
||||
draft: false,
|
||||
};
|
||||
|
||||
const space = {
|
||||
id: 'space-id',
|
||||
organization: 'org-id',
|
||||
language: TranslationLanguage.En,
|
||||
revision: 'space-revision-id',
|
||||
};
|
||||
const revision = { id: 'revision-id', pages: [], tags: [] };
|
||||
|
||||
function getBaseContext(): GitBookBaseContext {
|
||||
const dataFetcher = {
|
||||
getPublishedContentSite: async () => ({
|
||||
data: {
|
||||
site: { id: 'site-id', title: 'Site', urls: {} },
|
||||
structure: { type: 'siteSpaces', structure: [siteSpace] },
|
||||
customizations: { site: {}, siteSpaces: { 'site-space-id': {} } },
|
||||
scripts: [],
|
||||
},
|
||||
}),
|
||||
getSpace: async () => ({ data: space }),
|
||||
getRevision: async () => ({ data: revision }),
|
||||
};
|
||||
|
||||
return {
|
||||
dataFetcher,
|
||||
linker: createLinker({
|
||||
host: 'docs.example.com',
|
||||
siteBasePath: '/',
|
||||
spaceBasePath: '/',
|
||||
}),
|
||||
} as unknown as GitBookBaseContext;
|
||||
}
|
||||
|
||||
const ids = {
|
||||
organization: 'org-id',
|
||||
site: 'site-id',
|
||||
siteSection: undefined,
|
||||
siteSpace: 'site-space-id',
|
||||
shareKey: undefined,
|
||||
isFallback: false,
|
||||
noIndexSearch: false,
|
||||
isLoggedInVisitor: false,
|
||||
};
|
||||
|
||||
describe('fetchSiteScopeContextByIds', () => {
|
||||
it('resolves the site without reading the space or the revision', async () => {
|
||||
const context = await fetchSiteScopeContextByIds(getBaseContext(), {
|
||||
...ids,
|
||||
revision: 'revision-id',
|
||||
});
|
||||
|
||||
expect(context.site.id).toBe('site-id');
|
||||
expect(context.siteSpace.id).toBe('site-space-id');
|
||||
expect(context.revisionId).toBe('revision-id');
|
||||
// The language comes from the site structure, as the space itself is never fetched.
|
||||
expect(context.locale).toBe(TranslationLanguage.Fr);
|
||||
expect(context).not.toHaveProperty('space');
|
||||
expect(context).not.toHaveProperty('revision');
|
||||
expect(context).not.toHaveProperty('changeRequest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSiteContextByIds', () => {
|
||||
it('carries the same site data as the site scope, plus the space and the revision', async () => {
|
||||
const baseContext = getBaseContext();
|
||||
const [context, siteScopeContext] = await Promise.all([
|
||||
fetchSiteContextByIds(baseContext, {
|
||||
...ids,
|
||||
space: 'space-id',
|
||||
changeRequest: undefined,
|
||||
revision: 'revision-id',
|
||||
}),
|
||||
fetchSiteScopeContextByIds(baseContext, { ...ids, revision: 'revision-id' }),
|
||||
]);
|
||||
|
||||
const { linker: _linker, ...siteScope } = siteScopeContext;
|
||||
expect(context).toMatchObject(siteScope);
|
||||
expect(context.space).toBe(space as unknown as typeof context.space);
|
||||
expect(context.revision).toBe(revision as unknown as typeof context.revision);
|
||||
expect(context.changeRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the revision of the space when none is requested', async () => {
|
||||
const context = await fetchSiteContextByIds(getBaseContext(), {
|
||||
...ids,
|
||||
space: 'space-id',
|
||||
changeRequest: undefined,
|
||||
revision: undefined,
|
||||
});
|
||||
|
||||
expect(context.revisionId).toBe('space-revision-id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import { GITBOOK_URL } from './env';
|
||||
import { type ImageResizer, createImageResizer } from './images';
|
||||
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
|
||||
import type { PPRCacheScope } from '@/lib/cache-tags';
|
||||
import {
|
||||
type GitBookDataFetcher,
|
||||
createDataFetcher,
|
||||
@@ -101,6 +102,9 @@ export type SiteURLData = Pick<
|
||||
* the static cache of the other routes.
|
||||
*/
|
||||
isAiAgent?: boolean;
|
||||
|
||||
/** Opaque identifier that partitions PPR renders across revalidations. */
|
||||
revalidationId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -160,9 +164,23 @@ export type SiteSections = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Context when rendering a site.
|
||||
* Site-level context: everything derived from the published site and the URL data, without the
|
||||
* space, revision or change request. It is what the PPR shell renders with, so its data only
|
||||
* depends on the site-scoped token.
|
||||
*
|
||||
* Its `linker` does not know about a custom home page (that needs the revision pages), so page
|
||||
* links must be built from the full site context. Its `dataFetcher` may hold a site-scoped token,
|
||||
* so revision data must never be fetched through it.
|
||||
*/
|
||||
export type GitBookSiteContext = GitBookSpaceContext & {
|
||||
export type GitBookSiteScopeContext = GitBookBaseContext & {
|
||||
organizationId: string;
|
||||
|
||||
/** Identifier of the revision, as resolved by the URL lookup. */
|
||||
revisionId: string;
|
||||
|
||||
/** Share key of the space. */
|
||||
shareKey: string | undefined;
|
||||
|
||||
site: Site;
|
||||
|
||||
/** Current site space. */
|
||||
@@ -206,8 +224,16 @@ export type GitBookSiteContext = GitBookSpaceContext & {
|
||||
|
||||
/** Whether the request comes from a detected AI agent. Only set for markdown routes. */
|
||||
isAiAgent?: boolean;
|
||||
|
||||
/** Opaque identifier that partitions PPR renders across revalidations. */
|
||||
revalidationId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context when rendering a site.
|
||||
*/
|
||||
export type GitBookSiteContext = GitBookSpaceContext & GitBookSiteScopeContext;
|
||||
|
||||
/**
|
||||
* Context when rendering a page.
|
||||
*/
|
||||
@@ -222,12 +248,15 @@ export function getBaseContext(input: {
|
||||
siteURL: URL | string;
|
||||
siteURLData: SiteURLData;
|
||||
urlMode: 'url' | 'url-host';
|
||||
/** Set when rendering a PPR component, to scope the cache tags emitted by the fetcher. */
|
||||
pprScope?: PPRCacheScope;
|
||||
}) {
|
||||
const { urlMode, siteURLData } = input;
|
||||
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
|
||||
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: siteURLData.apiToken ?? null,
|
||||
...(input.pprScope ? { pprScope: input.pprScope } : {}),
|
||||
});
|
||||
|
||||
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
|
||||
@@ -289,44 +318,93 @@ export async function fetchSiteContextByURLLookup(
|
||||
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
|
||||
displayAgentInstructions: data.displayAgentInstructions,
|
||||
isAiAgent: data.isAiAgent,
|
||||
revalidationId: data.revalidationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a site context by IDs.
|
||||
* Identifiers needed to resolve the site-level part of a context. They all come from the URL
|
||||
* lookup, so no space or revision is involved.
|
||||
*/
|
||||
export async function fetchSiteContextByIds(
|
||||
type SiteScopeIds = {
|
||||
organization: string;
|
||||
site: string;
|
||||
siteSection: string | undefined;
|
||||
siteSpace: string | undefined;
|
||||
shareKey: string | undefined;
|
||||
contextId?: string;
|
||||
isFallback: boolean;
|
||||
noIndexSearch: boolean;
|
||||
isLoggedInVisitor: boolean;
|
||||
displayAgentInstructions?: boolean;
|
||||
isAiAgent?: boolean;
|
||||
revalidationId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a site scope context by IDs.
|
||||
*/
|
||||
export async function fetchSiteScopeContextByIds(
|
||||
baseContext: GitBookBaseContext,
|
||||
ids: {
|
||||
organization: string;
|
||||
site: string;
|
||||
siteSection: string | undefined;
|
||||
siteSpace: string | undefined;
|
||||
space: string;
|
||||
shareKey: string | undefined;
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
contextId?: string;
|
||||
isFallback: boolean;
|
||||
noIndexSearch: boolean;
|
||||
isLoggedInVisitor: boolean;
|
||||
displayAgentInstructions?: boolean;
|
||||
isAiAgent?: boolean;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
ids: SiteScopeIds & { revision: string }
|
||||
): Promise<GitBookSiteScopeContext> {
|
||||
return {
|
||||
...(await resolveSiteScope(baseContext, ids)),
|
||||
revisionId: ids.revision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the site scope context of a site using the resolution of a URL.
|
||||
*/
|
||||
export async function fetchSiteScopeContextByURLLookup(
|
||||
baseContext: GitBookBaseContext,
|
||||
data: SiteURLData
|
||||
): Promise<GitBookSiteScopeContext> {
|
||||
// The revision is only resolved upstream for PPR requests, the only ones rendering a site scope.
|
||||
assert(data.revision, 'cannot resolve a site scope context without a resolved revision');
|
||||
|
||||
return fetchSiteScopeContextByIds(baseContext, {
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
shareKey: data.shareKey,
|
||||
revision: data.revision,
|
||||
contextId: data.contextId,
|
||||
isFallback: data.isFallback ?? false,
|
||||
noIndexSearch: data.noIndexSearch ?? false,
|
||||
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
|
||||
displayAgentInstructions: data.displayAgentInstructions,
|
||||
isAiAgent: data.isAiAgent,
|
||||
revalidationId: data.revalidationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a site context holds that doesn't depend on the space or the revision.
|
||||
*
|
||||
* It never sets `revisionId`: the caller knows whether it comes from the URL lookup (site scope) or
|
||||
* from the resolved space context, and a key here would override the latter.
|
||||
*/
|
||||
async function resolveSiteScope(
|
||||
baseContext: GitBookBaseContext,
|
||||
ids: SiteScopeIds
|
||||
): Promise<Omit<GitBookSiteScopeContext, 'revisionId'>> {
|
||||
const { dataFetcher } = baseContext;
|
||||
|
||||
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
|
||||
await Promise.all([
|
||||
throwIfDataError(
|
||||
dataFetcher.getPublishedContentSite({
|
||||
organizationId: ids.organization,
|
||||
siteId: ids.site,
|
||||
siteShareKey: ids.shareKey,
|
||||
})
|
||||
),
|
||||
fetchSpaceContextByIds(baseContext, ids),
|
||||
]);
|
||||
const {
|
||||
site: orgSite,
|
||||
structure: siteStructure,
|
||||
customizations,
|
||||
scripts,
|
||||
} = await throwIfDataError(
|
||||
dataFetcher.getPublishedContentSite({
|
||||
organizationId: ids.organization,
|
||||
siteId: ids.site,
|
||||
siteShareKey: ids.shareKey,
|
||||
})
|
||||
);
|
||||
|
||||
const sections = ids.siteSection
|
||||
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||
@@ -390,7 +468,7 @@ export async function fetchSiteContextByIds(
|
||||
return siteSpaceSettings;
|
||||
}
|
||||
|
||||
const logger = getLogger().subLogger('fetchSiteContextByIds', {});
|
||||
const logger = getLogger().subLogger('resolveSiteScope', {});
|
||||
// We got the pointer from an API and customizations from another.
|
||||
// It's possible that the two are unsynced leading to not found customizations for the space.
|
||||
// It's better to fallback on customization of the site that displaying an error.
|
||||
@@ -418,14 +496,15 @@ export async function fetchSiteContextByIds(
|
||||
};
|
||||
|
||||
const siteLinker = site.urls.published
|
||||
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
|
||||
: spaceContext.linker;
|
||||
? linkerForPublishedURL(baseContext.linker, site.urls.published)
|
||||
: baseContext.linker;
|
||||
|
||||
return {
|
||||
...spaceContext,
|
||||
locale: siteSpace.space.language ?? spaceContext.locale,
|
||||
linker: getLinkerForSiteSpace(siteLinker, siteSpace, spaceContext.revision.pages),
|
||||
...baseContext,
|
||||
locale: siteSpace.space.language,
|
||||
linker: siteLinker,
|
||||
organizationId: ids.organization,
|
||||
shareKey: ids.shareKey,
|
||||
site,
|
||||
siteSpaces,
|
||||
visibleSiteSpaces,
|
||||
@@ -441,6 +520,35 @@ export async function fetchSiteContextByIds(
|
||||
isLoggedInVisitor: ids.isLoggedInVisitor,
|
||||
displayAgentInstructions: ids.displayAgentInstructions,
|
||||
isAiAgent: ids.isAiAgent,
|
||||
revalidationId: ids.revalidationId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a site context by IDs.
|
||||
*/
|
||||
export async function fetchSiteContextByIds(
|
||||
baseContext: GitBookBaseContext,
|
||||
ids: SiteScopeIds & {
|
||||
space: string;
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const [siteScope, spaceContext] = await Promise.all([
|
||||
resolveSiteScope(baseContext, ids),
|
||||
fetchSpaceContextByIds(baseContext, ids),
|
||||
]);
|
||||
|
||||
return {
|
||||
...spaceContext,
|
||||
...siteScope,
|
||||
locale: siteScope.siteSpace.space.language ?? spaceContext.locale,
|
||||
linker: getLinkerForSiteSpace(
|
||||
siteScope.linker,
|
||||
siteScope.siteSpace,
|
||||
spaceContext.revision.pages
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
|
||||
import { cache } from '../cache';
|
||||
import { DataFetcherError, wrapDataFetcherError } from './errors';
|
||||
import type { GitBookDataFetcher } from './types';
|
||||
import { type PPRCacheScope, scopeCacheTags } from '@/lib/cache-tags';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
|
||||
import { trace } from '@/lib/tracing';
|
||||
|
||||
@@ -21,6 +22,12 @@ interface DataFetcherInput {
|
||||
* API token.
|
||||
*/
|
||||
apiToken: string | null;
|
||||
|
||||
/**
|
||||
* Set when rendering a PPR component. It is part of the cache key, so each scope owns its own
|
||||
* copy of the data and can carry its own `ppr:<scope>:` cache tags.
|
||||
*/
|
||||
pprScope?: PPRCacheScope;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,15 +44,24 @@ export const noCacheFetchOptions: Partial<RequestInit> = {
|
||||
* The data are being cached by Next.js built-in cache.
|
||||
*/
|
||||
export function createDataFetcher(
|
||||
input: DataFetcherInput = { apiToken: null }
|
||||
rawInput: DataFetcherInput = { apiToken: null }
|
||||
): GitBookDataFetcher {
|
||||
// The input is part of the cache key of every cached fetcher below, so we normalize its shape
|
||||
// here: `pprScope` is omitted entirely when unset, keeping non-PPR cache keys unchanged.
|
||||
const input: DataFetcherInput = rawInput.pprScope
|
||||
? { apiToken: rawInput.apiToken, pprScope: rawInput.pprScope }
|
||||
: { apiToken: rawInput.apiToken };
|
||||
|
||||
return {
|
||||
pprScope: input.pprScope,
|
||||
|
||||
async api() {
|
||||
return apiClient(input);
|
||||
},
|
||||
|
||||
withToken({ apiToken }) {
|
||||
return createDataFetcher({
|
||||
...input,
|
||||
apiToken,
|
||||
});
|
||||
},
|
||||
@@ -246,7 +262,7 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
|
||||
const res = await api.users.getUserById(params.userId, {
|
||||
...noCacheFetchOptions,
|
||||
});
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('days');
|
||||
return res.data;
|
||||
});
|
||||
@@ -256,12 +272,12 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
|
||||
const getSpace = cache(
|
||||
async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'space',
|
||||
space: params.spaceId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
|
||||
@@ -275,7 +291,7 @@ const getSpace = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('days');
|
||||
return res.data;
|
||||
});
|
||||
@@ -286,13 +302,13 @@ const getSpace = cache(
|
||||
const getChangeRequest = cache(
|
||||
async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'change-request',
|
||||
space: params.spaceId,
|
||||
changeRequest: params.changeRequestId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -306,7 +322,7 @@ const getChangeRequest = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('minutes');
|
||||
return res.data;
|
||||
}
|
||||
@@ -332,7 +348,7 @@ const getRevision = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('max');
|
||||
return res.data;
|
||||
});
|
||||
@@ -346,13 +362,13 @@ const getChangeRequestChanges = cache(
|
||||
params: { spaceId: string; changeRequestId: string; limit?: number }
|
||||
) => {
|
||||
'use cache: remote';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'change-request',
|
||||
space: params.spaceId,
|
||||
changeRequest: params.changeRequestId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -369,7 +385,7 @@ const getChangeRequestChanges = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('minutes');
|
||||
return res.data;
|
||||
}
|
||||
@@ -401,7 +417,7 @@ const getRevisionSemanticChanges = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('max');
|
||||
return res.data;
|
||||
}
|
||||
@@ -438,7 +454,7 @@ const getRevisionPageMarkdown = cache(
|
||||
}
|
||||
);
|
||||
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('max');
|
||||
|
||||
if (!('markdown' in res.data)) {
|
||||
@@ -480,7 +496,7 @@ const getRevisionPageDocument = cache(
|
||||
}
|
||||
);
|
||||
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLifeFromResponse(res, 'max');
|
||||
|
||||
return res.data;
|
||||
@@ -513,7 +529,7 @@ const getRevisionReusableContentDocument = cache(
|
||||
}
|
||||
);
|
||||
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLifeFromResponse(res, 'max');
|
||||
|
||||
return res.data;
|
||||
@@ -546,7 +562,7 @@ const getRevisionPageByPath = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('max');
|
||||
return res.data;
|
||||
}
|
||||
@@ -569,7 +585,7 @@ const getDocument = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLifeFromResponse(res, 'max');
|
||||
return res.data;
|
||||
});
|
||||
@@ -588,15 +604,15 @@ const getComputedDocument = cache(
|
||||
}
|
||||
) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
...getComputedContentSourceCacheTags(
|
||||
{
|
||||
spaceId: params.spaceId,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
params.source
|
||||
)
|
||||
);
|
||||
),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -614,7 +630,7 @@ const getComputedDocument = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLifeFromResponse(res, 'max');
|
||||
return res.data;
|
||||
}
|
||||
@@ -627,13 +643,13 @@ const getComputedDocument = cache(
|
||||
const getLatestOpenAPISpecVersionContent = cache(
|
||||
async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'openapi',
|
||||
organization: params.organizationId,
|
||||
openAPISpec: params.slug,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -647,7 +663,7 @@ const getLatestOpenAPISpecVersionContent = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('max');
|
||||
return res.data;
|
||||
}
|
||||
@@ -663,12 +679,12 @@ const getPublishedContentSite = cache(
|
||||
_apiVersion: string
|
||||
) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -685,7 +701,7 @@ const getPublishedContentSite = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('days');
|
||||
return res.data;
|
||||
}
|
||||
@@ -705,12 +721,12 @@ const getSiteRedirectBySource = cache(
|
||||
}
|
||||
) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -728,7 +744,7 @@ const getSiteRedirectBySource = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('days');
|
||||
return res.data;
|
||||
}
|
||||
@@ -740,12 +756,12 @@ const getSiteRedirectBySource = cache(
|
||||
const getEmbedByUrl = cache(
|
||||
async (input: DataFetcherInput, params: { spaceId: string; url: string }) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'space',
|
||||
space: params.spaceId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => {
|
||||
@@ -759,7 +775,7 @@ const getEmbedByUrl = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('weeks');
|
||||
return res.data;
|
||||
});
|
||||
@@ -774,12 +790,12 @@ const searchSiteContent = cache(
|
||||
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
||||
) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
@@ -809,7 +825,7 @@ const searchSiteContent = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('hours');
|
||||
return res.data.items;
|
||||
}
|
||||
@@ -824,12 +840,12 @@ const renderIntegrationUi = cache(
|
||||
params: { integrationName: string; request: RenderIntegrationUI }
|
||||
) => {
|
||||
'use cache';
|
||||
cacheTag(
|
||||
cacheTagsFor(input, [
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
integration: params.integrationName,
|
||||
})
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(`renderIntegrationUi(${params.integrationName})`, async () => {
|
||||
@@ -841,7 +857,7 @@ const renderIntegrationUi = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('days');
|
||||
return res.data;
|
||||
});
|
||||
@@ -871,7 +887,7 @@ const listRevisionPageMetaLinks = cache(
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheTagsFor(input, getCacheTagsFromResponse(res));
|
||||
cacheLife('days');
|
||||
return res.data;
|
||||
}
|
||||
@@ -903,3 +919,11 @@ function getCacheTagsFromResponse(response: HttpResponse<unknown, unknown>) {
|
||||
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the current cache entry. Always tag through this helper, never `cacheTag` directly,
|
||||
* so that a PPR render never emits an unscoped tag.
|
||||
*/
|
||||
function cacheTagsFor(input: DataFetcherInput, tags: string[]) {
|
||||
cacheTag(...scopeCacheTags(tags, input.pprScope));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
|
||||
/**
|
||||
* Build the lookup candidates, using a trusted PPR lookup URL as the only candidate.
|
||||
*/
|
||||
export function getPublishedContentLookupPlan(input: { url: string; urlLookup?: string }) {
|
||||
if (input.urlLookup) {
|
||||
return {
|
||||
urls: [{ url: input.urlLookup, primary: true, extraPath: '' }],
|
||||
basePath: undefined,
|
||||
changeRequest: undefined,
|
||||
revision: undefined,
|
||||
direct: true,
|
||||
};
|
||||
}
|
||||
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
return { ...getURLLookupAlternatives(url), direct: false };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getPublishedContentLookupPlan } from './lookup-plan';
|
||||
|
||||
describe('getPublishedContentLookupPlan', () => {
|
||||
it('uses a supplied lookup URL directly without generating alternatives', () => {
|
||||
expect(
|
||||
getPublishedContentLookupPlan({
|
||||
url: 'https://docs.example.com/a/b/c',
|
||||
urlLookup: 'https://docs.example.com/a',
|
||||
})
|
||||
).toEqual({
|
||||
urls: [
|
||||
{
|
||||
url: 'https://docs.example.com/a',
|
||||
primary: true,
|
||||
extraPath: '',
|
||||
},
|
||||
],
|
||||
basePath: undefined,
|
||||
changeRequest: undefined,
|
||||
revision: undefined,
|
||||
direct: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses URL alternatives when no direct lookup URL is supplied', () => {
|
||||
const plan = getPublishedContentLookupPlan({
|
||||
url: 'https://docs.example.com/a/b/c',
|
||||
});
|
||||
|
||||
expect(plan.direct).toBeFalse();
|
||||
expect(plan.urls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from
|
||||
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import { getPublishedContentLookupPlan } from './lookup-plan';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
import { isAPITokenExpired } from '@/lib/api-token';
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { getLogger } from '@/lib/logger';
|
||||
@@ -17,6 +17,8 @@ interface LookupPublishedContentByUrlInput {
|
||||
redirectOnError: boolean;
|
||||
apiToken: string | null;
|
||||
visitorPayload: SiteVisitorPayload;
|
||||
/** A known lookup URL that can be resolved directly without racing alternatives. */
|
||||
urlLookup?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,11 +28,12 @@ interface LookupPublishedContentByUrlInput {
|
||||
export async function lookupPublishedContentByUrl(
|
||||
input: LookupPublishedContentByUrlInput
|
||||
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
const lookup = getPublishedContentLookupPlan(input);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const resolveAlternative = async (
|
||||
alternative: (typeof lookup.urls)[number],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const api = apiClient({ apiToken: input.apiToken });
|
||||
const resolveURL = (cacheBust?: string) =>
|
||||
tryCatch(
|
||||
@@ -43,7 +46,7 @@ export async function lookupPublishedContentByUrl(
|
||||
// field is enough to miss the cache and get a freshly minted token.
|
||||
...(cacheBust ? { cacheBust } : {}),
|
||||
} as ResolveBody, //TODO: remove cast when we are sure that everything is good
|
||||
{ signal }
|
||||
signal ? { signal } : undefined
|
||||
)
|
||||
);
|
||||
|
||||
@@ -142,7 +145,17 @@ export async function lookupPublishedContentByUrl(
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const result = lookup.direct
|
||||
? await resolveAlternative({
|
||||
url: input.urlLookup ?? input.url,
|
||||
primary: true,
|
||||
extraPath: '',
|
||||
})
|
||||
: await race(lookup.urls, (alternative, { signal }) =>
|
||||
resolveAlternative(alternative, signal)
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type * as api from '@gitbook/api';
|
||||
|
||||
import type { PPRCacheScope } from '@/lib/cache-tags';
|
||||
|
||||
export type DataFetcherErrorData = {
|
||||
code: number;
|
||||
message: string;
|
||||
@@ -24,6 +26,12 @@ export type DataFetcherResponse<T> =
|
||||
* It is used between v1 and v2.
|
||||
*/
|
||||
export interface GitBookDataFetcher {
|
||||
/**
|
||||
* Set when rendering a PPR component: the cache entries are partitioned per scope and their
|
||||
* cache tags are scoped, so a component and its data are invalidated as one unit.
|
||||
*/
|
||||
readonly pprScope?: PPRCacheScope;
|
||||
|
||||
/**
|
||||
* Get an API client for the current context.
|
||||
*/
|
||||
|
||||
+7
@@ -132,6 +132,13 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
|
||||
*/
|
||||
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
||||
|
||||
/**
|
||||
* Endpoint exchanging a PPR revalidation token for a content API token scoped to a single claims
|
||||
* bucket. Signing one requires the API token secret, which GitBook Open does not have.
|
||||
*/
|
||||
export const GITBOOK_EXCHANGE_TOKEN_URL =
|
||||
process.env.GITBOOK_EXCHANGE_TOKEN_URL || 'https://sites.gitbook.com/token';
|
||||
|
||||
/**
|
||||
* Shared secret used to sign server-to-server requests to the sites OAuth server consent endpoints.
|
||||
* This must match the sites OAuth provider signing secret (`functionsConfig.sitesOAuth.signingSecret`
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ResolveOpenAPIBlockArgs,
|
||||
} from './types';
|
||||
import type { FetchOpenAPIFilesystemResult } from './types';
|
||||
import { type PPRCacheScope, scopeCacheTags } from '@/lib/cache-tags';
|
||||
import { DataFetcherError, noCacheFetchOptions } from '@/lib/data';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
|
||||
@@ -44,7 +45,7 @@ export async function fetchOpenAPIFilesystem(
|
||||
return resolved.openapi.filesystem;
|
||||
}
|
||||
// For legacy blocks ("swagger"), we need to fetch the file system.
|
||||
return fetchFilesystem(resolved.href, context.space.id);
|
||||
return fetchFilesystem(resolved.href, context.space.id, context.dataFetcher.pprScope);
|
||||
})();
|
||||
|
||||
if ('error' in result) {
|
||||
@@ -74,7 +75,9 @@ export async function fetchOpenAPIFilesystem(
|
||||
*/
|
||||
async function fetchFilesystem(
|
||||
url: string,
|
||||
spaceId: string
|
||||
spaceId: string,
|
||||
// Part of the cache key, so each PPR scope owns its own entry, separate from the static one.
|
||||
pprScope: PPRCacheScope | undefined
|
||||
): Promise<
|
||||
| Filesystem
|
||||
| {
|
||||
@@ -86,7 +89,7 @@ async function fetchFilesystem(
|
||||
> {
|
||||
'use cache';
|
||||
try {
|
||||
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
|
||||
cacheTag(...scopeCacheTags([getCacheTag({ tag: 'space', space: spaceId })], pprScope));
|
||||
return await fetchFilesystemNoCache(url);
|
||||
} catch (error) {
|
||||
// To avoid hammering the file with requests, we cache the error for around a minute.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
import { PPR_TOKEN_SCOPE, exchangePPRToken } from './ppr-token';
|
||||
import { DataFetcherError } from '@/lib/data/errors';
|
||||
import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
function mockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response) {
|
||||
const calls: { url: string; body: unknown }[] = [];
|
||||
globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) });
|
||||
return handler(input, init);
|
||||
}) as unknown as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe('PPR_TOKEN_SCOPE', () => {
|
||||
it('maps every PPR cache scope to the claims bucket it resolves', () => {
|
||||
expect(PPR_TOKEN_SCOPE).toEqual({ header: 'site', toc: 'revision', body: 'page' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangePPRToken', () => {
|
||||
it('posts the token and scope, and returns the exchanged token', async () => {
|
||||
for (const scope of ['site', 'revision', 'page', 'full'] as const) {
|
||||
const calls = mockFetch(() => Response.json({ token: `exchanged-${scope}` }));
|
||||
|
||||
expect(await exchangePPRToken(`revalidation-token-${scope}`, scope)).toBe(
|
||||
`exchanged-${scope}`
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
url: GITBOOK_EXCHANGE_TOKEN_URL,
|
||||
body: { token: `revalidation-token-${scope}`, scope },
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with a 502 when the endpoint rejects the token', async () => {
|
||||
mockFetch(() => Response.json({ error: 'Invalid or expired token' }, { status: 401 }));
|
||||
|
||||
const error = await exchangePPRToken('rejected-token', 'site').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(DataFetcherError);
|
||||
expect((error as DataFetcherError).code).toBe(502);
|
||||
});
|
||||
|
||||
it('fails with a 502 when the endpoint returns no token', async () => {
|
||||
mockFetch(() => Response.json({}));
|
||||
|
||||
const error = await exchangePPRToken('tokenless-response', 'revision').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(DataFetcherError);
|
||||
expect((error as DataFetcherError).code).toBe(502);
|
||||
});
|
||||
|
||||
it('fails with a 502 when the endpoint is unreachable', async () => {
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const error = await exchangePPRToken('unreachable', 'page').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(DataFetcherError);
|
||||
expect((error as DataFetcherError).code).toBe(502);
|
||||
});
|
||||
|
||||
// Request-level memoization is `React.cache`, which is inert outside a render scope and so
|
||||
// cannot be exercised here. What is asserted instead: each scope is a distinct exchange.
|
||||
it('exchanges each scope separately', async () => {
|
||||
const calls = mockFetch(() => Response.json({ token: 'exchanged' }));
|
||||
|
||||
await Promise.all([
|
||||
exchangePPRToken('shared-token', 'site'),
|
||||
exchangePPRToken('shared-token', 'revision'),
|
||||
]);
|
||||
|
||||
expect(calls.map((call) => call.body)).toEqual([
|
||||
{ token: 'shared-token', scope: 'site' },
|
||||
{ token: 'shared-token', scope: 'revision' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'server-only';
|
||||
import { cache } from '@/lib/cache';
|
||||
import type { PPRCacheScope } from '@/lib/cache-tags';
|
||||
import { DataFetcherError } from '@/lib/data/errors';
|
||||
import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env';
|
||||
import { trace } from '@/lib/tracing';
|
||||
|
||||
/**
|
||||
* Scope of the claims to keep in the exchanged token. `full` merges every scope into one object,
|
||||
* for callers that resolve them all at once and cannot present a different token per scope.
|
||||
*/
|
||||
export type PPRTokenScope = 'site' | 'revision' | 'page' | 'full';
|
||||
|
||||
/** Each PPR cache scope resolves exactly one claims bucket, so it maps to one exchange scope. */
|
||||
export const PPR_TOKEN_SCOPE: Record<PPRCacheScope, PPRTokenScope> = {
|
||||
header: 'site',
|
||||
toc: 'revision',
|
||||
body: 'page',
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange the revalidation token carried by a PPR request for a content API token whose claims are
|
||||
* narrowed to `scope`. The API only understands the latter, and narrowing is what lets components
|
||||
* sharing a scope share a cache entry: the token is part of their cache key.
|
||||
*
|
||||
* Memoized per request, but never persisted — an exchanged token is a credential.
|
||||
*/
|
||||
export const exchangePPRToken = cache(
|
||||
async (token: string, scope: PPRTokenScope): Promise<string> => {
|
||||
return trace(`exchangePPRToken(${scope})`, async () => {
|
||||
const response = await fetchExchangedToken(token, scope);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new DataFetcherError(
|
||||
`Token exchange for scope "${scope}" responded with ${response.status}`,
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const { token: exchanged } = (await response.json()) as { token?: unknown };
|
||||
if (typeof exchanged !== 'string' || !exchanged) {
|
||||
throw new DataFetcherError(
|
||||
`Token exchange for scope "${scope}" returned no token`,
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
return exchanged;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
async function fetchExchangedToken(token: string, scope: PPRTokenScope): Promise<Response> {
|
||||
try {
|
||||
return await fetch(GITBOOK_EXCHANGE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, scope }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
} catch (error) {
|
||||
// Surface a transport failure the same way as a rejection, so callers only handle one type.
|
||||
throw new DataFetcherError(
|
||||
`Token exchange for scope "${scope}" failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
502
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getPPRRequest, getPPRRouteType, signPPRRequestHeaders } from './ppr';
|
||||
|
||||
const pprHeaders = new Headers({
|
||||
'x-gbo-site': 'site-id',
|
||||
'x-gbo-site-section': 'site-section-id',
|
||||
'x-gbo-site-space': 'site-space-id',
|
||||
'x-gbo-space': 'space-id',
|
||||
'x-gbo-site-base-path': '/docs',
|
||||
'x-gbo-base-path': '/docs/guide',
|
||||
'x-gbo-pathname': '/getting-started',
|
||||
'x-gbo-organization': 'organization-id',
|
||||
'x-gbo-share-key': 'share-key',
|
||||
'x-gbo-complete': 'true',
|
||||
'x-gbo-context-id': 'context-id',
|
||||
'x-gbo-canonical-url': 'https://docs.example.com/guide',
|
||||
'x-gbo-preview': 'false',
|
||||
'x-gbo-revision': 'revision-id',
|
||||
'x-gbo-change-request': 'change-request-id',
|
||||
'x-gbo-api-token': 'api-token',
|
||||
'x-gbo-revalidation-id': 'revalidation-id',
|
||||
'x-gbo-default-site-section': 'default-site-section-id',
|
||||
'x-gbo-default-site-space': 'default-site-space-id',
|
||||
'x-gbo-default-space': 'default-space-id',
|
||||
});
|
||||
|
||||
const SECRET = 'ppr-signing-secret';
|
||||
|
||||
/**
|
||||
* Sign a header set the way GBO does, so the parsing assertions can't pass or fail on the signature.
|
||||
*/
|
||||
async function signHeaders(headers: Headers, secret = SECRET) {
|
||||
const signed = new Headers(headers);
|
||||
await signPPRRequestHeaders(signed, secret);
|
||||
return signed;
|
||||
}
|
||||
|
||||
function getSignedPPRRequest(headers: Headers) {
|
||||
return signHeaders(headers).then((signed) => getPPRRequest(signed, SECRET));
|
||||
}
|
||||
|
||||
describe('getPPRRouteType', () => {
|
||||
it('routes static document pages through PPR when resolved content headers are present', async () => {
|
||||
expect(getPPRRouteType('static', true, await getSignedPPRRequest(pprHeaders))).toBe('ppr');
|
||||
});
|
||||
|
||||
it('keeps special static routes on their existing route', async () => {
|
||||
expect(getPPRRouteType('static', false, await getSignedPPRRequest(pprHeaders))).toBe(
|
||||
'static'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dynamic pages dynamic even when PPR is requested', async () => {
|
||||
expect(getPPRRouteType('dynamic', true, await getSignedPPRRequest(pprHeaders))).toBe(
|
||||
'dynamic'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not opt in with a partial resolved-content header set', async () => {
|
||||
for (const header of [
|
||||
'x-gbo-site',
|
||||
'x-gbo-site-space',
|
||||
'x-gbo-space',
|
||||
'x-gbo-site-base-path',
|
||||
'x-gbo-base-path',
|
||||
'x-gbo-pathname',
|
||||
'x-gbo-organization',
|
||||
'x-gbo-complete',
|
||||
'x-gbo-canonical-url',
|
||||
'x-gbo-api-token',
|
||||
'x-gbo-revision',
|
||||
'x-gbo-revalidation-id',
|
||||
'x-gbo-default-site-section',
|
||||
'x-gbo-default-site-space',
|
||||
'x-gbo-default-space',
|
||||
]) {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.delete(header);
|
||||
expect(await getSignedPPRRequest(headers)).toBeUndefined();
|
||||
expect(getPPRRouteType('static', true, await getSignedPPRRequest(headers))).toBe(
|
||||
'static'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not opt in when required headers are empty', async () => {
|
||||
for (const header of [
|
||||
'x-gbo-site',
|
||||
'x-gbo-site-space',
|
||||
'x-gbo-space',
|
||||
'x-gbo-site-base-path',
|
||||
'x-gbo-base-path',
|
||||
'x-gbo-pathname',
|
||||
'x-gbo-organization',
|
||||
'x-gbo-complete',
|
||||
'x-gbo-canonical-url',
|
||||
'x-gbo-api-token',
|
||||
'x-gbo-revision',
|
||||
'x-gbo-revalidation-id',
|
||||
'x-gbo-default-site-space',
|
||||
'x-gbo-default-space',
|
||||
]) {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.set(header, '');
|
||||
expect(await getSignedPPRRequest(headers)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not opt in when boolean headers are invalid', async () => {
|
||||
for (const header of ['x-gbo-complete', 'x-gbo-preview']) {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.set(header, 'yes');
|
||||
expect(await getSignedPPRRequest(headers)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads resolved content from the complete header set', async () => {
|
||||
expect(await getSignedPPRRequest(pprHeaders)).toEqual({
|
||||
content: {
|
||||
site: 'site-id',
|
||||
siteSection: 'site-section-id',
|
||||
siteSpace: 'site-space-id',
|
||||
space: 'space-id',
|
||||
siteBasePath: '/docs',
|
||||
basePath: '/docs/guide',
|
||||
pathname: '/getting-started',
|
||||
organization: 'organization-id',
|
||||
shareKey: 'share-key',
|
||||
complete: true,
|
||||
contextId: 'context-id',
|
||||
canonicalUrl: 'https://docs.example.com/guide',
|
||||
preview: false,
|
||||
revision: 'revision-id',
|
||||
changeRequest: 'change-request-id',
|
||||
apiToken: 'api-token',
|
||||
},
|
||||
defaults: {
|
||||
siteSection: 'default-site-section-id',
|
||||
siteSpace: 'default-site-space-id',
|
||||
space: 'default-space-id',
|
||||
},
|
||||
revalidationId: 'revalidation-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty optional headers as absent', async () => {
|
||||
const headers = new Headers(pprHeaders);
|
||||
for (const header of [
|
||||
'x-gbo-site-section',
|
||||
'x-gbo-share-key',
|
||||
'x-gbo-context-id',
|
||||
'x-gbo-preview',
|
||||
'x-gbo-change-request',
|
||||
]) {
|
||||
headers.set(header, '');
|
||||
}
|
||||
|
||||
expect((await getSignedPPRRequest(headers))?.content).toMatchObject({
|
||||
site: 'site-id',
|
||||
siteSection: undefined,
|
||||
shareKey: undefined,
|
||||
contextId: undefined,
|
||||
preview: undefined,
|
||||
revision: 'revision-id',
|
||||
changeRequest: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an explicitly empty default site section', async () => {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.set('x-gbo-default-site-section', '');
|
||||
|
||||
expect((await getSignedPPRRequest(headers))?.defaults).toEqual({
|
||||
siteSection: undefined,
|
||||
siteSpace: 'default-site-space-id',
|
||||
space: 'default-space-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires all default location headers before opting into PPR', async () => {
|
||||
for (const header of [
|
||||
'x-gbo-default-site-section',
|
||||
'x-gbo-default-site-space',
|
||||
'x-gbo-default-space',
|
||||
]) {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.delete(header);
|
||||
expect(await getSignedPPRRequest(headers)).toBeUndefined();
|
||||
expect(getPPRRouteType('static', true, await getSignedPPRRequest(headers))).toBe(
|
||||
'static'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses false and true boolean header values', async () => {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.set('x-gbo-complete', 'false');
|
||||
headers.set('x-gbo-preview', 'true');
|
||||
|
||||
expect((await getSignedPPRRequest(headers))?.content).toMatchObject({
|
||||
complete: false,
|
||||
preview: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PPR request signature', () => {
|
||||
it('accepts a header set signed with the configured secret', async () => {
|
||||
expect(await getPPRRequest(await signHeaders(pprHeaders), SECRET)).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores an unsigned header set', async () => {
|
||||
expect(await getPPRRequest(pprHeaders, SECRET)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a header set signed with another secret', async () => {
|
||||
const signed = await signHeaders(pprHeaders, 'another-secret');
|
||||
expect(await getPPRRequest(signed, SECRET)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a malformed signature', async () => {
|
||||
for (const signature of ['', 'not-hex', 'abc', 'ab'.repeat(31)]) {
|
||||
const headers = new Headers(pprHeaders);
|
||||
headers.set('x-gbo-signature', signature);
|
||||
expect(await getPPRRequest(headers, SECRET)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a header set modified after signing', async () => {
|
||||
for (const header of [
|
||||
'x-gbo-revalidation-id',
|
||||
'x-gbo-api-token',
|
||||
'x-gbo-pathname',
|
||||
'x-gbo-site',
|
||||
]) {
|
||||
const signed = await signHeaders(pprHeaders);
|
||||
signed.set(header, 'tampered');
|
||||
expect(await getPPRRequest(signed, SECRET)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a header set with an optional header dropped after signing', async () => {
|
||||
for (const header of ['x-gbo-share-key', 'x-gbo-context-id', 'x-gbo-change-request']) {
|
||||
const signed = await signHeaders(pprHeaders);
|
||||
signed.delete(header);
|
||||
expect(await getPPRRequest(signed, SECRET)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not opt into PPR when no secret is configured', async () => {
|
||||
expect(await getPPRRequest(await signHeaders(pprHeaders), null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { PublishedSiteContent } from '@gitbook/api';
|
||||
|
||||
export type SiteRouteType = 'dynamic' | 'static' | 'ppr';
|
||||
|
||||
export type PPRRequest = {
|
||||
content: PublishedSiteContent & { revision: string };
|
||||
defaults: {
|
||||
siteSection: string | undefined;
|
||||
siteSpace: string;
|
||||
space: string;
|
||||
};
|
||||
revalidationId: string;
|
||||
};
|
||||
|
||||
export const PPRRequestHeaders = {
|
||||
Site: 'x-gbo-site',
|
||||
SiteSection: 'x-gbo-site-section',
|
||||
SiteSpace: 'x-gbo-site-space',
|
||||
Space: 'x-gbo-space',
|
||||
SiteBasePath: 'x-gbo-site-base-path',
|
||||
BasePath: 'x-gbo-base-path',
|
||||
Pathname: 'x-gbo-pathname',
|
||||
Organization: 'x-gbo-organization',
|
||||
ShareKey: 'x-gbo-share-key',
|
||||
Complete: 'x-gbo-complete',
|
||||
ContextID: 'x-gbo-context-id',
|
||||
CanonicalURL: 'x-gbo-canonical-url',
|
||||
Preview: 'x-gbo-preview',
|
||||
Revision: 'x-gbo-revision',
|
||||
ChangeRequest: 'x-gbo-change-request',
|
||||
APIToken: 'x-gbo-api-token',
|
||||
RevalidationID: 'x-gbo-revalidation-id',
|
||||
DefaultSiteSection: 'x-gbo-default-site-section',
|
||||
DefaultSiteSpace: 'x-gbo-default-site-space',
|
||||
DefaultSpace: 'x-gbo-default-space',
|
||||
Signature: 'x-gbo-signature',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Header names covered by the signature, in an order both signer and verifier must agree on.
|
||||
*/
|
||||
const SignedPPRRequestHeaders = Object.values(PPRRequestHeaders)
|
||||
.filter((name) => name !== PPRRequestHeaders.Signature)
|
||||
.sort();
|
||||
|
||||
/**
|
||||
* GBO has already resolved PPR requests, so a complete header set can skip URL resolution.
|
||||
*
|
||||
* That skips visitor-auth validation and lets the headers pick the cache key, so the set is only
|
||||
* trusted once its GBO signature checks out. Without a secret to check against, PPR stays off.
|
||||
*/
|
||||
export async function getPPRRequest(
|
||||
headers: Headers,
|
||||
secret: string | null
|
||||
): Promise<PPRRequest | undefined> {
|
||||
const pprRequest = parsePPRRequest(headers);
|
||||
if (!pprRequest || !secret || !(await verifyPPRRequestHeaders(headers, secret))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pprRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a PPR header set the way GBO does. Only used by the dev proxy and tests.
|
||||
*/
|
||||
export async function signPPRRequestHeaders(headers: Headers, secret: string): Promise<void> {
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
await importSigningKey(secret),
|
||||
encodeUTF8(getSignedPayload(headers))
|
||||
);
|
||||
headers.set(PPRRequestHeaders.Signature, toHex(signature));
|
||||
}
|
||||
|
||||
async function verifyPPRRequestHeaders(headers: Headers, secret: string): Promise<boolean> {
|
||||
const signature = headers.get(PPRRequestHeaders.Signature);
|
||||
const decoded = signature ? fromHex(signature) : null;
|
||||
if (!decoded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// `crypto.subtle.verify` compares in constant time.
|
||||
return crypto.subtle.verify(
|
||||
'HMAC',
|
||||
await importSigningKey(secret),
|
||||
decoded,
|
||||
encodeUTF8(getSignedPayload(headers))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical string that gets signed. An absent header must not hash like an empty one, as dropping
|
||||
* one changes how the set is read.
|
||||
*/
|
||||
function getSignedPayload(headers: Headers): string {
|
||||
return SignedPPRRequestHeaders.map((name) => {
|
||||
const value = headers.get(name);
|
||||
return value === null ? name : `${name}=${encodeURIComponent(value)}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function importSigningKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
encodeUTF8(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
function encodeUTF8(value: string): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
function toHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function fromHex(value: string): Uint8Array<ArrayBuffer> | null {
|
||||
if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parsePPRRequest(headers: Headers): PPRRequest | undefined {
|
||||
const site = getRequiredHeader(headers, PPRRequestHeaders.Site);
|
||||
const siteSpace = getRequiredHeader(headers, PPRRequestHeaders.SiteSpace);
|
||||
const space = getRequiredHeader(headers, PPRRequestHeaders.Space);
|
||||
const siteBasePath = getRequiredHeader(headers, PPRRequestHeaders.SiteBasePath);
|
||||
const basePath = getRequiredHeader(headers, PPRRequestHeaders.BasePath);
|
||||
const pathname = getRequiredHeader(headers, PPRRequestHeaders.Pathname);
|
||||
const organization = getRequiredHeader(headers, PPRRequestHeaders.Organization);
|
||||
const complete = getBooleanHeader(headers, PPRRequestHeaders.Complete);
|
||||
const canonicalUrl = getRequiredHeader(headers, PPRRequestHeaders.CanonicalURL);
|
||||
const apiToken = getRequiredHeader(headers, PPRRequestHeaders.APIToken);
|
||||
const preview = getBooleanHeader(headers, PPRRequestHeaders.Preview);
|
||||
const revision = getRequiredHeader(headers, PPRRequestHeaders.Revision);
|
||||
const revalidationId = headers.get(PPRRequestHeaders.RevalidationID);
|
||||
const defaultSiteSection = getOptionalHeader(headers, PPRRequestHeaders.DefaultSiteSection);
|
||||
const defaultSiteSpace = getRequiredHeader(headers, PPRRequestHeaders.DefaultSiteSpace);
|
||||
const defaultSpace = getRequiredHeader(headers, PPRRequestHeaders.DefaultSpace);
|
||||
|
||||
if (
|
||||
!site ||
|
||||
!siteSpace ||
|
||||
!space ||
|
||||
!siteBasePath ||
|
||||
!basePath ||
|
||||
!pathname ||
|
||||
!organization ||
|
||||
complete === undefined ||
|
||||
!canonicalUrl ||
|
||||
!apiToken ||
|
||||
(headers.get(PPRRequestHeaders.Preview) && preview === undefined) ||
|
||||
!revision ||
|
||||
!revalidationId ||
|
||||
!headers.has(PPRRequestHeaders.DefaultSiteSection) ||
|
||||
!defaultSiteSpace ||
|
||||
!defaultSpace
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
content: {
|
||||
site,
|
||||
siteSection: getOptionalHeader(headers, PPRRequestHeaders.SiteSection),
|
||||
siteSpace,
|
||||
space,
|
||||
siteBasePath,
|
||||
basePath,
|
||||
pathname,
|
||||
organization,
|
||||
shareKey: getOptionalHeader(headers, PPRRequestHeaders.ShareKey),
|
||||
complete,
|
||||
contextId: getOptionalHeader(headers, PPRRequestHeaders.ContextID),
|
||||
canonicalUrl,
|
||||
preview,
|
||||
revision,
|
||||
changeRequest: getOptionalHeader(headers, PPRRequestHeaders.ChangeRequest),
|
||||
apiToken,
|
||||
},
|
||||
defaults: {
|
||||
siteSection: defaultSiteSection,
|
||||
siteSpace: defaultSiteSpace,
|
||||
space: defaultSpace,
|
||||
},
|
||||
revalidationId,
|
||||
};
|
||||
}
|
||||
|
||||
function getRequiredHeader(headers: Headers, name: string): string | undefined {
|
||||
return getOptionalHeader(headers, name);
|
||||
}
|
||||
|
||||
function getOptionalHeader(headers: Headers, name: string): string | undefined {
|
||||
return headers.get(name) || undefined;
|
||||
}
|
||||
|
||||
function getBooleanHeader(headers: Headers, name: string): boolean | undefined {
|
||||
const value = getOptionalHeader(headers, name);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the PPR rollout limited to ordinary static document pages.
|
||||
*/
|
||||
export function getPPRRouteType(
|
||||
routeType: SiteRouteType,
|
||||
isPPRPage: boolean | undefined,
|
||||
pprRequest: PPRRequest | undefined
|
||||
): SiteRouteType {
|
||||
return routeType === 'static' && isPPRPage && pprRequest ? 'ppr' : routeType;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type RevisionPageDocument, type RevisionPageGroup, SiteVisibility } from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
|
||||
/**
|
||||
* Return true if a page is indexable in search.
|
||||
@@ -24,13 +24,17 @@ export function isPageIndexable(
|
||||
/**
|
||||
* Return true if a space should be indexed by search engines.
|
||||
*/
|
||||
export function isSiteIndexable(context: GitBookSiteContext) {
|
||||
export function isSiteIndexable(context: GitBookSiteContext | GitBookSiteScopeContext) {
|
||||
if (context.noIndexSearch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent indexation of preview of revisions / change-requests
|
||||
if (context.changeRequest || context.revisionId !== context.space.revision) {
|
||||
// It cannot happen for Scoped kind of route
|
||||
if (
|
||||
('changeRequest' in context && context.changeRequest) ||
|
||||
('space' in context && context.revisionId !== context.space.revision)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
normalizeRequestURL,
|
||||
throwIfDataError,
|
||||
} from '@/lib/data';
|
||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
|
||||
import { GITBOOK_SECRET, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
|
||||
import { getImageResizingContextId } from '@/lib/images';
|
||||
import { isAITrainingOrIndexingRequest } from '@/lib/indexing-crawlers';
|
||||
import { MiddlewareHeaders } from '@/lib/middleware';
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
isOAuthProtectedResourceRequest,
|
||||
} from '@/lib/oauth-protected';
|
||||
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
|
||||
import { PPRRequestHeaders, type SiteRouteType, getPPRRequest, getPPRRouteType } from '@/lib/ppr';
|
||||
import {
|
||||
getPreviewCookieResponse,
|
||||
getPreviewRequestIdentifier,
|
||||
@@ -213,25 +214,40 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
//
|
||||
request.headers.delete('x-gitbook-disable-tracking');
|
||||
|
||||
const withAPIToken = async (apiToken: string | null) => {
|
||||
const siteURLData = await throwIfDataError(
|
||||
lookupPublishedContentByUrl({
|
||||
url: siteRequestURL.toString(),
|
||||
visitorPayload: {
|
||||
jwtToken: visitorToken?.token ?? undefined,
|
||||
unsignedClaims,
|
||||
type: getVisitorType(request),
|
||||
},
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling resolvePublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
const pprRequest = await getPPRRequest(request.headers, GITBOOK_SECRET);
|
||||
|
||||
// Use the API token passed in the request, if any
|
||||
// as it could be used for .preview hostnames
|
||||
apiToken,
|
||||
})
|
||||
);
|
||||
//
|
||||
// Strip the PPR headers once consumed: nothing downstream reads them, and a client can send
|
||||
// them itself.
|
||||
//
|
||||
for (const name of Object.values(PPRRequestHeaders)) {
|
||||
request.headers.delete(name);
|
||||
}
|
||||
|
||||
const withAPIToken = async (
|
||||
apiToken: string | null,
|
||||
resolvedPPRContent?: PublishedSiteContent
|
||||
) => {
|
||||
const siteURLData =
|
||||
resolvedPPRContent ??
|
||||
(await throwIfDataError(
|
||||
lookupPublishedContentByUrl({
|
||||
url: siteRequestURL.toString(),
|
||||
visitorPayload: {
|
||||
jwtToken: visitorToken?.token ?? undefined,
|
||||
unsignedClaims,
|
||||
type: getVisitorType(request),
|
||||
},
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling resolvePublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
|
||||
// Use the API token passed in the request, if any
|
||||
// as it could be used for .preview hostnames
|
||||
apiToken,
|
||||
})
|
||||
));
|
||||
|
||||
const cookies: ResponseCookies = visitorParamsCookie
|
||||
? [
|
||||
@@ -360,7 +376,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
|
||||
// The route is static, except when using dynamic parameters from query params
|
||||
// (customization override, theme, etc)
|
||||
let routeType: 'dynamic' | 'static' = 'static';
|
||||
let routeType: SiteRouteType = 'static';
|
||||
|
||||
// We pick only stable data from the siteURL data to prevent re-rendering of
|
||||
// the root layout when changing pages..
|
||||
@@ -393,7 +409,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
};
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||
requestHeaders.set(
|
||||
MiddlewareHeaders.SiteURL,
|
||||
@@ -457,6 +472,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
const {
|
||||
pathname,
|
||||
routeType: routeTypeFromPathname,
|
||||
isPPRPage,
|
||||
events,
|
||||
isAiAgent,
|
||||
} = encodePathInSiteContent(siteURLData, request);
|
||||
@@ -493,6 +509,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
routeType = getPPRRouteType(routeType, isPPRPage, pprRequest);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
|
||||
if (events && events.length > 0) {
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
@@ -522,6 +541,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
)
|
||||
)
|
||||
),
|
||||
...(routeType === 'ppr' && pprRequest
|
||||
? [
|
||||
encodeURIComponent(pprRequest.content.revision),
|
||||
encodeURIComponent(pprRequest.revalidationId),
|
||||
encodeURIComponent(
|
||||
rison.encode({
|
||||
siteSection: pprRequest.defaults.siteSection ?? null,
|
||||
siteSpace: pprRequest.defaults.siteSpace,
|
||||
space: pprRequest.defaults.space,
|
||||
})
|
||||
),
|
||||
]
|
||||
: []),
|
||||
pathname,
|
||||
].join('/');
|
||||
|
||||
@@ -597,6 +629,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
return writeResponseCookies(response, cookies);
|
||||
};
|
||||
|
||||
if (pprRequest) {
|
||||
return withAPIToken(null, pprRequest.content);
|
||||
}
|
||||
|
||||
// For preview requests like:
|
||||
// - https://<GITBOOK_PREVIEW_BASE_URL>/<siteID> requests (ex: https://sites.gitbook.com/preview/site_id/path)
|
||||
if (isPreviewRequest(siteRequestURL)) {
|
||||
@@ -776,6 +812,7 @@ function encodePathInSiteContent(
|
||||
): {
|
||||
pathname: string;
|
||||
routeType?: 'static' | 'dynamic';
|
||||
isPPRPage?: boolean;
|
||||
events?: ServerInsightsEventInput[] | undefined;
|
||||
/** Only set for markdown routes, where the output depends on the visitor being an agent. */
|
||||
isAiAgent?: boolean;
|
||||
@@ -938,7 +975,7 @@ function encodePathInSiteContent(
|
||||
],
|
||||
};
|
||||
}
|
||||
return { pathname: encodePagePath(pathname) };
|
||||
return { pathname: encodePagePath(pathname), isPPRPage: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-14",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
|
||||
Reference in New Issue
Block a user