diff --git a/bun.lockb b/bun.lockb index bad6bd4d1..39fb3bc97 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/app/[[...pathname]]/page.tsx b/src/app/[[...pathname]]/page.tsx deleted file mode 100644 index 0d93d8110..000000000 --- a/src/app/[[...pathname]]/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { headers } from 'next/headers'; - -import { api } from '@/lib/api'; -import { SpaceContent } from '@/components/SpaceContent'; - -interface PageParams { - pathname?: string[]; -} - -export default async function Page(props: { params: PageParams }) { - const { - params: { pathname }, - } = props; - - const { space, revision } = await fetchData(props.params); - - return ( - - ); -} - -export async function generateMetadata({ params }: { params: PageParams }) { - const { space, revision } = await fetchData(params); - - return { - title: space.title, - }; -} - -/** - * Fetch all the data needed for the page. - */ -async function fetchData(params: PageParams) { - const headersList = headers(); - const spaceId = headersList.get('x-gitbook-space'); - - if (!spaceId) { - throw new Error('Missing space id'); - } - - const [{ data: space }, { data: revision }] = await Promise.all([ - api().spaces.getSpaceById(spaceId), - api().spaces.getCurrentRevision(spaceId), - ]); - - return { - space, - revision, - }; -} diff --git a/src/app/[spaceId]/.gitbook/ogimage/[pageId]/route.tsx b/src/app/[spaceId]/.gitbook/ogimage/[pageId]/route.tsx new file mode 100644 index 000000000..ee6f84596 --- /dev/null +++ b/src/app/[spaceId]/.gitbook/ogimage/[pageId]/route.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { NextRequest, ImageResponse } from 'next/server'; +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 } = await fetchPageData(params); + + return new ImageResponse( + ( +
+
+
+

+ {space.title} +

+

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

+
+
+
+ ), + { + width: 1200, + height: 630, + }, + ); +} diff --git a/src/app/[spaceId]/[[...pathname]]/page.tsx b/src/app/[spaceId]/[[...pathname]]/page.tsx new file mode 100644 index 000000000..ac0d5b1f4 --- /dev/null +++ b/src/app/[spaceId]/[[...pathname]]/page.tsx @@ -0,0 +1,38 @@ +import { SpaceContent } from '@/components/SpaceContent'; +import { pageHref } from '@/lib/links'; +import { Metadata } from 'next'; +import { notFound, redirect } from 'next/navigation'; +import { PagePathParams, fetchPageData, getPagePath } from '../fetch'; + +/** + * Fetch and render a page. + */ +export default async function Page(props: { params: PagePathParams }) { + const { params } = props; + + const { space, revision, page } = await fetchPageData(props.params); + + if (!page) { + notFound(); + } else if (page.path !== getPagePath(params)) { + redirect(pageHref(page.path)); + } + + return ; +} + +export async function generateMetadata({ params }: { params: PagePathParams }): Promise { + const { space, page } = await fetchPageData(params); + if (!page) { + notFound(); + } + + return { + title: { default: page.title, template: `%s | ${space.title}` }, + description: page.description, + generator: 'GitBook', + openGraph: { + images: [pageHref('.gitbook/ogimage/' + page.id)], + }, + }; +} diff --git a/src/app/[spaceId]/fetch.ts b/src/app/[spaceId]/fetch.ts new file mode 100644 index 000000000..268eef5a0 --- /dev/null +++ b/src/app/[spaceId]/fetch.ts @@ -0,0 +1,128 @@ +import { api } from '@/lib/api'; +import { Revision, RevisionPage, RevisionPageDocument } from '@gitbook/api'; + +export interface SpaceParams { + spaceId: string; +} + +export interface PagePathParams extends SpaceParams { + pathname?: string[]; +} + +export interface PageIdParams extends SpaceParams { + pageId?: string; +} + +/** + * Fetch all the data needed for the page. + */ +export async function fetchPageData(params: PagePathParams | PageIdParams) { + const { spaceId } = params; + + const [{ data: space }, { data: revision }] = await Promise.all([ + api().spaces.getSpaceById(spaceId), + api().spaces.getCurrentRevision(spaceId), + ]); + + const page = + 'pageId' in params && params.pageId + ? resolvePageId(revision, params.pageId) + : resolvePagePath(revision, getPagePath(params)); + + return { + space, + revision, + page, + }; +} + +/** + * Get the page path from the params. + */ +export function getPagePath(params: PagePathParams): string { + const { pathname } = params; + return pathname ? pathname.join('/') : ''; +} + +/** + * Resolve a page path to a page document. + */ +function resolvePagePath(revision: Revision, pagePath: string): RevisionPageDocument | undefined { + const iteratePages = (pages: RevisionPage[]): RevisionPageDocument | undefined => { + for (const page of pages) { + if (page.type === 'link') { + continue; + } + + if (page.path !== pagePath) { + // TODO: can be optimized to count the number of slashes and skip the entire subtree + const result = iteratePages(page.pages); + if (result) { + return result; + } + + continue; + } + + return resolvePageDocument(page); + } + }; + + if (!pagePath) { + const firstPage = resolveFirstDocument(revision.pages); + if (!firstPage) { + return undefined; + } + + return firstPage; + } + + return iteratePages(revision.pages); +} + +function resolvePageId(revision: Revision, pageId: string): RevisionPageDocument | undefined { + const iteratePages = (pages: RevisionPage[]): RevisionPageDocument | undefined => { + for (const page of pages) { + if (page.type === 'link') { + continue; + } + + if (page.id === pageId) { + return resolvePageDocument(page); + } + + const result = iteratePages(page.pages); + if (result) { + return result; + } + } + }; + return iteratePages(revision.pages); +} + +function resolveFirstDocument(pages: RevisionPage[]): RevisionPageDocument | undefined { + for (const page of pages) { + if (page.type === 'link') { + continue; + } + + return resolvePageDocument(page); + } + + return; +} + +function resolvePageDocument(page: RevisionPage): RevisionPageDocument | undefined { + if (page.type === 'group') { + const firstDocument = resolveFirstDocument(page.pages); + if (firstDocument) { + return firstDocument; + } + + return; + } else if (page.type === 'link') { + return undefined; + } + + return page; +} diff --git a/src/app/globals.css b/src/app/[spaceId]/globals.css similarity index 100% rename from src/app/globals.css rename to src/app/[spaceId]/globals.css diff --git a/src/app/layout.tsx b/src/app/[spaceId]/layout.tsx similarity index 73% rename from src/app/layout.tsx rename to src/app/[spaceId]/layout.tsx index bc255000d..412cd61b9 100644 --- a/src/app/layout.tsx +++ b/src/app/[spaceId]/layout.tsx @@ -3,7 +3,7 @@ import './globals.css'; const inter = Inter({ subsets: ['latin'] }); -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function SpaceRootLayout({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/src/components/SpaceContent/SpaceContent.tsx b/src/components/SpaceContent/SpaceContent.tsx index 4e420b9a3..155c8e8b0 100644 --- a/src/components/SpaceContent/SpaceContent.tsx +++ b/src/components/SpaceContent/SpaceContent.tsx @@ -1,95 +1,31 @@ -import { Revision, RevisionPage, RevisionPageDocument, Space } from '@gitbook/api'; +import { Revision, RevisionPageDocument, Space } from '@gitbook/api'; import { TableOfContents } from '@/components/TableOfContents'; import clsx from 'clsx'; import { Header } from '@/components/Header'; import { PageBody } from '@/components/PageBody'; -import { notFound, redirect } from 'next/navigation'; -import { pageHref } from '@/lib/links'; /** * Render the entire content of the space (header, table of contents, footer, and page content). */ -export function SpaceContent(props: { space: Space; revision: Revision; pagePath: string }) { - const { space, revision, pagePath } = props; - const activePage = resolvePagePath(revision, pagePath); +export function SpaceContent(props: { + space: Space; + revision: Revision; + page: RevisionPageDocument; +}) { + const { space, revision, page } = props; return (
- +
- +
); } - -function resolvePagePath(revision: Revision, pagePath: string): RevisionPageDocument { - const resolveFirstDocument = (pages: RevisionPage[]): RevisionPageDocument | undefined => { - for (const page of pages) { - if (page.type === 'link') { - continue; - } - - return resolvePage(page); - } - - return; - }; - - const resolvePage = (page: RevisionPage): RevisionPageDocument => { - if (page.type === 'group') { - const firstDocument = resolveFirstDocument(page.pages); - if (firstDocument) { - redirect(pageHref(firstDocument.path)); - } - - notFound(); - } else if (page.type === 'link') { - notFound(); - } - - return page; - }; - - const iteratePages = (pages: RevisionPage[]): RevisionPageDocument | undefined => { - for (const page of pages) { - if (page.type === 'link') { - continue; - } - - if (page.path !== pagePath) { - // TODO: can be optimized to count the number of slashes and skip the entire subtree - const result = iteratePages(page.pages); - if (result) { - return result; - } - - continue; - } - - return resolvePage(page); - } - }; - - if (!pagePath) { - const firstPage = resolveFirstDocument(revision.pages); - if (!firstPage) { - notFound(); - } - - return firstPage; - } - - const result = iteratePages(revision.pages); - if (!result) { - notFound(); - } - - return result; -} diff --git a/src/lib/links.ts b/src/lib/links.ts index 889627d44..f99a4f3e1 100644 --- a/src/lib/links.ts +++ b/src/lib/links.ts @@ -2,12 +2,17 @@ import 'server-only'; import { headers } from 'next/headers'; +/** + * Return the base path for the current request. + */ +export function basePath(): string { + const headersList = headers(); + return headersList.get('x-gitbook-basepath') ?? ''; +} + /** * Create a link to a page path in the current space. */ export function pageHref(pagePath: string): string { - const headersList = headers(); - const basePath = headersList.get('x-gitbook-basepath') ?? ''; - - return `${basePath}/${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`; + return `${basePath()}/${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`; } diff --git a/src/middleware.ts b/src/middleware.ts index 55fe554a3..da17a6b74 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,24 +2,25 @@ import { NextResponse } from 'next/server'; import { NextRequest } from 'next/server'; /** - * Rewrite the request to extract the spaceId from the URL - * and pass it as a header. + * Middleware to add the base path to the request headers. */ export function middleware(request: NextRequest) { const url = new URL(request.url); - const [space, ...pathRest] = url.pathname.slice(1).split('/'); - const headers = new Headers(request.headers); - headers.set('x-gitbook-space', space); - headers.set('x-gitbook-basepath', `/${space}`); + headers.set('x-gitbook-basepath', headers.get('x-gitbook-basepath') ?? getDefaultBasePath(url)); - url.pathname = `/${pathRest.join('/')}`; - - return NextResponse.rewrite(url, { - headers, + return NextResponse.next({ + request: { + headers, + }, }); } +function getDefaultBasePath(url: URL) { + const [space] = url.pathname.slice(1).split('/'); + return `/${space}`; +} + export const config = { - matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)', + matcher: '/((?!_next/static|_next/image|favicon.ico).*)', };