mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 23:25:16 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f7ac7ca08 | |||
| f1440ca7ef | |||
| 119c22af11 | |||
| 7ef2eeed13 | |||
| 4005895337 | |||
| bf450e5cc7 | |||
| 4c12722c0a | |||
| ac124a7ea5 | |||
| 1de25056fa | |||
| bf3d2032ab | |||
| bfb2342ab3 | |||
| 76cbc3c10f | |||
| c80c76d50d | |||
| 263c89391e | |||
| ebf742e81a | |||
| 685317f508 | |||
| 0c224861e3 |
+73
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: /`]),
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
} {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export * from './DocumentView';
|
||||
export { createHighlightingContext } from './CodeBlock';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
-7
@@ -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}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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']}
|
||||
/>
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user