PDF export (#72)

* Start

* Improve generation

* Improve style

* Lint

* Use tilde instead of dot in the url

* Improve style

* Fix page group

* Format

* Add visual tests for PDF
This commit is contained in:
Samy Pessé
2023-12-26 18:36:44 +01:00
committed by GitHub
parent 1fde857aee
commit 24f5e73bbe
21 changed files with 316 additions and 234 deletions
@@ -1,172 +0,0 @@
import { CustomizationHeaderPreset, CustomizationSettings } from '@gitbook/api';
import assertNever from 'assert-never';
import Script from 'next/script';
import colors from 'tailwindcss/colors';
import { fonts, ibmPlexMono } from '@/fonts';
import { getSpaceLanguage } from '@/intl/server';
import { getSpaceContent } from '@/lib/api';
import { hexToRgb, shadesOfColor } from '@/lib/colors';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { tcls } from '@/lib/tailwind';
import { ClientContexts } from './ClientContexts';
import { PagePathParams } from '../fetch';
export default async function SpaceRootLayout(props: {
children: React.ReactNode;
params: PagePathParams;
}) {
const { params, children } = props;
const { customization, scripts } = await getSpaceContent({
spaceId: params.spaceId,
});
const headerTheme = generateHeaderTheme(customization);
const nonce = getContentSecurityPolicyNonce();
const language = getSpaceLanguage(customization);
return (
<html
lang={customization.internationalization.locale}
className={tcls(
customization.header.preset === CustomizationHeaderPreset.None
? null
: [
// Take the sticky header in consideration for the scrolling
`scroll-pt-[76px]`,
],
)}
>
<head>
{customization.privacyPolicy.url ? (
<link rel="privacy-policy" href={customization.privacyPolicy.url} />
) : null}
<style
nonce={
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
undefined
}
>{`
:root {
${generateColorVariable(
'primary-color',
customization.styling.primaryColor.light,
)}
${generateColorVariable(
'header-background',
headerTheme.backgroundColor.light,
)}
${generateColorVariable('header-link', headerTheme.linkColor.light)}
${generateColorVariable('yellow', '#f4e28d')}
${generateColorVariable('teal', '#3f89a1')}
${generateColorVariable('pomegranate', '#f25b3a')}
}
.dark {
${generateColorVariable(
'primary-color',
customization.styling.primaryColor.dark,
)}
${generateColorVariable(
'header-background',
headerTheme.backgroundColor.dark,
)}
${generateColorVariable('header-link', headerTheme.linkColor.dark)}
}
`}</style>
</head>
<body
className={tcls(
`${fonts[customization.styling.font].className}`,
`${ibmPlexMono.variable}`,
'bg-light',
'dark:bg-dark',
)}
>
<ClientContexts language={language}>{children}</ClientContexts>
{scripts.map(({ script }) => (
<Script key={script} src={script} strategy="lazyOnload" nonce={nonce} />
))}
</body>
</html>
);
}
type ColorInput = string | Record<string, string>;
function generateColorVariable(name: string, color: ColorInput) {
const shades: Record<string, string> = typeof color === 'string' ? shadesOfColor(color) : color;
return Object.entries(shades)
.map(([key, value]) => {
// Check the original hex value
const rgbValue = hexToRgb(value);
return `--${name}-${key}: ${rgbValue};`;
})
.join('\n');
}
function generateHeaderTheme(customization: CustomizationSettings): {
backgroundColor: { light: ColorInput; dark: ColorInput };
linkColor: { light: ColorInput; dark: ColorInput };
} {
switch (customization.header.preset) {
case CustomizationHeaderPreset.None:
case CustomizationHeaderPreset.Default: {
return {
backgroundColor: {
light: colors.white,
dark: colors.black,
},
linkColor: {
light: customization.styling.primaryColor.light,
dark: customization.styling.primaryColor.dark,
},
};
}
case CustomizationHeaderPreset.Bold: {
return {
backgroundColor: {
light: customization.styling.primaryColor.light,
dark: customization.styling.primaryColor.dark,
},
linkColor: {
// TODO: should depend on the color of the background
light: colors.white,
dark: colors.black,
},
};
}
case CustomizationHeaderPreset.Contrast: {
return {
backgroundColor: {
light: customization.styling.primaryColor.dark,
dark: customization.styling.primaryColor.light,
},
linkColor: {
light: colors.white,
dark: colors.black,
},
};
}
case CustomizationHeaderPreset.Custom: {
return {
backgroundColor: {
light: customization.header.backgroundColor?.light ?? colors.white,
dark: customization.header.backgroundColor?.dark ?? colors.black,
},
linkColor: {
light:
customization.header.linkColor?.light ??
customization.styling.primaryColor.light,
dark:
customization.header.linkColor?.dark ??
customization.styling.primaryColor.dark,
},
};
}
default: {
assertNever(customization.header.preset);
}
}
}
+11 -5
View File
@@ -1,16 +1,16 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
import Script from 'next/script';
import React from 'react';
import { CookiesToast } from '@/components/Cookies';
import { SpaceContent } from '@/components/SpaceContent';
import { getSpaceLanguage } from '@/intl/server';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { PageHrefContext, absoluteHref, baseUrl, pageHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { shouldIndexSpace } from '@/lib/seo';
import { ClientContexts } from './ClientContexts';
import { PagePathParams, fetchPageData, getPathnameParam } from '../fetch';
export const runtime = 'edge';
@@ -21,6 +21,7 @@ export const runtime = 'edge';
export default async function Page(props: { params: PagePathParams }) {
const { params } = props;
const nonce = getContentSecurityPolicyNonce();
const {
content,
space,
@@ -54,6 +55,11 @@ export default async function Page(props: { params: PagePathParams }) {
collection={collection}
collectionSpaces={collectionSpaces}
/>
{scripts.map(({ script }) => (
<Script key={script} src={script} strategy="lazyOnload" nonce={nonce} />
))}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
@@ -94,14 +100,14 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
{
url:
customIcon?.light ??
absoluteHref('.gitbook/icon?size=small&theme=light', true),
absoluteHref('~gitbook/icon?size=small&theme=light', true),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url:
customIcon?.dark ??
absoluteHref('.gitbook/icon?size=small&theme=dark', true),
absoluteHref('~gitbook/icon?size=small&theme=dark', true),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
@@ -109,7 +115,7 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
},
openGraph: {
images: [
customization.socialPreview.url ?? absoluteHref(`.gitbook/ogimage/${page.id}`),
customization.socialPreview.url ?? absoluteHref(`~gitbook/ogimage/${page.id}`),
],
},
robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow',
+11
View File
@@ -0,0 +1,11 @@
'use client';
import * as React from 'react';
export default function LayoutError(props: {}) {
return (
<html lang="en">
<body>Layout error</body>
</html>
);
}
+163 -2
View File
@@ -1,5 +1,166 @@
import { CustomizationHeaderPreset, CustomizationSettings } from '@gitbook/api';
import assertNever from 'assert-never';
import colors from 'tailwindcss/colors';
import { fonts, ibmPlexMono } from '@/fonts';
import { getSpaceLanguage } from '@/intl/server';
import { getSpaceContent } from '@/lib/api';
import { hexToRgb, shadesOfColor } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import { ClientContexts } from './ClientContexts';
import { SpaceParams } from './fetch';
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
export default async function SpaceRootLayout(props: {
children: React.ReactNode;
params: SpaceParams;
}) {
const { params, children } = props;
const { customization } = await getSpaceContent({
spaceId: params.spaceId,
});
const headerTheme = generateHeaderTheme(customization);
const language = getSpaceLanguage(customization);
return (
<html
lang={customization.internationalization.locale}
className={tcls(
customization.header.preset === CustomizationHeaderPreset.None
? null
: [
// Take the sticky header in consideration for the scrolling
`scroll-pt-[76px]`,
],
)}
>
<head>
{customization.privacyPolicy.url ? (
<link rel="privacy-policy" href={customization.privacyPolicy.url} />
) : null}
<style
nonce={
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
undefined
}
>{`
:root {
${generateColorVariable(
'primary-color',
customization.styling.primaryColor.light,
)}
${generateColorVariable(
'header-background',
headerTheme.backgroundColor.light,
)}
${generateColorVariable('header-link', headerTheme.linkColor.light)}
${generateColorVariable('yellow', '#f4e28d')}
${generateColorVariable('teal', '#3f89a1')}
${generateColorVariable('pomegranate', '#f25b3a')}
}
.dark {
${generateColorVariable(
'primary-color',
customization.styling.primaryColor.dark,
)}
${generateColorVariable(
'header-background',
headerTheme.backgroundColor.dark,
)}
${generateColorVariable('header-link', headerTheme.linkColor.dark)}
}
`}</style>
</head>
<body
className={tcls(
`${fonts[customization.styling.font].className}`,
`${ibmPlexMono.variable}`,
'bg-light',
'dark:bg-dark',
)}
>
<ClientContexts language={language}>{children}</ClientContexts>
</body>
</html>
);
}
type ColorInput = string | Record<string, string>;
function generateColorVariable(name: string, color: ColorInput) {
const shades: Record<string, string> = typeof color === 'string' ? shadesOfColor(color) : color;
return Object.entries(shades)
.map(([key, value]) => {
// Check the original hex value
const rgbValue = hexToRgb(value);
return `--${name}-${key}: ${rgbValue};`;
})
.join('\n');
}
function generateHeaderTheme(customization: CustomizationSettings): {
backgroundColor: { light: ColorInput; dark: ColorInput };
linkColor: { light: ColorInput; dark: ColorInput };
} {
switch (customization.header.preset) {
case CustomizationHeaderPreset.None:
case CustomizationHeaderPreset.Default: {
return {
backgroundColor: {
light: colors.white,
dark: colors.black,
},
linkColor: {
light: customization.styling.primaryColor.light,
dark: customization.styling.primaryColor.dark,
},
};
}
case CustomizationHeaderPreset.Bold: {
return {
backgroundColor: {
light: customization.styling.primaryColor.light,
dark: customization.styling.primaryColor.dark,
},
linkColor: {
// TODO: should depend on the color of the background
light: colors.white,
dark: colors.black,
},
};
}
case CustomizationHeaderPreset.Contrast: {
return {
backgroundColor: {
light: customization.styling.primaryColor.dark,
dark: customization.styling.primaryColor.light,
},
linkColor: {
light: colors.white,
dark: colors.black,
},
};
}
case CustomizationHeaderPreset.Custom: {
return {
backgroundColor: {
light: customization.header.backgroundColor?.light ?? colors.white,
dark: customization.header.backgroundColor?.dark ?? colors.black,
},
linkColor: {
light:
customization.header.linkColor?.light ??
customization.styling.primaryColor.light,
dark:
customization.header.linkColor?.dark ??
customization.styling.primaryColor.dark,
},
};
}
default: {
assertNever(customization.header.preset);
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ export async function GET(req: NextRequest, { params }: { params: SpaceParams })
const lines = [
`User-agent: *`,
'Disallow: /.gitbook/',
'Disallow: /~gitbook/',
...(shouldIndexSpace({ space, collection })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
@@ -1,12 +1,14 @@
import React from 'react';
import { notFound } from 'next/navigation';
import { NextRequest } from 'next/server';
import { ImageResponse } from 'next/og';
import { SpaceParams } from '../../fetch';
import { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { getEmojiForCode } from '@/lib/emojis';
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 { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind';
import { SpaceParams } from '../../fetch';
export const runtime = 'edge';
@@ -1,7 +1,8 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
import { PageIdParams, fetchPageData } from '../../../fetch';
export const runtime = 'edge';
@@ -0,0 +1,13 @@
'use client';
import * as React from 'react';
export function OpenPrintDialog(props: {}) {
React.useEffect(() => {
if (process.env.NODE_ENV !== 'development') {
window.print();
}
}, []);
return null;
}
@@ -1,14 +1,17 @@
import * as React from 'react';
import { getDocument, getSpace, getRevisionPages, ContentPointer } from '@/lib/api';
import { resolvePageId } from '@/lib/pages';
import { pagePDFContainerId, PageHrefContext } from '@/lib/links';
import { DocumentView } from '@/components/DocumentView';
import { SpaceParams } from '../../fetch';
import { Revision, RevisionPageDocument, RevisionPageGroup, Space } from '@gitbook/api';
import { notFound } from 'next/navigation';
import * as React from 'react';
import { DocumentView } from '@/components/DocumentView';
import { getDocument, getSpace, getRevisionPages, ContentPointer } from '@/lib/api';
import { pagePDFContainerId, PageHrefContext } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { OpenPrintDialog } from './OpenPrintDialog';
import { SpaceParams } from '../../fetch';
import './pdf.css';
export const runtime = 'edge';
@@ -17,6 +20,8 @@ interface PDFSearchParams {
page?: string;
/** If true, only the `page` is exported, and not its descendant */
only?: boolean;
/** Limit the number of pages */
limit?: number;
}
/**
@@ -39,33 +44,61 @@ export default async function PDFHTMLOutput(props: {
getRevisionPages(contentPointer),
]);
const pages = selectPages(rootPages, searchParams).slice(0, 4); // TODO: remove slice
const pages = selectPages(rootPages, searchParams).slice(0, searchParams.limit ?? 10);
const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id),
};
return (
<>
<div
className={tcls(
'my-11',
'print:my-0',
'mx-auto',
'max-w-4xl',
'w-full',
'p-12',
'print:p-0',
'shadow-xl',
'print:shadow-none',
'rounded-sm',
'bg-white',
)}
>
<SpaceIntro space={space} />
{pages.map(({ page, depth }) =>
page.type === 'group' ? (
<PDFPageGroup key={page.id} space={space} page={page} />
) : (
<PDFPageDocument
key={page.id}
space={space}
page={page}
refContext={{
content: contentPointer,
space,
pages: rootPages,
page,
...linksContext,
}}
/>
<React.Suspense key={page.id} fallback={null}>
<PDFPageDocument
space={space}
page={page}
refContext={{
content: contentPointer,
space,
pages: rootPages,
page,
...linksContext,
}}
/>
</React.Suspense>
),
)}
</>
<OpenPrintDialog />
</div>
);
}
async function SpaceIntro(props: { space: Space }) {
const { space } = props;
return (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-12')}>
<h1 className={tcls('text-6xl', 'font-bold')}>{space.title}</h1>
</div>
);
}
@@ -73,8 +106,18 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props;
return (
<div>
<h1>{page.title}</h1>
<div
className={tcls(
'break-before-page',
'mt-10',
'print:mt-0',
'flex',
'items-center',
'justify-center',
'py-12',
)}
>
<h1 className={tcls('text-5xl', 'font-bold')}>{page.title}</h1>
</div>
);
}
@@ -89,12 +132,16 @@ async function PDFPageDocument(props: {
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
return (
<div id={pagePDFContainerId(page)}>
<h1>{page.title}</h1>
<div
id={pagePDFContainerId(page)}
className={tcls('break-before-page', 'mt-10', 'print:mt-0')}
>
<h1 className={tcls('text-3xl', 'font-bold')}>{page.title}</h1>
{document ? (
<DocumentView
document={document}
style={'mt-6'}
blockStyle={['max-w-full']}
context={{
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
+8
View File
@@ -0,0 +1,8 @@
@page {
size: A4;
margin: 60pt 60pt;
@bottom-right {
content: counter(page);
}
}
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
}
// Prevent infinite loops
if (url.includes('/.gitbook/image')) {
if (url.includes('/~gitbook/image')) {
return new Response('Invalid url parameter', { status: 400 });
}
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTags } from '@/lib/cache';
export const runtime = 'edge';
+7 -2
View File
@@ -33,17 +33,22 @@ export interface DocumentContextProps {
export function DocumentView(
props: DocumentContextProps & {
document: JSONDocument;
/** Style passed to the container */
style?: ClassValue;
/** Style passed to all blocks */
blockStyle?: ClassValue;
},
) {
const { document, style, context } = props;
const { document, style, blockStyle = [], context } = props;
return (
<Blocks
nodes={document.nodes}
document={document}
ancestorBlocks={[]}
blockStyle={[]}
blockStyle={blockStyle}
style={['space-y-6', style]}
context={context}
/>
+2 -2
View File
@@ -93,11 +93,11 @@ function LogoFallback(props: HeaderLogoProps) {
}
: {
light: {
src: absoluteHref('.gitbook/icon?size=medium&theme=light'),
src: absoluteHref('~gitbook/icon?size=medium&theme=light'),
size: { width: 256, height: 256 },
},
dark: {
src: absoluteHref('.gitbook/icon?size=medium&theme=dark'),
src: absoluteHref('~gitbook/icon?size=medium&theme=dark'),
size: { width: 256, height: 256 },
},
}
-2
View File
@@ -49,7 +49,6 @@ export function PageBody(props: {
<PageHeader page={page} />
{document ? (
<DocumentView
document={document}
style={['space-y-5', 'grid']}
@@ -58,7 +57,6 @@ export function PageBody(props: {
resolveContentRef: (ref) => resolveContentRef(ref, context),
}}
/>
) : null}
{page.layout.pagination ? (
+1 -1
View File
@@ -48,7 +48,7 @@ export async function getResizedImageURL(
}
return (options) => {
const url = new URL('/.gitbook/image', rootUrl());
const url = new URL('/~gitbook/image', rootUrl());
url.searchParams.set('url', input);
if (options.width) {
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
export const config = {
matcher: '/((?!_next/static|_next/image|.gitbook/revalidate|.gitbook/image).*)',
matcher: '/((?!_next/static|_next/image|~gitbook/revalidate|~gitbook/image).*)',
skipTrailingSlashRedirect: true,
};
+8
View File
@@ -51,6 +51,10 @@ const testCases: TestsCase[] = [
name: 'Home',
url: '',
},
{
name: 'PDF',
url: '~gitbook/pdf?limit=10',
},
],
},
{
@@ -61,6 +65,10 @@ const testCases: TestsCase[] = [
name: 'Home',
url: '',
},
{
name: 'PDF',
url: '~gitbook/pdf?limit=10',
},
],
},
{
+1 -8
View File
@@ -25,13 +25,6 @@
"bun-types" // add Bun global
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/.gitbook/**/*.ts",
"**/.gitbook/**/*.tsx",
".next/types/**/*.ts"
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}