Compare commits

...

80 Commits

Author SHA1 Message Date
Samy Pessé 432da64919 Version Packages (#3073)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 18:13:52 +02:00
Nolann B. 7d0b422200 Handle grouped OpenAPISchemas (#3071) 2025-04-01 17:24:43 +02:00
Zeno Kapitein fb90eb0acc Limit tinted background on bold theme to sites with filled sidebar (#3072) 2025-04-01 16:43:08 +02:00
Samy Pessé b647538e41 Version Packages (#3033)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 16:35:09 +02:00
Samy Pessé e50797a5c1 Adapt new computed content and add tag for translation (#3070) 2025-04-01 15:10:55 +02:00
Nolann B. 5b1e01c786 Support for x-stability property (#3064) 2025-03-31 13:49:37 +02:00
Nolann B. 813b2af06b Support for x-enumDescriptions and x-gitbook-enum (#3066) 2025-03-31 12:18:57 +02:00
Nolann B. 6f71da88d8 Fix padding in OpenAPI schemas (#3067) 2025-03-31 12:03:53 +02:00
Samy Pessé fa91eb7eaf Fix dark mode PDF (#3065) 2025-03-28 18:00:33 +01:00
Zeno Kapitein c756761591 Add breadcrumbs to search results (#2772) 2025-03-28 13:29:05 +01:00
Samy Pessé 434af90366 Fix image resizing when using proxy by passing signing identifier in site URL data (#3063)
Co-authored-by: Taran Vohra <taranvohra@outlook.com>
2025-03-28 12:28:12 +01:00
Valentino Hudhra 0f41e194cb Fix dismissed annoucement banner ignored when switching sites (#3057) 2025-03-28 10:56:18 +01:00
Nolann B. bd353489e9 Fix missing alternative schemas (#3058) 2025-03-28 06:53:28 +01:00
Samy Pessé 77fd393f15 Fix click on search items when embedded in an iframe and track announcement links (#3061) 2025-03-27 20:13:14 +01:00
Taran Vohra 8701b5e078 Use the site canonical URL when rewriting in the middleware (#3060) 2025-03-27 19:39:39 +01:00
Nolann B. 61db166f92 Add OpenAPI write-only indicator (#3059) 2025-03-27 18:21:38 +01:00
Zeno Kapitein 0d9b9cea0f Fix announcement banner margin (#3056) 2025-03-27 15:59:47 +00:00
Nolann B. 7bb37c7f0a Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser (#3054) 2025-03-27 15:53:41 +01:00
Taran Vohra 5b2bf828e3 Use stable site URL data for route rewrite in the middleware (#3053) 2025-03-27 18:26:06 +05:30
Samy Pessé c85d3f9752 Serve a markdown version of the page when adding a .md extension (#3055)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-27 12:35:18 +00:00
Zeno Kapitein 4d7088bbf0 Stop variant selector from growing too large (#3043) 2025-03-27 10:42:18 +01:00
Samy Pessé dd6ab6044c Fix crash when using user mention or accessing invalid change requests (#3051) 2025-03-27 01:04:00 +01:00
Samy Pessé d236bf029c Fix flash for site with dark default mode (#3050) 2025-03-26 18:12:45 +01:00
Zeno Kapitein d70d566abe Support site announcement banner (#3008)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
2025-03-26 16:37:05 +01:00
Nolann B. a25fdedea9 Replace $ref with $reference in json-decycle (#3049) 2025-03-26 14:16:03 +00:00
Nolann B. 373183a2cd Safe parse OpenAPI JSON schema (#3046) 2025-03-26 13:55:22 +01:00
Samy Pessé e9fa50dfb5 Trim search query before doing search (#3042) 2025-03-26 10:18:18 +00:00
Samy Pessé 57ca4e0e88 Fix crash when integration is not installed on the space (#3040) 2025-03-26 10:19:57 +01:00
Samy Pessé bc90adb73a Allow indexation of images to ensure favicons display on Google (#3041) 2025-03-26 10:17:57 +01:00
Nolann B. 1505ddbd5a Fix multiple request examples selector not showing (#3039) 2025-03-25 20:48:57 +01:00
Greg Bergé cd99ed57fb Fix OpenAPI spec rendering (#3037) 2025-03-25 15:13:18 +00:00
Samy Pessé 27de1cd01d Default to the first ID as being active in scroll detection (#3036) 2025-03-25 15:39:45 +01:00
Nolann B. ae78fc5394 Fix XML in code sample (#3030) 2025-03-25 15:11:45 +01:00
Samy Pessé 40e8e69c6d Disallow crawling by web-robots of search/ask URLs (#3035) 2025-03-25 13:51:09 +00:00
Nolann B. e84a46a558 Fix OpenAPI tabs indicator overflow (#3032) 2025-03-25 10:03:58 +01:00
Samy Pessé 18a72d4466 v2: improve caching (#3031) 2025-03-25 09:40:54 +01:00
Zeno Kapitein d7aa5fa003 Remove loading state for recommended questions (#3029) 2025-03-24 19:19:12 +01:00
Samy Pessé 27532a5f41 Version Packages (#2988)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-24 17:43:16 +01:00
Nolann B. 48c18c03b9 Add option to pass scalar plugins (#3027) 2025-03-24 14:11:14 +01:00
Samy Pessé bba2e52e24 Fix support for site redirects that include section/variant paths (#3024) 2025-03-24 10:21:26 +01:00
Valentino Hudhra 54ee0149e1 Add support for custom fonts (#2981) 2025-03-24 08:20:58 +01:00
Greg Bergé 8fda9d2fab Update Argos (#3025) 2025-03-22 23:00:25 +01:00
Samy Pessé 77a72d79ce Update next.js to latest v14 and v15 (#3026) 2025-03-22 20:02:15 +01:00
Nolann B. 73e2b472ef Fix write only properties in request example (#3023) 2025-03-21 19:09:25 +01:00
Samy Pessé 2f28cc1323 v2: improve link and navigation between content in a same site (#3020) 2025-03-21 18:18:34 +01:00
Greg Bergé f5516d4e21 Improve OpenAPI TOC display (#3022) 2025-03-21 14:29:12 +00:00
Greg Bergé d2facb2db1 Mark OpenAPI properties as optional if not required (#3021) 2025-03-21 14:28:53 +00:00
Greg Bergé 6eae764b7c Add support for multiple body content types / examples (#2991) 2025-03-21 14:23:42 +01:00
Greg Bergé 970ef8f886 Add missing token (#3019) 2025-03-21 11:04:57 +01:00
Nolann B. ed0720699e Fix OpenAPI path overflow on mobile (#3018) 2025-03-21 11:02:32 +01:00
Greg Bergé ad94d00d5f Improve markdown parsing (#3017) 2025-03-21 10:53:01 +01:00
Greg Bergé c7843f7f52 Remove bun cache (#3016) 2025-03-21 10:42:57 +01:00
Greg Bergé 1fe3286bdb Fix OpenAPI overflow issue (#3015) 2025-03-21 10:42:09 +01:00
Samy Pessé ce030fdf76 v2: Fix redirect to strip jwt_token in url-host mode (#3014) 2025-03-21 00:46:39 +01:00
Samy Pessé 9ba8783952 v2: Fix the redirection when stripping the jwt_token from url (#3013) 2025-03-21 00:25:34 +01:00
Greg Bergé 2f3966cc53 Fix icons flaky (#3012) 2025-03-20 21:08:46 +00:00
Samy Pessé 8f65a22e13 Accept API token in query parameters for normal v2 routes (#3011) 2025-03-20 19:54:55 +01:00
Greg Bergé 2787d884cd Stabilize images & icons (#3009) 2025-03-20 19:40:42 +01:00
Nolann B. fc00b51683 Remove default value in generateSchemaExample (#3007) 2025-03-20 17:24:23 +01:00
Samy Pessé f80c3a7b37 v2: Fix display of integration blocks (#3010) 2025-03-20 15:56:31 +00:00
Samy Pessé 4e5d6c7487 v2: use static route for VA / adaptive content (#2998) 2025-03-20 15:41:17 +01:00
Greg Bergé 96f84b5917 Fix visual tests v2 (#3006) 2025-03-20 11:34:04 +01:00
Greg Bergé 6b8a5fdd1b Trigger tests (#3005) 2025-03-20 10:00:09 +00:00
Greg Bergé 89083ac790 Stabilize visual tests (#3001) 2025-03-20 09:22:09 +01:00
Nolann B. 70be2c6b2b Stringify default value (#3003) 2025-03-19 21:28:40 +01:00
Samy Pessé bcf877841b Update to latest version of the GitBook API (#3002) 2025-03-19 20:11:44 +01:00
Nolann B. a84b06bb19 Update resolveDescription and add minItems/maxItems (#3000) 2025-03-19 17:21:35 +01:00
Zeno Kapitein 24b7808d9a Fix prominent search bar width on md screens (#2996) 2025-03-19 17:14:07 +01:00
Greg Bergé 8364ee988e Fix Argos flakies (#2999) 2025-03-19 16:04:45 +01:00
Samy Pessé 07f6e2d233 Pass page.computedSeed to getComputedDocument (#2995) 2025-03-19 15:02:49 +01:00
Greg Bergé 7212973f34 Update Scalar client (#2993) 2025-03-18 16:02:40 +01:00
Greg Bergé af44e0436b fix typo (#2992) 2025-03-18 14:20:23 +01:00
Greg Bergé 3f9c35d505 Fix emoji display on other platforms than Mac OS (#2990) 2025-03-17 17:49:13 +01:00
Zeno Kapitein 2c13f8b95c Fix horizontal overscroll issue on large screens (#2989) 2025-03-17 15:38:44 +01:00
Zeno Kapitein 4dab1c56e5 Fix alignment of prominent search bar on full-width pages (#2987) 2025-03-17 15:02:23 +01:00
Zeno Kapitein 4723f03ef1 Restyle section group dropdown (#2985) 2025-03-17 13:22:54 +01:00
Samy Pessé 278689e0a0 Version Packages (#2984)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-17 11:06:18 +01:00
Nolann B. 886e204e68 Update OpenAPI operation path design (#2972) 2025-03-17 10:18:24 +01:00
Greg Bergé c59947ab39 Remove Sentry (#2986) 2025-03-17 09:00:58 +01:00
Zeno Kapitein 4f0a772a21 Override tint lightness if supplied color is out of bounds (#2983) 2025-03-14 18:24:25 +01:00
195 changed files with 5337 additions and 2855 deletions
@@ -50,6 +50,7 @@ runs:
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
run: bun run turbo build:v2:cloudflare
shell: bash
@@ -55,6 +55,7 @@ runs:
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
@@ -74,3 +75,4 @@ runs:
shell: bash
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
-9
View File
@@ -3,15 +3,6 @@ description: 'Install Bun and cache dependencies'
runs:
using: 'composite'
steps:
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
# and impacting bandwidth there.
- name: Cache bun
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
-10
View File
@@ -35,21 +35,11 @@ jobs:
- name: Sets env vars for production
if: github.ref == 'refs/heads/main'
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
- name: Sets env vars for preview
if: github.ref != 'refs/heads/main'
run: |
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
- name: Build Next.js with next-on-pages
run: bun run turbo gitbook#build:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
+3
View File
@@ -5,6 +5,9 @@ on:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
+3
View File
@@ -13,5 +13,8 @@
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+248 -572
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -7,12 +7,12 @@
"turbo": "^2.4.4",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.2.4",
"packageManager": "bun@1.2.5",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.99.0"
"@gitbook/api": "0.106.0"
},
"private": true,
"scripts": {
+32 -9
View File
@@ -60,7 +60,7 @@ export function getCacheTag(
| {
tag: 'computed-document';
space: string;
integration: string;
sourceType: string;
}
/**
* All data related to the URL of a content
@@ -84,6 +84,14 @@ export function getCacheTag(
organization: string;
openAPISpec: string;
}
/**
* All data related to a translation
*/
| {
tag: 'translation';
organization: string;
translationSettings: string;
}
): string {
switch (spec.tag) {
case 'user':
@@ -99,13 +107,15 @@ export function getCacheTag(
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.integration}`;
return `space:${spec.space}:computed-document:${spec.sourceType}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
default:
assertNever(spec);
}
@@ -157,6 +167,15 @@ export function getComputedContentSourceCacheTags(
})
);
break;
case 'translation-language':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
@@ -169,18 +188,22 @@ export function getComputedContentSourceCacheTags(
getCacheTag({
tag: 'computed-document',
space: inContext.spaceId,
integration: source.integration,
sourceType: source.type,
})
);
}
// We invalidate the computed content when a new version of the integration is deployed.
tags.push(
getCacheTag({
tag: 'integration',
integration: source.integration,
})
);
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
tags.push(
getCacheTag({
tag: 'integration',
integration,
})
);
}
return tags;
}
+13 -1
View File
@@ -1,7 +1,19 @@
# @gitbook/colors
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0
### Minor Changes
- 4f0a772: Override tint lightness if supplied color is out of bounds
## 0.2.0
### Minor Changes
- 445baaa: Initial release
- 445baaa: Initial release
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.2.0",
"version": "0.3.1",
"devDependencies": {
"typescript": "^5.5.3"
},
+16 -2
View File
@@ -184,6 +184,7 @@ export function colorScale(
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
const backgroundColor = rgbToOklch(hexToRgbArray(background));
let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
if (mixColor && mix?.ratio && mix.ratio > 0) {
// If defined, we mix in a (tiny) bit of the mix color with the base color.
@@ -192,7 +193,20 @@ export function colorScale(
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
}
const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
if (
(darkMode && baseColor.L < backgroundColor.L) ||
(!darkMode && baseColor.L > backgroundColor.L)
) {
// If the supplied color is outside of our lightness bounds, use the supplied color's lightness.
// This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look.
const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L;
backgroundColor.L = baseColor.L;
// At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out.
const amplifier = 1;
mapping = mapping.map((step, index) =>
index < 9 ? step + step * amplifier * difference : step
);
}
const result = [];
@@ -206,7 +220,7 @@ export function colorScale(
continue;
}
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
const chromaRatio = index < 8 ? index * 0.05 : 1;
const shade = {
L: targetL, // Blend lightness
+13
View File
@@ -1,5 +1,18 @@
# gitbook-v2
## 0.2.3
### Patch Changes
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
## 0.2.2
### Patch Changes
- 54ee014: Add initial support for loading custom fonts
- bba2e52: Fix site redirects when it includes a section/variant path
## 0.2.1
### Patch Changes
+11
View File
@@ -5,7 +5,17 @@
*/
const nextConfig = {
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
// This is needed to use 'use cache'
useCache: true,
// Content is fully static, we can cache it in the session memory cache for a long time
staleTimes: {
dynamic: 3600, // 1 hour
static: 3600, // 1 hour
},
},
env: {
@@ -23,6 +33,7 @@ const nextConfig = {
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
+7 -3
View File
@@ -1,20 +1,24 @@
{
"name": "gitbook-v2",
"version": "0.2.1",
"version": "0.2.3",
"private": true,
"dependencies": {
"next": "^15.2.1",
"next": "^15.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0",
"p-memoize": "^7.1.1"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.10",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
@@ -13,20 +13,20 @@ type PageProps = {
export default async function Page(props: PageProps) {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getDynamicSiteContext(await props.params);
const { context } = await getDynamicSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
@@ -6,7 +6,7 @@ import {
} from '@/components/SiteLayout';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -16,17 +16,16 @@ export default async function SiteDynamicLayout({
params,
children,
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const context = await getDynamicSiteContext(await params);
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={visitorAuthToken}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SiteLayout>
@@ -35,11 +34,11 @@ export default async function SiteDynamicLayout({
}
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -7,6 +7,6 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveIcon(context, request);
}
@@ -8,6 +8,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveOGImage(context, await params);
}
@@ -6,7 +6,7 @@ export default async function RootLayout(props: {
children: React.ReactNode;
}) {
const { params, children } = props;
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
}
@@ -6,7 +6,7 @@ export async function generateMetadata({
}: {
params: Promise<RouteLayoutParams>;
}) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return generatePDFMetadata(context);
}
@@ -15,6 +15,6 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const { params, searchParams } = props;
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return <PDFPage context={context} searchParams={await searchParams} />;
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
return servePagesSitemap(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
return serveRootSitemap(context);
}
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
'use cache';
const params = await props.params;
const context = await getStaticSiteContext(params);
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(
@@ -32,13 +32,13 @@ export default async function Page(props: PageProps) {
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getStaticSiteContext(await props.params);
const { context } = await getStaticSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const context = await getStaticSiteContext(params);
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
'use cache';
const context = await getStaticSiteContext(await params);
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
cacheTag(
getCacheTag({
@@ -33,7 +33,7 @@ export default async function SiteStaticLayout({
<SiteLayout
context={context}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={null}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SiteLayout>
@@ -42,11 +42,11 @@ export default async function SiteStaticLayout({
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return servePagesSitemap(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveRootSitemap(context);
}
@@ -9,6 +9,6 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveIcon(context, request);
}
@@ -0,0 +1,11 @@
import { servePageMarkdown } from '@/routes/markdownPage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageMarkdown(context, pathname);
}
@@ -10,6 +10,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveOGImage(context, await params);
}
+43 -15
View File
@@ -1,9 +1,9 @@
import {
fetchSiteContextByURL,
fetchSiteContextByURLLookup,
getBaseContext,
} from '@v2/lib/context';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import type { SiteAPIToken } from '@gitbook/api';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation';
import rison from 'rison';
export type RouteParamMode = 'url-host' | 'url';
@@ -12,6 +12,9 @@ export type RouteLayoutParams = {
/** URL encoded site URL */
siteURL: string;
/** URL and Rison encoded site data from getPublishedContentByUrl */
siteData: string;
};
export type RouteParams = RouteLayoutParams & {
@@ -21,19 +24,30 @@ export type RouteParams = RouteLayoutParams & {
/**
* Get the static context when rendering statically a site.
*/
export function getStaticSiteContext(params: RouteLayoutParams) {
export async function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
return fetchSiteContextByURL(
const siteURLData = getSiteURLDataFromParams(params);
// For static routes, we check the expiration of the JWT token
// as the route might be revalidated after expiration
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
forbidden();
}
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
{
url: siteURL.toString(),
visitorAuthToken: null,
redirectOnError: false,
}
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
@@ -42,15 +56,21 @@ export function getStaticSiteContext(params: RouteLayoutParams) {
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = await getSiteURLDataFromMiddleware();
const siteURLData = getSiteURLDataFromParams(params);
return fetchSiteContextByURLLookup(
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
};
}
/**
@@ -74,3 +94,11 @@ function getModeFromParams(mode: string): RouteParamMode {
return 'url';
}
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
}
+2
View File
@@ -7,6 +7,7 @@ import {
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_TRACKING,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_HOST,
@@ -25,6 +26,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_USER_AGENT,
GITBOOK_INTEGRATIONS_HOST,
@@ -20,8 +20,10 @@ export async function getSpacePDFContext(
const apiToken = await getAPITokenFromMiddleware();
const basePath = getPDFRoutePath(params);
const linker = createLinker({
pathname: getPDFRoutePath(params),
spaceBasePath: basePath,
siteBasePath: basePath,
});
const dataFetcher = createDataFetcher({
apiToken: apiToken,
+90 -99
View File
@@ -1,7 +1,7 @@
import { getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
PublishedSiteContentLookup,
PublishedSiteContent,
RevisionPage,
RevisionPageDocument,
Site,
@@ -13,12 +13,41 @@ import type {
SiteStructure,
Space,
} from '@gitbook/api';
import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
import { redirect } from 'next/navigation';
import {
type GitBookDataFetcher,
createDataFetcher,
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookSpaceLinker, createLinker } from './links';
import { type GitBookLinker, createLinker } from './links';
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
*/
export type SiteURLData = Pick<
PublishedSiteContent,
| 'organization'
| 'apiToken'
| 'site'
| 'siteSpace'
| 'space'
| 'revision'
| 'changeRequest'
| 'shareKey'
| 'siteSection'
| 'siteBasePath'
| 'basePath'
> & {
/**
* Identifier used for image resizing.
*/
imagesContextId: string;
};
/**
* Generic context when rendering content.
@@ -32,7 +61,7 @@ export type GitBookBaseContext = {
/**
* Linker to generate links in the current space.
*/
linker: GitBookSpaceLinker;
linker: GitBookLinker;
/**
* Image resizer to resize images.
@@ -102,27 +131,45 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
};
/**
* Get the base context for a request.
* Get the base context for a request on a site.
*/
export function getBaseContext(input: {
siteURL: URL | string;
siteURLData: SiteURLData;
urlMode: 'url' | 'url-host';
apiToken?: string | null;
}) {
const url = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const urlMode = input.urlMode;
const { urlMode, siteURLData } = input;
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const dataFetcher = createDataFetcher({
apiToken: input.apiToken ?? null,
apiToken: siteURLData.apiToken ?? null,
});
const linker = getLinkerForSiteURL({
siteURL: url,
urlMode,
});
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteURL.host,
siteBasePath: siteURLData.siteBasePath,
spaceBasePath: siteURLData.basePath,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
siteBasePath: `/url/${siteURL.host}${siteURLData.siteBasePath}`,
spaceBasePath: `/url/${siteURL.host}${siteURLData.basePath}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
};
}
const imageResizer = createImageResizer({
host: url.host,
imagesContextId: siteURLData.imagesContextId,
// To ensure image resizing work for proxied sites,
// we serve images from the root of the site.
linker: linker,
@@ -135,92 +182,23 @@ export function getBaseContext(input: {
};
}
/**
* Get the linker for a given site URL.
*/
export function getLinkerForSiteURL(input: {
siteURL: URL;
urlMode: 'url' | 'url-host';
}) {
const { siteURL, urlMode } = input;
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteURL.host,
pathname: siteURL.pathname,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
pathname: `/url/${siteURL.host}${siteURL.pathname}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
};
}
return linker;
}
/**
* Fetch the context of a site for a given URL and a base context.
*/
export async function fetchSiteContextByURL(
baseContext: GitBookBaseContext,
input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const data = await throwIfDataError(
dataFetcher.getPublishedContentByUrl({
url: input.url,
visitorAuthToken: input.visitorAuthToken,
redirectOnError: input.redirectOnError,
})
);
return fetchSiteContextByURLLookup(baseContext, data);
}
/**
* Fetch the context of a site using the resolution of a URL
*/
export async function fetchSiteContextByURLLookup(
baseContext: GitBookBaseContext,
data: PublishedSiteContentLookup
data: SiteURLData
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
if ('redirect' in data) {
redirect(data.redirect);
}
return await fetchSiteContextByIds(
{
...baseContext,
dataFetcher: dataFetcher.withToken({
apiToken: data.apiToken,
}),
},
{
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
space: data.space,
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
}
);
return await fetchSiteContextByIds(baseContext, {
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
space: data.space,
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
});
}
/**
@@ -329,7 +307,7 @@ export async function fetchSpaceContextByIds(
})
),
ids.changeRequest
? throwIfDataError(
? getDataOrNull(
dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
@@ -338,17 +316,30 @@ export async function fetchSpaceContextByIds(
: null,
]);
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
if (ids.changeRequest && !changeRequest) {
// When trying to render a change request with an invalid / non-existing ID,
// we should return a 404.
notFound();
}
const pages = await throwIfDataError(
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
const pages = await getDataOrNull(
dataFetcher.getRevisionPages({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
})
}),
// When trying to render a revision with an invalid / non-existing ID,
// we should handle gracefully the 404 and throw notFound.
ids.revision ? [404] : undefined
);
if (!pages) {
notFound();
}
return {
...baseContext,
+315 -239
View File
@@ -3,15 +3,13 @@ import {
type ComputedContentSource,
GitBookAPI,
type GitBookAPIServiceBinding,
type RenderIntegrationUI,
} from '@gitbook/api';
import {
getCacheTag,
getCacheTagForURL,
getComputedContentSourceCacheTags,
} from '@gitbook/cache-tags';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { wrapDataFetcherError } from './errors';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import { memoize } from './memoize';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
@@ -97,6 +95,15 @@ export function createDataFetcher(
})
);
},
getRevisionPageMarkdown(params) {
return trace('getRevisionPageMarkdown', () =>
getRevisionPageMarkdown(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
})
);
},
getReusableContent(params) {
return trace('getReusableContent', () =>
getReusableContent(input, {
@@ -144,6 +151,7 @@ export function createDataFetcher(
organizationId: params.organizationId,
spaceId: params.spaceId,
source: params.source,
seed: params.seed,
})
);
},
@@ -159,41 +167,39 @@ export function createDataFetcher(
return trace('searchSiteContent', () => searchSiteContent(input, params));
},
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) {
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
},
getPublishedContentByUrl(params) {
return trace('getPublishedContentByUrl', () =>
getPublishedContentByUrl(
{ apiToken: null },
{
url: params.url,
visitorAuthToken: params.visitorAuthToken,
redirectOnError: params.redirectOnError,
}
)
renderIntegrationUi(params) {
return trace('renderIntegrationUi', () =>
renderIntegrationUi(input, {
integrationName: params.integrationName,
request: params.request,
})
);
},
getUserById(userId) {
return trace('getUserById', () => getUserById(input, { userId }));
},
};
}
async function getUserById(input: DataFetcherInput, params: { userId: string }) {
const getUserById = memoize(async function getUserById(
input: DataFetcherInput,
params: { userId: string }
) {
'use cache';
cacheLife('days');
return trace('getUserById.uncached', () => {
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.users.getUserById(params.userId);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.users.getUserById(params.userId);
return res.data;
});
});
}
});
async function getSpace(
const getSpace = memoize(async function getSpace(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -202,24 +208,26 @@ async function getSpace(
) {
'use cache';
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
return trace('getSpace.uncached', () => {
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
});
return res.data;
});
return res.data;
});
}
});
async function getChangeRequest(
const getChangeRequest = memoize(async function getChangeRequest(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -228,23 +236,28 @@ async function getChangeRequest(
) {
'use cache';
cacheLife('minutes');
return trace('getChangeRequest.uncached', () => {
cacheLife('minutes');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getChangeRequestById(params.spaceId, params.changeRequestId);
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId
);
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
});
});
}
});
async function getRevision(
const getRevision = memoize(async function getRevision(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -254,18 +267,20 @@ async function getRevision(
) {
'use cache';
cacheLife('max');
return trace('getRevision.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
});
return res.data;
});
return res.data;
});
}
});
async function getRevisionPages(
const getRevisionPages = memoize(async function getRevisionPages(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -275,18 +290,24 @@ async function getRevisionPages(
) {
'use cache';
cacheLife('max');
return trace('getRevisionPages.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.listPagesInRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
}
);
return res.data.pages;
});
return res.data.pages;
});
}
});
async function getRevisionFile(
const getRevisionFile = memoize(async function getRevisionFile(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -296,21 +317,56 @@ async function getRevisionFile(
) {
'use cache';
cacheLife('max');
return trace('getRevisionFile.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{}
);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{}
);
return res.data;
});
});
}
});
async function getRevisionPageByPath(
const getRevisionPageMarkdown = memoize(async function getRevisionPageMarkdown(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
pageId: string;
}
) {
'use cache';
return trace('getRevisionPageMarkdown.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
format: 'markdown',
}
);
if (!('markdown' in res.data)) {
throw new DataFetcherError('Page is not a document', 404);
}
return res.data.markdown;
});
});
});
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -320,23 +376,25 @@ async function getRevisionPageByPath(
) {
'use cache';
cacheLife('max');
return trace('getRevisionPageByPath.uncached', () => {
cacheLife('max');
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{}
);
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{}
);
return res.data;
return res.data;
});
});
}
});
async function getDocument(
const getDocument = memoize(async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -345,47 +403,53 @@ async function getDocument(
) {
'use cache';
cacheLife('max');
return trace('getDocument.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
return res.data;
});
});
}
});
async function getComputedDocument(
const getComputedDocument = memoize(async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
organizationId: string;
source: ComputedContentSource;
seed: string;
}
) {
'use cache';
cacheLife('days');
return trace('getComputedDocument.uncached', () => {
cacheLife('days');
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getComputedDocument(params.spaceId, {
source: params.source,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getComputedDocument(params.spaceId, {
source: params.source,
seed: params.seed,
});
return res.data;
});
return res.data;
});
}
});
async function getReusableContent(
const getReusableContent = memoize(async function getReusableContent(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -395,84 +459,54 @@ async function getReusableContent(
) {
'use cache';
cacheLife('max');
return trace('getReusableContent.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return res.data;
});
}
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
}
) {
'use cache';
cacheTag(
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug
);
return res.data;
});
}
async function getPublishedContentByUrl(
input: DataFetcherInput,
params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}
) {
'use cache';
const { url, visitorAuthToken, redirectOnError } = params;
cacheTag(getCacheTagForURL(url));
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.urls.getPublishedContentByUrl({
url,
visitorAuthToken: visitorAuthToken ?? undefined,
redirectOnError,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return res.data;
});
});
});
if ('site' in res.data) {
const getLatestOpenAPISpecVersionContent = memoize(
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
}
) {
'use cache';
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
cacheTag(
getCacheTag({
tag: 'site',
site: res.data.site,
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
}
cacheLife('days');
return res.data;
});
}
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug
);
return res.data;
});
});
}
);
async function getPublishedContentSite(
const getPublishedContentSite = memoize(async function getPublishedContentSite(
input: DataFetcherInput,
params: {
organizationId: string;
@@ -482,24 +516,32 @@ async function getPublishedContentSite(
) {
'use cache';
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
return trace('getPublishedContentSite.uncached', () => {
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite(params.organizationId, params.siteId, {
shareKey: params.siteShareKey,
return trace('getPublishedContentSite', () => {
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
}
);
return res.data;
});
});
return res.data;
});
}
});
async function getSiteRedirectBySource(
const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
input: DataFetcherInput,
params: {
organizationId: string;
@@ -510,26 +552,32 @@ async function getSiteRedirectBySource(
) {
'use cache';
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
return trace('getSiteRedirectBySource.uncached', () => {
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getSiteRedirectBySource(params.organizationId, params.siteId, {
shareKey: params.siteShareKey,
source: params.source,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
}
);
return res.data;
});
return res.data;
});
}
});
async function getEmbedByUrl(
const getEmbedByUrl = memoize(async function getEmbedByUrl(
input: DataFetcherInput,
params: {
url: string;
@@ -538,34 +586,62 @@ async function getEmbedByUrl(
) {
'use cache';
cacheLife('weeks');
return trace('getEmbedByUrl.uncached', () => {
cacheLife('weeks');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data;
});
});
}
});
async function searchSiteContent(
const searchSiteContent = memoize(async function searchSiteContent(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) {
'use cache';
const { organizationId, siteId, query, scope } = params;
return trace('searchSiteContent.uncached', () => {
const { organizationId, siteId, query, scope } = params;
cacheLife('days');
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
});
return res.data.items;
});
return res.data.items;
});
}
});
const renderIntegrationUi = memoize(async function renderIntegrationUi(
input: DataFetcherInput,
params: {
integrationName: string;
request: RenderIntegrationUI;
}
) {
'use cache';
return trace('renderIntegrationUi.uncached', () => {
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost(
params.integrationName,
params.request
);
return res.data;
});
});
});
let loggedServiceBinding = false;
+16 -4
View File
@@ -1,5 +1,5 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath } from '@/lib/paths';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api';
import { apiClient } from './api';
@@ -15,13 +15,14 @@ export async function getPublishedContentByURL(input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
apiToken: string | null;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = await apiClient();
const api = await apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
@@ -35,6 +36,13 @@ export async function getPublishedContentByURL(input: {
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
// As this endpoint is cached by our API, we version the request
// to void getting stale data with missing properties.
// this could be improved by ensuring our API cache layer is versioned
// or invalidated when needed
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{
signal,
@@ -94,12 +102,16 @@ export async function getPublishedContentByURL(input: {
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
*/
if (alternative.primary || ('site' in data && data.complete)) {
const changeRequest = data.changeRequest ?? lookup.changeRequest;
const revision = data.revision ?? lookup.revision;
const siteResult: PublishedSiteContentLookup = {
...data,
changeRequest: data.changeRequest ?? lookup.changeRequest,
revision: data.revision ?? lookup.revision,
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}),
...(revision ? { revision } : {}),
};
return { data: siteResult };
}
@@ -0,0 +1,39 @@
import { describe, expect, it, mock } from 'bun:test';
import { memoize } from './memoize';
describe('memoize', () => {
it('should memoize the function', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
expect(await memoized()).toBe(await memoized());
});
it('should memoize the function with different arguments', async () => {
const fn = mock(async (a: number, b: number) => a + b);
const memoized = memoize(fn);
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
expect(fn.mock.calls.length).toBe(2);
});
it('should memoize a function complex object', async () => {
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
const memoized = memoize(fn);
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
await memoized({ foo: 'foo', bar: 2 })
);
expect(fn.mock.calls.length).toBe(2);
});
it('should wrap concurrent async calls', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
const promise1 = memoized();
const promise2 = memoized();
expect(await promise1).toBe(await promise2);
expect(fn.mock.calls.length).toBe(1);
});
});
@@ -0,0 +1,43 @@
import pMemoize from 'p-memoize';
/**
* We wrap 'use cache' calls in a p-memoize function to avoid
* executing the function multiple times when doing concurrent calls.
*
* Hopefully one day this can be done directly by 'use cache'.
*/
export function memoize<F extends (...args: any[]) => any>(f: F): F {
return pMemoize(f, {
cacheKey: (args) => {
return JSON.stringify(deepSortValue(args));
},
});
}
function deepSortValue(value: unknown): unknown {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (Array.isArray(value)) {
return value.map(deepSortValue);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, subValue]) => {
return [key, deepSortValue(subValue)] as const;
})
.sort((a, b) => {
return a[0].localeCompare(b[0]);
});
}
return value;
}
+2 -1
View File
@@ -15,12 +15,13 @@ export async function getPageDocument(
dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId })
);
}
if (page.computed) {
if ('computed' in page && page.computed) {
return getDataOrNull(
dataFetcher.getComputedDocument({
organizationId: space.organization,
spaceId: space.id,
source: page.computed,
seed: page.computedSeed,
})
);
}
+18 -9
View File
@@ -37,15 +37,6 @@ export interface GitBookDataFetcher {
*/
getUserById(userId: string): Promise<DataFetcherResponse<api.User>>;
/**
* Get a published content by its URL.
*/
getPublishedContentByUrl(params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<DataFetcherResponse<api.PublishedSiteContentLookup>>;
/**
* Get a published content site by its organization ID and site ID.
*/
@@ -106,6 +97,15 @@ export interface GitBookDataFetcher {
path: string;
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
/**
* Get the markdown content of a page by its path.
*/
getRevisionPageMarkdown(params: {
spaceId: string;
revisionId: string;
pageId: string;
}): Promise<DataFetcherResponse<string>>;
/**
* Get a document by its space ID and document ID.
*/
@@ -120,6 +120,7 @@ export interface GitBookDataFetcher {
organizationId: string;
spaceId: string;
source: api.ComputedContentSource;
seed: string;
}): Promise<DataFetcherResponse<api.JSONDocument>>;
/**
@@ -170,4 +171,12 @@ export interface GitBookDataFetcher {
/** Cache bust to ensure the search results are fresh when the space is updated. */
cacheBust?: string;
}): Promise<DataFetcherResponse<api.SearchSpaceResult[]>>;
/**
* Render an integration UI.
*/
renderIntegrationUi(params: {
integrationName: string;
request: api.RenderIntegrationUI;
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
}
+5
View File
@@ -64,6 +64,11 @@ export const GITBOOK_DISABLE_TRACKING = Boolean(
export const GITBOOK_INTEGRATIONS_HOST =
process.env.GITBOOK_INTEGRATIONS_HOST || 'integrations.gitbook.com';
/**
* Hostname for fonts.
*/
export const GITBOOK_FONTS_URL = process.env.GITBOOK_FONTS_URL || 'https://fonts.gitbook.com';
/**
* Endpoint to use for resizing images.
* It should be a Cloudflare domain with image resizing enabled.
@@ -1,7 +1,7 @@
import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookSpaceLinker } from '../links';
import type { GitBookLinker } from '../links';
import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types';
@@ -33,13 +33,13 @@ export interface CloudflareImageOptions {
* Create an image resizer for a rendering context.
*/
export function createImageResizer({
host,
imagesContextId,
linker,
}: {
/** The linker to use to create URLs. */
linker: GitBookSpaceLinker;
/** The host name of the current site. */
host: string;
linker: GitBookLinker;
/** The site identifier to use for verifying the image signature. */
imagesContextId: string;
}): ImageResizer {
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
return createNoopImageResizer();
@@ -58,11 +58,11 @@ export function createImageResizer({
return async (options) => {
cachedSignature ??= await generateImageSignature({
host,
imagesContextId,
url: urlInput,
});
const url = linker.toAbsoluteURL(linker.toPathInContent('/~gitbook/image'));
const url = linker.toAbsoluteURL(linker.toPathInSite('/~gitbook/image'));
const searchParams = new URLSearchParams();
searchParams.set('url', getImageAPIUrl(urlInput));
@@ -0,0 +1,14 @@
/**
* Get the site identifier to use for image resizing for an incoming request.
* This identifier can be obtained before resolving the request URL.
*/
export function getImageResizingContextId(url: URL): string {
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = url.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
}
return url.host;
}
@@ -2,3 +2,4 @@ export * from './types';
export * from './createImageResizer';
export * from './signatures';
export * from './utils';
export * from './getImageResizingContextId';
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = {
url: string;
host: string;
imagesContextId: string;
};
type SignFn = (input: SignFnInput) => MaybePromise<string>;
@@ -60,7 +60,7 @@ const generateSignatureV2: SignFn = async (input) => {
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
const all = [
input.url,
input.host, // The hostname is used to avoid serving images from other sites on the same domain
input.imagesContextId, // The hostname is used to avoid serving images from other sites on the same domain
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
]
.filter(Boolean)
+43 -27
View File
@@ -1,25 +1,51 @@
import { describe, expect, it } from 'bun:test';
import { appendBasePathToLinker, createLinker } from './links';
import { createLinker } from './links';
const root = createLinker({
host: 'docs.company.com',
pathname: '/',
spaceBasePath: '/',
siteBasePath: '/',
});
const variantInSection = createLinker({
host: 'docs.company.com',
pathname: '/section/variant',
spaceBasePath: '/section/variant',
siteBasePath: '/',
});
const siteGitBookIO = createLinker({
host: 'org.gitbook.io',
spaceBasePath: '/sitename/variant/',
siteBasePath: '/sitename/',
});
describe('toPathInContent', () => {
it('should return the correct path', () => {
expect(root.toPathInContent('some/path')).toBe('/some/path');
expect(variantInSection.toPathInContent('some/path')).toBe('/section/variant/some/path');
expect(root.toPathInSpace('some/path')).toBe('/some/path');
expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path');
});
it('should handle leading slash', () => {
expect(root.toPathInContent('/some/path')).toBe('/some/path');
expect(variantInSection.toPathInContent('/some/path')).toBe('/section/variant/some/path');
expect(root.toPathInSpace('/some/path')).toBe('/some/path');
expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path');
});
});
describe('toPathInSite', () => {
it('should return the correct path', () => {
expect(root.toPathInSite('some/path')).toBe('/some/path');
expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path');
});
});
describe('toRelativePathInSite', () => {
it('should return the correct path', () => {
expect(root.toRelativePathInSite('/some/path')).toBe('some/path');
expect(siteGitBookIO.toRelativePathInSite('/sitename/some/path')).toBe('some/path');
});
it('should preserve absolute paths outside of the site', () => {
expect(siteGitBookIO.toRelativePathInSite('/outside/some/path')).toBe('/outside/some/path');
});
});
@@ -32,27 +58,17 @@ describe('toAbsoluteURL', () => {
});
});
describe('appendBasePathToLinker', () => {
const prefixedRoot = appendBasePathToLinker(root, '/section/variant');
const prefixedVariantInSection = appendBasePathToLinker(variantInSection, '/base');
describe('toPathInContent', () => {
it('should return the correct path', () => {
expect(prefixedRoot.toPathInContent('some/path')).toBe('/section/variant/some/path');
expect(prefixedVariantInSection.toPathInContent('some/path')).toBe(
'/section/variant/base/some/path'
);
});
describe('toLinkForContent', () => {
it('should return the correct path', () => {
expect(root.toLinkForContent('https://docs.company.com/some/path')).toBe('/some/path');
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/sitename/some/path')).toBe(
'/sitename/some/path'
);
});
describe('toAbsoluteURL', () => {
it('should return the correct path', () => {
expect(prefixedRoot.toAbsoluteURL('some/path')).toBe(
'https://docs.company.com/some/path'
);
expect(prefixedVariantInSection.toAbsoluteURL('some/path')).toBe(
'https://docs.company.com/some/path'
);
});
it('should preserve an absolute URL if the site is not the same', () => {
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/anothersite/some/path')).toBe(
'https://org.gitbook.io/anothersite/some/path'
);
});
});
+55 -44
View File
@@ -1,4 +1,5 @@
import { getPagePath } from '@/lib/pages';
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import warnOnce from 'warn-once';
@@ -7,17 +8,28 @@ import warnOnce from 'warn-once';
*
* URL levels:
*
* https://docs.company.com/section/variant/page
* https://docs.company.com/basename/section/variant/page
*
* toPathInContent('some/path') => /section/variant/some/path
* toPathForPage({ pages, page }) => /section/variant/some/path
* toPathInSpace('some/path') => /basename/section/variant/some/path
* toPathInSite('some/path') => /basename/some/path
* toPathForPage({ pages, page }) => /basename/section/variant/some/path
* toAbsoluteURL('some/path') => https://docs.company.com/some/path
*/
export interface GitBookSpaceLinker {
export interface GitBookLinker {
/**
* Generate an absolute path for a relative path to the current content.
*/
toPathInContent(relativePath: string): string;
toPathInSpace(relativePath: string): string;
/**
* Generate an absolute path for a relative path to the current site.
*/
toPathInSite(relativePath: string): string;
/**
* Transform an absolute path in a site, to a relative path from the root of the site.
*/
toRelativePathInSite(absolutePath: string): string;
/**
* Generate an absolute path for a page in the current content.
@@ -48,14 +60,36 @@ export function createLinker(
servedOn: {
protocol?: string;
host?: string;
pathname: string;
/** The base path of the space */
spaceBasePath: string;
/** The base path of the site */
siteBasePath: string;
}
): GitBookSpaceLinker {
): GitBookLinker {
warnOnce(!servedOn.host, 'No host provided to createLinker. It can lead to issues with links.');
const linker: GitBookSpaceLinker = {
toPathInContent(relativePath: string): string {
return joinPaths(servedOn.pathname, relativePath);
const siteBasePath = withTrailingSlash(withLeadingSlash(servedOn.siteBasePath));
const spaceBasePath = withTrailingSlash(withLeadingSlash(servedOn.spaceBasePath));
const linker: GitBookLinker = {
toPathInSpace(relativePath: string): string {
return joinPaths(spaceBasePath, relativePath);
},
toPathInSite(relativePath: string): string {
return joinPaths(siteBasePath, relativePath);
},
toRelativePathInSite(absolutePath: string): string {
const normalizedPath = withLeadingSlash(absolutePath);
if (!normalizedPath.startsWith(servedOn.siteBasePath)) {
return normalizedPath;
}
return normalizedPath.slice(servedOn.siteBasePath.length);
},
toAbsoluteURL(absolutePath: string): string {
@@ -67,48 +101,25 @@ export function createLinker(
},
toPathForPage({ pages, page, anchor }) {
return linker.toPathInContent(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
return linker.toPathInSpace(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
},
toLinkForContent(url: string): string {
return url;
toLinkForContent(rawURL: string): string {
const url = new URL(rawURL);
// If the link points to a content in the same site, we return an absolute path
// instead of a full URL; it makes it possible to use router navigation
if (url.hostname === servedOn.host && url.pathname.startsWith(servedOn.siteBasePath)) {
return url.pathname;
}
return rawURL;
},
};
return linker;
}
/**
* Append a prefix to a linker.
*/
export function appendBasePathToLinker(
linker: GitBookSpaceLinker,
basePath: string
): GitBookSpaceLinker {
const linkerWithPrefix: GitBookSpaceLinker = {
toPathInContent(relativePath: string): string {
return linker.toPathInContent(joinPaths(basePath, relativePath));
},
toAbsoluteURL(absolutePath: string): string {
return linker.toAbsoluteURL(absolutePath);
},
toPathForPage({ pages, page, anchor }) {
return (
linkerWithPrefix.toPathInContent(getPagePath(pages, page)) +
(anchor ? `#${anchor}` : '')
);
},
toLinkForContent(url: string): string {
return linker.toLinkForContent(url);
},
};
return linkerWithPrefix;
}
function joinPaths(prefix: string, path: string): string {
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
+3 -21
View File
@@ -1,5 +1,6 @@
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
import { CustomizationThemeMode } from '@gitbook/api';
import { headers } from 'next/headers';
import type { SiteURLData } from './context';
export enum MiddlewareHeaders {
/**
@@ -32,11 +33,6 @@ export enum MiddlewareHeaders {
*/
Customization = 'x-gitbook-customization',
/**
* The visitor token used to access this content
*/
VisitorAuthToken = 'x-gitbook-visitor-token',
/**
* The API token used to fetch the content.
* This should only be passed for non-site dynamic routes.
@@ -62,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
* Get the site URL data from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
export async function getSiteURLDataFromMiddleware(): Promise<SiteURLData> {
const headersList = await headers();
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
@@ -105,20 +101,6 @@ export async function getThemeFromMiddleware() {
: CustomizationThemeMode.Dark;
}
/**
* Get the visitor auth token from the middleware headers.
* This function should only be called in a dynamic route.
*/
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
const headersList = await headers();
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
if (!visitorAuthToken) {
return null;
}
return visitorAuthToken;
}
/**
* Get the API token from the middleware headers.
* This function should only be called in a dynamic route.
@@ -16,8 +16,8 @@ export async function getServerActionBaseContext() {
return getBaseContext({
siteURL,
siteURLData,
urlMode,
apiToken: siteURLData.apiToken,
});
}
+250 -160
View File
@@ -1,6 +1,7 @@
import { CustomizationThemeMode } from '@gitbook/api';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import rison from 'rison';
import { getContentSecurityPolicy } from '@/lib/csp';
import { validateSerializedCustomization } from '@/lib/customization';
@@ -13,7 +14,6 @@ import {
normalizeVisitorAuthURL,
} from '@/lib/visitor-token';
import { serveResizedImage } from '@/routes/image';
import { getLinkerForSiteURL } from '@v2/lib/context';
import {
DataFetcherError,
getPublishedContentByURL,
@@ -21,7 +21,9 @@ import {
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { getImageResizingContextId } from '@v2/lib/images';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import type { SiteURLData } from './lib/context';
export const config = {
matcher: [
@@ -41,7 +43,7 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(normalized.toString());
}
for (const handler of [serveSiteRoutes, servePreviewRoutes]) {
for (const handler of [serveSiteRoutes, serveSpacePDFRoutes]) {
const result = await handler(requestURL, request);
if (result) {
return result;
@@ -63,7 +65,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return null;
}
const { url: siteURL, mode } = match;
const { url: siteRequestURL, mode } = match;
const imagesContextId = getImageResizingContextId(siteRequestURL);
/**
* Serve image resizing requests (all requests containing `/~gitbook/image`).
@@ -73,9 +76,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
*/
if (siteURL.pathname.endsWith('/~gitbook/image')) {
if (siteRequestURL.pathname.endsWith('/~gitbook/image')) {
return await serveResizedImage(request, {
host: siteURL.host,
imagesContextId: imagesContextId,
});
}
@@ -83,181 +86,215 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// Detect and extract the visitor authentication token from the request
//
// @ts-ignore - request typing
const visitorToken = getVisitorToken(request, siteURL);
const visitorToken = getVisitorToken({
cookies: request.cookies.getAll(),
url: siteRequestURL,
});
const data = await throwIfDataError(
getPublishedContentByURL({
url: siteURL.toString(),
visitorAuthToken: visitorToken?.token ?? null,
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
})
);
let cookies: ResponseCookies = {};
const withAPIToken = async (apiToken: string | null) => {
const siteURLData = await throwIfDataError(
getPublishedContentByURL({
url: siteRequestURL.toString(),
visitorAuthToken: visitorToken?.token ?? null,
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
//
// Handle redirects
//
if ('redirect' in data) {
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
console.log('redirect', data.redirect);
if (data.target === 'content') {
// For content redirects, we use the linker to redirect the optimal URL
// during development and testing in 'url' mode.
const linker = getLinkerForSiteURL({
siteURL,
urlMode: mode,
});
// Use the API token passed in the request, if any
// as it could be used for .preview hostnames
apiToken,
})
);
const cookies: ResponseCookies = [];
const contentRedirect = new URL(linker.toLinkForContent(data.redirect), request.url);
//
// Handle redirects
//
if ('redirect' in siteURLData) {
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
console.log('redirect', siteURLData.redirect);
if (siteURLData.target === 'content') {
let contentRedirect = new URL(siteURLData.redirect, request.url);
// Keep the same search params as the original request
// as it might contain a VA token
contentRedirect.search = request.nextUrl.search;
// For content redirects, we redirect using the /url/:url format
// during development and testing in 'url' mode.
if (mode === 'url') {
const urlObject = new URL(siteURLData.redirect);
contentRedirect = new URL(
`/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`,
request.url
);
}
return NextResponse.redirect(contentRedirect);
// Keep the same search params as the original request
// as it might contain a VA token
contentRedirect.search = request.nextUrl.search;
return NextResponse.redirect(contentRedirect);
}
return NextResponse.redirect(siteURLData.redirect);
}
return NextResponse.redirect(data.redirect);
}
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
cookies = {
...cookies,
...getResponseCookiesForVisitorAuth(data.basePath, visitorToken),
// We use the host/origin from the canonical URL to ensure the links are
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (
requestURLWithoutToken !== incomingURL &&
requestURLWithoutToken.toString() !== incomingURL.toString()
) {
return writeResponseCookies(
NextResponse.redirect(requestURLWithoutToken.toString()),
cookies
);
}
//
// Render and serve the content
//
// The route is static, except when using dynamic parameters from query params
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(
MiddlewareHeaders.SiteURL,
`${siteCanonicalURL.origin}${siteURLData.basePath}`
);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
// Preview of customization/theme
const customization = siteRequestURL.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Customization, customization);
}
const theme = siteRequestURL.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, theme);
}
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
// This is useful for testing dynamic routes.
if (request.cookies.has('gitbook-dynamic-route')) {
routeType = 'dynamic';
}
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
siteURLData.pathname
);
routeType = routeTypeFromPathname ?? routeType;
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const route = [
'sites',
routeType,
mode,
encodeURIComponent(siteURLWithoutProtocol),
encodeURIComponent(
rison.encode(
// rison can't encode undefined values
Object.fromEntries(
Object.entries(stableSiteURLData).filter(
([_, v]) => typeof v !== 'undefined'
)
)
)
),
pathname,
].join('/');
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
const response = NextResponse.rewrite(rewrittenURL, {
request: {
headers: requestHeaders,
},
});
// Add Content Security Policy header
response.headers.set('content-security-policy', getContentSecurityPolicy());
// Basic security headers
response.headers.set('strict-transport-security', 'max-age=31536000');
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
response.headers.set('x-content-type-options', 'nosniff');
// Debug header
response.headers.set('x-gitbook-route-type', routeType);
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
return writeResponseCookies(response, cookies);
};
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const requestURLWithoutToken = normalizeVisitorAuthURL(requestURL);
if (requestURLWithoutToken.toString() !== requestURL.toString()) {
return writeResponseCookies(
NextResponse.redirect(requestURLWithoutToken.toString()),
cookies
// For https://preview/<siteURL> requests,
if (siteRequestURL.hostname === 'preview') {
return serveWithQueryAPIToken(
// We scope the API token to the site ID.
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
request,
withAPIToken
);
}
//
// Render and serve the content
//
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
let routeType = visitorToken ? 'dynamic' : 'static';
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${data.basePath}`);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
// Preview of customization/theme
const customization = siteURL.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Customization, customization);
}
const theme = siteURL.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, theme);
}
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
// This is useful for testing dynamic routes.
if (request.cookies.has('gitbook-dynamic-route')) {
routeType = 'dynamic';
}
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const siteURLWithoutProtocol = `${siteURL.host}${data.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(data.pathname);
routeType = routeTypeFromPathname ?? routeType;
const route = [
'sites',
routeType,
mode,
encodeURIComponent(siteURLWithoutProtocol),
pathname,
].join('/');
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
const response = NextResponse.rewrite(rewrittenURL, {
request: {
headers: requestHeaders,
},
});
// Add Content Security Policy header
response.headers.set('content-security-policy', getContentSecurityPolicy());
// Basic security headers
response.headers.set('strict-transport-security', 'max-age=31536000');
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
response.headers.set('x-content-type-options', 'nosniff');
// Debug header
response.headers.set('x-gitbook-route-type', routeType);
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
return writeResponseCookies(response, cookies);
return withAPIToken(null);
}
/**
* Serve routes for previewing unpublished content.
* Routes are:
* - PDF export for a space: /~space/:spaceId/~gitbook/pdf
* - Preview of an unpublished site: /~site/:siteId/
* Serve routes for PDF export for a space: /~space/:spaceId/~gitbook/pdf
*/
async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
async function serveSpacePDFRoutes(requestURL: URL, request: NextRequest) {
const pathnameParts = requestURL.pathname.slice(1).split('/');
if (pathnameParts[0] !== '~space' && pathnameParts[0] !== '~site') {
return null;
}
// We store the API token in a cookie that is scoped to the specific preview route
// to avoid errors when multiple previews are opened in different tabs.
const cookieName = getPathScopedCookieName(
'gitbook-api-token',
pathnameParts.slice(0, 2).join('/')
);
// Extract a potential GitBook API token passed in the request
// If found, we redirect to the same URL but with the token in the cookie
const queryAPIToken = requestURL.searchParams.get('token');
if (queryAPIToken) {
requestURL.searchParams.delete('token');
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), {
[cookieName]: {
value: queryAPIToken,
options: {
httpOnly: true,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60, // 1 hour
return serveWithQueryAPIToken(
pathnameParts.slice(0, 2).join('/'),
request,
async (apiToken) => {
// Handle the rest with the router default logic
return NextResponse.next({
headers: {
[MiddlewareHeaders.APIToken]: apiToken,
},
},
});
}
const apiToken = request.cookies.get(cookieName)?.value;
if (!apiToken) {
throw new DataFetcherError('Missing API token', 400);
}
// Handle the rest with the router default logic
return NextResponse.next({
headers: {
[MiddlewareHeaders.APIToken]: apiToken,
},
});
});
}
);
}
/**
@@ -274,6 +311,45 @@ function serveErrorResponse(error: Error) {
throw error;
}
/**
* Server a response with an API token obtained from the query params.
*/
async function serveWithQueryAPIToken(
scopePath: string,
request: NextRequest,
serve: (apiToken: string) => Promise<NextResponse>
) {
// We store the API token in a cookie that is scoped to the specific route
// to avoid errors when multiple previews are opened in different tabs.
const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath);
// Extract a potential GitBook API token passed in the request
// If found, we redirect to the same URL but with the token in the cookie
const queryAPIToken = request.nextUrl.searchParams.get('token');
if (queryAPIToken) {
request.nextUrl.searchParams.delete('token');
return writeResponseCookies(NextResponse.redirect(request.nextUrl.toString()), [
{
name: cookieName,
value: queryAPIToken,
options: {
httpOnly: true,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60, // 1 hour
},
},
]);
}
const apiToken = request.cookies.get(cookieName)?.value;
if (!apiToken) {
throw new DataFetcherError('Missing API token', 400);
}
return serve(apiToken);
}
/**
* The URL of the GitBook content can be passed in 3 different ways (in order of priority):
* - The request has a `X-GitBook-URL` header:
@@ -342,12 +418,26 @@ function encodePathInSiteContent(rawPathname: string): {
return { pathname };
}
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(/\.md$/)) {
const pagePathWithoutMD = pathname.slice(0, -3);
return {
pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
switch (pathname) {
case '~gitbook/icon':
return { pathname };
case 'llms.txt':
case 'sitemap.xml':
case 'sitemap-pages.xml':
case 'robots.txt':
return { pathname };
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
// as they only depend on the site structure / pages.
return { pathname, routeType: 'static' };
case '~gitbook/pdf':
// PDF routes are always dynamic as they depend on the search params.
return { pathname, routeType: 'dynamic' };
@@ -371,8 +461,8 @@ function appendQueryParams(url: URL, from: URLSearchParams) {
* Write the cookies to a response.
*/
function writeResponseCookies<R extends NextResponse>(response: R, cookies: ResponseCookies): R {
Object.entries(cookies).forEach(([key, { value, options }]) => {
response.cookies.set(key, value, options);
cookies.forEach((cookie) => {
response.cookies.set(cookie.name, cookie.value, cookie.options);
});
return response;
-3
View File
@@ -23,8 +23,5 @@
## Caching
# GITBOOK_OUTPUT_CACHE=true
### Sentry ###
# SENTRY_DSN=xxx
### Silent logs
# SILENT=true
-3
View File
@@ -23,9 +23,6 @@ next-env.d.ts
# visual tests
screenshots/
# Sentry Config File
.sentryclirc
/test-results/
/playwright-report/
/blob-report/
+84
View File
@@ -1,5 +1,89 @@
# gitbook
## 0.9.1
### Patch Changes
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
- 7d0b422: Handle grouped OpenAPISchemas
- Updated dependencies [7d0b422]
- Updated dependencies [fb90eb0]
- @gitbook/react-openapi@1.1.8
- @gitbook/colors@0.3.1
## 0.9.0
### Minor Changes
- 77fd393: Track event when visitor is opening a search result.
- d70d566: Support site announcement banner
- 77fd393: Track event when clicking announcement banner link.
### Patch Changes
- e84a46a: Fix OpenAPI tabs indicator overflow
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
- 434af90: Fix image resizing when using the proxy feature in a site.
- c756761: Add breadcrumbs to search results
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 6f71da8: Fix padding in schemas
- fa91eb7: Fix PDF generation when user has dark mode configured.
- 5b1e01c: Support for x-stability property
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
- d236bf0: Fix flash when loading sites with dark mode as default theme
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- e9fa50d: Trim the search query to avoid showing a loading state when typing
- Updated dependencies [bd35348]
- Updated dependencies [ae78fc5]
- Updated dependencies [7bb37c7]
- Updated dependencies [373183a]
- Updated dependencies [1505ddb]
- Updated dependencies [61db166]
- Updated dependencies [5b1e01c]
- Updated dependencies [cd99ed5]
- Updated dependencies [813b2af]
- Updated dependencies [a25fded]
- @gitbook/react-openapi@1.1.7
- @gitbook/openapi-parser@2.1.2
## 0.8.2
### Patch Changes
- ed07206: Fix OpenAPI path overflow on mobile
- 4dab1c5: Fix alignment of `prominent` search bar on full-width pages
- 6eae764: Support body examples
- 54ee014: Add initial support for loading custom fonts
- d2facb2: Mark properties as optional if not required
- bba2e52: Fix site redirects when it includes a section/variant path
- 4723f03: Restyle section group dropdown
- 24b7808: Fix `prominent` search bar width on `md` screens
- 1fe3286: Fix OpenAPI block overflow issue
- Updated dependencies [48c18c0]
- Updated dependencies [6eae764]
- Updated dependencies [7212973]
- Updated dependencies [d2facb2]
- Updated dependencies [73e2b47]
- Updated dependencies [70be2c6]
- Updated dependencies [fc00b51]
- Updated dependencies [a84b06b]
- @gitbook/openapi-parser@2.1.1
- @gitbook/react-openapi@1.1.6
## 0.8.1
### Patch Changes
- 886e204: Update OpenAPI operation path design
- Updated dependencies [886e204]
- Updated dependencies [4f0a772]
- @gitbook/react-openapi@1.1.5
- @gitbook/colors@0.3.0
## 0.8.0
### Minor Changes
+34 -20
View File
@@ -1,4 +1,4 @@
import { type TestsCase, runTestCases } from './util';
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
/** A list of test cases to run on the customers' docs sites. */
const testCases: TestsCase[] = [
@@ -6,14 +6,21 @@ const testCases: TestsCase[] = [
name: 'Snyk',
contentBaseURL: 'https://docs.snyk.io',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
{ name: 'Home', url: '/', run: waitForCookiesDialog },
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
],
},
{
name: 'Nexthink',
contentBaseURL: 'https://docs.nexthink.com',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
tests: [
{
name: 'Home',
url: '/',
screenshot: { waitForTOCScrolling: false },
run: waitForCookiesDialog,
},
],
},
{
name: 'asiksupport-stg.dto.kemkes.go.id',
@@ -33,7 +40,7 @@ const testCases: TestsCase[] = [
{
name: 'docs.dify.ai',
contentBaseURL: 'https://docs.dify.ai',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'seeddao.gitbook.io',
@@ -63,27 +70,27 @@ const testCases: TestsCase[] = [
{
name: 'docs.portainer.io',
contentBaseURL: 'https://docs.portainer.io',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.chirptoken.io',
contentBaseURL: 'https://docs.chirptoken.io',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.dexscreener.com',
contentBaseURL: 'https://docs.dexscreener.com',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.pancakeswap.finance',
contentBaseURL: 'https://docs.pancakeswap.finance',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'book.character.ai',
contentBaseURL: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.tradeonnova.io',
@@ -103,7 +110,7 @@ const testCases: TestsCase[] = [
{
name: 'docs.keeper.io',
contentBaseURL: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'adiblar.gitbook.io',
@@ -138,12 +145,12 @@ const testCases: TestsCase[] = [
{
name: 'meshnet.nordvpn.com',
contentBaseURL: 'https://meshnet.nordvpn.com',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'manual.bubble.io',
contentBaseURL: 'https://manual.bubble.io',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.tickettool.xyz',
@@ -158,12 +165,12 @@ const testCases: TestsCase[] = [
{
name: 'docs.cherry-ai.com',
contentBaseURL: 'https://docs.cherry-ai.com',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.snyk.io',
contentBaseURL: 'https://docs.snyk.io',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.realapp.link',
@@ -188,7 +195,14 @@ const testCases: TestsCase[] = [
{
name: 'docs.umbraco.com',
contentBaseURL: 'https://docs.umbraco.com',
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
tests: [
{
name: 'Home',
url: '/welcome',
run: waitForCookiesDialog,
screenshot: { waitForTOCScrolling: false },
},
],
},
{
name: 'sosovalue-white-paper.gitbook.io',
@@ -198,12 +212,12 @@ const testCases: TestsCase[] = [
{
name: 'docs.revrobotics.com',
contentBaseURL: 'https://docs.revrobotics.com',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'chartschool.stockcharts.com',
contentBaseURL: 'https://chartschool.stockcharts.com',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.soniclabs.com',
@@ -218,7 +232,7 @@ const testCases: TestsCase[] = [
{
name: 'docs.thousandeyes.com',
contentBaseURL: 'https://docs.thousandeyes.com',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.raydium.io',
@@ -228,7 +242,7 @@ const testCases: TestsCase[] = [
{
name: 'docs.fluentbit.io',
contentBaseURL: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/' }],
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
];
+74 -3
View File
@@ -28,6 +28,7 @@ import {
headerLinks,
runTestCases,
waitForCookiesDialog,
waitForNotFound,
} from './util';
const testCases: TestsCase[] = [
@@ -308,6 +309,18 @@ const testCases: TestsCase[] = [
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
run: waitForCookiesDialog,
},
{
name: 'Invalid revision',
url: '~/revisions/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
{
name: 'Invalid change request',
url: '~/changes/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
],
},
{
@@ -367,6 +380,45 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Site Preview',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
tests: [
{
name: 'Main content',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
},
screenshot: false,
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
},
},
],
},
{
name: 'Markdown page',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Text page',
url: 'text-page.md',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -386,7 +438,7 @@ const testCases: TestsCase[] = [
url: 'blocks/block-images',
run: waitForCookiesDialog,
fullPage: true,
screenshot: { threshold: 0.8 },
screenshot: { threshold: 0.9 },
},
{
name: 'Images (with zoom)',
@@ -397,7 +449,6 @@ const testCases: TestsCase[] = [
await zoomImage.first().click();
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
},
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
@@ -432,7 +483,12 @@ const testCases: TestsCase[] = [
{
name: 'Integration Blocks',
url: 'blocks/integrations',
run: waitForCookiesDialog,
run: async (page) => {
await waitForCookiesDialog(page);
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
},
},
{
name: 'Tables',
@@ -781,6 +837,21 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Site Redirects with sections',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
tests: [
{
// This test that a redirect that incudes a section path works
name: 'Redirect to Quickstart page',
url: 'sections-2/redirect-test',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('Quickstart');
},
screenshot: false,
},
],
},
{
name: 'Share links',
contentBaseURL: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
+80 -35
View File
@@ -16,7 +16,7 @@ import {
type CustomizationThemedColor,
type SiteCustomizationSettings,
} from '@gitbook/api';
import { type BrowserContext, type Page, expect, test } from '@playwright/test';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -33,7 +33,7 @@ export interface Test {
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
@@ -63,6 +63,7 @@ export interface Test {
export type TestsCase = {
name: string;
skip?: boolean;
tests: Array<Test>;
contentBaseURL?: string;
};
@@ -133,11 +134,13 @@ export const headerLinks: CustomizationHeaderItem[] = [
];
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Cookies' });
const accept = dialog.getByRole('button', { name: 'Accept' });
const reject = dialog.getByRole('button', { name: 'Reject' });
await expect(accept).toBeVisible();
await expect(reject).toBeVisible();
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible();
}
export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response).not.toBeNull();
expect(response?.status()).toBe(404);
}
/**
@@ -145,6 +148,10 @@ export async function waitForCookiesDialog(page: Page) {
*/
export function runTestCases(testCases: TestsCase[]) {
for (const testCase of testCases) {
if (testCase.skip) {
continue;
}
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const testFn = testEntry.only ? test.only : test;
@@ -181,23 +188,24 @@ export function runTestCases(testCases: TestsCase[]) {
}
});
await page.goto(url);
const response = await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
await testEntry.run(page, response);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async () => {
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
@@ -319,29 +327,66 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
* Wait for all icons present on the page to be loaded.
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(async () => {
function loadImage(src: string) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (_error) => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
await page.waitForFunction(() => {
const urls = new Set<string>();
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
await Promise.all(
icons.map(async (icon) => {
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
throw new Error('No mask-image');
}
await loadImage(url);
})
);
const results = icons.map((icon) => {
if (!(icon instanceof SVGElement)) {
throw new Error('Icon is not an SVGElement');
}
// If loaded, good it passes the test.
if (icon.dataset.loadingState === 'loaded') {
return true;
}
// If not loaded yet, we need to load it.
if (icon.dataset.loadingState === 'pending') {
return false;
}
// Ignore icons that are not visible.
if (!icon.checkVisibility()) {
return true;
}
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
// If URL is invalid we throw an error.
if (!url) {
throw new Error('No mask-image');
}
// If the URL is already loaded, we just mark it as loaded.
if (urls.has(url)) {
icon.dataset.loadingState = 'loaded';
return true;
}
// Mark the icon as pending and load the image.
icon.dataset.loadingState = 'pending';
// Mark the URL as seen.
urls.add(url);
const img = new Image();
img.src = url;
img.decode().then(() => {
// Wait two frames to let the time to the icon to repaint.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
icon.dataset.loadingState = 'loaded';
});
});
});
return false;
});
return results.every((x) => x);
});
}
+47 -81
View File
@@ -1,83 +1,49 @@
const { withSentryConfig } = require('@sentry/nextjs');
module.exports = withSentryConfig(
{
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
},
webpack(config, { dev, webpack }) {
config.resolve.fallback = {
...config.resolve.fallback,
// Required for `swagger2openapi` to work:
fs: false,
path: false,
http: false,
};
// Tree shake debug code for Sentry
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-with-nextjs
if (!dev) {
config.plugins.push(
new webpack.DefinePlugin({
__SENTRY_DEBUG__: false,
// We always init Sentry with enableTracing: false for now, so this is useless
__SENTRY_TRACING__: false,
__RRWEB_EXCLUDE_IFRAME__: true,
__RRWEB_EXCLUDE_SHADOW_DOM__: true,
__SENTRY_EXCLUDE_REPLAY_WORKER__: true,
})
);
}
return config;
},
async headers() {
return [
// Cache all static assets for 1 year
{
source: '/~gitbook/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
module.exports = {
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
},
{
release: process.env.SENTRY_RELEASE,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring',
disableLogger: true,
}
);
webpack(config) {
config.resolve.fallback = {
...config.resolve.fallback,
// Required for `swagger2openapi` to work:
fs: false,
path: false,
http: false,
};
return config;
},
async headers() {
return [
// Cache all static assets for 1 year
{
source: '/~gitbook/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
};
+17 -13
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.8.0",
"version": "0.9.1",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -29,11 +29,11 @@
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "8.35.0",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1",
"ai": "^4.1.46",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
@@ -41,13 +41,12 @@
"framer-motion": "^10.16.14",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"micromark": "^4.0.1",
"micromark-extension-gfm": "^3.0.0",
"next": "14.2.23",
"memoizee": "^0.4.17",
"next": "14.2.25",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -57,22 +56,25 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0",
"ai": "^4.1.46",
"jwt-decode": "^4.0.0"
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@argos-ci/playwright": "^3.10.0",
"@argos-ci/playwright": "^4.3.0",
"@cloudflare/next-on-pages": "1.13.7",
"vercel": "^39.3.0",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.51.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
@@ -90,8 +92,10 @@
"jsonwebtoken": "^9.0.2",
"postcss": "^8",
"psi": "^4.1.0",
"stylelint": "^16.16.0",
"tailwindcss": "^3.4.0",
"ts-essentials": "^10.0.1",
"typescript": "^5.5.3"
"typescript": "^5.5.3",
"vercel": "^39.3.0"
}
}
+3
View File
@@ -18,5 +18,8 @@ export default defineConfig({
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
contextOptions: {
reducedMotion: 'reduce',
},
},
});
-18
View File
@@ -1,18 +0,0 @@
import * as Sentry from '@sentry/browser';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
release: process.env.SENTRY_RELEASE,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
beforeSendTransaction: () => {
return null;
},
});
}
+1 -7
View File
@@ -1,14 +1,8 @@
'use client';
import { captureException } from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error }) {
useEffect(() => {
captureException(error);
}, [error]);
export default function GlobalError() {
return (
<html lang="en">
<body>
@@ -1,4 +1,4 @@
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
import { getSiteURLDataFromMiddleware, getThemeFromMiddleware } from '@v2/lib/middleware';
import type { Metadata, Viewport } from 'next';
import type React from 'react';
@@ -7,6 +7,7 @@ import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { getVisitorAuthClaims } from '@/lib/adaptive';
import { getSiteContentPointer } from '@/lib/pointer';
import { shouldTrackEvents } from '@/lib/tracking';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
@@ -22,14 +23,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
const context = await fetchLayoutData();
const queryStringTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
const siteData = await getSiteURLDataFromMiddleware();
return (
<SiteLayout
context={context}
forcedTheme={queryStringTheme}
withTracking={await shouldTrackEvents()}
visitorAuthToken={visitorAuthToken}
visitorAuthClaims={getVisitorAuthClaims(siteData)}
>
{children}
</SiteLayout>
@@ -1,8 +1,5 @@
'use client';
import { captureException, withScope } from '@sentry/nextjs';
import React from 'react';
import { Button } from '@/components/primitives/Button';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
@@ -11,18 +8,9 @@ export default function ErrorPage(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
const { error, reset } = props;
const { reset } = props;
const language = useLanguage();
React.useEffect(() => {
withScope((scope) => {
if ('_componentStack' in error && error._componentStack) {
scope.setExtra('componentStack', error._componentStack);
}
captureException(error);
});
}, [error]);
return (
<div
className={tcls(
@@ -0,0 +1,32 @@
import { resolveContentRef } from '@/lib/references';
import type { GitBookSiteContext } from '@v2/lib/context';
import { AnnouncementBanner } from './AnnouncementBanner';
/**
* Server-side component to resolve content refs and pass down to client-side component
*/
export async function Announcement(props: {
context: GitBookSiteContext;
}) {
const { context } = props;
const { customization } = context;
if (
!customization.announcement ||
!customization.announcement.enabled ||
!customization.announcement.message
) {
return null;
}
const resolvedContentRef = customization.announcement?.link
? await resolveContentRef(customization.announcement?.link?.to, context)
: null;
return (
<AnnouncementBanner
announcement={customization.announcement}
contentRef={resolvedContentRef}
/>
);
}
@@ -0,0 +1,140 @@
'use client';
import * as storage from '@/lib/local-storage';
import type { ResolvedContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { CONTAINER_STYLE } from '../layout';
import { Link, linkStyles } from '../primitives';
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
/**
* Client-side component to enable closing the banner
*/
export function AnnouncementBanner(props: {
announcement: CustomizationAnnouncement;
contentRef: ResolvedContentRef | null;
}) {
const { announcement, contentRef } = props;
const hasLink = announcement.link && contentRef?.href;
const closeable = announcement.style !== 'danger';
const Tag = hasLink ? Link : 'div';
const style = BANNER_STYLES[announcement.style];
return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<button
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
type="button"
onClick={dismissAnnouncement}
>
<Icon icon="close" className="size-4" />
</button>
) : null}
</div>
</div>
</div>
);
}
/**
* Dismiss the announcement banner and store the dismissal state in local storage.
* @see AnnouncementScript
*/
function dismissAnnouncement() {
storage.setItem(ANNOUNCEMENT_STORAGE_KEY, {
visible: false,
at: Date.now(),
});
document.documentElement.classList.add(ANNOUNCEMENT_CSS_CLASS);
}
const BANNER_STYLES = {
info: {
container: 'bg-info ring-info-subtle',
hover: 'hover:bg-info-hover active:bg-info-active',
icon: 'circle-info',
iconColor: 'text-info-subtle',
close: 'hover:bg-tint-base hover:ring-info-subtle',
link: '',
},
warning: {
container: 'bg-warning decoration-warning/6 ring-warning-subtle',
hover: 'hover:bg-warning-hover',
icon: 'circle-exclamation',
iconColor: 'text-warning-subtle',
close: 'hover:bg-tint-base hover:ring-warning-subtle',
link: 'links-default:text-warning links-default:hover:text-warning-strong links-default:decoration-warning/6 links-accent:decoration-warning',
},
danger: {
container: 'bg-danger decoration-danger/6 ring-danger-subtle',
hover: 'hover:bg-danger-hover',
icon: 'triangle-exclamation',
iconColor: 'text-danger-subtle',
close: 'hover:bg-tint-base hover:ring-danger-subtle',
link: 'links-default:text-danger links-default:hover:text-danger-strong links-default:decoration-danger/6 links-accent:decoration-danger',
},
success: {
container: 'bg-success decoration-success/6 ring-success-subtle',
hover: 'hover:bg-success-hover',
icon: 'circle-check',
iconColor: 'text-success-subtle',
close: 'hover:bg-tint-base hover:ring-success-subtle',
link: 'links-default:text-success links-default:hover:text-success-strong links-default:decoration-success/6 links-accent:decoration-success',
},
};
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import {
ANNOUNCEMENT_CSS_CLASS,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_STORAGE_KEY,
} from './constants';
import { checkStorageForDismissedScript } from './script';
/**
* Inject a script to read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element as early as possible.
* Bypasses react state to prevent flickering.
*/
export function AnnouncementDismissedScript() {
const scriptArgs = JSON.stringify([
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS,
]).slice(1, -1);
// makes sure the banner dismissed state is kept when navigating between sites
React.useEffect(() => {
checkStorageForDismissedScript(
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS
);
}, []);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${checkStorageForDismissedScript.toString()})(${scriptArgs})`,
}}
/>
);
}
@@ -0,0 +1,12 @@
/**
* The local storage key for the announcement banner.
*/
export const ANNOUNCEMENT_STORAGE_KEY = '@gitbook/announcement';
/**
* The CSS class to hide the announcement banner. Applies to the <html> element.
*/
export const ANNOUNCEMENT_CSS_CLASS = 'announcement-hidden';
/**
* The number of days until the announcement banner resets.
*/
export const ANNOUNCEMENT_DAYS_TILL_RESET = 7;
@@ -0,0 +1,2 @@
export * from './Announcement';
export * from './AnnouncementDismissedScript';
@@ -0,0 +1,34 @@
/**
* Read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element.
*
* NOTE: this script is stringified and run in the browser, so it must be self-contained and have syntax supported in all browsers.
*/
export function checkStorageForDismissedScript(
storageKey: string,
daysTillReset: number,
cssClass: string
) {
let showBanner = true;
try {
const announcementStateStr = window.localStorage.getItem(storageKey);
const announcementState = announcementStateStr
? JSON.parse(announcementStateStr)
: undefined;
if (announcementState && !announcementState.visible) {
const dismissedAt = announcementState.at;
const nowTime = new Date().getTime();
// Check if enough days have passed since dismissal
const daysSinceDismissal = Math.floor((nowTime - dismissedAt) / (1000 * 60 * 60 * 24));
if (daysSinceDismissal < daysTillReset) {
showBanner = false;
}
}
} catch {}
if (!showBanner) {
document.documentElement.classList.add(cssClass);
}
}
@@ -54,7 +54,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;
let isOffscreen = defaultIsOffscreen;
return nodes.map((node) => {
return nodes.map((node, index) => {
isOffscreen =
isOffscreen ||
isBlockOffscreen({
@@ -65,7 +65,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
return (
<Block
key={node.key}
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'mx-auto w-full decoration-primary/6',
@@ -104,7 +104,7 @@ const HINT_STYLES: {
'[&_.can-override-text]:text-neutral-strong',
],
container:
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2',
'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
containerWithHeader: 'border-info-solid bg-info-subtle',
},
warning: {
@@ -24,14 +24,16 @@ export function Inlines<T extends DocumentInline | DocumentText>(
) {
const { nodes, document, ancestorInlines, ...contextProps } = props;
return nodes.map((node) => {
return nodes.map((node, index) => {
const key = node.key || `key-${index}`;
if (node.object === 'text') {
return <Text key={node.key} text={node} />;
return <Text key={key} text={node} />;
}
return (
<Inline
key={node.key}
key={key}
inline={node}
document={document}
ancestorInlines={ancestorInlines}
@@ -1,47 +1,13 @@
import type { ContentKitContext, DocumentBlockIntegration } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import {
ContentKit,
ContentKitOutput,
type ContentKitServerContext,
} from '@gitbook/react-contentkit';
import { renderIntegrationUi } from '@/lib/api';
import { parseMarkdown } from '@/lib/markdown';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import './contentkit.css';
import { ignoreAllThrownError } from '@v2/lib/data';
const outputContext: ContentKitServerContext = {
icons: {
maximize: (props) => <Icon icon="maximize" {...props} />,
edit: (props) => <Icon icon="edit" {...props} />,
github: (props) => <Icon icon="github" {...props} />,
gitlab: (props) => <Icon icon="gitlab" {...props} />,
close: (props) => <Icon icon="x" {...props} />,
email: (props) => <Icon icon="envelope" {...props} />,
settings: (props) => <Icon icon="gear" {...props} />,
search: (props) => <Icon icon="magnifying-glass" {...props} />,
delete: (props) => <Icon icon="trash" {...props} />,
star: (props) => <Icon icon="star" {...props} />,
warning: (props) => <Icon icon="triangle-exclamation" {...props} />,
link: (props) => <Icon icon="link" {...props} />,
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: (props) => <Icon icon="eye" {...props} />,
lock: (props) => <Icon icon="lock" {...props} />,
},
codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
},
markdown: async ({ className, markdown }) => {
const parsed = await parseMarkdown(markdown);
return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />;
},
};
import { getDataOrNull } from '@v2/lib/data';
import { contentKitServerContext } from './contentkit';
import { renderIntegrationUi } from './server-actions';
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
const { block, context, style } = props;
@@ -50,22 +16,29 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
throw new Error('integration block requires a content.spaceId');
}
const contentKitContext: ContentKitContext = {
type: 'document',
spaceId: context.contentContext?.space.id,
editable: false,
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
};
const { dataFetcher } = context.contentContext;
const initialInput = {
const initialInput: RenderIntegrationUI = {
componentId: block.data.block,
props: block.data.props,
action: block.data.action,
context: contentKitContext,
context: {
type: 'document',
spaceId: context.contentContext?.space.id,
editable: false,
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
},
};
const initialOutput = await ignoreAllThrownError(
renderIntegrationUi(block.data.integration, initialInput)
const initialOutput = await getDataOrNull(
dataFetcher.renderIntegrationUi({
integrationName: block.data.integration,
request: initialInput,
}),
// The API can respond with a 400 error if the integration is not installed
// and 404 if the integration is not found.
[404, 400]
);
if (!initialOutput || initialOutput.type === 'complete') {
return null;
@@ -74,21 +47,15 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return (
<div className={tcls(style)}>
<ContentKit
renderContext={{
integrationName: block.data.integration,
}}
security={{ firstPartyDomains: [GITBOOK_INTEGRATIONS_HOST] }}
initialInput={initialInput}
initialOutput={initialOutput}
render={async (request) => {
'use server';
const output = await renderIntegrationUi(block.data.integration, request);
return {
children: <ContentKitOutput output={output} context={outputContext} />,
output: output,
};
}}
render={renderIntegrationUi}
>
<ContentKitOutput output={initialOutput} context={outputContext} />
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
</div>
);
@@ -0,0 +1,31 @@
import { parseMarkdown } from '@/lib/markdown';
import { Icon } from '@gitbook/icons';
import type { ContentKitServerContext } from '@gitbook/react-contentkit';
import { PlainCodeBlock } from '../CodeBlock';
export const contentKitServerContext: ContentKitServerContext = {
icons: {
maximize: (props) => <Icon icon="maximize" {...props} />,
edit: (props) => <Icon icon="edit" {...props} />,
github: (props) => <Icon icon="github" {...props} />,
gitlab: (props) => <Icon icon="gitlab" {...props} />,
close: (props) => <Icon icon="x" {...props} />,
email: (props) => <Icon icon="envelope" {...props} />,
settings: (props) => <Icon icon="gear" {...props} />,
search: (props) => <Icon icon="magnifying-glass" {...props} />,
delete: (props) => <Icon icon="trash" {...props} />,
star: (props) => <Icon icon="star" {...props} />,
warning: (props) => <Icon icon="triangle-exclamation" {...props} />,
link: (props) => <Icon icon="link" {...props} />,
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: (props) => <Icon icon="eye" {...props} />,
lock: (props) => <Icon icon="lock" {...props} />,
},
codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
},
markdown: async ({ className, markdown }) => {
const parsed = await parseMarkdown(markdown);
return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />;
},
};
@@ -0,0 +1,37 @@
'use server';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import type { RenderIntegrationUI } from '@gitbook/api';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import { throwIfDataError } from '@v2/lib/data';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { contentKitServerContext } from './contentkit';
/**
* Server action to render an integration UI request from <ContentKit />.
* See `render` prop in <ContentKit /> for more details.
*/
export async function renderIntegrationUi({
renderContext,
request,
}: {
renderContext: {
integrationName: string;
};
request: RenderIntegrationUI;
}) {
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const output = await throwIfDataError(
serverAction.dataFetcher.renderIntegrationUi({
integrationName: renderContext.integrationName,
request,
})
);
return {
children: <ContentKitOutput output={output} context={contentKitServerContext} />,
output: output,
};
}
@@ -20,7 +20,7 @@ import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlock>) {
const { style } = props;
return (
<div className={tcls('flex w-full', style, 'max-w-full')}>
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<OpenAPIOperationBody {...props} />
</div>
);
@@ -77,7 +77,12 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
ancestorBlocks={props.ancestorBlocks}
isEstimatedOffscreen={props.isEstimatedOffscreen}
context={props.context}
style={headingProps.deprecated ? 'line-through' : undefined}
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:mt-0'
: undefined,
])}
block={{
object: 'block',
key: `${block.key}-heading`,
@@ -50,6 +50,7 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
return (
<BaseOpenAPISchemas
data={data}
grouped={block.data.grouped}
context={{
specUrl,
icons: {
@@ -264,14 +264,18 @@
gap: 6px;
}
.scalar-activate-button {
@apply flex gap-1.5 items-center;
@apply flex gap-2 items-center;
@apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none place-self-start;
@apply ring-1 ring-tint hover:ring-tint-hover;
@apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:scale-105 active:scale-100 transition-all;
@apply grow-0 shrink-0 truncate;
@apply text-sm px-2.5 py-1;
@apply text-[13px] px-2 py-0.5 font-mono font-medium [word-spacing:-2px];
}
.scalar-activate-button svg {
@apply size-2.5;
}
.scalar-app-loading {
@@ -1,6 +1,6 @@
/* Layout Components */
.openapi-operation {
@apply flex-1 flex flex-col gap-4 mb-14;
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
}
.openapi-schemas {
@@ -17,13 +17,34 @@
}
.openapi-summary {
@apply flex flex-col items-start justify-start gap-2;
@apply flex flex-col items-start justify-start gap-3;
}
.openapi-deprecated {
.openapi-summary-tags {
@apply flex flex-row gap-2 mt-[0.75em];
}
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
}
.openapi-stability-stable {
@apply text-green-600 dark:text-green-300 bg-green-50 dark:bg-green-900/6 ring-green-500/5;
}
.openapi-stability-alpha {
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
}
.openapi-stability-beta {
@apply text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/6 ring-blue-500/5;
}
.openapi-stability-experimental {
@apply text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-900/6 ring-violet-500/5;
}
.openapi-deprecated-sunset-date {
@apply font-semibold font-mono truncate;
}
@@ -131,7 +152,7 @@
/* Schema Structure */
.openapi-schema-root {
@apply mt-2;
/* unstyled */
}
.openapi-schema-properties {
@@ -201,10 +222,18 @@
@apply text-warning-subtle text-[0.813rem];
}
.openapi-schema-optional {
@apply text-info-subtle text-[0.813rem];
}
.openapi-schema-readonly {
@apply text-primary-subtle/9 text-[0.813rem];
}
.openapi-schema-writeonly {
@apply text-success dark:text-success-subtle/9 text-[0.813rem];
}
.openapi-schema-type {
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
}
@@ -236,7 +265,11 @@
/* Schema Enum */
.openapi-schema-enum {
@apply flex flex-row text-sm leading-relaxed flex-wrap text-tint;
@apply flex flex-row text-sm leading-relaxed gap-2 flex-wrap text-tint;
}
.openapi-schema-enum-list {
@apply flex flex-row gap-1.5 items-center;
}
.openapi-schema-enum-value {
@@ -391,19 +424,37 @@
@apply flex flex-row items-center h-fit;
}
.openapi-codesample-footer {
@apply flex gap-3 w-full justify-between flex-wrap;
}
.openapi-codesample-selectors {
@apply flex flex-row items-center gap-3 flex-wrap;
}
/* Path */
.openapi-path {
@apply flex items-center bg-transparent text-sm gap-2 p-2 border rounded-md border-tint-subtle;
@apply flex items-start text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
scrollbar-width: none;
-ms-overflow-style: none;
}
.openapi-path-variable {
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:!content-none after:!content-none;
}
.openapi-path-server {
@apply text-tint hidden md:inline;
}
.openapi-path .openapi-method {
@apply text-[0.813rem] m-0 px-1;
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-2;
}
.openapi-path-title {
@apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong;
scrollbar-width: none;
-ms-overflow-style: none;
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
@apply whitespace-nowrap md:whitespace-normal;
}
.openapi-path-title[data-deprecated="true"] {
@@ -445,13 +496,9 @@
/* Common Elements */
.openapi-select {
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 border border-tint-subtle bg-tint;
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
}
.openapi-select {
min-width: fit-content;
max-width: fit-content;
}
.openapi-select:focus {
width: auto;
}
@@ -499,7 +546,7 @@
/* Tabs */
.openapi-tabs-list {
@apply flex flex-row gap-1.5 py-1.5 px-2.5 border-b border-tint-subtle w-full overflow-x-scroll;
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -509,17 +556,22 @@
}
.openapi-tabs-tab[aria-selected="true"] {
@apply text-primary after:absolute after:-bottom-2 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
}
.openapi-tabs-panel {
@apply flex-1 text-sm focus-visible:outline-none;
@apply flex-1 text-sm relative focus-visible:outline-none;
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
.openapi-tabs-footer {
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
}
.openapi-tabs-footer .openapi-markdown {
@apply text-[0.813rem] text-tint;
}
/* Disclosure group */
.openapi-disclosure-group {
@apply border-b border-tint-subtle relative;
@@ -628,6 +680,45 @@
@apply border border-tint-subtle rounded-lg;
}
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema {
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema,
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root {
@apply p-2.5;
}
.openapi-tooltip {
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md font-medium px-1.5 py-0.5 shadow-sm text-[13px];
}
.openapi-tooltip svg {
@apply size-3 text-tint-strong;
}
.openapi-tooltip[data-entering] {
animation: tooltip-enter 0.2s ease-in-out forwards;
}
.openapi-tooltip[data-exiting] {
animation: tooltip-leave 0.2s ease-in-out forwards;
}
@keyframes tooltip-enter {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes tooltip-leave {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.openapi-copy-button {
@apply hover:brightness-95;
}
@@ -12,7 +12,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const tabs: TabsItem[] = [];
const tabsBody: React.ReactNode[] = [];
block.nodes.forEach((tab) => {
block.nodes.forEach((tab, index) => {
tabs.push({
id: tab.meta?.id ?? tab.key!,
title: tab.data.title ?? '',
@@ -20,6 +20,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
tabsBody.push(
<Blocks
key={tab.key ?? index}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
@@ -42,11 +42,11 @@ export function CurrentContentIcon(
}
: {
light: {
src: linker.toPathInContent('~gitbook/icon?size=medium&theme=light'),
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=light'),
size: { width: 256, height: 256 },
},
dark: {
src: linker.toPathInContent('~gitbook/icon?size=medium&theme=dark'),
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=dark'),
size: { width: 256, height: 256 },
},
}
@@ -123,7 +123,6 @@ export function DropdownMenuItem(
return (
<Link
href={href}
prefetch={false}
insights={insights}
className={tcls(
'rounded straight-corners:rounded-sm px-3 py-1 text-sm',
@@ -38,7 +38,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'theme-bold-tint:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
@@ -102,12 +102,14 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
? [
'md:grow-[0.8]',
'lg:basis-40',
'max-w-lg',
'md:max-w-[40%]',
'lg:max-w-lg',
'lg:ml-[max(calc((100%-18rem-48rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - margin (3rem)
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'page-no-toc:lg:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]',
'page-full-width:lg:ml-6',
'lg:mr-auto',
'page-full-width:lg:ml-[max(calc((100%-18rem-103rem-3rem)/2),1.5rem)]',
'page-full-width:2xl:ml-[max(calc((100%-18rem-96rem-14rem+3rem)/2),1.5rem)]',
'md:mr-auto',
'order-last',
'md:order-[unset]',
]
@@ -179,44 +181,46 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
</div>
{sections || siteSpaces.length > 1 ? (
<div
className={tcls(
'w-full',
'overflow-x-scroll',
'overflow-y-hidden',
'hide-scroll',
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
)}
>
<div className="scroll-nojump">
<div
className={tcls(
CONTAINER_STYLE,
'page-default-width:max-w-[unset]',
'grow',
'flex',
'items-end',
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
'w-full',
'overflow-x-scroll',
'overflow-y-hidden',
'hide-scroll',
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
)}
>
{siteSpaces.length > 1 && (
<div
id="variants"
className="my-2 mr-5 page-no-toc:flex hidden grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
>
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className="w-full grow py-1"
<div
className={tcls(
CONTAINER_STYLE,
'page-default-width:max-w-[unset]',
'grow',
'flex',
'items-end',
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
)}
>
{siteSpaces.length > 1 && (
<div
id="variants"
className="my-2 mr-5 page-no-toc:flex hidden grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
>
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className="w-full grow py-1"
/>
</div>
)}
{sections && (
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/>
</div>
)}
{sections && (
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/>
)}
)}
</div>
</div>
</div>
) : null}
@@ -20,7 +20,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
return (
<Link
href={linker.toAbsoluteURL(linker.toPathInContent(''))}
href={linker.toAbsoluteURL(linker.toPathInSpace(''))}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
>
{customization.header.logo ? (
@@ -47,17 +47,11 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
aria-label={tString(language, 'table_of_contents_button_label')}
onClick={toggleNavigation}
className={tcls(
'flex',
'flex-row',
'items-center',
'py-1',
'px-2',
'rounded',
'straight-corners:rounded-sm',
'flex flex-row items-center rounded straight-corners:rounded-sm px-2 py-1',
props.className
)}
>
<Icon icon="bars" className={tcls('size-4', 'text-inherit')} />
<Icon icon="bars" className="size-4 text-inherit" />
</button>
);
}
@@ -63,7 +63,7 @@ export function SpacesDropdown(props: {
className
)}
>
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
<span className={tcls('line-clamp-1', 'grow')}>{siteSpace.title}</span>
<DropdownChevron />
</div>
)}
@@ -5,8 +5,8 @@ import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import * as cookies from '@/lib/cookies';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { getAllBrowserCookiesMap } from '@/lib/browser-cookies';
import { getSession } from './sessions';
import { getVisitorId } from './visitorId';
@@ -23,6 +23,7 @@ type InsightsEventContext = {
siteShareKey: string | null;
spaceId: string;
revisionId: string;
visitorAuthClaims: VisitorAuthClaims;
};
/**
@@ -65,7 +66,6 @@ interface InsightsProviderProps extends InsightsEventContext {
enabled: boolean;
appURL: string;
apiHost: string;
visitorAuthToken: string | null;
children: React.ReactNode;
}
@@ -73,7 +73,7 @@ interface InsightsProviderProps extends InsightsEventContext {
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, appURL, apiHost, visitorAuthToken, children, ...context } = props;
const { enabled, appURL, apiHost, children, ...context } = props;
const visitorIdRef = React.useRef<string | null>(null);
const eventsRef = React.useRef<{
@@ -116,7 +116,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
pageContext: eventsForPathname.pageContext,
visitorId,
sessionId: session.id,
visitorAuthToken,
})
);
@@ -253,16 +252,15 @@ function transformEvents(input: {
pageContext: InsightsEventPageContext;
visitorId: string;
sessionId: string;
visitorAuthToken: string | null;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: cookies.getAll(),
cookies: getAllBrowserCookiesMap(),
referrer: document.referrer || null,
visitorAuthToken: input.visitorAuthToken ?? null,
visitorAuthClaims: input.context.visitorAuthClaims,
};
const location: api.SiteInsightsEventLocation = {
@@ -1,6 +1,6 @@
'use client';
import * as cookies from '@/lib/cookies';
import { getBrowserCookie, setBrowserCookie } from '@/lib/browser-cookies';
const GRANTED_COOKIE = '__gitbook_cookie_granted';
@@ -8,7 +8,7 @@ const GRANTED_COOKIE = '__gitbook_cookie_granted';
* Accept or reject cookies.
*/
export function setCookiesTracking(enabled: boolean) {
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
setBrowserCookie(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
expires: 365,
sameSite: 'none',
secure: true,
@@ -20,7 +20,7 @@ export function setCookiesTracking(enabled: boolean) {
* Return `undefined` if state is not known.
*/
export function isCookiesTrackingDisabled() {
const state = cookies.get(GRANTED_COOKIE);
const state = getBrowserCookie(GRANTED_COOKIE);
if (state === 'yes') {
return false;
@@ -1,6 +1,6 @@
'use client';
import * as cookies from '@/lib/cookies';
import { getBrowserCookie } from '@/lib/browser-cookies';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
@@ -37,7 +37,7 @@ async function fetchVisitorID(appURL: string): Promise<string> {
return generateRandomId();
}
const existingTrackingCookie = cookies.get(VISITORID_COOKIE);
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
@@ -1,5 +1,4 @@
import {
type CustomizationSettings,
type Revision,
type RevisionPageDocument,
type RevisionPageGroup,
@@ -11,7 +10,7 @@ import {
import { Icon } from '@gitbook/icons';
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
import { getPageDocument } from '@v2/lib/data';
import type { GitBookSpaceLinker } from '@v2/lib/links';
import type { GitBookLinker } from '@v2/lib/links';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import * as React from 'react';
@@ -24,7 +23,7 @@ import { tString } from '@/intl/translate';
import { getPagePDFContainerId } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { PageControlButtons } from './PageControlButtons';
@@ -57,7 +56,7 @@ export async function PDFPage(props: {
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomizationForSpace();
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = getSpaceLanguage(customization);
// Compute the pages to render
@@ -67,7 +66,7 @@ export async function PDFPage(props: {
);
// Build a linker that create anchor links for the pages rendered in the PDF page.
const linker: GitBookSpaceLinker = {
const linker: GitBookLinker = {
...baseContext.linker,
toPathForPage(input) {
if (pages.some((p) => p.page.id === input.page.id)) {
@@ -93,7 +92,7 @@ export async function PDFPage(props: {
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a
title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? linker.toAbsoluteURL(linker.toPathInContent(''))}
href={pdfParams.back ?? linker.toAbsoluteURL(linker.toPathInSpace(''))}
className={tcls(
'flex',
'flex-row',
@@ -180,7 +179,7 @@ export async function PDFPage(props: {
async function PDFSpaceIntro(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
}) {
const { space, customization } = props;
@@ -1,5 +1,6 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import { CustomizationThemeMode } from '@gitbook/api';
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
/**
@@ -14,8 +15,9 @@ export async function PDFRootLayout(props: {
return (
<CustomizationRootLayout
customization={
'customization' in context ? context.customization : defaultCustomizationForSpace()
'customization' in context ? context.customization : defaultCustomization()
}
forcedTheme={CustomizationThemeMode.Light}
>
{children}
</CustomizationRootLayout>
@@ -35,7 +35,7 @@ export function PageAside(props: {
const { customization, site, space } = context;
const language = getSpaceLanguage(customization);
const pdfHref = context.linker.toPathInContent(
const pdfHref = context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
@@ -228,7 +228,7 @@ export function PageAside(props: {
) : null}
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-bold-tint:bg-tint-subtle theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex',
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'page-api-block:xl:max-2xl:bg-transparent'
)}
>
@@ -70,9 +70,6 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
href={`#${section.id}`}
className={tcls(
'relative',
'flex',
'flex-row',
'items-baseline',
'z-10',
'text-sm',
@@ -114,7 +111,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'hover:bg-primary-hover',
'theme-muted:hover:bg-primary-active',
'theme-bold-tint:hover:bg-primary-active',
'[html.sidebar-filled.theme-bold.tint_&]:hover:bg-primary-active',
'theme-gradient:hover:bg-primary-active',
'tint:font-semibold',
@@ -126,7 +123,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
>
{section.tag ? (
<span
className={`openapi-method openapi-method-${section.tag.toLowerCase()}`}
className={`-mt-0.5 openapi-method openapi-method-${section.tag.toLowerCase()}`}
>
{section.tag}
</span>

Some files were not shown because too many files have changed in this diff Show More