mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 20:03:16 +00:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ee1a7ac4 | |||
| a7af3ca7bc | |||
| cda08a9843 | |||
| 7588774237 | |||
| 683373b263 | |||
| 673ec8fb5d | |||
| 63bacaa671 | |||
| e3a3d6a505 | |||
| 175171dda1 | |||
| a4b63b88c8 | |||
| c3675fdfe7 | |||
| 846de0e1a9 | |||
| 2fa08519b6 | |||
| 065627060b | |||
| 2982b6a808 | |||
| c3f56ecd40 | |||
| fe9e6c1afd | |||
| 73394ae929 | |||
| c754fc9ff4 | |||
| 1ca6208057 | |||
| cdfae4d4df | |||
| 3d9aefaf25 | |||
| c3da5469ca | |||
| 79f2d8ba76 | |||
| 7b1bdb92a6 | |||
| 5beb2dbd69 | |||
| d19e3507cc | |||
| 152aad2c8f | |||
| 3ee149d87d | |||
| 0ccc5034c4 | |||
| 95e8772eab | |||
| 9675290e61 | |||
| c7ca921d7f | |||
| 2d4ddc9d95 | |||
| 65d77295e5 | |||
| 662df6f9aa | |||
| e866080867 | |||
| dce39c4c0d | |||
| 7b02f72660 | |||
| 74392eccee | |||
| 184d616701 | |||
| 9b78692124 | |||
| f67b143851 | |||
| f65e3f5fbb | |||
| ac492e094d | |||
| f6823e92bb | |||
| 9437aa25e8 | |||
| 77d64d06b9 | |||
| 1ae1715993 | |||
| c1193fd2cd | |||
| 60a9b95a25 | |||
| 586e66d03a | |||
| 39d88867ae | |||
| f98b7b2b00 | |||
| abf7ec35a6 | |||
| cb3c1f9f78 | |||
| ce890c609c | |||
| 5b933777e9 | |||
| 8d1c349ed1 | |||
| 4026fc1339 | |||
| 4b9f206e50 | |||
| f6b75e7dbd | |||
| 949f25cfa6 | |||
| 0b44528a1c | |||
| ea4f70ec88 | |||
| 768b555f66 | |||
| 96d5537087 | |||
| 28c59d81e7 | |||
| 3d147902d6 | |||
| a6adfaaf27 | |||
| d29a7a6f15 | |||
| 7244a0419a | |||
| c338bca2ba | |||
| 4f59d18bfc | |||
| b87ec84fd5 | |||
| e793a177f6 | |||
| 9573535eba | |||
| 087a783574 | |||
| 3b0f5d82d5 | |||
| 69414bdc52 | |||
| adaf0deb5b | |||
| d919d2ccfa | |||
| 2d15453e51 | |||
| ca4518d068 | |||
| cb454a3c4e | |||
| e852206573 | |||
| 7e79e14a62 | |||
| 4d476a8cbf |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add automatic color contrast in site header, restyle search button
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add navigation tabs for sections
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Support resolution of new site URLs with sections
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Allow selection of server url
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for searching results in a sections site
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Update ogimage with new design
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix flickering when displaying an "Ask" answer with code blocks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Added support for new Reusable Content block.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improving the look and feel of new section tabs
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"packageManager": "bun@1.1.18",
|
||||
"patchedDependencies": {
|
||||
"@vercel/next@4.3.6": "patches/@vercel%2Fnext@4.3.6.patch"
|
||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -32,4 +32,5 @@ screenshots/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated public files
|
||||
/public/~gitbook/static/
|
||||
/public/~gitbook/static/*
|
||||
!/public/~gitbook/static/images
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLocale,
|
||||
PublishedSiteContentLookup,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
@@ -15,7 +14,7 @@ import { getContentTestURL } from '../tests/utils';
|
||||
|
||||
interface Test {
|
||||
name: string;
|
||||
url: string | (() => Promise<string>); // URL to visit for testing
|
||||
url: string; // URL to visit for testing
|
||||
run?: (page: Page) => Promise<unknown>; // The test to run
|
||||
fullPage?: boolean; // Whether the test should be fullscreened during testing
|
||||
screenshot?: false; // Should a screenshot be stored
|
||||
@@ -396,6 +395,10 @@ const testCases: TestsCase[] = [
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Stepper',
|
||||
url: 'blocks/stepper',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -496,7 +499,7 @@ const testCases: TestsCase[] = [
|
||||
tests: [
|
||||
{
|
||||
name: 'Valid link',
|
||||
url: 'TGs8PkF4GWVtbmPnWhYL/',
|
||||
url: 'thDznyWXCeEoT55WB7HC/',
|
||||
},
|
||||
{
|
||||
name: 'Invalid link',
|
||||
@@ -764,32 +767,6 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'open.gitbook.com',
|
||||
baseUrl: 'https://open.gitbook.com/',
|
||||
tests: [
|
||||
{
|
||||
name: 'GitBook Docs',
|
||||
url: async () => {
|
||||
const res = await fetch(
|
||||
`https://api.gitbook.com/v1/urls/published?url=https://docs.gitbook.com`,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to get published URL');
|
||||
}
|
||||
|
||||
const published = await res.json<PublishedSiteContentLookup>();
|
||||
if (!('site' in published)) {
|
||||
throw new Error('Expected site for published URL');
|
||||
}
|
||||
|
||||
return `~site/${published.site}?token=${published.apiToken}`;
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
@@ -797,9 +774,7 @@ for (const testCase of testCases) {
|
||||
for (const testEntry of testCase.tests) {
|
||||
const testFn = testEntry.only ? test.only : test;
|
||||
testFn(testEntry.name, async ({ page, baseURL }) => {
|
||||
const testEntryUrl =
|
||||
typeof testEntry.url === 'string' ? testEntry.url : await testEntry.url();
|
||||
const contentUrl = new URL(testEntryUrl, testCase.baseUrl);
|
||||
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
||||
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
||||
await page.goto(url);
|
||||
if (testEntry.run) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "0.60.0",
|
||||
"@gitbook/api": "^0.72.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -41,7 +41,7 @@
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "^14.2.5",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.17.4",
|
||||
"object-hash": "^3.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
+4
-7
@@ -1,14 +1,11 @@
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
|
||||
import { getSiteLayoutData } from '@/lib/api';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
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 pointer = getSiteContentPointer();
|
||||
const { customization } = await getSiteLayoutData(pointer);
|
||||
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
+28
-20
@@ -3,6 +3,7 @@ import { Metadata, Viewport } from 'next';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { serverUrlCache } from '@/components/DocumentView/OpenAPI/ServerUrlCache';
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
||||
@@ -26,11 +27,14 @@ export default async function Page(props: {
|
||||
}) {
|
||||
const { params, searchParams } = props;
|
||||
|
||||
serverUrlCache.parse(searchParams);
|
||||
|
||||
const {
|
||||
content: contentPointer,
|
||||
contentTarget,
|
||||
sections,
|
||||
space,
|
||||
parent,
|
||||
site,
|
||||
customization,
|
||||
pages,
|
||||
page,
|
||||
@@ -65,22 +69,39 @@ export default async function Page(props: {
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
siteContext: 'siteId' in contentPointer ? contentPointer : null,
|
||||
siteContext: contentPointer,
|
||||
space,
|
||||
revisionId: contentTarget.revisionId,
|
||||
pages,
|
||||
page,
|
||||
};
|
||||
|
||||
const withSections = Boolean(sections && sections.length > 0);
|
||||
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||
|
||||
return (
|
||||
<>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
|
||||
) : null}
|
||||
<div className={tcls('flex', 'flex-row')}>
|
||||
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
|
||||
<div className="flex flex-row-reverse justify-end">
|
||||
{page.layout.outline ? (
|
||||
<PageAside
|
||||
space={space}
|
||||
site={site}
|
||||
customization={customization}
|
||||
page={page}
|
||||
document={document}
|
||||
withHeaderOffset={headerOffset}
|
||||
withFullPageCover={withFullPageCover}
|
||||
withPageFeedback={withPageFeedback}
|
||||
context={contentRefContext}
|
||||
/>
|
||||
) : null}
|
||||
<PageBody
|
||||
space={space}
|
||||
contentPointer={contentPointer}
|
||||
pointer={contentPointer}
|
||||
contentTarget={contentTarget}
|
||||
customization={customization}
|
||||
context={contentRefContext}
|
||||
@@ -91,19 +112,6 @@ export default async function Page(props: {
|
||||
withPageFeedback && !page.layout.outline
|
||||
}
|
||||
/>
|
||||
{page.layout.outline ? (
|
||||
<PageAside
|
||||
space={space}
|
||||
site={parent?.object === 'site' ? parent : undefined}
|
||||
customization={customization}
|
||||
page={page}
|
||||
document={document}
|
||||
withHeaderOffset={withTopHeader}
|
||||
withFullPageCover={withFullPageCover}
|
||||
withPageFeedback={withPageFeedback}
|
||||
context={contentRefContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<PageClientLayout />
|
||||
@@ -130,7 +138,7 @@ export async function generateMetadata({
|
||||
params: PagePathParams;
|
||||
searchParams: { fallback?: string };
|
||||
}): Promise<Metadata> {
|
||||
const { space, pages, page, customization, parent, ancestors } = await getPageDataWithFallback({
|
||||
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
|
||||
pagePathParams: params,
|
||||
searchParams,
|
||||
});
|
||||
@@ -140,7 +148,7 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
return {
|
||||
title: [page.title, getContentTitle(space, customization, parent)]
|
||||
title: [page.title, getContentTitle(space, customization, site ?? null)]
|
||||
.filter(Boolean)
|
||||
.join(' | '),
|
||||
description: page.description ?? '',
|
||||
@@ -154,7 +162,7 @@ export async function generateMetadata({
|
||||
],
|
||||
},
|
||||
robots:
|
||||
isSpaceIndexable({ space, parent }) && isPageIndexable(ancestors, page)
|
||||
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
|
||||
? 'index, follow'
|
||||
: 'noindex, nofollow',
|
||||
};
|
||||
+10
-8
@@ -18,7 +18,7 @@ import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||
import { fetchSpaceData } from '../fetch';
|
||||
import { fetchContentData } from '../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -35,11 +35,12 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
contentTarget,
|
||||
customization,
|
||||
pages,
|
||||
parent,
|
||||
site,
|
||||
spaces,
|
||||
ancestors,
|
||||
scripts,
|
||||
} = await fetchSpaceData();
|
||||
sections,
|
||||
} = await fetchContentData();
|
||||
|
||||
ReactDOM.preconnect(api().endpoint);
|
||||
if (assetsDomain) {
|
||||
@@ -64,8 +65,9 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
<SpaceLayout
|
||||
space={space}
|
||||
contentTarget={contentTarget}
|
||||
parent={parent}
|
||||
site={site}
|
||||
spaces={spaces}
|
||||
sections={sections}
|
||||
customization={customization}
|
||||
pages={pages}
|
||||
ancestors={ancestors}
|
||||
@@ -97,7 +99,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const { customization } = await fetchSpaceData();
|
||||
const { customization } = await fetchContentData();
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
@@ -108,11 +110,11 @@ export async function generateViewport(): Promise<Viewport> {
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { space, parent, customization } = await fetchSpaceData();
|
||||
const { space, site, customization } = await fetchContentData();
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
return {
|
||||
title: getContentTitle(space, customization, parent),
|
||||
title: getContentTitle(space, customization, site),
|
||||
generator: `GitBook (${buildVersion()})`,
|
||||
metadataBase: new URL(baseUrl()),
|
||||
icons: {
|
||||
@@ -133,7 +135,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
},
|
||||
],
|
||||
},
|
||||
robots: isSpaceIndexable({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
|
||||
robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
|
||||
+7
-14
@@ -3,32 +3,25 @@ import { NextRequest } from 'next/server';
|
||||
|
||||
import { getCollection, getSite, getSpace } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
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,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const parent =
|
||||
'siteId' in pointer
|
||||
? await getSite(pointer.organizationId, pointer.siteId)
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
const pointer = getSiteContentPointer();
|
||||
const [site, space] = await Promise.all([
|
||||
getSite(pointer.organizationId, pointer.siteId),
|
||||
getSpace(pointer.spaceId, pointer.siteShareKey),
|
||||
]);
|
||||
|
||||
const lines = [
|
||||
`User-agent: *`,
|
||||
'Disallow: /~gitbook/',
|
||||
...(isSpaceIndexable({ space, parent })
|
||||
...(isSpaceIndexable({ space, site })
|
||||
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
|
||||
: [`Disallow: /`]),
|
||||
];
|
||||
+7
-9
@@ -5,21 +5,17 @@ import { NextRequest } from 'next/server';
|
||||
import { getSpaceContentData } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Generate a sitemap.xml for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = getContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(
|
||||
pointer,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const pointer = getSiteContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
|
||||
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||
const urls = pages.map(({ page, depth }) => {
|
||||
@@ -88,10 +84,12 @@ function flattenPages(
|
||||
return [
|
||||
...(page.type === 'document' ? [{ page, depth }] : []),
|
||||
...page.pages.flatMap((child) =>
|
||||
child.type === 'link' ? [] : flattenPage(child, depth + 1),
|
||||
child.type === 'document' ? flattenPage(child, depth + 1) : [],
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
return rootPags.flatMap((page) => (page.type === 'link' ? [] : flattenPage(page, 0)));
|
||||
return rootPags.flatMap((page) =>
|
||||
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
|
||||
);
|
||||
}
|
||||
+7
-19
@@ -1,18 +1,11 @@
|
||||
import { ContentVisibility } from '@gitbook/api';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
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 { getCurrentSiteCustomization, getSite, getSpace } from '@/lib/api';
|
||||
import { getEmojiForCode } from '@/lib/emojis';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
@@ -42,20 +35,15 @@ export async function GET(req: NextRequest) {
|
||||
const options = getOptions(req.url);
|
||||
const size = SIZES[options.size];
|
||||
|
||||
const pointer = getContentPointer();
|
||||
const pointer = getSiteContentPointer();
|
||||
const spaceId = pointer.spaceId;
|
||||
|
||||
const [space, customization] = await Promise.all([
|
||||
getSpace(spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
|
||||
getSpace(spaceId, pointer.siteShareKey),
|
||||
getCurrentSiteCustomization(pointer),
|
||||
]);
|
||||
const parent =
|
||||
'siteId' in pointer
|
||||
? await getSite(pointer.organizationId, pointer.siteId)
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
const contentTitle = getContentTitle(space, customization, parent);
|
||||
const site = await getSite(pointer.organizationId, pointer.siteId);
|
||||
const contentTitle = getContentTitle(space, customization, site);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
@@ -0,0 +1,223 @@
|
||||
import { CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import colorContrast from 'postcss-color-contrast/js';
|
||||
import React from 'react';
|
||||
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Render the OpenGraph image for a space.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
|
||||
const { space, page, customization, site } = await fetchPageData(params);
|
||||
|
||||
if (customization.socialPreview.url) {
|
||||
// If user configured a custom social preview, we redirect to it.
|
||||
redirect(customization.socialPreview.url);
|
||||
}
|
||||
|
||||
// TODO: Support all fonts available in GitBook
|
||||
// Right now this is impossible since next/font/google does not expose the cached font file
|
||||
// Another option would be to use the Satori prop `loadAdditionalAsset` [example](https://github.com/vercel/satori/blob/main/playground/pages/index.tsx),
|
||||
// but this prop isn't (yet) exposed through `ImageResponse`.
|
||||
const interRegular = await fetch(
|
||||
new URL('../../../../../../fonts/Inter/Inter-Regular.ttf', import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
const interBold = await fetch(
|
||||
new URL('../../../../../../fonts/Inter/Inter-Bold.ttf', import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const theme = customization.themes.default;
|
||||
const useLightTheme = theme === 'light';
|
||||
|
||||
// We have no access to CSS variables, so we'll have to hardcode some values
|
||||
const baseColors = {
|
||||
light: '#ffffff',
|
||||
dark: '#111827',
|
||||
};
|
||||
|
||||
let colors = {
|
||||
background: baseColors[theme],
|
||||
gradient: customization.styling.primaryColor[theme],
|
||||
title: customization.styling.primaryColor[theme],
|
||||
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
|
||||
};
|
||||
|
||||
const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
|
||||
const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
|
||||
|
||||
let gridAsset = useLightTheme ? gridBlack : gridWhite;
|
||||
|
||||
switch (customization.header.preset) {
|
||||
case CustomizationHeaderPreset.Custom:
|
||||
colors = {
|
||||
background: customization.header.backgroundColor?.[theme] || colors.background,
|
||||
gradient: customization.header.linkColor?.[theme] || colors.gradient,
|
||||
title: customization.header.linkColor?.[theme] || colors.title,
|
||||
body: colorContrast(
|
||||
customization.header.backgroundColor?.[theme] || colors.background,
|
||||
[baseColors.light, baseColors.dark],
|
||||
),
|
||||
};
|
||||
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
|
||||
break;
|
||||
|
||||
case CustomizationHeaderPreset.Bold:
|
||||
colors = {
|
||||
background: customization.styling.primaryColor[theme],
|
||||
gradient: colorContrast(customization.styling.primaryColor[theme], [
|
||||
baseColors.light,
|
||||
baseColors.dark,
|
||||
]),
|
||||
title: colorContrast(customization.styling.primaryColor[theme], [
|
||||
baseColors.light,
|
||||
baseColors.dark,
|
||||
]),
|
||||
body: colorContrast(customization.styling.primaryColor[theme], [
|
||||
baseColors.light,
|
||||
baseColors.dark,
|
||||
]),
|
||||
};
|
||||
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
|
||||
break;
|
||||
}
|
||||
|
||||
const favicon = function () {
|
||||
if ('icon' in customization.favicon)
|
||||
return (
|
||||
<img
|
||||
src={customization.favicon.icon[theme]}
|
||||
width={40}
|
||||
height={40}
|
||||
tw={tcls('mr-4')}
|
||||
alt="Icon"
|
||||
/>
|
||||
);
|
||||
if ('emoji' in customization.favicon)
|
||||
return (
|
||||
<span tw={tcls('text-4xl', 'mr-4')}>
|
||||
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<img
|
||||
src={absoluteHref(
|
||||
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
|
||||
true,
|
||||
)}
|
||||
alt="Icon"
|
||||
width={40}
|
||||
height={40}
|
||||
tw={tcls('mr-4')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw={tcls(
|
||||
'justify-between',
|
||||
'p-20',
|
||||
'relative',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'flex',
|
||||
'flex-col',
|
||||
`bg-[${colors.background}]`,
|
||||
`text-[${colors.body}]`,
|
||||
)}
|
||||
>
|
||||
{/* Gradient */}
|
||||
<div
|
||||
tw={tcls('absolute', 'inset-0')}
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Grid */}
|
||||
<img
|
||||
tw={tcls('absolute', 'inset-0', 'w-[100vw]', 'h-[100vh]')}
|
||||
src={gridAsset}
|
||||
alt="Grid"
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
{customization.header.logo ? (
|
||||
<img
|
||||
alt="Logo"
|
||||
height={60}
|
||||
src={
|
||||
useLightTheme
|
||||
? customization.header.logo.light
|
||||
: customization.header.logo.dark
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div tw={tcls('flex')}>
|
||||
{favicon()}
|
||||
<h3 tw={tcls('text-4xl', 'my-0')}>
|
||||
{getContentTitle(space, customization, site ?? null)}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and description */}
|
||||
<div tw={tcls('flex', 'flex-col')}>
|
||||
<h1
|
||||
tw={tcls(
|
||||
'text-8xl',
|
||||
'my-0',
|
||||
'tracking-tight',
|
||||
'leading-none',
|
||||
'text-left',
|
||||
`text-[${colors.title}]`,
|
||||
'font-bold',
|
||||
)}
|
||||
>
|
||||
{page
|
||||
? page.title.length > 64
|
||||
? page.title.slice(0, 64) + '...'
|
||||
: page.title
|
||||
: 'Not found'}
|
||||
</h1>
|
||||
{page?.description && page?.title.length <= 64 ? (
|
||||
<h2 tw={tcls('text-4xl', 'mb-0', 'mt-8', 'w-[75%]', 'font-normal')}>
|
||||
{page.description.length > 164
|
||||
? page.description.slice(0, 164) + '...'
|
||||
: page.description}
|
||||
</h2>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interRegular,
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interBold,
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import {
|
||||
getRevisionPageByPath,
|
||||
getDocument,
|
||||
ContentTarget,
|
||||
getSiteData,
|
||||
getSite,
|
||||
getCurrentSiteCustomization,
|
||||
getSiteStructure,
|
||||
} from '@/lib/api';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
export interface PagePathParams {
|
||||
pathname?: string[];
|
||||
}
|
||||
|
||||
export interface PageIdParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
type SectionsList = { list: SiteSection[]; section: SiteSection };
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content layout.
|
||||
*/
|
||||
export async function fetchContentData() {
|
||||
const content = getSiteContentPointer();
|
||||
|
||||
const [{ space, contentTarget, pages, customization, scripts }, siteStructure] =
|
||||
await Promise.all([
|
||||
getSiteData(content),
|
||||
fetchSiteStructure({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey: content.siteShareKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
const site = siteStructure.site;
|
||||
|
||||
const siteSections =
|
||||
content.siteSectionId && siteStructure.sections
|
||||
? parseSiteSectionsList(content.siteSectionId, siteStructure.sections)
|
||||
: null;
|
||||
|
||||
const spaces =
|
||||
siteStructure.spaces ??
|
||||
(siteSections ? parseSpacesFromSiteSpaces(siteSections.section.siteSpaces) : []);
|
||||
|
||||
// we grab the space attached to the parent as it contains overriden customizations
|
||||
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space: spaceRelativeToParent ?? space,
|
||||
pages,
|
||||
sections: siteSections,
|
||||
site,
|
||||
spaces,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
|
||||
const section = sections.find((section) => section.id === siteSectionId);
|
||||
assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections');
|
||||
return { list: sections, section } satisfies SectionsList;
|
||||
}
|
||||
|
||||
function sectionIsDefined(section?: SiteSection): section is NonNullable<SiteSection> {
|
||||
return typeof section !== 'undefined' && section !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content.
|
||||
* Optimized to fetch in parallel as much as possible.
|
||||
*/
|
||||
export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const content = getSiteContentPointer();
|
||||
|
||||
const { space, contentTarget, pages, customization, scripts } = await getSiteData(content);
|
||||
const page = await resolvePage(contentTarget, pages, params);
|
||||
const [siteStructure, document] = await Promise.all([
|
||||
fetchSiteStructure({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey: content.siteShareKey,
|
||||
}),
|
||||
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
...page,
|
||||
...siteStructure,
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page from the params.
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(
|
||||
contentTarget: ContentTarget,
|
||||
pages: RevisionPage[],
|
||||
params: PagePathParams | PageIdParams,
|
||||
) {
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(pages, params.pageId);
|
||||
}
|
||||
|
||||
const rawPathname = getPathnameParam(params);
|
||||
const pathname = normalizePathname(rawPathname);
|
||||
|
||||
// When resolving a page, we use the lowercased pathname
|
||||
const page = resolvePagePath(pages, pathname);
|
||||
if (page) {
|
||||
return page;
|
||||
}
|
||||
|
||||
// If page can't be found, we try with the API, in case we have a redirect
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
//
|
||||
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||
if (rawPathname.length <= 512) {
|
||||
const resolved = await getRevisionPageByPath(
|
||||
contentTarget.spaceId,
|
||||
contentTarget.revisionId,
|
||||
rawPathname,
|
||||
);
|
||||
if (resolved) {
|
||||
return resolvePageId(pages, resolved.id);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the structure of an organization site.
|
||||
* This includes the site and its sections or spaces.
|
||||
*/
|
||||
async function fetchSiteStructure(args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
siteShareKey: string | undefined;
|
||||
}) {
|
||||
const { organizationId, siteId, siteShareKey } = args;
|
||||
const [orgSite, siteStructure, siteParentCustomizations] = await Promise.all([
|
||||
getSite(organizationId, siteId),
|
||||
getSiteStructure({ organizationId, siteId, siteShareKey }),
|
||||
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
|
||||
]);
|
||||
|
||||
const siteSections =
|
||||
siteStructure.type === 'sections' && siteStructure.structure
|
||||
? siteStructure.structure
|
||||
: null;
|
||||
const siteSpaces =
|
||||
siteStructure.type === 'siteSpaces' && siteStructure.structure
|
||||
? parseSpacesFromSiteSpaces(siteStructure.structure)
|
||||
: null;
|
||||
|
||||
// override the title with the customization title
|
||||
const site = {
|
||||
...orgSite,
|
||||
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
site,
|
||||
spaces: siteSpaces,
|
||||
sections: siteSections,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
|
||||
const spaces: Record<string, Space> = {};
|
||||
siteSpaces.forEach((siteSpace) => {
|
||||
spaces[siteSpace.space.id] = {
|
||||
...siteSpace.space,
|
||||
title: siteSpace.title ?? siteSpace.space.title,
|
||||
urls: {
|
||||
...siteSpace.space.urls,
|
||||
published: siteSpace.urls.published,
|
||||
},
|
||||
};
|
||||
});
|
||||
return Object.values(spaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page path from the params.
|
||||
*/
|
||||
export function getPathnameParam(params: PagePathParams): string {
|
||||
const { pathname } = params;
|
||||
return pathname ? pathname.map((part) => decodeURIComponent(part)).join('/') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL pathname into the format used in the revision page path.
|
||||
*/
|
||||
export function normalizePathname(pathname: string) {
|
||||
return pathname.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { getSiteLayoutData } from '@/lib/api';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
/**
|
||||
* Layout to be used for the site root. It fetches the customization data for the site
|
||||
* and initializes the CustomizationRootLayout with it.
|
||||
*/
|
||||
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
const pointer = getSiteContentPointer();
|
||||
const { customization } = await getSiteLayoutData(pointer);
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import React from 'react';
|
||||
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
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 url = new URL(space.urls.published ?? space.urls.app);
|
||||
|
||||
if (customization.socialPreview.url) {
|
||||
// If user configured a custom social preview, we redirect to it.
|
||||
redirect(customization.socialPreview.url);
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw="bg-gray-50 py-16 px-14"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<h2 tw="text-7xl font-bold tracking-tight text-left">
|
||||
{getContentTitle(space, customization, parent)}
|
||||
</h2>
|
||||
<div tw="flex flex-1">
|
||||
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
|
||||
</div>
|
||||
<div tw="flex">
|
||||
<p tw="text-4xl">
|
||||
{url.hostname + (url.pathname.length > 1 ? url.pathname : '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { ContentVisibility, RevisionPage, Space } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import {
|
||||
getCollectionSpaces,
|
||||
getCollection,
|
||||
ContentPointer,
|
||||
getRevisionPageByPath,
|
||||
getDocument,
|
||||
getSpaceData,
|
||||
ContentTarget,
|
||||
SiteContentPointer,
|
||||
getCurrentSiteData,
|
||||
getSite,
|
||||
getSiteSpaces,
|
||||
getCurrentSiteCustomization,
|
||||
} from '@/lib/api';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
|
||||
export interface PagePathParams {
|
||||
pathname?: string[];
|
||||
}
|
||||
|
||||
export interface PageIdParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current content pointer from the params.
|
||||
*/
|
||||
export function getContentPointer(): ContentPointer | SiteContentPointer {
|
||||
const headerSet = headers();
|
||||
const spaceId = headerSet.get('x-gitbook-content-space');
|
||||
if (!spaceId) {
|
||||
throw new Error(
|
||||
'getContentPointer is called outside the scope of a request processed by the middleware',
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
|
||||
if (!organizationId) {
|
||||
throw new Error('Missing site content headers');
|
||||
}
|
||||
|
||||
const siteContent: SiteContentPointer = {
|
||||
siteId,
|
||||
spaceId,
|
||||
siteSpaceId: siteSpaceId ?? undefined,
|
||||
siteShareKey: siteShareKey ?? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the space layout.
|
||||
*/
|
||||
export async function fetchSpaceData() {
|
||||
const content = getContentPointer();
|
||||
|
||||
const siteShareKey = 'siteId' in content ? content.siteShareKey : undefined;
|
||||
|
||||
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
|
||||
'siteId' in content
|
||||
? [
|
||||
getCurrentSiteData(content),
|
||||
fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey,
|
||||
}),
|
||||
]
|
||||
: [getSpaceData(content, siteShareKey)],
|
||||
);
|
||||
|
||||
const parent = await (parentSite ?? fetchParentCollection(space));
|
||||
// we grab the space attached to the parent as it contains overriden customizations
|
||||
const spaceRelativeToParent = parent?.spaces.find((space) => space.id === content.spaceId);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space: spaceRelativeToParent ?? space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
...parent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content.
|
||||
* Optimized to fetch in parallel as much as possible.
|
||||
*/
|
||||
export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const content = getContentPointer();
|
||||
const siteShareKey = 'siteId' in content ? content.siteShareKey : undefined;
|
||||
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
|
||||
? getCurrentSiteData(content)
|
||||
: getSpaceData(content, siteShareKey));
|
||||
|
||||
const page = await resolvePage(contentTarget, pages, params);
|
||||
const [parent, document] = await Promise.all([
|
||||
'siteId' in content
|
||||
? fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey: content.siteShareKey,
|
||||
})
|
||||
: fetchParentCollection(space),
|
||||
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
...page,
|
||||
...parent,
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page from the params.
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(
|
||||
contentTarget: ContentTarget,
|
||||
pages: RevisionPage[],
|
||||
params: PagePathParams | PageIdParams,
|
||||
) {
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(pages, params.pageId);
|
||||
}
|
||||
|
||||
const rawPathname = getPathnameParam(params);
|
||||
const pathname = normalizePathname(rawPathname);
|
||||
|
||||
// When resolving a page, we use the lowercased pathname
|
||||
const page = resolvePagePath(pages, pathname);
|
||||
if (page) {
|
||||
return page;
|
||||
}
|
||||
|
||||
// If page can't be found, we try with the API, in case we have a redirect
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
//
|
||||
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||
if (rawPathname.length <= 512) {
|
||||
const resolved = await getRevisionPageByPath(
|
||||
contentTarget.spaceId,
|
||||
contentTarget.revisionId,
|
||||
rawPathname,
|
||||
);
|
||||
if (resolved) {
|
||||
return resolvePageId(pages, resolved.id);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchParentCollection(space: Space) {
|
||||
const parentCollectionId =
|
||||
space.visibility === ContentVisibility.InCollection ? space.parent : undefined;
|
||||
const [collection, spaces] = await Promise.all([
|
||||
parentCollectionId ? getCollection(parentCollectionId) : null,
|
||||
parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]),
|
||||
]);
|
||||
|
||||
return { parent: collection, spaces };
|
||||
}
|
||||
|
||||
async function fetchParentSite(args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
siteShareKey: string | undefined;
|
||||
}) {
|
||||
const { organizationId, siteId, siteShareKey } = args;
|
||||
const [site, siteSpaces, siteParentCustomizations] = await Promise.all([
|
||||
getSite(organizationId, siteId),
|
||||
getSiteSpaces({ organizationId, siteId, siteShareKey }),
|
||||
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
|
||||
]);
|
||||
|
||||
const spaces: Record<string, Space> = {};
|
||||
siteSpaces.forEach((siteSpace) => {
|
||||
spaces[siteSpace.space.id] = {
|
||||
...siteSpace.space,
|
||||
title: siteSpace.title ?? siteSpace.space.title,
|
||||
urls: {
|
||||
...siteSpace.space.urls,
|
||||
published: siteSpace.urls.published,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// override the title with the customization title
|
||||
const parent = {
|
||||
...site,
|
||||
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
parent,
|
||||
spaces: Object.values(spaces),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page path from the params.
|
||||
*/
|
||||
export function getPathnameParam(params: PagePathParams): string {
|
||||
const { pathname } = params;
|
||||
return pathname ? pathname.map((part) => decodeURIComponent(part)).join('/') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL pathname into the format used in the revision page path.
|
||||
*/
|
||||
export function normalizePathname(pathname: string) {
|
||||
return pathname.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SpaceIntegrationScript } from '@gitbook/api';
|
||||
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { getSiteLayoutData, getSpaceCustomization } from '@/lib/api';
|
||||
|
||||
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||
|
||||
/**
|
||||
* Layout to be used for the site root. It fetches the customization data for the
|
||||
* site or space and initializes the CustomizationRootLayout with it.
|
||||
*/
|
||||
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
const pointer = getSiteOrSpacePointerForPDF();
|
||||
const { customization } = await ('siteId' in pointer
|
||||
? getSiteLayoutData(pointer)
|
||||
: getSpaceLayoutData(pointer.spaceId));
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the layout data about a space at once.
|
||||
*/
|
||||
async function getSpaceLayoutData(spaceId: string) {
|
||||
const [customization, scripts] = await Promise.all([
|
||||
getSpaceCustomization(spaceId),
|
||||
[] as SpaceIntegrationScript[],
|
||||
]);
|
||||
|
||||
return {
|
||||
customization,
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
+14
-20
@@ -11,7 +11,6 @@ import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getContentPointer } from '@/app/(space)/fetch';
|
||||
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
|
||||
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
|
||||
import { PolymorphicComponentProp } from '@/components/utils/types';
|
||||
@@ -32,6 +31,7 @@ import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls';
|
||||
|
||||
import './pdf.css';
|
||||
import { PageControlButtons } from './PageControlButtons';
|
||||
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||
import { PrintButton } from './PrintButton';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
@@ -39,15 +39,12 @@ const DEFAULT_LIMIT = 100;
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const contentPointer = getContentPointer();
|
||||
const pointer = getSiteOrSpacePointerForPDF();
|
||||
const [space, customization] = await Promise.all([
|
||||
getSpace(
|
||||
contentPointer.spaceId,
|
||||
'siteId' in contentPointer ? contentPointer.siteShareKey : undefined,
|
||||
),
|
||||
'siteId' in contentPointer
|
||||
? getCurrentSiteCustomization(contentPointer)
|
||||
: getSpaceCustomization(contentPointer.spaceId),
|
||||
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer
|
||||
? getCurrentSiteCustomization(pointer)
|
||||
: getSpaceCustomization(pointer.spaceId),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -60,7 +57,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
* Render a space as a standalone HTML page that can be printed as a PDF.
|
||||
*/
|
||||
export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
|
||||
const contentPointer = getContentPointer();
|
||||
const pointer = getSiteOrSpacePointerForPDF();
|
||||
|
||||
const searchParams = new URLSearchParams(props.searchParams);
|
||||
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
||||
@@ -71,13 +68,10 @@ 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),
|
||||
getSpaceContentData(
|
||||
contentPointer,
|
||||
'siteId' in contentPointer ? contentPointer.siteShareKey : undefined,
|
||||
),
|
||||
'siteId' in pointer
|
||||
? getCurrentSiteCustomization(pointer)
|
||||
: getSpaceCustomization(pointer.spaceId),
|
||||
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
]);
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
@@ -175,7 +169,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
||||
space={space}
|
||||
page={page}
|
||||
refContext={{
|
||||
siteContext: 'siteId' in contentPointer ? contentPointer : null,
|
||||
siteContext: 'siteId' in pointer ? pointer : null,
|
||||
space,
|
||||
revisionId: contentTarget.revisionId,
|
||||
pages: rootPages,
|
||||
@@ -321,7 +315,7 @@ function selectPages(
|
||||
return [
|
||||
{ page, depth },
|
||||
...page.pages.flatMap((child) => {
|
||||
if (child.type === 'link') {
|
||||
if (child.type !== 'document') {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -356,7 +350,7 @@ function selectPages(
|
||||
}
|
||||
|
||||
const allPages = rootPages.flatMap((page) => {
|
||||
if (page.type === 'link') {
|
||||
if (page.type !== 'document' && page.type !== 'group') {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
|
||||
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
|
||||
|
||||
/**
|
||||
* PDF generation can be done at the site level (e.g. docs.foo.com/~gitbook/pdf) or
|
||||
* at the space level (e.g. open.gitbook.com/~space/:spaceId/~gitbook/pdf) which is
|
||||
* for PDF export of in-app private spaces.
|
||||
*
|
||||
* This function returns the pointer depending on the context.
|
||||
*/
|
||||
export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
|
||||
try {
|
||||
return getSiteContentPointer();
|
||||
} catch (error) {
|
||||
return getSpacePointer();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { ContentPointer, getChangeRequest, getRevision } from '@/lib/api';
|
||||
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
@@ -10,7 +10,7 @@ import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Tool
|
||||
import { DateRelative } from '../primitives';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
content: ContentPointer;
|
||||
content: SiteContentPointer;
|
||||
space: Space;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
@@ -21,6 +22,7 @@ export function Ad({
|
||||
spaceId,
|
||||
placement,
|
||||
ignore,
|
||||
siteAdsStatus,
|
||||
style,
|
||||
mode = 'auto',
|
||||
}: {
|
||||
@@ -29,6 +31,7 @@ export function Ad({
|
||||
placement: string;
|
||||
ignore: boolean;
|
||||
style?: ClassValue;
|
||||
siteAdsStatus?: SiteAds['status'];
|
||||
mode?: 'classic' | 'auto' | 'cover';
|
||||
}) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -70,20 +73,31 @@ export function Ad({
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const preview = new URL(window.location.href).searchParams.has('ads_preview');
|
||||
const previewParam = new URL(window.location.href).searchParams.get('ads_preview');
|
||||
const preview = !!previewParam;
|
||||
const realZoneId = preview ? PREVIEW_ZONE_ID : zoneId;
|
||||
const showPlaceholderAd =
|
||||
previewParam === 'placeholder' ||
|
||||
(siteAdsStatus &&
|
||||
(siteAdsStatus === SiteAdsStatus.Pending ||
|
||||
siteAdsStatus === SiteAdsStatus.InReview));
|
||||
|
||||
if (!realZoneId) {
|
||||
if (!realZoneId && !showPlaceholderAd) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await renderAd({
|
||||
placement,
|
||||
ignore: ignore || preview,
|
||||
zoneId: realZoneId,
|
||||
mode,
|
||||
});
|
||||
const result = showPlaceholderAd
|
||||
? await renderAd({ source: 'placeholder' })
|
||||
: realZoneId
|
||||
? await renderAd({
|
||||
placement,
|
||||
ignore: ignore || preview,
|
||||
zoneId: realZoneId,
|
||||
mode,
|
||||
source: 'live',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -97,15 +111,10 @@ export function Ad({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, zoneId, ignore, placement, mode]);
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
viaUrl.searchParams.set('utm_medium', 'ads');
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={tcls(style)}>
|
||||
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
|
||||
{ad ? (
|
||||
<>
|
||||
{ad}
|
||||
@@ -122,7 +131,7 @@ function AdSponsoredLink(props: { spaceId: string }) {
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
viaUrl.searchParams.set('utm_medium', 'sponsoring');
|
||||
viaUrl.searchParams.set('utm_medium', 'sponsored-by-gitbook');
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AdItem } from './types';
|
||||
/**
|
||||
* Classic rendering for an ad.
|
||||
*/
|
||||
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
export function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
return (
|
||||
<a
|
||||
className={tcls(
|
||||
@@ -33,7 +33,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
<img
|
||||
alt="Ads logo"
|
||||
className={tcls('rounded-md')}
|
||||
src={await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
|
||||
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -43,7 +43,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
>
|
||||
<img
|
||||
alt="Ads logo"
|
||||
src={await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
|
||||
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { AdCover } from './types';
|
||||
/**
|
||||
* Cover rendering for an ad.
|
||||
*/
|
||||
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||
export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||
|
||||
return (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<svg width="193" height="120" viewBox="0 0 193 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M183.432 0.776855H10.2788C5.07442 0.776855 0.855469 4.14309 0.855469 8.29556V111.678C0.855469 115.831 5.07442 119.197 10.2788 119.197H183.432C188.637 119.197 192.855 115.831 192.855 111.678V8.29556C192.855 4.14309 188.637 0.776855 183.432 0.776855Z"
|
||||
fill="#14171C" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M96.8556 72.2159C84.724 72.2159 74.8894 82.0505 74.8894 94.1818C74.8894 95.9661 73.4431 97.4124 71.659 97.4124C69.875 97.4124 68.4287 95.9661 68.4287 94.1818C68.4287 78.4825 81.1558 65.7554 96.8556 65.7554C112.556 65.7554 125.282 78.4825 125.282 94.1818C125.282 95.9661 123.836 97.4124 122.053 97.4124C120.268 97.4124 118.822 95.9661 118.822 94.1818C118.822 82.0505 108.987 72.2159 96.8556 72.2159Z"
|
||||
fill="url(#paint0_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M129.621 94.1816C129.621 76.0865 114.951 61.4172 96.8558 61.4172C78.7603 61.4172 64.091 76.0865 64.091 94.1816C64.091 95.9658 62.6447 97.4122 60.8607 97.4122C59.0766 97.4122 57.6304 95.9658 57.6304 94.1816C57.6304 72.5183 75.1922 54.9565 96.8558 54.9565C118.52 54.9565 136.081 72.5183 136.081 94.1816C136.081 95.9658 134.635 97.4122 132.85 97.4122C131.067 97.4122 129.621 95.9658 129.621 94.1816Z"
|
||||
fill="url(#paint1_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M96.856 50.3419C72.6438 50.3419 53.0159 69.9698 53.0159 94.1818C53.0159 95.966 51.5695 97.4124 49.7855 97.4124C48.0014 97.4124 46.5552 95.966 46.5552 94.1818C46.5552 66.4018 69.0756 43.8813 96.856 43.8813C124.637 43.8813 147.157 66.4018 147.157 94.1818C147.157 95.966 145.711 97.4124 143.927 97.4124C142.142 97.4124 140.696 95.966 140.696 94.1818C140.696 69.9698 121.068 50.3419 96.856 50.3419Z"
|
||||
fill="url(#paint2_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M151.587 94.1815C151.587 63.9548 127.083 39.4509 96.8557 39.4509C66.6286 39.4509 42.1246 63.9548 42.1246 94.1815C42.1246 95.9658 40.6784 97.4121 38.8943 97.4121C37.1103 97.4121 35.6641 95.9658 35.6641 94.1815C35.6641 60.3866 63.0605 32.9902 96.8557 32.9902C130.651 32.9902 158.047 60.3866 158.047 94.1815C158.047 95.9658 156.601 97.4121 154.818 97.4121C153.033 97.4121 151.587 95.9658 151.587 94.1815Z"
|
||||
fill="url(#paint3_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M96.8554 29.0217C60.8683 29.0217 31.6951 58.195 31.6951 94.1817C31.6951 95.966 30.2488 97.4123 28.4647 97.4123C26.6807 97.4123 25.2344 95.966 25.2344 94.1817C25.2344 54.6269 57.3003 22.561 96.8554 22.561C136.411 22.561 168.477 54.6269 168.477 94.1817C168.477 95.966 167.03 97.4123 165.246 97.4123C163.462 97.4123 162.015 95.966 162.015 94.1817C162.015 58.195 132.842 29.0217 96.8554 29.0217Z"
|
||||
fill="url(#paint4_radial_3089_1090)" />
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(88.9053 80.4777) rotate(64.8515) scale(18.7082 33.8612)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(143.478 58.0317) rotate(168.06) scale(87.7455 57.2588)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.19" stop-color="#F4E28D" />
|
||||
<stop offset="0.614011" stop-color="#BBDDE5" />
|
||||
<stop offset="1" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(82.7879 68.7762) rotate(63.8366) scale(31.9052 59.4091)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint3_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(101.132 56.1925) rotate(172.165) scale(66.0854 41.9909)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(76.8246 57.371) rotate(63.4233) scale(44.7721 84.288)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -5,9 +5,16 @@ import { headers } from 'next/headers';
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
import { AdCoverRendering } from './AdCoverRendering';
|
||||
import { AdPixels } from './AdPixels';
|
||||
import adRainbow from './assets/ad-rainbow.svg';
|
||||
import { AdItem, AdsResponse } from './types';
|
||||
|
||||
interface FetchAdOptions {
|
||||
type FetchAdOptions = FetchLiveAdOptions | FetchPlaceholderAdOptions;
|
||||
|
||||
interface FetchLiveAdOptions {
|
||||
/**
|
||||
* Source of the ad (live: from the platform)
|
||||
*/
|
||||
source: 'live';
|
||||
/** ID of the zone to fetch Ads for */
|
||||
zoneId: string;
|
||||
/** Mode to render the Ad */
|
||||
@@ -18,14 +25,22 @@ interface FetchAdOptions {
|
||||
ignore: boolean;
|
||||
}
|
||||
|
||||
interface FetchPlaceholderAdOptions {
|
||||
/**
|
||||
* Source of the ad (placeholder: static placeholder ad)
|
||||
*/
|
||||
source: 'placeholder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to render the Ad placement.
|
||||
* We use a server-action to avoid caching issues with server-side components,
|
||||
* and properly access user-agent and IP.
|
||||
*/
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const { mode } = options;
|
||||
const result = await fetchAd(options);
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
|
||||
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd();
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
@@ -48,16 +63,8 @@ async function fetchAd({
|
||||
zoneId,
|
||||
placement,
|
||||
ignore,
|
||||
}: FetchAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
|
||||
const headersSet = headers();
|
||||
const ip =
|
||||
headersSet.get('x-gitbook-ipv4') ??
|
||||
headersSet.get('x-gitbook-ip') ??
|
||||
headersSet.get('cf-pseudo-ipv4') ??
|
||||
headersSet.get('cf-connecting-ip') ??
|
||||
headersSet.get('x-forwarded-for') ??
|
||||
'';
|
||||
const userAgent = headersSet.get('user-agent') ?? '';
|
||||
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
|
||||
const { ip, userAgent } = getUserAgentAndIp();
|
||||
|
||||
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
|
||||
url.searchParams.set('segment', `placement:${placement}`);
|
||||
@@ -78,3 +85,50 @@ async function fetchAd({
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPlaceholderAd(): { ad: AdItem; ip: string } {
|
||||
const { ip } = getUserAgentAndIp();
|
||||
|
||||
return {
|
||||
ad: {
|
||||
active: '1',
|
||||
ad_via_link: '',
|
||||
bannerid: '',
|
||||
creativeid: '',
|
||||
description:
|
||||
'Your docs could be this good.\nPublish incredible open source docs for free with GitBook',
|
||||
evenodd: '0',
|
||||
external_id: '',
|
||||
height: '0',
|
||||
i: '0',
|
||||
identifier: '',
|
||||
longimp: '',
|
||||
longlink: '',
|
||||
num_slots: '1',
|
||||
rendering: 'carbon',
|
||||
smallImage: adRainbow.src,
|
||||
statimp: '',
|
||||
statlink:
|
||||
'https://www.gitbook.com/solutions/open-source?utm_campaign=sponsored-content&utm_medium=ad&utm_source=content',
|
||||
timestamp: Date.now().toString(),
|
||||
width: '0',
|
||||
zoneid: '',
|
||||
zonekey: '',
|
||||
},
|
||||
ip,
|
||||
};
|
||||
}
|
||||
|
||||
function getUserAgentAndIp() {
|
||||
const headersSet = headers();
|
||||
const ip =
|
||||
headersSet.get('x-gitbook-ipv4') ??
|
||||
headersSet.get('x-gitbook-ip') ??
|
||||
headersSet.get('cf-pseudo-ipv4') ??
|
||||
headersSet.get('cf-connecting-ip') ??
|
||||
headersSet.get('x-forwarded-for') ??
|
||||
'';
|
||||
const userAgent = headersSet.get('user-agent') ?? '';
|
||||
|
||||
return { ip, userAgent };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
|
||||
const onUpdateState = (enabled: boolean) => {
|
||||
setCookiesTracking(enabled);
|
||||
|
||||
// Reload the page to take the change in consideration
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -75,6 +74,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow(false)}
|
||||
aria-label={tString(language, 'cookies_close')}
|
||||
className={tcls(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DocumentBlock, JSONDocument } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
@@ -32,6 +31,9 @@ import { BlockMath } from './Math';
|
||||
import { OpenAPI } from './OpenAPI';
|
||||
import { Paragraph } from './Paragraph';
|
||||
import { Quote } from './Quote';
|
||||
import { ReusableContent } from './ReusableContent';
|
||||
import { Stepper } from './Stepper';
|
||||
import { StepperStep } from './StepperStep';
|
||||
import { Table } from './Table';
|
||||
import { Tabs } from './Tabs';
|
||||
|
||||
@@ -39,77 +41,102 @@ export interface BlockProps<Block extends DocumentBlock> extends DocumentContext
|
||||
block: Block;
|
||||
document: JSONDocument;
|
||||
ancestorBlocks: DocumentBlock[];
|
||||
/** If true, we estimate that the block will be outside the initial viewport */
|
||||
isEstimatedOffscreen: boolean;
|
||||
/** Class names to be passed to the underlying DOM element */
|
||||
style?: ClassValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative to `assertNever` that returns `null` instead of throwing an error.
|
||||
*/
|
||||
function nullIfNever(value: never): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
const { block, style, ...contextProps } = props;
|
||||
const { block, style, isEstimatedOffscreen, context } = props;
|
||||
|
||||
const content = (() => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return <Paragraph {...props} {...contextProps} block={block} />;
|
||||
return <Paragraph {...props} block={block} />;
|
||||
case 'heading-1':
|
||||
case 'heading-2':
|
||||
case 'heading-3':
|
||||
return <Heading {...props} {...contextProps} block={block} />;
|
||||
return <Heading {...props} block={block} />;
|
||||
case 'list-ordered':
|
||||
return <ListOrdered {...props} {...contextProps} block={block} />;
|
||||
return <ListOrdered {...props} block={block} />;
|
||||
case 'list-unordered':
|
||||
return <ListUnordered {...props} {...contextProps} block={block} />;
|
||||
return <ListUnordered {...props} block={block} />;
|
||||
case 'list-tasks':
|
||||
return <ListTasks {...props} {...contextProps} block={block} />;
|
||||
return <ListTasks {...props} block={block} />;
|
||||
case 'list-item':
|
||||
return <ListItem {...props} {...contextProps} block={block} />;
|
||||
return <ListItem {...props} block={block} />;
|
||||
case 'code':
|
||||
return <CodeBlock {...props} {...contextProps} block={block} />;
|
||||
return <CodeBlock {...props} block={block} />;
|
||||
case 'hint':
|
||||
return <Hint {...props} {...contextProps} block={block} />;
|
||||
return <Hint {...props} block={block} />;
|
||||
case 'images':
|
||||
return <Images {...props} {...contextProps} block={block} />;
|
||||
return <Images {...props} block={block} />;
|
||||
case 'tabs':
|
||||
return <Tabs {...props} {...contextProps} block={block} />;
|
||||
return <Tabs {...props} block={block} />;
|
||||
case 'expandable':
|
||||
return <Expandable {...props} {...contextProps} block={block} />;
|
||||
return <Expandable {...props} block={block} />;
|
||||
case 'table':
|
||||
return <Table {...props} {...contextProps} block={block} />;
|
||||
return <Table {...props} block={block} />;
|
||||
case 'swagger':
|
||||
return <OpenAPI {...props} {...contextProps} block={block} />;
|
||||
return <OpenAPI {...props} block={block} />;
|
||||
case 'embed':
|
||||
return <Embed {...props} {...contextProps} block={block} />;
|
||||
return <Embed {...props} block={block} />;
|
||||
case 'blockquote':
|
||||
return <Quote {...props} {...contextProps} block={block} />;
|
||||
return <Quote {...props} block={block} />;
|
||||
case 'math':
|
||||
return <BlockMath {...props} {...contextProps} block={block} />;
|
||||
return <BlockMath {...props} block={block} />;
|
||||
case 'file':
|
||||
return <File {...props} {...contextProps} block={block} />;
|
||||
return <File {...props} block={block} />;
|
||||
case 'divider':
|
||||
return <Divider {...props} {...contextProps} block={block} />;
|
||||
return <Divider {...props} block={block} />;
|
||||
case 'drawing':
|
||||
return <Drawing {...props} {...contextProps} block={block} />;
|
||||
return <Drawing {...props} block={block} />;
|
||||
case 'content-ref':
|
||||
return <BlockContentRef {...props} {...contextProps} block={block} />;
|
||||
return <BlockContentRef {...props} block={block} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
throw new Error('Blocks should be directly rendered by parent');
|
||||
case 'integration':
|
||||
return <IntegrationBlock {...props} {...contextProps} block={block} />;
|
||||
return <IntegrationBlock {...props} block={block} />;
|
||||
case 'synced-block':
|
||||
return <BlockSyncedBlock {...props} {...contextProps} block={block} />;
|
||||
return <BlockSyncedBlock {...props} block={block} />;
|
||||
case 'reusable-content':
|
||||
return <ReusableContent {...props} block={block} />;
|
||||
case 'stepper':
|
||||
return <Stepper {...props} block={block} />;
|
||||
case 'stepper-step':
|
||||
return <StepperStep {...props} block={block} />;
|
||||
default:
|
||||
assertNever(block);
|
||||
return nullIfNever(block);
|
||||
}
|
||||
})();
|
||||
|
||||
if (!isEstimatedOffscreen || context.wrapBlocksInSuspense === false) {
|
||||
// When blocks are estimated to be on the initial viewport, we render them immediately
|
||||
// to avoid a flash of a loading skeleton.
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<BlockPlaceholder block={block} style={style} />}>
|
||||
<React.Suspense fallback={<BlockSkeleton block={block} style={style} />}>
|
||||
{content}
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
|
||||
/**
|
||||
* Skeleton for a block while it is being loaded.
|
||||
*/
|
||||
export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }) {
|
||||
const { block, style } = props;
|
||||
const id = 'meta' in block && block.meta && 'id' in block.meta ? block.meta.id : undefined;
|
||||
|
||||
@@ -129,7 +156,6 @@ function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
|
||||
case 'code':
|
||||
case 'hint':
|
||||
case 'tabs':
|
||||
case 'synced-block':
|
||||
return <SkeletonParagraph id={id} style={style} />;
|
||||
case 'expandable':
|
||||
case 'table':
|
||||
@@ -138,6 +164,9 @@ function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
|
||||
case 'divider':
|
||||
case 'content-ref':
|
||||
case 'integration':
|
||||
case 'stepper':
|
||||
case 'synced-block':
|
||||
case 'reusable-content':
|
||||
return <SkeletonCard id={id} style={style} />;
|
||||
case 'embed':
|
||||
case 'images':
|
||||
@@ -146,8 +175,9 @@ function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
case 'stepper-step':
|
||||
throw new Error('Blocks should be directly rendered by parent');
|
||||
default:
|
||||
assertNever(block);
|
||||
return nullIfNever(block);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tcls, ClassValue } from '@/lib/tailwind';
|
||||
|
||||
import { Block } from './Block';
|
||||
import { DocumentContextProps } from './DocumentView';
|
||||
import { isBlockOffscreen } from './utils';
|
||||
|
||||
/**
|
||||
* Renders a list of blocks with a wrapper element.
|
||||
@@ -49,22 +50,35 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
|
||||
export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBlocksProps<TBlock>) {
|
||||
const { nodes, blockStyle, ...contextProps } = props;
|
||||
|
||||
let isOffscreen = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => (
|
||||
<Block
|
||||
key={node.key}
|
||||
block={node}
|
||||
style={[
|
||||
'w-full mx-auto decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
blockStyle,
|
||||
]}
|
||||
{...contextProps}
|
||||
/>
|
||||
))}
|
||||
{nodes.map((node) => {
|
||||
isOffscreen =
|
||||
isOffscreen ||
|
||||
isBlockOffscreen({
|
||||
document: props.document,
|
||||
block: node,
|
||||
ancestorBlocks: props.ancestorBlocks,
|
||||
});
|
||||
|
||||
return (
|
||||
<Block
|
||||
key={node.key}
|
||||
block={node}
|
||||
style={[
|
||||
'w-full mx-auto decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
{...contextProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
|
||||
}}
|
||||
block={block}
|
||||
ancestorBlocks={[]}
|
||||
isEstimatedOffscreen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ContentTarget } from '@/lib/api';
|
||||
import { ContentRefContext, ResolveContentRefOptions, ResolvedContentRef } from '@/lib/references';
|
||||
import { ClassValue } from '@/lib/tailwind';
|
||||
|
||||
import { BlockSkeleton } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export interface DocumentContext {
|
||||
@@ -46,6 +47,12 @@ export interface DocumentContext {
|
||||
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
|
||||
*/
|
||||
shouldHighlightCode: (spaceId: string | undefined) => boolean;
|
||||
|
||||
/**
|
||||
* True if the blocks should be wrapped in suspense boundary for isolated loading skeletons.
|
||||
* @default true
|
||||
*/
|
||||
wrapBlocksInSuspense?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentContextProps {
|
||||
@@ -83,3 +90,28 @@ export function DocumentView(
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for the entire document layout.
|
||||
*/
|
||||
export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle: ClassValue }) {
|
||||
const { document, blockStyle } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{document.nodes.map((block, index) => (
|
||||
<BlockSkeleton
|
||||
key={block.key!}
|
||||
block={block}
|
||||
style={[
|
||||
'w-full mx-auto decoration-primary/6',
|
||||
block.data && 'fullWidth' in block.data && block.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
blockStyle,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,10 @@ import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { BlockProps } from './Block';
|
||||
import { Caption } from './Caption';
|
||||
import { DocumentContext } from './DocumentView';
|
||||
import { isBlockOffscreen } from './utils';
|
||||
|
||||
export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
const { document, block, ancestorBlocks, style, context } = props;
|
||||
const { document, block, ancestorBlocks, style, context, isEstimatedOffscreen } = props;
|
||||
|
||||
const isOffscreen = isBlockOffscreen({ document, block, ancestorBlocks });
|
||||
const isMultipleImages = block.nodes.length > 1;
|
||||
const { align = 'center' } = block.data;
|
||||
|
||||
@@ -41,7 +39,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
style={[]}
|
||||
siblings={block.nodes.length}
|
||||
context={context}
|
||||
isOffscreen={isOffscreen}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -67,9 +65,9 @@ async function ImageBlock(props: {
|
||||
style: ClassValue;
|
||||
context: DocumentContext;
|
||||
siblings: number;
|
||||
isOffscreen: boolean;
|
||||
isEstimatedOffscreen: boolean;
|
||||
}) {
|
||||
const { block, context, isOffscreen } = props;
|
||||
const { block, context, isEstimatedOffscreen } = props;
|
||||
|
||||
const [src, darkSrc] = await Promise.all([
|
||||
context.resolveContentRef(block.data.ref),
|
||||
@@ -97,7 +95,7 @@ async function ImageBlock(props: {
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority={isOffscreen ? 'lazy' : 'high'}
|
||||
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
|
||||
preload
|
||||
zoom
|
||||
inlineStyle={{
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
renderIntegrationUi(block.data.integration, initialInput),
|
||||
true,
|
||||
);
|
||||
if (!initialOutput) {
|
||||
if (!initialOutput || initialOutput.type === 'complete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { LoadingPane } from '@/components/primitives';
|
||||
import { fetchOpenAPIBlock } from '@/lib/openapi';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import OpenAPIClientStateContainer from './OpenAPIClientStateContainer';
|
||||
import { serverUrlCache } from './ServerUrlCache';
|
||||
import { BlockProps } from '../Block';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
|
||||
@@ -17,7 +19,7 @@ import './scalar.css';
|
||||
* Render an OpenAPI block.
|
||||
*/
|
||||
export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
|
||||
const { block, style } = props;
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
|
||||
<React.Suspense fallback={<OpenAPIFallback />}>
|
||||
@@ -45,21 +47,30 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// To update the code sample we need to re-render the server component
|
||||
// so reading the cached value from search params
|
||||
const serverUrl = serverUrlCache.get('serverUrl');
|
||||
|
||||
return (
|
||||
<OpenAPIOperation
|
||||
data={data}
|
||||
context={{
|
||||
icons: {
|
||||
chevronDown: <Icon icon="chevron-down" />,
|
||||
chevronRight: <Icon icon="chevron-right" />,
|
||||
},
|
||||
CodeBlock: PlainCodeBlock,
|
||||
defaultInteractiveOpened: context.mode === 'print',
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
}}
|
||||
className="openapi-block"
|
||||
/>
|
||||
<OpenAPIClientStateContainer block={block} servers={data.servers}>
|
||||
<OpenAPIOperation
|
||||
data={data}
|
||||
context={{
|
||||
icons: {
|
||||
chevronDown: <Icon icon="chevron-down" />,
|
||||
chevronRight: <Icon icon="chevron-right" />,
|
||||
edit: <Icon icon="edit" />,
|
||||
editDone: <Icon icon="check" />,
|
||||
},
|
||||
CodeBlock: PlainCodeBlock,
|
||||
defaultInteractiveOpened: context.mode === 'print',
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
serverUrl,
|
||||
}}
|
||||
className="openapi-block"
|
||||
/>
|
||||
</OpenAPIClientStateContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { DocumentBlock } from '@gitbook/api';
|
||||
import { OpenAPIClientState } from '@gitbook/react-openapi/client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Client component that wraps `OpenAPIClientState` so we can
|
||||
* use some hooks (e.g. useRouter) in the `onUpdate` callback.
|
||||
*/
|
||||
export default function OpenAPIClientStateContainer(props: {
|
||||
children: React.ReactNode;
|
||||
block: DocumentBlock;
|
||||
servers: OpenAPIV3.ServerObject[];
|
||||
}) {
|
||||
const { block, children, servers } = props;
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<OpenAPIClientState
|
||||
isPending={isPending}
|
||||
servers={servers}
|
||||
params={
|
||||
searchParams.get('block') === block.key
|
||||
? Object.fromEntries(searchParams.entries())
|
||||
: undefined
|
||||
}
|
||||
onUpdate={async (params) => {
|
||||
startTransition(() => {
|
||||
const queryParams = new URLSearchParams(params ?? '');
|
||||
router.replace(`?${queryParams}`, { scroll: false });
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OpenAPIClientState>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createSearchParamsCache, parseAsString } from 'nuqs/server';
|
||||
|
||||
export const serverUrlCache = createSearchParamsCache({
|
||||
serverUrl: parseAsString,
|
||||
});
|
||||
@@ -57,7 +57,7 @@
|
||||
/** URL */
|
||||
|
||||
.openapi-url {
|
||||
@apply font-mono text-sm text-dark/8 dark:text-light/8;
|
||||
@apply flex items-center font-mono text-sm text-dark/8 dark:text-light/8;
|
||||
}
|
||||
|
||||
.openapi-url-var {
|
||||
@@ -364,3 +364,24 @@
|
||||
.openapi-markdown > *:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.openapi-select-button,
|
||||
.openapi-edit-button {
|
||||
@apply p-0.5 inline-flex text-dark/6 dark:text-light/6 hover:opacity-8;
|
||||
}
|
||||
|
||||
.openapi-edit-button {
|
||||
@apply p-0.5 ml-4 size-4;
|
||||
}
|
||||
|
||||
.openapi-edit-button > * {
|
||||
@apply size-full;
|
||||
}
|
||||
|
||||
.openapi-select-button {
|
||||
@apply leading-[1cap] disabled:opacity-5;
|
||||
}
|
||||
|
||||
.openapi-pending {
|
||||
@apply opacity-5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DocumentBlockReusableContent } from '@gitbook/api';
|
||||
|
||||
import { getDocument } from '@/lib/api';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { UnwrappedBlocks } from './Blocks';
|
||||
|
||||
export async function ReusableContent(props: BlockProps<DocumentBlockReusableContent>) {
|
||||
const { block, context, ancestorBlocks } = props;
|
||||
|
||||
if (!context.content) {
|
||||
throw new Error(`Expected a content context to render a reusable content block`);
|
||||
}
|
||||
|
||||
const resolved = await context.resolveContentRef(block.data.ref);
|
||||
if (!resolved?.reusableContent?.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = await getDocument(context.content.spaceId, resolved.reusableContent.document);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnwrappedBlocks
|
||||
nodes={document.nodes}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
context={context}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DocumentBlockStepper } from '@gitbook/api';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function Stepper(props: BlockProps<DocumentBlockStepper>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
return (
|
||||
<Blocks {...contextProps} nodes={block.nodes} ancestorBlocks={[...ancestorBlocks, block]} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DocumentBlockStepperStep } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
const ancestor = ancestorBlocks[ancestorBlocks.length - 1];
|
||||
assert(ancestor.type === 'stepper', 'Ancestor block must be a stepper');
|
||||
|
||||
const index = ancestor.nodes.indexOf(block);
|
||||
|
||||
const firstChild = block.nodes[0];
|
||||
const marginAdjustClassName = (() => {
|
||||
if (!firstChild) {
|
||||
return '';
|
||||
}
|
||||
switch (firstChild.type) {
|
||||
case 'heading-1':
|
||||
return '-mt-9';
|
||||
case 'heading-2':
|
||||
return '-mt-[calc(1.25rem+1px)]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-4 md:gap-8 max-w-3xl w-full mx-auto">
|
||||
<div className="relative select-none">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900 tabular-nums',
|
||||
'font-medium text-primary-800 dark:text-primary-200',
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-[0.875rem] top-9 w-px bg-primary-50 dark:bg-primary-900" />
|
||||
</div>
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
style={['flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,15 +20,12 @@ import { SearchButton } from '../Search';
|
||||
*/
|
||||
export function CompactHeader(props: {
|
||||
space: Space;
|
||||
parent: Site | Collection | null;
|
||||
site: Site | null;
|
||||
spaces: Space[];
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { space, spaces, parent, customization } = props;
|
||||
|
||||
const isMultiVariants =
|
||||
parent?.object === 'collection' ||
|
||||
(parent && parent.object === 'site' && spaces.length > 1);
|
||||
const { space, spaces, site, customization } = props;
|
||||
const isMultiVariants = site && spaces.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -45,7 +42,7 @@ export function CompactHeader(props: {
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex-grow-0', 'mt-5')}>
|
||||
<HeaderLogo parent={parent} space={space} customization={customization} />
|
||||
<HeaderLogo site={site} space={space} customization={customization} />
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationSettings,
|
||||
Site,
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api';
|
||||
import { CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
@@ -24,25 +18,23 @@ import { SearchButton } from '../Search';
|
||||
*/
|
||||
export function Header(props: {
|
||||
space: Space;
|
||||
parent: Site | Collection | null;
|
||||
site: Site | null;
|
||||
spaces: Space[];
|
||||
context: ContentRefContext;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
withTopHeader?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { context, space, parent, spaces, customization, withTopHeader } = props;
|
||||
|
||||
const { children, context, space, site, spaces, customization, withTopHeader } = props;
|
||||
const isCustomizationDefault =
|
||||
customization.header.preset === CustomizationHeaderPreset.Default;
|
||||
const isMultiVariants =
|
||||
parent?.object === 'collection' ||
|
||||
(parent && parent.object === 'site' && spaces.length > 1);
|
||||
const isMultiVariants = site && spaces.length > 1;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'flex-col',
|
||||
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
|
||||
'sticky',
|
||||
'top-0',
|
||||
@@ -76,7 +68,7 @@ export function Header(props: {
|
||||
CONTAINER_STYLE,
|
||||
)}
|
||||
>
|
||||
<HeaderLogo parent={parent} space={space} customization={customization} />
|
||||
<HeaderLogo site={site} space={space} customization={customization} />
|
||||
<span>
|
||||
{isMultiVariants ? <SpacesDropdown space={space} spaces={spaces} /> : null}
|
||||
</span>
|
||||
@@ -112,16 +104,21 @@ export function Header(props: {
|
||||
style={
|
||||
!isCustomizationDefault && withTopHeader
|
||||
? [
|
||||
'bg-header-background-400/8',
|
||||
'bg-header-link/3',
|
||||
'shadow-sm',
|
||||
'text-header-link/8',
|
||||
'ring-header-background-200/5',
|
||||
'[&>span]:text-header-link/7',
|
||||
'ring-header-link/3',
|
||||
'[&>span]:!text-header-link/7',
|
||||
'[&_svg]:text-header-link',
|
||||
'dark:bg-header-link-600/3',
|
||||
'dark:ring-header-link-600/2',
|
||||
'dark:text-header-link/7',
|
||||
'dark:[&_svg]:text-header-link/7',
|
||||
'contrast-more:bg-transparent',
|
||||
'contrast-more:ring-header-link',
|
||||
'contrast-more:[&>span]:!text-header-link',
|
||||
'dark:bg-header-link/3',
|
||||
'dark:ring-header-link/3',
|
||||
'[&>span]:!text-header-link/7',
|
||||
'dark:[&_svg]:text-header-link',
|
||||
'dark:contrast-more:bg-transparent',
|
||||
'dark:contrast-more:ring-header-link',
|
||||
'dark:contrast-more:[&>span]:!text-header-link',
|
||||
]
|
||||
: null
|
||||
}
|
||||
@@ -137,6 +134,7 @@ export function Header(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,14 +19,9 @@ import {
|
||||
} from './Dropdown';
|
||||
import { Button, Link } from '../primitives';
|
||||
|
||||
// @TODO Remove it once we have the proper types in API
|
||||
type CustomizationHeaderLinkWithStyle = CustomizationHeaderLink & {
|
||||
style?: 'link' | 'button-primary' | 'button-secondary';
|
||||
};
|
||||
|
||||
export async function HeaderLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationHeaderLinkWithStyle;
|
||||
link: CustomizationHeaderLink;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { context, link, customization } = props;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Link } from '../primitives';
|
||||
import { SpaceIcon } from '../Space/SpaceIcon';
|
||||
|
||||
interface HeaderLogoProps {
|
||||
parent: Site | Collection | null;
|
||||
site: Site | null;
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function HeaderLogo(props: HeaderLogoProps) {
|
||||
}
|
||||
|
||||
function LogoFallback(props: HeaderLogoProps) {
|
||||
const { parent, space, customization } = props;
|
||||
const { site, space, customization } = props;
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : undefined;
|
||||
const customEmoji = 'emoji' in customization.favicon ? customization.favicon.emoji : undefined;
|
||||
|
||||
@@ -124,7 +124,7 @@ function LogoFallback(props: HeaderLogoProps) {
|
||||
: 'text-header-link',
|
||||
)}
|
||||
>
|
||||
{getContentTitle(space, customization, parent)}
|
||||
{getContentTitle(space, customization, site)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,17 +22,27 @@ import { ScrollSectionsList } from './ScrollSectionsList';
|
||||
import { Ad } from '../Ads';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
|
||||
function getTopOffset(props: { sectionsHeader: boolean; topHeader: boolean }) {
|
||||
if (props.sectionsHeader && props.topHeader) {
|
||||
return 'lg:max-h-[calc(100vh_-_8rem)] top-32 page-api-block:xl:max-2xl:top-32';
|
||||
}
|
||||
if (props.sectionsHeader || props.topHeader) {
|
||||
return 'lg:max-h-[calc(100vh_-_4rem)] top-16 page-api-block:xl:max-2xl:top-16';
|
||||
}
|
||||
return 'lg:max-h-screen top-0 page-api-block:xl:max-2xl:top-0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Aside listing the headings in the document.
|
||||
*/
|
||||
export async function PageAside(props: {
|
||||
export function PageAside(props: {
|
||||
space: Space;
|
||||
site: Site | undefined;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
page: RevisionPageDocument;
|
||||
document: JSONDocument | null;
|
||||
context: ContentRefContext;
|
||||
withHeaderOffset: boolean;
|
||||
withHeaderOffset: { sectionsHeader: boolean; topHeader: boolean };
|
||||
withFullPageCover: boolean;
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
@@ -48,6 +58,7 @@ export async function PageAside(props: {
|
||||
} = props;
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const topOffset = getTopOffset(withHeaderOffset);
|
||||
return (
|
||||
<aside
|
||||
className={tcls(
|
||||
@@ -62,10 +73,8 @@ export async function PageAside(props: {
|
||||
'py-8',
|
||||
'break-anywhere', // To prevent long words in headings from breaking the layout
|
||||
'lg:h-full',
|
||||
withHeaderOffset ? 'lg:max-h-[calc(100vh_-_4rem)]' : 'lg:max-h-[100vh]',
|
||||
withHeaderOffset ? 'top-16' : 'top-0',
|
||||
'h-[100vh]',
|
||||
|
||||
'page-api-block:xl:max-2xl:z-[1]',
|
||||
// When in api page mode, we display it as an overlay on non-large resolutions
|
||||
'page-api-block:xl:max-2xl:backdrop-blur-md',
|
||||
'page-api-block:xl:max-2xl:fixed',
|
||||
@@ -77,9 +86,7 @@ export async function PageAside(props: {
|
||||
'page-api-block:xl:max-2xl:py-0',
|
||||
'page-api-block:xl:max-2xl:mt-3',
|
||||
'dark:page-api-block:xl:max-2xl:bg-dark-2/8',
|
||||
withHeaderOffset
|
||||
? 'page-api-block:xl:max-2xl:top-16'
|
||||
: 'page-api-block:xl:max-2xl:top-0',
|
||||
topOffset,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -190,6 +197,7 @@ export async function PageAside(props: {
|
||||
}
|
||||
placement="page.aside"
|
||||
spaceId={space.id}
|
||||
siteAdsStatus={site?.ads && site.ads.status ? site.ads.status : undefined}
|
||||
ignore={process.env.NODE_ENV !== 'production'}
|
||||
style={tcls('mt-4')}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import React from 'react';
|
||||
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { ContentPointer, ContentTarget, SiteContentPointer, api } from '@/lib/api';
|
||||
import { ContentTarget, SiteContentPointer, api } from '@/lib/api';
|
||||
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -21,13 +21,13 @@ import { PageFooterNavigation } from './PageFooterNavigation';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { PreservePageLayout } from './PreservePageLayout';
|
||||
import { TrackPageView } from './TrackPageView';
|
||||
import { DocumentView, createHighlightingContext } from '../DocumentView';
|
||||
import { DocumentView, DocumentViewSkeleton, createHighlightingContext } from '../DocumentView';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
import { DateRelative } from '../primitives';
|
||||
|
||||
export function PageBody(props: {
|
||||
space: Space;
|
||||
contentPointer: ContentPointer | SiteContentPointer;
|
||||
pointer: SiteContentPointer;
|
||||
contentTarget: ContentTarget;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
page: RevisionPageDocument;
|
||||
@@ -37,7 +37,7 @@ export function PageBody(props: {
|
||||
}) {
|
||||
const {
|
||||
space,
|
||||
contentPointer,
|
||||
pointer,
|
||||
contentTarget,
|
||||
customization,
|
||||
context,
|
||||
@@ -50,11 +50,6 @@ export function PageBody(props: {
|
||||
const language = getSpaceLanguage(customization);
|
||||
const updatedAt = page.updatedAt ?? page.createdAt;
|
||||
const shouldHighlightCode = createHighlightingContext();
|
||||
const sitePointer =
|
||||
'siteId' in contentPointer
|
||||
? { organizationId: contentPointer.organizationId, siteId: contentPointer.siteId }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main
|
||||
@@ -86,19 +81,28 @@ export function PageBody(props: {
|
||||
|
||||
<PageHeader page={page} />
|
||||
{document && !isNodeEmpty(document) ? (
|
||||
<DocumentView
|
||||
document={document}
|
||||
style={['[&>*+*]:mt-5', 'grid']}
|
||||
blockStyle={['page-api-block:ml-0']}
|
||||
context={{
|
||||
mode: 'default',
|
||||
content: contentTarget,
|
||||
contentRefContext: context,
|
||||
resolveContentRef: (ref, options) =>
|
||||
resolveContentRef(ref, context, options),
|
||||
shouldHighlightCode,
|
||||
}}
|
||||
/>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DocumentViewSkeleton
|
||||
document={document}
|
||||
blockStyle={['page-api-block:ml-0']}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DocumentView
|
||||
document={document}
|
||||
style={['[&>*+*]:mt-5', 'grid']}
|
||||
blockStyle={['page-api-block:ml-0']}
|
||||
context={{
|
||||
mode: 'default',
|
||||
content: contentTarget,
|
||||
contentRefContext: context,
|
||||
resolveContentRef: (ref, options) =>
|
||||
resolveContentRef(ref, context, options),
|
||||
shouldHighlightCode,
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<PageBodyBlankslate page={page} rootPages={context.pages} context={context} />
|
||||
)}
|
||||
@@ -146,7 +150,7 @@ export function PageBody(props: {
|
||||
</main>
|
||||
{shouldTrackPageViews() ? (
|
||||
<TrackPageView
|
||||
sitePointer={sitePointer}
|
||||
sitePointer={pointer}
|
||||
spaceId={space.id}
|
||||
pageId={page.id}
|
||||
apiHost={api().endpoint}
|
||||
|
||||
@@ -17,7 +17,9 @@ export async function PageBodyBlankslate(props: {
|
||||
}) {
|
||||
const { page, rootPages, context } = props;
|
||||
|
||||
const pages = page.pages.filter((child) => (child.type === 'document' ? !child.hidden : true));
|
||||
const pages = page.pages.filter((child) =>
|
||||
child.type === RevisionPageType.Document ? !child.hidden : true,
|
||||
);
|
||||
if (!pages.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -43,7 +45,11 @@ export async function PageBodyBlankslate(props: {
|
||||
/>
|
||||
);
|
||||
|
||||
if (child.type === RevisionPageType.Link) {
|
||||
if (child.type === RevisionPageType.Computed) {
|
||||
throw new Error(
|
||||
'Unexpected computed page, it should have been computed in the API',
|
||||
);
|
||||
} else if (child.type === RevisionPageType.Link) {
|
||||
const resolved = await resolveContentRef(child.target, context);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SiteContentPointer } from '@/lib/api';
|
||||
*/
|
||||
export function TrackPageView(props: {
|
||||
apiHost: string;
|
||||
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
|
||||
sitePointer: SiteContentPointer;
|
||||
spaceId: string;
|
||||
pageId: string | undefined;
|
||||
}) {
|
||||
@@ -45,7 +45,7 @@ async function sendSpaceTrackPageViewRequest(args: {
|
||||
|
||||
async function sendSiteTrackPageViewRequest(args: {
|
||||
apiHost: string;
|
||||
sitePointer: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
|
||||
sitePointer: SiteContentPointer;
|
||||
body: RequestSiteTrackPageView;
|
||||
}) {
|
||||
const { apiHost, sitePointer, body } = args;
|
||||
@@ -70,7 +70,7 @@ let latestPageId: string | undefined | null = null;
|
||||
*/
|
||||
async function trackPageView(args: {
|
||||
apiHost: string;
|
||||
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
|
||||
sitePointer: SiteContentPointer;
|
||||
spaceId: string;
|
||||
pageId: string | undefined;
|
||||
}) {
|
||||
|
||||
+19
-14
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationSettings,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
@@ -11,30 +11,28 @@ import assertNever from 'assert-never';
|
||||
import colorContrast from 'postcss-color-contrast/js';
|
||||
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 { getStaticFileURL } from '@/lib/assets';
|
||||
import { hexToRgb, shadesOfColor } from '@/lib/colors';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
import { emojiFontClassName } from '../primitives';
|
||||
|
||||
import './globals.css';
|
||||
import '@gitbook/icons/style.css';
|
||||
import { getContentPointer } from './fetch';
|
||||
|
||||
/**
|
||||
* Layout shared between the content and the PDF renderer.
|
||||
* It takes care of setting the theme and the language.
|
||||
*/
|
||||
export default async function SpaceRootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
export async function CustomizationRootLayout(props: {
|
||||
customization: SiteCustomizationSettings | CustomizationSettings;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { customization, children } = props;
|
||||
|
||||
const pointer = getContentPointer();
|
||||
const { customization } = await ('siteId' in pointer
|
||||
? getCurrentSiteLayoutData(pointer)
|
||||
: getSpaceLayoutData(pointer.spaceId));
|
||||
const headerTheme = generateHeaderTheme(customization);
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
@@ -88,7 +86,7 @@ export default async function SpaceRootLayout(props: { children: React.ReactNode
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
${generateColorVariable(
|
||||
'primary-base',
|
||||
customization.styling.primaryColor.light,
|
||||
@@ -200,9 +198,16 @@ function generateHeaderTheme(customization: CustomizationSettings | SiteCustomiz
|
||||
dark: customization.styling.primaryColor.dark,
|
||||
},
|
||||
linkColor: {
|
||||
// TODO: should depend on the color of the background
|
||||
light: colors.white,
|
||||
dark: colors.black,
|
||||
light: colorContrast(
|
||||
customization.styling.primaryColor.light,
|
||||
[colors.white, colors.black],
|
||||
'aaa',
|
||||
),
|
||||
dark: colorContrast(
|
||||
customization.styling.primaryColor.dark,
|
||||
[colors.white, colors.black],
|
||||
'aaa',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CustomizationRootLayout';
|
||||
@@ -78,13 +78,18 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
type: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// During development, the useEffect is called twice and the second call doesn't process the stream,
|
||||
// causing the component to get stuck in the loading state.
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
}, [spaceId, query, setSearchState, setState]);
|
||||
|
||||
@@ -94,10 +99,11 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
|
||||
};
|
||||
}, [setState]);
|
||||
|
||||
let hasAnswer = false;
|
||||
if (state && 'answer' in state) {
|
||||
hasAnswer = !!state?.answer?.body;
|
||||
}
|
||||
const loading = (
|
||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
||||
<Loading className={tcls('w-5', 'py-4', 'text-primary')} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,9 +118,9 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
|
||||
{state?.type === 'answer' ? (
|
||||
<>
|
||||
{state.answer ? (
|
||||
<div className={tcls('w-full')}>
|
||||
<AnswerBody answer={state.answer} />
|
||||
</div>
|
||||
<React.Suspense fallback={loading}>
|
||||
<TransitionAnswerBody answer={state.answer} placeholder={loading} />
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<div className={tcls('p-4')}>{t(language, 'search_ask_no_answer')}</div>
|
||||
)}
|
||||
@@ -123,15 +129,35 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
|
||||
{state?.type === 'error' ? (
|
||||
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
||||
) : null}
|
||||
{state?.type === 'loading' ? (
|
||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
||||
<Loading className={tcls('w-5', 'py-4', 'text-primary')} />
|
||||
</div>
|
||||
) : null}
|
||||
{state?.type === 'loading' ? loading : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since the answer can be an async component that could suspend rendering,
|
||||
* we need to wrap it in a transition to avoid flickering.
|
||||
*/
|
||||
function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: React.ReactNode }) {
|
||||
const { answer, placeholder } = props;
|
||||
const [display, setDisplay] = React.useState<AskAnswerResult | null>(null);
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
startTransition(() => {
|
||||
setDisplay(answer);
|
||||
});
|
||||
}, [answer]);
|
||||
|
||||
return display ? (
|
||||
<div className={tcls('w-full')}>
|
||||
<AnswerBody answer={display} />
|
||||
</div>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswerBody(props: { answer: AskAnswerResult }) {
|
||||
const { answer } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
@@ -50,9 +50,15 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
'ring-1',
|
||||
'ring-inset',
|
||||
'ring-dark/1',
|
||||
'contrast-more:ring-dark',
|
||||
'contrast-more:bg-light',
|
||||
'contrast-more:text-dark',
|
||||
'dark:bg-light/1',
|
||||
'dark:ring-light/1',
|
||||
'dark:text-light/7',
|
||||
'contrast-more:dark:ring-light',
|
||||
'contrast-more:dark:bg-dark',
|
||||
'contrast-more:dark:text-light',
|
||||
'[&>p]:hidden',
|
||||
'[&>span]:hidden',
|
||||
'md:justify-start',
|
||||
@@ -97,11 +103,13 @@ const Shortcut = () => {
|
||||
'justify-end',
|
||||
'text-xs',
|
||||
'text-dark/5',
|
||||
'contrast-more:text-dark',
|
||||
'dark:text-light/5',
|
||||
'contrast-more:dark:text-light',
|
||||
`[font-feature-settings:"calt",_"case"]`,
|
||||
)}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'} + K
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl +'} K
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { SearchAskAnswer, searchAskState } from './SearchAskAnswer';
|
||||
@@ -21,8 +22,9 @@ interface SearchModalProps {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
spaceTitle: string;
|
||||
parent: Site | Collection | null;
|
||||
isMultiVariants: boolean;
|
||||
withAsk: boolean;
|
||||
pointer: SiteContentPointer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +138,17 @@ function SearchModalBody(
|
||||
onClose: (to?: string) => void;
|
||||
},
|
||||
) {
|
||||
const { spaceId, revisionId, spaceTitle, withAsk, parent, state, onChangeQuery, onClose } =
|
||||
props;
|
||||
const {
|
||||
pointer,
|
||||
spaceId,
|
||||
revisionId,
|
||||
spaceTitle,
|
||||
withAsk,
|
||||
isMultiVariants,
|
||||
state,
|
||||
onChangeQuery,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
@@ -237,9 +248,10 @@ function SearchModalBody(
|
||||
{!state.ask || !withAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
pointer={pointer}
|
||||
spaceId={spaceId}
|
||||
revisionId={revisionId}
|
||||
parent={state.global ? parent : null}
|
||||
global={isMultiVariants && state.global}
|
||||
query={state.query}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={() => {
|
||||
@@ -250,7 +262,9 @@ function SearchModalBody(
|
||||
});
|
||||
}}
|
||||
>
|
||||
{parent && state.query ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
||||
{isMultiVariants && state.query ? (
|
||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||
) : null}
|
||||
</SearchResults>
|
||||
) : null}
|
||||
{state.query && state.ask && withAsk ? (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Collection, Site } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { isQuestion } from './isQuestion';
|
||||
@@ -12,8 +12,8 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||
import {
|
||||
getRecommendedQuestions,
|
||||
OrderedComputedResult,
|
||||
searchParentContent,
|
||||
searchSpaceContent,
|
||||
searchSiteSpaceContent,
|
||||
searchAllSiteContent,
|
||||
} from './server-actions';
|
||||
import { Loading } from '../primitives';
|
||||
|
||||
@@ -40,13 +40,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
query: string;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
parent: Site | Collection | null;
|
||||
global: boolean;
|
||||
withAsk: boolean;
|
||||
pointer: SiteContentPointer;
|
||||
onSwitchToAsk: () => void;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>,
|
||||
) {
|
||||
const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk } = props;
|
||||
const { children, query, pointer, spaceId, revisionId, global, withAsk, onSwitchToAsk } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const debounceTimeout = React.useRef<Timer | null>(null);
|
||||
@@ -93,9 +94,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
debounceTimeout.current = setTimeout(async () => {
|
||||
setCursor(null);
|
||||
|
||||
const fetchedResults = await (parent
|
||||
? searchParentContent(parent, query)
|
||||
: searchSpaceContent(spaceId, revisionId, query));
|
||||
const fetchedResults = await (global
|
||||
? searchAllSiteContent(query, pointer)
|
||||
: searchSiteSpaceContent(query, pointer, revisionId));
|
||||
|
||||
setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults);
|
||||
}, 350);
|
||||
@@ -107,7 +108,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [query, spaceId, revisionId, parent, withAsk]);
|
||||
}, [query, global, pointer, spaceId, revisionId, withAsk]);
|
||||
|
||||
// Scroll to the active result.
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
Collection,
|
||||
RevisionPage,
|
||||
SearchAIAnswer,
|
||||
SearchPageResult,
|
||||
Site,
|
||||
SiteSpace,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { RevisionPage, SearchAIAnswer, SearchPageResult, SiteSpace, Space } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { getContentPointer } from '@/app/(space)/fetch';
|
||||
import { streamResponse } from '@/lib/actions';
|
||||
import * as api from '@/lib/api';
|
||||
import { absoluteHref, pageHref } from '@/lib/links';
|
||||
@@ -52,116 +45,115 @@ export interface AskAnswerResult {
|
||||
hasAnswer: boolean;
|
||||
}
|
||||
|
||||
export async function searchSiteContent(args: {
|
||||
/**
|
||||
* Search for content in a site by scoping the search to all content, a specific spaces or current space.
|
||||
*/
|
||||
async function searchSiteContent(args: {
|
||||
pointer: api.SiteContentPointer;
|
||||
query: string;
|
||||
siteSpaceIds?: string[];
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
cacheBust?: string;
|
||||
}): Promise<OrderedComputedResult[]> {
|
||||
const { siteSpaceIds, query, cacheBust } = args;
|
||||
const pointer = getContentPointer();
|
||||
const { pointer, scope, query, cacheBust } = args;
|
||||
|
||||
if (query.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (siteSpaceIds?.length === 0) {
|
||||
// if we have no siteSpaces to search in then we won't find anything. skip the call.
|
||||
return [];
|
||||
}
|
||||
const needsStructure =
|
||||
scope.mode === 'all' ||
|
||||
scope.mode === 'current' ||
|
||||
(scope.mode === 'specific' && scope.siteSpaceIds.length > 1);
|
||||
|
||||
if ('siteId' in pointer && 'organizationId' in pointer) {
|
||||
const [searchResults, allSiteSpaces] = await Promise.all([
|
||||
api.searchSiteContent(
|
||||
pointer.organizationId,
|
||||
pointer.siteId,
|
||||
query,
|
||||
siteSpaceIds,
|
||||
cacheBust,
|
||||
),
|
||||
siteSpaceIds
|
||||
? null
|
||||
: api.getSiteSpaces({
|
||||
organizationId: pointer.organizationId,
|
||||
siteId: pointer.siteId,
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
}),
|
||||
]);
|
||||
const [searchResults, siteStructure] = await Promise.all([
|
||||
api.searchSiteContent(pointer.organizationId, pointer.siteId, query, scope, cacheBust),
|
||||
needsStructure
|
||||
? api.getSiteStructure({
|
||||
organizationId: pointer.organizationId,
|
||||
siteId: pointer.siteId,
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
if (!siteSpaceIds) {
|
||||
// We are searching all of this Site's content
|
||||
return searchResults.items
|
||||
.map((spaceItem) => {
|
||||
const siteSpace = allSiteSpaces?.find(
|
||||
(siteSpace) => siteSpace.space.id === spaceItem.id,
|
||||
);
|
||||
const siteSpaces = siteStructure
|
||||
? siteStructure.type === 'siteSpaces'
|
||||
? siteStructure.structure
|
||||
: siteStructure.structure.reduce<SiteSpace[]>((prev, section) => {
|
||||
const sectionSiteSpaces = section.siteSpaces.map((siteSpace) => ({
|
||||
...siteSpace,
|
||||
space: {
|
||||
...siteSpace.space,
|
||||
title: section.title + ' › ' + siteSpace.space.title,
|
||||
},
|
||||
}));
|
||||
|
||||
return spaceItem.pages.map((item) => transformSitePageResult(item, siteSpace));
|
||||
})
|
||||
.flat(2);
|
||||
}
|
||||
prev.push(...sectionSiteSpaces);
|
||||
return prev;
|
||||
}, [])
|
||||
: null;
|
||||
|
||||
if (siteSpaces) {
|
||||
// We are searching all of this Site's content
|
||||
return searchResults.items
|
||||
.map((spaceItem) => {
|
||||
return spaceItem.pages.map((item) => transformPageResult(item));
|
||||
const siteSpace = siteSpaces.find(
|
||||
(siteSpace) => siteSpace.space.id === spaceItem.id,
|
||||
);
|
||||
|
||||
return spaceItem.pages.map((item) => transformSitePageResult(item, siteSpace));
|
||||
})
|
||||
.flat(2);
|
||||
}
|
||||
|
||||
// This should never happen
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in a space
|
||||
*/
|
||||
export async function searchSpaceContent(
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
query: string,
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
const pointer = getContentPointer();
|
||||
|
||||
if ('siteId' in pointer && 'organizationId' in pointer) {
|
||||
const siteSpaceIds = pointer.siteSpaceId ? [pointer.siteSpaceId] : []; // if we don't have a siteSpaceID search all content
|
||||
|
||||
// This is a site so use a different function which we can eventually call directly
|
||||
// We also want to break cache for this specific space if the revisionId is different so use it as a cache busting key
|
||||
return await searchSiteContent({ siteSpaceIds, query, cacheBust: revisionId });
|
||||
}
|
||||
|
||||
const data = await api.searchSpaceContent(spaceId, revisionId, query);
|
||||
return data.items.map((item) => transformPageResult(item, undefined)).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in a parent (site or collection)
|
||||
*/
|
||||
export async function searchParentContent(
|
||||
parent: Site | Collection,
|
||||
query: string,
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
const pointer = getContentPointer();
|
||||
const isSite = 'siteId' in pointer;
|
||||
|
||||
if (isSite) {
|
||||
return searchSiteContent({ query });
|
||||
}
|
||||
|
||||
const [data, collectionSpaces] = await Promise.all([
|
||||
api.searchParentContent(parent.id, query),
|
||||
parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null,
|
||||
]);
|
||||
|
||||
let spaces: Space[] = collectionSpaces ? collectionSpaces : [];
|
||||
|
||||
return data.items
|
||||
return searchResults.items
|
||||
.map((spaceItem) => {
|
||||
const space = spaces.find((space) => space.id === spaceItem.id);
|
||||
return spaceItem.pages.map((item) => transformPageResult(item, space));
|
||||
return spaceItem.pages.map((item) => transformPageResult(item));
|
||||
})
|
||||
.flat(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in the entire site.
|
||||
*/
|
||||
export async function searchAllSiteContent(
|
||||
query: string,
|
||||
pointer: api.SiteContentPointer,
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return await searchSiteContent({
|
||||
pointer,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in a space.
|
||||
*/
|
||||
export async function searchSiteSpaceContent(
|
||||
query: string,
|
||||
pointer: api.SiteContentPointer,
|
||||
revisionId: string,
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
const siteSpaceId = pointer.siteSpaceId;
|
||||
assert(siteSpaceId, 'Expected siteSpaceId for searchSiteSpaceContent');
|
||||
|
||||
return await searchSiteContent({
|
||||
pointer,
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
// which searches in the current space + all default spaces of sections
|
||||
scope: pointer.siteSectionId
|
||||
? { mode: 'current', siteSpaceId }
|
||||
: { mode: 'specific', siteSpaceIds: [siteSpaceId] },
|
||||
// We want to break cache for this specific space if the revisionId is different so use it as a cache busting key
|
||||
cacheBust: revisionId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to ask a question in a space.
|
||||
*/
|
||||
@@ -223,7 +215,8 @@ function transformAnswer(
|
||||
mode: 'default',
|
||||
contentRefContext: null,
|
||||
resolveContentRef: async () => null,
|
||||
shouldHighlightCode: () => false,
|
||||
shouldHighlightCode: () => true,
|
||||
wrapBlocksInSuspense: false,
|
||||
}}
|
||||
style={['space-y-5']}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
import { SiteSection } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Button, Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* A set of tabs representing site sections for multi-section sites
|
||||
*/
|
||||
export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteSection }) {
|
||||
const { sections, section: currentSection } = props;
|
||||
|
||||
const tabs = sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
path: section.urls.published ?? '',
|
||||
}));
|
||||
|
||||
const currentTabRef = React.useRef<HTMLAnchorElement>(null);
|
||||
const navRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = React.useState(
|
||||
sections.findIndex((section) => section.id === currentSection?.id),
|
||||
);
|
||||
const [tabDimensions, setTabDimensions] = React.useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentTabRef.current && navRef.current) {
|
||||
const rect = currentTabRef.current.getBoundingClientRect();
|
||||
const navRect = navRef.current.getBoundingClientRect();
|
||||
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
|
||||
}
|
||||
}, [currentIndex]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
function onResize() {
|
||||
if (currentTabRef.current && navRef.current) {
|
||||
const rect = currentTabRef.current.getBoundingClientRect();
|
||||
const navRect = navRef.current.getBoundingClientRect();
|
||||
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
() => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const scale = (tabDimensions?.width ?? 0) * 0.01;
|
||||
const startPos = `${tabDimensions?.left ?? 0}px`;
|
||||
|
||||
const hasMoreSections = false; /** TODO: determine whether we need to show the more button */
|
||||
|
||||
return tabs.length > 0 ? (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
ref={navRef}
|
||||
className="flex flex-nowrap items-center max-w-screen mb-px"
|
||||
style={
|
||||
{
|
||||
'--tab-scale': `${scale}`,
|
||||
'--tab-start': `${startPos}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'relative flex gap-2 bg-transparent',
|
||||
/* add a pseudo element for active tab indicator */
|
||||
"after:block after:content-[''] after:origin-left after:absolute after:-bottom-px after:left-0 after:scale-x-[--tab-scale] after:transition-transform after:translate-x-[var(--tab-start)] after:h-0.5 after:w-[100px] after:bg-primary dark:after:bg-primary-500",
|
||||
)}
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
active={currentIndex === index}
|
||||
key={index + tab.path}
|
||||
label={tab.label}
|
||||
href={tab.path}
|
||||
ref={currentIndex === index ? currentTabRef : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreSections ? <MoreSectionsButton /> : null}
|
||||
</nav>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab item - a link to a site section
|
||||
*/
|
||||
const Tab = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
{ active: boolean; href: string; label: string; onClick?: () => void }
|
||||
>(function Tab(props, ref) {
|
||||
const { active, href, label, onClick } = props;
|
||||
return (
|
||||
<Link
|
||||
className={tcls(
|
||||
'px-3 py-3.5 rounded',
|
||||
active && 'text-primary dark:text-primary-500',
|
||||
!active && 'hover:text-primary dark:hover:text-primary-500 transition-colors',
|
||||
)}
|
||||
role="tab"
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span ref={ref} className={tcls('inline-flex w-full truncate')}>
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Dropdown trigger for when there are too many sections to show them all
|
||||
*/
|
||||
function MoreSectionsButton() {
|
||||
return (
|
||||
<div>
|
||||
<Button variant="secondary" size="small">
|
||||
<Icon icon="ellipsis-h" size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SiteSectionTabs';
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSettings,
|
||||
Revision,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
RevisionPageGroup,
|
||||
Site,
|
||||
SiteCustomizationSettings,
|
||||
SiteSection,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
@@ -17,18 +17,21 @@ import { CONTAINER_STYLE } from '@/components/layout';
|
||||
import { ColorDebugger } from '@/components/primitives/ColorDebugger';
|
||||
import { SearchModal } from '@/components/Search';
|
||||
import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { ContentPointer, ContentTarget, SiteContentPointer } from '@/lib/api';
|
||||
import { ContentTarget, SiteContentPointer } from '@/lib/api';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { SiteSectionTabs } from '../SiteSectionTabs';
|
||||
|
||||
/**
|
||||
* Render the entire content of the space (header, table of contents, footer, and page content).
|
||||
*/
|
||||
export function SpaceLayout(props: {
|
||||
content: ContentPointer | SiteContentPointer;
|
||||
content: SiteContentPointer;
|
||||
contentTarget: ContentTarget;
|
||||
space: Space;
|
||||
parent: Site | Collection | null;
|
||||
site: Site | null;
|
||||
sections: { list: SiteSection[]; section: SiteSection } | null;
|
||||
spaces: Space[];
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
pages: Revision['pages'];
|
||||
@@ -38,7 +41,8 @@ export function SpaceLayout(props: {
|
||||
const {
|
||||
space,
|
||||
contentTarget,
|
||||
parent,
|
||||
site,
|
||||
sections,
|
||||
spaces,
|
||||
content,
|
||||
pages,
|
||||
@@ -50,24 +54,38 @@ export function SpaceLayout(props: {
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
siteContext: 'siteId' in content ? content : null,
|
||||
siteContext: content,
|
||||
space,
|
||||
revisionId: contentTarget.revisionId,
|
||||
pages,
|
||||
};
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 0);
|
||||
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <ColorDebugger /> */}
|
||||
<Header
|
||||
withTopHeader={withTopHeader}
|
||||
space={space}
|
||||
parent={parent}
|
||||
site={site}
|
||||
spaces={spaces}
|
||||
context={contentRefContext}
|
||||
customization={customization}
|
||||
/>
|
||||
|
||||
>
|
||||
{sections ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full shadow-thintop dark:shadow-light/1 bg-light dark:bg-dark z-[9] mt-0.5',
|
||||
)}
|
||||
>
|
||||
<div className={tcls(CONTAINER_STYLE)}>
|
||||
<SiteSectionTabs sections={sections.list} section={sections.section} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Header>
|
||||
<div className={tcls('scroll-nojump')}>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -92,13 +110,13 @@ export function SpaceLayout(props: {
|
||||
withTopHeader ? null : (
|
||||
<CompactHeader
|
||||
space={space}
|
||||
parent={parent}
|
||||
site={site}
|
||||
spaces={spaces}
|
||||
customization={customization}
|
||||
/>
|
||||
)
|
||||
}
|
||||
withHeaderOffset={withTopHeader}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
<div className={tcls('flex-1', 'flex', 'flex-col')}>{children}</div>
|
||||
</div>
|
||||
@@ -117,7 +135,8 @@ export function SpaceLayout(props: {
|
||||
revisionId={contentTarget.revisionId}
|
||||
spaceTitle={customization.title ?? space.title}
|
||||
withAsk={customization.aiSearch.enabled}
|
||||
parent={parent && spaces.length > 1 ? parent : null}
|
||||
isMultiVariants={Boolean(site && spaces.length > 1)}
|
||||
pointer={content}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import {
|
||||
RevisionPage,
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
RevisionPageType,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
@@ -19,7 +24,13 @@ export function PagesList(props: {
|
||||
return (
|
||||
<ul className={tcls('flex', 'flex-1', 'flex-col', 'gap-y-0.5', style)}>
|
||||
{pages.map((page) => {
|
||||
if (page.type === 'link') {
|
||||
if (page.type === RevisionPageType.Computed) {
|
||||
throw new Error(
|
||||
'Unexpected computed page, it should have been computed in the API',
|
||||
);
|
||||
}
|
||||
|
||||
if (page.type === RevisionPageType.Link) {
|
||||
return <PageLinkItem key={page.id} page={page} context={context} />;
|
||||
}
|
||||
|
||||
@@ -27,7 +38,7 @@ export function PagesList(props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (page.type === 'group') {
|
||||
if (page.type === RevisionPageType.Group) {
|
||||
return (
|
||||
<PageGroupItem
|
||||
key={page.id}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { ContentPointer } from '@/lib/api';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -16,17 +16,30 @@ import { PagesList } from './PagesList';
|
||||
import { TOCScrollContainer } from './TOCScroller';
|
||||
import { Trademark } from './Trademark';
|
||||
|
||||
function getTopOffset(props: { sectionsHeader: boolean; topHeader: boolean }) {
|
||||
if (props.sectionsHeader && props.topHeader) {
|
||||
return 'lg:top-32 lg:h-[calc(100vh_-_8rem)]';
|
||||
}
|
||||
if (props.sectionsHeader || props.topHeader) {
|
||||
return 'lg:top-16 lg:h-[calc(100vh_-_4rem)]';
|
||||
}
|
||||
return 'lg:top-0 lg:h-[100vh]';
|
||||
}
|
||||
|
||||
export function TableOfContents(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
content: ContentPointer;
|
||||
content: SiteContentPointer;
|
||||
context: ContentRefContext;
|
||||
pages: Revision['pages'];
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
header?: React.ReactNode;
|
||||
withHeaderOffset: boolean;
|
||||
headerOffset: { sectionsHeader: boolean; topHeader: boolean };
|
||||
}) {
|
||||
const { space, customization, pages, ancestors, header, context, withHeaderOffset } = props;
|
||||
const { space, customization, pages, ancestors, header, context, headerOffset } = props;
|
||||
|
||||
const withHeaderOffset = headerOffset.sectionsHeader || headerOffset.topHeader;
|
||||
const topOffset = getTopOffset(headerOffset);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -50,9 +63,8 @@ export function TableOfContents(props: {
|
||||
'lg:sticky',
|
||||
'dark:bg-dark',
|
||||
'dark:navigation-open:shadow-light/2',
|
||||
withHeaderOffset ? 'lg:h-[calc(100vh_-_4rem)]' : 'lg:h-[100vh]',
|
||||
withHeaderOffset ? 'lg:top-16' : 'lg:top-0',
|
||||
'page-no-toc:hidden',
|
||||
topOffset,
|
||||
)}
|
||||
>
|
||||
{header ? header : null}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function Button({
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className={domClassName}>
|
||||
<button type="button" onClick={onClick} className={domClassName}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -28,10 +28,10 @@ export function SkeletonParagraph(props: { id?: string; style?: ClassValue }) {
|
||||
export function SkeletonHeading(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
return (
|
||||
<div id={id} role="status" aria-busy className="skeleton-heading">
|
||||
<div id={id} role="status" aria-busy className={tcls(style)}>
|
||||
<LoadingPane
|
||||
tile={12}
|
||||
style={['rounded-md', 'h-[47px]', '[max-width:calc(48rem-1px)]', style]}
|
||||
style={['rounded-md', 'h-[47px]', '[max-width:calc(48rem-1px)]']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -43,16 +43,10 @@ export function SkeletonHeading(props: { id?: string; style?: ClassValue }) {
|
||||
export function SkeletonImage(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
return (
|
||||
<div id={id} role="status" aria-busy className="skeleton-image">
|
||||
<div id={id} role="status" aria-busy className={tcls(style)}>
|
||||
<LoadingPane
|
||||
tile={96}
|
||||
style={[
|
||||
'rounded-md',
|
||||
'h-full',
|
||||
'aspect-video',
|
||||
'[max-width:calc(48rem-1px)]',
|
||||
style,
|
||||
]}
|
||||
style={['rounded-md', 'h-full', 'aspect-video', '[max-width:calc(48rem-1px)]']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -64,12 +58,7 @@ export function SkeletonImage(props: { id?: string; style?: ClassValue }) {
|
||||
export function SkeletonCard(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="status"
|
||||
aria-busy
|
||||
className={tcls('skeleton-card', 'flex', 'gap-[25px]', style)}
|
||||
>
|
||||
<div id={id} role="status" aria-busy className={tcls('flex', 'gap-[25px]', style)}>
|
||||
<LoadingPane tile={24} delay={0} style={['rounded-md', 'aspect-[1/1.2]', 'w-full']} />
|
||||
<LoadingPane tile={24} delay={1} style={['rounded-md', 'aspect-[1/1.2]', 'w-full']} />
|
||||
<LoadingPane tile={24} delay={2} style={['rounded-md', 'aspect-[1/1.2]', 'w-full']} />
|
||||
@@ -83,10 +72,10 @@ export function SkeletonCard(props: { id?: string; style?: ClassValue }) {
|
||||
export function SkeletonSmall(props: { id?: string; style?: ClassValue }) {
|
||||
const { id, style } = props;
|
||||
return (
|
||||
<div id={id} role="status" aria-busy className="skeleton-small">
|
||||
<div id={id} role="status" aria-busy className={tcls(style)}>
|
||||
<LoadingPane
|
||||
tile={12}
|
||||
style={['rounded-md', 'h-[35px]', '[max-width:calc(48rem-1px)]', style]}
|
||||
style={['rounded-md', 'h-[35px]', '[max-width:calc(48rem-1px)]']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,12 @@ type ImageSource = {
|
||||
aspectRatio?: string;
|
||||
};
|
||||
|
||||
type ImageSourceSized = {
|
||||
src: string;
|
||||
size: ImageSize | null;
|
||||
aspectRatio?: string;
|
||||
};
|
||||
|
||||
export type ImageResponsiveSize = {
|
||||
/** Media query to apply this width for */
|
||||
media?: string;
|
||||
@@ -96,7 +102,7 @@ interface ImgDOMPropsWithSrc extends React.ComponentPropsWithoutRef<'img'> {
|
||||
* We don't use the `next/image` component because we need to load images from external sources,
|
||||
* and we want to avoid client components.
|
||||
*/
|
||||
export async function Image(
|
||||
export function Image(
|
||||
props: PolymorphicComponentProp<
|
||||
'img',
|
||||
{
|
||||
@@ -145,26 +151,114 @@ export async function Image(
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePicture(
|
||||
props: PolymorphicComponentProp<
|
||||
'img',
|
||||
{
|
||||
source: ImageSource;
|
||||
} & ImageCommonProps
|
||||
>,
|
||||
) {
|
||||
const { source, ...rest } = props;
|
||||
const { size } = source;
|
||||
|
||||
return size ? (
|
||||
<ImagePictureSized {...rest} source={{ ...source, size }} />
|
||||
) : (
|
||||
<ImagePictureUnsized {...rest} source={source} />
|
||||
);
|
||||
}
|
||||
|
||||
async function ImagePictureUnsized(
|
||||
props: PolymorphicComponentProp<
|
||||
'img',
|
||||
{
|
||||
source: ImageSource;
|
||||
} & ImageCommonProps
|
||||
>,
|
||||
) {
|
||||
const { source, ...rest } = props;
|
||||
|
||||
const size = await getImageSize(source.src);
|
||||
return <ImagePictureSized {...rest} source={{ ...source, size }} />;
|
||||
}
|
||||
|
||||
function ImagePictureSized(
|
||||
props: PolymorphicComponentProp<
|
||||
'img',
|
||||
{
|
||||
source: ImageSourceSized;
|
||||
} & ImageCommonProps
|
||||
>,
|
||||
) {
|
||||
const {
|
||||
source,
|
||||
sizes,
|
||||
style: _style,
|
||||
alt,
|
||||
quality = 100,
|
||||
priority = 'normal',
|
||||
inline = false,
|
||||
zoom = false,
|
||||
resize = true,
|
||||
preload = false,
|
||||
inlineStyle,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && sizes.length === 0) {
|
||||
throw new Error('You must provide at least one size for the image.');
|
||||
}
|
||||
|
||||
const attrs = getImageAttributes({ sizes, source, quality, resize });
|
||||
const canBeFetched = checkIsHttpURL(attrs.src);
|
||||
const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined;
|
||||
const loading = priority === 'lazy' ? 'lazy' : undefined;
|
||||
const aspectRatioStyle = source.aspectRatio ? { aspectRatio: source.aspectRatio } : {};
|
||||
const style = { ...aspectRatioStyle, ...inlineStyle };
|
||||
|
||||
// Preload the image if needed.
|
||||
if (fetchPriority === 'high' || preload) {
|
||||
ReactDOM.preload(attrs.src, {
|
||||
as: 'image',
|
||||
imageSrcSet: attrs.srcSet,
|
||||
imageSizes: attrs.sizes,
|
||||
fetchPriority,
|
||||
});
|
||||
}
|
||||
|
||||
const imgProps: ImgDOMPropsWithSrc = {
|
||||
alt,
|
||||
style,
|
||||
loading,
|
||||
fetchPriority,
|
||||
...rest,
|
||||
...attrs,
|
||||
};
|
||||
|
||||
return zoom ? <ZoomImage {...imgProps} /> : <img {...imgProps} alt={imgProps.alt ?? ''} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes for an image.
|
||||
* src, srcSet, sizes, width, height, etc.
|
||||
*/
|
||||
async function getImageAttributes(params: {
|
||||
function getImageAttributes(params: {
|
||||
sizes: ImageResponsiveSize[];
|
||||
source: ImageSource;
|
||||
source: ImageSourceSized;
|
||||
quality: number;
|
||||
resize: boolean;
|
||||
}): Promise<{
|
||||
}): {
|
||||
src: string;
|
||||
srcSet?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}> {
|
||||
} {
|
||||
const { sizes, source, quality, resize } = params;
|
||||
let src = source.src;
|
||||
|
||||
const getURL = resize ? await getResizedImageURLFactory(source.src) : null;
|
||||
const getURL = resize ? getResizedImageURLFactory(source.src) : null;
|
||||
|
||||
if (!getURL) {
|
||||
return {
|
||||
@@ -204,11 +298,7 @@ async function getImageAttributes(params: {
|
||||
sourceSizes.push(`${defaultSize.width}px`);
|
||||
}
|
||||
|
||||
// If we don't know the size of the image, we can try reading it from the image itself.
|
||||
const size =
|
||||
source.size ?? (await getImageSize(source.src, { width: defaultSize?.width, dpr: 3 }));
|
||||
|
||||
if (!size) {
|
||||
if (!source.size) {
|
||||
return { src };
|
||||
}
|
||||
|
||||
@@ -216,7 +306,7 @@ async function getImageAttributes(params: {
|
||||
src,
|
||||
srcSet: sources.join(', '),
|
||||
sizes: sourceSizes.join(', '),
|
||||
...size,
|
||||
...source.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,59 +320,3 @@ function getFetchPriority(priority: ImageCommonProps['priority']) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function ImagePicture(
|
||||
props: PolymorphicComponentProp<
|
||||
'img',
|
||||
{
|
||||
source: ImageSource;
|
||||
} & ImageCommonProps
|
||||
>,
|
||||
) {
|
||||
const {
|
||||
source,
|
||||
sizes,
|
||||
style: _style,
|
||||
alt,
|
||||
quality = 100,
|
||||
priority = 'normal',
|
||||
inline = false,
|
||||
zoom = false,
|
||||
resize = true,
|
||||
preload = false,
|
||||
inlineStyle,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && sizes.length === 0) {
|
||||
throw new Error('You must provide at least one size for the image.');
|
||||
}
|
||||
|
||||
const attrs = await getImageAttributes({ sizes, source, quality, resize });
|
||||
const canBeFetched = checkIsHttpURL(attrs.src);
|
||||
const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined;
|
||||
const loading = priority === 'lazy' ? 'lazy' : undefined;
|
||||
const aspectRatioStyle = source.aspectRatio ? { aspectRatio: source.aspectRatio } : {};
|
||||
const style = { ...aspectRatioStyle, ...inlineStyle };
|
||||
|
||||
// Preload the image if needed.
|
||||
if (fetchPriority === 'high' || preload) {
|
||||
ReactDOM.preload(attrs.src, {
|
||||
as: 'image',
|
||||
imageSrcSet: attrs.srcSet,
|
||||
imageSizes: attrs.sizes,
|
||||
fetchPriority,
|
||||
});
|
||||
}
|
||||
|
||||
const imgProps: ImgDOMPropsWithSrc = {
|
||||
alt,
|
||||
style,
|
||||
loading,
|
||||
fetchPriority,
|
||||
...rest,
|
||||
...attrs,
|
||||
};
|
||||
|
||||
return zoom ? <ZoomImage {...imgProps} /> : <img {...imgProps} alt={imgProps.alt ?? ''} />;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+133
-100
@@ -8,11 +8,11 @@ import {
|
||||
GitBookAPIError,
|
||||
HttpResponse,
|
||||
List,
|
||||
PublishedContentLookup,
|
||||
PublishedSiteContentLookup,
|
||||
RequestRenderIntegrationUI,
|
||||
RevisionFile,
|
||||
SiteCustomizationSettings,
|
||||
RevisionReusableContent,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { headers } from 'next/headers';
|
||||
@@ -28,22 +28,27 @@ import {
|
||||
noCacheFetchOptions,
|
||||
parseCacheResponse,
|
||||
} from './cache';
|
||||
import { defaultCustomizationForSpace } from './utils';
|
||||
|
||||
/**
|
||||
* Pointer to a relative content, it might change overtime, the pointer is relative in the content history.
|
||||
*/
|
||||
export interface ContentPointer {
|
||||
export interface SpaceContentPointer {
|
||||
spaceId: string;
|
||||
changeRequestId?: string;
|
||||
revisionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer to a relative content, it might change overtime, the pointer is relative in the content history.
|
||||
* Pointer to a specific site content.
|
||||
*/
|
||||
export interface SiteContentPointer extends ContentPointer {
|
||||
export interface SiteContentPointer extends SpaceContentPointer {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
/**
|
||||
* ID of the siteSection. When rendering a multi-section site. Can be undefined.
|
||||
*/
|
||||
siteSectionId: string | undefined;
|
||||
/**
|
||||
* ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews)
|
||||
*/
|
||||
@@ -126,18 +131,25 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
|
||||
return apiSyncStorage.run(client, fn);
|
||||
}
|
||||
|
||||
// FIXME: Fix @gitbook/api types for PublishedSiteContentLookup
|
||||
type SpaceContentLookup = {
|
||||
kind: 'space';
|
||||
/** ID of the space matching. */
|
||||
space: string;
|
||||
/** Identifier of the change request being previewed in this URL. */
|
||||
changeRequest?: string;
|
||||
/** Identifier of the revision being previewed in this URL. */
|
||||
revision?: string;
|
||||
/** Path of the content relative to the space */
|
||||
pathname: string;
|
||||
/** Prefix of the path in the URL dedicated to the space */
|
||||
basePath: string;
|
||||
/** Short-lived API token to fetch content related to the space in the context of the URL. */
|
||||
apiToken: string;
|
||||
};
|
||||
|
||||
export type PublishedContentWithCache =
|
||||
| ((
|
||||
| PublishedContentLookup
|
||||
| (PublishedSiteContentLookup & {
|
||||
//TODO: Remove this once the @gitbook/api is bumped
|
||||
/**
|
||||
* Whether the resolved site URL is complete and at it's terminal point, meaning no more site path segments
|
||||
* can be further expected before any page path segments.
|
||||
*/
|
||||
complete: boolean;
|
||||
})
|
||||
) & {
|
||||
| ((SpaceContentLookup | PublishedSiteContentLookup) & {
|
||||
cacheMaxAge?: number;
|
||||
cacheTags?: string[];
|
||||
})
|
||||
@@ -322,23 +334,6 @@ export const getChangeRequest = cache({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the scripts to load for the space.
|
||||
*/
|
||||
export const getSpaceIntegrationScripts = cache({
|
||||
name: 'api.getSpaceIntegrationScripts',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.listSpaceIntegrationScripts(spaceId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
interface GetRevisionOptions {
|
||||
/**
|
||||
* Whether to fetch the Git metadata of the pages.
|
||||
@@ -496,6 +491,43 @@ const getRevisionFileById = cache({
|
||||
},
|
||||
});
|
||||
|
||||
const getRevisionReusableContentById = cache({
|
||||
name: 'api.getRevisionReusableContentById.v1',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
reusableContentId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const response = await (async () => {
|
||||
return api().spaces.getReusableContentInRevisionById(
|
||||
spaceId,
|
||||
revisionId,
|
||||
reusableContentId,
|
||||
{
|
||||
metadata: false,
|
||||
},
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
})();
|
||||
|
||||
return cacheResponse(response, cacheTtl_7days);
|
||||
} catch (error: any) {
|
||||
if (error instanceof GitBookAPIError && error.code === 404) {
|
||||
return { data: null, ...cacheTtl_7days };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all the files in a revision of a space.
|
||||
* It should not be used directly, use `getRevisionFile` instead.
|
||||
@@ -587,6 +619,30 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get reusable content in a revision.
|
||||
*/
|
||||
export const getReusableContent = async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
reusableContentId: string,
|
||||
): Promise<RevisionReusableContent | null> => {
|
||||
const hasRevisionInMemory = await getRevision.hasInMemory(spaceId, revisionId, {
|
||||
metadata: false,
|
||||
});
|
||||
|
||||
if (hasRevisionInMemory) {
|
||||
const revision = await getRevision(spaceId, revisionId, { metadata: false });
|
||||
return (
|
||||
revision.reusableContents.find(
|
||||
(reusableContent) => reusableContent.id === reusableContentId,
|
||||
) ?? null
|
||||
);
|
||||
} else {
|
||||
return getRevisionReusableContentById(spaceId, revisionId, reusableContentId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a document by its ID.
|
||||
*/
|
||||
@@ -746,6 +802,36 @@ export const getSiteSpaces = cache({
|
||||
},
|
||||
});
|
||||
|
||||
export const getSiteStructure = cache({
|
||||
name: 'api.getSiteStructure',
|
||||
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
/** Site share key that can be used as context to resolve site space published urls */
|
||||
siteShareKey: string | undefined;
|
||||
},
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
const response = await api().orgs.getSiteStructure(
|
||||
args.organizationId,
|
||||
args.siteId,
|
||||
{
|
||||
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
|
||||
},
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: response.data,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the scripts to load for the site.
|
||||
*/
|
||||
@@ -766,10 +852,10 @@ export const getSiteIntegrationScripts = cache({
|
||||
/**
|
||||
* Fetch all the data to render the current site at once.
|
||||
*/
|
||||
export async function getCurrentSiteData(pointer: SiteContentPointer) {
|
||||
export async function getSiteData(pointer: SiteContentPointer) {
|
||||
const [{ space, pages, contentTarget }, { customization, scripts }] = await Promise.all([
|
||||
getSpaceData(pointer, pointer.siteShareKey),
|
||||
getCurrentSiteLayoutData(pointer),
|
||||
getSpaceContentData(pointer, pointer.siteShareKey),
|
||||
getSiteLayoutData(pointer),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -784,22 +870,13 @@ export async function getCurrentSiteData(pointer: SiteContentPointer) {
|
||||
/**
|
||||
* Fetch all the layout data about the current site at once.
|
||||
*/
|
||||
export async function getCurrentSiteLayoutData(args: {
|
||||
export async function getSiteLayoutData(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,
|
||||
}),
|
||||
getCurrentSiteCustomization(args),
|
||||
getSiteIntegrationScripts(args.organizationId, args.siteId),
|
||||
]);
|
||||
|
||||
@@ -829,29 +906,12 @@ export async function getCurrentSiteCustomization(args: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
*/
|
||||
export const getSpaceCustomizationFromAPI = cache({
|
||||
name: 'api.getSpaceCustomization',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, {
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
*/
|
||||
export async function getSpaceCustomization(spaceId: string): Promise<CustomizationSettings> {
|
||||
const headersList = headers();
|
||||
const raw = await getSpaceCustomizationFromAPI(spaceId);
|
||||
const raw = defaultCustomizationForSpace();
|
||||
|
||||
const extend = headersList.get('x-gitbook-customization');
|
||||
if (extend) {
|
||||
@@ -910,30 +970,15 @@ export const getCollectionSpaces = cache({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all the data to render a space at once.
|
||||
*/
|
||||
export async function getSpaceData(pointer: ContentPointer, shareKey: string | undefined) {
|
||||
const [{ space, pages, contentTarget }, { customization, scripts }] = await Promise.all([
|
||||
getSpaceContentData(pointer, shareKey),
|
||||
getSpaceLayoutData(pointer.spaceId),
|
||||
]);
|
||||
|
||||
return {
|
||||
space,
|
||||
pages,
|
||||
contentTarget,
|
||||
customization,
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the content data about a space at once.
|
||||
* This function executes the requests in parallel and should be used as early as possible
|
||||
* instead of calling the individual functions.
|
||||
*/
|
||||
export async function getSpaceContentData(pointer: ContentPointer, shareKey: string | undefined) {
|
||||
export async function getSpaceContentData(
|
||||
pointer: SpaceContentPointer,
|
||||
shareKey: string | undefined,
|
||||
) {
|
||||
const [space, changeRequest] = await Promise.all([
|
||||
getSpace(pointer.spaceId, shareKey),
|
||||
pointer.changeRequestId ? getChangeRequest(pointer.spaceId, pointer.changeRequestId) : null,
|
||||
@@ -958,21 +1003,6 @@ export async function getSpaceContentData(pointer: ContentPointer, shareKey: str
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the layout data about a space at once.
|
||||
*/
|
||||
export async function getSpaceLayoutData(spaceId: string) {
|
||||
const [customization, scripts] = await Promise.all([
|
||||
getSpaceCustomization(spaceId),
|
||||
getSpaceIntegrationScripts(spaceId),
|
||||
]);
|
||||
|
||||
return {
|
||||
customization,
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search content in a space.
|
||||
*/
|
||||
@@ -1028,7 +1058,10 @@ export const searchSiteContent = cache({
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
query: string,
|
||||
siteSpaceIds?: string[],
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] },
|
||||
/** A cache bust param to avoid revalidating lot of cache entries by tags */
|
||||
cacheBust?: string,
|
||||
options?: CacheFunctionOptions,
|
||||
@@ -1038,7 +1071,7 @@ export const searchSiteContent = cache({
|
||||
siteId,
|
||||
{
|
||||
query,
|
||||
siteSpaceIds,
|
||||
...scope,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
|
||||
@@ -76,14 +76,14 @@ interface ResizeImageOptions {
|
||||
/**
|
||||
* Create a function to get resized image URLs for a given image URL.
|
||||
*/
|
||||
export async function getResizedImageURLFactory(
|
||||
export function getResizedImageURLFactory(
|
||||
input: string,
|
||||
): Promise<((options: ResizeImageOptions) => string) | null> {
|
||||
): ((options: ResizeImageOptions) => string) | null {
|
||||
if (!checkIsSizableImageURL(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signature = await generateSignatureV1(input);
|
||||
const signature = generateSignatureV1(input);
|
||||
|
||||
return (options) => {
|
||||
const url = new URL('/~gitbook/image', rootUrl());
|
||||
@@ -113,11 +113,8 @@ export async function getResizedImageURLFactory(
|
||||
* Create a new URL for an image with resized parameters.
|
||||
* The URL is signed and verified by the server.
|
||||
*/
|
||||
export async function getResizedImageURL(
|
||||
input: string,
|
||||
options: ResizeImageOptions,
|
||||
): Promise<string> {
|
||||
const factory = await getResizedImageURLFactory(input);
|
||||
export function getResizedImageURL(input: string, options: ResizeImageOptions): string {
|
||||
const factory = getResizedImageURLFactory(input);
|
||||
return factory?.(options) ?? input;
|
||||
}
|
||||
|
||||
@@ -129,7 +126,7 @@ export async function verifyImageSignature(
|
||||
{ signature, version }: { signature: string; version: '1' | '0' },
|
||||
): Promise<boolean> {
|
||||
const expectedSignature =
|
||||
version === '1' ? await generateSignatureV1(input) : await generateSignatureV0(input);
|
||||
version === '1' ? generateSignatureV1(input) : await generateSignatureV0(input);
|
||||
return expectedSignature === signature;
|
||||
}
|
||||
|
||||
@@ -236,7 +233,7 @@ const fnv1aUtf8Buffer = new Uint8Array(512);
|
||||
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
|
||||
* to know that it was the algorithm that was used.
|
||||
*/
|
||||
async function generateSignatureV1(input: string): Promise<string> {
|
||||
function generateSignatureV1(input: string): string {
|
||||
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,39 @@ describe('getURLLookupAlternatives', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c/d'))).toEqual({
|
||||
revision: undefined,
|
||||
changeRequest: undefined,
|
||||
basePath: undefined,
|
||||
urls: [
|
||||
{
|
||||
extraPath: 'a/b/c/d',
|
||||
url: 'https://docs.mycompany.com/',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'b/c/d',
|
||||
url: 'https://docs.mycompany.com/a',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'c/d',
|
||||
url: 'https://docs.mycompany.com/a/b',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'd',
|
||||
url: 'https://docs.mycompany.com/a/b/c',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: '',
|
||||
url: 'https://docs.mycompany.com/a/b/c/d',
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not match before the variant for a variant url', () => {
|
||||
@@ -216,29 +249,34 @@ describe('getURLLookupAlternatives', () => {
|
||||
});
|
||||
|
||||
it('should limit depth', () => {
|
||||
expect(getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c/d'))).toEqual({
|
||||
expect(getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c/d/e'))).toEqual({
|
||||
revision: undefined,
|
||||
changeRequest: undefined,
|
||||
basePath: undefined,
|
||||
urls: [
|
||||
{
|
||||
extraPath: 'a/b/c/d',
|
||||
extraPath: 'a/b/c/d/e',
|
||||
url: 'https://docs.mycompany.com/',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'b/c/d',
|
||||
extraPath: 'b/c/d/e',
|
||||
url: 'https://docs.mycompany.com/a',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'c/d',
|
||||
extraPath: 'c/d/e',
|
||||
url: 'https://docs.mycompany.com/a/b',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'd',
|
||||
extraPath: 'd/e',
|
||||
url: 'https://docs.mycompany.com/a/b/c',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'e',
|
||||
url: 'https://docs.mycompany.com/a/b/c/d',
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
@@ -269,7 +307,12 @@ describe('getURLLookupAlternatives', () => {
|
||||
{
|
||||
extraPath: 'c/d',
|
||||
url: 'https://docs.mycompany.com/a/~/b',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
extraPath: 'd',
|
||||
primary: true,
|
||||
url: 'https://docs.mycompany.com/a/~/b/c',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -90,8 +90,8 @@ export function getURLLookupAlternatives(input: URL) {
|
||||
pushAlternative(noPathURL, url.pathname.slice(1));
|
||||
}
|
||||
|
||||
// Otherwise match with the first two segments of the path
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
// Otherwise match with the first four segments of the path
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
if (pathSegments.length >= i) {
|
||||
const shortURL = new URL(url);
|
||||
shortURL.pathname = pathSegments.slice(0, i).join('/');
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Revision, RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import {
|
||||
Revision,
|
||||
RevisionPage,
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
RevisionPageType,
|
||||
} from '@gitbook/api';
|
||||
|
||||
export type AncestorRevisionPage = RevisionPageDocument | RevisionPageGroup;
|
||||
|
||||
@@ -14,7 +20,7 @@ export function resolvePagePath(
|
||||
ancestors: AncestorRevisionPage[],
|
||||
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined => {
|
||||
for (const page of pages) {
|
||||
if (page.type === 'link') {
|
||||
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -56,7 +62,7 @@ export function resolvePageId(
|
||||
ancestors: AncestorRevisionPage[],
|
||||
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined => {
|
||||
for (const page of pages) {
|
||||
if (page.type === 'link') {
|
||||
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -139,14 +145,14 @@ function resolvePageDocument(
|
||||
page: RevisionPage,
|
||||
ancestors: AncestorRevisionPage[],
|
||||
): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined {
|
||||
if (page.type === 'group') {
|
||||
if (page.type === RevisionPageType.Group) {
|
||||
const firstDocument = resolveFirstDocument(page.pages, [...ancestors, page]);
|
||||
if (firstDocument) {
|
||||
return firstDocument;
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (page.type === 'link') {
|
||||
} else if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -162,7 +168,7 @@ function flattenPages(
|
||||
): RevisionPageDocument[] {
|
||||
const result: RevisionPageDocument[] = [];
|
||||
for (const page of pages) {
|
||||
if (page.type === 'link') {
|
||||
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -170,7 +176,7 @@ function flattenPages(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (page.type === 'document') {
|
||||
if (page.type === RevisionPageType.Document) {
|
||||
result.push(page);
|
||||
}
|
||||
result.push(...flattenPages(page.pages, filter));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { SiteContentPointer, SpaceContentPointer } from './api';
|
||||
|
||||
/**
|
||||
* Get the current site content pointer from the headers
|
||||
*/
|
||||
export function getSiteContentPointer(): SiteContentPointer {
|
||||
const headerSet = headers();
|
||||
const spaceId = headerSet.get('x-gitbook-content-space');
|
||||
const siteId = headerSet.get('x-gitbook-content-site');
|
||||
const organizationId = headerSet.get('x-gitbook-content-organization');
|
||||
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
|
||||
const siteSectionId = headerSet.get('x-gitbook-content-site-section');
|
||||
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
|
||||
|
||||
if (!spaceId || !siteId || !organizationId) {
|
||||
throw new Error(
|
||||
'getSiteContentPointer is called outside the scope of a request processed by the middleware',
|
||||
);
|
||||
}
|
||||
|
||||
const pointer: SiteContentPointer = {
|
||||
siteId,
|
||||
spaceId,
|
||||
siteSectionId: siteSectionId ?? undefined,
|
||||
siteSpaceId: siteSpaceId ?? undefined,
|
||||
siteShareKey: siteShareKey ?? undefined,
|
||||
organizationId,
|
||||
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
|
||||
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
|
||||
};
|
||||
|
||||
return pointer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current space pointer from the headers. This should be used when rendering
|
||||
* the space in an isolated context (e.g. PDF generation).
|
||||
*/
|
||||
export function getSpacePointer(): SpaceContentPointer {
|
||||
const headerSet = headers();
|
||||
const spaceId = headerSet.get('x-gitbook-content-space');
|
||||
if (!spaceId) {
|
||||
throw new Error(
|
||||
'getSpacePointer is called outside the scope of a request processed by the middleware',
|
||||
);
|
||||
}
|
||||
|
||||
const pointer: SpaceContentPointer = {
|
||||
spaceId,
|
||||
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
|
||||
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
|
||||
};
|
||||
|
||||
return pointer;
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { ContentRef, Revision, RevisionFile, RevisionPageDocument, Space } from '@gitbook/api';
|
||||
import {
|
||||
ContentRef,
|
||||
Revision,
|
||||
RevisionFile,
|
||||
RevisionPageDocument,
|
||||
RevisionReusableContent,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
|
||||
import { PageIcon } from '@/components/PageIcon';
|
||||
|
||||
import {
|
||||
ContentPointer,
|
||||
SiteContentPointer,
|
||||
SpaceContentPointer,
|
||||
getCollection,
|
||||
getDocument,
|
||||
getReusableContent,
|
||||
getRevisionFile,
|
||||
getSpace,
|
||||
getSpaceContentData,
|
||||
@@ -35,6 +43,8 @@ export interface ResolvedContentRef {
|
||||
active: boolean;
|
||||
/** File, if the reference is a file */
|
||||
file?: RevisionFile;
|
||||
/** Resolved reusable content, if the ref points to reusable content on a revision. */
|
||||
reusableContent?: RevisionReusableContent;
|
||||
}
|
||||
|
||||
export interface ContentRefContext extends PageHrefContext {
|
||||
@@ -45,7 +55,7 @@ export interface ContentRefContext extends PageHrefContext {
|
||||
|
||||
/**
|
||||
* Site in which we are resolving the content reference.
|
||||
* If null, the site is not known (legacy published content mode)
|
||||
* If `null`, the site is not known (e.g. PDF generation in a private space).
|
||||
*/
|
||||
siteContext: SiteContentPointer | null;
|
||||
/**
|
||||
@@ -255,9 +265,24 @@ export async function resolveContentRef(
|
||||
};
|
||||
}
|
||||
|
||||
case 'synced-block': {
|
||||
return null;
|
||||
case 'reusable-content': {
|
||||
const reusableContent = await getReusableContent(
|
||||
space.id,
|
||||
revisionId,
|
||||
contentRef.reusableContent,
|
||||
);
|
||||
if (!reusableContent) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
href: gitbookAppHref(`/s/${space.id}`),
|
||||
text: reusableContent.title,
|
||||
active: false,
|
||||
reusableContent,
|
||||
};
|
||||
}
|
||||
case 'synced-block':
|
||||
return null;
|
||||
|
||||
default:
|
||||
assertNever(contentRef);
|
||||
@@ -269,7 +294,7 @@ async function resolveContentRefInSpace(
|
||||
siteContext: SiteContentPointer | null,
|
||||
contentRef: ContentRef,
|
||||
) {
|
||||
const pointer: ContentPointer = {
|
||||
const pointer: SpaceContentPointer = {
|
||||
spaceId,
|
||||
};
|
||||
|
||||
|
||||
@@ -31,13 +31,7 @@ export function isPageIndexable(
|
||||
/**
|
||||
* Return true if a space should be indexed by search engines.
|
||||
*/
|
||||
export function isSpaceIndexable({
|
||||
space,
|
||||
parent,
|
||||
}: {
|
||||
space: Space;
|
||||
parent: Site | Collection | null;
|
||||
}) {
|
||||
export function isSpaceIndexable({ space, site }: { space: Space; site: Site | null }) {
|
||||
const headerSet = headers();
|
||||
|
||||
if (
|
||||
@@ -55,16 +49,12 @@ export function isSpaceIndexable({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parent && parent.object === 'site') {
|
||||
return shouldIndexVisibility(parent.visibility);
|
||||
if (site) {
|
||||
return shouldIndexVisibility(site.visibility);
|
||||
}
|
||||
|
||||
if (space.visibility === ContentVisibility.InCollection) {
|
||||
return parent && parent.object === 'collection'
|
||||
? shouldIndexVisibility(parent.visibility)
|
||||
: false;
|
||||
}
|
||||
return shouldIndexVisibility(space.visibility);
|
||||
// space with no site should not be indexed
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldIndexVisibility(visibility: ContentVisibility | SiteVisibility) {
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationSettings,
|
||||
Site,
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import * as api from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Get the title to display for a content.
|
||||
*/
|
||||
export function getContentTitle(
|
||||
space: Space,
|
||||
customization: CustomizationSettings | SiteCustomizationSettings,
|
||||
parent: Site | Collection | null,
|
||||
space: api.Space,
|
||||
customization: api.CustomizationSettings | api.SiteCustomizationSettings,
|
||||
parent: api.Site | null,
|
||||
) {
|
||||
// When we are rendering a site, always give priority to the customization title first
|
||||
// and then fallback to the site title
|
||||
@@ -24,3 +18,57 @@ export function getContentTitle(
|
||||
// Otherwise the legacy behavior is not changed to avoid regressions
|
||||
return parent ? parent.title : (customization.title ?? space.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the customizations with the default values for a space.
|
||||
*/
|
||||
export function defaultCustomizationForSpace(): api.CustomizationSettings {
|
||||
return {
|
||||
internationalization: {
|
||||
inherit: false,
|
||||
locale: api.CustomizationLocale.En,
|
||||
},
|
||||
styling: {
|
||||
primaryColor: {
|
||||
dark: '#346DDB',
|
||||
light: '#346DDB',
|
||||
},
|
||||
corners: api.CustomizationCorners.Rounded,
|
||||
font: api.CustomizationFont.Inter,
|
||||
background: api.CustomizationBackground.Plain,
|
||||
},
|
||||
favicon: {},
|
||||
header: {
|
||||
preset: api.CustomizationHeaderPreset.Default,
|
||||
links: [],
|
||||
},
|
||||
footer: {
|
||||
groups: [],
|
||||
},
|
||||
themes: {
|
||||
default: api.CustomizationThemeMode.Light,
|
||||
toggeable: false,
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
},
|
||||
feedback: {
|
||||
enabled: false,
|
||||
},
|
||||
pdf: {
|
||||
enabled: true,
|
||||
},
|
||||
aiSearch: {
|
||||
enabled: true,
|
||||
},
|
||||
pagination: {
|
||||
enabled: true,
|
||||
},
|
||||
privacyPolicy: {},
|
||||
socialPreview: {},
|
||||
git: {
|
||||
showEditLink: false,
|
||||
},
|
||||
inherit: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitBookAPI } from '@gitbook/api';
|
||||
import { GitBookAPI, ContentAPITokenPayload } from '@gitbook/api';
|
||||
import { setTag, setContext } from '@sentry/nextjs';
|
||||
import assertNever from 'assert-never';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
getSpaceContentData,
|
||||
userAgent,
|
||||
withAPI,
|
||||
getSpaceLayoutData,
|
||||
DEFAULT_API_ENDPOINT,
|
||||
getCurrentSiteLayoutData,
|
||||
getSiteLayoutData,
|
||||
getSite,
|
||||
} from '@/lib/api';
|
||||
import { race } from '@/lib/async';
|
||||
@@ -76,32 +75,6 @@ export type LookupResult = PublishedContentWithCache & {
|
||||
cookies?: LookupCookies;
|
||||
};
|
||||
|
||||
// FIXME: This is a temporary type for now. Use @gitbook/api types when available.
|
||||
interface ContentAPIBaseToken {
|
||||
organization: string;
|
||||
spaces: string[];
|
||||
rateLimitMultiplier?: number;
|
||||
}
|
||||
|
||||
type SpaceAPIToken = ContentAPIBaseToken & {
|
||||
kind: 'space';
|
||||
space: string;
|
||||
};
|
||||
|
||||
type CollectionAPIToken = ContentAPIBaseToken & {
|
||||
kind: 'collection';
|
||||
collection: string;
|
||||
};
|
||||
|
||||
type SiteAPIToken = ContentAPIBaseToken & {
|
||||
kind: 'site';
|
||||
site: string;
|
||||
siteSpace: string;
|
||||
space: string;
|
||||
};
|
||||
|
||||
type ContentAPITokenPayload = SpaceAPIToken | CollectionAPIToken | SiteAPIToken;
|
||||
|
||||
/**
|
||||
* Middleware to lookup the space to render.
|
||||
* It takes as input a request with an URL, and a set of headers:
|
||||
@@ -202,12 +175,12 @@ export async function middleware(request: NextRequest) {
|
||||
);
|
||||
|
||||
const { scripts } = await ('site' in resolved
|
||||
? getCurrentSiteLayoutData({
|
||||
? getSiteLayoutData({
|
||||
organizationId: resolved.organization,
|
||||
siteId: resolved.site,
|
||||
siteSpaceId: resolved.siteSpace,
|
||||
})
|
||||
: getSpaceLayoutData(resolved.space));
|
||||
: { scripts: [] });
|
||||
return getContentSecurityPolicy(scripts, nonce);
|
||||
},
|
||||
);
|
||||
@@ -227,6 +200,9 @@ export async function middleware(request: NextRequest) {
|
||||
if ('site' in resolved) {
|
||||
headers.set('x-gitbook-content-organization', resolved.organization);
|
||||
headers.set('x-gitbook-content-site', resolved.site);
|
||||
if (resolved.siteSection) {
|
||||
headers.set('x-gitbook-content-site-section', resolved.siteSection);
|
||||
}
|
||||
if (resolved.siteSpace) {
|
||||
headers.set('x-gitbook-content-site-space', resolved.siteSpace);
|
||||
}
|
||||
@@ -398,6 +374,7 @@ async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult> {
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'space',
|
||||
space: spaceId,
|
||||
basePath: '',
|
||||
pathname: url.pathname,
|
||||
@@ -544,7 +521,8 @@ async function lookupSiteOrSpaceInMultiIdMode(
|
||||
}
|
||||
|
||||
return {
|
||||
...decoded,
|
||||
// In multi-id mode, complete is always considered true because there is no URL to resolve
|
||||
...(decoded.kind === 'site' ? { ...decoded, complete: true } : decoded),
|
||||
changeRequest: changeRequestId,
|
||||
revision: revisionId,
|
||||
basePath: normalizePathname(basePathParts.join('/')),
|
||||
@@ -720,6 +698,7 @@ async function lookupSpaceByAPI(
|
||||
...('site' in data
|
||||
? {
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
organization: data.organization,
|
||||
shareKey: data.shareKey,
|
||||
|
||||
@@ -192,6 +192,7 @@ const config: Config = {
|
||||
},
|
||||
boxShadow: {
|
||||
thinbottom: '0px 1px 0px rgba(0, 0, 0, 0.05)',
|
||||
thintop: '0px -1px 0px rgba(0, 0, 0, 0.05)',
|
||||
'1xs': '0px 1px 1px rgba(0, 0, 0, 0.09), 0px 3.267px 2.754px rgb(0, 0, 0, 0.05), 0px 6.278px 6.63px rgb(0, 0, 0, 0.05), 0px 14px 22px rgb(0, 0, 0, 0.04)',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf ./dist && rm -rf ./src/data"
|
||||
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||
"unit": "bun test"
|
||||
},
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { it } from 'bun:test';
|
||||
import { validateIconName } from './icons';
|
||||
|
||||
it('should have the GitBook custom icon', () => {
|
||||
if (!validateIconName('gitbook')) {
|
||||
const message =
|
||||
'The GitBook icon is missing. It indicates that the dependencies were installed without the font-awesome custom package.';
|
||||
if (process.env.CI) {
|
||||
throw new Error(message);
|
||||
} else {
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -14,7 +14,10 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react",
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user