Compare commits

..

17 Commits

Author SHA1 Message Date
Samy Pessé 0f7ac7ca08 Merge branch 'main' into fix-iframely-csp 2024-04-18 08:39:09 -07:00
spastorelli f1440ca7ef Fix preview crashing when token do not include site data (#2292) 2024-04-18 17:36:16 +02:00
Samy Pessé 119c22af11 Pass a nonce to the script for cdn.iframe.ly/embed.js 2024-04-18 08:25:11 -07:00
Taran Vohra 7ef2eeed13 fix site cache tag to exclude org id (#2291) 2024-04-18 20:28:39 +05:30
Taran Vohra 4005895337 Fix previews for sites in multi-id mode (#2289) 2024-04-18 18:19:52 +05:30
Steven H bf450e5cc7 Support GitHub-flavoured Markdown when parsing OpenAPI descriptions. (#2288) 2024-04-17 14:21:07 +01:00
Taran Vohra 4c12722c0a Use appropriate customization settings for sites (#2279) 2024-04-17 17:28:14 +05:30
Taran Vohra ac124a7ea5 Add e2e tests for site variants (#2286) 2024-04-16 20:59:58 +05:30
spastorelli 1de25056fa Fix space dropdown showing for single variant sites and update E2E tests (#2284) 2024-04-16 15:29:15 +02:00
Steven H bf3d2032ab Avoid crashing Cloudflare Worker when syntax highlighting too many lines of code. (#2283) 2024-04-15 11:57:22 +01:00
spastorelli bfb2342ab3 Add support for integration inject script for sites (#2277) 2024-04-11 10:28:24 +02:00
Samy Pessé 76cbc3c10f Print signature and url when having error with image resizing (#2275) 2024-04-09 11:22:36 +02:00
Taran Vohra c80c76d50d Override site's spaces published URLs (#2274) 2024-04-09 09:59:25 +05:30
Johan Preynat 263c89391e Add HSTS and other security headers (#2271) 2024-04-08 19:44:57 +02:00
Samy Pessé ebf742e81a Use a sampleRate of 0.1 for edge/node runtimes (#2273) 2024-04-08 15:05:01 +02:00
Samy Pessé 685317f508 Fix full-width for code-blocks and embeds (#2272) 2024-04-08 14:45:42 +02:00
Taran Vohra 0c224861e3 Sites support (#2262) 2024-04-08 17:06:22 +05:30
51 changed files with 827 additions and 216 deletions
BIN
View File
Binary file not shown.
+73 -1
View File
@@ -40,6 +40,72 @@ async function waitForCookiesDialog(page: Page) {
}
const testCases: TestsCase[] = [
{
name: 'GitBook Site (Single Variant)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Home',
url: '',
run: waitForCookiesDialog,
},
{
name: 'No variants dropdown',
url: '',
run: async (page) => {
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
0,
);
},
},
{
name: 'Search',
url: '?q=',
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
{
name: 'Not found',
url: 'content-not-found',
run: waitForCookiesDialog,
},
],
},
{
name: 'GitBook Site (Multi Variants)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
tests: [
{
name: 'Variants dropdown',
url: '',
run: async (page) => {
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
await spaceDrowpdown.waitFor();
},
},
{
name: 'Default variant',
url: '',
},
{
name: 'RFC variant',
url: 'v/rfcs',
},
],
},
{
name: 'GitBook',
baseUrl: 'https://docs.gitbook.com',
@@ -177,6 +243,11 @@ const testCases: TestsCase[] = [
url: 'blocks/lists',
fullPage: true,
},
{
name: 'Code',
url: 'blocks/code',
fullPage: true,
},
{
name: 'Cards',
url: 'blocks/cards',
@@ -189,6 +260,7 @@ const testCases: TestsCase[] = [
{
name: 'Embeds',
url: 'blocks/embeds',
fullPage: true,
},
{
name: 'Annotations',
@@ -476,7 +548,7 @@ for (const testCase of testCases) {
}
if (testEntry.screenshot !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-13', 'iphone-x', 'ipad-2'],
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
+2 -1
View File
@@ -20,7 +20,7 @@
],
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.39.0",
"@gitbook/api": "^0.43.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
@@ -51,6 +51,7 @@
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
+1 -1
View File
@@ -3,7 +3,7 @@
"exports": "./src/index.ts",
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "^0.36.0",
"@gitbook/api": "^0.41.0",
"assert-never": "^1.2.1"
},
"peerDependencies": {
+2
View File
@@ -6,6 +6,8 @@ if (dsn) {
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
+2
View File
@@ -6,6 +6,8 @@ if (dsn) {
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
+1 -1
View File
@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
// Verify the signature
const verified = await verifyImageSignature(url, signature);
if (!verified) {
return new Response('Invalid signature', { status: 400 });
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
}
// Cloudflare-specific options are in the cf object.
@@ -1,11 +1,14 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getSpaceLayoutData } from '@/lib/api';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { getContentPointer } from '../../fetch';
export default async function NotFound() {
const { customization } = await getSpaceLayoutData(getContentPointer().spaceId);
const pointer = getContentPointer();
const { customization } = await ('siteId' in pointer
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const language = getSpaceLanguage(customization);
@@ -104,13 +104,13 @@ export async function generateViewport({ params }: { params: PagePathParams }):
}
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
const { space, pages, page, customization, collection } = await fetchPageData(params);
const { space, pages, page, customization, parent } = await fetchPageData(params);
if (!page) {
notFound();
}
return {
title: [page.title, customization.title ?? space.title, collection?.title]
title: [page.title, customization.title ?? space.title, parent?.title]
.filter(Boolean)
.join(' | '),
description: page.description ?? '',
+7 -7
View File
@@ -32,8 +32,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
contentTarget,
customization,
pages,
collection,
collectionSpaces,
parent,
spaces,
ancestors,
scripts,
} = await fetchSpaceData();
@@ -56,8 +56,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
<SpaceLayout
space={space}
contentTarget={contentTarget}
collection={collection}
collectionSpaces={collectionSpaces}
parent={parent}
spaces={spaces}
customization={customization}
pages={pages}
ancestors={ancestors}
@@ -100,11 +100,11 @@ export async function generateViewport(): Promise<Viewport> {
}
export async function generateMetadata(): Promise<Metadata> {
const { space, collection, customization } = await fetchSpaceData();
const { space, parent, customization } = await fetchSpaceData();
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return {
title: `${collection ? collection.title : customization.title ?? space.title}`,
title: `${parent ? parent.title : customization.title ?? space.title}`,
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()),
icons: {
@@ -125,7 +125,7 @@ export async function generateMetadata(): Promise<Metadata> {
},
],
},
robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow',
robots: shouldIndexSpace({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
};
}
+10 -7
View File
@@ -1,7 +1,7 @@
import { ContentVisibility } from '@gitbook/api';
import { NextRequest } from 'next/server';
import { getCollection, getSpace } from '@/lib/api';
import { getCollection, getSite, getSpace } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo';
@@ -13,16 +13,19 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
const space = await getSpace(getContentPointer().spaceId);
const collection =
space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const pointer = getContentPointer();
const space = await getSpace(pointer.spaceId);
const parent =
'siteId' in pointer
? await getSite(pointer.organizationId, pointer.siteId)
: space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...(shouldIndexSpace({ space, collection })
...(shouldIndexSpace({ space, parent })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
+17 -8
View File
@@ -5,7 +5,13 @@ import { NextRequest } from 'next/server';
import React from 'react';
import { getContentPointer } from '@/app/(space)/fetch';
import { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
import {
getCollection,
getCurrentSiteCustomization,
getSite,
getSpace,
getSpaceCustomization,
} from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind';
@@ -35,17 +41,20 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const spaceId = getContentPointer().spaceId;
const pointer = getContentPointer();
const spaceId = pointer.spaceId;
const [space, customization] = await Promise.all([
getSpace(spaceId),
getSpaceCustomization(spaceId),
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
]);
const collection =
space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const contentTitle = collection?.title ?? customization.title ?? space.title;
const parent =
'siteId' in pointer
? await getSite(pointer.organizationId, pointer.siteId)
: space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const contentTitle = parent?.title ?? customization.title ?? space.title;
return new ImageResponse(
(
@@ -11,7 +11,7 @@ export const runtime = 'edge';
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, collection } = await fetchPageData(params);
const { space, page, customization, parent } = await fetchPageData(params);
const url = new URL(space.urls.published ?? space.urls.app);
if (customization.socialPreview.url) {
@@ -31,7 +31,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
}}
>
<h2 tw="text-7xl font-bold tracking-tight text-left">
{collection?.title ?? customization.title ?? space.title}
{parent?.title ?? customization.title ?? space.title}
</h2>
<div tw="flex flex-1">
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
+21 -5
View File
@@ -4,6 +4,7 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { Metadata } from 'next';
@@ -11,12 +12,18 @@ import { notFound } from 'next/navigation';
import * as React from 'react';
import { getContentPointer } from '@/app/(space)/fetch';
import { DocumentView } from '@/components/DocumentView';
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
import { PolymorphicComponentProp } from '@/components/utils/types';
import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate';
import { getDocument, getSpace, getSpaceCustomization, getSpaceContentData } from '@/lib/api';
import {
getDocument,
getSpace,
getSpaceCustomization,
getSpaceContentData,
getCurrentSiteCustomization,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -35,7 +42,9 @@ export async function generateMetadata(): Promise<Metadata> {
const contentPointer = getContentPointer();
const [space, customization] = await Promise.all([
getSpace(contentPointer.spaceId),
getSpaceCustomization(contentPointer.spaceId),
'siteId' in contentPointer
? getCurrentSiteCustomization(contentPointer)
: getSpaceCustomization(contentPointer.spaceId),
]);
return {
@@ -59,7 +68,9 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
// Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
getSpaceCustomization(contentPointer.spaceId),
'siteId' in contentPointer
? getCurrentSiteCustomization(contentPointer)
: getSpaceCustomization(contentPointer.spaceId),
getSpaceContentData(contentPointer),
]);
const language = getSpaceLanguage(customization);
@@ -172,7 +183,10 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
);
}
async function PDFSpaceIntro(props: { space: Space; customization: CustomizationSettings }) {
async function PDFSpaceIntro(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, customization } = props;
return (
@@ -216,6 +230,7 @@ async function PDFPageDocument(props: {
const { space, page, refContext } = props;
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
const shouldHighlightCode = createHighlightingContext();
return (
<PrintPage id={pagePDFContainerId(page)}>
@@ -238,6 +253,7 @@ async function PDFPageDocument(props: {
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
shouldHighlightCode,
}}
/>
) : null}
+68 -16
View File
@@ -9,6 +9,10 @@ import {
getDocument,
getSpaceData,
ContentTarget,
SiteContentPointer,
getCurrentSiteData,
getSite,
getSiteSpaces,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
@@ -23,7 +27,7 @@ export interface PageIdParams {
/**
* Get the current content pointer from the params.
*/
export function getContentPointer() {
export function getContentPointer(): ContentPointer | SiteContentPointer {
const headerSet = headers();
const spaceId = headerSet.get('x-gitbook-content-space');
if (!spaceId) {
@@ -32,13 +36,31 @@ export function getContentPointer() {
);
}
const content: ContentPointer = {
spaceId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
const siteId = headerSet.get('x-gitbook-content-site');
if (siteId) {
const organizationId = headerSet.get('x-gitbook-content-organization');
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
if (!organizationId) {
throw new Error('Missing site content headers');
}
return content;
const siteContent: SiteContentPointer = {
siteId,
spaceId,
siteSpaceId: siteSpaceId ?? undefined,
organizationId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
return siteContent;
} else {
const content: ContentPointer = {
spaceId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
return content;
}
}
/**
@@ -46,8 +68,14 @@ export function getContentPointer() {
*/
export async function fetchSpaceData() {
const content = getContentPointer();
const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content);
const collection = await fetchParentCollection(space);
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
'siteId' in content
? [getCurrentSiteData(content), fetchParentSite(content.organizationId, content.siteId)]
: [getSpaceData(content)],
);
const parent = await (parentSite ?? fetchParentCollection(space));
return {
content,
@@ -57,7 +85,7 @@ export async function fetchSpaceData() {
customization,
scripts,
ancestors: [],
...collection,
...parent,
};
}
@@ -67,11 +95,15 @@ export async function fetchSpaceData() {
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getContentPointer();
const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content);
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
? getCurrentSiteData(content)
: getSpaceData(content));
const page = await resolvePage(contentTarget, pages, params);
const [collection, document] = await Promise.all([
fetchParentCollection(space),
const [parent, document] = await Promise.all([
'siteId' in content
? fetchParentSite(content.organizationId, content.siteId)
: fetchParentCollection(space),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
@@ -84,7 +116,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
scripts,
ancestors: [],
...page,
...collection,
...parent,
document,
};
}
@@ -133,12 +165,32 @@ async function resolvePage(
async function fetchParentCollection(space: Space) {
const parentCollectionId =
space.visibility === ContentVisibility.InCollection ? space.parent : undefined;
const [collection, collectionSpaces] = await Promise.all([
const [collection, spaces] = await Promise.all([
parentCollectionId ? getCollection(parentCollectionId) : null,
parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]),
]);
return { collection, collectionSpaces };
return { parent: collection, spaces };
}
async function fetchParentSite(organizationId: string, siteId: string) {
const [site, siteSpaces] = await Promise.all([
getSite(organizationId, siteId),
getSiteSpaces(organizationId, siteId),
]);
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return { parent: site, spaces: Object.values(spaces) };
}
/**
+7 -3
View File
@@ -3,6 +3,7 @@ import {
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationSettings,
SiteCustomizationSettings,
} from '@gitbook/api';
import assertNever from 'assert-never';
import colors from 'tailwindcss/colors';
@@ -10,7 +11,7 @@ import colors from 'tailwindcss/colors';
import { emojiFontClassName } from '@/components/primitives';
import { fonts, ibmPlexMono } from '@/fonts';
import { getSpaceLanguage } from '@/intl/server';
import { getSpaceLayoutData } from '@/lib/api';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
import { hexToRgb, shadesOfColor } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
@@ -25,7 +26,10 @@ import { getContentPointer } from './fetch';
export default async function SpaceRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const { customization } = await getSpaceLayoutData(getContentPointer().spaceId);
const pointer = getContentPointer();
const { customization } = await ('siteId' in pointer
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const headerTheme = generateHeaderTheme(customization);
const language = getSpaceLanguage(customization);
@@ -120,7 +124,7 @@ function generateColorVariable(name: string, color: ColorInput) {
.join('\n');
}
function generateHeaderTheme(customization: CustomizationSettings): {
function generateHeaderTheme(customization: CustomizationSettings | SiteCustomizationSettings): {
backgroundColor: { light: ColorInput; dark: ColorInput };
linkColor: { light: ColorInput; dark: ColorInput };
} {
+3
View File
@@ -76,6 +76,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
</p>
<button
onClick={() => setShow(false)}
aria-label={tString(language, 'cookies_close')}
className={tcls(
'absolute',
'top-3',
@@ -97,6 +98,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
variant="primary"
size="small"
aria-label={tString(language, 'cookies_accept')}
onClick={() => {
onUpdateState(true);
}}
@@ -106,6 +108,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
variant="secondary"
size="small"
aria-label={tString(language, 'cookies_reject')}
onClick={() => {
onUpdateState(false);
}}
+9 -1
View File
@@ -34,7 +34,15 @@ export function Blocks<T extends DocumentBlock, Tag extends React.ElementType =
<Block
key={node.key}
block={node}
style={['max-w-3xl', 'w-full', 'mx-auto', 'decoration-primary/6', blockStyle]}
style={[
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
'w-full',
'mx-auto',
'decoration-primary/6',
blockStyle,
]}
{...contextProps}
/>
))}
@@ -3,7 +3,7 @@ import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import { highlight, HighlightLine, HighlightToken } from './highlight';
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
import { BlockProps } from '../Block';
import { DocumentContext } from '../DocumentView';
import { Inline } from '../Inline';
@@ -15,16 +15,14 @@ import './theme.css';
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const lines = await highlight(block);
const withHighlighting = context.shouldHighlightCode();
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const fullWidth = block.data.fullWidth;
const fullWidthStyle = fullWidth ? 'max-w-4xl' : 'max-w-3xl';
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
@@ -32,7 +30,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', fullWidthStyle, style)}>
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div
className={tcls(
'flex',
@@ -50,6 +50,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => true,
}}
block={block}
ancestorBlocks={[]}
@@ -0,0 +1,16 @@
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
/**
* Protect against memory issues when highlighting a large number of code blocks.
* This context only allows 50 code blocks per render to be highlighted.
* Once highlighting can scale up to a large number of code blocks, it can be removed.
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
export function createHighlightingContext() {
let count = 0;
return () => {
count += 1;
return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
};
}
@@ -1,10 +1,12 @@
import { DocumentBlockCode, DocumentBlockCodeLine, DocumentInlineAnnotation } from '@gitbook/api';
import {
loadWasm,
bundledLanguages,
ThemedToken,
getHighlighter,
createCssVariablesTheme,
HighlighterGeneric,
bundledLanguages,
bundledThemes,
} from 'shiki';
// @ts-ignore - onigWasm is a Wasm module
import onigWasm from 'shiki/onig.wasm?module';
@@ -13,6 +15,8 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
import { trace } from '@/lib/tracing';
import { DocumentContext } from '../DocumentView';
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -45,12 +49,14 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
});
const highlighter = await loadHighlighter();
await loadHighlighterLanguage(langName);
await loadHighlighterLanguage(highlighter, langName);
const lines = highlighter.codeToTokensBase(code, {
lang: langName,
tokenizeMaxLineLength: 120,
});
let currentIndex = 0;
let currentIndex = 0;
return lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
@@ -315,9 +321,15 @@ const loadHighlighter = singleton(async () => {
});
const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage(lang: keyof typeof bundledLanguages) {
async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<keyof typeof bundledLanguages, keyof typeof bundledThemes>,
lang: keyof typeof bundledLanguages,
) {
await loadLanguagesMutex.runBlocking(async () => {
const highlighter = await loadHighlighter();
if (highlighter.getLoadedLanguages().includes(lang)) {
return;
}
await trace(
`highlighting.loadLanguage(${lang})`,
async () => await highlighter.loadLanguage(lang),
@@ -1,2 +1,3 @@
export * from './CodeBlock';
export * from './PlainCodeBlock';
export * from './createHighlightingContext';
@@ -36,6 +36,16 @@ export interface DocumentContext {
* Transform an ID to be added to the DOM.
*/
getId?: (id: string) => string;
/**
* Returns true if the given code block should be highlighted.
* This function was added to protect against memory issues when highlighting
* a large number of code blocks.
* Once highlighting can scale up to a large number of code blocks, it can be removed.
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
shouldHighlightCode: () => boolean;
}
export interface DocumentContextProps {
+17 -8
View File
@@ -1,13 +1,13 @@
import { DocumentBlockEmbed } from '@gitbook/api';
import Script from 'next/script';
import { Card } from '@/components/primitives';
import { api } from '@/lib/api';
import { getNodeFragmentByName, isNodeEmpty } from '@/lib/document';
import { ClassValue, tcls } from '@/lib/tailwind';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { Inlines } from './Inlines';
export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
const { block } = props;
@@ -17,11 +17,20 @@ export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
return (
<Caption {...props}>
{embed.type === 'rich' ? (
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
/>
<>
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
/>
{/* We load the iframely script to resize the embed iframes dynamically */}
<Script
src="https://cdn.iframe.ly/embed.js"
defer
async
nonce={getContentSecurityPolicyNonce()}
/>
</>
) : (
<Card
leadingIcon={
-1
View File
@@ -31,7 +31,6 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
align === 'right' && 'justify-end',
align === 'left' && 'justify-start',
isMultipleImages && ['grid', 'grid-flow-col', 'max-w-none'],
block.data.fullWidth ? 'max-w-screen-2xl' : null,
)}
>
{block.nodes.map((node: any, i: number) => (
@@ -12,14 +12,12 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
<div
className={tcls(
style,
'max-w-full',
'md:max-w-3xl',
'inline-grid',
'gap-4',
'grid-cols-1',
'min-[432px]:grid-cols-2',
view.cardSize === 'large' ? 'md:grid-cols-2' : 'md:grid-cols-3',
block.data.fullWidth ? ['max-w-full', 'large:flex-column'] : null,
block.data.fullWidth ? 'large:flex-column' : null,
)}
>
{records.map((record) => {
+1 -26
View File
@@ -14,7 +14,6 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
const tableWrapper = columnsOverThreshold
? [
// has over X columns
'w-full',
'overflow-x-auto',
'overflow-y-hidden',
'mx-auto',
@@ -22,32 +21,8 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
'border',
'border-dark/3',
'dark:border-light/2',
block.data.fullWidth
? [
// has over X columns, and is full width
'max-w-full',
]
: [
// NOT full width, but has over X columns
'max-w-4xl',
],
]
: [
'w-full',
'overflow-x-auto',
'overflow-y-hidden',
'mx-auto',
// has under X columns
block.data.fullWidth
? [
// has under X columns, and is full width
'max-w-full',
]
: [
// NOT full width, but has under X columns
'max-w-3xl',
],
];
: ['overflow-x-auto', 'overflow-y-hidden', 'mx-auto'];
const tableTR = columnsOverThreshold
? ['[&>*+*]:border-l', '[&>*]:px-4']
+1
View File
@@ -1 +1,2 @@
export * from './DocumentView';
export { createHighlightingContext } from './CodeBlock';
+2 -2
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, Space } from '@gitbook/api';
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import React from 'react';
import { Image } from '@/components/utils';
@@ -12,7 +12,7 @@ import { ThemeToggler } from '../ThemeToggler';
export function Footer(props: {
space: Space;
context: ContentRefContext;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { context, customization } = props;
+12 -6
View File
@@ -1,4 +1,10 @@
import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { t } from '@/intl/server';
@@ -13,11 +19,11 @@ import { SearchButton } from '../Search';
*/
export function CompactHeader(props: {
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
parent: Site | Collection | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, collection, customization } = props;
const { space, parent, customization } = props;
return (
<div
@@ -34,7 +40,7 @@ export function CompactHeader(props: {
)}
>
<div className={tcls('flex-grow-0', 'mt-5')}>
<HeaderLogo collection={collection} space={space} customization={customization} />
<HeaderLogo parent={parent} space={space} customization={customization} />
</div>
<div
className={tcls(
+17 -18
View File
@@ -1,4 +1,10 @@
import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react';
@@ -7,26 +13,29 @@ import { t, getSpaceLanguage } from '@/intl/server';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { CollectionSpacesDropdown } from './CollectionSpacesDropdown';
import { HeaderLink } from './HeaderLink';
import { HeaderLinks } from './HeaderLinks';
import { HeaderLogo } from './HeaderLogo';
import { SpacesDropdown } from './SpacesDropdown';
import { SearchButton } from '../Search';
/**
* Render the header for the space.
*/
export function Header(props: {
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
parent: Site | Collection | null;
spaces: Space[];
context: ContentRefContext;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
withTopHeader?: boolean;
}) {
const { context, space, collection, collectionSpaces, customization, withTopHeader } = props;
const { context, space, parent, spaces, customization, withTopHeader } = props;
const isCustomizationDefault =
customization.header.preset === CustomizationHeaderPreset.Default;
const isMultiVariants =
parent?.object === 'collection' ||
(parent && parent.object === 'site' && spaces.length > 1);
return (
<header
@@ -66,19 +75,9 @@ export function Header(props: {
CONTAINER_STYLE,
)}
>
<HeaderLogo
collection={collection}
space={space}
customization={customization}
/>
<HeaderLogo parent={parent} space={space} customization={customization} />
<span>
{collection ? (
<CollectionSpacesDropdown
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
/>
) : null}
{isMultiVariants ? <SpacesDropdown space={space} spaces={spaces} /> : null}
</span>
<HeaderLinks>
{customization.header.links.map((link, index) => {
+2 -1
View File
@@ -3,6 +3,7 @@ import {
CustomizationHeaderLink,
CustomizationSettings,
CustomizationHeaderPreset,
SiteCustomizationSettings,
} from '@gitbook/api';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -20,7 +21,7 @@ import { Link } from '../primitives';
export async function HeaderLink(props: {
context: ContentRefContext;
link: CustomizationHeaderLink;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { context, link, customization } = props;
+12 -5
View File
@@ -1,4 +1,11 @@
import { Collection, CustomizationHeaderPreset, CustomizationSettings, Space } from '@gitbook/api';
import {
Collection,
CustomizationHeaderPreset,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
import { Image } from '@/components/utils';
@@ -8,9 +15,9 @@ import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
interface HeaderLogoProps {
collection: Collection | null;
parent: Site | Collection | null;
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
}
/**
@@ -86,7 +93,7 @@ export function HeaderLogo(props: HeaderLogoProps) {
}
function LogoFallback(props: HeaderLogoProps) {
const { collection, space, customization } = props;
const { parent, space, customization } = props;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return (
@@ -138,7 +145,7 @@ function LogoFallback(props: HeaderLogoProps) {
: 'text-header-link',
)}
>
{collection ? collection.title : customization.title ?? space.title}
{parent ? parent.title : customization.title ?? space.title}
</h1>
</>
);
@@ -4,18 +4,15 @@ import { tcls } from '@/lib/tailwind';
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
export function CollectionSpacesDropdown(props: {
space: Space;
collection: Collection;
collectionSpaces: Space[];
}) {
const { space, collectionSpaces } = props;
export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
const { space, spaces } = props;
return (
<Dropdown
button={(buttonProps) => (
<div
{...buttonProps}
data-testid="space-dropdown-button"
className={tcls(
'justify-self-start',
'flex',
@@ -32,7 +29,7 @@ export function CollectionSpacesDropdown(props: {
)}
>
<DropdownMenu>
{collectionSpaces.map((otherSpace) => (
{spaces.map((otherSpace) => (
<DropdownMenuItem
key={otherSpace.id}
href={otherSpace.urls.published ?? otherSpace.urls.app}
+8 -2
View File
@@ -2,7 +2,13 @@ import { Menu } from '@geist-ui/icons';
import DownloadCloud from '@geist-ui/icons/downloadCloud';
import Github from '@geist-ui/icons/github';
import Gitlab from '@geist-ui/icons/gitlab';
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import {
CustomizationSettings,
JSONDocument,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import urlJoin from 'url-join';
@@ -21,7 +27,7 @@ import { PageFeedbackForm } from '../PageFeedback';
*/
export async function PageAside(props: {
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
page: RevisionPageDocument;
document: JSONDocument | null;
context: ContentRefContext;
+11 -3
View File
@@ -1,4 +1,10 @@
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import {
CustomizationSettings,
JSONDocument,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
@@ -13,14 +19,14 @@ import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { TrackPageView } from './TrackPageView';
import { DocumentView } from '../DocumentView';
import { DocumentView, createHighlightingContext } from '../DocumentView';
import { PageFeedbackForm } from '../PageFeedback';
import { DateRelative } from '../primitives';
export function PageBody(props: {
space: Space;
contentTarget: ContentTarget;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
page: RevisionPageDocument;
document: JSONDocument | null;
context: ContentRefContext;
@@ -32,6 +38,7 @@ export function PageBody(props: {
const asFullWidth = document ? hasFullWidthBlock(document) : false;
const language = getSpaceLanguage(customization);
const updatedAt = page.updatedAt ?? page.createdAt;
const shouldHighlightCode = createHighlightingContext();
return (
<>
@@ -73,6 +80,7 @@ export function PageBody(props: {
contentRefContext: context,
resolveContentRef: (ref, options) =>
resolveContentRef(ref, context, options),
shouldHighlightCode,
}}
/>
) : (
@@ -1,6 +1,12 @@
import ChevronLeft from '@geist-ui/icons/chevronLeft';
import ChevronRight from '@geist-ui/icons/chevronRight';
import { CustomizationSettings, Revision, RevisionPageDocument, Space } from '@gitbook/api';
import {
CustomizationSettings,
Revision,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { t, getSpaceLanguage } from '@/intl/server';
@@ -15,7 +21,7 @@ import { Link } from '../primitives';
*/
export function PageFooterNavigation(props: {
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages'];
page: RevisionPageDocument;
}) {
+6 -13
View File
@@ -1,6 +1,7 @@
'use client';
import IconSearch from '@geist-ui/icons/search';
import { Collection, Site } from '@gitbook/api';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import React from 'react';
@@ -20,7 +21,7 @@ interface SearchModalProps {
spaceId: string;
revisionId: string;
spaceTitle: string;
collectionId: string | null;
parent: Site | Collection | null;
withAsk: boolean;
}
@@ -135,16 +136,8 @@ function SearchModalBody(
onClose: (to?: string) => void;
},
) {
const {
spaceId,
revisionId,
spaceTitle,
withAsk,
collectionId,
state,
onChangeQuery,
onClose,
} = props;
const { spaceId, revisionId, spaceTitle, withAsk, parent, state, onChangeQuery, onClose } =
props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
@@ -244,7 +237,7 @@ function SearchModalBody(
ref={resultsRef}
spaceId={spaceId}
revisionId={revisionId}
collectionId={state.global ? collectionId : null}
parent={state.global ? parent : null}
query={state.query}
withAsk={withAsk}
onSwitchToAsk={() => {
@@ -256,7 +249,7 @@ function SearchModalBody(
}}
onClose={onClose}
>
{collectionId && state.query ? (
{parent && state.query ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
</SearchResults>
+7 -7
View File
@@ -1,3 +1,4 @@
import { Collection, Site } from '@gitbook/api';
import assertNever from 'assert-never';
import React from 'react';
@@ -11,7 +12,7 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
import {
getRecommendedQuestions,
OrderedComputedResult,
searchCollectionContent,
searchParentContent,
searchSpaceContent,
} from './server-actions';
import { Loading } from '../primitives';
@@ -39,15 +40,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
query: string;
spaceId: string;
revisionId: string;
collectionId: string | null;
parent: Site | Collection | null;
withAsk: boolean;
onSwitchToAsk: () => void;
onClose: (to?: string) => void;
},
ref: React.Ref<SearchResultsRef>,
) {
const { children, query, spaceId, revisionId, collectionId, withAsk, onSwitchToAsk, onClose } =
props;
const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk, onClose } = props;
const language = useLanguage();
const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null);
@@ -94,8 +94,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
debounceTimeout.current = setTimeout(async () => {
setCursor(null);
const fetchedResults = await (collectionId
? searchCollectionContent(collectionId, query)
const fetchedResults = await (parent
? searchParentContent(parent, query)
: searchSpaceContent(spaceId, revisionId, query));
setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults);
}, 250);
@@ -107,7 +107,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
}
};
}
}, [query, spaceId, revisionId, collectionId, withAsk]);
}, [query, spaceId, revisionId, parent, withAsk]);
// Scroll to the active result.
React.useEffect(() => {
+39 -8
View File
@@ -1,7 +1,16 @@
'use server';
import { RevisionPage, SearchAIAnswer, SearchPageResult, Space } from '@gitbook/api';
import {
Collection,
RevisionPage,
SearchAIAnswer,
SearchPageResult,
Site,
Space,
} from '@gitbook/api';
import { headers } from 'next/headers';
import { getContentPointer } from '@/app/(space)/fetch';
import { streamResponse } from '@/lib/actions';
import * as api from '@/lib/api';
import { absoluteHref, pageHref } from '@/lib/links';
@@ -55,20 +64,41 @@ export async function searchSpaceContent(
}
/**
* Server action to search content in a collection
* Server action to search content in a parent (site or collection)
*/
export async function searchCollectionContent(
collectionId: string,
export async function searchParentContent(
parent: Site | Collection,
query: string,
): Promise<OrderedComputedResult[]> {
const [data, collectionSpaces] = await Promise.all([
api.searchCollectionContent(collectionId, query),
api.getCollectionSpaces(collectionId),
const pointer = getContentPointer();
const [data, collectionSpaces, siteSpaces] = await Promise.all([
api.searchParentContent(parent.id, query),
parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null,
parent.object === 'site' && 'organizationId' in pointer
? api.getSiteSpaces(pointer.organizationId, parent.id)
: null,
]);
let spaces: Space[] = [];
if (collectionSpaces) {
spaces = collectionSpaces;
} else if (siteSpaces) {
spaces = Object.values(
siteSpaces.reduce(
(acc, siteSpace) => {
acc[siteSpace.space.id] = siteSpace.space;
return acc;
},
{} as Record<string, Space>,
),
);
}
return data.items
.map((spaceItem) => {
const space = collectionSpaces.find((space) => space.id === spaceItem.id);
const space = spaces.find((space) => space.id === spaceItem.id);
return spaceItem.pages.map((item) => transformPageResult(item, space));
})
.flat(2);
@@ -131,6 +161,7 @@ function transformAnswer(
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => false,
}}
style={['space-y-5']}
/>
+12 -10
View File
@@ -5,6 +5,8 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -26,9 +28,9 @@ export function SpaceLayout(props: {
content: ContentPointer;
contentTarget: ContentTarget;
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
parent: Site | Collection | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages'];
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
children: React.ReactNode;
@@ -36,8 +38,8 @@ export function SpaceLayout(props: {
const {
space,
contentTarget,
collection,
collectionSpaces,
parent,
spaces,
content,
pages,
customization,
@@ -59,8 +61,8 @@ export function SpaceLayout(props: {
<Header
withTopHeader={withTopHeader}
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
parent={parent}
spaces={spaces}
context={contentRefContext}
customization={customization}
/>
@@ -89,8 +91,8 @@ export function SpaceLayout(props: {
withTopHeader ? null : (
<CompactHeader
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
parent={parent}
spaces={spaces}
customization={customization}
/>
)
@@ -114,7 +116,7 @@ export function SpaceLayout(props: {
revisionId={contentTarget.revisionId}
spaceTitle={customization.title ?? space.title}
withAsk={customization.aiSearch.enabled}
collectionId={collection && collectionSpaces.length > 1 ? collection.id : null}
parent={parent && spaces.length > 1 ? parent : null}
/>
</React.Suspense>
</>
@@ -3,6 +3,7 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -16,7 +17,7 @@ import { Trademark } from './Trademark';
export function TableOfContents(props: {
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
content: ContentPointer;
context: ContentRefContext;
pages: Revision['pages'];
+9 -3
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, Space } from '@gitbook/api';
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import { t, getSpaceLanguage } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
@@ -8,7 +8,10 @@ import { IconLogo } from '../icons/IconLogo';
/**
* Trademark link to the GitBook.
*/
export function Trademark(props: { space: Space; customization: CustomizationSettings }) {
export function Trademark(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
return (
<div
className={tcls(
@@ -54,7 +57,10 @@ export function Trademark(props: { space: Space; customization: CustomizationSet
/**
* Trademark link to the GitBook.
*/
export function TrademarkLink(props: { space: Space; customization: CustomizationSettings }) {
export function TrademarkLink(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, customization } = props;
const language = getSpaceLanguage(customization);
+4 -2
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings } from '@gitbook/api';
import { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api';
import { languages, TranslationLanguage } from './translations';
@@ -7,7 +7,9 @@ export * from './translate';
/**
* Create the translation context for a space to use in the server components.
*/
export function getSpaceLanguage(customization: CustomizationSettings): TranslationLanguage {
export function getSpaceLanguage(
customization: CustomizationSettings | SiteCustomizationSettings,
): TranslationLanguage {
const fallback = languages.en;
const { locale } = customization.internationalization;
+260 -5
View File
@@ -9,8 +9,10 @@ import {
HttpResponse,
List,
PublishedContentLookup,
PublishedSiteContentLookup,
RequestRenderIntegrationUI,
RevisionFile,
SiteCustomizationSettings,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { headers } from 'next/headers';
@@ -35,6 +37,18 @@ export interface ContentPointer {
revisionId?: string;
}
/**
* Pointer to a relative content, it might change overtime, the pointer is relative in the content history.
*/
export interface SiteContentPointer extends ContentPointer {
organizationId: string;
siteId: string;
/**
* ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews)
*/
siteSpaceId: string | undefined;
}
/**
* Pointer to a content that is immutable, it will never change.
*/
@@ -109,7 +123,7 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
}
export type PublishedContentWithCache =
| (PublishedContentLookup & {
| ((PublishedContentLookup | PublishedSiteContentLookup) & {
cacheMaxAge?: number;
cacheTags?: string[];
})
@@ -581,6 +595,240 @@ export const getDocument = cache(
},
);
/**
* Get the customization settings for a site-space from the API.
*/
const getSiteSpaceCustomizationFromAPI = cache(
'api.getSiteSpaceCustomizationById',
async (
organizationId: string,
siteId: string,
siteSpaceId: string,
options: CacheFunctionOptions,
) => {
const response = await api().orgs.getSiteSpaceCustomizationById(
organizationId,
siteId,
siteSpaceId,
{
signal: options.signal,
...noCacheFetchOptions,
},
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'site',
site: siteId,
}),
],
});
},
);
/**
* Get the customization settings for a site from the API.
*/
const getSiteCustomizationFromAPI = cache(
'api.getSiteCustomizationById',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteCustomizationById(organizationId, siteId, {
signal: options.signal,
...noCacheFetchOptions,
});
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'site',
site: siteId,
}),
],
});
},
);
/**
* Get the customization settings for a site space from the API.
*/
async function getSiteSpaceCustomization(args: {
organizationId: string;
siteId: string;
siteSpaceId: string;
}): Promise<SiteCustomizationSettings> {
const headersList = headers();
const raw = await getSiteSpaceCustomizationFromAPI(
args.organizationId,
args.siteId,
args.siteSpaceId,
);
const extend = headersList.get('x-gitbook-customization');
if (extend) {
try {
const parsed = rison.decode_object<Partial<SiteCustomizationSettings>>(extend);
return { ...raw, ...parsed };
} catch (error) {
console.error(
`Failed to parse x-gitbook-customization header (ignored): ${
(error as Error).stack ?? (error as Error).message ?? error
}`,
);
}
}
return raw;
}
/**
* Get the customization settings for a site space from the API.
*/
async function getSiteCustomization(args: {
organizationId: string;
siteId: string;
}): Promise<SiteCustomizationSettings> {
const headersList = headers();
const raw = await getSiteCustomizationFromAPI(args.organizationId, args.siteId);
const extend = headersList.get('x-gitbook-customization');
if (extend) {
try {
const parsed = rison.decode_object<Partial<SiteCustomizationSettings>>(extend);
return { ...raw, ...parsed };
} catch (error) {
console.error(
`Failed to parse x-gitbook-customization header (ignored): ${
(error as Error).stack ?? (error as Error).message ?? error
}`,
);
}
}
return raw;
}
/**
* Get the infos about a site by its ID.
*/
export const getSite = cache(
'api.getSite',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteById(organizationId, siteId, {
...noCacheFetchOptions,
signal: options.signal,
});
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
});
},
);
/**
* List all the site-spaces variants published in a site.
*/
export const getSiteSpaces = cache(
'api.getSiteSpaces',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await getAll((params) =>
api().orgs.listSiteSpaces(organizationId, siteId, params, {
...noCacheFetchOptions,
signal: options.signal,
}),
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
data: response.data.items.map((siteSpace) => siteSpace),
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
});
},
);
/**
* List the scripts to load for the site.
*/
export const getSiteIntegrationScripts = cache(
'api.getSiteIntegrationScripts',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
...noCacheFetchOptions,
signal: options.signal,
});
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
});
},
);
/**
* Fetch all the data to render the current site at once.
*/
export async function getCurrentSiteData(pointer: SiteContentPointer) {
const [{ space, pages, contentTarget }, { customization, scripts }] = await Promise.all([
getSpaceData(pointer),
getCurrentSiteLayoutData(pointer),
]);
return {
space,
pages,
contentTarget,
customization,
scripts,
};
}
/**
* Fetch all the layout data about the current site at once.
*/
export async function getCurrentSiteLayoutData(args: {
organizationId: string;
siteId: string;
siteSpaceId: string | undefined;
}) {
const [customization, scripts] = await Promise.all([
args.siteSpaceId
? getSiteSpaceCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
siteSpaceId: args.siteSpaceId,
})
: getSiteCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
}),
getSiteIntegrationScripts(args.organizationId, args.siteId),
]);
return {
customization,
scripts,
};
}
/**
* Get the customization settings for the current site from the API.
*/
export async function getCurrentSiteCustomization(args: {
organizationId: string;
siteId: string;
siteSpaceId: string | undefined;
}): Promise<SiteCustomizationSettings> {
return args.siteSpaceId
? getSiteSpaceCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
siteSpaceId: args.siteSpaceId,
})
: getSiteCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
});
}
/**
* Get the customization settings for a space from the API.
*/
@@ -752,11 +1000,11 @@ export const searchSpaceContent = cache(
);
/**
* Search content accross all spaces in a collection.
* Search content accross all spaces in a parent (site or collection).
*/
export const searchCollectionContent = cache(
'api.searchCollectionContent',
async (collectionId: string, query: string, options: CacheFunctionOptions) => {
export const searchParentContent = cache(
'api.searchParentContent',
async (parentId: string, query: string, options: CacheFunctionOptions) => {
const response = await api().search.searchContent(
{ query },
{
@@ -834,6 +1082,11 @@ export function getAPICacheTag(
| {
tag: 'synced-block';
syncedBlock: string;
}
// All data related to a site
| {
tag: 'site';
site: string;
},
): string {
switch (spec.tag) {
@@ -845,6 +1098,8 @@ export function getAPICacheTag(
return `collection:${spec.collection}`;
case 'synced-block':
return `synced-block:${spec.syncedBlock}`;
case 'site':
return `site:${spec.site}`;
default:
assertNever(spec);
}
-9
View File
@@ -108,14 +108,12 @@ export const cloudflareKVCache: CacheBackend = {
const kv = await getKVNamespace();
if (!kv) {
console.log('no kv');
return result;
}
const pendingDeletions: Array<Promise<unknown>> = [];
const iterateKVPage = async (prefix: string, cursor: string | null, max: number = 3) => {
console.log(`iterateKVPage prefix=${prefix} cursor=${cursor} max=${max}`)
const entries = await kv.list<KVTagMetadata>({
prefix,
cursor,
@@ -127,20 +125,15 @@ export const cloudflareKVCache: CacheBackend = {
const metadata = entry.metadata;
const key = metadata.meta.key;
console.log(`clear ${JSON.stringify(entry)}`)
result.metas.push(metadata.meta);
result.keys.push(key);
// Delete the tag key and the value key
pendingDeletions.push(kv.delete(getValueKey(key)));
pendingDeletions.push(kv.delete(entry.name));
} else {
console.log(`entry ${JSON.stringify(entry)} has no metadata`)
}
}
console.log(`cacheStatus = ${entries.cacheStatus} list_complete=${entries.list_complete}`);
if (!entries.list_complete && max > 0) {
await iterateKVPage(prefix, entries.cursor, max - 1);
}
@@ -180,11 +173,9 @@ async function getKVNamespace(): Promise<KVNamespace | null> {
const cloudflare = getOptionalRequestContext();
if (cloudflare) {
console.log(`return cloudflare.env.CACHE_KV: ${'CACHE_KV' in cloudflare.env ? !!cloudflare.env.CACHE_KV : null}`);
// @ts-ignore
return cloudflare.env.CACHE_KV ?? null;
}
console.log(`no cloudflare :(`);
return null;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'bun:test';
import { parseMarkdown } from './markdown';
describe('parseMarkdown', () => {
it('should parse a simple table', async () => {
const result = await parseMarkdown(`## Table
| a | b | c | d |
| - | :- | -: | :-: |`);
expect(result).toContain('<table>');
});
it('should parse a complex table', async () => {
const result =
await parseMarkdown(`Returns information for all non-fungible tokens for an account.
## Ordering
When considering NFTs, their order is governed by a combination of their numerical **token.Id** and **serialnumber** values, with **token.id** being the parent column.
A serialnumbers value governs its order within the given token.id
In that regard, if a user acquired a set of NFTs in the order (2-2, 2-4 1-5, 1-1, 1-3, 3-3, 3-4), the following layouts illustrate the ordering expectations for ownership listing
1. **All NFTs in ASC order**: 1-1, 1-3, 1-5, 2-2, 2-4, 3-3, 3-4
2. **All NFTs in DESC order**: 3-4, 3-3, 2-4, 2-2, 1-5, 1-3, 1-1
3. **NFTs above 1-1 in ASC order**: 1-3, 1-5, 2-2, 2-4, 3-3, 3-4
4. **NFTs below 3-3 in ASC order**: 1-1, 1-3, 1-5, 2-2, 2-4
5. **NFTs between 1-3 and 3-3 inclusive in DESC order**: 3-4, 3-3, 2-4, 2-2, 1-5, 1-3
Note: The default order for this API is currently DESC
## Filtering
When filtering there are some restrictions enforced to ensure correctness and scalability.
**The table below defines the restrictions and support for the NFT ownership endpoint**
| Query Param | Comparison Operator | Support | Description | Example |
| ------------- | ------------------- | ------- | --------------------- | ------- |
| token.id | eq | Y | Single occurrence only. | ?token.id=X |
| | ne | N | | |
| | lt(e) | Y | Single occurrence only. | ?token.id=lte:X |
| | gt(e) | Y | Single occurrence only. | ?token.id=gte:X |
| serialnumber | eq | Y | Single occurrence only. Requires the presence of a **token.id** query | ?serialnumber=Y |
| | ne | N | | |
| | lt(e) | Y | Single occurrence only. Requires the presence of an **lte** or **eq** **token.id** query | ?token.id=lte:X&serialnumber=lt:Y |
| | gt(e) | Y | Single occurrence only. Requires the presence of an **gte** or **eq** **token.id** query | ?token.id=gte:X&serialnumber=gt:Y |
| spender.id | eq | Y | | ?spender.id=Z |
| | ne | N | | |
| | lt(e) | Y | | ?spender.id=lt:Z |
| | gt(e) | Y | | ?spender.id=gt:Z |
Note: When searching across a range for individual NFTs a **serialnumber** with an additional **token.id** query filter must be provided.
Both filters must be a single occurrence of **gt(e)** or **lt(e)** which provide a lower and or upper boundary for search.`);
expect(result).toContain('<table>');
});
});
+2
View File
@@ -1,5 +1,6 @@
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
@@ -10,6 +11,7 @@ import { unified } from 'unified';
export async function parseMarkdown(markdown: string): Promise<string> {
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
+11 -5
View File
@@ -1,4 +1,4 @@
import { Collection, ContentVisibility, Space } from '@gitbook/api';
import { Collection, ContentVisibility, Site, SiteVisibility, Space } from '@gitbook/api';
import { headers } from 'next/headers';
/**
@@ -6,10 +6,10 @@ import { headers } from 'next/headers';
*/
export function shouldIndexSpace({
space,
collection,
parent,
}: {
space: Space;
collection: Collection | null;
parent: Site | Collection | null;
}) {
const headerSet = headers();
@@ -28,12 +28,18 @@ export function shouldIndexSpace({
return false;
}
if (parent && parent.object === 'site') {
return shouldIndexVisibility(parent.visibility);
}
if (space.visibility === ContentVisibility.InCollection) {
return collection ? shouldIndexVisibility(collection.visibility) : false;
return parent && parent.object === 'collection'
? shouldIndexVisibility(parent.visibility)
: false;
}
return shouldIndexVisibility(space.visibility);
}
function shouldIndexVisibility(visibility: ContentVisibility) {
function shouldIndexVisibility(visibility: ContentVisibility | SiteVisibility) {
return visibility === ContentVisibility.Public;
}
+41 -1
View File
@@ -1,6 +1,7 @@
import { GitBookAPI } from '@gitbook/api';
import * as Sentry from '@sentry/nextjs';
import assertNever from 'assert-never';
import jwt from 'jsonwebtoken';
import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextResponse, NextRequest } from 'next/server';
@@ -14,6 +15,7 @@ import {
withAPI,
getSpaceLayoutData,
DEFAULT_API_ENDPOINT,
getCurrentSiteLayoutData,
} from '@/lib/api';
import { race } from '@/lib/async';
import { buildVersion } from '@/lib/build';
@@ -72,6 +74,13 @@ export type LookupResult = PublishedContentWithCache & {
cookies?: LookupCookies;
};
interface ContentAPITokenPayload {
organization: string;
spaces: string[];
collection?: string;
site?: string;
}
/**
* Middleware to lookup the space to render.
* It takes as input a request with an URL, and a set of headers:
@@ -139,6 +148,7 @@ export async function middleware(request: NextRequest) {
space: resolved.space,
changeRequest: resolved.changeRequest,
revision: resolved.revision,
...('site' in resolved ? { site: resolved.site, siteSpace: resolved.siteSpace } : {}),
});
// Because of how Next will encode, we need to encode ourselves the pathname before rewriting to it.
@@ -167,7 +177,13 @@ export async function middleware(request: NextRequest) {
}),
);
const { scripts } = await getSpaceLayoutData(resolved.space);
const { scripts } = await ('site' in resolved
? getCurrentSiteLayoutData({
organizationId: resolved.organization,
siteId: resolved.site,
siteSpaceId: resolved.siteSpace,
})
: getSpaceLayoutData(resolved.space));
return getContentSecurityPolicy(scripts, nonce);
},
);
@@ -184,6 +200,13 @@ export async function middleware(request: NextRequest) {
headers.set('x-gitbook-origin-basepath', originBasePath);
headers.set('x-gitbook-basepath', joinPath(originBasePath, resolved.basePath));
headers.set('x-gitbook-content-space', resolved.space);
if ('site' in resolved) {
headers.set('x-gitbook-content-organization', resolved.organization);
headers.set('x-gitbook-content-site', resolved.site);
if (resolved.siteSpace) {
headers.set('x-gitbook-content-site-space', resolved.siteSpace);
}
}
if (resolved.revision) {
headers.set('x-gitbook-content-revision', resolved.revision);
}
@@ -221,6 +244,10 @@ export async function middleware(request: NextRequest) {
// Add Content Security Policy header
response.headers.set('content-security-policy', csp);
// Basic security headers
response.headers.set('strict-transport-security', 'max-age=31536000');
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
response.headers.set('x-content-type-options', 'nosniff');
const isPrefetch = request.headers.has('x-middleware-prefetch');
@@ -457,10 +484,20 @@ async function lookupSpaceInMultiIdMode(request: NextRequest, url: URL): Promise
};
}
const decoded = jwt.decode(apiToken) as ContentAPITokenPayload;
const siteLookupResult =
typeof decoded.site === 'string' &&
decoded.site &&
typeof decoded.organization === 'string' &&
decoded.organization
? { site: decoded.site, organization: decoded.organization }
: {};
return {
space: spaceId,
changeRequest: changeRequestId,
revision: revisionId,
...siteLookupResult,
basePath: normalizePathname(basePathParts.join('/')),
pathname: normalizePathname(pathSegments.join('/')),
apiToken,
@@ -612,6 +649,9 @@ async function lookupSpaceByAPI(
apiToken: data.apiToken,
cacheMaxAge: data.cacheMaxAge,
cacheTags: data.cacheTags,
...('site' in data
? { site: data.site, siteSpace: data.siteSpace, organization: data.organization }
: {}),
} as PublishedContentWithCache;
});