mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-05 12:54:22 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca77c85d5 | |||
| 0c92b3a679 | |||
| dff0c7903e | |||
| ffebd1790e | |||
| 0e0085e49a | |||
| 8e131693ea | |||
| 96325161be | |||
| 9f5290bccc | |||
| 61992424af | |||
| 380af10236 | |||
| 0911abc55c | |||
| d8dc59b1a9 | |||
| d04f8bc05e | |||
| 576a781a8f | |||
| df2841cf36 | |||
| 70bfdda39c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Recolor text over a background page cover in the first frame after hydration.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix page cover image jumping on load
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve dark theme contrast for Mermaid edge labels.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Use the page's tag title, when set, for the HTML `<title>` of published pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Scroll the table of contents to the active page after client-side navigation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.6 upgrade (root `package.json`'s `patchedDependencies` still pinned it to the old `16.3.3` patch file, so Bun stopped applying it).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the site auth login link sometimes redirecting back to an internal URL after login.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
|
||||
@@ -129,8 +129,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.1.3",
|
||||
"@opennextjs/cloudflare": "1.20.5",
|
||||
"@opennextjs/aws": "4.1.6",
|
||||
"@opennextjs/cloudflare": "1.20.7",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -342,10 +342,10 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"next@16.3.6": "patches/next@16.3.6.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@opennextjs/aws": "4.1.5",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"axios": "1.8.4",
|
||||
@@ -355,7 +355,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.202.0",
|
||||
"@gitbook/api": "0.204.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -727,7 +727,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.204.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-SJCe0Ipyt+V5HZX4EZpFO8AHTOWIMwj2ljQTbcqgU7rVOAyCNDzILp8mRk9stbboSi1pv09+rDnZK1ZguPvFTw=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -943,9 +943,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.1.6", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.26 <16 || >=16.3.6" }, "bin": { "open-next": "dist/index.js" } }, "sha512-J5mzpWo6duetc3ZtcyBxrUqtD6Dwh8msRcMTNQdvpsgTUjEFp03AJmSOnJ/7BKkuF791YTQgjk4iJYIIYLmKqA=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.6", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.26 <16 || >=16.3.6", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-obZ5l96+MUmGqKd7akJlT9tv0XAvaUDDJj13MyGTVTzA+lbal8kNRoUwei08rtu1Gv+anASAwwMP3dvzcZx+Jw=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
|
||||
+3
-4
@@ -19,8 +19,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@opennextjs/aws": "4.1.5"
|
||||
"axios": "1.8.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -49,7 +48,7 @@
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.202.0",
|
||||
"@gitbook/api": "0.204.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -68,6 +67,6 @@
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.3.3": "patches/next@16.3.3.patch"
|
||||
"next@16.3.6": "patches/next@16.3.6.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.1.3",
|
||||
"@opennextjs/cloudflare": "1.20.5",
|
||||
"@opennextjs/aws": "4.1.6",
|
||||
"@opennextjs/cloudflare": "1.20.7",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
|
||||
@@ -119,9 +119,13 @@ export function useAI(): AIContext {
|
||||
icon: <AISearchIcon />,
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||
);
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
query: null,
|
||||
ask: query,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: true,
|
||||
}));
|
||||
}
|
||||
},
|
||||
pageAction: false,
|
||||
|
||||
@@ -247,11 +247,15 @@ async function renderMermaidDiagram(args: {
|
||||
const { source, id, darkMode, mermaidRuntimeURL } = args;
|
||||
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
|
||||
|
||||
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
|
||||
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
darkMode,
|
||||
theme: darkMode ? 'dark' : undefined,
|
||||
themeVariables,
|
||||
});
|
||||
|
||||
const renderContainer = createMermaidRenderContainer();
|
||||
|
||||
@@ -37,7 +37,10 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: embed.html,
|
||||
__html:
|
||||
context.mode !== 'print' && shouldLazyLoad(block.data.url)
|
||||
? lazyLoadIframes(embed.html)
|
||||
: embed.html,
|
||||
}}
|
||||
data-visual-test="blackout"
|
||||
/>
|
||||
@@ -73,6 +76,23 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages with many Loom embeds exhaust the browser's request budget
|
||||
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
|
||||
*/
|
||||
function lazyLoadIframes(html: string): string {
|
||||
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
|
||||
}
|
||||
|
||||
function shouldLazyLoad(url: string): boolean {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an integration block with an unfurl action from the GitBook Embed response.
|
||||
*/
|
||||
|
||||
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
const anchorElement = (
|
||||
<InlineLinkAnchor
|
||||
href={resolved.href}
|
||||
contentRef={inline.data.ref}
|
||||
isExternal={inline.data.ref.kind === 'url'}
|
||||
contentRef={resolved.resolvedRef ?? inline.data.ref}
|
||||
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
|
||||
>
|
||||
{inlinesElement}
|
||||
</InlineLinkAnchor>
|
||||
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
|
||||
|
||||
let breadcrumbs = resolved.ancestors ?? [];
|
||||
const isMailto = resolved.href.startsWith('mailto:');
|
||||
const isExternal = inline.data.ref.kind === 'url';
|
||||
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
|
||||
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
|
||||
|
||||
if (isMailto) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from 'react';
|
||||
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
|
||||
import type * as api from '@gitbook/api';
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
|
||||
|
||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||
@@ -75,12 +76,15 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
[pathname: string]:
|
||||
| {
|
||||
url: string;
|
||||
previousUrl: string | null;
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: CurrentContentContext;
|
||||
pageContext?: InsightsEventPageContext;
|
||||
}
|
||||
| undefined;
|
||||
}>({});
|
||||
// Href of the last page an event was tracked on; `undefined` until the first event.
|
||||
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Synchronously flush all the pending events.
|
||||
@@ -105,6 +109,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
allEvents.push(
|
||||
...transformEvents({
|
||||
url: eventsForPathname.url,
|
||||
previousUrl: eventsForPathname.previousUrl,
|
||||
events: eventsForPathname.events,
|
||||
context: currentContent,
|
||||
pageContext: eventsForPathname.pageContext,
|
||||
@@ -154,6 +159,9 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
) => {
|
||||
const pathname = window.location.pathname;
|
||||
const previous = eventsRef.current[pathname];
|
||||
const lastUrl =
|
||||
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
|
||||
lastUrlRef.current = window.location.href;
|
||||
eventsRef.current[pathname] = {
|
||||
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
|
||||
// attribute to their page even when the pathname's ambient context has none — such
|
||||
@@ -161,6 +169,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
// context keep the stored one.
|
||||
pageContext: ctx ?? previous?.pageContext,
|
||||
url: previous?.url ?? window.location.href,
|
||||
previousUrl: previous ? previous.previousUrl : lastUrl,
|
||||
events: [
|
||||
...(previous?.events ?? []),
|
||||
{
|
||||
@@ -214,6 +223,18 @@ export function useTrackEvent(): TrackEventCallback {
|
||||
return React.useContext(InsightsContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
|
||||
* to a missing page) still records the page it came from.
|
||||
*/
|
||||
function getSameOriginReferrer(): string | null {
|
||||
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
|
||||
return null;
|
||||
}
|
||||
const referrer = new URL(document.referrer);
|
||||
return referrer.origin === window.location.origin ? referrer.href : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post the events to the server.
|
||||
*/
|
||||
@@ -240,6 +261,7 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
|
||||
*/
|
||||
function transformEvents(input: {
|
||||
url: string;
|
||||
previousUrl: string | null;
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: CurrentContentContext;
|
||||
pageContext: InsightsEventPageContext;
|
||||
@@ -258,6 +280,11 @@ function transformEvents(input: {
|
||||
|
||||
const location: api.SiteInsightsEventLocation = {
|
||||
url: input.url,
|
||||
// The embed's navigation is between its own tabs, not pages of the site.
|
||||
previousUrl:
|
||||
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
|
||||
? null
|
||||
: input.previousUrl,
|
||||
siteSection: input.context.siteSectionId ?? null,
|
||||
siteSpace: input.context.siteSpaceId ?? null,
|
||||
space: input.context.spaceId,
|
||||
|
||||
@@ -29,18 +29,21 @@ interface PageCoverImageProps {
|
||||
|
||||
export function PageCoverImage(props: PageCoverImageProps) {
|
||||
const { imgs, y, height, mask } = props;
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
|
||||
height,
|
||||
aspectRatio: PAGE_COVER_SIZE,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
|
||||
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
|
||||
<img
|
||||
src={imgs.light.src}
|
||||
srcSet={imgs.light.srcSet}
|
||||
@@ -52,7 +55,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
aspectRatio: height
|
||||
? undefined
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
objectPosition: `50% ${objectPositionY}`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
maskComposite: 'intersect',
|
||||
maskImage:
|
||||
@@ -73,7 +76,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
aspectRatio: height
|
||||
? undefined
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
objectPosition: `50% ${objectPositionY}`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
maskComposite: 'intersect',
|
||||
maskImage:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
interface ImageSize {
|
||||
width: number;
|
||||
@@ -22,19 +21,18 @@ interface Images {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate the object position Y percentage for a cover image
|
||||
* based on the y offset, image dimensions, and container dimensions.
|
||||
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
|
||||
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
|
||||
* since the position is computed against its width.
|
||||
*/
|
||||
export function useCoverPosition(imgs: Images, y: number) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
export function useCoverPosition(
|
||||
imgs: Images,
|
||||
y: number,
|
||||
container: { height: number | undefined; aspectRatio: ImageSize }
|
||||
) {
|
||||
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
|
||||
|
||||
const container = useResizeObserver({
|
||||
// @ts-expect-error wrong types
|
||||
ref: containerRef,
|
||||
});
|
||||
|
||||
// Load original image dimensions if not provided in `imgs`
|
||||
useLayoutEffect(() => {
|
||||
// Check if we have dimensions from dark (if provided) or else the default light.
|
||||
@@ -68,42 +66,31 @@ export function useCoverPosition(imgs: Images, y: number) {
|
||||
// Check dark first, then light, then loaded dimensions
|
||||
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
|
||||
|
||||
// Calculate ratio and dimensions similar to useCoverPosition hook
|
||||
const ratio =
|
||||
imageDimensions && container.height && container.width
|
||||
? Math.max(
|
||||
container.width / imageDimensions.width,
|
||||
container.height / imageDimensions.height
|
||||
)
|
||||
: 1;
|
||||
const safeRatio = ratio || 1;
|
||||
|
||||
const scaledHeight =
|
||||
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
|
||||
const maxOffset =
|
||||
scaledHeight && container.height
|
||||
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
|
||||
: 0;
|
||||
|
||||
// Parse the position between the allowed min/max
|
||||
const objectPositionY = useMemo(() => {
|
||||
if (!container.height || !imageDimensions) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const scaled = imageDimensions.height * safeRatio;
|
||||
if (scaled <= container.height || maxOffset === 0) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
|
||||
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
|
||||
return relative * 100;
|
||||
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
objectPositionY,
|
||||
objectPositionY: imageDimensions
|
||||
? getCoverObjectPositionY(imageDimensions, y, container)
|
||||
: '50%',
|
||||
isLoading: !imageDimensions || isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
|
||||
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
|
||||
* after hydration, without measuring the container.
|
||||
*/
|
||||
function getCoverObjectPositionY(
|
||||
image: ImageSize,
|
||||
y: number,
|
||||
container: { height: number | undefined; aspectRatio: ImageSize }
|
||||
): string {
|
||||
const containerHeight = container.height
|
||||
? `${container.height}px`
|
||||
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
|
||||
// Rendered height of the image under `object-fit: cover`.
|
||||
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
|
||||
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
|
||||
const offset = `${scaledHeight} * ${y / image.height}`;
|
||||
|
||||
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
import { useIsMounted } from '../hooks/useIsMounted';
|
||||
import { Button, type ButtonProps } from '../primitives/Button';
|
||||
import { DropdownMenuItem } from '../primitives/DropdownMenu';
|
||||
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
|
||||
@@ -16,9 +17,17 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
|
||||
const rawPathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const currentSearch = searchParams?.toString();
|
||||
const pathname = rawPathname ?? '/';
|
||||
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
|
||||
// the site API token), so the location is only added once mounted.
|
||||
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
|
||||
const isMounted = useIsMounted();
|
||||
const pathname = isMounted ? (rawPathname ?? '/') : null;
|
||||
|
||||
return useMemo(() => {
|
||||
if (pathname === null) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
|
||||
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
|
||||
const siteBasePath = removeTrailingSlash(
|
||||
|
||||
@@ -82,7 +82,7 @@ type CoverOverlap =
|
||||
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
|
||||
*/
|
||||
function useMarkTextOverCover() {
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||
|
||||
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
|
||||
});
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
update();
|
||||
|
||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { RevisionPageDocument } from '@gitbook/api';
|
||||
|
||||
import { getPageFullTitle } from './title';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext {
|
||||
return {
|
||||
site: { title: siteTitle },
|
||||
visibleSections: sectionTitle
|
||||
? {
|
||||
current: { title: sectionTitle, default: false },
|
||||
list: [{ object: 'site-section' }, { object: 'site-section' }],
|
||||
}
|
||||
: undefined,
|
||||
visibleSiteSpaces: [],
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
function makePage(title: string, tagTitle?: string): RevisionPageDocument {
|
||||
return { title, tagTitle } as unknown as RevisionPageDocument;
|
||||
}
|
||||
|
||||
describe('getPageFullTitle', () => {
|
||||
it('uses tagTitle as the first segment', () => {
|
||||
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe(
|
||||
'SEO title | GitBook'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the page title when tagTitle is absent', () => {
|
||||
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe(
|
||||
'Page title | GitBook'
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates the section title against tagTitle', () => {
|
||||
expect(
|
||||
getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section'))
|
||||
).toBe('Section | GitBook');
|
||||
});
|
||||
|
||||
it('deduplicates the site title against tagTitle', () => {
|
||||
expect(
|
||||
getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title'))
|
||||
).toBe('SEO title');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { notFound, redirect } from 'next/navigation';
|
||||
import {
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationHeaderPreset,
|
||||
type RevisionPageDocument,
|
||||
SiteInsightsDisplayContext,
|
||||
type TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
getPathnameParam,
|
||||
} from './fetch';
|
||||
import { PageClientLayout } from './PageClientLayout';
|
||||
import { getPageFullTitle } from './title';
|
||||
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
|
||||
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
@@ -33,11 +33,7 @@ import { getResizedImageURL } from '@/lib/images';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
|
||||
import {
|
||||
getSiteSpacePagePaths,
|
||||
getSiteStructureTitle,
|
||||
resolveSiteSpaceCustomHomePage,
|
||||
} from '@/lib/sites';
|
||||
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import {
|
||||
generateUpdatesFilterCSS,
|
||||
@@ -45,6 +41,8 @@ import {
|
||||
updatesFilterStyleHref,
|
||||
} from '@/lib/updates';
|
||||
|
||||
export { getPageFullTitle } from './title';
|
||||
|
||||
export type SitePageProps = {
|
||||
context: GitBookSiteContext;
|
||||
pageParams: PagePathParams;
|
||||
@@ -421,20 +419,3 @@ async function resolvePageMetaLinks(
|
||||
alternates: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the <title> for a page.
|
||||
*/
|
||||
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
|
||||
const { site } = context;
|
||||
const siteStructureTitle = getSiteStructureTitle(context);
|
||||
|
||||
return [
|
||||
page.title,
|
||||
// Prevent duplicate titles by comparing against the page title.
|
||||
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
|
||||
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
mock.module('server-only', () => ({}));
|
||||
|
||||
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
|
||||
const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } =
|
||||
await import('./fetch');
|
||||
const { normalizeURL } = await import('@/lib/data/urls');
|
||||
|
||||
const page = {
|
||||
@@ -94,6 +95,70 @@ describe('fetchPageData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMissingPagePath', () => {
|
||||
function createRedirectContext(options: {
|
||||
siteRedirect?: { target: string; permanent?: boolean };
|
||||
spaceRedirectPageId?: string;
|
||||
}) {
|
||||
const getSiteRedirectBySource = mock(async ({ source }: { source: string }) =>
|
||||
options.siteRedirect && source === '/old'
|
||||
? {
|
||||
data: {
|
||||
target: options.siteRedirect.target,
|
||||
redirect: { permanent: options.siteRedirect.permanent ?? false },
|
||||
},
|
||||
}
|
||||
: { error: { code: 404, message: 'Not found' } }
|
||||
);
|
||||
const getRevisionPageByPath = mock(async () =>
|
||||
options.spaceRedirectPageId
|
||||
? { data: { id: options.spaceRedirectPageId } }
|
||||
: { error: { code: 404, message: 'Not found' } }
|
||||
);
|
||||
|
||||
return {
|
||||
organizationId: 'org-1',
|
||||
site: { id: 'site-1' },
|
||||
space: { id: 'space-1', revision: 'revision-1' },
|
||||
revisionId: 'revision-1',
|
||||
revision: { pages: [page] },
|
||||
linker: {
|
||||
toPathInSpace: (path: string) => path,
|
||||
toRelativePathInSite: (path: string) => path,
|
||||
toLinkForContent: (url: string) => new URL(url).pathname,
|
||||
},
|
||||
dataFetcher: { getSiteRedirectBySource, getRevisionPageByPath },
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
it('resolves a site redirect', async () => {
|
||||
const context = createRedirectContext({
|
||||
siteRedirect: { target: 'https://docs.example.com/new', permanent: true },
|
||||
});
|
||||
|
||||
expect(await resolveMissingPagePath(context, 'old')).toEqual({
|
||||
type: 'redirect',
|
||||
destination: '/new',
|
||||
permanent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a space redirect to a page', async () => {
|
||||
const context = createRedirectContext({ spaceRedirectPageId: page.id });
|
||||
|
||||
expect(await resolveMissingPagePath(context, 'old')).toEqual({
|
||||
type: 'page',
|
||||
page: { page, ancestors: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', async () => {
|
||||
const context = createRedirectContext({});
|
||||
|
||||
expect(await resolveMissingPagePath(context, 'old')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLowercasePathnameRedirect', () => {
|
||||
it('redirects ASCII paths with uppercase letters', () => {
|
||||
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
|
||||
|
||||
@@ -2,13 +2,14 @@ import { permanentRedirect, redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
CustomizationPageActionType,
|
||||
type RevisionPageDocument,
|
||||
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
|
||||
SITE_REDIRECT_SOURCE_PATH_PATTERN,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { type ResolvedPagePath, resolvePageId } from '@/lib/pages';
|
||||
import { withLeadingSlash } from '@/lib/paths';
|
||||
import { resolveSiteSpacePagePath } from '@/lib/sites';
|
||||
|
||||
@@ -70,7 +71,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
|
||||
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
|
||||
const { revision } = context;
|
||||
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(revision.pages, params.pageId);
|
||||
@@ -85,72 +86,102 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
|
||||
return page;
|
||||
}
|
||||
|
||||
const fallback = await resolveMissingPagePath(context, rawPathname);
|
||||
if (fallback?.type === 'redirect') {
|
||||
return fallback.permanent
|
||||
? permanentRedirect(fallback.destination)
|
||||
: redirect(fallback.destination);
|
||||
}
|
||||
|
||||
return fallback?.page;
|
||||
}
|
||||
|
||||
export type MissingPagePathResolution =
|
||||
| {
|
||||
type: 'redirect';
|
||||
/** Destination as returned by `linker.toLinkForContent` (absolute path or URL). */
|
||||
destination: string;
|
||||
permanent: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'page';
|
||||
page: ResolvedPagePath<RevisionPageDocument>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a pathname that doesn't match any page of the revision, using site-level and space-level redirects.
|
||||
*/
|
||||
export async function resolveMissingPagePath(
|
||||
context: GitBookSiteContext,
|
||||
rawPathname: string
|
||||
): Promise<MissingPagePathResolution | undefined> {
|
||||
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
|
||||
|
||||
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||
// API has a limit of less than 512 characters for the source path, so we use the same limit here.
|
||||
if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
|
||||
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
|
||||
const redirectPathname = withLeadingSlash(rawPathname);
|
||||
// If a page can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const redirectSources = new Set(
|
||||
[
|
||||
// Test the pathname relative to the root
|
||||
// For example hello/world -> section/variant/hello/world
|
||||
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
|
||||
// Test the pathname relative to the content/space
|
||||
// For example hello/world -> /hello/world
|
||||
redirectPathname,
|
||||
]
|
||||
.map(toSiteRedirectSourceCandidate)
|
||||
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
|
||||
);
|
||||
if (rawPathname.length >= SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const source of redirectSources) {
|
||||
// We try to resolve the site redirect
|
||||
const resolvedSiteRedirect =
|
||||
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
|
||||
(await getDataOrNull(
|
||||
context.dataFetcher.getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId: site.id,
|
||||
source,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
));
|
||||
if (resolvedSiteRedirect) {
|
||||
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
|
||||
const isPublicLiveContext =
|
||||
!shareKey &&
|
||||
!context.changeRequest &&
|
||||
!context.preview &&
|
||||
context.revisionId === context.space.revision &&
|
||||
!context.isLoggedInVisitor;
|
||||
if (
|
||||
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
|
||||
const redirectPathname = withLeadingSlash(rawPathname);
|
||||
// If a page can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const redirectSources = new Set(
|
||||
[
|
||||
// Test the pathname relative to the root
|
||||
// For example hello/world -> section/variant/hello/world
|
||||
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
|
||||
// Test the pathname relative to the content/space
|
||||
// For example hello/world -> /hello/world
|
||||
redirectPathname,
|
||||
]
|
||||
.map(toSiteRedirectSourceCandidate)
|
||||
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
|
||||
);
|
||||
|
||||
for (const source of redirectSources) {
|
||||
// We try to resolve the site redirect
|
||||
const resolvedSiteRedirect =
|
||||
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
|
||||
(await getDataOrNull(
|
||||
context.dataFetcher.getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId: site.id,
|
||||
source,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
));
|
||||
if (resolvedSiteRedirect) {
|
||||
const isPublicLiveContext =
|
||||
!shareKey &&
|
||||
!context.changeRequest &&
|
||||
!context.preview &&
|
||||
context.revisionId === context.space.revision &&
|
||||
!context.isLoggedInVisitor;
|
||||
return {
|
||||
type: 'redirect',
|
||||
destination: linker.toLinkForContent(resolvedSiteRedirect.target),
|
||||
permanent: Boolean(
|
||||
resolvedSiteRedirect.redirect?.permanent &&
|
||||
!resolvedSiteRedirect.redirect.draft &&
|
||||
isPublicLiveContext
|
||||
) {
|
||||
return permanentRedirect(destination);
|
||||
}
|
||||
return redirect(destination);
|
||||
}
|
||||
}
|
||||
|
||||
// If page still can't be found, we try with the API, in case we have a redirect at space level.
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
const resolved = await getDataOrNull(
|
||||
context.dataFetcher.getRevisionPageByPath({
|
||||
spaceId: space.id,
|
||||
revisionId: revisionId,
|
||||
path: rawPathname,
|
||||
})
|
||||
);
|
||||
if (resolved) {
|
||||
return resolvePageId(revision.pages, resolved.id);
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// If page still can't be found, we try with the API, in case we have a redirect at space level.
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
const resolved = await getDataOrNull(
|
||||
context.dataFetcher.getRevisionPageByPath({
|
||||
spaceId: space.id,
|
||||
revisionId: revisionId,
|
||||
path: rawPathname,
|
||||
})
|
||||
);
|
||||
const page = resolved ? resolvePageId(revision.pages, resolved.id) : undefined;
|
||||
return page ? { type: 'page', page } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RevisionPageDocument } from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getSiteStructureTitle } from '@/lib/sites';
|
||||
|
||||
/**
|
||||
* Get the <title> for a page.
|
||||
*/
|
||||
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
|
||||
const { site } = context;
|
||||
const siteStructureTitle = getSiteStructureTitle(context);
|
||||
const tagTitle = page.tagTitle || page.title;
|
||||
|
||||
return [
|
||||
tagTitle,
|
||||
// The first page of a section is often the same as the section title, so we don't need to show it.
|
||||
tagTitle !== siteStructureTitle ? siteStructureTitle : null,
|
||||
// The site title can also be the same as the page title on the site's landing page.
|
||||
tagTitle !== site.title ? site.title : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
|
||||
<div
|
||||
className={tcls(
|
||||
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
|
||||
// No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls.
|
||||
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent',
|
||||
isSticking ? '' : 'after:opacity-0',
|
||||
'mt-1 pt-2.5 pb-0',
|
||||
'bg-tint-base',
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function TableOfContents(props: {
|
||||
orientation="vertical"
|
||||
contentClassName="flex flex-col p-2 gutter-stable"
|
||||
active="[data-active=true]"
|
||||
followActive
|
||||
leading={{
|
||||
fade: true,
|
||||
button: {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function Trademark(
|
||||
const { space } = context;
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
const url = new URL('https://www.gitbook.com');
|
||||
const url = new URL('https://www.gitbook.com/powered-by');
|
||||
url.searchParams.set('utm_source', 'content');
|
||||
url.searchParams.set('utm_medium', 'trademark');
|
||||
url.searchParams.set('utm_campaign', space.id);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
* A container that encapsulates a scrollable area with usability features.
|
||||
* - Faded edges when there is more content than the container can display.
|
||||
* - Buttons to advance the scroll position.
|
||||
* - Auto-scroll to the active item when it's initially active.
|
||||
* - Auto-scroll to the active item on mount and when it changes.
|
||||
*/
|
||||
export type ScrollContainerProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -42,6 +42,12 @@ export type ScrollContainerProps = {
|
||||
/** The ID or ref of the active item to scroll to. */
|
||||
active?: string | React.RefObject<HTMLElement | null>;
|
||||
|
||||
/**
|
||||
* Only scroll to the active item when it is not fully visible, and keep following it
|
||||
* when it changes later (requires `active` to be a selector).
|
||||
*/
|
||||
followActive?: boolean;
|
||||
|
||||
/** Scroll by one page of fully visible direct children instead of one viewport. */
|
||||
scrollByVisibleItems?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
@@ -53,6 +59,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
contentClassName,
|
||||
orientation,
|
||||
active,
|
||||
followActive = false,
|
||||
scrollByVisibleItems = false,
|
||||
leading = { fade: true, button: true },
|
||||
trailing = { fade: true, button: true },
|
||||
@@ -80,8 +87,44 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
if (!activeItem || !container.contains(activeItem)) {
|
||||
return;
|
||||
}
|
||||
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
|
||||
return;
|
||||
}
|
||||
scrollToElementInContainer(activeItem, container);
|
||||
}, [active]);
|
||||
}, [active, followActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!followActive || !container || typeof active !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
// Active items can mount only after a collapsed group expands.
|
||||
const observer = new MutationObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
for (const activeItem of container.querySelectorAll(active)) {
|
||||
if (!isElementFullyVisibleInContainer(activeItem, container)) {
|
||||
scrollToElementInContainer(activeItem, container, 'smooth');
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(container, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-active'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, followActive]);
|
||||
|
||||
const scrollFurther = () => {
|
||||
const container = containerRef.current;
|
||||
@@ -335,7 +378,11 @@ function scrollByViewport(
|
||||
/**
|
||||
* Scroll to an element in a container.
|
||||
*/
|
||||
function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
export function scrollToElementInContainer(
|
||||
element: Element,
|
||||
container: HTMLElement,
|
||||
behavior: ScrollBehavior = 'auto'
|
||||
) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
@@ -350,8 +397,26 @@ function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
(rect.left - containerRect.left) -
|
||||
container.clientWidth / 2 +
|
||||
rect.width / 2,
|
||||
// Use 'auto' to avoid additional scroll animations when scrolling to an element
|
||||
// as this may be called during layout/initialization when the page is not fully loaded.
|
||||
behavior: 'auto',
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
|
||||
function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) {
|
||||
if (
|
||||
!element.getClientRects().length ||
|
||||
container.clientHeight === 0 ||
|
||||
container.clientWidth === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
elementRect.top >= containerRect.top &&
|
||||
elementRect.bottom <= containerRect.bottom &&
|
||||
elementRect.left >= containerRect.left &&
|
||||
elementRect.right <= containerRect.right
|
||||
);
|
||||
}
|
||||
|
||||
@@ -519,6 +519,7 @@ export async function fetchSpaceContextByIds(
|
||||
shareKey: string | undefined;
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
revisionMetadata?: boolean;
|
||||
}
|
||||
): Promise<GitBookSpaceContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
@@ -552,6 +553,7 @@ export async function fetchSpaceContextByIds(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: ids.space,
|
||||
revisionId,
|
||||
...(ids.revisionMetadata ? { metadata: true } : {}),
|
||||
}),
|
||||
|
||||
// When trying to render a revision with an invalid / non-existing ID,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function createDataFetcher(
|
||||
return getRevision(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata ?? false,
|
||||
});
|
||||
},
|
||||
getRevisionPageByPath(params) {
|
||||
@@ -319,8 +320,15 @@ const getChangeRequest = cache(
|
||||
|
||||
// We don't use remote cache on vercel because of the 2Mb limit on cache size that makes some route crash
|
||||
const getRevision = cache(
|
||||
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
|
||||
async (
|
||||
input: DataFetcherInput,
|
||||
params: { spaceId: string; revisionId: string; metadata: boolean }
|
||||
) => {
|
||||
'use cache';
|
||||
if (params.metadata) {
|
||||
// Git paths can change without changing the content revision.
|
||||
cacheTag(getCacheTag({ tag: 'space', space: params.spaceId }));
|
||||
}
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
|
||||
const api = apiClient(input);
|
||||
@@ -328,7 +336,7 @@ const getRevision = cache(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
{
|
||||
metadata: false,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface GitBookDataFetcher {
|
||||
getRevision(params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
metadata?: boolean;
|
||||
}): Promise<DataFetcherResponse<api.Revision>>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
findGitPageURLTarget,
|
||||
findPageByGitPath,
|
||||
findPageForGitPageURLTarget,
|
||||
matchesGitPageURLTargetPath,
|
||||
} from './gitPageURL';
|
||||
|
||||
const SPACES = [
|
||||
{
|
||||
id: 'a',
|
||||
gitSync: {
|
||||
url: 'https://github.com/acme/docs/tree/main',
|
||||
installationProjectDirectory: 'guides',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
gitSync: {
|
||||
url: 'https://github.com/acme/docs/tree/main',
|
||||
installationProjectDirectory: '/api/',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('findGitPageURLTarget', () => {
|
||||
it.each(['header%201', '%E6%97%A5%E6%9C%AC', 'part%2Fone', 'percent%2520'])(
|
||||
'decodes anchor %s once',
|
||||
(anchor) => {
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
|
||||
SPACES
|
||||
)?.anchor
|
||||
).toBe(decodeURIComponent(anchor));
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['%ZZ', '%E0%A4'])('keeps malformed anchor %s unresolved', (anchor) => {
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
|
||||
SPACES
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the www host alias without accepting unrelated hosts', () => {
|
||||
expect(
|
||||
findGitPageURLTarget('https://www.github.com/acme/docs/tree/main/api/auth.md', SPACES)
|
||||
?.space
|
||||
).toBe('b');
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
'https://www.github.com.evil.test/acme/docs/tree/main/api/auth.md',
|
||||
SPACES
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('matches the repository, ref and directory and preserves anchors', () => {
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
|
||||
SPACES
|
||||
)
|
||||
).toEqual({ space: 'b', path: 'api/auth.md', anchor: 'tokens' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://github.com/other/docs/tree/main/api/auth.md',
|
||||
'https://github.com/acme/docs/tree/preview/api/auth.md',
|
||||
'https://github.com.evil.test/acme/docs/tree/main/api/auth.md',
|
||||
'https://github.com/acme/docs/tree/main/api-other/auth.md',
|
||||
'https://github.com/acme/docs/tree/main/api/auth.md?raw=1',
|
||||
'https://github.com/acme/docs/tree/main/api/%ZZ.md',
|
||||
'https://github.com/acme/docs/tree/main/api/%2Fsecret.md',
|
||||
])('does not reinterpret %s', (url) => {
|
||||
expect(findGitPageURLTarget(url, SPACES)).toBeNull();
|
||||
});
|
||||
|
||||
it('supports blob URLs and encoded file names', () => {
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
'https://github.com/acme/docs/blob/main/api/hello%20world.md',
|
||||
SPACES
|
||||
)
|
||||
).toEqual({ space: 'b', path: 'api/hello world.md', anchor: undefined });
|
||||
});
|
||||
|
||||
it('matches self-hosted GitLab with nested groups and a slash in the branch', () => {
|
||||
const spaces = [
|
||||
{
|
||||
id: 'b',
|
||||
gitSync: {
|
||||
url: 'https://git.example.com/group/sub/docs/-/tree/release/v2',
|
||||
installationProjectDirectory: 'api',
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
'https://git.example.com/group/sub/docs/-/blob/release/v2/api/auth.md',
|
||||
spaces
|
||||
)?.space
|
||||
).toBe('b');
|
||||
});
|
||||
|
||||
it('rejects ambiguous owners and ambiguous branch prefixes', () => {
|
||||
expect(
|
||||
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
|
||||
...SPACES,
|
||||
{ ...SPACES[1]!, id: 'duplicate' },
|
||||
])
|
||||
).toBeNull();
|
||||
expect(
|
||||
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
|
||||
...SPACES,
|
||||
{
|
||||
id: 'other-ref',
|
||||
gitSync: {
|
||||
url: 'https://github.com/acme/docs/tree/main/api',
|
||||
installationProjectDirectory: '',
|
||||
},
|
||||
},
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the most specific directory and deduplicates site placements', () => {
|
||||
expect(
|
||||
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
|
||||
...SPACES,
|
||||
SPACES[1]!,
|
||||
{
|
||||
id: 'root',
|
||||
gitSync: { url: SPACES[0]!.gitSync.url, installationProjectDirectory: '' },
|
||||
},
|
||||
])?.space
|
||||
).toBe('b');
|
||||
});
|
||||
|
||||
it('treats an omitted live project directory as the repository root', () => {
|
||||
expect(
|
||||
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
|
||||
{ id: 'old', gitSync: { url: SPACES[0]!.gitSync.url } },
|
||||
])
|
||||
).toEqual({ space: 'old', path: 'api/auth.md', anchor: undefined });
|
||||
});
|
||||
|
||||
it.each(['live', 'disconnected'])('keeps similar space directories distinct (%s)', (state) => {
|
||||
const spaces = ['docs/space-a', 'docs/space-b', 'api-reference'].map((directory) => ({
|
||||
id: directory,
|
||||
...(state === 'live'
|
||||
? {
|
||||
gitSync: {
|
||||
url: SPACES[0]!.gitSync.url,
|
||||
installationProjectDirectory: `/${directory}`,
|
||||
},
|
||||
}
|
||||
: { previousGitSync: { url: `${SPACES[0]!.gitSync.url}/${directory}` } }),
|
||||
}));
|
||||
const url = new URL(
|
||||
'../docs/space-b/page.md#details',
|
||||
`${SPACES[0]!.gitSync.url}/api-reference/README.md`
|
||||
).href;
|
||||
const target = findGitPageURLTarget(url, spaces);
|
||||
expect(target?.space).toBe('docs/space-b');
|
||||
expect(target?.anchor).toBe('details');
|
||||
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/page.md')).toBe(true);
|
||||
expect(
|
||||
findGitPageURLTarget(
|
||||
url,
|
||||
spaces.filter((space) => space.id !== 'docs/space-b')
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'https://github.com/old/repo/tree/main/docs/space-b',
|
||||
'https://github.com/old/repo/blob/main/docs/space-b/hello%20world.md#details',
|
||||
],
|
||||
[
|
||||
'https://git.example.com/group/repo/-/tree/release/v2/docs/space-b',
|
||||
'https://git.example.com/group/repo/-/blob/release/v2/docs/space-b/hello%20world.md#details',
|
||||
],
|
||||
])('matches the remembered project URL %s', (previousURL, href) => {
|
||||
const target = findGitPageURLTarget(href, [
|
||||
{ id: 'b', previousGitSync: { url: previousURL } },
|
||||
]);
|
||||
expect(target?.space).toBe('b');
|
||||
expect(target?.anchor).toBe('details');
|
||||
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/hello world.md')).toBe(
|
||||
true
|
||||
);
|
||||
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-a/hello world.md')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://github.com/someone-else/example/blob/main/docs/space-b/page.md',
|
||||
'https://github.com/old/repo/tree/other/docs/space-b/page.md',
|
||||
'https://github.com.evil.test/old/repo/tree/main/docs/space-b/page.md',
|
||||
'https://github.com/old/repo/tree/main/docs/space-b-other/page.md',
|
||||
'https://github.com/old/repo/tree/main/other-docs/space-b/page.md',
|
||||
'https://github.com/old/repo/tree/main/docs/space-b/%2Fsecret.md',
|
||||
'https://github.com/old/repo/tree/main/docs/space-b/%ZZ.md',
|
||||
])('does not reinterpret an unrelated or invalid URL: %s', (href) => {
|
||||
expect(
|
||||
findGitPageURLTarget(href, [
|
||||
{
|
||||
id: 'b',
|
||||
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
|
||||
},
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('requires a valid previous URL', () => {
|
||||
for (const url of [undefined, 'invalid', 'https://github.com/old/repo']) {
|
||||
expect(
|
||||
findGitPageURLTarget('https://github.com/old/repo/tree/main/docs/space-b/page.md', [
|
||||
{ id: 'b', previousGitSync: { url } },
|
||||
])
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports disconnected repository roots and directory README links', () => {
|
||||
const target = findGitPageURLTarget(
|
||||
'https://github.com/old/repo/tree/release/v2/docs/space-b/',
|
||||
[{ id: 'b', previousGitSync: { url: 'https://github.com/old/repo/tree/release/v2' } }]
|
||||
);
|
||||
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/README.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duplicate previous owners and ignores stale metadata on live installations', () => {
|
||||
const previous = {
|
||||
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
|
||||
};
|
||||
const url = 'https://github.com/old/repo/tree/main/docs/space-b/page.md';
|
||||
expect(
|
||||
findGitPageURLTarget(url, [
|
||||
{ id: 'b', ...previous },
|
||||
{ id: 'copy', ...previous },
|
||||
])
|
||||
).toBeNull();
|
||||
expect(findGitPageURLTarget(url, [{ ...SPACES[1]!, ...previous }])).toBeNull();
|
||||
});
|
||||
|
||||
it('compares live and previous project URLs at the same directory boundary', () => {
|
||||
const href = 'https://github.com/acme/docs/tree/main/api/auth.md';
|
||||
const disconnected = {
|
||||
id: 'old-api',
|
||||
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
|
||||
};
|
||||
expect(findGitPageURLTarget(href, [SPACES[1]!, disconnected])).toBeNull();
|
||||
expect(
|
||||
findGitPageURLTarget(href, [
|
||||
disconnected,
|
||||
{ id: 'root', gitSync: { url: SPACES[0]!.gitSync.url } },
|
||||
])?.space
|
||||
).toBe('old-api');
|
||||
});
|
||||
|
||||
it('accepts the www alias for a remembered project URL', () => {
|
||||
expect(
|
||||
findGitPageURLTarget('https://www.github.com/acme/docs/blob/main/api/auth.md', [
|
||||
{ id: 'b', previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' } },
|
||||
])?.space
|
||||
).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageByGitPath', () => {
|
||||
const pages = [
|
||||
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
|
||||
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
|
||||
];
|
||||
it('finds nested pages and directory README links', () => {
|
||||
expect(findPageByGitPath(pages, 'api/auth.md')?.id).toBe('auth');
|
||||
expect(findPageByGitPath(pages, 'api/11.8/')?.id).toBe('readme');
|
||||
expect(findPageByGitPath(pages, 'api/missing.md')).toBeNull();
|
||||
});
|
||||
it('does not select between duplicate paths', () => {
|
||||
expect(
|
||||
findPageByGitPath(
|
||||
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
|
||||
'api/auth.md'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageForGitPageURLTarget', () => {
|
||||
const pages = [
|
||||
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
|
||||
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
|
||||
];
|
||||
it('finds the page of a live target, including directory README links', () => {
|
||||
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/auth.md' })?.id).toBe(
|
||||
'auth'
|
||||
);
|
||||
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/11.8/' })?.id).toBe(
|
||||
'readme'
|
||||
);
|
||||
});
|
||||
it('finds the page of a remembered target whose path still includes the ref', () => {
|
||||
expect(
|
||||
findPageForGitPageURLTarget(pages, {
|
||||
space: 'b',
|
||||
path: 'release/v2/api/auth.md',
|
||||
pathIncludesRef: true,
|
||||
})?.id
|
||||
).toBe('auth');
|
||||
});
|
||||
it('does not select between duplicate paths', () => {
|
||||
expect(
|
||||
findPageForGitPageURLTarget(
|
||||
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
|
||||
{ space: 'b', path: 'api/auth.md' }
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
export interface GitPageURLSpace {
|
||||
id: string;
|
||||
gitSync?: {
|
||||
url?: string;
|
||||
installationProjectDirectory?: string;
|
||||
};
|
||||
previousGitSync?: { url?: string };
|
||||
}
|
||||
|
||||
export interface GitPageURLTarget {
|
||||
space: string;
|
||||
path: string;
|
||||
anchor?: string;
|
||||
/** Previous project URLs do not distinguish a slash-containing ref from the file path. */
|
||||
pathIncludesRef?: boolean;
|
||||
}
|
||||
|
||||
/** Locate a unique owning space without fetching any revisions. */
|
||||
export function findGitPageURLTarget(
|
||||
href: string,
|
||||
spaces: readonly GitPageURLSpace[]
|
||||
): GitPageURLTarget | null {
|
||||
const url = parseURL(href);
|
||||
if (!url || url.search) {
|
||||
return null;
|
||||
}
|
||||
let anchor: string | undefined;
|
||||
try {
|
||||
anchor = decodeURIComponent(url.hash.slice(1)) || undefined;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = new Map<string, GitPageURLTarget & { root: string; tree?: string }>();
|
||||
for (const space of spaces) {
|
||||
const directory = space.gitSync?.installationProjectDirectory ?? '';
|
||||
let root = directory.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
||||
let filePath: string | null;
|
||||
let treeKey: string | undefined;
|
||||
if (space.gitSync) {
|
||||
const tree = space.gitSync.url ? parseURL(space.gitSync.url) : null;
|
||||
if (!tree || (tree.host !== url.host && url.host !== `www.${tree.host}`)) {
|
||||
continue;
|
||||
}
|
||||
const prefix = tree.pathname.replace(/\/$/, '');
|
||||
const blobPrefix = prefix
|
||||
.replace('/-/tree/', '/-/blob/')
|
||||
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
|
||||
const matchedPrefix = [prefix, blobPrefix].find((candidate) =>
|
||||
url.pathname.startsWith(`${candidate}/`)
|
||||
);
|
||||
if (!matchedPrefix) {
|
||||
continue;
|
||||
}
|
||||
filePath = decodeGitPath(url.pathname.slice(matchedPrefix.length + 1));
|
||||
treeKey = `${tree.host}${prefix}`;
|
||||
if (!filePath || (root && filePath !== root && !filePath.startsWith(`${root}/`))) {
|
||||
continue;
|
||||
}
|
||||
root = `${prefix}/${root}`.replace(/\/$/, '');
|
||||
} else {
|
||||
const previous = space.previousGitSync?.url
|
||||
? parseURL(space.previousGitSync.url)
|
||||
: null;
|
||||
filePath = previous ? findPreviousGitPath(url, previous) : null;
|
||||
if (!filePath || !previous) {
|
||||
continue;
|
||||
}
|
||||
root = previous.pathname.replace(/\/$/, '');
|
||||
}
|
||||
matches.set(space.id, {
|
||||
space: space.id,
|
||||
path: filePath,
|
||||
anchor,
|
||||
root,
|
||||
tree: treeKey,
|
||||
...(!space.gitSync ? { pathIncludesRef: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const candidates = [...matches.values()];
|
||||
if (
|
||||
new Set(candidates.flatMap((candidate) => (candidate.tree ? [candidate.tree] : []))).size >
|
||||
1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const longestRoot = Math.max(...candidates.map((candidate) => candidate.root.length));
|
||||
const owners = candidates.filter((candidate) => candidate.root.length === longestRoot);
|
||||
if (owners.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const owner = owners[0]!;
|
||||
return {
|
||||
space: owner.space,
|
||||
path: owner.path,
|
||||
anchor: owner.anchor,
|
||||
...(owner.pathIncludesRef ? { pathIncludesRef: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Match stored page paths after verifying the owning repository URL; callers must reject multiple pages. */
|
||||
export function matchesGitPageURLTargetPath(target: GitPageURLTarget, filePath: string): boolean {
|
||||
const paths = [target.path, `${target.path.replace(/\/$/, '')}/README.md`];
|
||||
return paths.some((path) =>
|
||||
target.pathIncludesRef ? path.endsWith(`/${filePath}`) : path === filePath
|
||||
);
|
||||
}
|
||||
|
||||
/** Match an API revision's nested page tree, including directory README links. */
|
||||
export function findPageByGitPath<T extends { id: string; git?: { path: string }; pages?: T[] }>(
|
||||
pages: readonly T[],
|
||||
filePath: string
|
||||
): T | null {
|
||||
const paths = [filePath, `${filePath.replace(/\/$/, '')}/README.md`];
|
||||
const matches: T[] = [];
|
||||
const visit = (children: readonly T[]) => {
|
||||
for (const page of children) {
|
||||
if (page.git && paths.includes(page.git.path)) {
|
||||
matches.push(page);
|
||||
}
|
||||
visit(page.pages ?? []);
|
||||
}
|
||||
};
|
||||
visit(pages);
|
||||
return matches.length === 1 ? matches[0]! : null;
|
||||
}
|
||||
|
||||
/** Find the only page of an API revision's page tree at the path a target points to. */
|
||||
export function findPageForGitPageURLTarget<
|
||||
T extends { id: string; git?: { path: string }; pages?: T[] },
|
||||
>(pages: readonly T[], target: GitPageURLTarget): T | null {
|
||||
const matches: T[] = [];
|
||||
const visit = (children: readonly T[]) => {
|
||||
for (const page of children) {
|
||||
if (page.git && matchesGitPageURLTargetPath(target, page.git.path)) {
|
||||
matches.push(page);
|
||||
}
|
||||
visit(page.pages ?? []);
|
||||
}
|
||||
};
|
||||
visit(pages);
|
||||
return matches.length === 1 ? matches[0]! : null;
|
||||
}
|
||||
|
||||
function parseURL(href: string): URL | null {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
|
||||
? url
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeGitPath(encoded: string): string | null {
|
||||
try {
|
||||
// Encoded separators make repository/ref boundaries ambiguous.
|
||||
if (/%2f|%5c/i.test(encoded)) {
|
||||
return null;
|
||||
}
|
||||
const decoded = decodeURIComponent(encoded);
|
||||
return decoded.includes('\\') ||
|
||||
decoded.split('/').some((part) => part === '.' || part === '..')
|
||||
? null
|
||||
: decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findPreviousGitPath(url: URL, previous: URL): string | null {
|
||||
if (
|
||||
previous.search ||
|
||||
previous.hash ||
|
||||
!/\/(?:tree|blob)\/.+/.test(previous.pathname) ||
|
||||
(url.host !== previous.host && url.host !== `www.${previous.host}`)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const prefix = previous.pathname.replace(/\/$/, '');
|
||||
const blobPrefix = prefix
|
||||
.replace('/-/tree/', '/-/blob/')
|
||||
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
|
||||
if (
|
||||
![prefix, blobPrefix].some(
|
||||
(candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Keep the ref until revision lookup: its slash boundary is not recorded separately.
|
||||
const suffix = url.pathname.match(/\/(?:tree|blob)\/(.+)$/)?.[1];
|
||||
return suffix ? decodeGitPath(suffix) : null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
|
||||
|
||||
@@ -738,3 +738,221 @@ describe('resolveContentRef for direct space links', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repository page links', () => {
|
||||
function fixture(
|
||||
options: {
|
||||
denied?: boolean;
|
||||
missing?: boolean;
|
||||
draft?: boolean;
|
||||
gitSync?: object | null;
|
||||
previousGitSync?: object;
|
||||
pageGitPath?: string;
|
||||
} = {}
|
||||
) {
|
||||
const page = {
|
||||
id: 'target-page',
|
||||
type: 'document',
|
||||
title: 'Authentication',
|
||||
path: 'authentication',
|
||||
slug: 'authentication',
|
||||
pages: [],
|
||||
git: { path: options.pageGitPath ?? 'api/auth.md', oid: 'blob' },
|
||||
} as unknown as RevisionPageDocument;
|
||||
const targetSpace = {
|
||||
id: 'target',
|
||||
title: 'API',
|
||||
organization: 'org',
|
||||
revision: 'target-main',
|
||||
gitSync:
|
||||
options.gitSync === null
|
||||
? undefined
|
||||
: (options.gitSync ?? {
|
||||
url: 'https://github.com/acme/docs/tree/main',
|
||||
installationProjectDirectory: 'api',
|
||||
}),
|
||||
previousGitSync: options.previousGitSync,
|
||||
urls: {
|
||||
app: 'https://app.gitbook.com/s/target',
|
||||
published: 'https://docs.example.com/api/',
|
||||
},
|
||||
} as unknown as Space;
|
||||
const targetSiteSpace = {
|
||||
id: 'site-target',
|
||||
title: 'API',
|
||||
space: targetSpace,
|
||||
path: 'api',
|
||||
draft: options.draft ?? false,
|
||||
urls: { published: 'https://docs.example.com/api/' },
|
||||
} as unknown as SiteSpace;
|
||||
const getSpace = mock(async () =>
|
||||
options.denied ? { error: { code: 403, message: 'Forbidden' } } : { data: targetSpace }
|
||||
);
|
||||
const getRevision = mock(async () => ({
|
||||
data: {
|
||||
id: 'target-main',
|
||||
pages: options.missing
|
||||
? []
|
||||
: [{ ...page, id: 'home', path: '', slug: '', git: undefined }, page],
|
||||
files: [],
|
||||
reusableContents: [],
|
||||
},
|
||||
}));
|
||||
const context = {
|
||||
organizationId: 'org',
|
||||
site: { id: 'site' },
|
||||
space: { id: 'source', revision: 'source-main' },
|
||||
revision: { pages: [] },
|
||||
revisionId: 'source-main',
|
||||
changeRequest: null,
|
||||
structure: { type: 'siteSpaces', structure: [targetSiteSpace] },
|
||||
linker: createLinker({
|
||||
host: 'docs.example.com',
|
||||
siteBasePath: '/',
|
||||
spaceBasePath: '/source/',
|
||||
}),
|
||||
dataFetcher: { getSpace, getRevision },
|
||||
} as unknown as GitBookAnyContext;
|
||||
return { context, getSpace, getRevision };
|
||||
}
|
||||
|
||||
const ref = {
|
||||
kind: 'url' as const,
|
||||
url: 'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
|
||||
};
|
||||
|
||||
it('renders a matching repository URL as a site page link with its anchor', async () => {
|
||||
const { context, getRevision } = fixture();
|
||||
const result = await resolveContentRef(ref, context);
|
||||
expect(result?.href).toBe('/api/authentication#tokens');
|
||||
expect(result?.text).toBe('Authentication');
|
||||
expect(result?.ancestors?.[0]?.label).toBe('API');
|
||||
expect(result?.resolvedRef).toEqual({
|
||||
kind: 'anchor',
|
||||
space: 'target',
|
||||
page: 'target-page',
|
||||
anchor: 'tokens',
|
||||
});
|
||||
expect(getRevision).toHaveBeenCalledWith({
|
||||
spaceId: 'target',
|
||||
revisionId: 'target-main',
|
||||
metadata: true,
|
||||
});
|
||||
expect(ref.kind).toBe('url');
|
||||
});
|
||||
|
||||
it('preserves asset URLs that do not match a page', async () => {
|
||||
const { context } = fixture();
|
||||
const assetRef = {
|
||||
kind: 'url' as const,
|
||||
url: ref.url.replace('auth.md#tokens', 'diagram.png'),
|
||||
};
|
||||
expect((await resolveContentRef(assetRef, context))?.href).toBe(assetRef.url);
|
||||
});
|
||||
|
||||
it('resolves a link to a folder with a dot in its name to its README page', async () => {
|
||||
const { context } = fixture({ pageGitPath: 'api/11.8/README.md' });
|
||||
expect(
|
||||
(
|
||||
await resolveContentRef(
|
||||
{ kind: 'url', url: ref.url.replace('auth.md', '11.8') },
|
||||
context
|
||||
)
|
||||
)?.href
|
||||
).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('resolves pages stored with another Markdown extension', async () => {
|
||||
const { context } = fixture({ pageGitPath: 'api/Auth.MARKDOWN' });
|
||||
expect(
|
||||
(
|
||||
await resolveContentRef(
|
||||
{ kind: 'url', url: ref.url.replace('auth.md', 'Auth.MARKDOWN') },
|
||||
context
|
||||
)
|
||||
)?.href
|
||||
).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('resolves a space syncing from the repository root', async () => {
|
||||
const { context } = fixture({
|
||||
gitSync: { url: 'https://github.com/acme/docs/tree/main' },
|
||||
});
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('resolves a space whose Git Sync was removed from its remembered project URL', async () => {
|
||||
const { context } = fixture({
|
||||
gitSync: null,
|
||||
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
|
||||
});
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('decodes the anchor of the repository URL', async () => {
|
||||
const { context } = fixture();
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'url', url: ref.url.replace('#tokens', '#access%20tokens') },
|
||||
context
|
||||
);
|
||||
expect(result?.resolvedRef).toEqual({
|
||||
kind: 'anchor',
|
||||
space: 'target',
|
||||
page: 'target-page',
|
||||
anchor: 'access tokens',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads only the matching space in a 500-space site', async () => {
|
||||
const { context, getSpace, getRevision } = fixture();
|
||||
if (!('site' in context) || context.structure.type !== 'siteSpaces') {
|
||||
throw new Error('Expected a site fixture');
|
||||
}
|
||||
const target = context.structure.structure[0]!;
|
||||
context.structure.structure.push(
|
||||
...Array.from({ length: 499 }, (_, index) => ({
|
||||
...target,
|
||||
id: `site-${index}`,
|
||||
space: {
|
||||
...target.space,
|
||||
id: `space-${index}`,
|
||||
gitSync: {
|
||||
...target.space.gitSync!,
|
||||
installationProjectDirectory: `other-${index}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
);
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
expect(getSpace).toHaveBeenCalledTimes(1);
|
||||
expect(getRevision).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([{ denied: true }, { missing: true }, { draft: true }])(
|
||||
'preserves the fallback for unavailable content: %j',
|
||||
async (state) => {
|
||||
const { context } = fixture(state);
|
||||
const result = await resolveContentRef(ref, context);
|
||||
expect(result).toEqual({ href: ref.url, text: ref.url, active: false });
|
||||
}
|
||||
);
|
||||
|
||||
it('does not fetch revisions for a different repository or branch', async () => {
|
||||
const { context, getRevision } = fixture();
|
||||
for (const url of [
|
||||
ref.url.replace('/main/', '/preview/'),
|
||||
ref.url.replace('/acme/', '/other/'),
|
||||
]) {
|
||||
expect((await resolveContentRef({ kind: 'url', url }, context))?.href).toBe(url);
|
||||
}
|
||||
expect(getRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the unchanged stored URL when the target becomes available', async () => {
|
||||
const state = { missing: true };
|
||||
const { context } = fixture(state);
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe(ref.url);
|
||||
state.missing = false;
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
RevisionReusableContent,
|
||||
SiteSection,
|
||||
SiteSpace,
|
||||
SiteStructure,
|
||||
Space,
|
||||
TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
@@ -16,12 +17,19 @@ import type { Filesystem } from '@gitbook/openapi-parser';
|
||||
|
||||
import { getGitBookAppHref } from './app';
|
||||
import { getBlockById, getBlockTitle } from './document';
|
||||
import {
|
||||
type GitPageURLSpace,
|
||||
type GitPageURLTarget,
|
||||
findGitPageURLTarget,
|
||||
findPageForGitPageURLTarget,
|
||||
} from './gitPageURL';
|
||||
import { resolvePageId } from './pages';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLinkerForSiteSpace,
|
||||
getLocalizedTitle,
|
||||
listAllSiteSpaces,
|
||||
} from './sites';
|
||||
import { getRevisionTags, resolveTag } from './tags';
|
||||
import type { ClassValue } from './tailwind';
|
||||
@@ -42,7 +50,15 @@ import {
|
||||
} from '@/lib/data';
|
||||
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
|
||||
|
||||
// The spaces of each site that can own a repository URL, and the hosts of their repositories.
|
||||
const siteGitSpaces = new WeakMap<
|
||||
SiteStructure,
|
||||
{ spaces: GitPageURLSpace[]; hosts: Set<string> }
|
||||
>();
|
||||
|
||||
export interface ResolvedContentRef {
|
||||
/** Effective destination when a repository URL resolves to a site page. */
|
||||
resolvedRef?: ContentRef;
|
||||
/** Text to render in the content ref */
|
||||
text: string;
|
||||
/** Additional sub text to render in the content ref */
|
||||
@@ -143,6 +159,61 @@ export async function resolveContentRef(
|
||||
|
||||
switch (contentRef.kind) {
|
||||
case 'url': {
|
||||
if ('site' in context) {
|
||||
const target = findSiteGitPageURLTarget(context.structure, contentRef.url);
|
||||
if (target) {
|
||||
try {
|
||||
// Site CRs must select the target member's revision here instead of main.
|
||||
const targetContext = await createContextForSpace(
|
||||
target.space,
|
||||
context,
|
||||
true
|
||||
);
|
||||
const page =
|
||||
targetContext &&
|
||||
findPageForGitPageURLTarget(
|
||||
targetContext.spaceContext.revision.pages,
|
||||
target
|
||||
);
|
||||
if (page?.type === 'document' && targetContext) {
|
||||
const resolvedRef: ContentRef = target.anchor
|
||||
? {
|
||||
kind: 'anchor',
|
||||
space: target.space,
|
||||
page: page.id,
|
||||
anchor: target.anchor,
|
||||
}
|
||||
: { kind: 'page', space: target.space, page: page.id };
|
||||
const resolved = await resolveContentRef(
|
||||
resolvedRef,
|
||||
targetContext.spaceContext,
|
||||
options
|
||||
);
|
||||
if (resolved) {
|
||||
const foundSiteSpace = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(entry) => entry.space.id === target.space
|
||||
);
|
||||
return {
|
||||
...resolved,
|
||||
resolvedRef,
|
||||
ancestors: [
|
||||
...resolvePageAncestors(
|
||||
context,
|
||||
resolvedRef,
|
||||
foundSiteSpace,
|
||||
targetContext
|
||||
),
|
||||
...(resolved.ancestors ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unavailable or forbidden target must not prevent rendering the source page.
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
href: contentRef.url,
|
||||
text: contentRef.url,
|
||||
@@ -619,6 +690,44 @@ async function resolveContentRefInSpace(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the site space owning a repository URL. Links to other hosts, which most are, skip
|
||||
* matching against every space of the site.
|
||||
*/
|
||||
function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPageURLTarget | null {
|
||||
let site = siteGitSpaces.get(structure);
|
||||
if (!site) {
|
||||
const spaces: GitPageURLSpace[] = listAllSiteSpaces(structure)
|
||||
.filter((siteSpace) => !siteSpace.draft)
|
||||
.map((siteSpace) => siteSpace.space);
|
||||
const hosts = new Set(
|
||||
spaces.flatMap((space) => {
|
||||
const host = getURLHost(space.gitSync?.url ?? space.previousGitSync?.url);
|
||||
return host ? [host, `www.${host}`] : [];
|
||||
})
|
||||
);
|
||||
site = { spaces, hosts };
|
||||
siteGitSpaces.set(structure, site);
|
||||
}
|
||||
|
||||
const host = getURLHost(href);
|
||||
if (!host || !site.hosts.has(host)) {
|
||||
return null;
|
||||
}
|
||||
return findGitPageURLTarget(href, site.spaces);
|
||||
}
|
||||
|
||||
function getURLHost(href: string | undefined): string | null {
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(href).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new context for a specific spaceId.
|
||||
*
|
||||
@@ -627,7 +736,8 @@ async function resolveContentRefInSpace(
|
||||
*/
|
||||
async function createContextForSpace(
|
||||
spaceId: string,
|
||||
context: GitBookAnyContext
|
||||
context: GitBookAnyContext,
|
||||
revisionMetadata = false
|
||||
): Promise<{
|
||||
spaceContext: GitBookSpaceContext;
|
||||
baseURL: URL;
|
||||
@@ -639,6 +749,7 @@ async function createContextForSpace(
|
||||
shareKey: context?.shareKey,
|
||||
changeRequest: undefined,
|
||||
revision: undefined,
|
||||
revisionMetadata,
|
||||
})
|
||||
),
|
||||
getBestTargetSpace(context, spaceId),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
import type { RevisionPageDocument } from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
|
||||
mock.module('server-only', () => ({}));
|
||||
|
||||
const { servePageMarkdown, toMarkdownDestination } = await import('./markdownPage');
|
||||
|
||||
const page = {
|
||||
id: 'page-1',
|
||||
title: 'New page',
|
||||
kind: 'sheet',
|
||||
type: 'document',
|
||||
path: 'new-page',
|
||||
slug: 'new-page',
|
||||
pages: [],
|
||||
} as unknown as RevisionPageDocument;
|
||||
|
||||
function createContext(options: {
|
||||
siteRedirect?: { target: string; permanent?: boolean };
|
||||
spaceRedirectPageId?: string;
|
||||
}) {
|
||||
return {
|
||||
organizationId: 'org-1',
|
||||
site: { id: 'site-1' },
|
||||
siteSpace: { id: 'site-space-1' },
|
||||
space: { id: 'space-1', revision: 'revision-1' },
|
||||
revisionId: 'revision-1',
|
||||
revision: { pages: [page] },
|
||||
linker: createLinker({
|
||||
host: 'docs.example.com',
|
||||
siteBasePath: '/docs/',
|
||||
spaceBasePath: '/docs/',
|
||||
}),
|
||||
dataFetcher: {
|
||||
getSiteRedirectBySource: async ({ source }: { source: string }) =>
|
||||
options.siteRedirect && source === '/old-page'
|
||||
? {
|
||||
data: {
|
||||
target: options.siteRedirect.target,
|
||||
redirect: { permanent: options.siteRedirect.permanent ?? false },
|
||||
},
|
||||
}
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
getRevisionPageByPath: async () =>
|
||||
options.spaceRedirectPageId
|
||||
? { data: { id: options.spaceRedirectPageId } }
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
},
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
describe('servePageMarkdown', () => {
|
||||
it('redirects to the markdown version of a site redirect target', async () => {
|
||||
const context = createContext({
|
||||
siteRedirect: { target: 'https://docs.example.com/docs/new-page', permanent: true },
|
||||
});
|
||||
|
||||
const response = await servePageMarkdown(context, 'old-page');
|
||||
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get('Location')).toBe('/docs/new-page.md');
|
||||
});
|
||||
|
||||
it('redirects to the markdown version of a space redirect target', async () => {
|
||||
const context = createContext({ spaceRedirectPageId: page.id });
|
||||
|
||||
const response = await servePageMarkdown(context, 'old-page');
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('Location')).toBe('/docs/new-page.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMarkdownDestination', () => {
|
||||
it('appends .md to same-site paths', () => {
|
||||
expect(toMarkdownDestination('/docs/new-page')).toBe('/docs/new-page.md');
|
||||
expect(toMarkdownDestination('/docs/new-page/?a=1#b')).toBe('/docs/new-page.md?a=1#b');
|
||||
});
|
||||
|
||||
it('points the site root to its markdown route', () => {
|
||||
expect(toMarkdownDestination('/')).toBe('/.md');
|
||||
expect(toMarkdownDestination('/?a=1#b')).toBe('/.md?a=1#b');
|
||||
});
|
||||
|
||||
it('leaves markdown paths and external URLs untouched', () => {
|
||||
expect(toMarkdownDestination('/docs/new-page.md')).toBe('/docs/new-page.md');
|
||||
expect(toMarkdownDestination('https://example.com/page')).toBe('https://example.com/page');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
|
||||
import { resolveMissingPagePath } from '@/components/SitePage/fetch';
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getExposableError } from '@/lib/data';
|
||||
@@ -22,12 +23,36 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat
|
||||
linker: linkerWithMarkdownPages(baseContext.linker),
|
||||
};
|
||||
|
||||
const pageLookup = resolveSiteSpacePagePathDocumentOrGroup(
|
||||
context.siteSpace,
|
||||
context.revision.pages,
|
||||
pagePath
|
||||
);
|
||||
const pageLookup =
|
||||
resolveSiteSpacePagePathDocumentOrGroup(
|
||||
context.siteSpace,
|
||||
context.revision.pages,
|
||||
pagePath
|
||||
) ??
|
||||
// Page paths are lowercase, match the case-insensitive lookup of HTML pages.
|
||||
resolveSiteSpacePagePathDocumentOrGroup(
|
||||
context.siteSpace,
|
||||
context.revision.pages,
|
||||
pagePath.toLowerCase()
|
||||
);
|
||||
if (!pageLookup) {
|
||||
const fallback = await resolveMissingPagePath(baseContext, pagePath);
|
||||
if (fallback?.type === 'redirect') {
|
||||
return markdownRedirect(
|
||||
toMarkdownDestination(fallback.destination),
|
||||
fallback.permanent
|
||||
);
|
||||
}
|
||||
if (fallback?.type === 'page') {
|
||||
return markdownRedirect(
|
||||
context.linker.toPathForPage({
|
||||
pages: context.revision.pages,
|
||||
page: fallback.page.page,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Generates a markdown body for missing pages. Return this with a 200 status (not 404) because agents discard 404 response bodies.=
|
||||
return {
|
||||
markdown: renderNotFoundMarkdown(context, pagePath),
|
||||
@@ -63,6 +88,33 @@ function getMarkdownRobots(
|
||||
return context.isAiAgent ? 'index, follow' : 'noindex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a redirect destination to its markdown version, so agents keep receiving markdown.
|
||||
* Destinations outside the site are returned as full URLs and left untouched.
|
||||
*/
|
||||
export function toMarkdownDestination(destination: string): string {
|
||||
if (!destination.startsWith('/')) {
|
||||
return destination;
|
||||
}
|
||||
|
||||
const url = new URL(destination, 'https://gitbook.invalid');
|
||||
const pathname = url.pathname.replace(/\/+$/, '');
|
||||
if (pathname.endsWith('.md')) {
|
||||
return destination;
|
||||
}
|
||||
|
||||
// A root destination trims to an empty pathname; its markdown route is `/.md`.
|
||||
return `${pathname || '/'}.md${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function markdownRedirect(location: string, permanent: boolean) {
|
||||
// Same status codes as Next's `redirect` / `permanentRedirect`.
|
||||
return new Response(null, {
|
||||
status: permanent ? 308 : 307,
|
||||
headers: { Location: location, Vary: 'Accept' },
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotFoundMarkdown(context: GitBookSiteContext, pagePath: string) {
|
||||
const similarPages = getSimilarPages(context.revision.pages, pagePath, 5);
|
||||
const sitemapUrl = context.linker.toAbsoluteURL(context.linker.toPathInSite('sitemap.md'));
|
||||
@@ -164,11 +216,14 @@ Use this mechanism when the answer is not explicitly present in the current page
|
||||
* Return a markdown content.
|
||||
*/
|
||||
export async function serveMarkdown(
|
||||
fn: () => Promise<string | { markdown: string; robots: string }>,
|
||||
fn: () => Promise<string | { markdown: string; robots: string } | Response>,
|
||||
isChatGPT?: boolean
|
||||
) {
|
||||
try {
|
||||
const result = await fn();
|
||||
if (result instanceof Response) {
|
||||
return result;
|
||||
}
|
||||
const { markdown, robots } =
|
||||
typeof result === 'string' ? { markdown: result, robots: 'noindex' } : result;
|
||||
return new Response(markdown, {
|
||||
|
||||
Reference in New Issue
Block a user