mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-30 22:18:46 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 078eacce45 | |||
| 9f168ff93b |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Recolor text over a background page cover in the first frame after hydration.
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Fix page cover image jumping on load
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Improve dark theme contrast for Mermaid edge labels.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"gitbook": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
|
|
||||||
@@ -129,8 +129,8 @@
|
|||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||||
"@modelcontextprotocol/sdk": "1.17.5",
|
"@modelcontextprotocol/sdk": "1.17.5",
|
||||||
"@opennextjs/aws": "4.1.6",
|
"@opennextjs/aws": "4.1.3",
|
||||||
"@opennextjs/cloudflare": "1.20.7",
|
"@opennextjs/cloudflare": "1.20.5",
|
||||||
"@panzoom/panzoom": "^4.6.1",
|
"@panzoom/panzoom": "^4.6.1",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -346,6 +346,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
|
"@opennextjs/aws": "4.1.5",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"axios": "1.8.4",
|
"axios": "1.8.4",
|
||||||
@@ -355,7 +356,7 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@gitbook/api": "0.203.0",
|
"@gitbook/api": "0.202.0",
|
||||||
"@scalar/api-client-react": "^1.3.46",
|
"@scalar/api-client-react": "^1.3.46",
|
||||||
"@tsconfig/node20": "^20.1.6",
|
"@tsconfig/node20": "^20.1.6",
|
||||||
"@tsconfig/strictest": "^2.0.6",
|
"@tsconfig/strictest": "^2.0.6",
|
||||||
@@ -727,7 +728,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=="],
|
"@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.203.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-cSFUMM7cIMTHW9cdMpexGSkHHaXmc506fx+LQE2+KLqce8GlBDU/ElIP4RU0MGtN+SSYF31AUgvPD8ZerBVcMA=="],
|
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
|
||||||
|
|
||||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||||
|
|
||||||
@@ -943,9 +944,9 @@
|
|||||||
|
|
||||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||||
|
|
||||||
"@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/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/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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -19,7 +19,8 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.27.3",
|
||||||
"axios": "1.8.4"
|
"axios": "1.8.4",
|
||||||
|
"@opennextjs/aws": "4.1.5"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
"@tsconfig/strictest": "^2.0.6",
|
"@tsconfig/strictest": "^2.0.6",
|
||||||
"@tsconfig/node20": "^20.1.6",
|
"@tsconfig/node20": "^20.1.6",
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@gitbook/api": "0.203.0",
|
"@gitbook/api": "0.202.0",
|
||||||
"@scalar/api-client-react": "^1.3.46",
|
"@scalar/api-client-react": "^1.3.46",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||||
"@modelcontextprotocol/sdk": "1.17.5",
|
"@modelcontextprotocol/sdk": "1.17.5",
|
||||||
"@opennextjs/aws": "4.1.6",
|
"@opennextjs/aws": "4.1.3",
|
||||||
"@opennextjs/cloudflare": "1.20.7",
|
"@opennextjs/cloudflare": "1.20.5",
|
||||||
"@panzoom/panzoom": "^4.6.1",
|
"@panzoom/panzoom": "^4.6.1",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
|||||||
@@ -119,13 +119,9 @@ export function useAI(): AIContext {
|
|||||||
icon: <AISearchIcon />,
|
icon: <AISearchIcon />,
|
||||||
open: (query?: string) => {
|
open: (query?: string) => {
|
||||||
if (query) {
|
if (query) {
|
||||||
setSearchState((prev) => ({
|
setSearchState((prev) =>
|
||||||
...prev,
|
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||||
query: null,
|
);
|
||||||
ask: query,
|
|
||||||
scope: prev?.scope ?? 'default',
|
|
||||||
open: true,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pageAction: false,
|
pageAction: false,
|
||||||
|
|||||||
@@ -247,15 +247,11 @@ async function renderMermaidDiagram(args: {
|
|||||||
const { source, id, darkMode, mermaidRuntimeURL } = args;
|
const { source, id, darkMode, mermaidRuntimeURL } = args;
|
||||||
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
|
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({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
securityLevel: 'strict',
|
securityLevel: 'strict',
|
||||||
darkMode,
|
darkMode,
|
||||||
theme: darkMode ? 'dark' : undefined,
|
theme: darkMode ? 'dark' : undefined,
|
||||||
themeVariables,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderContainer = createMermaidRenderContainer();
|
const renderContainer = createMermaidRenderContainer();
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html: embed.html,
|
||||||
context.mode !== 'print' && shouldLazyLoad(block.data.url)
|
|
||||||
? lazyLoadIframes(embed.html)
|
|
||||||
: embed.html,
|
|
||||||
}}
|
}}
|
||||||
data-visual-test="blackout"
|
data-visual-test="blackout"
|
||||||
/>
|
/>
|
||||||
@@ -76,23 +73,6 @@ 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.
|
* Create an integration block with an unfurl action from the GitBook Embed response.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as React from 'react';
|
|||||||
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||||
|
|
||||||
import type * as api from '@gitbook/api';
|
import type * as api from '@gitbook/api';
|
||||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
|
||||||
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
|
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
|
||||||
|
|
||||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||||
@@ -76,15 +75,12 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
[pathname: string]:
|
[pathname: string]:
|
||||||
| {
|
| {
|
||||||
url: string;
|
url: string;
|
||||||
previousUrl: string | null;
|
|
||||||
events: TrackEventInput<InsightsEventName>[];
|
events: TrackEventInput<InsightsEventName>[];
|
||||||
context: CurrentContentContext;
|
context: CurrentContentContext;
|
||||||
pageContext?: InsightsEventPageContext;
|
pageContext?: InsightsEventPageContext;
|
||||||
}
|
}
|
||||||
| undefined;
|
| 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.
|
* Synchronously flush all the pending events.
|
||||||
@@ -109,7 +105,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
allEvents.push(
|
allEvents.push(
|
||||||
...transformEvents({
|
...transformEvents({
|
||||||
url: eventsForPathname.url,
|
url: eventsForPathname.url,
|
||||||
previousUrl: eventsForPathname.previousUrl,
|
|
||||||
events: eventsForPathname.events,
|
events: eventsForPathname.events,
|
||||||
context: currentContent,
|
context: currentContent,
|
||||||
pageContext: eventsForPathname.pageContext,
|
pageContext: eventsForPathname.pageContext,
|
||||||
@@ -159,9 +154,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
) => {
|
) => {
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const previous = eventsRef.current[pathname];
|
const previous = eventsRef.current[pathname];
|
||||||
const lastUrl =
|
|
||||||
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
|
|
||||||
lastUrlRef.current = window.location.href;
|
|
||||||
eventsRef.current[pathname] = {
|
eventsRef.current[pathname] = {
|
||||||
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
|
// 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
|
// attribute to their page even when the pathname's ambient context has none — such
|
||||||
@@ -169,7 +161,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
|||||||
// context keep the stored one.
|
// context keep the stored one.
|
||||||
pageContext: ctx ?? previous?.pageContext,
|
pageContext: ctx ?? previous?.pageContext,
|
||||||
url: previous?.url ?? window.location.href,
|
url: previous?.url ?? window.location.href,
|
||||||
previousUrl: previous ? previous.previousUrl : lastUrl,
|
|
||||||
events: [
|
events: [
|
||||||
...(previous?.events ?? []),
|
...(previous?.events ?? []),
|
||||||
{
|
{
|
||||||
@@ -223,18 +214,6 @@ export function useTrackEvent(): TrackEventCallback {
|
|||||||
return React.useContext(InsightsContext);
|
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.
|
* Post the events to the server.
|
||||||
*/
|
*/
|
||||||
@@ -261,7 +240,6 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
|
|||||||
*/
|
*/
|
||||||
function transformEvents(input: {
|
function transformEvents(input: {
|
||||||
url: string;
|
url: string;
|
||||||
previousUrl: string | null;
|
|
||||||
events: TrackEventInput<InsightsEventName>[];
|
events: TrackEventInput<InsightsEventName>[];
|
||||||
context: CurrentContentContext;
|
context: CurrentContentContext;
|
||||||
pageContext: InsightsEventPageContext;
|
pageContext: InsightsEventPageContext;
|
||||||
@@ -280,11 +258,6 @@ function transformEvents(input: {
|
|||||||
|
|
||||||
const location: api.SiteInsightsEventLocation = {
|
const location: api.SiteInsightsEventLocation = {
|
||||||
url: input.url,
|
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,
|
siteSection: input.context.siteSectionId ?? null,
|
||||||
siteSpace: input.context.siteSpaceId ?? null,
|
siteSpace: input.context.siteSpaceId ?? null,
|
||||||
space: input.context.spaceId,
|
space: input.context.spaceId,
|
||||||
|
|||||||
@@ -29,21 +29,18 @@ interface PageCoverImageProps {
|
|||||||
|
|
||||||
export function PageCoverImage(props: PageCoverImageProps) {
|
export function PageCoverImage(props: PageCoverImageProps) {
|
||||||
const { imgs, y, height, mask } = props;
|
const { imgs, y, height, mask } = props;
|
||||||
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
|
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||||
height,
|
|
||||||
aspectRatio: PAGE_COVER_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||||
<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 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
|
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
|
||||||
<img
|
<img
|
||||||
src={imgs.light.src}
|
src={imgs.light.src}
|
||||||
srcSet={imgs.light.srcSet}
|
srcSet={imgs.light.srcSet}
|
||||||
@@ -55,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
|||||||
aspectRatio: height
|
aspectRatio: height
|
||||||
? undefined
|
? undefined
|
||||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
: `${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.
|
height, // if no height is passed, no height will be set.
|
||||||
maskComposite: 'intersect',
|
maskComposite: 'intersect',
|
||||||
maskImage:
|
maskImage:
|
||||||
@@ -76,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
|||||||
aspectRatio: height
|
aspectRatio: height
|
||||||
? undefined
|
? undefined
|
||||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
: `${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.
|
height, // if no height is passed, no height will be set.
|
||||||
maskComposite: 'intersect',
|
maskComposite: 'intersect',
|
||||||
maskImage:
|
maskImage:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useLayoutEffect, useState } from 'react';
|
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useResizeObserver } from 'usehooks-ts';
|
||||||
|
|
||||||
interface ImageSize {
|
interface ImageSize {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -21,18 +22,19 @@ interface Images {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
|
* Hook to calculate the object position Y percentage for a cover image
|
||||||
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
|
* based on the y offset, image dimensions, and container dimensions.
|
||||||
* since the position is computed against its width.
|
|
||||||
*/
|
*/
|
||||||
export function useCoverPosition(
|
export function useCoverPosition(imgs: Images, y: number) {
|
||||||
imgs: Images,
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
y: number,
|
|
||||||
container: { height: number | undefined; aspectRatio: ImageSize }
|
|
||||||
) {
|
|
||||||
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
|
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
|
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`
|
// Load original image dimensions if not provided in `imgs`
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// Check if we have dimensions from dark (if provided) or else the default light.
|
// Check if we have dimensions from dark (if provided) or else the default light.
|
||||||
@@ -66,31 +68,42 @@ export function useCoverPosition(
|
|||||||
// Check dark first, then light, then loaded dimensions
|
// Check dark first, then light, then loaded dimensions
|
||||||
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
|
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 {
|
return {
|
||||||
objectPositionY: imageDimensions
|
containerRef,
|
||||||
? getCoverObjectPositionY(imageDimensions, y, container)
|
objectPositionY,
|
||||||
: '50%',
|
|
||||||
isLoading: !imageDimensions || isLoading,
|
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}))`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type CoverOverlap =
|
|||||||
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
|
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
|
||||||
*/
|
*/
|
||||||
function useMarkTextOverCover() {
|
function useMarkTextOverCover() {
|
||||||
React.useLayoutEffect(() => {
|
React.useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
update();
|
scheduleUpdate();
|
||||||
|
|
||||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||||
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function Trademark(
|
|||||||
const { space } = context;
|
const { space } = context;
|
||||||
const language = await getSpaceLanguage(context);
|
const language = await getSpaceLanguage(context);
|
||||||
|
|
||||||
const url = new URL('https://www.gitbook.com/powered-by');
|
const url = new URL('https://www.gitbook.com');
|
||||||
url.searchParams.set('utm_source', 'content');
|
url.searchParams.set('utm_source', 'content');
|
||||||
url.searchParams.set('utm_medium', 'trademark');
|
url.searchParams.set('utm_medium', 'trademark');
|
||||||
url.searchParams.set('utm_campaign', space.id);
|
url.searchParams.set('utm_campaign', space.id);
|
||||||
|
|||||||
@@ -738,3 +738,131 @@ describe('resolveContentRef for direct space links', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveContentRef for a page ref whose page was re-created', () => {
|
||||||
|
function buildPage(id: string, title: string, path: string): RevisionPageDocument {
|
||||||
|
return {
|
||||||
|
object: 'page',
|
||||||
|
id,
|
||||||
|
type: 'document',
|
||||||
|
kind: 'sheet',
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
slug: path.split('/').pop(),
|
||||||
|
pages: [],
|
||||||
|
tags: [],
|
||||||
|
layout: {},
|
||||||
|
urls: { app: 'https://app.gitbook.com/page' },
|
||||||
|
} as unknown as RevisionPageDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRevision(id: string, pages: RevisionPageDocument[]): Revision {
|
||||||
|
return {
|
||||||
|
object: 'revision',
|
||||||
|
id,
|
||||||
|
type: 'edits',
|
||||||
|
pages,
|
||||||
|
files: [],
|
||||||
|
reusableContents: [],
|
||||||
|
tags: [],
|
||||||
|
parents: [],
|
||||||
|
createdAt: '',
|
||||||
|
urls: { app: '' },
|
||||||
|
} as unknown as Revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpace(id: string): Space {
|
||||||
|
return {
|
||||||
|
object: 'space',
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
organization: 'org',
|
||||||
|
revision: `rev-${id}`,
|
||||||
|
urls: {
|
||||||
|
location: `https://api.gitbook.com/spaces/${id}`,
|
||||||
|
app: `https://app.gitbook.com/o/org/s/${id}/`,
|
||||||
|
published: `https://${id}.gitbook.io/`,
|
||||||
|
},
|
||||||
|
} as unknown as Space;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recreated = buildPage('page-new', 'Detector', 'alerts-by-name/detector');
|
||||||
|
const other = buildPage('page-other', 'Other', 'alerts-by-name/other');
|
||||||
|
const alertsRevision = buildRevision('rev-alerts', [recreated, other]);
|
||||||
|
const alerts = buildSpace('alerts');
|
||||||
|
const notes = buildSpace('notes');
|
||||||
|
|
||||||
|
const dataFetcher = {
|
||||||
|
getSpace: async ({ spaceId }: { spaceId: string }) =>
|
||||||
|
spaceId === 'alerts'
|
||||||
|
? { data: alerts }
|
||||||
|
: { error: { code: 404, message: 'Not found' } },
|
||||||
|
getRevision: async ({ spaceId }: { spaceId: string }) =>
|
||||||
|
spaceId === 'alerts'
|
||||||
|
? { data: alertsRevision }
|
||||||
|
: { error: { code: 404, message: 'Not found' } },
|
||||||
|
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
||||||
|
withToken: function () {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as unknown as GitBookDataFetcher;
|
||||||
|
|
||||||
|
function buildContext(space: Space, revision: Revision): GitBookAnyContext {
|
||||||
|
return {
|
||||||
|
dataFetcher,
|
||||||
|
linker: createLinker({
|
||||||
|
host: 'docs.example.com',
|
||||||
|
spaceBasePath: '/',
|
||||||
|
siteBasePath: '/',
|
||||||
|
}),
|
||||||
|
organizationId: 'org',
|
||||||
|
space,
|
||||||
|
revision,
|
||||||
|
revisionId: revision.id,
|
||||||
|
changeRequest: null,
|
||||||
|
shareKey: undefined,
|
||||||
|
} as unknown as GitBookAnyContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refs only get a typed `path` once @gitbook/api ships it, so the tests add it through a cast.
|
||||||
|
const withPath = (ref: object, path: string) => ({ ...ref, path }) as never;
|
||||||
|
|
||||||
|
it('finds the page at the recorded path when its ID no longer exists', async () => {
|
||||||
|
const result = await resolveContentRef(
|
||||||
|
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/detector'),
|
||||||
|
buildContext(alerts, alertsRevision)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.text).toBe('Detector');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the page at the recorded path in another space', async () => {
|
||||||
|
const result = await resolveContentRef(
|
||||||
|
withPath(
|
||||||
|
{ kind: 'page', space: 'alerts', page: 'page-deleted' },
|
||||||
|
'alerts-by-name/detector'
|
||||||
|
),
|
||||||
|
buildContext(notes, buildRevision('rev-notes', []))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.text).toBe('Detector');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps resolving by ID while the page still exists', async () => {
|
||||||
|
const result = await resolveContentRef(
|
||||||
|
withPath({ kind: 'page', page: 'page-other' }, 'alerts-by-name/detector'),
|
||||||
|
buildContext(alerts, alertsRevision)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.text).toBe('Other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resolve when neither the ID nor the recorded path exists', async () => {
|
||||||
|
const result = await resolveContentRef(
|
||||||
|
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/removed'),
|
||||||
|
buildContext(alerts, alertsRevision)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type React from 'react';
|
|||||||
import type {
|
import type {
|
||||||
ContentRef,
|
ContentRef,
|
||||||
JSONDocument,
|
JSONDocument,
|
||||||
|
Revision,
|
||||||
RevisionFile,
|
RevisionFile,
|
||||||
RevisionPageDocument,
|
RevisionPageDocument,
|
||||||
RevisionReusableContent,
|
RevisionReusableContent,
|
||||||
@@ -16,7 +17,7 @@ import type { Filesystem } from '@gitbook/openapi-parser';
|
|||||||
|
|
||||||
import { getGitBookAppHref } from './app';
|
import { getGitBookAppHref } from './app';
|
||||||
import { getBlockById, getBlockTitle } from './document';
|
import { getBlockById, getBlockTitle } from './document';
|
||||||
import { resolvePageId } from './pages';
|
import { resolvePageId, resolvePagePath } from './pages';
|
||||||
import {
|
import {
|
||||||
findSiteSpaceBy,
|
findSiteSpaceBy,
|
||||||
getFallbackSiteSpacePath,
|
getFallbackSiteSpacePath,
|
||||||
@@ -192,7 +193,8 @@ export async function resolveContentRef(
|
|||||||
? activePage
|
? activePage
|
||||||
? { page: activePage, ancestors: [] }
|
? { page: activePage, ancestors: [] }
|
||||||
: undefined
|
: undefined
|
||||||
: resolvePageId(revision.pages, contentRef.page);
|
: (resolvePageId(revision.pages, contentRef.page) ??
|
||||||
|
resolvePageAtRecordedPath(revision, contentRef));
|
||||||
|
|
||||||
const page = resolvePageResult?.page;
|
const page = resolvePageResult?.page;
|
||||||
const ancestors =
|
const ancestors =
|
||||||
@@ -430,6 +432,17 @@ export function resolveContentRefFallback(contentRef: ContentRef): ResolvedConte
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the page at the path recorded on a page or anchor ref, for when its page ID no longer
|
||||||
|
* exists (the page was deleted and re-created at the same path).
|
||||||
|
*/
|
||||||
|
function resolvePageAtRecordedPath(revision: Revision, contentRef: ContentRef) {
|
||||||
|
// TODO: read `contentRef.path` directly once @gitbook/api ships it on ContentRefPage and
|
||||||
|
// ContentRefAnchor; until then the field is untyped.
|
||||||
|
const { path } = contentRef as { path?: unknown };
|
||||||
|
return typeof path === 'string' && path ? resolvePagePath(revision.pages, path) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to get the best possible target space while resolving a content ref.
|
* This function is used to get the best possible target space while resolving a content ref.
|
||||||
* It will try to return the space in the site context if it exists to avoid cross-site links.
|
* It will try to return the space in the site context if it exists to avoid cross-site links.
|
||||||
|
|||||||
Reference in New Issue
Block a user