Add link to generate links in PDF mode

This commit is contained in:
Samy Pessé
2023-11-03 16:59:37 -04:00
parent 85af5fd4e2
commit 2d4faaf396
11 changed files with 177 additions and 97 deletions
+41 -41
View File
@@ -1,43 +1,43 @@
{
"name": "gitbook",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different"
},
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.12.0",
"@readme/openapi-parser": "^2.5.0",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
"bun-types": "^1.0.7",
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"next": "13.5.6",
"react": "^18",
"react-dom": "^18",
"server-only": "^0.0.1",
"shiki": "^0.14.5",
"tailwind-merge": "^1.14.0"
},
"devDependencies": {
"@types/jsontoxml": "^1.0.5",
"@types/katex": "^0.16.5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10",
"eslint": "^8",
"eslint-config-next": "13.5.6",
"postcss": "^8",
"prettier": "^3.0.3",
"tailwindcss": "^3.3.4",
"typescript": "^5"
}
"name": "gitbook",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different"
},
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.12.0",
"@readme/openapi-parser": "^2.5.0",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
"bun-types": "^1.0.7",
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"next": "13.5.6",
"react": "^18",
"react-dom": "^18",
"server-only": "^0.0.1",
"shiki": "^0.14.5",
"tailwind-merge": "^1.14.0"
},
"devDependencies": {
"@types/jsontoxml": "^1.0.5",
"@types/katex": "^0.16.5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10",
"eslint": "^8",
"eslint-config-next": "13.5.6",
"postcss": "^8",
"prettier": "^3.0.3",
"tailwindcss": "^3.3.4",
"typescript": "^5"
}
}
+39 -20
View File
@@ -2,6 +2,7 @@ import * as React from 'react';
import { api } from '@/lib/api';
import { resolvePageId } from '@/lib/pages';
import { pagePDFContainerId, PageHrefContext } from '@/lib/links';
import { DocumentView } from '@/components/DocumentView';
import { SpaceParams } from '../../fetch';
@@ -15,12 +16,14 @@ interface PDFSearchParams {
only?: boolean;
}
/**
* Render a space as a standalone HTML page without interactive elements.
* The HTML can be converted to PDF.
*/
export default async function PDFHTMLOutput(props: { params: SpaceParams; searchParams: PDFSearchParams }) {
export default async function PDFHTMLOutput(props: {
params: SpaceParams;
searchParams: PDFSearchParams;
}) {
const { params, searchParams } = props;
const { spaceId } = params;
@@ -31,42 +34,53 @@ export default async function PDFHTMLOutput(props: { params: SpaceParams; search
const pages = selectPages(revision, searchParams).slice(0, 4); // TODO: remove slice
const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id),
};
return (
<>
{pages.map(({ page, depth }) => (
page.type === 'group' ? <PDFPageGroup space={space} revision={revision} page={page} /> : <PDFPageDocument space={space} revision={revision} page={page} />
))}
{pages.map(({ page, depth }) =>
page.type === 'group' ? (
<PDFPageGroup key={page.id} space={space} revision={revision} page={page} />
) : (
<PDFPageDocument
key={page.id}
space={space}
revision={revision}
page={page}
linksContext={linksContext}
/>
),
)}
</>
)
);
}
async function PDFPageGroup(props: {
space: Space;
revision: Revision;
page: RevisionPageGroup;
}) {
async function PDFPageGroup(props: { space: Space; revision: Revision; page: RevisionPageGroup }) {
const { page } = props;
return (
<div>
<h1>{page.title}</h1>
</div>
)
);
}
async function PDFPageDocument(props: {
space: Space;
revision: Revision;
page: RevisionPageDocument;
linksContext: PageHrefContext;
}) {
const { space, revision, page } = props;
const { space, revision, page, linksContext } = props;
const {
data: { document },
} = await api().spaces.getPageInRevisionById(space.id, revision.id, page.id);
return (
<div>
<div id={pagePDFContainerId(page)}>
<h1>{page.title}</h1>
<DocumentView
document={document}
@@ -75,10 +89,11 @@ async function PDFPageDocument(props: {
space,
revision,
page,
...linksContext,
}}
/>
</div>
)
);
}
type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: number };
@@ -87,12 +102,17 @@ type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: nu
* Compute the ordered flat set of pages to render.
*/
function selectPages(revision: Revision, params: PDFSearchParams): FlatPageEntry[] {
const flattenPage = (page: RevisionPageDocument | RevisionPageGroup, depth: number): FlatPageEntry[] => {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
): FlatPageEntry[] => {
return [
{ page, depth },
...page.pages.flatMap((child) => child.type === 'link' ? [] : flattenPage(child, depth + 1)),
...page.pages.flatMap((child) =>
child.type === 'link' ? [] : flattenPage(child, depth + 1),
),
];
}
};
if (params.page) {
const found = resolvePageId(revision, params.page);
@@ -107,6 +127,5 @@ function selectPages(revision: Revision, params: PDFSearchParams): FlatPageEntry
return flattenPage(found.page, 0);
}
return revision.pages.flatMap((page) => page.type === 'link' ? [] : flattenPage(page, 0));
return revision.pages.flatMap((page) => (page.type === 'link' ? [] : flattenPage(page, 0)));
}
+4 -3
View File
@@ -1,5 +1,5 @@
import { SpaceContent } from '@/components/SpaceContent';
import { pageHref } from '@/lib/links';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { Metadata } from 'next';
import { notFound, redirect } from 'next/navigation';
import { PagePathParams, fetchPageData, getPagePath } from '../fetch';
@@ -12,11 +12,12 @@ export default async function Page(props: { params: PagePathParams }) {
const { params } = props;
const { space, revision, page, ancestors } = await fetchPageData(props.params);
const linksContext: PageHrefContext = {};
if (!page) {
notFound();
} else if (page.path !== getPagePath(params)) {
redirect(pageHref(page.path));
redirect(pageHref(page, linksContext));
}
const {
@@ -45,7 +46,7 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
description: page.description,
generator: 'GitBook',
openGraph: {
images: [pageHref('.gitbook/ogimage/' + page.id)],
images: [absoluteHref('.gitbook/ogimage/' + page.id)],
},
robots: space.visibility === 'public' ? 'index, follow' : 'noindex, nofollow',
+18 -15
View File
@@ -30,14 +30,14 @@ export async function GET(req: NextRequest, { params }: { params: SpaceParams })
url: {
loc: pageHref(page.path),
priority: normalizedPriority,
...(lastModified ? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified)
.toISOString()
.split('T')[0],
} : {})
}
}
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
});
const xml = jsontoxml(
@@ -56,7 +56,7 @@ export async function GET(req: NextRequest, { params }: { params: SpaceParams })
{
xmlHeader: true,
prettyPrint: true,
}
},
);
return new Response(xml, {
@@ -69,14 +69,17 @@ export async function GET(req: NextRequest, { params }: { params: SpaceParams })
type FlatPageEntry = { page: RevisionPageDocument; depth: number };
function flattenPages(revision: Revision): FlatPageEntry[] {
const flattenPage = (page: RevisionPageDocument | RevisionPageGroup, depth: number): FlatPageEntry[] => {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
): FlatPageEntry[] => {
return [
...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) => child.type === 'link' ? [] : flattenPage(child, depth + 1)),
...page.pages.flatMap((child) =>
child.type === 'link' ? [] : flattenPage(child, depth + 1),
),
];
}
};
return revision.pages.flatMap((page) => page.type === 'link' ? [] : flattenPage(page, 0));
return revision.pages.flatMap((page) => (page.type === 'link' ? [] : flattenPage(page, 0)));
}
+4 -3
View File
@@ -4,14 +4,15 @@ import { BlockProps } from './Block';
import { Inlines } from './Inlines';
import { DocumentBlockHeading } from '@gitbook/api';
import { getBlockTextStyle } from './spacing';
import { pageLocalId } from '@/lib/links';
export function Heading(props: BlockProps<DocumentBlockHeading>) {
const { block, style, ...contextProps } = props;
const { block, style, context, ...rest } = props;
const textStyle = getBlockTextStyle(block);
const Tag = TAGS[block.type];
const id = block.meta?.id ?? '';
const id = pageLocalId(context.page, block.meta?.id ?? '', context);
return (
<Tag id={id} className={tcls(textStyle.textSize, 'group', 'relative', style)}>
@@ -56,7 +57,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
</a>
</div>
<Inlines {...contextProps} nodes={block.nodes} />
<Inlines {...rest} context={context} nodes={block.nodes} />
</Tag>
);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { Inlines } from './Inlines';
import { DocumentBlockImage, DocumentBlockImages } from '@gitbook/api';
export function Images(props: BlockProps<DocumentBlockImages>) {
const { block, style, ...contextProps } = props;
const { block, style, context } = props;
return (
<div
@@ -28,7 +28,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
block={node}
style={[i > 0 && 'mt-4', style]}
siblings={block.nodes.length}
{...contextProps}
context={context}
/>
))}
</div>
@@ -9,19 +9,19 @@ export async function RecordCard(
record: TableRecordKV;
},
) {
const { block, view, record } = props;
const { block, view, record, context } = props;
const coverFile = view.coverDefinition
? (record[1].values[view.coverDefinition]?.[0] as string)
: null;
const cover = coverFile
? await resolveContentRef({ kind: 'file', file: coverFile }, props.context)
? await resolveContentRef({ kind: 'file', file: coverFile }, context)
: null;
const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef)
: null;
const target = targetRef ? await resolveContentRef(targetRef, props.context) : null;
const target = targetRef ? await resolveContentRef(targetRef, context) : null;
const body = (
<>
+2 -2
View File
@@ -1,4 +1,4 @@
import { pageHref } from '@/lib/links';
import { absoluteHref } from '@/lib/links';
import { Space } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
@@ -47,7 +47,7 @@ export function Header(props: { space: Space; asFullWidth: boolean }) {
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
)}
>
<Link href={pageHref('')} className={tcls('flex-1')}>
<Link href={absoluteHref('')} className={tcls('flex-1')}>
<h1 className={tcls('text-lg', 'text-slate-800', 'font-semibold')}>
{space.title}
</h1>
@@ -15,7 +15,7 @@ export function PageDocumentItem(props: {
const hasActiveDescendant = ancestors.some((ancestor) => ancestor.id === page.id);
const linkProps = {
href: pageHref(page.path || ''),
href: pageHref(page),
className: tcls(
'flex',
'flex-row',
+58 -2
View File
@@ -1,6 +1,15 @@
import 'server-only';
import { headers } from 'next/headers';
import { RevisionPageDocument } from '@gitbook/api';
export interface PageHrefContext {
/**
* If defined, we are generating a PDF of the specific page IDs,
* and these pages will be rendered in the same HTML output.
*/
pdf?: string[];
}
/**
* Return the base path for the current request.
@@ -10,9 +19,56 @@ export function basePath(): string {
return headersList.get('x-gitbook-basepath') ?? '';
}
/**
* Create an absolute href in the current content.
*/
export function absoluteHref(href: string): string {
return `${basePath()}/${href.startsWith('/') ? href.slice(1) : href}`;
}
/**
* Create a link to a page path in the current space.
*/
export function pageHref(pagePath: string): string {
return `${basePath()}/${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`;
export function pageHref(
page: RevisionPageDocument,
context: PageHrefContext = {},
/** Anchor to link to in the page. */
anchor?: string,
): string {
const { pdf } = context;
if (pdf) {
if (pdf.includes(page.id)) {
return '#' + pagePDFContainerId(page, anchor);
} else {
// Use an absolute URL to the page
// TODO: we need to extend RevisionPageDocument with "urls"
return page.urls?.published || '/todo';
}
}
return absoluteHref(page.path) + (anchor ? '#' + anchor : '');
}
/**
* Create the HTML ID for the container of a page during a PDF rendering.
*/
export function pagePDFContainerId(page: RevisionPageDocument, anchor?: string): string {
return `pdf-page-${page.id}` + (anchor ? `-${anchor}` : '');
}
/**
* Create an HTML ID for a block in a page.
* It ensures the ID is unique in the entire HTML page (in case we are generating a PDF with multiple pages).
*/
export function pageLocalId(
page: RevisionPageDocument,
localId: string,
context: PageHrefContext,
): string {
if (!context.pdf?.length) {
return localId;
}
return pagePDFContainerId(page, localId);
}
+5 -5
View File
@@ -1,6 +1,6 @@
import { ContentRef, Revision, RevisionPageDocument, Space } from '@gitbook/api';
import { resolvePageId } from './pages';
import { pageHref } from './links';
import { pageHref, PageHrefContext } from './links';
export interface ResolvedContentRef {
/** Text to render in the content ref */
@@ -9,7 +9,7 @@ export interface ResolvedContentRef {
href: string;
}
export interface ContentRefContext {
export interface ContentRefContext extends PageHrefContext {
space: Space;
revision: Revision;
page: RevisionPageDocument;
@@ -20,7 +20,7 @@ export interface ContentRefContext {
*/
export async function resolveContentRef(
contentRef: ContentRef,
{ space, revision, page: activePage }: ContentRefContext,
{ space, revision, page: activePage, ...linksContext }: ContentRefContext,
): Promise<ResolvedContentRef | null> {
// Try to resolve a local ref in the current space
if (contentRef.kind === 'url') {
@@ -49,13 +49,13 @@ export async function resolveContentRef(
if (contentRef.kind === 'page') {
return {
href: pageHref(page.path),
href: pageHref(page, linksContext),
text: page.title,
};
}
return {
href: pageHref(page.path) + '#' + contentRef.anchor,
href: pageHref(page, linksContext, contentRef.anchor),
text: page.title + '#' + contentRef.anchor,
};
} else if (contentRef.kind === 'space' && contentRef.space === space.id) {