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).*)',
};