From 2d4faaf396e9886bfa31aeb342ff40da8de3fe01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Fri, 3 Nov 2023 16:59:37 -0400 Subject: [PATCH] Add link to generate links in PDF mode --- package.json | 82 +++++++++---------- src/app/[spaceId]/.gitbook/pdf/page.tsx | 59 ++++++++----- src/app/[spaceId]/[[...pathname]]/page.tsx | 7 +- src/app/[spaceId]/sitemap.xml/route.ts | 33 ++++---- src/components/DocumentView/Heading.tsx | 7 +- src/components/DocumentView/Images.tsx | 4 +- .../DocumentView/Table/RecordCard.tsx | 6 +- src/components/Header/Header.tsx | 4 +- .../TableOfContents/PageDocumentItem.tsx | 2 +- src/lib/links.ts | 60 +++++++++++++- src/lib/references.ts | 10 +-- 11 files changed, 177 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index 599716ab7..12a2e49ba 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/src/app/[spaceId]/.gitbook/pdf/page.tsx b/src/app/[spaceId]/.gitbook/pdf/page.tsx index d67080f9f..9ebddb820 100644 --- a/src/app/[spaceId]/.gitbook/pdf/page.tsx +++ b/src/app/[spaceId]/.gitbook/pdf/page.tsx @@ -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' ? : - ))} + {pages.map(({ page, depth }) => + page.type === 'group' ? ( + + ) : ( + + ), + )} - ) + ); } -async function PDFPageGroup(props: { - space: Space; - revision: Revision; - page: RevisionPageGroup; -}) { +async function PDFPageGroup(props: { space: Space; revision: Revision; page: RevisionPageGroup }) { const { page } = props; return (

{page.title}

- ) + ); } 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 ( -
+

{page.title}

- ) + ); } 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))); } - diff --git a/src/app/[spaceId]/[[...pathname]]/page.tsx b/src/app/[spaceId]/[[...pathname]]/page.tsx index 193943f4b..fda08e280 100644 --- a/src/app/[spaceId]/[[...pathname]]/page.tsx +++ b/src/app/[spaceId]/[[...pathname]]/page.tsx @@ -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', diff --git a/src/app/[spaceId]/sitemap.xml/route.ts b/src/app/[spaceId]/sitemap.xml/route.ts index 513087979..fd8c834c0 100644 --- a/src/app/[spaceId]/sitemap.xml/route.ts +++ b/src/app/[spaceId]/sitemap.xml/route.ts @@ -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))); } - - diff --git a/src/components/DocumentView/Heading.tsx b/src/components/DocumentView/Heading.tsx index 1e79d7fda..7979c997d 100644 --- a/src/components/DocumentView/Heading.tsx +++ b/src/components/DocumentView/Heading.tsx @@ -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) { - 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 ( @@ -56,7 +57,7 @@ export function Heading(props: BlockProps) {
- + ); } diff --git a/src/components/DocumentView/Images.tsx b/src/components/DocumentView/Images.tsx index eb596f825..442db07e7 100644 --- a/src/components/DocumentView/Images.tsx +++ b/src/components/DocumentView/Images.tsx @@ -6,7 +6,7 @@ import { Inlines } from './Inlines'; import { DocumentBlockImage, DocumentBlockImages } from '@gitbook/api'; export function Images(props: BlockProps) { - const { block, style, ...contextProps } = props; + const { block, style, context } = props; return (
) { block={node} style={[i > 0 && 'mt-4', style]} siblings={block.nodes.length} - {...contextProps} + context={context} /> ))}
diff --git a/src/components/DocumentView/Table/RecordCard.tsx b/src/components/DocumentView/Table/RecordCard.tsx index b5252b505..76340d9db 100644 --- a/src/components/DocumentView/Table/RecordCard.tsx +++ b/src/components/DocumentView/Table/RecordCard.tsx @@ -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 = ( <> diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 2ef06fdff..17a0cb59f 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -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'], )} > - +

{space.title}

diff --git a/src/components/TableOfContents/PageDocumentItem.tsx b/src/components/TableOfContents/PageDocumentItem.tsx index 08a8b0882..2ac71243d 100644 --- a/src/components/TableOfContents/PageDocumentItem.tsx +++ b/src/components/TableOfContents/PageDocumentItem.tsx @@ -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', diff --git a/src/lib/links.ts b/src/lib/links.ts index f99a4f3e1..0f9966749 100644 --- a/src/lib/links.ts +++ b/src/lib/links.ts @@ -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); } diff --git a/src/lib/references.ts b/src/lib/references.ts index 66ea8b4dc..a645e86f7 100644 --- a/src/lib/references.ts +++ b/src/lib/references.ts @@ -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 { // 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) {