Simplify and generate og:image for each page

This commit is contained in:
Samy Pessé
2023-10-23 21:32:29 +02:00
parent e29fb2091e
commit 02bbb39765
10 changed files with 239 additions and 143 deletions
BIN
View File
Binary file not shown.
-54
View File
@@ -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 (
<SpaceContent
space={space}
revision={revision}
pagePath={pathname ? pathname.join('/') : ''}
/>
);
}
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,
};
}
@@ -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(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'white',
}}
>
<div tw="bg-gray-50 flex flex-1">
<div tw="flex flex-col w-full py-16 px-14">
<h2 tw="text-7xl font-bold tracking-tight text-gray-900 text-left">
{space.title}
</h2>
<p tw="text-4xl text-indigo-600">{page ? page.title : 'Not found'}</p>
</div>
</div>
</div>
),
{
width: 1200,
height: 630,
},
);
}
@@ -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 <SpaceContent space={space} revision={revision} page={page} />;
}
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
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)],
},
};
}
+128
View File
@@ -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;
}
@@ -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 (
<html lang="en">
<body className={inter.className}>{children}</body>
+9 -73
View File
@@ -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 (
<div>
<Header space={space} />
<div className={clsx('max-w-8xl mx-auto px-4 sm:px-6 md:px-8')}>
<TableOfContents revision={revision} activePage={activePage} />
<TableOfContents revision={revision} activePage={page} />
<div className={clsx('lg:pl-[19.5rem]')}>
<div className={clsx('max-w-3xl', 'py-8', 'px-4')}>
<PageBody space={space} revision={revision} page={activePage} />
<PageBody space={space} revision={revision} page={page} />
</div>
</div>
</div>
</div>
);
}
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;
}
+9 -4
View File
@@ -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}`;
}
+12 -11
View File
@@ -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).*)',
};