Compare commits

..

2 Commits

Author SHA1 Message Date
Steven Hall 7c0cd40a2d type' 2024-04-05 13:54:40 +02:00
Steven Hall 5b30483b64 Logs 2024-04-05 13:50:00 +02:00
67 changed files with 389 additions and 1198 deletions
+1 -5
View File
@@ -1,9 +1,5 @@
on: [push]
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
deploy:
+2 -2
View File
@@ -75,7 +75,7 @@ GitBook's rendering engine is fully open-source and built on top of [Next.js](ht
### Types of contributions
We encourage you to contribute to GitBook to help us build the best tool for documenting technical knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
We encourage you to contribute to GitBook to help us build the best tool for doucmenting techincal knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
#### Translations
@@ -92,7 +92,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
>
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
>
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintainance, or updates to forked and self-hosted instances of this project.
>
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
BIN
View File
Binary file not shown.
+1 -73
View File
@@ -40,72 +40,6 @@ 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',
@@ -243,11 +177,6 @@ const testCases: TestsCase[] = [
url: 'blocks/lists',
fullPage: true,
},
{
name: 'Code',
url: 'blocks/code',
fullPage: true,
},
{
name: 'Cards',
url: 'blocks/cards',
@@ -260,7 +189,6 @@ const testCases: TestsCase[] = [
{
name: 'Embeds',
url: 'blocks/embeds',
fullPage: true,
},
{
name: 'Annotations',
@@ -548,7 +476,7 @@ for (const testCase of testCases) {
}
if (testEntry.screenshot !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
viewports: ['macbook-13', 'iphone-x', 'ipad-2'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
-9
View File
@@ -39,15 +39,6 @@ module.exports = withSentryConfig(
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
}
]
}
},
{
silent: true,
+2 -3
View File
@@ -20,7 +20,7 @@
],
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.46.0",
"@gitbook/api": "^0.39.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
@@ -51,7 +51,6 @@
"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",
@@ -64,7 +63,7 @@
},
"devDependencies": {
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "^1.11.3",
"@cloudflare/next-on-pages": "^1.9.0",
"@cloudflare/workers-types": "^4.20231218.0",
"@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6",
+1 -1
View File
@@ -3,7 +3,7 @@
"exports": "./src/index.ts",
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "^0.46.0",
"@gitbook/api": "^0.36.0",
"assert-never": "^1.2.1"
},
"peerDependencies": {
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "@gitbook/react-openapi",
"exports": "./src/index.ts",
"dependencies": {
"@scalar/api-client-react": "^0.3.7",
"@scalar/oas-utils": "0.1.6",
"@scalar/api-client-react": "^0.2.9",
"@scalar/oas-utils": "0.1.1",
"classnames": "^2.5.1",
"flatted": "^3.2.9",
"openapi-types": "^12.1.3",
+1 -10
View File
@@ -7,7 +7,6 @@ import {
getRequestFromOperation,
Query,
Header,
RequestBody,
} from '@scalar/oas-utils';
import React from 'react';
@@ -99,15 +98,7 @@ export function ScalarApiClient(props: { children: React.ReactNode }) {
{
url: operationData.path,
},
getRequestFromOperation(
{
...operation,
information: {
requestBody: operationData.operation.requestBody as RequestBody,
},
},
{ requiredOnly: false },
),
getRequestFromOperation(operation, { requiredOnly: false }),
);
return {
-2
View File
@@ -6,8 +6,6 @@ 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,8 +6,6 @@ if (dsn) {
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
+3 -6
View File
@@ -1,7 +1,6 @@
import { NextRequest } from 'next/server';
import { verifyImageSignature, resizeImage, CloudflareImageOptions } from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge';
@@ -11,14 +10,12 @@ export const runtime = 'edge';
* Fetch and resize an image.
*/
export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url');
const url = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign');
if (!urlParam || !signature) {
if (!url || !signature) {
return new Response('Missing url/sign parameters', { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Prevent infinite loops
if (url.includes('/~gitbook/image')) {
return new Response('Invalid url parameter', { status: 400 });
@@ -27,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 "${signature ?? ''}" for "${url}"`, { status: 400 });
return new Response('Invalid signature', { status: 400 });
}
// Cloudflare-specific options are in the cf object.
@@ -1,14 +1,11 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
import { getSpaceLayoutData } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { getContentPointer } from '../../fetch';
export default async function NotFound() {
const pointer = getContentPointer();
const { customization } = await ('siteId' in pointer
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const { customization } = await getSpaceLayoutData(getContentPointer().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, parent } = await fetchPageData(params);
const { space, pages, page, customization, collection } = await fetchPageData(params);
if (!page) {
notFound();
}
return {
title: [page.title, customization.title ?? space.title, parent?.title]
title: [page.title, customization.title ?? space.title, collection?.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,
parent,
spaces,
collection,
collectionSpaces,
ancestors,
scripts,
} = await fetchSpaceData();
@@ -56,8 +56,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
<SpaceLayout
space={space}
contentTarget={contentTarget}
parent={parent}
spaces={spaces}
collection={collection}
collectionSpaces={collectionSpaces}
customization={customization}
pages={pages}
ancestors={ancestors}
@@ -100,11 +100,11 @@ export async function generateViewport(): Promise<Viewport> {
}
export async function generateMetadata(): Promise<Metadata> {
const { space, parent, customization } = await fetchSpaceData();
const { space, collection, customization } = await fetchSpaceData();
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return {
title: `${parent ? parent.title : customization.title ?? space.title}`,
title: `${collection ? collection.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, parent }) ? 'index, follow' : 'noindex, nofollow',
robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow',
};
}
+7 -10
View File
@@ -1,7 +1,7 @@
import { ContentVisibility } from '@gitbook/api';
import { NextRequest } from 'next/server';
import { getCollection, getSite, getSpace } from '@/lib/api';
import { getCollection, getSpace } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo';
@@ -13,19 +13,16 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
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 space = await getSpace(getContentPointer().spaceId);
const collection =
space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...(shouldIndexSpace({ space, parent })
...(shouldIndexSpace({ space, collection })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
+8 -17
View File
@@ -5,13 +5,7 @@ import { NextRequest } from 'next/server';
import React from 'react';
import { getContentPointer } from '@/app/(space)/fetch';
import {
getCollection,
getCurrentSiteCustomization,
getSite,
getSpace,
getSpaceCustomization,
} from '@/lib/api';
import { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind';
@@ -41,20 +35,17 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const pointer = getContentPointer();
const spaceId = pointer.spaceId;
const spaceId = getContentPointer().spaceId;
const [space, customization] = await Promise.all([
getSpace(spaceId),
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
getSpaceCustomization(spaceId),
]);
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;
const collection =
space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const contentTitle = collection?.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, parent } = await fetchPageData(params);
const { space, page, customization, collection } = 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">
{parent?.title ?? customization.title ?? space.title}
{collection?.title ?? customization.title ?? space.title}
</h2>
<div tw="flex flex-1">
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
+5 -21
View File
@@ -4,7 +4,6 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { Metadata } from 'next';
@@ -12,18 +11,12 @@ import { notFound } from 'next/navigation';
import * as React from 'react';
import { getContentPointer } from '@/app/(space)/fetch';
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
import { DocumentView } 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,
getCurrentSiteCustomization,
} from '@/lib/api';
import { getDocument, getSpace, getSpaceCustomization, getSpaceContentData } from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -42,9 +35,7 @@ export async function generateMetadata(): Promise<Metadata> {
const contentPointer = getContentPointer();
const [space, customization] = await Promise.all([
getSpace(contentPointer.spaceId),
'siteId' in contentPointer
? getCurrentSiteCustomization(contentPointer)
: getSpaceCustomization(contentPointer.spaceId),
getSpaceCustomization(contentPointer.spaceId),
]);
return {
@@ -68,9 +59,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
// Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in contentPointer
? getCurrentSiteCustomization(contentPointer)
: getSpaceCustomization(contentPointer.spaceId),
getSpaceCustomization(contentPointer.spaceId),
getSpaceContentData(contentPointer),
]);
const language = getSpaceLanguage(customization);
@@ -183,10 +172,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
);
}
async function PDFSpaceIntro(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
async function PDFSpaceIntro(props: { space: Space; customization: CustomizationSettings }) {
const { space, customization } = props;
return (
@@ -230,7 +216,6 @@ 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)}>
@@ -253,7 +238,6 @@ async function PDFPageDocument(props: {
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
shouldHighlightCode,
}}
/>
) : null}
+16 -68
View File
@@ -9,10 +9,6 @@ import {
getDocument,
getSpaceData,
ContentTarget,
SiteContentPointer,
getCurrentSiteData,
getSite,
getSiteSpaces,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
@@ -27,7 +23,7 @@ export interface PageIdParams {
/**
* Get the current content pointer from the params.
*/
export function getContentPointer(): ContentPointer | SiteContentPointer {
export function getContentPointer() {
const headerSet = headers();
const spaceId = headerSet.get('x-gitbook-content-space');
if (!spaceId) {
@@ -36,31 +32,13 @@ export function getContentPointer(): ContentPointer | SiteContentPointer {
);
}
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');
}
const content: ContentPointer = {
spaceId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
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;
}
return content;
}
/**
@@ -68,14 +46,8 @@ export function getContentPointer(): ContentPointer | SiteContentPointer {
*/
export async function fetchSpaceData() {
const content = getContentPointer();
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));
const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content);
const collection = await fetchParentCollection(space);
return {
content,
@@ -85,7 +57,7 @@ export async function fetchSpaceData() {
customization,
scripts,
ancestors: [],
...parent,
...collection,
};
}
@@ -95,15 +67,11 @@ export async function fetchSpaceData() {
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getContentPointer();
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
? getCurrentSiteData(content)
: getSpaceData(content));
const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content);
const page = await resolvePage(contentTarget, pages, params);
const [parent, document] = await Promise.all([
'siteId' in content
? fetchParentSite(content.organizationId, content.siteId)
: fetchParentCollection(space),
const [collection, document] = await Promise.all([
fetchParentCollection(space),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
@@ -116,7 +84,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
scripts,
ancestors: [],
...page,
...parent,
...collection,
document,
};
}
@@ -165,32 +133,12 @@ async function resolvePage(
async function fetchParentCollection(space: Space) {
const parentCollectionId =
space.visibility === ContentVisibility.InCollection ? space.parent : undefined;
const [collection, spaces] = await Promise.all([
const [collection, collectionSpaces] = await Promise.all([
parentCollectionId ? getCollection(parentCollectionId) : null,
parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]),
]);
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) };
return { collection, collectionSpaces };
}
/**
+3 -7
View File
@@ -3,7 +3,6 @@ import {
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationSettings,
SiteCustomizationSettings,
} from '@gitbook/api';
import assertNever from 'assert-never';
import colors from 'tailwindcss/colors';
@@ -11,7 +10,7 @@ import colors from 'tailwindcss/colors';
import { emojiFontClassName } from '@/components/primitives';
import { fonts, ibmPlexMono } from '@/fonts';
import { getSpaceLanguage } from '@/intl/server';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
import { getSpaceLayoutData } from '@/lib/api';
import { hexToRgb, shadesOfColor } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
@@ -26,10 +25,7 @@ import { getContentPointer } from './fetch';
export default async function SpaceRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getContentPointer();
const { customization } = await ('siteId' in pointer
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const { customization } = await getSpaceLayoutData(getContentPointer().spaceId);
const headerTheme = generateHeaderTheme(customization);
const language = getSpaceLanguage(customization);
@@ -124,7 +120,7 @@ function generateColorVariable(name: string, color: ColorInput) {
.join('\n');
}
function generateHeaderTheme(customization: CustomizationSettings | SiteCustomizationSettings): {
function generateHeaderTheme(customization: CustomizationSettings): {
backgroundColor: { light: ColorInput; dark: ColorInput };
linkColor: { light: ColorInput; dark: ColorInput };
} {
-3
View File
@@ -76,7 +76,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
</p>
<button
onClick={() => setShow(false)}
aria-label={tString(language, 'cookies_close')}
className={tcls(
'absolute',
'top-3',
@@ -98,7 +97,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
variant="primary"
size="small"
aria-label={tString(language, 'cookies_accept')}
onClick={() => {
onUpdateState(true);
}}
@@ -108,7 +106,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
variant="secondary"
size="small"
aria-label={tString(language, 'cookies_reject')}
onClick={() => {
onUpdateState(false);
}}
+1 -9
View File
@@ -34,15 +34,7 @@ export function Blocks<T extends DocumentBlock, Tag extends React.ElementType =
<Block
key={node.key}
block={node}
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,
]}
style={['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, plainHighlighting } from './highlight';
import { highlight, HighlightLine, HighlightToken } from './highlight';
import { BlockProps } from '../Block';
import { DocumentContext } from '../DocumentView';
import { Inline } from '../Inline';
@@ -15,14 +15,16 @@ import './theme.css';
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const withHighlighting = context.shouldHighlightCode(context.content?.spaceId);
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
const lines = await highlight(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',
@@ -30,7 +32,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', fullWidthStyle, style)}>
<div
className={tcls(
'flex',
@@ -50,7 +50,6 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => true,
}}
block={block}
ancestorBlocks={[]}
@@ -1,29 +0,0 @@
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
/**
* Spaces where we'll always use plain highlighting.
*/
const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL',
];
/**
* 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 (spaceId?: string | undefined) => {
if (!spaceId || PLAIN_HIGHLIGHTING_SPACES.includes(spaceId)) {
return false;
}
count += 1;
return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
};
}
@@ -1,12 +1,10 @@
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';
@@ -15,8 +13,6 @@ 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[];
@@ -49,14 +45,12 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
});
const highlighter = await loadHighlighter();
await loadHighlighterLanguage(highlighter, langName);
await loadHighlighterLanguage(langName);
const lines = highlighter.codeToTokensBase(code, {
lang: langName,
tokenizeMaxLineLength: 120,
});
let currentIndex = 0;
return lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
@@ -321,15 +315,9 @@ const loadHighlighter = singleton(async () => {
});
const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<keyof typeof bundledLanguages, keyof typeof bundledThemes>,
lang: keyof typeof bundledLanguages,
) {
async function loadHighlighterLanguage(lang: keyof typeof bundledLanguages) {
await loadLanguagesMutex.runBlocking(async () => {
if (highlighter.getLoadedLanguages().includes(lang)) {
return;
}
const highlighter = await loadHighlighter();
await trace(
`highlighting.loadLanguage(${lang})`,
async () => await highlighter.loadLanguage(lang),
@@ -1,3 +1,2 @@
export * from './CodeBlock';
export * from './PlainCodeBlock';
export * from './createHighlightingContext';
@@ -36,16 +36,6 @@ 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: (spaceId: string | undefined) => boolean;
}
export interface DocumentContextProps {
+11 -48
View File
@@ -1,38 +1,26 @@
import * as gitbookAPI from '@gitbook/api';
import Script from 'next/script';
import { DocumentBlockEmbed } from '@gitbook/api';
import { Card } from '@/components/primitives';
import { api } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { getNodeFragmentByName, isNodeEmpty } from '@/lib/document';
import { ClassValue, tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { IntegrationBlock } from './Integration';
import { Inlines } from './Inlines';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props;
export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
const { block } = props;
const { data: embed } = await (context.content
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
: api().urls.getEmbedByUrl({ url: block.data.url }));
const { data: embed } = await api().urls.getEmbedByUrl({ url: block.data.url });
return (
<Caption {...props}>
{embed.type === 'rich' ? (
<>
<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 />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
{...otherProps}
context={context}
block={createIntegrationBlock(block.data.url, embed.integration, embed.block)}
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
/>
) : (
<Card
@@ -49,28 +37,3 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
</Caption>
);
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
function createIntegrationBlock(
url: string,
integration: string,
block: gitbookAPI.IntegrationBlock,
): gitbookAPI.DocumentBlockIntegration {
return {
object: 'block',
type: 'integration',
isVoid: true,
data: {
integration,
block: block.id,
props: {},
action: {
action: '@link.unfurl',
url,
},
url,
},
};
}
+41 -6
View File
@@ -1,10 +1,12 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { DocumentBlockFile } from '@gitbook/api';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context, style } = props;
@@ -16,7 +18,42 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return null;
}
const contentType = getSimplifiedContentType(file.contentType);
const contentType = (() => {
switch (file.contentType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'image/png':
case 'image/jpeg':
case 'image/gif':
case 'image/webp':
case 'image/tiff':
case 'image/svg+xml':
return 'image';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
})();
const icon = (() => {
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
})();
return (
<a
@@ -66,9 +103,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'dark:border-light/2',
)}
>
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>
<FileIcon contentType={contentType} />
</div>
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>{icon}</div>
<div
className={tcls(
'text-xs',
-24
View File
@@ -1,24 +0,0 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { SimplifiedFileType } from '@/lib/files';
/**
* Render an appropriate icon for a file.
*/
export function FileIcon(props: { contentType: SimplifiedFileType | null }) {
const { contentType } = props;
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
}
+1
View File
@@ -31,6 +31,7 @@ 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) => (
@@ -70,7 +70,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const initialInput = {
componentId: block.data.block,
props: block.data.props,
action: block.data.action,
context: contentKitContext,
};
+74 -82
View File
@@ -1,123 +1,115 @@
.light .scalar-modal-layout,
.light .scalar {
--scalar-color-1: color-mix(
--theme-color-1: color-mix(
in srgb,
rgb(var(--primary-base-300, 180 180 180)),
rgb(var(--dark-base, 23 23 23)) 96%
);
--scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.72));
--scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
--scalar-color-accent: #007d9c;
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72));
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
--theme-color-accent: #007d9c;
--scalar-background-1: rgb(var(--light-base, 255 255 255));
--scalar-background-2: color-mix(
--theme-background-1: rgb(var(--light-base, 255 255 255));
--theme-background-2: color-mix(
in srgb,
rgb(var(--primary-base-800, 30 30 30)),
var(--scalar-background-1) 96%
var(--theme-background-1) 96%
);
--scalar-background-3: color-mix(
--theme-background-3: color-mix(
in srgb,
rgb(var(--primary-base-800, 30 30 30)),
var(--scalar-background-1) 90%
var(--theme-background-1) 90%
);
--scalar-background-accent: #007d9c1f;
--scalar-code-language-color-supersede: var(--scalar-color-1);
--scalar-code-languages-background-supersede: var(--scalar-background-1);
--scalar-border-color: color-mix(
--theme-background-accent: #007d9c1f;
--theme-code-language-color-supersede: var(--theme-color-1);
--theme-code-languages-background-supersede: var(--theme-background-1);
--theme-border-color: color-mix(
in srgb,
var(--scalar-color-1),
var(--theme-color-1),
transparent calc(100% - 100% * 0.08)
);
--scalar-color-green: #0a6355;
--scalar-color-red: #dc1b19;
--scalar-color-yellow: #ffc90d;
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
--scalar-color-orange: #ff8d4d;
--scalar-color-purple: #8250df;
--theme-color-green: #0a6355;
--theme-color-red: #dc1b19;
--theme-color-yellow: #ffc90d;
--theme-color-blue: rgb(var(--primary-color-500, 52 109 219));
--theme-color-orange: #ff8d4d;
--theme-color-purple: #8250df;
--scalar-scrollbar-color: rgba(255, 255, 255, 0.24);
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48);
--scalar-button-1: rgb(49 53 56);
--scalar-button-1-color: #fff;
--scalar-button-1-hover: rgb(28 31 33);
--theme-scrollbar-color: rgba(255, 255, 255, 0.24);
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
}
.dark .scalar-modal-layout,
.dark .scalar {
--scalar-color-1: color-mix(
--theme-color-1: color-mix(
in srgb,
rgb(var(--primary-base-700, 70 70 70)),
rgb(var(--light-base, 255 255 255)) 100%
);
--scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.64));
--scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
--scalar-color-accent: #50b7e0;
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64));
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
--theme-color-accent: #50b7e0;
--scalar-background-1: rgb(var(--dark-base, 22 22 22));
--scalar-background-2: color-mix(
--theme-background-1: rgb(var(--dark-base, 22 22 22));
--theme-background-2: color-mix(
in srgb,
rgb(var(--primary-base-200, 200 200 200)),
var(--scalar-background-1) 92%
var(--theme-background-1) 92%
);
--scalar-background-3: color-mix(
--theme-background-3: color-mix(
in srgb,
rgb(var(--primary-base-200, 200 200 200)),
var(--scalar-background-1) 88%
var(--theme-background-1) 88%
);
--scalar-background-accent: #8ab4f81f;
--scalar-code-languages-background-supersede: var(--scalar-background-1);
--scalar-border-color: color-mix(
--theme-background-accent: #8ab4f81f;
--theme-code-languages-background-supersede: var(--theme-background-1);
--theme-border-color: color-mix(
in srgb,
var(--scalar-color-1),
var(--theme-color-1),
transparent calc(100% - 100% * 0.08)
);
--scalar-color-green: #56b6c2;
--scalar-color-red: rgb(245 124 97);
--scalar-color-yellow: #edbe20;
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
--scalar-color-orange: #d19a66;
--scalar-color-purple: #5203d1;
--theme-color-green: #56b6c2;
--theme-color-red: rgb(245 124 97);
--theme-color-yellow: #edbe20;
--theme-color-blue: rgb(var(--primary-color-400, 93 138 226));
--theme-color-orange: #d19a66;
--theme-color-purple: #5203d1;
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
--scalar-button-1: #f6f6f6;
--scalar-button-1-color: #000;
--scalar-button-1-hover: #e7e7e7;
--theme-scrollbar-color: rgba(0, 0, 0, 0.18);
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
}
.scalar-modal-layout,
.scalar {
--scalar-font: initial;
--scalar-font-code: var(--font-mono);
--theme-font: initial;
--theme-font-code: var(--font-mono);
--scalar-paragraph: 16px;
--scalar-small: 14px;
--scalar-mini: 13px;
--scalar-micro: 12px;
--theme-paragraph: 16px;
--theme-small: 14px;
--theme-mini: 13px;
--theme-micro: 12px;
--scalar-bold: 600;
--scalar-semibold: 500;
--scalar-regular: 400;
--theme-bold: 600;
--theme-semibold: 500;
--theme-regular: 400;
/* Font sizes for interactive applications (not rendered text content) */
--scalar-font-size-1: 24px;
--scalar-font-size-2: 16px;
--scalar-font-size-3: 14px;
--scalar-font-size-4: 13px;
--scalar-font-size-5: 12px;
--theme-font-size-1: 24px;
--theme-font-size-2: 16px;
--theme-font-size-3: 14px;
--theme-font-size-4: 13px;
--theme-font-size-5: 12px;
--scalar-line-height-1: 32px;
--scalar-line-height-2: 24px;
--scalar-line-height-3: 20px;
--scalar-line-height-4: 18px;
--scalar-line-height-5: 16px;
--theme-line-height-1: 32px;
--theme-line-height-2: 24px;
--theme-line-height-3: 20px;
--theme-line-height-4: 18px;
--theme-line-height-5: 16px;
--scalar-app-header-height: 35px;
}
.scalar input::placeholder {
color: var(--scalar-color-3);
color: var(--theme-color-3);
}
.scalar .scalar-app-header {
width: 100%;
@@ -142,15 +134,15 @@
display: flex;
align-items: center;
background: transparent;
color: var(--scalar-color-1);
font-size: var(--scalar-small);
font-weight: var(--scalar-semibold);
color: var(--theme-color-1);
font-size: var(--theme-small);
font-weight: var(--theme-semibold);
}
.scalar-api-client__close:hover {
cursor: pointer;
}
.scalar .scalar-app {
background: var(--scalar-background-3);
background: var(--theme-background-3);
height: calc(100dvh - 100px);
max-width: 1280px;
width: 100%;
@@ -235,10 +227,10 @@
line-height: normal;
}
.scalar .scalar-app-header span {
color: var(--scalar-color-3);
color: var(--theme-color-3);
}
.scalar .scalar-app-header a {
color: var(--scalar-color-1);
color: var(--theme-color-1);
}
.scalar .scalar-app-header a:hover {
text-decoration: underline;
@@ -259,7 +251,7 @@
display: flex;
gap: 6px;
align-items: center;
color: var(--scalar-color-blue);
color: var(--theme-color-blue);
appearance: none;
outline: none;
border: none;
@@ -269,7 +261,7 @@
padding: 0 0.5rem;
}
.scalar-activate:hover .scalar-activate-button {
background: var(--scalar-background-3);
background: var(--theme-background-3);
border-radius: 3px;
}
@@ -301,12 +293,12 @@
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
background: var(--scalar-scrollbar-color, var(--default-theme-scrollbar-color));
background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color));
background-clip: content-box;
border: 3px solid transparent;
}
.scalar .custom-scroll::-webkit-scrollbar-thumb:active {
background: var(--scalar-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
background-clip: content-box;
border: 3px solid transparent;
}
@@ -330,4 +322,4 @@
.scalar .custom-scroll {
padding-right: 12px;
}
}
}
@@ -4,9 +4,7 @@ import assertNever from 'assert-never';
import { Checkbox, Emoji } from '@/components/primitives';
import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
@@ -14,7 +12,6 @@ import { TableRecordKV } from './Table';
import { getColumnAlignment } from './utils';
import { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { FileIcon } from '../FileIcon';
/**
* Render the value for a column in a record.
@@ -128,41 +125,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return (
<Tag className={tcls('text-base')}>
{files.filter(filterOutNullable).map((ref, index) => {
const contentType = ref.file
? getSimplifiedContentType(ref.file.contentType)
: null;
return (
<StyledLink
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
>
{contentType === 'image' ? (
<Image
style={['max-h-[1lh]', 'h-[1lh]']}
alt={ref.text}
sizes={[{ width: 24 }]}
sources={{
light: {
src: ref.href,
size: {
width: 24,
height: 24,
},
},
}}
priority="lazy"
/>
) : (
<FileIcon contentType={contentType} />
)}
{ref.text}
</StyledLink>
);
})}
{files.filter(filterOutNullable).map((file, index) => (
<StyledLink key={index} href={file.href}>
{file.text}
</StyledLink>
))}
</Tag>
);
case 'content-ref': {
@@ -12,12 +12,14 @@ 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 ? 'large:flex-column' : null,
block.data.fullWidth ? ['max-w-full', 'large:flex-column'] : null,
)}
>
{records.map((record) => {
+26 -1
View File
@@ -14,6 +14,7 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
const tableWrapper = columnsOverThreshold
? [
// has over X columns
'w-full',
'overflow-x-auto',
'overflow-y-hidden',
'mx-auto',
@@ -21,8 +22,32 @@ 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',
],
]
: ['overflow-x-auto', 'overflow-y-hidden', 'mx-auto'];
: [
'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',
],
];
const tableTR = columnsOverThreshold
? ['[&>*+*]:border-l', '[&>*]:px-4']
-1
View File
@@ -1,2 +1 @@
export * from './DocumentView';
export { createHighlightingContext } from './CodeBlock';
+2 -2
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import { CustomizationSettings, 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 | SiteCustomizationSettings;
customization: CustomizationSettings;
}) {
const { context, customization } = props;
@@ -4,15 +4,18 @@ import { tcls } from '@/lib/tailwind';
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
const { space, spaces } = props;
export function CollectionSpacesDropdown(props: {
space: Space;
collection: Collection;
collectionSpaces: Space[];
}) {
const { space, collectionSpaces } = props;
return (
<Dropdown
button={(buttonProps) => (
<div
{...buttonProps}
data-testid="space-dropdown-button"
className={tcls(
'justify-self-start',
'flex',
@@ -29,7 +32,7 @@ export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
)}
>
<DropdownMenu>
{spaces.map((otherSpace) => (
{collectionSpaces.map((otherSpace) => (
<DropdownMenuItem
key={otherSpace.id}
href={otherSpace.urls.published ?? otherSpace.urls.app}
+6 -12
View File
@@ -1,10 +1,4 @@
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import React from 'react';
import { t } from '@/intl/server';
@@ -19,11 +13,11 @@ import { SearchButton } from '../Search';
*/
export function CompactHeader(props: {
space: Space;
parent: Site | Collection | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
}) {
const { space, parent, customization } = props;
const { space, collection, customization } = props;
return (
<div
@@ -40,7 +34,7 @@ export function CompactHeader(props: {
)}
>
<div className={tcls('flex-grow-0', 'mt-5')}>
<HeaderLogo parent={parent} space={space} customization={customization} />
<HeaderLogo collection={collection} space={space} customization={customization} />
</div>
<div
className={tcls(
+18 -17
View File
@@ -1,10 +1,4 @@
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react';
@@ -13,29 +7,26 @@ 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;
parent: Site | Collection | null;
spaces: Space[];
collection: Collection | null;
collectionSpaces: Space[];
context: ContentRefContext;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
withTopHeader?: boolean;
}) {
const { context, space, parent, spaces, customization, withTopHeader } = props;
const { context, space, collection, collectionSpaces, customization, withTopHeader } = props;
const isCustomizationDefault =
customization.header.preset === CustomizationHeaderPreset.Default;
const isMultiVariants =
parent?.object === 'collection' ||
(parent && parent.object === 'site' && spaces.length > 1);
return (
<header
@@ -75,9 +66,19 @@ export function Header(props: {
CONTAINER_STYLE,
)}
>
<HeaderLogo parent={parent} space={space} customization={customization} />
<HeaderLogo
collection={collection}
space={space}
customization={customization}
/>
<span>
{isMultiVariants ? <SpacesDropdown space={space} spaces={spaces} /> : null}
{collection ? (
<CollectionSpacesDropdown
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
/>
) : null}
</span>
<HeaderLinks>
{customization.header.links.map((link, index) => {
+1 -2
View File
@@ -3,7 +3,6 @@ import {
CustomizationHeaderLink,
CustomizationSettings,
CustomizationHeaderPreset,
SiteCustomizationSettings,
} from '@gitbook/api';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -21,7 +20,7 @@ import { Link } from '../primitives';
export async function HeaderLink(props: {
context: ContentRefContext;
link: CustomizationHeaderLink;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
}) {
const { context, link, customization } = props;
+5 -12
View File
@@ -1,11 +1,4 @@
import {
Collection,
CustomizationHeaderPreset,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { Collection, CustomizationHeaderPreset, CustomizationSettings, Space } from '@gitbook/api';
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
import { Image } from '@/components/utils';
@@ -15,9 +8,9 @@ import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
interface HeaderLogoProps {
parent: Site | Collection | null;
collection: Collection | null;
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
}
/**
@@ -93,7 +86,7 @@ export function HeaderLogo(props: HeaderLogoProps) {
}
function LogoFallback(props: HeaderLogoProps) {
const { parent, space, customization } = props;
const { collection, space, customization } = props;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return (
@@ -145,7 +138,7 @@ function LogoFallback(props: HeaderLogoProps) {
: 'text-header-link',
)}
>
{parent ? parent.title : customization.title ?? space.title}
{collection ? collection.title : customization.title ?? space.title}
</h1>
</>
);
+2 -8
View File
@@ -2,13 +2,7 @@ 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,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import React from 'react';
import urlJoin from 'url-join';
@@ -27,7 +21,7 @@ import { PageFeedbackForm } from '../PageFeedback';
*/
export async function PageAside(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
page: RevisionPageDocument;
document: JSONDocument | null;
context: ContentRefContext;
+3 -11
View File
@@ -1,10 +1,4 @@
import {
CustomizationSettings,
JSONDocument,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
@@ -19,14 +13,14 @@ import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { TrackPageView } from './TrackPageView';
import { DocumentView, createHighlightingContext } from '../DocumentView';
import { DocumentView } from '../DocumentView';
import { PageFeedbackForm } from '../PageFeedback';
import { DateRelative } from '../primitives';
export function PageBody(props: {
space: Space;
contentTarget: ContentTarget;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
page: RevisionPageDocument;
document: JSONDocument | null;
context: ContentRefContext;
@@ -38,7 +32,6 @@ export function PageBody(props: {
const asFullWidth = document ? hasFullWidthBlock(document) : false;
const language = getSpaceLanguage(customization);
const updatedAt = page.updatedAt ?? page.createdAt;
const shouldHighlightCode = createHighlightingContext();
return (
<>
@@ -80,7 +73,6 @@ export function PageBody(props: {
contentRefContext: context,
resolveContentRef: (ref, options) =>
resolveContentRef(ref, context, options),
shouldHighlightCode,
}}
/>
) : (
@@ -1,12 +1,6 @@
import ChevronLeft from '@geist-ui/icons/chevronLeft';
import ChevronRight from '@geist-ui/icons/chevronRight';
import {
CustomizationSettings,
Revision,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { CustomizationSettings, Revision, RevisionPageDocument, Space } from '@gitbook/api';
import React from 'react';
import { t, getSpaceLanguage } from '@/intl/server';
@@ -21,7 +15,7 @@ import { Link } from '../primitives';
*/
export function PageFooterNavigation(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
pages: Revision['pages'];
page: RevisionPageDocument;
}) {
+13 -6
View File
@@ -1,7 +1,6 @@
'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';
@@ -21,7 +20,7 @@ interface SearchModalProps {
spaceId: string;
revisionId: string;
spaceTitle: string;
parent: Site | Collection | null;
collectionId: string | null;
withAsk: boolean;
}
@@ -136,8 +135,16 @@ function SearchModalBody(
onClose: (to?: string) => void;
},
) {
const { spaceId, revisionId, spaceTitle, withAsk, parent, state, onChangeQuery, onClose } =
props;
const {
spaceId,
revisionId,
spaceTitle,
withAsk,
collectionId,
state,
onChangeQuery,
onClose,
} = props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
@@ -237,7 +244,7 @@ function SearchModalBody(
ref={resultsRef}
spaceId={spaceId}
revisionId={revisionId}
parent={state.global ? parent : null}
collectionId={state.global ? collectionId : null}
query={state.query}
withAsk={withAsk}
onSwitchToAsk={() => {
@@ -249,7 +256,7 @@ function SearchModalBody(
}}
onClose={onClose}
>
{parent && state.query ? (
{collectionId && state.query ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
</SearchResults>
+7 -7
View File
@@ -1,4 +1,3 @@
import { Collection, Site } from '@gitbook/api';
import assertNever from 'assert-never';
import React from 'react';
@@ -12,7 +11,7 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
import {
getRecommendedQuestions,
OrderedComputedResult,
searchParentContent,
searchCollectionContent,
searchSpaceContent,
} from './server-actions';
import { Loading } from '../primitives';
@@ -40,14 +39,15 @@ export const SearchResults = React.forwardRef(function SearchResults(
query: string;
spaceId: string;
revisionId: string;
parent: Site | Collection | null;
collectionId: string | null;
withAsk: boolean;
onSwitchToAsk: () => void;
onClose: (to?: string) => void;
},
ref: React.Ref<SearchResultsRef>,
) {
const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk, onClose } = props;
const { children, query, spaceId, revisionId, collectionId, 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 (parent
? searchParentContent(parent, query)
const fetchedResults = await (collectionId
? searchCollectionContent(collectionId, 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, parent, withAsk]);
}, [query, spaceId, revisionId, collectionId, withAsk]);
// Scroll to the active result.
React.useEffect(() => {
+8 -39
View File
@@ -1,16 +1,7 @@
'use server';
import {
Collection,
RevisionPage,
SearchAIAnswer,
SearchPageResult,
Site,
Space,
} from '@gitbook/api';
import { headers } from 'next/headers';
import { RevisionPage, SearchAIAnswer, SearchPageResult, Space } from '@gitbook/api';
import { getContentPointer } from '@/app/(space)/fetch';
import { streamResponse } from '@/lib/actions';
import * as api from '@/lib/api';
import { absoluteHref, pageHref } from '@/lib/links';
@@ -64,41 +55,20 @@ export async function searchSpaceContent(
}
/**
* Server action to search content in a parent (site or collection)
* Server action to search content in a collection
*/
export async function searchParentContent(
parent: Site | Collection,
export async function searchCollectionContent(
collectionId: string,
query: string,
): Promise<OrderedComputedResult[]> {
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,
const [data, collectionSpaces] = await Promise.all([
api.searchCollectionContent(collectionId, query),
api.getCollectionSpaces(collectionId),
]);
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 = spaces.find((space) => space.id === spaceItem.id);
const space = collectionSpaces.find((space) => space.id === spaceItem.id);
return spaceItem.pages.map((item) => transformPageResult(item, space));
})
.flat(2);
@@ -161,7 +131,6 @@ function transformAnswer(
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => false,
}}
style={['space-y-5']}
/>
+10 -12
View File
@@ -5,8 +5,6 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -28,9 +26,9 @@ export function SpaceLayout(props: {
content: ContentPointer;
contentTarget: ContentTarget;
space: Space;
parent: Site | Collection | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
pages: Revision['pages'];
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
children: React.ReactNode;
@@ -38,8 +36,8 @@ export function SpaceLayout(props: {
const {
space,
contentTarget,
parent,
spaces,
collection,
collectionSpaces,
content,
pages,
customization,
@@ -61,8 +59,8 @@ export function SpaceLayout(props: {
<Header
withTopHeader={withTopHeader}
space={space}
parent={parent}
spaces={spaces}
collection={collection}
collectionSpaces={collectionSpaces}
context={contentRefContext}
customization={customization}
/>
@@ -91,8 +89,8 @@ export function SpaceLayout(props: {
withTopHeader ? null : (
<CompactHeader
space={space}
parent={parent}
spaces={spaces}
collection={collection}
collectionSpaces={collectionSpaces}
customization={customization}
/>
)
@@ -116,7 +114,7 @@ export function SpaceLayout(props: {
revisionId={contentTarget.revisionId}
spaceTitle={customization.title ?? space.title}
withAsk={customization.aiSearch.enabled}
parent={parent && spaces.length > 1 ? parent : null}
collectionId={collection && collectionSpaces.length > 1 ? collection.id : null}
/>
</React.Suspense>
</>
@@ -3,7 +3,6 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -17,7 +16,7 @@ import { Trademark } from './Trademark';
export function TableOfContents(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: CustomizationSettings;
content: ContentPointer;
context: ContentRefContext;
pages: Revision['pages'];
@@ -75,7 +74,7 @@ export function TableOfContents(props: {
'dark:group-hover:[&::-webkit-scrollbar-thumb]:bg-light/3',
'navigation-open:flex', // can be auto height animated as such https://stackoverflow.com/a/76944290
'lg:-ml-5',
customization.trademark.enabled ? 'lg:pb-20' : 'lg:pb-4',
customization.trademark.enabled ? 'lg:pb-16' : 'lg:pb-4',
)}
>
<PagesList
+6 -16
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import { CustomizationSettings, Space } from '@gitbook/api';
import { t, getSpaceLanguage } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
@@ -8,10 +8,7 @@ import { IconLogo } from '../icons/IconLogo';
/**
* Trademark link to the GitBook.
*/
export function Trademark(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
export function Trademark(props: { space: Space; customization: CustomizationSettings }) {
return (
<div
className={tcls(
@@ -57,10 +54,7 @@ export function Trademark(props: {
/**
* Trademark link to the GitBook.
*/
export function TrademarkLink(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
export function TrademarkLink(props: { space: Space; customization: CustomizationSettings }) {
const { space, customization } = props;
const language = getSpaceLanguage(customization);
@@ -84,20 +78,16 @@ export function TrademarkLink(props: {
'flex-row',
'items-center',
'hover:bg-dark/1',
'bg-light',
'dark:bg-dark',
'px-4',
'py-4',
'rounded-lg',
'py-2',
'rounded-md',
'straight-corners:rounded-none',
'hover:backdrop-blur-sm',
'lg:ring-0',
'tracking-[-0.016em]',
'dark:hover:bg-light/1',
'dark:ring-light/1',
'border',
'border-dark/2',
'dark:border-light/2',
'dark:font-normal',
)}
>
<IconLogo className={tcls('w-5', 'h-5', 'mr-3')} />
+3 -6
View File
@@ -1,16 +1,14 @@
import { ClassValue, tcls } from '@/lib/tailwind';
import { tcls } from '@/lib/tailwind';
import { Link, LinkProps } from '../primitives/Link';
/**
* Styled version of Link component.
*/
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
export function StyledLink(props: LinkProps) {
return (
<Link
{...rest}
{...props}
className={tcls(
'underline',
'underline-offset-2',
@@ -18,7 +16,6 @@ export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValu
'text-primary',
'hover:text-primary-700',
'transition-colors',
style,
)}
>
{props.children}
+2 -4
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api';
import { CustomizationSettings } from '@gitbook/api';
import { languages, TranslationLanguage } from './translations';
@@ -7,9 +7,7 @@ export * from './translate';
/**
* Create the translation context for a space to use in the server components.
*/
export function getSpaceLanguage(
customization: CustomizationSettings | SiteCustomizationSettings,
): TranslationLanguage {
export function getSpaceLanguage(customization: CustomizationSettings): TranslationLanguage {
const fallback = languages.en;
const { locale } = customization.internationalization;
+5 -260
View File
@@ -9,10 +9,8 @@ import {
HttpResponse,
List,
PublishedContentLookup,
PublishedSiteContentLookup,
RequestRenderIntegrationUI,
RevisionFile,
SiteCustomizationSettings,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { headers } from 'next/headers';
@@ -37,18 +35,6 @@ 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.
*/
@@ -123,7 +109,7 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
}
export type PublishedContentWithCache =
| ((PublishedContentLookup | PublishedSiteContentLookup) & {
| (PublishedContentLookup & {
cacheMaxAge?: number;
cacheTags?: string[];
})
@@ -595,240 +581,6 @@ 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.
*/
@@ -1000,11 +752,11 @@ export const searchSpaceContent = cache(
);
/**
* Search content accross all spaces in a parent (site or collection).
* Search content accross all spaces in a collection.
*/
export const searchParentContent = cache(
'api.searchParentContent',
async (parentId: string, query: string, options: CacheFunctionOptions) => {
export const searchCollectionContent = cache(
'api.searchCollectionContent',
async (collectionId: string, query: string, options: CacheFunctionOptions) => {
const response = await api().search.searchContent(
{ query },
{
@@ -1082,11 +834,6 @@ export function getAPICacheTag(
| {
tag: 'synced-block';
syncedBlock: string;
}
// All data related to a site
| {
tag: 'site';
site: string;
},
): string {
switch (spec.tag) {
@@ -1098,8 +845,6 @@ 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,12 +108,14 @@ 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,
@@ -125,15 +127,20 @@ 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);
}
@@ -173,9 +180,11 @@ 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;
}
-25
View File
@@ -1,25 +0,0 @@
export type SimplifiedFileType = 'image' | 'pdf' | 'archive';
/**
* Get a simplified content type for the given mime type.
*/
export function getSimplifiedContentType(mimeType: string): SimplifiedFileType | null {
if (mimeType.startsWith('image')) {
return 'image';
}
switch (mimeType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ import 'server-only';
import { noCacheFetchOptions } from '@/lib/cache/http';
import { rootUrl } from './links';
import { getImageAPIUrl } from './urls';
export interface CloudflareImageJsonFormat {
width: number;
@@ -77,7 +76,7 @@ export async function getResizedImageURL(
return (options) => {
const url = new URL('/~gitbook/image', rootUrl());
url.searchParams.set('url', getImageAPIUrl(input));
url.searchParams.set('url', input);
if (options.width) {
url.searchParams.set('width', options.width.toString());
-57
View File
@@ -1,57 +0,0 @@
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,6 +1,5 @@
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';
@@ -11,7 +10,6 @@ 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)
+5 -11
View File
@@ -1,4 +1,4 @@
import { Collection, ContentVisibility, Site, SiteVisibility, Space } from '@gitbook/api';
import { Collection, ContentVisibility, Space } from '@gitbook/api';
import { headers } from 'next/headers';
/**
@@ -6,10 +6,10 @@ import { headers } from 'next/headers';
*/
export function shouldIndexSpace({
space,
parent,
collection,
}: {
space: Space;
parent: Site | Collection | null;
collection: Collection | null;
}) {
const headerSet = headers();
@@ -28,18 +28,12 @@ export function shouldIndexSpace({
return false;
}
if (parent && parent.object === 'site') {
return shouldIndexVisibility(parent.visibility);
}
if (space.visibility === ContentVisibility.InCollection) {
return parent && parent.object === 'collection'
? shouldIndexVisibility(parent.visibility)
: false;
return collection ? shouldIndexVisibility(collection.visibility) : false;
}
return shouldIndexVisibility(space.visibility);
}
function shouldIndexVisibility(visibility: ContentVisibility | SiteVisibility) {
function shouldIndexVisibility(visibility: ContentVisibility) {
return visibility === ContentVisibility.Public;
}
-18
View File
@@ -65,21 +65,3 @@ export function getPDFUrlSearchParams(
return searchParams;
}
/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
* the token in the image API.
*/
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
/**
* Prepare a URL for the GitBook Open Image API.
*/
export function getImageAPIUrl(url: string): string {
return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
}
export function parseImageAPIURL(url: string): string {
return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
}
+4 -21
View File
@@ -2,7 +2,6 @@ import { it, describe, expect } from 'bun:test';
import { NextRequest } from 'next/server';
import {
VisitorAuthCookieValue,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
getVisitorAuthToken,
@@ -18,18 +17,14 @@ describe('getVisitorAuthToken', () => {
const request = nextRequest('https://example.com', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
});
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
});
it('should return the token from the cookie root basepath for a sub-path', () => {
const request = nextRequest('https://example.com/hello/world', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
});
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
});
it('should return the closest token from the path', () => {
@@ -39,9 +34,7 @@ describe('getVisitorAuthToken', () => {
value: getVisitorAuthCookieValue('/hello/', '123'),
},
});
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
});
it('should return the token from the cookie in a collection type url', () => {
@@ -50,9 +43,7 @@ describe('getVisitorAuthToken', () => {
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
},
});
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
});
it('should return undefined if no cookie and no query param', () => {
@@ -61,14 +52,6 @@ describe('getVisitorAuthToken', () => {
});
});
function assertVisitorAuthCookieValue(value: unknown): asserts value is VisitorAuthCookieValue {
if (value && typeof value === 'object' && 'token' in value) {
return;
}
throw new Error('Expected a VisitorAuthCookieValue');
}
function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) {
const nextUrl = new URL(url);
// @ts-ignore
+11 -20
View File
@@ -16,10 +16,7 @@ export type VisitorAuthCookieValue = {
* Get the visitor authentication token for the request. This token can either be in the
* query parameters or stored as a cookie.
*/
export function getVisitorAuthToken(
request: NextRequest,
url: URL,
): string | VisitorAuthCookieValue | undefined {
export function getVisitorAuthToken(request: NextRequest, url: URL): string | undefined {
return url.searchParams.get(VISITOR_AUTH_PARAM) ?? getVisitorAuthTokenFromCookies(request, url);
}
@@ -71,10 +68,7 @@ function getUrlBasePathCombinations(url: URL): string[] {
* checking all cookies for a matching "visitor authentication cookie" and returning the
* best possible match for the current URL.
*/
function getVisitorAuthTokenFromCookies(
request: NextRequest,
url: URL,
): VisitorAuthCookieValue | undefined {
function getVisitorAuthTokenFromCookies(request: NextRequest, url: URL): string | undefined {
const urlBasePaths = getUrlBasePathCombinations(url);
// Try to find a visitor authentication token for the current URL. The request
// for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/`
@@ -96,17 +90,14 @@ function getVisitorAuthTokenFromCookies(
function findVisitorAuthCookieForBasePath(
request: NextRequest,
basePath: string,
): VisitorAuthCookieValue | undefined {
return Array.from(request.cookies).reduce<VisitorAuthCookieValue | undefined>(
(acc, [name, cookie]) => {
if (name === getVisitorAuthCookieName(basePath)) {
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
if (value.basePath === basePath) {
acc = value;
}
): string | undefined {
return Array.from(request.cookies).reduce<string | undefined>((acc, [name, cookie]) => {
if (name === getVisitorAuthCookieName(basePath)) {
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
if (value.basePath === basePath) {
acc = value.token;
}
return acc;
},
undefined,
);
}
return acc;
}, undefined);
}
+19 -83
View File
@@ -1,7 +1,6 @@
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';
@@ -15,14 +14,12 @@ import {
withAPI,
getSpaceLayoutData,
DEFAULT_API_ENDPOINT,
getCurrentSiteLayoutData,
} from '@/lib/api';
import { race } from '@/lib/async';
import { buildVersion } from '@/lib/build';
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware';
import {
VisitorAuthCookieValue,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
getVisitorAuthToken,
@@ -75,14 +72,6 @@ export type LookupResult = PublishedContentWithCache & {
cookies?: LookupCookies;
};
interface ContentAPITokenPayload {
organization: string;
spaces: string[];
collection?: string;
site?: string;
siteSpace?: string;
}
/**
* Middleware to lookup the space to render.
* It takes as input a request with an URL, and a set of headers:
@@ -140,10 +129,8 @@ export async function middleware(request: NextRequest) {
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
const normalizedVA = normalizeVisitorAuthURL(normalized);
// We don't do this redirect when working locally as we can't write cookies reliably on localhost.
// On localhost we just keep the jwt_token in the URL.
if (url.hostname !== 'localhost' && normalizedVA.toString() !== normalized.toString()) {
if (normalizedVA.toString() !== normalized.toString()) {
console.log(`redirecting to ${normalizedVA.toString()}`);
return writeCookies(NextResponse.redirect(normalizedVA.toString()), resolved.cookies);
}
@@ -152,12 +139,13 @@ 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.
const rewritePathname = normalizePathname(encodePathname(resolved.pathname));
console.log(`${request.method} (${resolved.space}) ${rewritePathname}`);
// Resolution might have changed the API endpoint
apiEndpoint = resolved.apiEndpoint ?? apiEndpoint;
@@ -179,13 +167,7 @@ export async function middleware(request: NextRequest) {
}),
);
const { scripts } = await ('site' in resolved
? getCurrentSiteLayoutData({
organizationId: resolved.organization,
siteId: resolved.site,
siteSpaceId: resolved.siteSpace,
})
: getSpaceLayoutData(resolved.space));
const { scripts } = await getSpaceLayoutData(resolved.space);
return getContentSecurityPolicy(scripts, nonce);
},
);
@@ -202,13 +184,6 @@ 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);
}
@@ -246,10 +221,6 @@ 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');
@@ -486,20 +457,10 @@ async function lookupSpaceInMultiIdMode(request: NextRequest, url: URL): Promise
};
}
const { organization, site, siteSpace } = jwt.decode(apiToken) as ContentAPITokenPayload;
const siteLookupResult =
typeof organization === 'string' && organization && typeof site === 'string' && site
? {
organization,
site,
...(typeof siteSpace === 'string' && siteSpace ? { siteSpace } : {}),
}
: {};
return {
space: spaceId,
changeRequest: changeRequestId,
revision: revisionId,
...siteLookupResult,
basePath: normalizePathname(basePathParts.join('/')),
pathname: normalizePathname(pathSegments.join('/')),
apiToken,
@@ -589,7 +550,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
*/
async function lookupSpaceByAPI(
lookupURL: URL,
visitorAuthToken: ReturnType<typeof getVisitorAuthToken>,
visitorAuthToken: string | undefined,
): Promise<LookupResult> {
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
@@ -599,17 +560,9 @@ async function lookupSpaceByAPI(
);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const data = await getPublishedContentByUrl(
alternative.url,
typeof visitorAuthToken === 'undefined'
? undefined
: typeof visitorAuthToken === 'string'
? visitorAuthToken
: visitorAuthToken.token,
{
signal,
},
);
const data = await getPublishedContentByUrl(alternative.url, visitorAuthToken, {
signal,
});
if ('error' in data) {
if (alternative.primary) {
@@ -659,9 +612,6 @@ 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;
});
@@ -681,36 +631,22 @@ async function lookupSpaceByAPI(
*/
function getLookupResultForVisitorAuth(
basePath: string,
visitorAuthToken: string | VisitorAuthCookieValue,
visitorAuthToken: string,
): Partial<LookupResult> {
return {
// No caching for content served with visitor auth
cacheMaxAge: undefined,
cacheTags: [],
cookies: {
/**
* If the visitorAuthToken has been retrieved from a cookie, we set it back only
* if the basePath matches the current one. This is to avoid setting cookie for
* different base paths.
*/
...(typeof visitorAuthToken === 'string' || visitorAuthToken.basePath === basePath
? {
[getVisitorAuthCookieName(basePath)]: {
value: getVisitorAuthCookieValue(
basePath,
typeof visitorAuthToken === 'string'
? visitorAuthToken
: visitorAuthToken.token,
),
options: {
httpOnly: true,
sameSite: 'none',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60,
},
},
}
: {}),
[getVisitorAuthCookieName(basePath)]: {
value: getVisitorAuthCookieValue(basePath, visitorAuthToken),
options: {
httpOnly: true,
sameSite: 'none',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60,
},
},
},
};
}