diff --git a/bun.lockb b/bun.lockb index e193aff75a..1959b56c5f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/e2e/pages.spec.ts b/e2e/pages.spec.ts index f032436f96..0f244c1ce3 100644 --- a/e2e/pages.spec.ts +++ b/e2e/pages.spec.ts @@ -40,6 +40,41 @@ async function waitForCookiesDialog(page: Page) { } const testCases: TestsCase[] = [ + { + name: 'GitBook Site', + baseUrl: 'https://gitbook.gitbook.io/gitbook-site/', + tests: [ + { + name: 'Home', + url: '', + run: waitForCookiesDialog, + }, + { + name: 'Search', + url: '?q=', + }, + { + name: 'Search Results', + url: '?q=gitbook', + run: async (page) => { + await page.waitForSelector('[data-test="search-results"]'); + }, + }, + { + name: 'AI Search', + url: '?q=What+is+GitBook%3F&ask=true', + run: async (page) => { + await page.waitForSelector('[data-test="search-ask-answer"]'); + }, + screenshot: false, + }, + { + name: 'Not found', + url: 'content-not-found', + run: waitForCookiesDialog, + }, + ], + }, { name: 'GitBook', baseUrl: 'https://docs.gitbook.com', diff --git a/package.json b/package.json index 3946c7166e..52494f3c59 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "dependencies": { "@geist-ui/icons": "^1.0.2", - "@gitbook/api": "^0.39.0", + "@gitbook/api": "^0.41.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@sentry/nextjs": "^7.94.1", diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index 4e286736f5..6e4a5ed661 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -3,7 +3,7 @@ "exports": "./src/index.ts", "dependencies": { "classnames": "^2.5.1", - "@gitbook/api": "^0.36.0", + "@gitbook/api": "^0.41.0", "assert-never": "^1.2.1" }, "peerDependencies": { diff --git a/src/app/(space)/(content)/[[...pathname]]/page.tsx b/src/app/(space)/(content)/[[...pathname]]/page.tsx index c273708563..4b3dfddf33 100644 --- a/src/app/(space)/(content)/[[...pathname]]/page.tsx +++ b/src/app/(space)/(content)/[[...pathname]]/page.tsx @@ -104,13 +104,13 @@ export async function generateViewport({ params }: { params: PagePathParams }): } export async function generateMetadata({ params }: { params: PagePathParams }): Promise { - const { space, pages, page, customization, collection } = await fetchPageData(params); + const { space, pages, page, customization, parent } = await fetchPageData(params); if (!page) { notFound(); } return { - title: [page.title, customization.title ?? space.title, collection?.title] + title: [page.title, customization.title ?? space.title, parent?.title] .filter(Boolean) .join(' | '), description: page.description ?? '', diff --git a/src/app/(space)/(content)/layout.tsx b/src/app/(space)/(content)/layout.tsx index 20ac6cbcc5..1272ca3305 100644 --- a/src/app/(space)/(content)/layout.tsx +++ b/src/app/(space)/(content)/layout.tsx @@ -32,8 +32,8 @@ export default async function ContentLayout(props: { children: React.ReactNode } contentTarget, customization, pages, - collection, - collectionSpaces, + parent, + spaces, ancestors, scripts, } = await fetchSpaceData(); @@ -56,8 +56,8 @@ export default async function ContentLayout(props: { children: React.ReactNode } { } export async function generateMetadata(): Promise { - const { space, collection, customization } = await fetchSpaceData(); + const { space, parent, customization } = await fetchSpaceData(); const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; return { - title: `${collection ? collection.title : customization.title ?? space.title}`, + title: `${parent ? parent.title : customization.title ?? space.title}`, generator: `GitBook (${buildVersion()})`, metadataBase: new URL(baseUrl()), icons: { @@ -125,7 +125,7 @@ export async function generateMetadata(): Promise { }, ], }, - robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow', + robots: shouldIndexSpace({ space, parent }) ? 'index, follow' : 'noindex, nofollow', }; } diff --git a/src/app/(space)/(core)/robots.txt/route.ts b/src/app/(space)/(core)/robots.txt/route.ts index 1f4b8cd190..7f199eed5f 100644 --- a/src/app/(space)/(core)/robots.txt/route.ts +++ b/src/app/(space)/(core)/robots.txt/route.ts @@ -1,7 +1,7 @@ import { ContentVisibility } from '@gitbook/api'; import { NextRequest } from 'next/server'; -import { getCollection, getSpace } from '@/lib/api'; +import { getCollection, getSite, getSpace } from '@/lib/api'; import { absoluteHref } from '@/lib/links'; import { shouldIndexSpace } from '@/lib/seo'; @@ -13,16 +13,19 @@ export const runtime = 'edge'; * Generate a robots.txt for the current space. */ export async function GET(req: NextRequest) { - const space = await getSpace(getContentPointer().spaceId); - const collection = - space.visibility === ContentVisibility.InCollection && space.parent - ? await getCollection(space.parent) - : null; + const pointer = getContentPointer(); + const space = await getSpace(pointer.spaceId); + const parent = + 'siteId' in pointer + ? await getSite(pointer.organizationId, pointer.siteId) + : space.visibility === ContentVisibility.InCollection && space.parent + ? await getCollection(space.parent) + : null; const lines = [ `User-agent: *`, 'Disallow: /~gitbook/', - ...(shouldIndexSpace({ space, collection }) + ...(shouldIndexSpace({ space, parent }) ? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`] : [`Disallow: /`]), ]; diff --git a/src/app/(space)/(core)/~gitbook/ogimage/[pageId]/route.tsx b/src/app/(space)/(core)/~gitbook/ogimage/[pageId]/route.tsx index 7d55db3380..760c82e3b4 100644 --- a/src/app/(space)/(core)/~gitbook/ogimage/[pageId]/route.tsx +++ b/src/app/(space)/(core)/~gitbook/ogimage/[pageId]/route.tsx @@ -11,7 +11,7 @@ export const runtime = 'edge'; * Render the OpenGraph image for a space. */ export async function GET(req: NextRequest, { params }: { params: PageIdParams }) { - const { space, page, customization, collection } = await fetchPageData(params); + const { space, page, customization, parent } = await fetchPageData(params); const url = new URL(space.urls.published ?? space.urls.app); if (customization.socialPreview.url) { @@ -31,7 +31,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams } }} >

- {collection?.title ?? customization.title ?? space.title} + {parent?.title ?? customization.title ?? space.title}

{page ? page.title : 'Not found'}

diff --git a/src/app/(space)/fetch.ts b/src/app/(space)/fetch.ts index 61d6861c88..11e4a18496 100644 --- a/src/app/(space)/fetch.ts +++ b/src/app/(space)/fetch.ts @@ -9,6 +9,10 @@ import { getDocument, getSpaceData, ContentTarget, + SiteContentPointer, + getSiteSpaceData, + getSite, + getSiteSpaces, } from '@/lib/api'; import { resolvePagePath, resolvePageId } from '@/lib/pages'; @@ -23,7 +27,7 @@ export interface PageIdParams { /** * Get the current content pointer from the params. */ -export function getContentPointer() { +export function getContentPointer(): ContentPointer | SiteContentPointer { const headerSet = headers(); const spaceId = headerSet.get('x-gitbook-content-space'); if (!spaceId) { @@ -32,13 +36,31 @@ export function getContentPointer() { ); } - const content: ContentPointer = { - spaceId, - revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined, - changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined, - }; + const siteId = headerSet.get('x-gitbook-content-site'); + if (siteId) { + const organizationId = headerSet.get('x-gitbook-content-organization'); + const siteSpaceId = headerSet.get('x-gitbook-content-site-space'); + if (!organizationId || !siteSpaceId) { + throw new Error('Missing site content headers'); + } - return content; + const siteContent: SiteContentPointer = { + siteId, + spaceId, + siteSpaceId, + organizationId, + revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined, + changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined, + }; + return siteContent; + } else { + const content: ContentPointer = { + spaceId, + revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined, + changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined, + }; + return content; + } } /** @@ -46,8 +68,14 @@ export function getContentPointer() { */ export async function fetchSpaceData() { const content = getContentPointer(); - const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content); - const collection = await fetchParentCollection(space); + + const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all( + 'siteId' in content + ? [getSiteSpaceData(content), fetchParentSite(content.organizationId, content.siteId)] + : [getSpaceData(content)], + ); + + const parent = await (parentSite ?? fetchParentCollection(space)); return { content, @@ -57,7 +85,7 @@ export async function fetchSpaceData() { customization, scripts, ancestors: [], - ...collection, + ...parent, }; } @@ -133,12 +161,26 @@ async function resolvePage( async function fetchParentCollection(space: Space) { const parentCollectionId = space.visibility === ContentVisibility.InCollection ? space.parent : undefined; - const [collection, collectionSpaces] = await Promise.all([ + const [collection, spaces] = await Promise.all([ parentCollectionId ? getCollection(parentCollectionId) : null, parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]), ]); - return { collection, collectionSpaces }; + return { parent: collection, spaces }; +} + +async function fetchParentSite(organizationId: string, siteId: string) { + const [site, siteSpaces] = await Promise.all([ + getSite(organizationId, siteId), + getSiteSpaces(organizationId, siteId), + ]); + + const spaces: Record = {}; + siteSpaces.forEach((siteSpace) => { + spaces[siteSpace.space.id] = siteSpace.space; + }); + + return { parent: site, spaces: Object.values(spaces) }; } /** diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 0c29936088..49e03a2e80 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,4 +1,4 @@ -import { CustomizationSettings, Space } from '@gitbook/api'; +import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api'; import React from 'react'; import { Image } from '@/components/utils'; @@ -12,7 +12,7 @@ import { ThemeToggler } from '../ThemeToggler'; export function Footer(props: { space: Space; context: ContentRefContext; - customization: CustomizationSettings; + customization: CustomizationSettings | SiteCustomizationSettings; }) { const { context, customization } = props; diff --git a/src/components/Header/CompactHeader.tsx b/src/components/Header/CompactHeader.tsx index c60326481a..4eba6e1f70 100644 --- a/src/components/Header/CompactHeader.tsx +++ b/src/components/Header/CompactHeader.tsx @@ -1,4 +1,10 @@ -import { Collection, CustomizationSettings, Space } from '@gitbook/api'; +import { + Collection, + CustomizationSettings, + Site, + SiteCustomizationSettings, + Space, +} from '@gitbook/api'; import React from 'react'; import { t } from '@/intl/server'; @@ -13,11 +19,11 @@ import { SearchButton } from '../Search'; */ export function CompactHeader(props: { space: Space; - collection: Collection | null; - collectionSpaces: Space[]; - customization: CustomizationSettings; + parent: Site | Collection | null; + spaces: Space[]; + customization: CustomizationSettings | SiteCustomizationSettings; }) { - const { space, collection, customization } = props; + const { space, parent, customization } = props; return (
- +
- - - {collection ? ( - - ) : null} - + + {parent ? : null} {customization.header.links.map((link, index) => { return ( diff --git a/src/components/Header/HeaderLink.tsx b/src/components/Header/HeaderLink.tsx index 3c05b6ea9e..46a037d6f4 100644 --- a/src/components/Header/HeaderLink.tsx +++ b/src/components/Header/HeaderLink.tsx @@ -3,6 +3,7 @@ import { CustomizationHeaderLink, CustomizationSettings, CustomizationHeaderPreset, + SiteCustomizationSettings, } from '@gitbook/api'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; @@ -20,7 +21,7 @@ import { Link } from '../primitives'; export async function HeaderLink(props: { context: ContentRefContext; link: CustomizationHeaderLink; - customization: CustomizationSettings; + customization: CustomizationSettings | SiteCustomizationSettings; }) { const { context, link, customization } = props; diff --git a/src/components/Header/HeaderLogo.tsx b/src/components/Header/HeaderLogo.tsx index afae0199e6..bfc4953386 100644 --- a/src/components/Header/HeaderLogo.tsx +++ b/src/components/Header/HeaderLogo.tsx @@ -1,4 +1,11 @@ -import { Collection, CustomizationHeaderPreset, CustomizationSettings, Space } from '@gitbook/api'; +import { + Collection, + CustomizationHeaderPreset, + CustomizationSettings, + Site, + SiteCustomizationSettings, + Space, +} from '@gitbook/api'; import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu'; import { Image } from '@/components/utils'; @@ -8,9 +15,9 @@ import { tcls } from '@/lib/tailwind'; import { Link } from '../primitives'; interface HeaderLogoProps { - collection: Collection | null; + parent: Site | Collection | null; space: Space; - customization: CustomizationSettings; + customization: CustomizationSettings | SiteCustomizationSettings; } /** @@ -86,7 +93,7 @@ export function HeaderLogo(props: HeaderLogoProps) { } function LogoFallback(props: HeaderLogoProps) { - const { collection, space, customization } = props; + const { parent, space, customization } = props; const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; return ( @@ -138,7 +145,7 @@ function LogoFallback(props: HeaderLogoProps) { : 'text-header-link', )} > - {collection ? collection.title : customization.title ?? space.title} + {parent ? parent.title : customization.title ?? space.title} ); diff --git a/src/components/Header/CollectionSpacesDropdown.tsx b/src/components/Header/SpacesDropdown.tsx similarity index 83% rename from src/components/Header/CollectionSpacesDropdown.tsx rename to src/components/Header/SpacesDropdown.tsx index 8019ee7192..0d398d859a 100644 --- a/src/components/Header/CollectionSpacesDropdown.tsx +++ b/src/components/Header/SpacesDropdown.tsx @@ -4,12 +4,8 @@ import { tcls } from '@/lib/tailwind'; import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown'; -export function CollectionSpacesDropdown(props: { - space: Space; - collection: Collection; - collectionSpaces: Space[]; -}) { - const { space, collectionSpaces } = props; +export function SpacesDropdown(props: { space: Space; spaces: Space[] }) { + const { space, spaces } = props; return ( - {collectionSpaces.map((otherSpace) => ( + {spaces.map((otherSpace) => ( void; }, ) { - const { - spaceId, - revisionId, - spaceTitle, - withAsk, - collectionId, - state, - onChangeQuery, - onClose, - } = props; + const { spaceId, revisionId, spaceTitle, withAsk, parent, state, onChangeQuery, onClose } = + props; const language = useLanguage(); const resultsRef = React.useRef(null); @@ -244,7 +237,7 @@ function SearchModalBody( ref={resultsRef} spaceId={spaceId} revisionId={revisionId} - collectionId={state.global ? collectionId : null} + parent={state.global ? parent : null} query={state.query} withAsk={withAsk} onSwitchToAsk={() => { @@ -256,7 +249,7 @@ function SearchModalBody( }} onClose={onClose} > - {collectionId && state.query ? ( + {parent && state.query ? ( ) : null} diff --git a/src/components/Search/SearchResults.tsx b/src/components/Search/SearchResults.tsx index 3926f33876..db88a2c97d 100644 --- a/src/components/Search/SearchResults.tsx +++ b/src/components/Search/SearchResults.tsx @@ -1,3 +1,4 @@ +import { Collection, Site } from '@gitbook/api'; import assertNever from 'assert-never'; import React from 'react'; @@ -11,7 +12,7 @@ import { SearchSectionResultItem } from './SearchSectionResultItem'; import { getRecommendedQuestions, OrderedComputedResult, - searchCollectionContent, + searchParentContent, searchSpaceContent, } from './server-actions'; import { Loading } from '../primitives'; @@ -39,15 +40,14 @@ export const SearchResults = React.forwardRef(function SearchResults( query: string; spaceId: string; revisionId: string; - collectionId: string | null; + parent: Site | Collection | null; withAsk: boolean; onSwitchToAsk: () => void; onClose: (to?: string) => void; }, ref: React.Ref, ) { - const { children, query, spaceId, revisionId, collectionId, withAsk, onSwitchToAsk, onClose } = - props; + const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk, onClose } = props; const language = useLanguage(); const debounceTimeout = React.useRef(null); @@ -94,8 +94,8 @@ export const SearchResults = React.forwardRef(function SearchResults( debounceTimeout.current = setTimeout(async () => { setCursor(null); - const fetchedResults = await (collectionId - ? searchCollectionContent(collectionId, query) + const fetchedResults = await (parent + ? searchParentContent(parent, query) : searchSpaceContent(spaceId, revisionId, query)); setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults); }, 250); @@ -107,7 +107,7 @@ export const SearchResults = React.forwardRef(function SearchResults( } }; } - }, [query, spaceId, revisionId, collectionId, withAsk]); + }, [query, spaceId, revisionId, parent, withAsk]); // Scroll to the active result. React.useEffect(() => { diff --git a/src/components/Search/server-actions.tsx b/src/components/Search/server-actions.tsx index bd179f3ecf..af26502648 100644 --- a/src/components/Search/server-actions.tsx +++ b/src/components/Search/server-actions.tsx @@ -1,7 +1,16 @@ 'use server'; -import { RevisionPage, SearchAIAnswer, SearchPageResult, Space } from '@gitbook/api'; - +import { + Collection, + RevisionPage, + SearchAIAnswer, + SearchPageResult, + Site, + Space, +} from '@gitbook/api'; +import { headers } from 'next/headers'; + +import { getContentPointer } from '@/app/(space)/fetch'; import { streamResponse } from '@/lib/actions'; import * as api from '@/lib/api'; import { absoluteHref, pageHref } from '@/lib/links'; @@ -55,20 +64,41 @@ export async function searchSpaceContent( } /** - * Server action to search content in a collection + * Server action to search content in a parent (site or collection) */ -export async function searchCollectionContent( - collectionId: string, +export async function searchParentContent( + parent: Site | Collection, query: string, ): Promise { - const [data, collectionSpaces] = await Promise.all([ - api.searchCollectionContent(collectionId, query), - api.getCollectionSpaces(collectionId), + const pointer = getContentPointer(); + + const [data, collectionSpaces, siteSpaces] = await Promise.all([ + api.searchParentContent(parent.id, query), + parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null, + parent.object === 'site' && 'organizationId' in pointer + ? api.getSiteSpaces(pointer.organizationId, parent.id) + : null, ]); + let spaces: Space[] = []; + + if (collectionSpaces) { + spaces = collectionSpaces; + } else if (siteSpaces) { + spaces = Object.values( + siteSpaces.reduce( + (acc, siteSpace) => { + acc[siteSpace.space.id] = siteSpace.space; + return acc; + }, + {} as Record, + ), + ); + } + return data.items .map((spaceItem) => { - const space = collectionSpaces.find((space) => space.id === spaceItem.id); + const space = spaces.find((space) => space.id === spaceItem.id); return spaceItem.pages.map((item) => transformPageResult(item, space)); }) .flat(2); diff --git a/src/components/SpaceLayout/SpaceLayout.tsx b/src/components/SpaceLayout/SpaceLayout.tsx index 80df824b23..9e044f83b5 100644 --- a/src/components/SpaceLayout/SpaceLayout.tsx +++ b/src/components/SpaceLayout/SpaceLayout.tsx @@ -5,6 +5,8 @@ import { Revision, RevisionPageDocument, RevisionPageGroup, + Site, + SiteCustomizationSettings, Space, } from '@gitbook/api'; import React from 'react'; @@ -26,9 +28,9 @@ export function SpaceLayout(props: { content: ContentPointer; contentTarget: ContentTarget; space: Space; - collection: Collection | null; - collectionSpaces: Space[]; - customization: CustomizationSettings; + parent: Site | Collection | null; + spaces: Space[]; + customization: CustomizationSettings | SiteCustomizationSettings; pages: Revision['pages']; ancestors: Array; children: React.ReactNode; @@ -36,8 +38,8 @@ export function SpaceLayout(props: { const { space, contentTarget, - collection, - collectionSpaces, + parent, + spaces, content, pages, customization, @@ -59,8 +61,8 @@ export function SpaceLayout(props: {
@@ -89,8 +91,8 @@ export function SpaceLayout(props: { withTopHeader ? null : ( ) @@ -114,7 +116,7 @@ export function SpaceLayout(props: { revisionId={contentTarget.revisionId} spaceTitle={customization.title ?? space.title} withAsk={customization.aiSearch.enabled} - collectionId={collection && collectionSpaces.length > 1 ? collection.id : null} + parent={parent && spaces.length > 1 ? parent : null} /> diff --git a/src/components/TableOfContents/TableOfContents.tsx b/src/components/TableOfContents/TableOfContents.tsx index dae8bbcdb6..673daae130 100644 --- a/src/components/TableOfContents/TableOfContents.tsx +++ b/src/components/TableOfContents/TableOfContents.tsx @@ -3,6 +3,7 @@ import { Revision, RevisionPageDocument, RevisionPageGroup, + SiteCustomizationSettings, Space, } from '@gitbook/api'; import React from 'react'; @@ -16,7 +17,7 @@ import { Trademark } from './Trademark'; export function TableOfContents(props: { space: Space; - customization: CustomizationSettings; + customization: CustomizationSettings | SiteCustomizationSettings; content: ContentPointer; context: ContentRefContext; pages: Revision['pages']; diff --git a/src/components/TableOfContents/Trademark.tsx b/src/components/TableOfContents/Trademark.tsx index 4e9fb6e052..41d76573c5 100644 --- a/src/components/TableOfContents/Trademark.tsx +++ b/src/components/TableOfContents/Trademark.tsx @@ -1,4 +1,4 @@ -import { CustomizationSettings, Space } from '@gitbook/api'; +import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api'; import { t, getSpaceLanguage } from '@/intl/server'; import { tcls } from '@/lib/tailwind'; @@ -8,7 +8,10 @@ import { IconLogo } from '../icons/IconLogo'; /** * Trademark link to the GitBook. */ -export function Trademark(props: { space: Space; customization: CustomizationSettings }) { +export function Trademark(props: { + space: Space; + customization: CustomizationSettings | SiteCustomizationSettings; +}) { return (
(client: GitBookAPI, fn: () => Promise): Promise } export type PublishedContentWithCache = - | (PublishedContentLookup & { + | ((PublishedContentLookup | PublishedSiteContentLookup) & { cacheMaxAge?: number; cacheTags?: string[]; }) @@ -581,6 +592,152 @@ export const getDocument = cache( }, ); +/** + * Get the customization settings for a site-space from the API. + */ +export const getSiteSpaceCustomizationFromAPI = cache( + 'api.getSiteSpaceCustomizationById', + async ( + organizationId: string, + siteId: string, + siteSpaceId: string, + options: CacheFunctionOptions, + ) => { + const response = await api().orgs.getSiteSpaceCustomizationById( + organizationId, + siteId, + siteSpaceId, + { + signal: options.signal, + ...noCacheFetchOptions, + }, + ); + return cacheResponse(response, { + revalidateBefore: 60 * 60, + tags: [ + getAPICacheTag({ + tag: 'site-space', + organization: organizationId, + site: siteId, + siteSpace: siteSpaceId, + }), + ], + }); + }, +); + +/** + * Get the customization settings for a site space from the API. + */ +export async function getSiteSpaceCustomization(args: { + organizationId: string; + siteId: string; + siteSpaceId: string; +}): Promise { + const headersList = headers(); + const raw = await getSiteSpaceCustomizationFromAPI( + args.organizationId, + args.siteId, + args.siteSpaceId, + ); + + const extend = headersList.get('x-gitbook-customization'); + if (extend) { + try { + const parsed = rison.decode_object>(extend); + return { ...raw, ...parsed }; + } catch (error) { + console.error( + `Failed to parse x-gitbook-customization header (ignored): ${ + (error as Error).stack ?? (error as Error).message ?? error + }`, + ); + } + } + + return raw; +} + +/** + * Get the infos about a site by its ID. + */ +export const getSite = cache( + 'api.getSite', + async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { + const response = await api().orgs.getSiteById(organizationId, siteId, { + ...noCacheFetchOptions, + signal: options.signal, + }); + return cacheResponse(response, { + revalidateBefore: 60 * 60, + tags: [getAPICacheTag({ tag: 'site', organization: organizationId, site: siteId })], + }); + }, +); + +/** + * List all the site-spaces variants published in a site. + */ +export const getSiteSpaces = cache( + 'api.getSiteSpaces', + async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { + const response = await getAll((params) => + api().orgs.listSiteSpaces(organizationId, siteId, params, { + ...noCacheFetchOptions, + signal: options.signal, + }), + ); + + return cacheResponse(response, { + revalidateBefore: 60 * 60, + data: response.data.items.map((siteSpace) => siteSpace), + tags: [getAPICacheTag({ tag: 'site', organization: organizationId, site: siteId })], + }); + }, +); + +/** + * Fetch all the data to render a site-space at once. + */ +export async function getSiteSpaceData(pointer: SiteContentPointer) { + const [{ space, pages, contentTarget }, { customization, scripts }] = await Promise.all([ + getSpaceData(pointer), + getSiteSpaceLayoutData(pointer), + ]); + + return { + space, + pages, + contentTarget, + customization, + scripts, + }; +} + +/** + * Fetch all the layout data about a site-space at once. + */ +export async function getSiteSpaceLayoutData(args: { + organizationId: string; + siteId: string; + siteSpaceId: string; + spaceId: string; +}) { + const [customization, scripts] = await Promise.all([ + getSiteSpaceCustomization({ + organizationId: args.organizationId, + siteId: args.siteId, + siteSpaceId: args.siteSpaceId, + }), + getSpaceIntegrationScripts(args.spaceId), + ]); + + return { + customization, + scripts, + }; +} + /** * Get the customization settings for a space from the API. */ @@ -752,11 +909,11 @@ export const searchSpaceContent = cache( ); /** - * Search content accross all spaces in a collection. + * Search content accross all spaces in a parent (site or collection). */ -export const searchCollectionContent = cache( - 'api.searchCollectionContent', - async (collectionId: string, query: string, options: CacheFunctionOptions) => { +export const searchParentContent = cache( + 'api.searchParentContent', + async (parentId: string, query: string, options: CacheFunctionOptions) => { const response = await api().search.searchContent( { query }, { @@ -834,6 +991,18 @@ export function getAPICacheTag( | { tag: 'synced-block'; syncedBlock: string; + } + // All data related to a site + | { + tag: 'site'; + site: string; + organization: string; + } + | { + tag: 'site-space'; + site: string; + siteSpace: string; + organization: string; }, ): string { switch (spec.tag) { @@ -845,6 +1014,10 @@ export function getAPICacheTag( return `collection:${spec.collection}`; case 'synced-block': return `synced-block:${spec.syncedBlock}`; + case 'site': + return `site:${spec.organization}:${spec.site}`; + case 'site-space': + return `site-space:${spec.organization}:${spec.site}:${spec.siteSpace}`; default: assertNever(spec); } diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 98d558edc1..43fdbfb9de 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -1,4 +1,4 @@ -import { Collection, ContentVisibility, Space } from '@gitbook/api'; +import { Collection, ContentVisibility, Site, SiteVisibility, Space } from '@gitbook/api'; import { headers } from 'next/headers'; /** @@ -6,10 +6,10 @@ import { headers } from 'next/headers'; */ export function shouldIndexSpace({ space, - collection, + parent, }: { space: Space; - collection: Collection | null; + parent: Site | Collection | null; }) { const headerSet = headers(); @@ -28,12 +28,18 @@ export function shouldIndexSpace({ return false; } + if (parent && parent.object === 'site') { + return shouldIndexVisibility(parent.visibility); + } + if (space.visibility === ContentVisibility.InCollection) { - return collection ? shouldIndexVisibility(collection.visibility) : false; + return parent && parent.object === 'collection' + ? shouldIndexVisibility(parent.visibility) + : false; } return shouldIndexVisibility(space.visibility); } -function shouldIndexVisibility(visibility: ContentVisibility) { +function shouldIndexVisibility(visibility: ContentVisibility | SiteVisibility) { return visibility === ContentVisibility.Public; } diff --git a/src/middleware.ts b/src/middleware.ts index 13edf50b9a..5f7eee2535 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -14,6 +14,7 @@ import { withAPI, getSpaceLayoutData, DEFAULT_API_ENDPOINT, + getSiteSpaceLayoutData, } from '@/lib/api'; import { race } from '@/lib/async'; import { buildVersion } from '@/lib/build'; @@ -139,6 +140,7 @@ export async function middleware(request: NextRequest) { space: resolved.space, changeRequest: resolved.changeRequest, revision: resolved.revision, + ...('site' in resolved ? { site: resolved.site, siteSpace: resolved.siteSpace } : {}), }); // Because of how Next will encode, we need to encode ourselves the pathname before rewriting to it. @@ -167,7 +169,14 @@ export async function middleware(request: NextRequest) { }), ); - const { scripts } = await getSpaceLayoutData(resolved.space); + const { scripts } = await ('site' in resolved + ? getSiteSpaceLayoutData({ + organizationId: resolved.organization, + siteId: resolved.site, + siteSpaceId: resolved.siteSpace, + spaceId: resolved.space, + }) + : getSpaceLayoutData(resolved.space)); return getContentSecurityPolicy(scripts, nonce); }, ); @@ -184,6 +193,11 @@ export async function middleware(request: NextRequest) { headers.set('x-gitbook-origin-basepath', originBasePath); headers.set('x-gitbook-basepath', joinPath(originBasePath, resolved.basePath)); headers.set('x-gitbook-content-space', resolved.space); + if ('site' in resolved) { + headers.set('x-gitbook-content-organization', resolved.organization); + headers.set('x-gitbook-content-site', resolved.site); + headers.set('x-gitbook-content-site-space', resolved.siteSpace); + } if (resolved.revision) { headers.set('x-gitbook-content-revision', resolved.revision); } @@ -612,6 +626,9 @@ async function lookupSpaceByAPI( apiToken: data.apiToken, cacheMaxAge: data.cacheMaxAge, cacheTags: data.cacheTags, + ...('site' in data + ? { site: data.site, siteSpace: data.siteSpace, organization: data.organization } + : {}), } as PublishedContentWithCache; });