mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-03 12:10:34 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a705c6d3b | |||
| 4ee22f767d | |||
| 1accb6e28a | |||
| d4289c387c | |||
| 6ba70e649f | |||
| ee78e745d4 | |||
| c0ec780a53 | |||
| 528a05398c | |||
| f584148ab9 | |||
| 0de22f7a96 | |||
| 09c7c30779 | |||
| bffdef4370 | |||
| 848aaa808f | |||
| 8401b1362f | |||
| 063b05b1e1 | |||
| 4df83559f7 | |||
| e02c922464 | |||
| 9fde9517b6 | |||
| 3be3324329 | |||
| cfac92add6 | |||
| 1b849823b0 | |||
| c49f5862a4 | |||
| 14b3eafd0b | |||
| 776f8a8f5f | |||
| fde32e26bf | |||
| a6f65916f1 | |||
| 6059efe094 | |||
| 16194c50ba | |||
| af3c6a971c | |||
| ecfdb976a3 | |||
| 08acea651a | |||
| 6b503608cd | |||
| d8763997a5 | |||
| 32aa1f99c1 | |||
| 718a8a54b1 | |||
| 741dd490d4 | |||
| 02d876e6d8 | |||
| 56c52e0bb0 | |||
| 6036becf93 | |||
| 3a7210d514 | |||
| 6088fa582d | |||
| 6691492ef7 | |||
| 7ee9158cd3 | |||
| 5ae1b883ab |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix UI search without ask AI enabled and fix error with questions not returned from API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix search no results error showing while there are results
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Reintroduce a safety check around search whilst we continue investigating caching.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Handle security issue with cookies on Safari
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Investigate an issue causing caches to return empty objects instead of null/undefined.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix shrinking page icons
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Simplify search results logic to investigate a bug
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Try to fix error on og image generation
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix multiple bugs due to headers read in an anarchic way in the app.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix zoom image view transition on Safari
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Position the variant picker in the ToC
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Handle Firefox security error on localStorage
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Restyle PageAside to use sidebar list styles
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix server actions stability leading to no results found sometimes on search
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix search UI behaviour
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Force route handler to be dynamic to avoid errors
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix viewing PDF from space
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Vertical orientation for sections list on sites without header
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix server actions cache compromised. Leading to some bugs on frontend.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix view transition error on Safari
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Bump `heading-3` font size to offset it from paragraphs
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
run: bun run build:cloudflare
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
@@ -94,6 +95,48 @@ jobs:
|
||||
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
|
||||
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
|
||||
edit-mode: replace
|
||||
visual-testing:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: packages/gitbook/test-results/
|
||||
retention-days: 3
|
||||
pagespeed-testing:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run pagespeed tests
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
name: Format
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.100.0"
|
||||
"wrangler": "3.82.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = withSentryConfig(
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
},
|
||||
|
||||
webpack(config, { dev, webpack }) {
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/cloudflare": "8.35.0",
|
||||
"@sentry/nextjs": "8.35.0",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
|
||||
// Disable tracing as it creates additional requests in an env where subrequests are limited.
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Disable transactions as it creates additional requests in an env where subrequests are limited.
|
||||
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
|
||||
beforeSendTransaction: () => {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import {
|
||||
CURRENT_SIGNATURE_VERSION,
|
||||
isSignatureVersion,
|
||||
@@ -39,7 +40,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
|
||||
const ctx = getGitBookContextFromHeaders(request.headers);
|
||||
const verified = await verifyImageSignature(ctx, url, { signature, version: signatureVersion });
|
||||
if (!verified) {
|
||||
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { TrackPageViewEvent } from '@/components/Insights';
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { getSiteData, getSpaceContentData } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export default async function NotFound() {
|
||||
const pointer = await getSiteContentPointer();
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const pointer = getSiteContentPointer(ctx);
|
||||
const [{ space }, { customization }] = await Promise.all([
|
||||
getSpaceContentData(pointer, pointer.siteShareKey),
|
||||
getSiteData(pointer),
|
||||
getSpaceContentData(ctx, pointer, pointer.siteShareKey),
|
||||
getSiteData(ctx, pointer),
|
||||
]);
|
||||
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context';
|
||||
import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links';
|
||||
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
@@ -15,18 +17,23 @@ import { PageClientLayout } from './PageClientLayout';
|
||||
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Props = {
|
||||
params: Promise<PagePathParams>;
|
||||
searchParams: Promise<{ fallback?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch and render a page.
|
||||
*/
|
||||
export default async function Page(props: {
|
||||
params: Promise<PagePathParams>;
|
||||
searchParams: Promise<{ fallback?: string }>;
|
||||
}) {
|
||||
const { params: rawParams, searchParams: rawSearchParams } = props;
|
||||
|
||||
const params = await rawParams;
|
||||
const searchParams = await rawSearchParams;
|
||||
export default async function Page(props: Props) {
|
||||
const [headersList, params, searchParams] = await Promise.all([
|
||||
headers(),
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
const ctx = getGitBookContextFromHeaders(headersList);
|
||||
|
||||
const {
|
||||
content: contentPointer,
|
||||
@@ -39,7 +46,7 @@ export default async function Page(props: {
|
||||
page,
|
||||
ancestors,
|
||||
document,
|
||||
} = await getPageDataWithFallback({
|
||||
} = await getPageDataWithFallback(ctx, {
|
||||
pagePathParams: params,
|
||||
searchParams,
|
||||
redirectOnFallback: true,
|
||||
@@ -52,12 +59,12 @@ export default async function Page(props: {
|
||||
if (pathname !== rawPathname) {
|
||||
// If the pathname was not normalized, redirect to the normalized version
|
||||
// before trying to resolve the page again
|
||||
redirect(await getAbsoluteHref(pathname));
|
||||
redirect(getAbsoluteHref(ctx, pathname));
|
||||
} else {
|
||||
notFound();
|
||||
}
|
||||
} else if (getPagePath(pages, page) !== rawPathname) {
|
||||
redirect(await getPageHref(pages, page, linksContext));
|
||||
redirect(getPageHref(ctx, pages, page, linksContext));
|
||||
}
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
@@ -121,12 +128,10 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<PagePathParams>;
|
||||
}): Promise<Viewport> {
|
||||
const { customization } = await fetchPageData(await params);
|
||||
export async function generateViewport(props: Props): Promise<Viewport> {
|
||||
const [params, headersList] = await Promise.all([props.params, headers()]);
|
||||
const ctx = getGitBookContextFromHeaders(headersList);
|
||||
const { customization } = await fetchPageData(ctx, params);
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
@@ -136,17 +141,20 @@ export async function generateViewport({
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<PagePathParams>;
|
||||
searchParams: Promise<{ fallback?: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
|
||||
pagePathParams: await params,
|
||||
searchParams: await searchParams,
|
||||
});
|
||||
export async function generateMetadata(props: Props): Promise<Metadata> {
|
||||
const [params, searchParams, headersList] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
headers(),
|
||||
]);
|
||||
const ctx = getGitBookContextFromHeaders(headersList);
|
||||
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback(
|
||||
ctx,
|
||||
{
|
||||
pagePathParams: params,
|
||||
searchParams: searchParams,
|
||||
},
|
||||
);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
@@ -159,17 +167,16 @@ export async function generateMetadata({
|
||||
description: page.description ?? '',
|
||||
alternates: {
|
||||
// Trim trailing slashes in canonical URL to match the redirect behavior
|
||||
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
|
||||
canonical: getAbsoluteHref(ctx, getPagePath(pages, page), true).replace(/\/+$/, ''),
|
||||
},
|
||||
openGraph: {
|
||||
images: [
|
||||
customization.socialPreview.url ??
|
||||
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
|
||||
getAbsoluteHref(ctx, `~gitbook/ogimage/${page.id}`, true),
|
||||
],
|
||||
},
|
||||
robots:
|
||||
(await isSpaceIndexable({ space, site: site ?? null })) &&
|
||||
isPageIndexable(ancestors, page)
|
||||
isSpaceIndexable(ctx, { space, site: site ?? null }) && isPageIndexable(ancestors, page)
|
||||
? 'index, follow'
|
||||
: 'noindex, nofollow',
|
||||
};
|
||||
@@ -178,14 +185,17 @@ export async function generateMetadata({
|
||||
/**
|
||||
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
|
||||
*/
|
||||
async function getPageDataWithFallback(args: {
|
||||
pagePathParams: PagePathParams;
|
||||
searchParams: { fallback?: string };
|
||||
redirectOnFallback?: boolean;
|
||||
}) {
|
||||
async function getPageDataWithFallback(
|
||||
ctx: GitBookContext,
|
||||
args: {
|
||||
pagePathParams: PagePathParams;
|
||||
searchParams: { fallback?: string };
|
||||
redirectOnFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
const { pagePathParams, searchParams, redirectOnFallback = false } = args;
|
||||
|
||||
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams);
|
||||
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(ctx, pagePathParams);
|
||||
|
||||
let page = targetPage;
|
||||
const canFallback = !!searchParams.fallback;
|
||||
@@ -193,7 +203,7 @@ async function getPageDataWithFallback(args: {
|
||||
const rootPage = resolveFirstDocument(pages, []);
|
||||
|
||||
if (redirectOnFallback && rootPage?.page) {
|
||||
redirect(await getPageHref(pages, rootPage?.page));
|
||||
redirect(getPageHref(ctx, pages, rootPage?.page));
|
||||
}
|
||||
|
||||
page = rootPage?.page;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { api } from '@/lib/api';
|
||||
import { assetsDomain } from '@/lib/assets';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
||||
import { GitBookContext, getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
@@ -22,14 +23,16 @@ import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||
import { fetchContentData } from '../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Layout when rendering the content.
|
||||
*/
|
||||
export default async function ContentLayout(props: { children: React.ReactNode }) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { children } = props;
|
||||
|
||||
const nonce = await getContentSecurityPolicyNonce();
|
||||
const nonce = getContentSecurityPolicyNonce(ctx);
|
||||
const {
|
||||
content,
|
||||
space,
|
||||
@@ -41,10 +44,9 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
ancestors,
|
||||
scripts,
|
||||
sections,
|
||||
} = await fetchContentData();
|
||||
} = await fetchContentData(ctx);
|
||||
|
||||
const apiCtx = await api();
|
||||
ReactDOM.preconnect(apiCtx.client.endpoint);
|
||||
ReactDOM.preconnect(api(ctx).client.endpoint);
|
||||
if (assetsDomain) {
|
||||
ReactDOM.preconnect(assetsDomain);
|
||||
}
|
||||
@@ -56,7 +58,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
});
|
||||
});
|
||||
|
||||
const queryStringTheme = await getQueryStringTheme();
|
||||
const queryStringTheme = getQueryStringTheme(ctx);
|
||||
|
||||
return (
|
||||
<NuqsAdapter>
|
||||
@@ -105,7 +107,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const { customization } = await fetchContentData();
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { customization } = await fetchContentData(ctx);
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
@@ -116,46 +119,43 @@ export async function generateViewport(): Promise<Viewport> {
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { space, site, customization } = await fetchContentData();
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { space, site, customization } = await fetchContentData(ctx);
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
return {
|
||||
title: getContentTitle(space, customization, site),
|
||||
generator: `GitBook (${buildVersion()})`,
|
||||
metadataBase: new URL(await getBaseUrl()),
|
||||
metadataBase: new URL(getBaseUrl(ctx)),
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url:
|
||||
customIcon?.light ??
|
||||
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)),
|
||||
getAbsoluteHref(ctx, '~gitbook/icon?size=small&theme=light', true),
|
||||
type: 'image/png',
|
||||
media: '(prefers-color-scheme: light)',
|
||||
},
|
||||
{
|
||||
url:
|
||||
customIcon?.dark ??
|
||||
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
|
||||
getAbsoluteHref(ctx, '~gitbook/icon?size=small&theme=dark', true),
|
||||
type: 'image/png',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
],
|
||||
},
|
||||
robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow',
|
||||
robots: isSpaceIndexable(ctx, { space, site }) ? 'index, follow' : 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* For preview, the theme can be set via query string (?theme=light).
|
||||
*/
|
||||
async function getQueryStringTheme() {
|
||||
const headersList = await headers();
|
||||
const queryStringTheme = headersList.get('x-gitbook-theme');
|
||||
if (!queryStringTheme) {
|
||||
function getQueryStringTheme(ctx: GitBookContext) {
|
||||
if (!ctx.theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return queryStringTheme === 'light'
|
||||
? CustomizationThemeMode.Light
|
||||
: CustomizationThemeMode.Dark;
|
||||
return ctx.theme === 'light' ? CustomizationThemeMode.Light : CustomizationThemeMode.Dark;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getSpace, getSite } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
@@ -11,17 +12,18 @@ export const runtime = 'edge';
|
||||
* Generate a robots.txt for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = await getSiteContentPointer();
|
||||
const ctx = getGitBookContextFromHeaders(req.headers);
|
||||
const pointer = getSiteContentPointer(ctx);
|
||||
const [site, space] = await Promise.all([
|
||||
getSite(pointer.organizationId, pointer.siteId),
|
||||
getSpace(pointer.spaceId, pointer.siteShareKey),
|
||||
getSite(ctx, pointer.organizationId, pointer.siteId),
|
||||
getSpace(ctx, pointer.spaceId, pointer.siteShareKey),
|
||||
]);
|
||||
|
||||
const lines = [
|
||||
`User-agent: *`,
|
||||
'Disallow: /~gitbook/',
|
||||
...((await isSpaceIndexable({ space, site }))
|
||||
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
|
||||
...(isSpaceIndexable(ctx, { space, site })
|
||||
? [`Allow: /`, `Sitemap: ${getAbsoluteHref(ctx, `/sitemap.xml`, true)}`]
|
||||
: [`Disallow: /`]),
|
||||
];
|
||||
const content = lines.join('\n');
|
||||
|
||||
@@ -3,6 +3,7 @@ import jsontoxml from 'jsontoxml';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getSpaceContentData } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
@@ -14,33 +15,32 @@ export const runtime = 'edge';
|
||||
* Generate a sitemap.xml for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = await getSiteContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
|
||||
const ctx = getGitBookContextFromHeaders(req.headers);
|
||||
const pointer = getSiteContentPointer(ctx);
|
||||
const { pages: rootPages } = await getSpaceContentData(ctx, pointer, pointer.siteShareKey);
|
||||
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||
const urls = await Promise.all(
|
||||
pages.map(async ({ page, depth }) => {
|
||||
// Decay priority with depth
|
||||
const priority = Math.pow(2, -0.25 * depth);
|
||||
// Normalize to keep 2 decimals
|
||||
const normalizedPriority = Math.floor(100 * priority) / 100;
|
||||
const urls = pages.map(({ page, depth }) => {
|
||||
// Decay priority with depth
|
||||
const priority = Math.pow(2, -0.25 * depth);
|
||||
// Normalize to keep 2 decimals
|
||||
const normalizedPriority = Math.floor(100 * priority) / 100;
|
||||
|
||||
const lastModified = page.updatedAt || page.createdAt;
|
||||
const lastModified = page.updatedAt || page.createdAt;
|
||||
|
||||
return {
|
||||
url: {
|
||||
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
|
||||
priority: normalizedPriority,
|
||||
...(lastModified
|
||||
? {
|
||||
// lastmod format is YYYY-MM-DD
|
||||
lastmod: new Date(lastModified).toISOString().split('T')[0],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
url: {
|
||||
loc: getAbsoluteHref(ctx, getPagePath(rootPages, page), true),
|
||||
priority: normalizedPriority,
|
||||
...(lastModified
|
||||
? {
|
||||
// lastmod format is YYYY-MM-DD
|
||||
lastmod: new Date(lastModified).toISOString().split('T')[0],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const xml = jsontoxml(
|
||||
[
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
|
||||
import { getSite, getSiteData, getSpace } from '@/lib/api';
|
||||
import { getEmojiForCode } from '@/lib/emojis';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
@@ -32,17 +33,18 @@ const SIZES = {
|
||||
* Render an icon for the space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const ctx = getGitBookContextFromHeaders(req.headers);
|
||||
const options = getOptions(req.url);
|
||||
const size = SIZES[options.size];
|
||||
|
||||
const pointer = await getSiteContentPointer();
|
||||
const pointer = getSiteContentPointer(ctx);
|
||||
const spaceId = pointer.spaceId;
|
||||
|
||||
const [space, { customization }] = await Promise.all([
|
||||
getSpace(spaceId, pointer.siteShareKey),
|
||||
getSiteData(pointer),
|
||||
getSpace(ctx, spaceId, pointer.siteShareKey),
|
||||
getSiteData(ctx, pointer),
|
||||
]);
|
||||
const site = await getSite(pointer.organizationId, pointer.siteId);
|
||||
const site = await getSite(ctx, pointer.organizationId, pointer.siteId);
|
||||
const contentTitle = getContentTitle(space, customization, site);
|
||||
|
||||
return new ImageResponse(
|
||||
|
||||
@@ -5,44 +5,92 @@ import { NextRequest } from 'next/server';
|
||||
import colorContrast from 'postcss-color-contrast/js';
|
||||
import React from 'react';
|
||||
|
||||
import { googleFontsMap } from '@/fonts';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
async function loadGoogleFont(font: string, text: string) {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`;
|
||||
const css = await (await fetch(url)).text();
|
||||
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
|
||||
|
||||
if (resource) {
|
||||
const response = await fetch(resource[1]);
|
||||
if (response.status == 200) {
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('failed to load font data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the OpenGraph image for a space.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
|
||||
const { space, page, customization, site } = await fetchPageData(await params);
|
||||
const ctx = getGitBookContextFromHeaders(req.headers);
|
||||
const { space, page, customization, site } = await fetchPageData(ctx, await params);
|
||||
|
||||
// If user configured a custom social preview, we redirect to it.
|
||||
if (customization.socialPreview.url) {
|
||||
// If user configured a custom social preview, we redirect to it.
|
||||
redirect(customization.socialPreview.url);
|
||||
}
|
||||
|
||||
// TODO: Support all fonts available in GitBook
|
||||
// Right now this is impossible since next/font/google does not expose the cached font file
|
||||
// Another option would be to use the Satori prop `loadAdditionalAsset` [example](https://github.com/vercel/satori/blob/main/playground/pages/index.tsx),
|
||||
// but this prop isn't (yet) exposed through `ImageResponse`.
|
||||
const interRegular = await fetch(
|
||||
new URL('../../../../../../fonts/Inter/Inter-Regular.ttf', import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
const interBold = await fetch(
|
||||
new URL('../../../../../../fonts/Inter/Inter-Bold.ttf', import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
// Compute all text to load only the necessary fonts
|
||||
const contentTitle = customization.header.logo
|
||||
? ''
|
||||
: getContentTitle(space, customization, site ?? null);
|
||||
const pageTitle = page
|
||||
? page.title.length > 64
|
||||
? page.title.slice(0, 64) + '...'
|
||||
: page.title
|
||||
: 'Not found';
|
||||
const pageDescription =
|
||||
page?.description && page?.title.length <= 64
|
||||
? page.description.length > 164
|
||||
? page.description.slice(0, 164) + '...'
|
||||
: page.description
|
||||
: '';
|
||||
|
||||
const fontFamily = googleFontsMap[customization.styling.font] ?? 'Inter';
|
||||
|
||||
const regularText = pageDescription;
|
||||
const boldText = `${contentTitle}${pageTitle}`;
|
||||
|
||||
const fonts = (
|
||||
await Promise.all([
|
||||
regularText
|
||||
? loadGoogleFont(`${fontFamily}:wght@400`, regularText).then((data) => ({
|
||||
name: fontFamily,
|
||||
data,
|
||||
style: 'normal' as const,
|
||||
weight: 400 as const,
|
||||
}))
|
||||
: null,
|
||||
boldText
|
||||
? loadGoogleFont(`${fontFamily}:wght@700`, `${contentTitle}${pageTitle}`).then(
|
||||
(data) => ({
|
||||
name: fontFamily,
|
||||
data,
|
||||
style: 'normal' as const,
|
||||
weight: 700 as const,
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
])
|
||||
).filter(filterOutNullable);
|
||||
|
||||
const theme = customization.themes.default;
|
||||
const useLightTheme = theme === 'light';
|
||||
|
||||
// We have no access to CSS variables, so we'll have to hardcode some values
|
||||
const baseColors = {
|
||||
light: '#ffffff',
|
||||
dark: '#111827',
|
||||
};
|
||||
const baseColors = { light: '#ffffff', dark: '#111827' };
|
||||
|
||||
let colors = {
|
||||
background: baseColors[theme],
|
||||
@@ -51,10 +99,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
|
||||
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
|
||||
};
|
||||
|
||||
const [gridWhite, gridBlack] = await Promise.all([
|
||||
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
|
||||
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
|
||||
]);
|
||||
const gridWhite = getAbsoluteHref(ctx, '~gitbook/static/images/ogimage-grid-white.png', true);
|
||||
const gridBlack = getAbsoluteHref(ctx, '~gitbook/static/images/ogimage-grid-black.png', true);
|
||||
|
||||
let gridAsset = useLightTheme ? gridBlack : gridWhite;
|
||||
|
||||
@@ -92,48 +138,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
|
||||
break;
|
||||
}
|
||||
|
||||
const favicon = await (async () => {
|
||||
if ('icon' in customization.favicon)
|
||||
return (
|
||||
<img
|
||||
src={customization.favicon.icon[theme]}
|
||||
width={40}
|
||||
height={40}
|
||||
tw={tcls('mr-4')}
|
||||
alt="Icon"
|
||||
/>
|
||||
);
|
||||
if ('emoji' in customization.favicon)
|
||||
return (
|
||||
<span tw={tcls('text-4xl', 'mr-4')}>
|
||||
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
|
||||
</span>
|
||||
);
|
||||
const src = await getAbsoluteHref(
|
||||
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
|
||||
true,
|
||||
);
|
||||
return <img src={src} alt="Icon" width={40} height={40} tw={tcls('mr-4')} />;
|
||||
})();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw={tcls(
|
||||
'justify-between',
|
||||
'p-20',
|
||||
'relative',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'flex',
|
||||
'flex-col',
|
||||
`bg-[${colors.background}]`,
|
||||
`text-[${colors.body}]`,
|
||||
)}
|
||||
tw={`justify-between p-20 relative w-full h-full flex flex-col bg-[${colors.background}] text-[${colors.body}]`}
|
||||
style={{
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
{/* Gradient */}
|
||||
<div
|
||||
tw={tcls('absolute', 'inset-0')}
|
||||
tw="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
|
||||
opacity: 0.5,
|
||||
@@ -141,11 +156,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
|
||||
></div>
|
||||
|
||||
{/* Grid */}
|
||||
<img
|
||||
tw={tcls('absolute', 'inset-0', 'w-[100vw]', 'h-[100vh]')}
|
||||
src={gridAsset}
|
||||
alt="Grid"
|
||||
/>
|
||||
<img tw="absolute inset-0 w-[100vw] h-[100vh]" src={gridAsset} alt="Grid" />
|
||||
|
||||
{/* Logo */}
|
||||
{customization.header.logo ? (
|
||||
@@ -159,39 +170,46 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div tw={tcls('flex')}>
|
||||
{favicon}
|
||||
<h3 tw={tcls('text-4xl', 'my-0')}>
|
||||
{getContentTitle(space, customization, site ?? null)}
|
||||
</h3>
|
||||
<div tw="flex">
|
||||
{(() => {
|
||||
if ('icon' in customization.favicon)
|
||||
return (
|
||||
<img
|
||||
src={customization.favicon.icon[theme]}
|
||||
width={40}
|
||||
height={40}
|
||||
tw="mr-4"
|
||||
alt="Icon"
|
||||
/>
|
||||
);
|
||||
if ('emoji' in customization.favicon)
|
||||
return (
|
||||
<span tw="text-4xl mr-4">
|
||||
{String.fromCodePoint(
|
||||
parseInt('0x' + customization.favicon.emoji),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
const src = getAbsoluteHref(
|
||||
ctx,
|
||||
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
|
||||
true,
|
||||
);
|
||||
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
|
||||
})()}
|
||||
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and description */}
|
||||
<div tw={tcls('flex', 'flex-col')}>
|
||||
<div tw="flex flex-col">
|
||||
<h1
|
||||
tw={tcls(
|
||||
'text-8xl',
|
||||
'my-0',
|
||||
'tracking-tight',
|
||||
'leading-none',
|
||||
'text-left',
|
||||
`text-[${colors.title}]`,
|
||||
'font-bold',
|
||||
)}
|
||||
tw={`text-8xl my-0 tracking-light leading-none text-left text-[${colors.title}] font-bold`}
|
||||
>
|
||||
{page
|
||||
? page.title.length > 64
|
||||
? page.title.slice(0, 64) + '...'
|
||||
: page.title
|
||||
: 'Not found'}
|
||||
{pageTitle}
|
||||
</h1>
|
||||
{page?.description && page?.title.length <= 64 ? (
|
||||
<h2 tw={tcls('text-4xl', 'mb-0', 'mt-8', 'w-[75%]', 'font-normal')}>
|
||||
{page.description.length > 164
|
||||
? page.description.slice(0, 164) + '...'
|
||||
: page.description}
|
||||
</h2>
|
||||
{pageDescription ? (
|
||||
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,20 +217,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interRegular,
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interBold,
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getSiteData,
|
||||
getSiteRedirectBySource,
|
||||
} from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
@@ -22,13 +23,13 @@ export interface PageIdParams {
|
||||
/**
|
||||
* Fetch all the data needed to render the content layout.
|
||||
*/
|
||||
export async function fetchContentData() {
|
||||
const content = await getSiteContentPointer();
|
||||
export async function fetchContentData(ctx: GitBookContext) {
|
||||
const content = getSiteContentPointer(ctx);
|
||||
|
||||
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
|
||||
await Promise.all([
|
||||
getSpaceContentData(content, content.siteShareKey),
|
||||
getSiteData(content),
|
||||
getSpaceContentData(ctx, content, content.siteShareKey),
|
||||
getSiteData(ctx, content),
|
||||
]);
|
||||
|
||||
// we grab the space attached to the parent as it contains overriden customizations
|
||||
@@ -53,10 +54,10 @@ export async function fetchContentData() {
|
||||
* Fetch all the data needed to render the content.
|
||||
* Optimized to fetch in parallel as much as possible.
|
||||
*/
|
||||
export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const contentData = await fetchContentData();
|
||||
export async function fetchPageData(ctx: GitBookContext, params: PagePathParams | PageIdParams) {
|
||||
const contentData = await fetchContentData(ctx);
|
||||
|
||||
const page = await resolvePage({
|
||||
const page = await resolvePage(ctx, {
|
||||
organizationId: contentData.space.organization,
|
||||
siteId: contentData.site.id,
|
||||
spaceId: contentData.contentTarget.spaceId,
|
||||
@@ -66,7 +67,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
params,
|
||||
});
|
||||
const document = page?.page.documentId
|
||||
? await getDocument(contentData.space.id, page.page.documentId)
|
||||
? await getDocument(ctx, contentData.space.id, page.page.documentId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -80,15 +81,18 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
* Resolve a page from the params.
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(input: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
shareKey: string | undefined;
|
||||
pages: RevisionPage[];
|
||||
params: PagePathParams | PageIdParams;
|
||||
}) {
|
||||
async function resolvePage(
|
||||
ctx: GitBookContext,
|
||||
input: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
shareKey: string | undefined;
|
||||
pages: RevisionPage[];
|
||||
params: PagePathParams | PageIdParams;
|
||||
},
|
||||
) {
|
||||
const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input;
|
||||
|
||||
if ('pageId' in params) {
|
||||
@@ -109,13 +113,13 @@ async function resolvePage(input: {
|
||||
// If page can't be found, we try with the API, in case we have a redirect at space level.
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname);
|
||||
const resolved = await getRevisionPageByPath(ctx, spaceId, revisionId, rawPathname);
|
||||
if (resolved) {
|
||||
return resolvePageId(pages, resolved.id);
|
||||
}
|
||||
|
||||
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const resolvedSiteRedirect = await getSiteRedirectBySource({
|
||||
const resolvedSiteRedirect = await getSiteRedirectBySource(ctx, {
|
||||
organizationId,
|
||||
siteId,
|
||||
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { getSiteData } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
/**
|
||||
@@ -8,9 +11,10 @@ import { getSiteContentPointer } from '@/lib/pointer';
|
||||
*/
|
||||
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
|
||||
const pointer = await getSiteContentPointer();
|
||||
const { customization } = await getSiteData(pointer);
|
||||
const pointer = getSiteContentPointer(ctx);
|
||||
const { customization } = await getSiteData(ctx, pointer);
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { SpaceIntegrationScript } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { getSiteData, getSpaceCustomization } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context';
|
||||
|
||||
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||
|
||||
@@ -10,12 +12,13 @@ import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||
* site or space and initializes the CustomizationRootLayout with it.
|
||||
*/
|
||||
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { children } = props;
|
||||
|
||||
const pointer = await getSiteOrSpacePointerForPDF();
|
||||
const pointer = getSiteOrSpacePointerForPDF(ctx);
|
||||
const { customization } = await ('siteId' in pointer
|
||||
? getSiteData(pointer)
|
||||
: getSpaceLayoutData());
|
||||
? getSiteData(ctx, pointer)
|
||||
: getSpaceLayoutData(ctx));
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||
@@ -25,14 +28,11 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
|
||||
/**
|
||||
* Fetch all the layout data about a space at once.
|
||||
*/
|
||||
async function getSpaceLayoutData() {
|
||||
const [{ customization }, scripts] = await Promise.all([
|
||||
getSpaceCustomization(),
|
||||
[] as SpaceIntegrationScript[],
|
||||
]);
|
||||
async function getSpaceLayoutData(ctx: GitBookContext) {
|
||||
const { customization } = await getSpaceCustomization(ctx);
|
||||
|
||||
return {
|
||||
customization,
|
||||
scripts,
|
||||
scripts: [] as SpaceIntegrationScript[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
getSpaceContentData,
|
||||
getSiteData,
|
||||
} from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
@@ -37,12 +39,14 @@ import { PrintButton } from './PrintButton';
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const pointer = await getSiteOrSpacePointerForPDF();
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const pointer = getSiteOrSpacePointerForPDF(ctx);
|
||||
const [space, { customization }] = await Promise.all([
|
||||
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
|
||||
getSpace(ctx, pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer ? getSiteData(ctx, pointer) : getSpaceCustomization(ctx),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -57,19 +61,20 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function PDFHTMLOutput(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const pointer = await getSiteOrSpacePointerForPDF();
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const pointer = getSiteOrSpacePointerForPDF(ctx);
|
||||
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
||||
|
||||
// Build current PDF URL and preserve all search params
|
||||
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
|
||||
let currentPDFUrl = getAbsoluteHref(ctx, '~gitbook/pdf', true);
|
||||
currentPDFUrl += '?' + searchParams.toString();
|
||||
|
||||
// Load the content,
|
||||
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
|
||||
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
|
||||
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer ? getSiteData(ctx, pointer) : getSpaceCustomization(ctx),
|
||||
getSpaceContentData(ctx, pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
]);
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
@@ -88,7 +93,7 @@ export default async function PDFHTMLOutput(props: {
|
||||
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
|
||||
<a
|
||||
title={tString(language, 'pdf_goback')}
|
||||
href={pdfParams.back ?? (await getAbsoluteHref(''))}
|
||||
href={pdfParams.back ?? getAbsoluteHref(ctx, '')}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
@@ -226,9 +231,10 @@ async function PDFPageDocument(props: {
|
||||
page: RevisionPageDocument;
|
||||
refContext: ContentRefContext;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { space, page, refContext } = props;
|
||||
|
||||
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
|
||||
const document = page.documentId ? await getDocument(ctx, space.id, page.documentId) : null;
|
||||
|
||||
return (
|
||||
<PrintPage id={getPagePDFContainerId(page)}>
|
||||
@@ -249,7 +255,7 @@ async function PDFPageDocument(props: {
|
||||
revisionId: refContext.revisionId,
|
||||
},
|
||||
contentRefContext: refContext,
|
||||
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
|
||||
resolveContentRef: (ref) => resolveContentRef(ctx, ref, refContext),
|
||||
getId: (id) => getPagePDFContainerId(page, id),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
|
||||
|
||||
/**
|
||||
@@ -8,12 +9,12 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
|
||||
*
|
||||
* This function returns the pointer depending on the context.
|
||||
*/
|
||||
export async function getSiteOrSpacePointerForPDF(): Promise<
|
||||
SiteContentPointer | SpaceContentPointer
|
||||
> {
|
||||
export function getSiteOrSpacePointerForPDF(
|
||||
ctx: GitBookContext,
|
||||
): SiteContentPointer | SpaceContentPointer {
|
||||
try {
|
||||
return getSiteContentPointer();
|
||||
return getSiteContentPointer(ctx);
|
||||
} catch (error) {
|
||||
return getSpacePointer();
|
||||
return getSpacePointer(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @ts-ignore
|
||||
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
||||
import { withSentry } from '@sentry/cloudflare';
|
||||
|
||||
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||
|
||||
const exportedHandler = {
|
||||
/**
|
||||
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
|
||||
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const response = await withMiddlewareHeadersStorage(() =>
|
||||
nextOnPagesHandler.fetch(request, env, ctx),
|
||||
@@ -12,19 +15,4 @@ const exportedHandler = {
|
||||
|
||||
return response;
|
||||
},
|
||||
} satisfies ExportedHandler<{ ASSETS: Fetcher }>;
|
||||
|
||||
/**
|
||||
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
|
||||
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
|
||||
*/
|
||||
export default withSentry(
|
||||
(env) => ({
|
||||
dsn: env.SENTRY_DSN,
|
||||
release: env.SENTRY_RELEASE,
|
||||
environment: env.SENTRY_ENVIRONMENT,
|
||||
tracesSampleRate: 0,
|
||||
}),
|
||||
// @ts-ignore
|
||||
exportedHandler,
|
||||
);
|
||||
} as ExportedHandler<{ ASSETS: Fetcher }>;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
@@ -68,8 +70,9 @@ export function AdminToolbar(props: AdminToolbarProps) {
|
||||
|
||||
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
|
||||
const { spaceId, changeRequestId } = props;
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
|
||||
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
|
||||
const changeRequest = await getChangeRequest(ctx, spaceId, changeRequestId);
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
@@ -89,6 +92,7 @@ async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: s
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<RefreshChangeRequestButton
|
||||
ctx={ctx}
|
||||
spaceId={spaceId}
|
||||
changeRequestId={changeRequestId}
|
||||
revisionId={changeRequest.revision}
|
||||
@@ -101,8 +105,9 @@ async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: s
|
||||
|
||||
async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
|
||||
const { spaceId, revisionId } = props;
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
|
||||
const revision = await getRevision(spaceId, revisionId, {
|
||||
const revision = await getRevision(ctx, spaceId, revisionId, {
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ToolbarButton } from './Toolbar';
|
||||
@@ -14,6 +16,7 @@ const minInterval = 1000 * 30; // 5 minutes
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshChangeRequestButton(props: {
|
||||
ctx: GitBookContext;
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
import * as React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { getIpAndUserAgentFromHeaders, IpAndUserAgent } from '@/lib/gitbook-context';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { renderAd } from './renderAd';
|
||||
@@ -18,6 +20,7 @@ const PREVIEW_ZONE_ID = 'CVAIKKQM';
|
||||
* https://docs.buysellads.com/ad-serving-api
|
||||
*/
|
||||
export function Ad({
|
||||
ipAndUserAgent,
|
||||
zoneId,
|
||||
spaceId,
|
||||
placement,
|
||||
@@ -26,6 +29,7 @@ export function Ad({
|
||||
style,
|
||||
mode = 'auto',
|
||||
}: {
|
||||
ipAndUserAgent: IpAndUserAgent;
|
||||
zoneId: string | null;
|
||||
spaceId: string;
|
||||
placement: string;
|
||||
@@ -92,7 +96,7 @@ export function Ad({
|
||||
|
||||
(async () => {
|
||||
const result = showPlaceholderAd
|
||||
? await renderAd({ source: 'placeholder' })
|
||||
? await renderAd({ source: 'placeholder', ipAndUserAgent })
|
||||
: realZoneId
|
||||
? await renderAd({
|
||||
placement,
|
||||
@@ -100,6 +104,7 @@ export function Ad({
|
||||
zoneId: realZoneId,
|
||||
mode,
|
||||
source: 'live',
|
||||
ipAndUserAgent,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -115,7 +120,7 @@ export function Ad({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
|
||||
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus, ipAndUserAgent]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { headers } from 'next/headers';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -9,10 +11,11 @@ import { AdItem } from './types';
|
||||
* Classic rendering for an ad.
|
||||
*/
|
||||
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const smallImgSrc =
|
||||
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
|
||||
'smallImage' in ad ? getResizedImageURL(ctx, ad.smallImage, { width: 192, dpr: 2 }) : null;
|
||||
const logoSrc =
|
||||
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
|
||||
'logo' in ad ? getResizedImageURL(ctx, ad.logo, { width: 192 - 48, dpr: 2 }) : null;
|
||||
return (
|
||||
<a
|
||||
className={tcls(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { headers } from 'next/headers';
|
||||
import * as React from 'react';
|
||||
|
||||
import { hexToRgba } from '@/lib/colors';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -10,7 +12,8 @@ import { AdCover } from './types';
|
||||
* Cover rendering for an ad.
|
||||
*/
|
||||
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const largeImage = await getResizedImageURL(ctx, ad.largeImage, { width: 128, dpr: 2 });
|
||||
|
||||
return (
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { IpAndUserAgent } from '@/lib/gitbook-context';
|
||||
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
import { AdCoverRendering } from './AdCoverRendering';
|
||||
@@ -23,6 +23,8 @@ interface FetchLiveAdOptions {
|
||||
placement: string;
|
||||
/** If true, we'll not track it as an impression */
|
||||
ignore: boolean;
|
||||
/** IP and User-Agent to use for the request */
|
||||
ipAndUserAgent: IpAndUserAgent;
|
||||
}
|
||||
|
||||
interface FetchPlaceholderAdOptions {
|
||||
@@ -30,6 +32,8 @@ interface FetchPlaceholderAdOptions {
|
||||
* Source of the ad (placeholder: static placeholder ad)
|
||||
*/
|
||||
source: 'placeholder';
|
||||
/** IP and User-Agent to use for the request */
|
||||
ipAndUserAgent: IpAndUserAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +44,8 @@ interface FetchPlaceholderAdOptions {
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
|
||||
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
|
||||
const result =
|
||||
options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd(options);
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,12 +65,12 @@ export async function renderAd(options: FetchAdOptions) {
|
||||
}
|
||||
|
||||
async function fetchAd({
|
||||
ipAndUserAgent,
|
||||
zoneId,
|
||||
placement,
|
||||
ignore,
|
||||
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
|
||||
const { ip, userAgent } = await getUserAgentAndIp();
|
||||
|
||||
const { ip, userAgent } = ipAndUserAgent;
|
||||
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
|
||||
url.searchParams.set('segment', `placement:${placement}`);
|
||||
url.searchParams.set('v', 'true');
|
||||
@@ -86,9 +91,7 @@ async function fetchAd({
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
|
||||
const { ip } = await getUserAgentAndIp();
|
||||
|
||||
function getPlaceholderAd(options: FetchPlaceholderAdOptions): { ad: AdItem; ip: string } {
|
||||
return {
|
||||
ad: {
|
||||
active: '1',
|
||||
@@ -115,20 +118,6 @@ async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
|
||||
zoneid: '',
|
||||
zonekey: '',
|
||||
},
|
||||
ip,
|
||||
ip: options.ipAndUserAgent.ip,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserAgentAndIp() {
|
||||
const headersSet = await headers();
|
||||
const ip =
|
||||
headersSet.get('x-gitbook-ipv4') ??
|
||||
headersSet.get('x-gitbook-ip') ??
|
||||
headersSet.get('cf-pseudo-ipv4') ??
|
||||
headersSet.get('cf-connecting-ip') ??
|
||||
headersSet.get('x-forwarded-for') ??
|
||||
'';
|
||||
const userAgent = headersSet.get('user-agent') ?? '';
|
||||
|
||||
return { ip, userAgent };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
'use server';
|
||||
|
||||
import { getChangeRequest } from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
|
||||
/**
|
||||
* Return true if a change-request has been updated.
|
||||
*/
|
||||
export async function hasContentBeenUpdated(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
|
||||
export async function hasContentBeenUpdated(
|
||||
ctx: GitBookContext,
|
||||
props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
},
|
||||
) {
|
||||
const changeRequest = await getChangeRequest.revalidate(
|
||||
ctx,
|
||||
props.spaceId,
|
||||
props.changeRequestId,
|
||||
);
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useEventCallback } from 'usehooks-ts';
|
||||
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
|
||||
import { hasContentBeenUpdated } from './server-actions';
|
||||
|
||||
@@ -8,17 +11,23 @@ import { hasContentBeenUpdated } from './server-actions';
|
||||
* Return a callback to check if a change request has been updated and to refresh the page if it has.
|
||||
*/
|
||||
export function useCheckForContentUpdate(props: {
|
||||
ctx: GitBookContext;
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
const { spaceId, changeRequestId, revisionId } = props;
|
||||
const { ctx, spaceId, changeRequestId, revisionId } = props;
|
||||
const getCtx = useEventCallback(() => ctx);
|
||||
|
||||
return React.useCallback(async () => {
|
||||
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
|
||||
const updated = await hasContentBeenUpdated(getCtx(), {
|
||||
spaceId,
|
||||
changeRequestId,
|
||||
revisionId,
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [spaceId, changeRequestId, revisionId]);
|
||||
}, [spaceId, changeRequestId, revisionId, getCtx]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { DocumentBlockContentRef } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { Card } from '@/components/primitives';
|
||||
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ResolvedContentRef } from '@/lib/references';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
@@ -52,7 +54,9 @@ async function SpaceRefCard(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { customization: spaceCustomization } = await getSpaceCustomization();
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
|
||||
const { customization: spaceCustomization } = await getSpaceCustomization(ctx);
|
||||
const customFavicon = spaceCustomization?.favicon;
|
||||
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
|
||||
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
|
||||
|
||||
@@ -5,6 +5,7 @@ import ReactDOM from 'react-dom';
|
||||
|
||||
import { Card } from '@/components/primitives';
|
||||
import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
@@ -12,15 +13,15 @@ import { Caption } from './Caption';
|
||||
import { IntegrationBlock } from './Integration';
|
||||
|
||||
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { block, context, ...otherProps } = props;
|
||||
const headersList = await headers();
|
||||
const nonce = headersList.get('x-nonce') || undefined;
|
||||
const nonce = ctx.nonce || undefined;
|
||||
|
||||
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
|
||||
|
||||
const embed = await (context.content
|
||||
? getEmbedByUrlInSpace(context.content.spaceId, block.data.url)
|
||||
: getEmbedByUrl(block.data.url));
|
||||
? getEmbedByUrlInSpace(ctx, context.content.spaceId, block.data.url)
|
||||
: getEmbedByUrl(ctx, block.data.url));
|
||||
|
||||
return (
|
||||
<Caption {...props}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ContentKitContext, DocumentBlockIntegration } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { ContentKit, ContentKitOutput, ContentKitServerContext } from '@gitbook/react-contentkit';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { ignoreAPIError, renderIntegrationUi } from '@/lib/api';
|
||||
import { INTEGRATIONS_HOST } from '@/lib/csp';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { parseMarkdown } from '@/lib/markdown';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -45,6 +47,8 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
throw new Error('integration block requires a content.spaceId');
|
||||
}
|
||||
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
|
||||
const contentKitContext: ContentKitContext = {
|
||||
type: 'document',
|
||||
spaceId: context.content.spaceId,
|
||||
@@ -60,9 +64,10 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
};
|
||||
|
||||
const initialOutput = await ignoreAPIError(
|
||||
renderIntegrationUi(block.data.integration, initialInput),
|
||||
renderIntegrationUi(ctx, block.data.integration, initialInput),
|
||||
true,
|
||||
);
|
||||
|
||||
if (!initialOutput || initialOutput.type === 'complete') {
|
||||
return null;
|
||||
}
|
||||
@@ -76,7 +81,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
render={async (request) => {
|
||||
'use server';
|
||||
|
||||
const output = await renderIntegrationUi(block.data.integration, request);
|
||||
const output = await renderIntegrationUi(ctx, block.data.integration, request);
|
||||
|
||||
return {
|
||||
children: <ContentKitOutput output={output} context={outputContext} />,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { DocumentBlockReusableContent } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getDocument } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { UnwrappedBlocks } from './Blocks';
|
||||
|
||||
export async function ReusableContent(props: BlockProps<DocumentBlockReusableContent>) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { block, context, ancestorBlocks } = props;
|
||||
|
||||
if (!context.content) {
|
||||
@@ -17,7 +20,11 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = await getDocument(context.content.spaceId, resolved.reusableContent.document);
|
||||
const document = await getDocument(
|
||||
ctx,
|
||||
context.content.spaceId,
|
||||
resolved.reusableContent.document,
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function getBlockTextStyle(block: DocumentBlock): {
|
||||
};
|
||||
case 'heading-3':
|
||||
return {
|
||||
textSize: 'text-base font-semibold',
|
||||
textSize: 'text-xl font-semibold',
|
||||
lineHeight: 'leading-snug',
|
||||
marginTop: 'mt-[0.5em]',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CustomizationContentLink, CustomizationFooterGroup } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -22,8 +24,9 @@ export function FooterLinksGroup(props: {
|
||||
}
|
||||
|
||||
async function FooterLink(props: { link: CustomizationContentLink; context: ContentRefContext }) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { link, context } = props;
|
||||
const resolved = await resolveContentRef(link.to, context);
|
||||
const resolved = await resolveContentRef(ctx, link.to, context);
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
|
||||
@@ -91,8 +91,9 @@ export function DropdownChevron() {
|
||||
'opacity-6',
|
||||
'size-3',
|
||||
'ms-1',
|
||||
'transition-transform',
|
||||
'group-hover/dropdown:rotate-180',
|
||||
'transition-all',
|
||||
'group-hover/dropdown:opacity-11',
|
||||
'group-focus-within/dropdown:rotate-180',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,9 @@ import { HeaderLinks } from './HeaderLinks';
|
||||
import { HeaderLogo } from './HeaderLogo';
|
||||
import { SpacesDropdown } from './SpacesDropdown';
|
||||
import { SearchButton } from '../Search';
|
||||
import { SiteSectionTabs } from '../SiteSectionTabs';
|
||||
import { SiteSectionTabs } from '../SiteSections';
|
||||
import { HeaderMobileMenu } from './HeaderMobileMenu';
|
||||
|
||||
/**
|
||||
* Render the header for the space.
|
||||
*/
|
||||
@@ -74,14 +75,23 @@ export function Header(props: {
|
||||
'gap-4',
|
||||
'lg:gap-8',
|
||||
'flex',
|
||||
'h-16',
|
||||
'items-center',
|
||||
'justify-between',
|
||||
'w-full',
|
||||
'py-3',
|
||||
'min-h-16',
|
||||
'sm:h-16',
|
||||
isMultiVariants && 'page-no-toc:max-[400px]:flex-wrap',
|
||||
CONTAINER_STYLE,
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-full shrink min-w-0 gap-2 lg:gap-4 justify-start items-center">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex max-w-full',
|
||||
isMultiVariants && 'page-no-toc:max-[400px]:w-full',
|
||||
'shrink min-w-0 gap-2 lg:gap-4 justify-start items-center',
|
||||
)}
|
||||
>
|
||||
<HeaderMobileMenu
|
||||
className={tcls(
|
||||
'lg:hidden',
|
||||
@@ -93,31 +103,14 @@ export function Header(props: {
|
||||
)}
|
||||
/>
|
||||
<HeaderLogo site={site} space={space} customization={customization} />
|
||||
{!hasSiteSections && isMultiVariants ? (
|
||||
<div className="z-20 shrink hidden sm:block">
|
||||
<SpacesDropdown
|
||||
className={tcls(
|
||||
!isCustomizationDefault &&
|
||||
withTopHeader && [
|
||||
'bg-header-link/2',
|
||||
'dark:bg-header-link/2',
|
||||
'text-header-link/8',
|
||||
'dark:text-header-link/8',
|
||||
'ring-1',
|
||||
'ring-header-link/4',
|
||||
'dark:ring-header-link/4',
|
||||
'contrast-more:bg-header-background',
|
||||
'contrast-more:text-header-link',
|
||||
'contrast-more:ring-header-link',
|
||||
],
|
||||
)}
|
||||
space={space}
|
||||
spaces={spaces}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMultiVariants && (
|
||||
<div className="hidden page-no-toc:flex mr-auto">
|
||||
<SpacesDropdown space={space} spaces={spaces} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customization.header.links.length > 0 && (
|
||||
<HeaderLinks>
|
||||
{customization.header.links.map((link, index) => {
|
||||
@@ -218,7 +211,7 @@ export function Header(props: {
|
||||
'overflow-x-auto hide-scroll',
|
||||
)}
|
||||
>
|
||||
<SiteSectionTabs {...sections} />
|
||||
<SiteSectionTabs sections={sections} />
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -25,9 +27,10 @@ export async function HeaderLink(props: {
|
||||
link: CustomizationHeaderItem;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { context, link, customization } = props;
|
||||
|
||||
const target = link.to ? await resolveContentRef(link.to, context) : null;
|
||||
const target = link.to ? await resolveContentRef(ctx, link.to, context) : null;
|
||||
const headerPreset = customization.header.preset;
|
||||
const linkStyle = link.style ?? 'link';
|
||||
|
||||
@@ -207,9 +210,10 @@ async function SubHeaderLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationContentLink;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { context, link } = props;
|
||||
|
||||
const target = await resolveContentRef(link.to, context);
|
||||
const target = await resolveContentRef(ctx, link.to, context);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -67,9 +69,10 @@ async function MoreMenuLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationHeaderItem | CustomizationContentLink;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { context, link } = props;
|
||||
|
||||
const target = link.to ? await resolveContentRef(link.to, context) : null;
|
||||
const target = link.to ? await resolveContentRef(ctx, link.to, context) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { Image } from '@/components/utils';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
@@ -25,8 +27,9 @@ interface HeaderLogoProps {
|
||||
*/
|
||||
|
||||
export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { customization } = props;
|
||||
const href = await getAbsoluteHref('');
|
||||
const href = getAbsoluteHref(ctx, '');
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -11,18 +11,19 @@ import { useScrollListener } from '../hooks/useScrollListener';
|
||||
|
||||
const globalClassName = 'navigation-open';
|
||||
|
||||
const SCROLL_DISTANCE = 320;
|
||||
|
||||
/**
|
||||
* Button to show/hide the table of content on mobile.
|
||||
*/
|
||||
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) {
|
||||
const language = useLanguage();
|
||||
const scrollDistance = 320;
|
||||
|
||||
const pathname = usePathname();
|
||||
const [hasScrolled, setHasScrolled] = useState(false);
|
||||
const hasScrollRef = useRef(false);
|
||||
|
||||
const toggleNavigation = () => {
|
||||
if (!hasScrolled && document.body.classList.contains(globalClassName)) {
|
||||
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
|
||||
document.body.classList.remove(globalClassName);
|
||||
} else {
|
||||
document.body.classList.add(globalClassName);
|
||||
@@ -30,13 +31,9 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
|
||||
}
|
||||
};
|
||||
|
||||
const windowRef = useRef(window);
|
||||
const windowRef = useRef(typeof window === 'undefined' ? null : window);
|
||||
useScrollListener(() => {
|
||||
if (window.scrollY >= scrollDistance) {
|
||||
setHasScrolled(true);
|
||||
} else {
|
||||
setHasScrolled(false);
|
||||
}
|
||||
hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE;
|
||||
}, windowRef);
|
||||
|
||||
// Close the navigation when navigating to a page
|
||||
|
||||
@@ -10,6 +10,10 @@ export function SpacesDropdown(props: { space: Space; spaces: Space[]; className
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className={tcls(
|
||||
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
|
||||
'group-focus-within/dropdown:group-hover/dropdown:visible', // When the dropdown is already open, it should remain visible when hovered
|
||||
)}
|
||||
button={(buttonProps) => (
|
||||
<div
|
||||
{...buttonProps}
|
||||
@@ -18,29 +22,54 @@ export function SpacesDropdown(props: { space: Space; spaces: Space[]; className
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'rounded-2xl',
|
||||
'transition-all',
|
||||
'hover:cursor-pointer',
|
||||
|
||||
'px-3',
|
||||
'py-2',
|
||||
'gap-2',
|
||||
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-none',
|
||||
|
||||
'hover:cursor-pointer',
|
||||
'bg-dark/1',
|
||||
'dark:bg-light/1',
|
||||
'bg-light',
|
||||
'group-hover/dropdown:bg-light-1',
|
||||
'group-focus-within/dropdown:bg-light-1',
|
||||
'dark:bg-dark-1',
|
||||
'dark:group-hover/dropdown:bg-dark-3',
|
||||
'dark:group-focus-within/dropdown:bg-dark-3',
|
||||
|
||||
'text-sm',
|
||||
'text-dark-4',
|
||||
'group-hover/dropdown:text-dark',
|
||||
'group-focus-within/dropdown:text-dark',
|
||||
'dark:text-light-4',
|
||||
'dark:group-hover/dropdown:text-light',
|
||||
'dark:group-focus-within/dropdown:text-light',
|
||||
|
||||
'ring-1',
|
||||
'ring-dark/2',
|
||||
'group-hover/dropdown:ring-dark/4',
|
||||
'group-focus-within/dropdown:ring-dark/4',
|
||||
'dark:ring-light/2',
|
||||
'dark:group-hover/dropdown:ring-light/4',
|
||||
'dark:group-focus-within/dropdown:ring-light/4',
|
||||
|
||||
'contrast-more:bg-light',
|
||||
'contrast-more:ring-1',
|
||||
'contrast-more:ring-dark',
|
||||
'dark:contrast-more:ring-light',
|
||||
'dark:contrast-more:bg-dark',
|
||||
'contrast-more:ring-1',
|
||||
'contrast-more:group-hover/dropdown:ring-2',
|
||||
'contrast-more:ring-dark',
|
||||
'contrast-more:group-hover/dropdown:ring-dark',
|
||||
'contrast-more:group-focus-within/dropdown:ring-dark',
|
||||
'dark:contrast-more:ring-light',
|
||||
'dark:contrast-more:group-hover/dropdown:ring-light',
|
||||
'dark:contrast-more:group-focus-within/dropdown:ring-light',
|
||||
|
||||
'px-3',
|
||||
'py-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-2">{space.title}</span>
|
||||
<span className={tcls('line-clamp-2', 'grow')}>{space.title}</span>
|
||||
<DropdownChevron />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import cookies from 'js-cookie';
|
||||
import * as cookies from '@/lib/cookies';
|
||||
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Transition, motion, useReducedMotion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function AnimatedLine({ transition }: { transition?: Transition }) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
layoutId="sections-line"
|
||||
className={tcls([
|
||||
'border-primary',
|
||||
'border-l',
|
||||
'dark:border-primary-400',
|
||||
'h-full',
|
||||
'absolute',
|
||||
'z-20',
|
||||
'-left-[5px]',
|
||||
])}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : transition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Transition, motion, useReducedMotion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
export function AsideSectionHighlight({
|
||||
transition,
|
||||
className,
|
||||
}: {
|
||||
transition?: Transition;
|
||||
className?: ClassValue;
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
layoutId="sections-line"
|
||||
className={tcls([
|
||||
'border-primary',
|
||||
'sidebar-list-line:border-l-2',
|
||||
|
||||
'dark:border-primary-400',
|
||||
'inset-0',
|
||||
'pointer-events-none',
|
||||
'absolute',
|
||||
'z-0',
|
||||
'sidebar-list-line:-left-px',
|
||||
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-none',
|
||||
'sidebar-list-line:rounded-l-none',
|
||||
|
||||
'sidebar-list-pill:bg-primary/3',
|
||||
'dark:sidebar-list-pill:bg-primary-400/3',
|
||||
|
||||
'contrast-more:border',
|
||||
'contrast-more:bg-primary/3',
|
||||
'dark:contrast-more:bg-primary-400/3',
|
||||
className,
|
||||
])}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : transition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { getDocumentSections } from '@/lib/document';
|
||||
import { getGitBookContextFromHeaders, getIpAndUserAgentFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -23,13 +25,15 @@ import { Ad } from '../Ads';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
|
||||
function getTopOffset(props: { sectionsHeader: boolean; topHeader: boolean }) {
|
||||
if (props.sectionsHeader && props.topHeader) {
|
||||
return 'lg:max-h-[calc(100vh_-_8rem)] top-32 page-api-block:xl:max-2xl:top-32';
|
||||
if (props.topHeader && props.sectionsHeader) {
|
||||
return 'lg:top-[6.75rem] lg:max-h-[calc(100vh_-_6.75rem)]';
|
||||
}
|
||||
|
||||
if (props.topHeader) {
|
||||
return 'lg:max-h-[calc(100vh_-_6rem)] top-24 page-api-block:xl:max-2xl:top-24';
|
||||
return 'lg:top-16 lg:max-h-[calc(100vh_-_4rem)]';
|
||||
}
|
||||
return 'lg:max-h-screen top-0 page-api-block:xl:max-2xl:top-0';
|
||||
|
||||
return 'lg:top-0 lg:max-h-screen';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +50,9 @@ export async function PageAside(props: {
|
||||
withFullPageCover: boolean;
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
const headersList = await headers();
|
||||
const ctx = getGitBookContextFromHeaders(headersList);
|
||||
const ipAndUserAgent = getIpAndUserAgentFromHeaders(headersList);
|
||||
const {
|
||||
space,
|
||||
site,
|
||||
@@ -59,7 +66,8 @@ export async function PageAside(props: {
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const topOffset = getTopOffset(withHeaderOffset);
|
||||
const pdfHref = await getAbsoluteHref(
|
||||
const pdfHref = getAbsoluteHref(
|
||||
ctx,
|
||||
`~gitbook/pdf?${getPDFUrlSearchParams({
|
||||
page: page.id,
|
||||
only: true,
|
||||
@@ -76,22 +84,32 @@ export async function PageAside(props: {
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'sticky',
|
||||
withHeaderOffset.topHeader ? 'py-4' : 'py-8',
|
||||
'break-anywhere', // To prevent long words in headings from breaking the layout
|
||||
'lg:h-full',
|
||||
'h-[100vh]',
|
||||
'page-api-block:xl:max-2xl:z-[1]',
|
||||
|
||||
'text-dark/7',
|
||||
'dark:text-light/7',
|
||||
'contrast-more:text-dark',
|
||||
'contrast-more:dark:text-light',
|
||||
|
||||
// When in api page mode, we display it as an overlay on non-large resolutions
|
||||
'page-api-block:xl:max-2xl:backdrop-blur-md',
|
||||
'page-api-block:xl:max-2xl:z-10',
|
||||
'page-api-block:xl:max-2xl:fixed',
|
||||
'page-api-block:xl:max-2xl:right-8',
|
||||
'page-api-block:xl:max-2xl:w-56',
|
||||
'page-api-block:xl:max-2xl:bg-light-2/9',
|
||||
'page-api-block:xl:max-2xl:rounded',
|
||||
'page-api-block:xl:max-2xl:bg-light-2',
|
||||
'page-api-block:xl:max-2xl:bg-opacity-9',
|
||||
'page-api-block:xl:max-2xl:contrast-more:bg-opacity-11',
|
||||
'page-api-block:xl:max-2xl:backdrop-blur-lg',
|
||||
'page-api-block:xl:max-2xl:border',
|
||||
'page-api-block:xl:max-2xl:border-dark/2',
|
||||
'page-api-block:xl:max-2xl:dark:border-light/2',
|
||||
'page-api-block:xl:max-2xl:hover:shadow-lg',
|
||||
'page-api-block:xl:max-2xl:hover:shadow-dark/2',
|
||||
'page-api-block:xl:max-2xl:rounded-md',
|
||||
'page-api-block:xl:max-2xl:h-auto',
|
||||
'page-api-block:xl:max-2xl:py-0',
|
||||
'page-api-block:xl:max-2xl:mt-3',
|
||||
'page-api-block:xl:max-2xl:my-8',
|
||||
'dark:page-api-block:xl:max-2xl:bg-dark-2/8',
|
||||
|
||||
topOffset,
|
||||
)}
|
||||
>
|
||||
@@ -99,17 +117,28 @@ export async function PageAside(props: {
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'page-api-block:xl:max-2xl:flex',
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'font-semibold',
|
||||
'uppercase',
|
||||
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-3',
|
||||
'text-sm',
|
||||
'font-semibold',
|
||||
'px-2',
|
||||
'py-2',
|
||||
'gap-2',
|
||||
'p-2',
|
||||
)}
|
||||
>
|
||||
<Icon icon="bars" className={tcls('size-3')} />
|
||||
<Icon icon="block-quote" className={tcls('size-3')} />
|
||||
{t(language, 'on_this_page')}
|
||||
<Icon
|
||||
icon="chevron-down"
|
||||
className={tcls(
|
||||
'size-3',
|
||||
'opacity-6',
|
||||
'ml-auto',
|
||||
'page-api-block:xl:max-2xl:group-hover/aside:hidden',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -118,9 +147,11 @@ export async function PageAside(props: {
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-6',
|
||||
'py-8',
|
||||
'[&::-webkit-scrollbar]:bg-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-transparent',
|
||||
|
||||
'page-api-block:xl:max-2xl:py-0',
|
||||
// Hide it for api page, until hovered
|
||||
'page-api-block:xl:max-2xl:hidden',
|
||||
'page-api-block:xl:max-2xl:group-hover/aside:flex',
|
||||
@@ -132,11 +163,24 @@ export async function PageAside(props: {
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls('flex', 'flex-col', 'gap-3', 'page-api-block:xl:max-2xl:px-3')}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-3',
|
||||
'sidebar-list-default:px-3',
|
||||
'border-t',
|
||||
'first:border-none',
|
||||
'border-dark/2',
|
||||
'dark:border-light/2',
|
||||
'py-4',
|
||||
'first:pt-0',
|
||||
'page-api-block:xl:max-2xl:px-3',
|
||||
'empty:hidden',
|
||||
)}
|
||||
>
|
||||
{withPageFeedback ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
|
||||
<PageFeedbackForm ctx={ctx} pageId={page.id} className={tcls('mt-2')} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
|
||||
@@ -148,10 +192,8 @@ export async function PageAside(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'hover:text-primary',
|
||||
'py-2',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
@@ -175,10 +217,8 @@ export async function PageAside(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'hover:text-primary',
|
||||
'py-2',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
|
||||
@@ -189,6 +229,7 @@ export async function PageAside(props: {
|
||||
</div>
|
||||
</div>
|
||||
<Ad
|
||||
ipAndUserAgent={ipAndUserAgent}
|
||||
zoneId={
|
||||
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
||||
}
|
||||
@@ -196,16 +237,19 @@ export async function PageAside(props: {
|
||||
spaceId={space.id}
|
||||
siteAdsStatus={site?.ads && site.ads.status ? site.ads.status : undefined}
|
||||
ignore={process.env.NODE_ENV !== 'production'}
|
||||
style={tcls('mt-4')}
|
||||
style={tcls(site?.ads && site.ads.status === SiteAdsStatus.Live && ['mb-4'])}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
async function PageAsideSections(props: { document: JSONDocument; context: ContentRefContext }) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { document, context } = props;
|
||||
|
||||
const sections = await getDocumentSections(document, (ref) => resolveContentRef(ref, context));
|
||||
const sections = await getDocumentSections(document, (ref) =>
|
||||
resolveContentRef(ctx, ref, context),
|
||||
);
|
||||
|
||||
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useScrollActiveId } from '@/components/hooks';
|
||||
import { DocumentSection } from '@/lib/document';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AnimatedLine } from './AnimatedLine';
|
||||
import { AsideSectionHighlight } from './AsideSectionHighlight';
|
||||
import { HEADER_HEIGHT_DESKTOP } from '../layout';
|
||||
|
||||
/**
|
||||
@@ -36,44 +36,88 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<ul className={tcls('border-l', 'border-dark/2', 'dark:border-light/1', 'pl-1')}>
|
||||
<ul className={tcls('sidebar-list-line:border-l', 'border-dark/2', 'dark:border-light/1')}>
|
||||
{sections.map((section) => (
|
||||
<motion.li
|
||||
key={section.id}
|
||||
className={tcls('flex', 'flex-row', 'relative', 'h-fit')}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'relative',
|
||||
'h-fit',
|
||||
'mt-2',
|
||||
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0'],
|
||||
)}
|
||||
>
|
||||
{activeId === section.id ? <AnimatedLine transition={springCurve} /> : null}
|
||||
{activeId === section.id ? (
|
||||
<AsideSectionHighlight
|
||||
transition={springCurve}
|
||||
className={tcls(
|
||||
section?.depth > 1
|
||||
? [
|
||||
'sidebar-list-default:rounded-l-none',
|
||||
'sidebar-list-line:rounded-l-none',
|
||||
'sidebar-list-default:border-l',
|
||||
]
|
||||
: [
|
||||
'sidebar-list-default:ml-3',
|
||||
'contrast-more:sidebar-list-default:ml-0',
|
||||
],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className={tcls(
|
||||
'relative',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'z-10',
|
||||
'w-full',
|
||||
'items-baseline',
|
||||
'left-[-1px]',
|
||||
'relative',
|
||||
'z-10',
|
||||
'text-sm',
|
||||
|
||||
'w-full',
|
||||
'py-1',
|
||||
'ps-3',
|
||||
'pe-2',
|
||||
'px-3',
|
||||
|
||||
'transition-all',
|
||||
'duration-200',
|
||||
section.depth > 1 ? ['ps-6', 'opacity-8'] : null,
|
||||
activeId === section.id
|
||||
? [
|
||||
'text-primary',
|
||||
'dark:text-primary-400',
|
||||
'[&>span]:bg-primary-400',
|
||||
'dark:[&>span]:bg-primary-600',
|
||||
'dark:[&>span]:text-dark',
|
||||
]
|
||||
: [
|
||||
'text-neutral-500',
|
||||
'dark:text-neutral-400',
|
||||
'hover:text-neutral-900',
|
||||
'dark:hover:text-neutral-100',
|
||||
],
|
||||
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-none',
|
||||
'sidebar-list-line:rounded-l-none',
|
||||
|
||||
'hover:bg-dark/1',
|
||||
'dark:hover:bg-light/1',
|
||||
'contrast-more:hover:ring-1',
|
||||
'contrast-more:hover:ring-inset',
|
||||
'contrast-more:hover:ring-current',
|
||||
|
||||
section.depth > 1 && [
|
||||
'subitem',
|
||||
'sidebar-list-line:pl-6',
|
||||
'opacity-8',
|
||||
'contrast-more:opacity-11',
|
||||
|
||||
'sidebar-list-default:rounded-l-none',
|
||||
'sidebar-list-default:border-l',
|
||||
'sidebar-list-default:border-dark/3',
|
||||
'dark:sidebar-list-default:border-light/3',
|
||||
],
|
||||
|
||||
activeId === section.id && [
|
||||
'text-primary',
|
||||
'hover:text-primary',
|
||||
'dark:text-primary-400',
|
||||
'dark:hover:text-primary-400',
|
||||
|
||||
'contrast-more:font-semibold',
|
||||
|
||||
'hover:bg-primary/3',
|
||||
'dark:hover:bg-primary-400/3',
|
||||
'sidebar-list-pill:hover:bg-transparent',
|
||||
'dark:sidebar-list-pill:hover:bg-transparent',
|
||||
],
|
||||
)}
|
||||
>
|
||||
{section.tag ? (
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { ContentTarget, SiteContentPointer, api } from '@/lib/api';
|
||||
import { ContentTarget, SiteContentPointer } from '@/lib/api';
|
||||
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -25,7 +27,7 @@ import { TrackPageViewEvent } from '../Insights';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
import { DateRelative } from '../primitives';
|
||||
|
||||
export function PageBody(props: {
|
||||
export async function PageBody(props: {
|
||||
space: Space;
|
||||
pointer: SiteContentPointer;
|
||||
contentTarget: ContentTarget;
|
||||
@@ -36,6 +38,7 @@ export function PageBody(props: {
|
||||
context: ContentRefContext;
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const {
|
||||
space,
|
||||
contentTarget,
|
||||
@@ -99,7 +102,7 @@ export function PageBody(props: {
|
||||
content: contentTarget,
|
||||
contentRefContext: context,
|
||||
resolveContentRef: (ref, options) =>
|
||||
resolveContentRef(ref, context, options),
|
||||
resolveContentRef(ctx, ref, context, options),
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
@@ -140,7 +143,7 @@ export function PageBody(props: {
|
||||
</p>
|
||||
) : null}
|
||||
{withPageFeedback ? (
|
||||
<PageFeedbackForm orientation="horizontal" pageId={page.id} />
|
||||
<PageFeedbackForm ctx={ctx} orientation="horizontal" pageId={page.id} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RevisionPage, RevisionPageDocument, RevisionPageType } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { Card } from '@/components/primitives';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getPageHref } from '@/lib/links';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -15,6 +17,7 @@ export async function PageBodyBlankslate(props: {
|
||||
rootPages: RevisionPage[];
|
||||
context: ContentRefContext;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { page, rootPages, context } = props;
|
||||
|
||||
const pages = page.pages.filter((child) =>
|
||||
@@ -35,7 +38,7 @@ export async function PageBodyBlankslate(props: {
|
||||
'Unexpected computed page, it should have been computed in the API',
|
||||
);
|
||||
} else if (child.type === RevisionPageType.Link) {
|
||||
const resolved = await resolveContentRef(child.target, context);
|
||||
const resolved = await resolveContentRef(ctx, child.target, context);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
@@ -53,7 +56,7 @@ export async function PageBodyBlankslate(props: {
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const href = await getPageHref(rootPages, child);
|
||||
const href = getPageHref(ctx, rootPages, child);
|
||||
return <Card key={child.id} title={child.title} leadingIcon={icon} href={href} />;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { Image, ImageSize } from '@/components/utils';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -17,8 +19,9 @@ export async function PageCover(props: {
|
||||
cover: RevisionPageDocumentCover;
|
||||
context: ContentRefContext;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { as, page, cover, context } = props;
|
||||
const resolved = cover.ref ? await resolveContentRef(cover.ref, context) : null;
|
||||
const resolved = cover.ref ? await resolveContentRef(ctx, cover.ref, context) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon, IconName } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getPageHref } from '@/lib/links';
|
||||
import { resolvePrevNextPages } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -24,11 +26,12 @@ export async function PageFooterNavigation(props: {
|
||||
pages: Revision['pages'];
|
||||
page: RevisionPageDocument;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { customization, pages, page } = props;
|
||||
const { previous, next } = resolvePrevNextPages(pages, page);
|
||||
const language = getSpaceLanguage(customization);
|
||||
const previousHref = previous ? await getPageHref(pages, previous) : '';
|
||||
const nextHref = next ? await getPageHref(pages, next) : '';
|
||||
const previousHref = previous ? getPageHref(ctx, pages, previous) : '';
|
||||
const nextHref = next ? getPageHref(ctx, pages, next) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RevisionPage, RevisionPageDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getPageHref } from '@/lib/links';
|
||||
import { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -14,6 +16,7 @@ export async function PageHeader(props: {
|
||||
ancestors: AncestorRevisionPage[];
|
||||
pages: RevisionPage[];
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { page, ancestors, pages } = props;
|
||||
|
||||
if (!page.layout.title && !page.layout.description) {
|
||||
@@ -22,7 +25,7 @@ export async function PageHeader(props: {
|
||||
|
||||
const ancestorElements = await Promise.all(
|
||||
ancestors.map(async (breadcrumb, index) => {
|
||||
const href = await getPageHref(pages, breadcrumb);
|
||||
const href = await getPageHref(ctx, pages, breadcrumb);
|
||||
return (
|
||||
<Fragment key={breadcrumb.id}>
|
||||
<li key={breadcrumb.id}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { PageFeedbackRating } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getVisitorId, useTrackEvent } from '../Insights';
|
||||
@@ -14,11 +16,12 @@ import { postPageFeedback } from './server-actions';
|
||||
* Form to submit feedback on a page.
|
||||
*/
|
||||
export function PageFeedbackForm(props: {
|
||||
ctx: GitBookContext;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
pageId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { orientation = 'vertical', pageId, className } = props;
|
||||
const { ctx, orientation = 'vertical', pageId, className } = props;
|
||||
const languages = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
@@ -26,7 +29,7 @@ export function PageFeedbackForm(props: {
|
||||
const onSubmit = async (rating: PageFeedbackRating) => {
|
||||
setSubmitted(true);
|
||||
const visitorId = await getVisitorId();
|
||||
await postPageFeedback({ pageId, visitorId, rating });
|
||||
await postPageFeedback(ctx, { pageId, visitorId, rating });
|
||||
|
||||
trackEvent({
|
||||
type: 'page_post_feedback',
|
||||
@@ -47,9 +50,7 @@ export function PageFeedbackForm(props: {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className={tcls('text-sm', 'text-dark/6', 'dark:text-light/5')}>
|
||||
{t(languages, 'was_this_helpful')}
|
||||
</p>
|
||||
<p className={tcls('text-sm')}>{t(languages, 'was_this_helpful')}</p>
|
||||
<div
|
||||
className={tcls(
|
||||
'inline-flex',
|
||||
@@ -66,7 +67,7 @@ export function PageFeedbackForm(props: {
|
||||
)}
|
||||
>
|
||||
{submitted ? (
|
||||
<p className={tcls('text-sm', 'px-4', 'text-dark/7', 'dark:text-light/6')}>
|
||||
<p className={tcls('text-sm', 'px-4')}>
|
||||
{t(languages, 'was_this_helpful_thank_you')}
|
||||
</p>
|
||||
) : (
|
||||
@@ -117,10 +118,10 @@ function RatingButton(props: { rating: number; label: string; onClick: () => voi
|
||||
'h-8',
|
||||
'w-8',
|
||||
'rounded-sm',
|
||||
'text-dark/6',
|
||||
'text-dark/7',
|
||||
'hover:bg-primary/4',
|
||||
'hover:text-primary-600',
|
||||
'dark:text-light/5',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:text-primary-300',
|
||||
'dark:hover:bg-primary-300/2',
|
||||
)}
|
||||
|
||||
@@ -4,23 +4,25 @@ import { PageFeedbackRating } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
export async function postPageFeedback(args: {
|
||||
pageId: string;
|
||||
visitorId: string;
|
||||
rating: PageFeedbackRating;
|
||||
}) {
|
||||
const { organizationId, siteId, siteSpaceId } = await getSiteContentPointer();
|
||||
export async function postPageFeedback(
|
||||
ctx: GitBookContext,
|
||||
args: {
|
||||
pageId: string;
|
||||
visitorId: string;
|
||||
rating: PageFeedbackRating;
|
||||
},
|
||||
) {
|
||||
const { organizationId, siteId, siteSpaceId } = getSiteContentPointer(ctx);
|
||||
|
||||
assert(
|
||||
siteSpaceId,
|
||||
`No siteSpaceId in pointer. organizationId: ${organizationId}, siteId: ${siteId}, pageId: ${args.pageId}`,
|
||||
);
|
||||
|
||||
const apiCtx = await api();
|
||||
|
||||
await apiCtx.client.orgs.createSitesPageFeedback(
|
||||
await api(ctx).client.orgs.createSitesPageFeedback(
|
||||
organizationId,
|
||||
siteId,
|
||||
siteSpaceId,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { useEventCallback } from 'usehooks-ts';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
@@ -9,6 +10,7 @@ import { t } from '@/intl/translate';
|
||||
import { TranslationLanguage } from '@/intl/translations';
|
||||
import { iterateStreamResponse } from '@/lib/actions';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
@@ -32,14 +34,19 @@ export type SearchAskState =
|
||||
/**
|
||||
* Fetch and render the answers to a question.
|
||||
*/
|
||||
export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: string }) {
|
||||
const { pointer, query } = props;
|
||||
export function SearchAskAnswer(props: {
|
||||
ctx: GitBookContext;
|
||||
pointer: SiteContentPointer;
|
||||
query: string;
|
||||
}) {
|
||||
const { ctx, pointer, query } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
const [, setSearchState] = useSearch();
|
||||
const [askState, setAskState] = useSearchAskContext();
|
||||
const { organizationId, siteId, siteSpaceId } = pointer;
|
||||
const getCtx = useEventCallback(() => ctx);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -52,7 +59,13 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str
|
||||
query,
|
||||
});
|
||||
|
||||
const response = streamAskQuestion(organizationId, siteId, siteSpaceId ?? null, query);
|
||||
const response = streamAskQuestion(
|
||||
getCtx(),
|
||||
organizationId,
|
||||
siteId,
|
||||
siteSpaceId ?? null,
|
||||
query,
|
||||
);
|
||||
const stream = iterateStreamResponse(response);
|
||||
|
||||
// When we pass in "ask" mode, the query could still be updated by the client
|
||||
@@ -81,7 +94,16 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
}, [organizationId, siteId, siteSpaceId, query, setAskState, setSearchState, trackEvent]);
|
||||
}, [
|
||||
organizationId,
|
||||
siteId,
|
||||
siteSpaceId,
|
||||
query,
|
||||
setAskState,
|
||||
setSearchState,
|
||||
trackEvent,
|
||||
getCtx,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
@@ -18,6 +19,7 @@ import { SearchState, UpdateSearchState, useSearch } from './useSearch';
|
||||
import { LoadingPane } from '../primitives/LoadingPane';
|
||||
|
||||
interface SearchModalProps {
|
||||
ctx: GitBookContext;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
spaceTitle: string;
|
||||
@@ -308,6 +310,7 @@ function SearchModalBody(
|
||||
{!state.ask || !withAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
ctx={props.ctx}
|
||||
pointer={pointer}
|
||||
spaceId={spaceId}
|
||||
revisionId={revisionId}
|
||||
@@ -318,7 +321,7 @@ function SearchModalBody(
|
||||
></SearchResults>
|
||||
) : null}
|
||||
{state.query && state.ask && withAsk ? (
|
||||
<SearchAskAnswer pointer={pointer} query={state.query} />
|
||||
<SearchAskAnswer ctx={props.ctx} pointer={pointer} query={state.query} />
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
import { captureException } from '@sentry/nextjs';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
import { useEventCallback } from 'usehooks-ts';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
@@ -32,10 +36,11 @@ type ResultType =
|
||||
* Fetch the results of the keyboard navigable elements to display for a query:
|
||||
* - Recommended questions if no query is provided.
|
||||
* - Search results if a query is provided.
|
||||
* - If withAsk is true and the query is a question, add a question result.
|
||||
* - If withAsk is true, add a question result.
|
||||
*/
|
||||
export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
ctx: GitBookContext;
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
spaceId: string;
|
||||
@@ -47,78 +52,227 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>,
|
||||
) {
|
||||
const { children, query, pointer, spaceId, revisionId, global, withAsk, onSwitchToAsk } = props;
|
||||
const { ctx, children, query, pointer, spaceId, revisionId, withAsk, global, onSwitchToAsk } =
|
||||
props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
const debounceTimeout = React.useRef<Timer | null>(null);
|
||||
const [results, setResults] = React.useState<ResultType[] | null>(null);
|
||||
const [resultsState, setResultsState] = React.useState<{
|
||||
results: ResultType[];
|
||||
fetching: boolean;
|
||||
}>({ results: [], fetching: true });
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
const suggestedQuestionsRef = React.useRef<null | string[]>(null);
|
||||
const suggestedQuestionsRef = React.useRef<null | ResultType[]>(null);
|
||||
|
||||
// const getCtx = useEventCallback(() => ctx);
|
||||
const memoizedCtx = React.useMemo(() => ctx, [ctx.apiToken, ctx.visitorToken]);
|
||||
const getCtx = useEventCallback(() => memoizedCtx);
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
setResultsState((prev) => ({ ...prev, fetching: true }));
|
||||
|
||||
const results = await (global
|
||||
? searchAllSiteContent(getCtx(), query, pointer)
|
||||
: searchSiteSpaceContent(getCtx(), query, pointer, revisionId));
|
||||
|
||||
if (!results) {
|
||||
captureException(
|
||||
new Error(
|
||||
`corrupt-cache: ${global ? 'searchAllSiteContent' : 'searchSiteSpaceContent'} is ${results}`,
|
||||
),
|
||||
{ extra: { results } },
|
||||
);
|
||||
setResultsState({ results: [], fetching: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setResultsState({ results, fetching: false });
|
||||
|
||||
trackEvent({
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
});
|
||||
}, [global, getCtx, pointer, query, revisionId, trackEvent]);
|
||||
|
||||
const timeoutRef = React.useRef<Timer | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
if (!withAsk) {
|
||||
setResultsState({ results: [], fetching: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestedQuestionsRef.current) {
|
||||
setResultsState({ results: suggestedQuestionsRef.current, fetching: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(suggestedQuestionsRef.current
|
||||
? Promise.resolve(suggestedQuestionsRef.current)
|
||||
: getRecommendedQuestions(spaceId)
|
||||
).then((questions) => {
|
||||
suggestedQuestionsRef.current = questions;
|
||||
setResultsState({ results: [], fetching: true });
|
||||
getRecommendedQuestions(getCtx(), spaceId).then((questions) => {
|
||||
if (!questions) {
|
||||
if (!cancelled) {
|
||||
setResultsState({ results: [], fetching: false });
|
||||
}
|
||||
captureException(
|
||||
new Error(`corrupt-cache: getRecommendedQuestions is ${questions}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = questions.map((question) => ({
|
||||
type: 'recommended-question',
|
||||
id: question,
|
||||
question: question,
|
||||
})) satisfies ResultType[];
|
||||
|
||||
suggestedQuestionsRef.current = results;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(
|
||||
questions.map((question) => ({
|
||||
type: 'recommended-question',
|
||||
id: question,
|
||||
question: question,
|
||||
})),
|
||||
);
|
||||
setResultsState({ results, fetching: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
} else {
|
||||
if (withAsk) {
|
||||
setResults((prev) => withQuestionResult(prev, query));
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
debounceTimeout.current = setTimeout(async () => {
|
||||
const fetchedResults = await (global
|
||||
? searchAllSiteContent(query, pointer)
|
||||
: searchSiteSpaceContent(query, pointer, revisionId));
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults);
|
||||
|
||||
trackEvent({
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
});
|
||||
}, 350);
|
||||
fetchResults();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
debounceTimeout.current = null;
|
||||
cancelled = true;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [query, global, pointer, spaceId, revisionId, withAsk]);
|
||||
}, [query, fetchResults, global, pointer, spaceId, revisionId, withAsk, getCtx]);
|
||||
|
||||
// React.useEffect(() => {
|
||||
// if (!query) {
|
||||
// if (!withAsk) {
|
||||
// setResultsState({ results: [], fetching: false });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (suggestedQuestionsRef.current) {
|
||||
// setResultsState({ results: suggestedQuestionsRef.current, fetching: false });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// let cancelled = false;
|
||||
|
||||
// setResultsState({ results: [], fetching: true });
|
||||
// getRecommendedQuestions(getCtx(), spaceId).then((questions) => {
|
||||
// if (!questions) {
|
||||
// if (!cancelled) {
|
||||
// setResultsState({ results: [], fetching: false });
|
||||
// }
|
||||
// captureException(
|
||||
// new Error(`corrupt-cache: getRecommendedQuestions is ${questions}`),
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const results = questions.map((question) => ({
|
||||
// type: 'recommended-question',
|
||||
// id: question,
|
||||
// question: question,
|
||||
// })) satisfies ResultType[];
|
||||
|
||||
// suggestedQuestionsRef.current = results;
|
||||
|
||||
// if (cancelled) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// setResultsState({ results, fetching: false });
|
||||
// });
|
||||
|
||||
// return () => {
|
||||
// cancelled = true;
|
||||
// };
|
||||
// } else {
|
||||
// setResultsState((prev) => ({ results: prev.results, fetching: true }));
|
||||
|
||||
// let cancelled = false;
|
||||
// timeoutRef.current = setTimeout(async () => {
|
||||
// // const timeout = setTimeout(async () => {
|
||||
// const results = await (global
|
||||
// ? searchAllSiteContent(getCtx(), query, pointer)
|
||||
// : searchSiteSpaceContent(getCtx(), query, pointer, revisionId));
|
||||
|
||||
// if (cancelled) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (!results) {
|
||||
// captureException(
|
||||
// new Error(
|
||||
// `corrupt-cache: ${global ? 'searchAllSiteContent' : 'searchSiteSpaceContent'} is ${results}`,
|
||||
// ),
|
||||
// { extra: { results } },
|
||||
// );
|
||||
// // setResultsState({ results: [], fetching: false });
|
||||
// setResultsState((prev) => {
|
||||
// if (prev.results.length === 0 && !prev.fetching) return prev;
|
||||
// return { results: [], fetching: false };
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // setResultsState({ results, fetching: false });
|
||||
// setResultsState((prev) => {
|
||||
// if (prev.results === results && !prev.fetching) return prev;
|
||||
// return { results, fetching: false };
|
||||
// });
|
||||
|
||||
// trackEvent({
|
||||
// type: 'search_type_query',
|
||||
// query,
|
||||
// });
|
||||
// }, 1000);
|
||||
|
||||
// // return () => {
|
||||
// // cancelled = true;
|
||||
// // clearTimeout(timeout);
|
||||
// // };
|
||||
// return () => {
|
||||
// cancelled = true;
|
||||
// if (timeoutRef.current) {
|
||||
// clearTimeout(timeoutRef.current);
|
||||
// timeoutRef.current = null;
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
// }, [query, global, pointer, spaceId, revisionId, withAsk, trackEvent, getCtx]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAsk) {
|
||||
return resultsState.results;
|
||||
}
|
||||
return withQuestionResult(resultsState.results, query);
|
||||
}, [resultsState.results, query, withAsk]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
} else if (results && results.length > 0) {
|
||||
} else if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setCursor(0);
|
||||
}
|
||||
@@ -138,10 +292,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
|
||||
const moveBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
if (!results) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursor((prev) => {
|
||||
if (prev === null) {
|
||||
return 0;
|
||||
@@ -175,96 +325,95 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
[moveBy, select],
|
||||
);
|
||||
|
||||
if (!results?.some((result) => result.type !== 'question')) {
|
||||
if (query) {
|
||||
return (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
|
||||
<Loading className={tcls('w-6', 'text-primary')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (resultsState.fetching) {
|
||||
return (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
|
||||
<Loading className={tcls('w-6', 'text-primary')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noResults = (
|
||||
<div
|
||||
data-test="search-noresults"
|
||||
className={tcls('text', 'text-dark/8', 'p-8', 'text-center', 'dark:text-light/8')}
|
||||
>
|
||||
{t(language, 'search_no_results', query)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('overflow-auto')}>
|
||||
{children}
|
||||
{results.length === 0 ? (
|
||||
<div
|
||||
data-test="search-noresults"
|
||||
className={tcls(
|
||||
'text',
|
||||
'text-dark/8',
|
||||
'p-8',
|
||||
'text-center',
|
||||
'dark:text-light/8',
|
||||
)}
|
||||
>
|
||||
{t(language, 'search_no_results', query)}
|
||||
</div>
|
||||
query ? (
|
||||
noResults
|
||||
) : null
|
||||
) : (
|
||||
<div data-test="search-results">
|
||||
{results.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
<>
|
||||
<div data-test="search-results">
|
||||
{results.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
{!results.some((result) => result.type !== 'question') && noResults}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -273,8 +422,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
/**
|
||||
* Add a "Ask <question>" item at the top of the results list.
|
||||
*/
|
||||
function withQuestionResult(results: null | ResultType[], query: string): null | ResultType[] {
|
||||
const without = results ? results.filter((result) => result.type !== 'question') : null;
|
||||
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
|
||||
const without = results.filter((result) => result.type !== 'question');
|
||||
|
||||
if (query.length === 0) {
|
||||
return without;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { RevisionPage, SearchAIAnswer, SearchPageResult, SiteSpace, Space } from '@gitbook/api';
|
||||
import { captureException } from '@sentry/nextjs';
|
||||
import * as React from 'react';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { streamResponse } from '@/lib/actions';
|
||||
import * as api from '@/lib/api';
|
||||
import { GitBookContext } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref, getPageHref } from '@/lib/links';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
@@ -48,15 +50,18 @@ export interface AskAnswerResult {
|
||||
/**
|
||||
* Search for content in a site by scoping the search to all content, a specific spaces or current space.
|
||||
*/
|
||||
async function searchSiteContent(args: {
|
||||
pointer: api.SiteContentPointer;
|
||||
query: string;
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
cacheBust?: string;
|
||||
}): Promise<OrderedComputedResult[]> {
|
||||
async function searchSiteContent(
|
||||
ctx: GitBookContext,
|
||||
args: {
|
||||
pointer: api.SiteContentPointer;
|
||||
query: string;
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
cacheBust?: string;
|
||||
},
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
const { pointer, scope, query, cacheBust } = args;
|
||||
|
||||
if (query.length <= 1) {
|
||||
@@ -69,8 +74,8 @@ async function searchSiteContent(args: {
|
||||
(scope.mode === 'specific' && scope.siteSpaceIds.length > 1);
|
||||
|
||||
const [searchResults, siteData] = await Promise.all([
|
||||
api.searchSiteContent(pointer.organizationId, pointer.siteId, query, scope, cacheBust),
|
||||
needsStructure ? api.getSiteData(pointer) : null,
|
||||
api.searchSiteContent(ctx, pointer.organizationId, pointer.siteId, query, scope, cacheBust),
|
||||
needsStructure ? api.getSiteData(ctx, pointer) : null,
|
||||
]);
|
||||
const siteStructure = siteData?.structure;
|
||||
|
||||
@@ -93,38 +98,31 @@ async function searchSiteContent(args: {
|
||||
|
||||
if (siteSpaces) {
|
||||
// We are searching all of this Site's content
|
||||
return (
|
||||
await Promise.all(
|
||||
searchResults.items.map(async (spaceItem) => {
|
||||
const siteSpace = siteSpaces.find(
|
||||
(siteSpace) => siteSpace.space.id === spaceItem.id,
|
||||
);
|
||||
return searchResults.items
|
||||
.map((spaceItem) => {
|
||||
const siteSpace = siteSpaces.find(
|
||||
(siteSpace) => siteSpace.space.id === spaceItem.id,
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
spaceItem.pages.map((item) => transformSitePageResult(item, siteSpace)),
|
||||
);
|
||||
}),
|
||||
)
|
||||
).flat(2);
|
||||
return spaceItem.pages.map((item) => transformSitePageResult(ctx, item, siteSpace));
|
||||
})
|
||||
.flat(2);
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
searchResults.items.map((spaceItem) => {
|
||||
return Promise.all(spaceItem.pages.map((item) => transformPageResult(item)));
|
||||
}),
|
||||
)
|
||||
).flat(2);
|
||||
return searchResults.items
|
||||
.map((spaceItem) => spaceItem.pages.map((item) => transformPageResult(ctx, item)))
|
||||
.flat(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in the entire site.
|
||||
*/
|
||||
export async function searchAllSiteContent(
|
||||
ctx: GitBookContext,
|
||||
query: string,
|
||||
pointer: api.SiteContentPointer,
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return await searchSiteContent({
|
||||
return await searchSiteContent(ctx, {
|
||||
pointer,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
@@ -135,6 +133,7 @@ export async function searchAllSiteContent(
|
||||
* Server action to search content in a space.
|
||||
*/
|
||||
export async function searchSiteSpaceContent(
|
||||
ctx: GitBookContext,
|
||||
query: string,
|
||||
pointer: api.SiteContentPointer,
|
||||
revisionId: string,
|
||||
@@ -142,7 +141,7 @@ export async function searchSiteSpaceContent(
|
||||
const siteSpaceId = pointer.siteSpaceId;
|
||||
assert(siteSpaceId, 'Expected siteSpaceId for searchSiteSpaceContent');
|
||||
|
||||
return await searchSiteContent({
|
||||
return await searchSiteContent(ctx, {
|
||||
pointer,
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
@@ -159,13 +158,13 @@ export async function searchSiteSpaceContent(
|
||||
* Server action to ask a question in a space.
|
||||
*/
|
||||
export const streamAskQuestion = streamResponse(async function* (
|
||||
ctx: GitBookContext,
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
siteSpaceId: string | null,
|
||||
question: string,
|
||||
) {
|
||||
const apiCtx = await api.api();
|
||||
const stream = apiCtx.client.orgs.streamAskInSite(
|
||||
const stream = api.api(ctx).client.orgs.streamAskInSite(
|
||||
organizationId,
|
||||
siteId,
|
||||
{
|
||||
@@ -199,7 +198,9 @@ export const streamAskQuestion = streamResponse(async function* (
|
||||
if (!spacePromises.has(source.space)) {
|
||||
spacePromises.set(
|
||||
source.space,
|
||||
api.getRevisionPages(source.space, source.revision, { metadata: false }),
|
||||
api.getRevisionPages(ctx, source.space, source.revision, {
|
||||
metadata: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,48 +222,56 @@ export const streamAskQuestion = streamResponse(async function* (
|
||||
return map;
|
||||
}, new Map<string, RevisionPage[]>());
|
||||
});
|
||||
yield await transformAnswer(chunk.answer, pages);
|
||||
yield transformAnswer(ctx, chunk.answer, pages);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List suggested questions for a space.
|
||||
*/
|
||||
export async function getRecommendedQuestions(spaceId: string): Promise<string[]> {
|
||||
const data = await api.getRecommendedQuestionsInSpace(spaceId);
|
||||
export async function getRecommendedQuestions(
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
): Promise<string[]> {
|
||||
const data = await api.getRecommendedQuestionsInSpace(ctx, spaceId);
|
||||
if (!data.questions) {
|
||||
captureException(new Error('Expected questions in getRecommendedQuestions'), {
|
||||
extra: { data },
|
||||
});
|
||||
return [];
|
||||
}
|
||||
return data.questions;
|
||||
}
|
||||
|
||||
async function transformAnswer(
|
||||
function transformAnswer(
|
||||
ctx: GitBookContext,
|
||||
answer: SearchAIAnswer,
|
||||
spacePages: Map<string, RevisionPage[]>,
|
||||
): Promise<AskAnswerResult> {
|
||||
const sources = (
|
||||
await Promise.all(
|
||||
answer.sources.map(async (source) => {
|
||||
if (source.type !== 'page') {
|
||||
return null;
|
||||
}
|
||||
): AskAnswerResult {
|
||||
const sources = answer.sources
|
||||
.map((source) => {
|
||||
if (source.type !== 'page') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pages = spacePages.get(source.space);
|
||||
const pages = spacePages.get(source.space);
|
||||
|
||||
if (!pages) {
|
||||
return null;
|
||||
}
|
||||
if (!pages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = resolvePageId(pages, source.page);
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
const page = resolvePageId(pages, source.page);
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: source.page,
|
||||
title: page.page.title,
|
||||
href: await getPageHref(pages, page.page),
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter(filterOutNullable);
|
||||
return {
|
||||
id: source.page,
|
||||
title: page.page.title,
|
||||
href: getPageHref(ctx, pages, page.page),
|
||||
};
|
||||
})
|
||||
.filter(filterOutNullable);
|
||||
|
||||
return {
|
||||
body:
|
||||
@@ -283,16 +292,19 @@ async function transformAnswer(
|
||||
};
|
||||
}
|
||||
|
||||
async function transformSectionsAndPage(args: {
|
||||
item: SearchPageResult;
|
||||
space?: Space;
|
||||
spaceURL?: string;
|
||||
}): Promise<[ComputedPageResult, ComputedSectionResult[]]> {
|
||||
function transformSectionsAndPage(
|
||||
ctx: GitBookContext,
|
||||
args: {
|
||||
item: SearchPageResult;
|
||||
space?: Space;
|
||||
spaceURL?: string;
|
||||
},
|
||||
): [ComputedPageResult, ComputedSectionResult[]] {
|
||||
const { item, space, spaceURL } = args;
|
||||
|
||||
// Resolve a relative path to an absolute URL
|
||||
// if the search result is relative to another space, we use the space URL
|
||||
const getURL = async (path: string, spaceURL?: string) => {
|
||||
const getURL = (path: string, spaceURL?: string) => {
|
||||
if (spaceURL) {
|
||||
if (!spaceURL.endsWith('/')) {
|
||||
spaceURL += '/';
|
||||
@@ -302,33 +314,36 @@ async function transformSectionsAndPage(args: {
|
||||
}
|
||||
return spaceURL + path;
|
||||
} else {
|
||||
return getAbsoluteHref(path);
|
||||
return getAbsoluteHref(ctx, path);
|
||||
}
|
||||
};
|
||||
|
||||
const sections = await Promise.all(
|
||||
item.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
|
||||
const sections =
|
||||
item.sections?.map<ComputedSectionResult>((section) => ({
|
||||
type: 'section',
|
||||
id: item.id + '/' + section.id,
|
||||
title: section.title,
|
||||
href: await getURL(section.path, spaceURL),
|
||||
href: getURL(section.path, spaceURL),
|
||||
body: section.body,
|
||||
})) ?? [],
|
||||
);
|
||||
})) ?? [];
|
||||
|
||||
const page: ComputedPageResult = {
|
||||
type: 'page',
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
href: await getURL(item.path, spaceURL),
|
||||
href: getURL(item.path, spaceURL),
|
||||
spaceTitle: space?.title,
|
||||
};
|
||||
|
||||
return [page, sections];
|
||||
}
|
||||
|
||||
async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteSpace) {
|
||||
const [page, sections] = await transformSectionsAndPage({
|
||||
function transformSitePageResult(
|
||||
ctx: GitBookContext,
|
||||
item: SearchPageResult,
|
||||
siteSpace?: SiteSpace,
|
||||
) {
|
||||
const [page, sections] = transformSectionsAndPage(ctx, {
|
||||
item,
|
||||
space: siteSpace?.space,
|
||||
spaceURL: siteSpace?.urls.published,
|
||||
@@ -337,8 +352,8 @@ async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteS
|
||||
return [page, ...sections];
|
||||
}
|
||||
|
||||
async function transformPageResult(item: SearchPageResult, space?: Space) {
|
||||
const [page, sections] = await transformSectionsAndPage({
|
||||
function transformPageResult(ctx: GitBookContext, item: SearchPageResult, space?: Space) {
|
||||
const [page, sections] = transformSectionsAndPage(ctx, {
|
||||
item,
|
||||
space,
|
||||
spaceURL: space?.urls.published ?? space?.urls.app,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { type IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { SectionsList } from '@/lib/api';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
import { useIsMounted } from '../hooks';
|
||||
import { TOCScrollContainer, useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller';
|
||||
|
||||
const MAX_ITEMS = 5; // If there are more sections than this, they'll be shown below the fold in a scrollview.
|
||||
|
||||
/**
|
||||
* A list of items representing site sections for multi-section sites
|
||||
*/
|
||||
export function SiteSectionList(props: { sections: SectionsList; className: ClassValue }) {
|
||||
const {
|
||||
sections: { list: sections, index: currentIndex },
|
||||
className,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
sections.length > 0 && (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className={tcls(
|
||||
`text-dark/8 dark:text-light/8 text-sm
|
||||
border-b border-dark/2 dark:border-light/2 -mx-5 relative
|
||||
before:absolute before:contents[] before:left-0 before:right-2 before:bottom-0 before:h-12 before:pointer-events-none before:bg-gradient-to-b from-transparent to-light dark:to-dark`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<TOCScrollContainer
|
||||
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
|
||||
className="overflow-y-auto px-2 pb-6 gutter-stable"
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<SiteSectionListItem
|
||||
section={section}
|
||||
isActive={index === currentIndex}
|
||||
key={section.id}
|
||||
/>
|
||||
))}
|
||||
</TOCScrollContainer>
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteSectionListItem(props: { section: SiteSection; isActive: boolean }) {
|
||||
const { section, isActive, ...otherProps } = props;
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item
|
||||
|
||||
const linkRef = React.createRef<HTMLAnchorElement>();
|
||||
useScrollToActiveTOCItem({ linkRef, isActive });
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={section.urls.published ?? ''}
|
||||
ref={linkRef}
|
||||
aria-current={isActive && 'page'}
|
||||
className={`flex flex-row items-center gap-3 px-3 py-2
|
||||
hover:bg-dark/1 contrast-more:hover:ring-1 contrast-more:hover:ring-dark dark:hover:bg-light/1 contrast-more:dark:hover:ring-light
|
||||
hover:text-dark/9 dark:hover:text-light/9
|
||||
rounded-md straight-corners:rounded-none transition-all group/section-link
|
||||
${
|
||||
isActive
|
||||
? `text-primary hover:text-primary contrast-more:text-primary-700 contrast-more:hover:text-primary-700 contrast-more:font-semibold
|
||||
dark:text-primary-400 dark:hover:text-primary-400 dark:contrast-more:text-primary-300 dark:contrast-more:hover:text-primary-300
|
||||
hover:bg-primary/3 contrast-more:hover:ring-1 contrast-more:hover:ring-primary-700 dark:hover:bg-primary-400/3 contrast-more:dark:hover:ring-primary-300`
|
||||
: null
|
||||
}`}
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
className={`size-8 flex items-center justify-center
|
||||
bg-light-1 dark:bg-dark-1 shadow-sm shadow-dark/4
|
||||
dark:shadow-none rounded-md straight-corners:rounded-none leading-none
|
||||
ring-1 ring-dark/1 dark:ring-light/2
|
||||
text-dark/6 contrast-more:text-dark dark:text-light/6 contrast-more:dark:text-light
|
||||
group-hover/section-link:scale-110 group-active/section-link:scale-90 group-active/section-link:shadow-none
|
||||
transition-transform text-lg
|
||||
${
|
||||
isActive
|
||||
? `bg-primary-50 dark:bg-primary-900
|
||||
ring-primary-600/6 dark:ring-primary-400/6
|
||||
shadow-md shadow-primary-600/4
|
||||
contrast-more:ring-2 contrast-more:ring-primary-700 contrast-more:dark:ring-primary-300
|
||||
text-primary-600 contrast-more:text-primary-700 dark:text-primary-400 dark:contrast-more:text-primary-300`
|
||||
: null
|
||||
}`}
|
||||
>
|
||||
{section.icon ? (
|
||||
<SectionIcon icon={section.icon as IconName} isActive={isActive} />
|
||||
) : (
|
||||
<span className={`opacity-8 text-sm ${isActive && 'opacity-10'}`}>
|
||||
{section.title.substring(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{section.title}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+6
-7
@@ -4,20 +4,19 @@ import type { SiteSection } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { SectionsList } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* A set of navigational tabs representing site sections for multi-section sites
|
||||
*/
|
||||
export function SiteSectionTabs(props: {
|
||||
list: SiteSection[];
|
||||
section: SiteSection;
|
||||
index: number;
|
||||
}) {
|
||||
const { list: sections, index: currentIndex } = props;
|
||||
export function SiteSectionTabs(props: { sections: SectionsList }) {
|
||||
const {
|
||||
sections: { list: sections, index: currentIndex },
|
||||
} = props;
|
||||
|
||||
const currentTabRef = React.useRef<HTMLAnchorElement>(null);
|
||||
const navRef = React.useRef<HTMLDivElement>(null);
|
||||
+1
@@ -1 +1,2 @@
|
||||
export * from './SiteSectionTabs';
|
||||
export * from './SiteSectionList';
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CustomizationThemedURL } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { Image } from '@/components/utils';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
|
||||
import { Emoji } from '../primitives';
|
||||
@@ -14,6 +16,7 @@ export async function SpaceIcon(
|
||||
'sources'
|
||||
>,
|
||||
) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { icon, emoji, alt, ...imageProps } = props;
|
||||
|
||||
if (emoji && !icon) {
|
||||
@@ -37,14 +40,16 @@ export async function SpaceIcon(
|
||||
}
|
||||
: {
|
||||
light: {
|
||||
src: await getAbsoluteHref(
|
||||
src: getAbsoluteHref(
|
||||
ctx,
|
||||
'~gitbook/icon?size=medium&theme=light',
|
||||
true,
|
||||
),
|
||||
size: { width: 256, height: 256 },
|
||||
},
|
||||
dark: {
|
||||
src: await getAbsoluteHref(
|
||||
src: getAbsoluteHref(
|
||||
ctx,
|
||||
'~gitbook/icon?size=medium&theme=dark',
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -19,13 +20,14 @@ import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { api, ContentTarget, type SectionsList, SiteContentPointer } from '@/lib/api';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { getCurrentVisitorToken } from '@/lib/visitor-token';
|
||||
|
||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
import { SiteSectionList } from '../SiteSections';
|
||||
|
||||
/**
|
||||
* Render the entire content of the space (header, table of contents, footer, and page content).
|
||||
@@ -42,6 +44,7 @@ export async function SpaceLayout(props: {
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const {
|
||||
space,
|
||||
contentTarget,
|
||||
@@ -73,9 +76,9 @@ export async function SpaceLayout(props: {
|
||||
'sidebar' in customization.styling &&
|
||||
customization.styling.sidebar.background === CustomizationSidebarBackgroundStyle.Filled,
|
||||
};
|
||||
const apiHost = (await api()).client.endpoint;
|
||||
const visitorAuthToken = await getCurrentVisitorToken();
|
||||
const enabled = await shouldTrackEvents();
|
||||
const apiHost = api(ctx).client.endpoint;
|
||||
const visitorAuthToken = ctx.visitorToken;
|
||||
const enabled = shouldTrackEvents(ctx);
|
||||
|
||||
return (
|
||||
<InsightsProvider
|
||||
@@ -84,7 +87,6 @@ export async function SpaceLayout(props: {
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
{...content}
|
||||
>
|
||||
{/* <ColorDebugger /> */}
|
||||
<Header
|
||||
withTopHeader={withTopHeader}
|
||||
space={space}
|
||||
@@ -136,47 +138,37 @@ export async function SpaceLayout(props: {
|
||||
}
|
||||
innerHeader={
|
||||
// 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.
|
||||
withVariants || !withTopHeader ? (
|
||||
<>
|
||||
{!withTopHeader && (
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'lg:block',
|
||||
'flex-shrink-0',
|
||||
'grow-0',
|
||||
'md:grow',
|
||||
'sm:max-w-xs',
|
||||
'lg:max-w-full',
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchButton>
|
||||
<span className={tcls('flex-1')}>
|
||||
{t(
|
||||
getSpaceLanguage(customization),
|
||||
customization.aiSearch.enabled
|
||||
? 'search_or_ask'
|
||||
: 'search',
|
||||
)}
|
||||
</span>
|
||||
</SearchButton>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
{withVariants && (
|
||||
<div
|
||||
className={tcls(
|
||||
withTopHeader && !sections
|
||||
? 'sm:hidden'
|
||||
: ['sm:hidden', 'lg:flex'],
|
||||
)}
|
||||
>
|
||||
<SpacesDropdown space={space} spaces={spaces} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
<>
|
||||
{!withTopHeader && (
|
||||
<div className={tcls('hidden', 'lg:block')}>
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchButton>
|
||||
<span className={tcls('flex-1')}>
|
||||
{t(
|
||||
getSpaceLanguage(customization),
|
||||
customization.aiSearch.enabled
|
||||
? 'search_or_ask'
|
||||
: 'search',
|
||||
)}
|
||||
</span>
|
||||
</SearchButton>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
{!withTopHeader && withSections && sections && (
|
||||
<SiteSectionList
|
||||
className={tcls('hidden', 'lg:block')}
|
||||
sections={sections}
|
||||
/>
|
||||
)}
|
||||
{withVariants && (
|
||||
<SpacesDropdown
|
||||
space={space}
|
||||
spaces={spaces}
|
||||
className={tcls('w-full')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
@@ -193,6 +185,7 @@ export async function SpaceLayout(props: {
|
||||
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchModal
|
||||
ctx={ctx}
|
||||
spaceId={contentTarget.spaceId}
|
||||
revisionId={contentTarget.revisionId}
|
||||
spaceTitle={customization.title ?? space.title}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { getPageHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
@@ -15,8 +17,9 @@ export async function PageDocumentItem(props: {
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
context: ContentRefContext;
|
||||
}) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { rootPages, page, ancestors, context } = props;
|
||||
const href = await getPageHref(rootPages, page);
|
||||
const href = await getPageHref(ctx, rootPages, page);
|
||||
|
||||
return (
|
||||
<li className={tcls('flex', 'flex-col')}>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { RevisionPageLink } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { Link } from '@/components/primitives';
|
||||
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { TOCPageIcon } from './TOCPageIcon';
|
||||
|
||||
export async function PageLinkItem(props: { page: RevisionPageLink; context: ContentRefContext }) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
const { page, context } = props;
|
||||
|
||||
const resolved = await resolveContentRef(page.target, context);
|
||||
const resolved = await resolveContentRef(ctx, page.target, context);
|
||||
|
||||
return (
|
||||
<li className={tcls('flex', 'flex-col')}>
|
||||
|
||||
@@ -17,6 +17,7 @@ export function TOCPageIcon({ page }: { page: RevisionPage }) {
|
||||
'dark:text-light/6',
|
||||
'group-aria-current-page/toclink:text-primary',
|
||||
'group-aria-current-page/toclink:dark:text-primary-400',
|
||||
'shrink-0',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,13 +16,17 @@ function useTOCScrollContainerRefContext() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TOCScrollContainer(props: { children: React.ReactNode; className?: ClassValue }) {
|
||||
const { children, className } = props;
|
||||
export function TOCScrollContainer(props: {
|
||||
children: React.ReactNode;
|
||||
className?: ClassValue;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const { children, className, style } = props;
|
||||
const scrollContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<TOCScrollContainerRefContext.Provider value={scrollContainerRef}>
|
||||
<div ref={scrollContainerRef} className={tcls(className)}>
|
||||
<div ref={scrollContainerRef} className={tcls(className)} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
</TOCScrollContainerRefContext.Provider>
|
||||
@@ -30,7 +34,7 @@ export function TOCScrollContainer(props: { children: React.ReactNode; className
|
||||
}
|
||||
|
||||
// Offset to scroll the table of contents item by.
|
||||
const TOC_ITEM_OFFSET = 200;
|
||||
const TOC_ITEM_OFFSET = 100;
|
||||
|
||||
/**
|
||||
* Scrolls the table of contents container to the page item when it becomes active
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ToggleableLinkItem(
|
||||
'hover:contrast-more:ring-dark',
|
||||
'dark:contrast-more:hover:ring-light',
|
||||
|
||||
'before:content-[]',
|
||||
'before:contents[]',
|
||||
'before:absolute',
|
||||
'before:inset-y-0',
|
||||
'before:-left-px',
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ThemeToggler } from '../ThemeToggler';
|
||||
|
||||
/**
|
||||
* Styled version of Next.js Link component.
|
||||
*/
|
||||
interface ColorIndex {
|
||||
base: string;
|
||||
opacities: string[];
|
||||
}
|
||||
|
||||
const darkColorIndex: Record<string, ColorIndex> = {
|
||||
0: {
|
||||
base: 'bg-dark-1',
|
||||
opacities: [
|
||||
'bg-dark-1/10',
|
||||
'bg-dark-1/9',
|
||||
'bg-dark-1/8',
|
||||
'bg-dark-1/7',
|
||||
'bg-dark-1/6',
|
||||
'bg-dark-1/5',
|
||||
'bg-dark-1/4',
|
||||
'bg-dark-1/3',
|
||||
'bg-dark-1/2',
|
||||
'bg-dark-1/1',
|
||||
],
|
||||
},
|
||||
1: {
|
||||
base: 'bg-dark',
|
||||
opacities: [
|
||||
'bg-dark/10',
|
||||
'bg-dark/9',
|
||||
'bg-dark/8',
|
||||
'bg-dark/7',
|
||||
'bg-dark/6',
|
||||
'bg-dark/5',
|
||||
'bg-dark/4',
|
||||
'bg-dark/3',
|
||||
'bg-dark/2',
|
||||
'bg-dark/1',
|
||||
],
|
||||
},
|
||||
2: {
|
||||
base: 'bg-dark-2',
|
||||
opacities: [
|
||||
'bg-dark-2/10',
|
||||
'bg-dark-2/9',
|
||||
'bg-dark-2/8',
|
||||
'bg-dark-2/7',
|
||||
'bg-dark-2/6',
|
||||
'bg-dark-2/5',
|
||||
'bg-dark-2/4',
|
||||
'bg-dark-2/3',
|
||||
'bg-dark-2/2',
|
||||
'bg-dark-2/1',
|
||||
],
|
||||
},
|
||||
3: {
|
||||
base: 'bg-dark-3',
|
||||
opacities: [
|
||||
'bg-dark-3/10',
|
||||
'bg-dark-3/9',
|
||||
'bg-dark-3/8',
|
||||
'bg-dark-3/7',
|
||||
'bg-dark-3/6',
|
||||
'bg-dark-3/5',
|
||||
'bg-dark-3/4',
|
||||
'bg-dark-3/3',
|
||||
'bg-dark-3/2',
|
||||
'bg-dark-3/1',
|
||||
],
|
||||
},
|
||||
4: {
|
||||
base: 'bg-dark-4',
|
||||
opacities: [
|
||||
'bg-dark-4/10',
|
||||
'bg-dark-4/9',
|
||||
'bg-dark-4/8',
|
||||
'bg-dark-4/7',
|
||||
'bg-dark-4/6',
|
||||
'bg-dark-4/5',
|
||||
'bg-dark-4/4',
|
||||
'bg-dark-4/3',
|
||||
'bg-dark-4/2',
|
||||
'bg-dark-4/1',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const lightColorIndex: Record<string, ColorIndex> = {
|
||||
0: {
|
||||
base: 'bg-light-1',
|
||||
opacities: [
|
||||
'bg-light-1/10',
|
||||
'bg-light-1/9',
|
||||
'bg-light-1/8',
|
||||
'bg-light-1/7',
|
||||
'bg-light-1/6',
|
||||
'bg-light-1/5',
|
||||
'bg-light-1/4',
|
||||
'bg-light-1/3',
|
||||
'bg-light-1/2',
|
||||
'bg-light-1/1',
|
||||
],
|
||||
},
|
||||
1: {
|
||||
base: 'bg-light',
|
||||
opacities: [
|
||||
'bg-light/10',
|
||||
'bg-light/9',
|
||||
'bg-light/8',
|
||||
'bg-light/7',
|
||||
'bg-light/6',
|
||||
'bg-light/5',
|
||||
'bg-light/4',
|
||||
'bg-light/3',
|
||||
'bg-light/2',
|
||||
'bg-light/1',
|
||||
],
|
||||
},
|
||||
2: {
|
||||
base: 'bg-light-2',
|
||||
opacities: [
|
||||
'bg-light-2/10',
|
||||
'bg-light-2/9',
|
||||
'bg-light-2/8',
|
||||
'bg-light-2/7',
|
||||
'bg-light-2/6',
|
||||
'bg-light-2/5',
|
||||
'bg-light-2/4',
|
||||
'bg-light-2/3',
|
||||
'bg-light-2/2',
|
||||
'bg-light-2/1',
|
||||
],
|
||||
},
|
||||
3: {
|
||||
base: 'bg-light-3',
|
||||
opacities: [
|
||||
'bg-light-3/10',
|
||||
'bg-light-3/9',
|
||||
'bg-light-3/8',
|
||||
'bg-light-3/7',
|
||||
'bg-light-3/6',
|
||||
'bg-light-3/5',
|
||||
'bg-light-3/4',
|
||||
'bg-light-3/3',
|
||||
'bg-light-3/2',
|
||||
'bg-light-3/1',
|
||||
],
|
||||
},
|
||||
4: {
|
||||
base: 'bg-light-4',
|
||||
opacities: [
|
||||
'bg-light-4/10',
|
||||
'bg-light-4/9',
|
||||
'bg-light-4/8',
|
||||
'bg-light-4/7',
|
||||
'bg-light-4/6',
|
||||
'bg-light-4/5',
|
||||
'bg-light-4/4',
|
||||
'bg-light-4/3',
|
||||
'bg-light-4/2',
|
||||
'bg-light-4/1',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function ColorDebugger() {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'w-100',
|
||||
'top-0',
|
||||
'left-0',
|
||||
'z-50',
|
||||
'transition-colors',
|
||||
'duration-200',
|
||||
'bg-light',
|
||||
'hover:bg-black',
|
||||
'dark:bg-dark',
|
||||
'dark:hover:bg-white',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'place-self-start')}>
|
||||
<ThemeToggler />
|
||||
</div>
|
||||
|
||||
{Object.keys(darkColorIndex).map((key) => {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<div className={tcls('flex', 'w-full', 'flex-col')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'text-xs',
|
||||
'w-full',
|
||||
'h-6',
|
||||
'aspect-square',
|
||||
darkColorIndex[key].base,
|
||||
)}
|
||||
>
|
||||
BASE {darkColorIndex[key].base}
|
||||
</div>
|
||||
{darkColorIndex[key].opacities.map((opacity, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={tcls(
|
||||
'w-full',
|
||||
'h-5',
|
||||
'flex',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'text-xs',
|
||||
opacity,
|
||||
)}
|
||||
>
|
||||
{opacity}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'text-xs',
|
||||
'w-full',
|
||||
'h-6',
|
||||
'aspect-square',
|
||||
lightColorIndex[key].base,
|
||||
)}
|
||||
>
|
||||
BASE {lightColorIndex[key].base}
|
||||
</div>
|
||||
{lightColorIndex[key].opacities.map((opacity, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={tcls(
|
||||
'w-full',
|
||||
'h-5',
|
||||
'flex',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'text-xs',
|
||||
opacity,
|
||||
)}
|
||||
>
|
||||
{opacity}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { headers } from 'next/headers';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context';
|
||||
import { checkIsHttpURL, getImageSize, getResizedImageURLFactory } from '@/lib/images';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -191,6 +193,8 @@ async function ImagePictureSized(
|
||||
} & ImageCommonProps
|
||||
>,
|
||||
) {
|
||||
const ctx = getGitBookContextFromHeaders(await headers());
|
||||
|
||||
const {
|
||||
source,
|
||||
sizes,
|
||||
@@ -210,7 +214,7 @@ async function ImagePictureSized(
|
||||
throw new Error('You must provide at least one size for the image.');
|
||||
}
|
||||
|
||||
const attrs = await getImageAttributes({ sizes, source, quality, resize });
|
||||
const attrs = await getImageAttributes(ctx, { sizes, source, quality, resize });
|
||||
const canBeFetched = checkIsHttpURL(attrs.src);
|
||||
const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined;
|
||||
const loading = priority === 'lazy' ? 'lazy' : undefined;
|
||||
@@ -243,12 +247,15 @@ async function ImagePictureSized(
|
||||
* Get the attributes for an image.
|
||||
* src, srcSet, sizes, width, height, etc.
|
||||
*/
|
||||
async function getImageAttributes(params: {
|
||||
sizes: ImageResponsiveSize[];
|
||||
source: ImageSourceSized;
|
||||
quality: number;
|
||||
resize: boolean;
|
||||
}): Promise<{
|
||||
async function getImageAttributes(
|
||||
ctx: GitBookContext,
|
||||
params: {
|
||||
sizes: ImageResponsiveSize[];
|
||||
source: ImageSourceSized;
|
||||
quality: number;
|
||||
resize: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
src: string;
|
||||
srcSet?: string;
|
||||
sizes?: string;
|
||||
@@ -258,7 +265,7 @@ async function getImageAttributes(params: {
|
||||
const { sizes, source, quality, resize } = params;
|
||||
let src = source.src;
|
||||
|
||||
const getURL = resize ? await getResizedImageURLFactory(source.src) : null;
|
||||
const getURL = resize ? getResizedImageURLFactory(ctx, source.src) : null;
|
||||
|
||||
if (!getURL) {
|
||||
return {
|
||||
|
||||
@@ -269,17 +269,28 @@ function ZoomImageModal(props: {
|
||||
}
|
||||
|
||||
function startViewTransition(callback: () => void, onEnd?: () => void) {
|
||||
// @ts-ignore
|
||||
if (document.startViewTransition) {
|
||||
// @ts-ignore
|
||||
const transition = document.startViewTransition(() => {
|
||||
ReactDOM.flushSync(() => callback());
|
||||
});
|
||||
transition.finished.then(() => {
|
||||
if (onEnd) {
|
||||
onEnd();
|
||||
try {
|
||||
const transition = document.startViewTransition(() => {
|
||||
ReactDOM.flushSync(() => callback());
|
||||
});
|
||||
transition.finished.then(() => {
|
||||
if (onEnd) {
|
||||
onEnd();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Safari can throw an error if another transition is already in progress
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' || error.name === 'InvalidStateError')
|
||||
) {
|
||||
callback();
|
||||
onEnd?.();
|
||||
return;
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
onEnd?.();
|
||||
|
||||
@@ -203,3 +203,21 @@ export const fonts: { [fontName in CustomizationFont]: { className: string } } =
|
||||
[CustomizationFont.Ubuntu]: ubuntu,
|
||||
[CustomizationFont.ABCFavorit]: abcFavorit,
|
||||
};
|
||||
|
||||
export const googleFontsMap: { [fontName in CustomizationFont]: string } = {
|
||||
[CustomizationFont.Inter]: 'Inter',
|
||||
[CustomizationFont.FiraSans]: 'Fira Sans Extra Condensed',
|
||||
[CustomizationFont.IBMPlexSerif]: 'IBM Plex Serif',
|
||||
[CustomizationFont.Lato]: 'Lato',
|
||||
[CustomizationFont.Merriweather]: 'Merriweather',
|
||||
[CustomizationFont.NotoSans]: 'Noto Sans',
|
||||
[CustomizationFont.OpenSans]: 'Open Sans',
|
||||
[CustomizationFont.Overpass]: 'Overpass',
|
||||
[CustomizationFont.Poppins]: 'Poppins',
|
||||
[CustomizationFont.Raleway]: 'Raleway',
|
||||
[CustomizationFont.Roboto]: 'Roboto',
|
||||
[CustomizationFont.RobotoSlab]: 'Roboto Slab',
|
||||
[CustomizationFont.SourceSansPro]: 'Source Sans 3',
|
||||
[CustomizationFont.Ubuntu]: 'Ubuntu',
|
||||
[CustomizationFont.ABCFavorit]: 'Inter',
|
||||
};
|
||||
|
||||
+168
-141
@@ -32,6 +32,7 @@ import {
|
||||
noCacheFetchOptions,
|
||||
parseCacheResponse,
|
||||
} from './cache';
|
||||
import { GitBookContext } from './gitbook-context';
|
||||
import { defaultCustomizationForSpace } from './utils';
|
||||
|
||||
/**
|
||||
@@ -110,16 +111,14 @@ export const DEFAULT_API_ENDPOINT = process.env.GITBOOK_API_URL ?? 'https://api.
|
||||
/**
|
||||
* Create a new API client with a token.
|
||||
*/
|
||||
export async function apiWithToken(
|
||||
export function apiWithToken(
|
||||
apiToken: string,
|
||||
contextId: string | undefined,
|
||||
): Promise<GitBookAPIContext> {
|
||||
const headersList = await headers();
|
||||
const apiEndpoint = headersList.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT;
|
||||
|
||||
ctx: GitBookContext,
|
||||
): GitBookAPIContext {
|
||||
const gitbook = new GitBookAPI({
|
||||
authToken: apiToken,
|
||||
endpoint: apiEndpoint,
|
||||
endpoint: ctx.apiEndpoint,
|
||||
userAgent: userAgent(),
|
||||
});
|
||||
|
||||
@@ -129,23 +128,19 @@ export async function apiWithToken(
|
||||
/**
|
||||
* Create an API client for the current request.
|
||||
*/
|
||||
export async function api(): Promise<GitBookAPIContext> {
|
||||
export function api(ctx: GitBookContext): GitBookAPIContext {
|
||||
const existing = apiSyncStorage.getStore();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const headersList = await headers();
|
||||
const apiToken = headersList.get('x-gitbook-token');
|
||||
const contextId = headersList.get('x-gitbook-token-context') ?? undefined;
|
||||
|
||||
if (!apiToken) {
|
||||
if (!ctx.apiToken) {
|
||||
throw new Error(
|
||||
'Missing GitBook API token, please check that the request is correctly processed by the middleware',
|
||||
);
|
||||
}
|
||||
|
||||
return apiWithToken(apiToken, contextId);
|
||||
return apiWithToken(ctx.apiToken, ctx.apiTokenContextId ?? undefined, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,15 +172,14 @@ export type PublishedContentWithCache =
|
||||
*/
|
||||
export const getUserById = cache({
|
||||
name: 'api.getUserById',
|
||||
tag: (userId) =>
|
||||
tag: (_ctx, userId) =>
|
||||
getAPICacheTag({
|
||||
tag: 'user',
|
||||
user: userId,
|
||||
}),
|
||||
get: async (userId: string, options: CacheFunctionOptions) => {
|
||||
get: async (ctx: GitBookContext, userId: string, options: CacheFunctionOptions) => {
|
||||
try {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.users.getUserById(userId, {
|
||||
const response = await api(ctx).client.users.getUserById(userId, {
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
});
|
||||
@@ -210,12 +204,13 @@ export const getUserById = cache({
|
||||
*/
|
||||
export const getPublishedContentByUrl = cache({
|
||||
name: 'api.getPublishedContentByUrl.v4',
|
||||
tag: (url) =>
|
||||
tag: (_ctx, url) =>
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: new URL(url).hostname,
|
||||
}),
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
url: string,
|
||||
visitorAuthToken: string | undefined,
|
||||
// Prefer undefined for a better cache key.
|
||||
@@ -223,8 +218,7 @@ export const getPublishedContentByUrl = cache({
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.urls.getPublishedContentByUrl(
|
||||
const response = await api(ctx).client.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url,
|
||||
visitorAuthToken,
|
||||
@@ -272,10 +266,14 @@ export const getPublishedContentByUrl = cache({
|
||||
*/
|
||||
export const getSpace = cache({
|
||||
name: 'api.getSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getSpaceById(
|
||||
tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
shareKey: string | undefined,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api(ctx).client.spaces.getSpaceById(
|
||||
spaceId,
|
||||
{
|
||||
shareKey,
|
||||
@@ -296,14 +294,22 @@ export const getSpace = cache({
|
||||
*/
|
||||
export const getChangeRequest = cache({
|
||||
name: 'api.getChangeRequest',
|
||||
tag: (spaceId, changeRequestId) =>
|
||||
tag: (_ctx, spaceId, changeRequestId) =>
|
||||
getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }),
|
||||
get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getChangeRequestById(spaceId, changeRequestId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
changeRequestId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api(ctx).client.spaces.getChangeRequestById(
|
||||
spaceId,
|
||||
changeRequestId,
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
ttl: 60 * 60,
|
||||
revalidateBefore: 10 * 60,
|
||||
@@ -321,27 +327,22 @@ interface GetRevisionOptions {
|
||||
metadata: boolean;
|
||||
}
|
||||
|
||||
const getAPIContextId = async () => {
|
||||
const apiCtx = await api();
|
||||
return apiCtx.contextId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a revision by its ID.
|
||||
*/
|
||||
export const getRevision = cache({
|
||||
name: 'api.getRevision.v2',
|
||||
tag: (spaceId, revisionId) =>
|
||||
tag: (_ctx, spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fetchOptions: GetRevisionOptions,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getRevisionById(
|
||||
const response = await api(ctx).client.spaces.getRevisionById(
|
||||
spaceId,
|
||||
revisionId,
|
||||
{
|
||||
@@ -363,17 +364,17 @@ export const getRevision = cache({
|
||||
*/
|
||||
export const getRevisionPages = cache({
|
||||
name: 'api.getRevisionPages.v4',
|
||||
tag: (spaceId, revisionId) =>
|
||||
tag: (_ctx, spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fetchOptions: GetRevisionOptions,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.listPagesInRevisionById(
|
||||
const response = await api(ctx).client.spaces.listPagesInRevisionById(
|
||||
spaceId,
|
||||
revisionId,
|
||||
{
|
||||
@@ -398,10 +399,11 @@ export const getRevisionPages = cache({
|
||||
*/
|
||||
export const getRevisionPageByPath = cache({
|
||||
name: 'api.getRevisionPageByPath.v3',
|
||||
tag: (spaceId, revisionId) =>
|
||||
tag: (_ctx, spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
pagePath: string,
|
||||
@@ -410,8 +412,7 @@ export const getRevisionPageByPath = cache({
|
||||
const encodedPath = encodeURIComponent(pagePath);
|
||||
|
||||
try {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getPageInRevisionByPath(
|
||||
const response = await api(ctx).client.spaces.getPageInRevisionByPath(
|
||||
spaceId,
|
||||
revisionId,
|
||||
encodedPath,
|
||||
@@ -444,17 +445,17 @@ export const getRevisionPageByPath = cache({
|
||||
*/
|
||||
const getRevisionFileById = cache({
|
||||
name: 'api.getRevisionFile.v3',
|
||||
tag: (spaceId, revisionId) =>
|
||||
tag: (_ctx, spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fileId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getFileInRevisionById(
|
||||
const response = await api(ctx).client.spaces.getFileInRevisionById(
|
||||
spaceId,
|
||||
revisionId,
|
||||
fileId,
|
||||
@@ -480,18 +481,18 @@ const getRevisionFileById = cache({
|
||||
|
||||
const getRevisionReusableContentById = cache({
|
||||
name: 'api.getRevisionReusableContentById.v1',
|
||||
tag: (spaceId, revisionId) =>
|
||||
tag: (_ctx, spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
reusableContentId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getReusableContentInRevisionById(
|
||||
const response = await api(ctx).client.spaces.getReusableContentInRevisionById(
|
||||
spaceId,
|
||||
revisionId,
|
||||
reusableContentId,
|
||||
@@ -521,13 +522,17 @@ const getRevisionReusableContentById = cache({
|
||||
*/
|
||||
const getRevisionAllFiles = cache({
|
||||
name: 'api.getRevisionAllFiles.v2',
|
||||
tag: (spaceId, revisionId) =>
|
||||
tag: (_ctx, spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await getAll(
|
||||
async (params) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.listFilesInRevisionById(
|
||||
const response = await api(ctx).client.spaces.listFilesInRevisionById(
|
||||
spaceId,
|
||||
revisionId,
|
||||
{
|
||||
@@ -561,35 +566,39 @@ const getRevisionAllFiles = cache({
|
||||
* The approach is optimized to use the entire list of files in the revision if it has been fetched
|
||||
* or to use a per-file approach if not.
|
||||
*/
|
||||
export const getRevisionFile = batch<[string, string, string], RevisionFile | null>(
|
||||
export const getRevisionFile = batch<[GitBookContext, string, string, string], RevisionFile | null>(
|
||||
async (executions) => {
|
||||
const [spaceId, revisionId] = executions[0];
|
||||
const [ctx, spaceId, revisionId] = executions[0];
|
||||
|
||||
const hasRevisionInMemory = await getRevision.hasInMemory(spaceId, revisionId, {
|
||||
const hasRevisionInMemory = await getRevision.hasInMemory(ctx, spaceId, revisionId, {
|
||||
metadata: false,
|
||||
});
|
||||
const hasRevisionFilesInMemory = await getRevisionAllFiles.hasInMemory(spaceId, revisionId);
|
||||
const hasRevisionFilesInMemory = await getRevisionAllFiles.hasInMemory(
|
||||
ctx,
|
||||
spaceId,
|
||||
revisionId,
|
||||
);
|
||||
|
||||
// When fetching more than 5 files, we should bundle them all into one call to get the entire revision
|
||||
if (executions.length > 5 || hasRevisionFilesInMemory || hasRevisionInMemory) {
|
||||
let files: Record<string, RevisionFile> = {};
|
||||
|
||||
if (hasRevisionInMemory) {
|
||||
const revision = await getRevision(spaceId, revisionId, { metadata: false });
|
||||
const revision = await getRevision(ctx, spaceId, revisionId, { metadata: false });
|
||||
files = {};
|
||||
revision.files.forEach((file) => {
|
||||
files[file.id] = file;
|
||||
});
|
||||
} else {
|
||||
files = await getRevisionAllFiles(spaceId, revisionId);
|
||||
files = await getRevisionAllFiles(ctx, spaceId, revisionId);
|
||||
}
|
||||
|
||||
return executions.map(([spaceId, revisionId, fileId]) => files[fileId] ?? null);
|
||||
return executions.map(([ctx, spaceId, revisionId, fileId]) => files[fileId] ?? null);
|
||||
} else {
|
||||
// Fetch file individually
|
||||
return Promise.all(
|
||||
executions.map(([spaceId, revisionId, fileId]) =>
|
||||
getRevisionFileById(spaceId, revisionId, fileId),
|
||||
executions.map(([ctx, spaceId, revisionId, fileId]) =>
|
||||
getRevisionFileById(ctx, spaceId, revisionId, fileId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -597,13 +606,13 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu
|
||||
{
|
||||
delay: 20,
|
||||
groupBy: (spaceId, revisionId) => spaceId + '/' + revisionId,
|
||||
skip: async (spaceId, revisionId, fileId) => {
|
||||
skip: async (ctx, spaceId, revisionId, fileId) => {
|
||||
return (
|
||||
(await getRevision.hasInMemory(spaceId, revisionId, {
|
||||
(await getRevision.hasInMemory(ctx, spaceId, revisionId, {
|
||||
metadata: false,
|
||||
})) ||
|
||||
(await getRevisionAllFiles.hasInMemory(spaceId, revisionId)) ||
|
||||
(await getRevisionFileById.hasInMemory(spaceId, revisionId, fileId))
|
||||
(await getRevisionAllFiles.hasInMemory(ctx, spaceId, revisionId)) ||
|
||||
(await getRevisionFileById.hasInMemory(ctx, spaceId, revisionId, fileId))
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -613,23 +622,24 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu
|
||||
* Get reusable content in a revision.
|
||||
*/
|
||||
export const getReusableContent = async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
reusableContentId: string,
|
||||
): Promise<RevisionReusableContent | null> => {
|
||||
const hasRevisionInMemory = await getRevision.hasInMemory(spaceId, revisionId, {
|
||||
const hasRevisionInMemory = await getRevision.hasInMemory(ctx, spaceId, revisionId, {
|
||||
metadata: false,
|
||||
});
|
||||
|
||||
if (hasRevisionInMemory) {
|
||||
const revision = await getRevision(spaceId, revisionId, { metadata: false });
|
||||
const revision = await getRevision(ctx, spaceId, revisionId, { metadata: false });
|
||||
return (
|
||||
revision.reusableContents.find(
|
||||
(reusableContent) => reusableContent.id === reusableContentId,
|
||||
) ?? null
|
||||
);
|
||||
} else {
|
||||
return getRevisionReusableContentById(spaceId, revisionId, reusableContentId);
|
||||
return getRevisionReusableContentById(ctx, spaceId, revisionId, reusableContentId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -638,12 +648,16 @@ export const getReusableContent = async (
|
||||
*/
|
||||
export const getDocument = cache({
|
||||
name: 'api.getDocument.v2',
|
||||
tag: (spaceId, documentId) =>
|
||||
tag: (_ctx, spaceId, documentId) =>
|
||||
getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getDocumentById(
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
documentId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api(ctx).client.spaces.getDocumentById(
|
||||
spaceId,
|
||||
documentId,
|
||||
{
|
||||
@@ -674,9 +688,10 @@ function validateSiteRedirectSource(source: string) {
|
||||
*/
|
||||
export const getSiteRedirectBySource = cache({
|
||||
name: 'api.getSiteRedirectBySource',
|
||||
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
tag: (_ctx, { siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
@@ -694,8 +709,7 @@ export const getSiteRedirectBySource = cache({
|
||||
};
|
||||
}
|
||||
try {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.orgs.getSiteRedirectBySource(
|
||||
const response = await api(ctx).client.orgs.getSiteRedirectBySource(
|
||||
args.organizationId,
|
||||
args.siteId,
|
||||
{
|
||||
@@ -735,11 +749,15 @@ export const getSiteRedirectBySource = cache({
|
||||
*/
|
||||
export const getSite = cache({
|
||||
name: 'api.getSite',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.orgs.getSiteById(organizationId, siteId, {
|
||||
tag: (_ctx, _organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api(ctx).client.orgs.getSiteById(organizationId, siteId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
@@ -754,9 +772,10 @@ export const getSite = cache({
|
||||
*/
|
||||
export const getPublishedContentSite = cache({
|
||||
name: 'api.getPublishedContentSite',
|
||||
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
tag: (_ctx, { siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
args: {
|
||||
organizationId: string;
|
||||
siteId: string /** Site share key that can be used as context to resolve site space published urls */;
|
||||
@@ -764,8 +783,7 @@ export const getPublishedContentSite = cache({
|
||||
},
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.orgs.getPublishedContentSite(
|
||||
const response = await api(ctx).client.orgs.getPublishedContentSite(
|
||||
args.organizationId,
|
||||
args.siteId,
|
||||
{
|
||||
@@ -813,6 +831,7 @@ function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
|
||||
* experience for the site (structure, customizations, scripts etc)
|
||||
*/
|
||||
export async function getSiteData(
|
||||
ctx: GitBookContext,
|
||||
pointer: Pick<
|
||||
SiteContentPointer,
|
||||
'organizationId' | 'siteId' | 'siteSectionId' | 'siteSpaceId' | 'siteShareKey'
|
||||
@@ -823,7 +842,7 @@ export async function getSiteData(
|
||||
structure: siteStructure,
|
||||
customizations,
|
||||
scripts,
|
||||
} = await getPublishedContentSite({
|
||||
} = await getPublishedContentSite(ctx, {
|
||||
organizationId: pointer.organizationId,
|
||||
siteId: pointer.siteId,
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
@@ -850,7 +869,8 @@ export async function getSiteData(
|
||||
const spaces =
|
||||
siteSpaces ?? (sections ? parseSpacesFromSiteSpaces(sections.section.siteSpaces) : []);
|
||||
|
||||
const customization = await getActiveCustomizationSettings(
|
||||
const customization = getActiveCustomizationSettings(
|
||||
ctx,
|
||||
pointer.siteSpaceId ? customizations.siteSpaces[pointer.siteSpaceId] : customizations.site,
|
||||
);
|
||||
|
||||
@@ -867,13 +887,12 @@ export async function getSiteData(
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
*/
|
||||
export async function getSpaceCustomization(): Promise<{
|
||||
export function getSpaceCustomization(ctx: GitBookContext): {
|
||||
customization: CustomizationSettings;
|
||||
}> {
|
||||
const headersList = await headers();
|
||||
} {
|
||||
const raw = defaultCustomizationForSpace();
|
||||
|
||||
const extend = headersList.get('x-gitbook-customization');
|
||||
const extend = ctx.customization;
|
||||
if (extend) {
|
||||
try {
|
||||
const parsed = rison.decode_object<Partial<CustomizationSettings>>(extend);
|
||||
@@ -897,10 +916,9 @@ export async function getSpaceCustomization(): Promise<{
|
||||
*/
|
||||
export const getCollection = cache({
|
||||
name: 'api.getCollection',
|
||||
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.collections.getCollectionById(collectionId, {
|
||||
tag: (_ctx, collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (ctx: GitBookContext, collectionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api(ctx).client.collections.getCollectionById(collectionId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
@@ -915,11 +933,10 @@ export const getCollection = cache({
|
||||
*/
|
||||
export const getCollectionSpaces = cache({
|
||||
name: 'api.getCollectionSpaces',
|
||||
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
tag: (_ctx, collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (ctx: GitBookContext, collectionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await getAll(async (params) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.collections.listSpacesInCollectionById(
|
||||
const response = await api(ctx).client.collections.listSpacesInCollectionById(
|
||||
collectionId,
|
||||
params,
|
||||
{
|
||||
@@ -945,19 +962,22 @@ export const getCollectionSpaces = cache({
|
||||
* instead of calling the individual functions.
|
||||
*/
|
||||
export async function getSpaceContentData(
|
||||
ctx: GitBookContext,
|
||||
pointer: SpaceContentPointer,
|
||||
shareKey: string | undefined,
|
||||
) {
|
||||
const [space, changeRequest] = await Promise.all([
|
||||
getSpace(pointer.spaceId, shareKey),
|
||||
pointer.changeRequestId ? getChangeRequest(pointer.spaceId, pointer.changeRequestId) : null,
|
||||
getSpace(ctx, pointer.spaceId, shareKey),
|
||||
pointer.changeRequestId
|
||||
? getChangeRequest(ctx, pointer.spaceId, pointer.changeRequestId)
|
||||
: null,
|
||||
]);
|
||||
|
||||
const contentTarget: ContentTarget = {
|
||||
spaceId: pointer.spaceId,
|
||||
revisionId: changeRequest?.revision ?? pointer.revisionId ?? space.revision,
|
||||
};
|
||||
const pages = await getRevisionPages(space.id, contentTarget.revisionId, {
|
||||
const pages = await getRevisionPages(ctx, space.id, contentTarget.revisionId, {
|
||||
// We only care about the Git metadata when the Git sync is enabled
|
||||
// otherwise we can optimize performance by not fetching it
|
||||
metadata: !!space.gitSync,
|
||||
@@ -975,17 +995,17 @@ export async function getSpaceContentData(
|
||||
*/
|
||||
export const searchSpaceContent = cache({
|
||||
name: 'api.searchSpaceContent',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
/** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */
|
||||
revisionId: string,
|
||||
query: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.searchSpaceContent(
|
||||
const response = await api(ctx).client.spaces.searchSpaceContent(
|
||||
spaceId,
|
||||
{ query },
|
||||
{
|
||||
@@ -1002,11 +1022,15 @@ export const searchSpaceContent = cache({
|
||||
*/
|
||||
export const searchParentContent = cache({
|
||||
name: 'api.searchParentContent',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
get: async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.search.searchContent(
|
||||
tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
parentId: string,
|
||||
query: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api(ctx).client.search.searchContent(
|
||||
{ query },
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
@@ -1024,9 +1048,10 @@ export const searchParentContent = cache({
|
||||
*/
|
||||
export const searchSiteContent = cache({
|
||||
name: 'api.searchSiteContent',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: getAPIContextId,
|
||||
tag: (_ctx, _organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
getKeySuffix: (ctx) => api(ctx).contextId,
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
query: string,
|
||||
@@ -1038,8 +1063,7 @@ export const searchSiteContent = cache({
|
||||
cacheBust?: string,
|
||||
options?: CacheFunctionOptions,
|
||||
) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.orgs.searchSiteContent(
|
||||
const response = await api(ctx).client.orgs.searchSiteContent(
|
||||
organizationId,
|
||||
siteId,
|
||||
{
|
||||
@@ -1064,10 +1088,9 @@ export const searchSiteContent = cache({
|
||||
*/
|
||||
export const getRecommendedQuestionsInSpace = cache({
|
||||
name: 'api.getRecommendedQuestionsInSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getRecommendedQuestionsInSpace(spaceId, {
|
||||
tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (ctx: GitBookContext, spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api(ctx).client.spaces.getRecommendedQuestionsInSpace(spaceId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
@@ -1080,14 +1103,15 @@ export const getRecommendedQuestionsInSpace = cache({
|
||||
*/
|
||||
export const renderIntegrationUi = cache({
|
||||
name: 'api.renderIntegrationUi',
|
||||
tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }),
|
||||
tag: (_ctx, integrationName) =>
|
||||
getAPICacheTag({ tag: 'integration', integration: integrationName }),
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
integrationName: string,
|
||||
request: RequestRenderIntegrationUI,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.integrations.renderIntegrationUiWithPost(
|
||||
const response = await api(ctx).client.integrations.renderIntegrationUiWithPost(
|
||||
integrationName,
|
||||
request,
|
||||
{
|
||||
@@ -1105,9 +1129,8 @@ export const renderIntegrationUi = cache({
|
||||
*/
|
||||
export const getEmbedByUrl = cache({
|
||||
name: 'api.getEmbedByUrl',
|
||||
get: async (url: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.urls.getEmbedByUrl(
|
||||
get: async (ctx: GitBookContext, url: string, options: CacheFunctionOptions) => {
|
||||
const response = await api(ctx).client.urls.getEmbedByUrl(
|
||||
{ url },
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
@@ -1123,10 +1146,14 @@ export const getEmbedByUrl = cache({
|
||||
*/
|
||||
export const getEmbedByUrlInSpace = cache({
|
||||
name: 'api.getEmbedByUrlInSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, url: string, options: CacheFunctionOptions) => {
|
||||
const apiCtx = await api();
|
||||
const response = await apiCtx.client.spaces.getEmbedByUrlInSpace(
|
||||
tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (
|
||||
ctx: GitBookContext,
|
||||
spaceId: string,
|
||||
url: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api(ctx).client.spaces.getEmbedByUrlInSpace(
|
||||
spaceId,
|
||||
{ url },
|
||||
{
|
||||
@@ -1297,11 +1324,11 @@ async function getAll<T, E>(
|
||||
* Selects the customization settings from the x-gitbook-customization header if present,
|
||||
* otherwise returns the original API-provided settings.
|
||||
*/
|
||||
async function getActiveCustomizationSettings(
|
||||
function getActiveCustomizationSettings(
|
||||
ctx: GitBookContext,
|
||||
settings: SiteCustomizationSettings,
|
||||
): Promise<SiteCustomizationSettings> {
|
||||
const headersList = await headers();
|
||||
const extend = headersList.get('x-gitbook-customization');
|
||||
): SiteCustomizationSettings {
|
||||
const extend = ctx.customization;
|
||||
if (extend) {
|
||||
try {
|
||||
const parsedSettings = rison.decode_object<SiteCustomizationSettings>(extend);
|
||||
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
import { cloudflareCache } from './cloudflare-cache';
|
||||
import { cloudflareDOCache } from './cloudflare-do';
|
||||
import { cloudflareKVCache } from './cloudflare-kv';
|
||||
import { memoryCache } from './memory';
|
||||
|
||||
export const cacheBackends = [
|
||||
@@ -9,6 +10,8 @@ export const cacheBackends = [
|
||||
// Cache local to the datacenter
|
||||
// It can't be purged globally but it's faster
|
||||
cloudflareCache,
|
||||
// Cache global, but with slow replication
|
||||
cloudflareKVCache,
|
||||
// Global cache with slower performances
|
||||
cloudflareDOCache,
|
||||
];
|
||||
|
||||
+6
-6
@@ -74,7 +74,7 @@ describe('cache', () => {
|
||||
describe('cache with suffix key', () => {
|
||||
const impl = mock((arg: string) => 'test-' + arg);
|
||||
const getKeySuffixImpl: Mock<NonNullable<CacheDefinition<[string], string>['getKeySuffix']>> =
|
||||
mock(async () => hash({ test: 1 }));
|
||||
mock(() => hash({ test: 1 }));
|
||||
|
||||
let fn: CacheFunction<[string], string>;
|
||||
let testId = 0;
|
||||
@@ -112,7 +112,7 @@ describe('cache with suffix key', () => {
|
||||
|
||||
expect(impl).toHaveBeenCalledTimes(2);
|
||||
|
||||
getKeySuffixImpl.mockImplementation(async () => hash({ test: 2 }));
|
||||
getKeySuffixImpl.mockImplementation(() => hash({ test: 2 }));
|
||||
expect(await fn('a')).toEqual('test-a');
|
||||
|
||||
expect(impl).toHaveBeenCalledTimes(3);
|
||||
@@ -120,7 +120,7 @@ describe('cache with suffix key', () => {
|
||||
|
||||
it('should preserve behaviour even when the returned key suffix is undefined', async () => {
|
||||
// Start with the returned suffix being undefined
|
||||
getKeySuffixImpl.mockImplementation(async () => undefined);
|
||||
getKeySuffixImpl.mockImplementation(() => undefined);
|
||||
const result = await fn('a');
|
||||
expect(result).toEqual('test-a');
|
||||
|
||||
@@ -128,19 +128,19 @@ describe('cache with suffix key', () => {
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The returned suffix changes so we should get the value computed by the function
|
||||
getKeySuffixImpl.mockImplementation(async () => hash({ test: 1 }));
|
||||
getKeySuffixImpl.mockImplementation(() => hash({ test: 1 }));
|
||||
expect(await fn('a')).toEqual('test-a');
|
||||
|
||||
expect(impl).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The returned suffix is undefined again so we should get the value from a previous cache entry
|
||||
getKeySuffixImpl.mockImplementation(async () => undefined);
|
||||
getKeySuffixImpl.mockImplementation(() => undefined);
|
||||
expect(await fn('a')).toEqual('test-a');
|
||||
|
||||
expect(impl).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The returned suffix goes back to a previous hash so we should the value from a previous cache entry
|
||||
getKeySuffixImpl.mockImplementation(async () => hash({ test: 1 }));
|
||||
getKeySuffixImpl.mockImplementation(() => hash({ test: 1 }));
|
||||
expect(await fn('a')).toEqual('test-a');
|
||||
|
||||
expect(impl).toHaveBeenCalledTimes(2);
|
||||
|
||||
+37
-16
@@ -2,7 +2,8 @@ import hash from 'object-hash';
|
||||
|
||||
import { cacheBackends } from './backends';
|
||||
import { memoryCache } from './memory';
|
||||
import { CacheEntry } from './types';
|
||||
import { CacheBackend, CacheEntry } from './types';
|
||||
import { captureException } from '../../sentry';
|
||||
import { race, singletonMap } from '../async';
|
||||
import { TraceSpan, trace } from '../tracing';
|
||||
import { waitUntil } from '../waitUntil';
|
||||
@@ -56,7 +57,7 @@ export interface CacheDefinition<Args extends any[], Result> {
|
||||
getKeyArgs?: (args: Args) => any[];
|
||||
|
||||
/** Returns a precomputed hash that is used alongside arguments to generate the cache key */
|
||||
getKeySuffix?: () => Promise<string | undefined>;
|
||||
getKeySuffix?: (...args: Args) => string | undefined;
|
||||
|
||||
/** Default ttl (in seconds) */
|
||||
defaultTtl?: number;
|
||||
@@ -113,7 +114,7 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
// Write it to the cache
|
||||
if (result.ttl && result.ttl > 0) {
|
||||
await waitUntil(setCacheEntry(cacheEntry));
|
||||
await waitUntil(setCacheEntry(cacheEntry, cacheBackends));
|
||||
}
|
||||
|
||||
return cacheEntry;
|
||||
@@ -141,7 +142,27 @@ export function cache<Args extends any[], Result>(
|
||||
cacheBackends,
|
||||
async (backend, { signal }) => {
|
||||
const entry = await backend.get({ key, tag }, { signal });
|
||||
return entry ? ([entry, backend.name] as const) : null;
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detect empty cache entries to avoid returning them.
|
||||
// Also log in Sentry to investigate what cache is returning empty entries.
|
||||
if (
|
||||
entry.data &&
|
||||
typeof entry.data === 'object' &&
|
||||
Object.keys(entry.data).length === 0
|
||||
) {
|
||||
captureException(
|
||||
new Error(
|
||||
`Cache entry ${key} from ${backendName} is an empty object`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [entry, backend.name] as const;
|
||||
},
|
||||
{
|
||||
signal,
|
||||
@@ -186,13 +207,12 @@ export function cache<Args extends any[], Result>(
|
||||
// done in the revalidate function above.
|
||||
if (fromBackend?.replication === 'global') {
|
||||
await waitUntil(
|
||||
Promise.all(
|
||||
cacheBackends
|
||||
.filter(
|
||||
(backend) =>
|
||||
backend.name !== backendName && backend.replication === 'local',
|
||||
)
|
||||
.map((backend) => backend.set(savedEntry)),
|
||||
setCacheEntry(
|
||||
savedEntry,
|
||||
cacheBackends.filter(
|
||||
(backend) =>
|
||||
backend.name !== backendName && backend.replication === 'local',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -225,7 +245,7 @@ export function cache<Args extends any[], Result>(
|
||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const cacheKeySuffix = cacheDef.getKeySuffix ? await cacheDef.getKeySuffix() : undefined;
|
||||
const cacheKeySuffix = cacheDef.getKeySuffix ? cacheDef.getKeySuffix(...args) : undefined;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs, cacheKeySuffix);
|
||||
|
||||
return await trace(
|
||||
@@ -243,7 +263,7 @@ export function cache<Args extends any[], Result>(
|
||||
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const cacheKeySuffix = cacheDef.getKeySuffix ? await cacheDef.getKeySuffix() : undefined;
|
||||
const cacheKeySuffix = cacheDef.getKeySuffix ? cacheDef.getKeySuffix(...args) : undefined;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs, cacheKeySuffix);
|
||||
|
||||
const result = await revalidate(key, signal, ...args);
|
||||
@@ -252,7 +272,7 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
cacheFn.hasInMemory = async (...args: Args) => {
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const cacheKeySuffix = cacheDef.getKeySuffix ? await cacheDef.getKeySuffix() : undefined;
|
||||
const cacheKeySuffix = cacheDef.getKeySuffix ? cacheDef.getKeySuffix(...args) : undefined;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs, cacheKeySuffix);
|
||||
const tag = cacheDef.tag?.(...args);
|
||||
|
||||
@@ -309,14 +329,15 @@ function hashValue(arg: any): string {
|
||||
return JSON.stringify(arg);
|
||||
}
|
||||
|
||||
async function setCacheEntry(entry: CacheEntry) {
|
||||
async function setCacheEntry(entry: CacheEntry, backend: CacheBackend | CacheBackend[]) {
|
||||
return await trace(
|
||||
{
|
||||
operation: `cache.setCacheEntry`,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
await Promise.all(cacheBackends.map((backend) => backend.set(entry)));
|
||||
const backends = Array.isArray(backend) ? backend : [backend];
|
||||
await Promise.all(backends.map((backend) => backend.set(entry)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,17 @@ import { merge } from 'content-security-policy-merger';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { assetsDomain } from './assets';
|
||||
import { GitBookContext } from './gitbook-context';
|
||||
import { filterOutNullable } from './typescript';
|
||||
/**
|
||||
* Get the current nonce for the current request.
|
||||
*/
|
||||
export async function getContentSecurityPolicyNonce(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const nonce = headersList.get('x-nonce');
|
||||
if (!nonce) {
|
||||
export function getContentSecurityPolicyNonce(ctx: GitBookContext): string {
|
||||
if (!ctx.nonce) {
|
||||
throw new Error('No nonce found in headers');
|
||||
}
|
||||
|
||||
return nonce;
|
||||
return ctx.nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { DEFAULT_API_ENDPOINT } from './api';
|
||||
import { formatBasePath } from './links';
|
||||
|
||||
export type GitBookContext = {
|
||||
theme: string | null;
|
||||
nonce: string | null;
|
||||
visitorToken: string | null;
|
||||
trackPageViews: boolean;
|
||||
apiEndpoint: string;
|
||||
apiToken: string | null;
|
||||
apiTokenContextId: string | null;
|
||||
customization: string | null;
|
||||
host: string;
|
||||
basePath: string;
|
||||
protocol: string;
|
||||
originBasePath: string;
|
||||
|
||||
// Content pointers
|
||||
spaceId: string | null;
|
||||
siteId: string | null;
|
||||
organizationId: string | null;
|
||||
siteSpaceId: string | null;
|
||||
siteSectionId: string | null;
|
||||
siteShareKey: string | null;
|
||||
contentRevisionId: string | null;
|
||||
changeRequestId: string | null;
|
||||
|
||||
// Indexation
|
||||
searchIndexation: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the gitbook context from the headers.
|
||||
*/
|
||||
export function getGitBookContextFromHeaders(headers: Headers): GitBookContext {
|
||||
return {
|
||||
theme: headers.get('x-gitbook-theme'),
|
||||
nonce: headers.get('x-nonce'),
|
||||
visitorToken: headers.get('x-gitbook-visitor-token'),
|
||||
trackPageViews: headers.has('x-gitbook-track-page-views'),
|
||||
apiEndpoint: headers.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT,
|
||||
apiToken: headers.get('x-gitbook-token'),
|
||||
apiTokenContextId: headers.get('x-gitbook-token-context'),
|
||||
customization: headers.get('x-gitbook-customization'),
|
||||
basePath: formatBasePath(headers.get('x-gitbook-basepath')),
|
||||
host: headers.get('x-gitbook-host') ?? headers.get('host') ?? '',
|
||||
protocol: headers.get('x-forwarded-proto') ?? 'https',
|
||||
originBasePath: headers.get('x-gitbook-origin-basepath') ?? '/',
|
||||
spaceId: headers.get('x-gitbook-content-space'),
|
||||
siteId: headers.get('x-gitbook-content-site'),
|
||||
organizationId: headers.get('x-gitbook-content-organization'),
|
||||
siteSpaceId: headers.get('x-gitbook-content-site-space'),
|
||||
siteSectionId: headers.get('x-gitbook-content-site-section'),
|
||||
siteShareKey: headers.get('x-gitbook-content-site-share-key'),
|
||||
contentRevisionId: headers.get('x-gitbook-content-revision'),
|
||||
changeRequestId: headers.get('x-gitbook-content-changerequest'),
|
||||
searchIndexation: headers.has('x-gitbook-search-indexation'),
|
||||
};
|
||||
}
|
||||
|
||||
export type IpAndUserAgent = {
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the IP and User-Agent from the headers.
|
||||
* This function can only be called at the top level of a component or route.
|
||||
*/
|
||||
export function getIpAndUserAgentFromHeaders(headers: Headers): IpAndUserAgent {
|
||||
const ip =
|
||||
headers.get('x-gitbook-ipv4') ??
|
||||
headers.get('x-gitbook-ip') ??
|
||||
headers.get('cf-pseudo-ipv4') ??
|
||||
headers.get('cf-connecting-ip') ??
|
||||
headers.get('x-forwarded-for') ??
|
||||
'';
|
||||
const userAgent = headers.get('user-agent') ?? '';
|
||||
|
||||
return { ip, userAgent };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'server-only';
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
import type { MaybePromise } from 'p-map';
|
||||
|
||||
import { getHost } from './links';
|
||||
import { GitBookContext } from './gitbook-context';
|
||||
|
||||
/**
|
||||
* GitBook has supported different version of image signing in the past. To maintain backwards
|
||||
@@ -19,12 +19,14 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
|
||||
/**
|
||||
* A mapping of signature versions to signature functions.
|
||||
*/
|
||||
const IMAGE_SIGNATURE_FUNCTIONS: Record<SignatureVersion, (input: string) => MaybePromise<string>> =
|
||||
{
|
||||
'0': generateSignatureV0,
|
||||
'1': generateSignatureV1,
|
||||
'2': generateSignatureV2,
|
||||
};
|
||||
const IMAGE_SIGNATURE_FUNCTIONS: Record<
|
||||
SignatureVersion,
|
||||
(ctx: GitBookContext, input: string) => MaybePromise<string>
|
||||
> = {
|
||||
'0': generateSignatureV0,
|
||||
'1': generateSignatureV1,
|
||||
'2': generateSignatureV2,
|
||||
};
|
||||
|
||||
export function isSignatureVersion(input: string): input is SignatureVersion {
|
||||
return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input);
|
||||
@@ -34,11 +36,12 @@ export function isSignatureVersion(input: string): input is SignatureVersion {
|
||||
* Verify a signature of an image URL
|
||||
*/
|
||||
export async function verifyImageSignature(
|
||||
ctx: GitBookContext,
|
||||
input: string,
|
||||
{ signature, version }: { signature: string; version: SignatureVersion },
|
||||
): Promise<boolean> {
|
||||
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
||||
const generated = await generator(input);
|
||||
const generated = await generator(ctx, input);
|
||||
return generated === signature;
|
||||
}
|
||||
|
||||
@@ -48,11 +51,14 @@ export async function verifyImageSignature(
|
||||
* This function is sync. If you need to implement an async version of image signing, you'll need to change
|
||||
* ths signature of this fn and where it's used.
|
||||
*/
|
||||
export async function generateImageSignature(input: string): Promise<{
|
||||
export function generateImageSignature(
|
||||
ctx: GitBookContext,
|
||||
input: string,
|
||||
): {
|
||||
signature: string;
|
||||
version: SignatureVersion;
|
||||
}> {
|
||||
const result = await generateSignatureV2(input);
|
||||
} {
|
||||
const result = generateSignatureV2(ctx, input);
|
||||
return { signature: result, version: CURRENT_SIGNATURE_VERSION };
|
||||
}
|
||||
|
||||
@@ -63,8 +69,8 @@ const fnv1aUtf8Buffer = new Uint8Array(512);
|
||||
* Generate a signature for an image.
|
||||
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
|
||||
*/
|
||||
async function generateSignatureV2(input: string): Promise<string> {
|
||||
const hostName = await getHost();
|
||||
function generateSignatureV2(ctx: GitBookContext, input: string): string {
|
||||
const hostName = ctx.host;
|
||||
const all = [
|
||||
input,
|
||||
hostName, // The hostname is used to avoid serving images from other sites on the same domain
|
||||
@@ -83,7 +89,7 @@ const fnv1aUtf8BufferV1 = new Uint8Array(512);
|
||||
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
|
||||
* to know that it was the algorithm that was used.
|
||||
*/
|
||||
function generateSignatureV1(input: string): string {
|
||||
function generateSignatureV1(ctx: GitBookContext, input: string): string {
|
||||
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||
return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16);
|
||||
}
|
||||
@@ -93,7 +99,7 @@ function generateSignatureV1(input: string): string {
|
||||
* We still need it to validate older signatures that were generated without versioning
|
||||
* but still exist in previously generated and cached content.
|
||||
*/
|
||||
async function generateSignatureV0(input: string): Promise<string> {
|
||||
async function generateSignatureV0(ctx: GitBookContext, input: string): Promise<string> {
|
||||
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user