mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-27 15:37:05 +00:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36877d92a6 | |||
| 23433d1287 | |||
| 5d9183e1b4 | |||
| a1d5326ffa | |||
| cc26fb34aa | |||
| b1abbf603b | |||
| a70e8cd7dc | |||
| b38eed850f | |||
| f4c50c7e3c | |||
| 5d308c764e | |||
| 08e780496b | |||
| e4cd67ebe2 | |||
| 939b160bfa | |||
| 689e78d8e7 | |||
| 37c1d4b8ea | |||
| 75855585f5 | |||
| dcf0f9aac5 | |||
| 1d7253307a | |||
| 2b6c593289 | |||
| c0b339e406 | |||
| da485f5144 | |||
| 139a8050a5 | |||
| da7b369ffd | |||
| 432da64919 | |||
| 7d0b422200 | |||
| fb90eb0acc | |||
| b647538e41 | |||
| e50797a5c1 | |||
| 5b1e01c786 | |||
| 813b2af06b | |||
| 6f71da88d8 | |||
| fa91eb7eaf | |||
| c756761591 | |||
| 434af90366 | |||
| 0f41e194cb | |||
| bd353489e9 | |||
| 77fd393f15 | |||
| 8701b5e078 | |||
| 61db166f92 | |||
| 0d9b9cea0f | |||
| 7bb37c7f0a | |||
| 5b2bf828e3 | |||
| c85d3f9752 | |||
| 4d7088bbf0 | |||
| dd6ab6044c | |||
| d236bf029c | |||
| d70d566abe | |||
| a25fdedea9 | |||
| 373183a2cd | |||
| e9fa50dfb5 | |||
| 57ca4e0e88 | |||
| bc90adb73a | |||
| 1505ddbd5a | |||
| cd99ed57fb | |||
| 27de1cd01d | |||
| ae78fc5394 | |||
| 40e8e69c6d | |||
| e84a46a558 | |||
| 18a72d4466 | |||
| d7aa5fa003 | |||
| 27532a5f41 | |||
| 48c18c03b9 | |||
| bba2e52e24 | |||
| 54ee0149e1 | |||
| 8fda9d2fab | |||
| 77a72d79ce | |||
| 73e2b472ef | |||
| 2f28cc1323 | |||
| f5516d4e21 | |||
| d2facb2db1 | |||
| 6eae764b7c | |||
| 970ef8f886 | |||
| ed0720699e | |||
| ad94d00d5f | |||
| c7843f7f52 | |||
| 1fe3286bdb | |||
| ce030fdf76 | |||
| 9ba8783952 | |||
| 2f3966cc53 | |||
| 8f65a22e13 | |||
| 2787d884cd | |||
| fc00b51683 | |||
| f80c3a7b37 | |||
| 4e5d6c7487 | |||
| 96f84b5917 | |||
| 6b8a5fdd1b | |||
| 89083ac790 | |||
| 70be2c6b2b | |||
| bcf877841b | |||
| a84b06bb19 | |||
| 24b7808d9a | |||
| 8364ee988e | |||
| 07f6e2d233 | |||
| 7212973f34 | |||
| af44e0436b | |||
| 3f9c35d505 | |||
| 2c13f8b95c | |||
| 4dab1c56e5 | |||
| 4723f03ef1 | |||
| 278689e0a0 | |||
| 886e204e68 | |||
| c59947ab39 | |||
| 4f0a772a21 | |||
| 903f962673 | |||
| 31d800e48b | |||
| c1c58eb919 | |||
| eec3eed100 | |||
| 99da8df3d1 | |||
| c9ea23919e | |||
| 16292def79 | |||
| 9108c56ca1 | |||
| b011ea0af8 | |||
| d33171b779 | |||
| 6aaeae269e | |||
| e5f0cc8663 | |||
| 9b5f971aa1 | |||
| ff3b708ad3 | |||
| e996904013 | |||
| f32bf1f15d | |||
| 6cda2dd57b | |||
| c60e9ba27f | |||
| 9bc3d504c8 | |||
| b5e4fd75e9 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@gitbook/openapi-parser': patch
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Remove stable from x-stability
|
||||
@@ -44,11 +44,13 @@ runs:
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
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
|
||||
|
||||
@@ -49,11 +49,13 @@ runs:
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
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
|
||||
@@ -73,3 +75,4 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.next
|
||||
.vercel
|
||||
.open-next
|
||||
|
||||
# Generated
|
||||
packages/emoji-codepoints/index.ts
|
||||
packages/gitbook/public/~gitbook/static/
|
||||
packages/icons/src/data/*.json
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
|
||||
# v2
|
||||
packages/gitbook-v2/public/~gitbook/static/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4
|
||||
}
|
||||
Vendored
+3
@@ -13,5 +13,8 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@
|
||||
"**/.vercel/**/*",
|
||||
"**/.cache/**/*",
|
||||
"**/.wrangler/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*"
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
+6
-2
@@ -7,11 +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"
|
||||
"react-dom": "18.3.1",
|
||||
"@gitbook/api": "0.107.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -37,5 +38,8 @@
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tooltip": "^1.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/cache-tags
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f32bf1f: Export function `getCacheTagForURL` to easily get the cache tag for a URL.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "0.96.1",
|
||||
"@gitbook/api": "*",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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,18 +107,31 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache tag for a given URL.
|
||||
*/
|
||||
export function getCacheTagForURL(url: string | URL) {
|
||||
const parsedURL = url instanceof URL ? url : new URL(url);
|
||||
return getCacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags for a computed content source.
|
||||
*/
|
||||
@@ -146,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
|
||||
@@ -158,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.
|
||||
|
||||
if (source.type.startsWith('integration:')) {
|
||||
const integration = source.type.split(':')[1];
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
integration: source.integration,
|
||||
integration,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [f32bf1f]
|
||||
- @gitbook/cache-tags@0.2.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.2.0",
|
||||
"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": "0.96.1",
|
||||
"@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": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d",
|
||||
"@opennextjs/cloudflare": "^0.5.10",
|
||||
"@types/rison": "^0.0.9",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8"
|
||||
},
|
||||
@@ -26,6 +30,7 @@
|
||||
"start": "next start",
|
||||
"build:v2:cloudflare": "opennextjs-cloudflare",
|
||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
||||
"unit": "bun test"
|
||||
"unit": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 +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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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>;
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
+3
-3
@@ -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({
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -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 });
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_API_TOKEN,
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
@@ -22,7 +24,9 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_URL,
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
fetchSpaceContextByIds,
|
||||
} from '@v2/lib/context';
|
||||
import { createDataFetcher } from '@v2/lib/data';
|
||||
import { GITBOOK_API_URL } from '@v2/lib/env';
|
||||
import { createLinker } from '@v2/lib/links';
|
||||
import { getAPITokenFromMiddleware } from '@v2/lib/middleware';
|
||||
|
||||
@@ -21,12 +20,13 @@ 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,
|
||||
apiEndpoint: GITBOOK_API_URL,
|
||||
});
|
||||
|
||||
const baseContext: GitBookBaseContext = {
|
||||
|
||||
@@ -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_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from './env';
|
||||
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,28 +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 ?? GITBOOK_API_TOKEN,
|
||||
apiEndpoint: GITBOOK_API_URL,
|
||||
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,
|
||||
@@ -136,82 +182,14 @@ 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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
return await fetchSiteContextByIds(baseContext, {
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
@@ -220,8 +198,7 @@ export async function fetchSiteContextByURLLookup(
|
||||
shareKey: data.shareKey,
|
||||
changeRequest: data.changeRequest,
|
||||
revision: data.revision,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,7 +307,7 @@ export async function fetchSpaceContextByIds(
|
||||
})
|
||||
),
|
||||
ids.changeRequest
|
||||
? throwIfDataError(
|
||||
? getDataOrNull(
|
||||
dataFetcher.getChangeRequest({
|
||||
spaceId: ids.space,
|
||||
changeRequestId: ids.changeRequest,
|
||||
@@ -339,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,
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
import { type ComputedContentSource, GitBookAPI } from '@gitbook/api';
|
||||
import { trace } from '@/lib/tracing';
|
||||
import {
|
||||
type ComputedContentSource,
|
||||
GitBookAPI,
|
||||
type GitBookAPIServiceBinding,
|
||||
type RenderIntegrationUI,
|
||||
} from '@gitbook/api';
|
||||
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 {
|
||||
/**
|
||||
* API host to use.
|
||||
*/
|
||||
apiEndpoint: string;
|
||||
|
||||
/**
|
||||
* API token.
|
||||
*/
|
||||
apiToken: string | null;
|
||||
}
|
||||
|
||||
const commonInput: DataFetcherInput = {
|
||||
apiEndpoint: GITBOOK_API_URL,
|
||||
apiToken: GITBOOK_API_TOKEN,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a data fetcher using an API token.
|
||||
* The data are being cached by Next.js built-in cache.
|
||||
*/
|
||||
export function createDataFetcher(input: DataFetcherInput = commonInput): GitBookDataFetcher {
|
||||
export function createDataFetcher(
|
||||
input: DataFetcherInput = { apiToken: null }
|
||||
): GitBookDataFetcher {
|
||||
return {
|
||||
apiEndpoint: input.apiEndpoint,
|
||||
|
||||
async api() {
|
||||
return getAPI(input);
|
||||
return apiClient(input);
|
||||
},
|
||||
|
||||
withToken({ apiToken }) {
|
||||
return createDataFetcher({
|
||||
...input,
|
||||
apiToken,
|
||||
});
|
||||
},
|
||||
@@ -45,125 +41,165 @@ export function createDataFetcher(input: DataFetcherInput = commonInput): GitBoo
|
||||
// API that are tied to the token
|
||||
//
|
||||
getPublishedContentSite(params) {
|
||||
return getPublishedContentSite(input, {
|
||||
return trace('getPublishedContentSite', () =>
|
||||
getPublishedContentSite(input, {
|
||||
organizationId: params.organizationId,
|
||||
siteId: params.siteId,
|
||||
siteShareKey: params.siteShareKey,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getSiteRedirectBySource(params) {
|
||||
return getSiteRedirectBySource(input, {
|
||||
return trace('getSiteRedirectBySource', () =>
|
||||
getSiteRedirectBySource(input, {
|
||||
organizationId: params.organizationId,
|
||||
siteId: params.siteId,
|
||||
siteShareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevision(params) {
|
||||
return getRevision(input, {
|
||||
return trace('getRevision', () =>
|
||||
getRevision(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionPages(params) {
|
||||
return getRevisionPages(input, {
|
||||
return trace('getRevisionPages', () =>
|
||||
getRevisionPages(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionFile(params) {
|
||||
return getRevisionFile(input, {
|
||||
return trace('getRevisionFile', () =>
|
||||
getRevisionFile(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
fileId: params.fileId,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionPageByPath(params) {
|
||||
return getRevisionPageByPath(input, {
|
||||
return trace('getRevisionPageByPath', () =>
|
||||
getRevisionPageByPath(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
path: params.path,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionPageMarkdown(params) {
|
||||
return trace('getRevisionPageMarkdown', () =>
|
||||
getRevisionPageMarkdown(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
pageId: params.pageId,
|
||||
})
|
||||
);
|
||||
},
|
||||
getReusableContent(params) {
|
||||
return getReusableContent(input, {
|
||||
return trace('getReusableContent', () =>
|
||||
getReusableContent(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
reusableContentId: params.reusableContentId,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getLatestOpenAPISpecVersionContent(params) {
|
||||
return getLatestOpenAPISpecVersionContent(input, {
|
||||
return trace('getLatestOpenAPISpecVersionContent', () =>
|
||||
getLatestOpenAPISpecVersionContent(input, {
|
||||
organizationId: params.organizationId,
|
||||
slug: params.slug,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getSpace(params) {
|
||||
return getSpace(input, {
|
||||
return trace('getSpace', () =>
|
||||
getSpace(input, {
|
||||
spaceId: params.spaceId,
|
||||
shareKey: params.shareKey,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getChangeRequest(params) {
|
||||
return getChangeRequest(input, {
|
||||
return trace('getChangeRequest', () =>
|
||||
getChangeRequest(input, {
|
||||
spaceId: params.spaceId,
|
||||
changeRequestId: params.changeRequestId,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getDocument(params) {
|
||||
return getDocument(input, {
|
||||
return trace('getDocument', () =>
|
||||
getDocument(input, {
|
||||
spaceId: params.spaceId,
|
||||
documentId: params.documentId,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
getComputedDocument(params) {
|
||||
return getComputedDocument(input, {
|
||||
return trace('getComputedDocument', () =>
|
||||
getComputedDocument(input, {
|
||||
organizationId: params.organizationId,
|
||||
spaceId: params.spaceId,
|
||||
source: params.source,
|
||||
});
|
||||
seed: params.seed,
|
||||
})
|
||||
);
|
||||
},
|
||||
getEmbedByUrl(params) {
|
||||
return getEmbedByUrl(input, {
|
||||
return trace('getEmbedByUrl', () =>
|
||||
getEmbedByUrl(input, {
|
||||
url: params.url,
|
||||
spaceId: params.spaceId,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
searchSiteContent(params) {
|
||||
return searchSiteContent(input, params);
|
||||
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 getUserById(commonInput, userId);
|
||||
renderIntegrationUi(params) {
|
||||
return trace('renderIntegrationUi', () =>
|
||||
renderIntegrationUi(input, {
|
||||
integrationName: params.integrationName,
|
||||
request: params.request,
|
||||
})
|
||||
);
|
||||
},
|
||||
getPublishedContentByUrl(params) {
|
||||
return getPublishedContentByUrl(commonInput, {
|
||||
url: params.url,
|
||||
visitorAuthToken: params.visitorAuthToken,
|
||||
redirectOnError: params.redirectOnError,
|
||||
});
|
||||
|
||||
getUserById(userId) {
|
||||
return trace('getUserById', () => getUserById(input, { userId }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserById(input: DataFetcherInput, userId: string) {
|
||||
const getUserById = memoize(async function getUserById(
|
||||
input: DataFetcherInput,
|
||||
params: { userId: string }
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getUserById.uncached', () => {
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).users.getUserById(userId);
|
||||
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;
|
||||
@@ -172,6 +208,7 @@ async function getSpace(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getSpace.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
@@ -181,14 +218,16 @@ async function getSpace(
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getSpaceById(params.spaceId, {
|
||||
shareKey: params.shareKey,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getChangeRequest(
|
||||
const getChangeRequest = memoize(async function getChangeRequest(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -197,10 +236,12 @@ async function getChangeRequest(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getChangeRequest.uncached', () => {
|
||||
cacheLife('minutes');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getChangeRequestById(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getChangeRequestById(
|
||||
params.spaceId,
|
||||
params.changeRequestId
|
||||
);
|
||||
@@ -213,9 +254,10 @@ async function getChangeRequest(
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getRevision(
|
||||
const getRevision = memoize(async function getRevision(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -225,17 +267,20 @@ async function getRevision(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevision.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||
metadata: params.metadata,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getRevisionPages(
|
||||
const getRevisionPages = memoize(async function getRevisionPages(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -245,10 +290,12 @@ async function getRevisionPages(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionPages.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.listPagesInRevisionById(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.listPagesInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
{
|
||||
@@ -257,9 +304,10 @@ async function getRevisionPages(
|
||||
);
|
||||
return res.data.pages;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getRevisionFile(
|
||||
const getRevisionFile = memoize(async function getRevisionFile(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -269,19 +317,56 @@ async function getRevisionFile(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionFile.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getFileInRevisionById(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getFileInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.fileId
|
||||
params.fileId,
|
||||
{}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function getRevisionPageByPath(
|
||||
return res.data.markdown;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -291,21 +376,25 @@ async function getRevisionPageByPath(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionPageByPath.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
const encodedPath = encodeURIComponent(params.path);
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getPageInRevisionByPath(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getPageInRevisionByPath(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
encodedPath
|
||||
encodedPath,
|
||||
{}
|
||||
);
|
||||
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getDocument(
|
||||
const getDocument = memoize(async function getDocument(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -314,24 +403,29 @@ async function getDocument(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getDocument.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getDocumentById(params.spaceId, params.documentId);
|
||||
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';
|
||||
|
||||
return trace('getComputedDocument.uncached', () => {
|
||||
cacheLife('days');
|
||||
|
||||
cacheTag(
|
||||
@@ -345,14 +439,17 @@ async function getComputedDocument(
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
||||
source: params.source,
|
||||
seed: params.seed,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getReusableContent(
|
||||
const getReusableContent = memoize(async function getReusableContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -362,18 +459,22 @@ async function getReusableContent(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getReusableContent.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getReusableContentInRevisionById(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getReusableContentInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.reusableContentId
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const getLatestOpenAPISpecVersionContent = memoize(
|
||||
async function getLatestOpenAPISpecVersionContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
@@ -383,6 +484,7 @@ async function getLatestOpenAPISpecVersionContent(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'openapi',
|
||||
@@ -393,56 +495,18 @@ async function getLatestOpenAPISpecVersionContent(
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.getLatestOpenApiSpecVersionContent(
|
||||
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;
|
||||
|
||||
const hostname = new URL(url).hostname;
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'url',
|
||||
hostname,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).urls.getPublishedContentByUrl({
|
||||
url,
|
||||
visitorAuthToken: visitorAuthToken ?? undefined,
|
||||
redirectOnError,
|
||||
});
|
||||
|
||||
if ('site' in res.data) {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: res.data.site,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
async function getPublishedContentSite(
|
||||
const getPublishedContentSite = memoize(async function getPublishedContentSite(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
@@ -452,16 +516,19 @@ async function getPublishedContentSite(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getPublishedContentSite.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return trace('getPublishedContentSite', () => {
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.getPublishedContentSite(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getPublishedContentSite(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
@@ -470,9 +537,11 @@ async function getPublishedContentSite(
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function getSiteRedirectBySource(
|
||||
const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
@@ -483,6 +552,7 @@ async function getSiteRedirectBySource(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getSiteRedirectBySource.uncached', () => {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
@@ -492,7 +562,8 @@ async function getSiteRedirectBySource(
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.getSiteRedirectBySource(
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getSiteRedirectBySource(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
@@ -503,9 +574,10 @@ async function getSiteRedirectBySource(
|
||||
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getEmbedByUrl(
|
||||
const getEmbedByUrl = memoize(async function getEmbedByUrl(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
url: string;
|
||||
@@ -514,40 +586,100 @@ async function getEmbedByUrl(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getEmbedByUrl.uncached', () => {
|
||||
cacheLife('weeks');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = getAPI(input);
|
||||
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';
|
||||
|
||||
return trace('searchSiteContent.uncached', () => {
|
||||
const { organizationId, siteId, query, scope } = params;
|
||||
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.searchSiteContent(organizationId, siteId, {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
||||
query,
|
||||
...scope,
|
||||
});
|
||||
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;
|
||||
|
||||
/**
|
||||
* Create a new API client.
|
||||
*/
|
||||
export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
|
||||
const { apiToken } = input;
|
||||
let serviceBinding: GitBookAPIServiceBinding | undefined;
|
||||
|
||||
try {
|
||||
// HACK: This is a workaround to avoid webpack trying to bundle this cloudflare only module
|
||||
// @ts-ignore
|
||||
const { env } = await import(
|
||||
/* webpackIgnore: true */ `${'__cloudflare:workers'.replaceAll('_', '')}`
|
||||
);
|
||||
serviceBinding = env.GITBOOK_API;
|
||||
if (!loggedServiceBinding) {
|
||||
loggedServiceBinding = true;
|
||||
if (serviceBinding) {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
||||
console.log(`using service binding for the API (${GITBOOK_API_URL})`);
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
||||
console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getAPI(input: DataFetcherInput) {
|
||||
const { apiEndpoint, apiToken } = input;
|
||||
const api = new GitBookAPI({
|
||||
authToken: apiToken ?? undefined,
|
||||
endpoint: apiEndpoint,
|
||||
authToken: apiToken || GITBOOK_API_TOKEN || undefined,
|
||||
endpoint: GITBOOK_API_URL,
|
||||
userAgent: GITBOOK_USER_AGENT,
|
||||
serviceBinding,
|
||||
});
|
||||
|
||||
return api;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { GitBookAPI, type PublishedSiteContentLookup } from '@gitbook/api';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
@@ -14,40 +15,40 @@ 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 = new GitBookAPI({
|
||||
authToken: GITBOOK_API_TOKEN ?? undefined,
|
||||
endpoint: GITBOOK_API_URL,
|
||||
userAgent: GITBOOK_USER_AGENT,
|
||||
});
|
||||
const api = await apiClient({ apiToken: input.apiToken });
|
||||
|
||||
const startTime = performance.now();
|
||||
const callResult = await tryCatch(
|
||||
const callResult = await trace(
|
||||
{
|
||||
operation: 'getPublishedContentByURL',
|
||||
name: alternative.url,
|
||||
},
|
||||
() =>
|
||||
tryCatch(
|
||||
api.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url: alternative.url,
|
||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||
redirectOnError: input.redirectOnError,
|
||||
cache: true,
|
||||
|
||||
// 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,
|
||||
headers: {
|
||||
'x-gitbook-force-cache': 'true',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
const endTime = performance.now();
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log performance data
|
||||
console.log(
|
||||
`getPublishedContentByURL(${alternative.url}) Time taken: ${endTime - startTime}ms`
|
||||
)
|
||||
);
|
||||
|
||||
if (callResult.error) {
|
||||
@@ -101,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;
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ export type DataFetcherResponse<T> =
|
||||
* It is used between v1 and v2.
|
||||
*/
|
||||
export interface GitBookDataFetcher {
|
||||
/**
|
||||
* Endpoint of the API.
|
||||
*/
|
||||
apiEndpoint: string;
|
||||
|
||||
/**
|
||||
* Get an API client for the current context.
|
||||
*/
|
||||
@@ -42,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.
|
||||
*/
|
||||
@@ -111,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.
|
||||
*/
|
||||
@@ -125,6 +120,7 @@ export interface GitBookDataFetcher {
|
||||
organizationId: string;
|
||||
spaceId: string;
|
||||
source: api.ComputedContentSource;
|
||||
seed: string;
|
||||
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
||||
|
||||
/**
|
||||
@@ -175,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>>;
|
||||
}
|
||||
|
||||
+10
@@ -34,6 +34,11 @@ export const GITBOOK_APP_URL = process.env.GITBOOK_APP_URL || 'https://app.gitbo
|
||||
*/
|
||||
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL || 'https://api.gitbook.com';
|
||||
|
||||
/**
|
||||
* Default GitBook API URL endpoint, to be shared with the client.
|
||||
*/
|
||||
export const GITBOOK_API_PUBLIC_URL = process.env.GITBOOK_API_PUBLIC_URL || GITBOOK_API_URL;
|
||||
|
||||
/**
|
||||
* Default GitBook API token.
|
||||
* It can be use to avoid rate-limiting.
|
||||
@@ -59,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)
|
||||
|
||||
@@ -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', () => {
|
||||
describe('toLinkForContent', () => {
|
||||
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'
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,35 +86,46 @@ 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(
|
||||
const withAPIToken = async (apiToken: string | null) => {
|
||||
const siteURLData = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
url: siteURL.toString(),
|
||||
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',
|
||||
|
||||
// Use the API token passed in the request, if any
|
||||
// as it could be used for .preview hostnames
|
||||
apiToken,
|
||||
})
|
||||
);
|
||||
let cookies: ResponseCookies = {};
|
||||
const cookies: ResponseCookies = [];
|
||||
|
||||
//
|
||||
// Handle redirects
|
||||
//
|
||||
if ('redirect' in data) {
|
||||
if ('redirect' in siteURLData) {
|
||||
// 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,
|
||||
});
|
||||
console.log('redirect', siteURLData.redirect);
|
||||
if (siteURLData.target === 'content') {
|
||||
let contentRedirect = new URL(siteURLData.redirect, request.url);
|
||||
|
||||
const contentRedirect = new URL(linker.toLinkForContent(data.redirect), request.url);
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the same search params as the original request
|
||||
// as it might contain a VA token
|
||||
@@ -120,20 +134,25 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
return NextResponse.redirect(contentRedirect);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(data.redirect);
|
||||
return NextResponse.redirect(siteURLData.redirect);
|
||||
}
|
||||
|
||||
cookies = {
|
||||
...cookies,
|
||||
...getResponseCookiesForVisitorAuth(data.basePath, visitorToken),
|
||||
};
|
||||
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, 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 requestURLWithoutToken = normalizeVisitorAuthURL(requestURL);
|
||||
if (requestURLWithoutToken.toString() !== requestURL.toString()) {
|
||||
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
|
||||
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||
if (
|
||||
requestURLWithoutToken !== incomingURL &&
|
||||
requestURLWithoutToken.toString() !== incomingURL.toString()
|
||||
) {
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
||||
cookies
|
||||
@@ -144,22 +163,26 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
// Render and serve the content
|
||||
//
|
||||
|
||||
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
|
||||
let routeType = visitorToken ? 'dynamic' : 'static';
|
||||
// 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, `${siteURL.origin}${data.basePath}`);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
|
||||
requestHeaders.set(
|
||||
MiddlewareHeaders.SiteURL,
|
||||
`${siteCanonicalURL.origin}${siteURLData.basePath}`
|
||||
);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
||||
|
||||
// Preview of customization/theme
|
||||
const customization = siteURL.searchParams.get('customization');
|
||||
const customization = siteRequestURL.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
}
|
||||
const theme = siteURL.searchParams.get('theme');
|
||||
const theme = siteRequestURL.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||
@@ -175,15 +198,44 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
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);
|
||||
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('/');
|
||||
|
||||
@@ -207,51 +259,34 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
|
||||
|
||||
return writeResponseCookies(response, 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
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = request.cookies.get(cookieName)?.value;
|
||||
if (!apiToken) {
|
||||
throw new DataFetcherError('Missing API token', 400);
|
||||
}
|
||||
|
||||
return serveWithQueryAPIToken(
|
||||
pathnameParts.slice(0, 2).join('/'),
|
||||
request,
|
||||
async (apiToken) => {
|
||||
// Handle the rest with the router default logic
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
@@ -259,6 +294,8 @@ async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve an error response.
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
main = ".open-next/worker.js"
|
||||
name = "gitbook-open-v2"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
compatibility_date = "2025-03-11"
|
||||
compatibility_flags = ["nodejs_compat", "allow_importable_env"]
|
||||
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
||||
observability = { enabled = true }
|
||||
|
||||
@@ -12,6 +12,10 @@ kv_namespaces = [
|
||||
d1_databases = [
|
||||
{ binding = "NEXT_CACHE_D1", database_id = "f59ddb40-ad72-4312-9395-0ac6a129af8e", database_name = "gitbook-open-v2-tags-preview" }
|
||||
]
|
||||
services = [
|
||||
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-preview" },
|
||||
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
|
||||
]
|
||||
|
||||
[env.staging]
|
||||
routes = [
|
||||
@@ -24,6 +28,10 @@ kv_namespaces = [
|
||||
d1_databases = [
|
||||
{ binding = "NEXT_CACHE_D1", database_id = "9df62e39-1f35-4066-83aa-e9b8ed3ac8d5", database_name = "gitbook-open-v2-tags-staging" }
|
||||
]
|
||||
services = [
|
||||
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-staging" },
|
||||
{ binding = "GITBOOK_API", service = "gitbook-x-staging-api-cache" }
|
||||
]
|
||||
|
||||
[env.production]
|
||||
routes = [
|
||||
@@ -36,3 +44,7 @@ kv_namespaces = [
|
||||
d1_databases = [
|
||||
{ binding = "NEXT_CACHE_D1", database_id = "a6f16fce-5f45-43a9-89a4-7b83ddf25b77", database_name = "gitbook-open-v2-tags-production" }
|
||||
]
|
||||
services = [
|
||||
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-production" },
|
||||
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
|
||||
]
|
||||
|
||||
@@ -23,8 +23,5 @@
|
||||
## Caching
|
||||
# GITBOOK_OUTPUT_CACHE=true
|
||||
|
||||
### Sentry ###
|
||||
# SENTRY_DSN=xxx
|
||||
|
||||
### Silent logs
|
||||
# SILENT=true
|
||||
@@ -23,9 +23,6 @@ next-env.d.ts
|
||||
# visual tests
|
||||
screenshots/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
@@ -1,5 +1,125 @@
|
||||
# gitbook
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- da7b369: Fix missing headers in OpenAPIResponses
|
||||
- 139a805: Fix OpenAPI enum display
|
||||
- Updated dependencies [da7b369]
|
||||
- Updated dependencies [da485f5]
|
||||
- Updated dependencies [139a805]
|
||||
- @gitbook/react-openapi@1.1.9
|
||||
|
||||
## 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
|
||||
|
||||
- eec3eed: Add styling for prominent search bar option
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 16292de: Display sidebar on no-TOC pages
|
||||
- 9b5f971: Transparent background for OpenAPI path block
|
||||
- 99da8df: Optimize favicons and og:image using the image resizer
|
||||
- b011ea0: Fix rendering of code blocks in Ask AI when being streamed
|
||||
- 9bc3d50: Info hint background and link color fixes
|
||||
- 31d800e: Render OpenAPISchemas block
|
||||
- Updated dependencies [6aaeae2]
|
||||
- Updated dependencies [c60e9ba]
|
||||
- Updated dependencies [9108c56]
|
||||
- Updated dependencies [31d800e]
|
||||
- Updated dependencies [ff3b708]
|
||||
- Updated dependencies [f32bf1f]
|
||||
- Updated dependencies [c9ea239]
|
||||
- @gitbook/react-contentkit@0.7.0
|
||||
- @gitbook/react-openapi@1.1.4
|
||||
- @gitbook/cache-tags@0.2.0
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type TestsCase,
|
||||
allDeprecatedThemePresets,
|
||||
allLocales,
|
||||
allSearchStyles,
|
||||
allSidebarBackgroundStyles,
|
||||
allThemeModes,
|
||||
allThemes,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
@@ -307,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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -366,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/',
|
||||
@@ -385,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)',
|
||||
@@ -396,7 +449,6 @@ const testCases: TestsCase[] = [
|
||||
await zoomImage.first().click();
|
||||
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
|
||||
},
|
||||
fullPage: true,
|
||||
screenshot: { threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
@@ -431,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',
|
||||
@@ -647,6 +704,23 @@ const testCases: TestsCase[] = [
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
]),
|
||||
...allSearchStyles.flatMap((searchStyle) => ({
|
||||
name: `Theme ${theme} – Search ${searchStyle} – Mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
theme,
|
||||
search: searchStyle,
|
||||
},
|
||||
header: {
|
||||
links: headerLinks,
|
||||
},
|
||||
themes: {
|
||||
default: themeMode,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
]),
|
||||
// Deprecated header themes
|
||||
...allDeprecatedThemePresets.flatMap((preset) => [
|
||||
@@ -763,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/',
|
||||
|
||||
@@ -2,12 +2,13 @@ import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationFont,
|
||||
CustomizationDefaultFont,
|
||||
type CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLinksStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationSearchStyle,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationTheme,
|
||||
@@ -15,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';
|
||||
@@ -32,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.
|
||||
*/
|
||||
@@ -62,6 +63,7 @@ export interface Test {
|
||||
|
||||
export type TestsCase = {
|
||||
name: string;
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
contentBaseURL?: string;
|
||||
};
|
||||
@@ -109,6 +111,11 @@ export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] =
|
||||
CustomizationSidebarBackgroundStyle.Filled,
|
||||
];
|
||||
|
||||
export const allSearchStyles: CustomizationSearchStyle[] = [
|
||||
CustomizationSearchStyle.Prominent,
|
||||
CustomizationSearchStyle.Subtle,
|
||||
];
|
||||
|
||||
// Common customization settings
|
||||
|
||||
export const headerLinks: CustomizationHeaderItem[] = [
|
||||
@@ -127,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,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;
|
||||
@@ -175,9 +188,9 @@ 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) {
|
||||
@@ -191,7 +204,8 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
`,
|
||||
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);
|
||||
@@ -249,7 +263,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
|
||||
successColor: { light: '#00C950', dark: '#00C950' },
|
||||
corners: CustomizationCorners.Rounded,
|
||||
font: CustomizationFont.Inter,
|
||||
font: CustomizationDefaultFont.Inter,
|
||||
background: CustomizationBackground.Plain,
|
||||
icons: CustomizationIconsStyle.Regular,
|
||||
links: CustomizationLinksStyle.Default,
|
||||
@@ -257,6 +271,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
background: CustomizationSidebarBackgroundStyle.Default,
|
||||
list: CustomizationSidebarListStyle.Default,
|
||||
},
|
||||
search: CustomizationSearchStyle.Subtle,
|
||||
},
|
||||
internationalization: {
|
||||
locale: CustomizationLocale.En,
|
||||
@@ -312,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'));
|
||||
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;
|
||||
}
|
||||
|
||||
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 is invalid we throw an error.
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
await loadImage(url);
|
||||
})
|
||||
);
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
const { withSentryConfig } = require('@sentry/nextjs');
|
||||
|
||||
module.exports = withSentryConfig(
|
||||
{
|
||||
module.exports = {
|
||||
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 }) {
|
||||
webpack(config) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
@@ -23,21 +17,6 @@ module.exports = withSentryConfig(
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -67,17 +46,4 @@ module.exports = withSentryConfig(
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.7.3",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "0.96.1",
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
@@ -29,25 +29,22 @@
|
||||
"@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",
|
||||
"ajv": "^8.12.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"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 +54,30 @@
|
||||
"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": "^1.27.2",
|
||||
"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"
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.24.5",
|
||||
"event-iterator": "^2.0.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"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 +95,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,8 @@ export default defineConfig({
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,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,28 +1,16 @@
|
||||
'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 { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
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(
|
||||
@@ -47,9 +35,8 @@ export default function ErrorPage(props: {
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
>
|
||||
{t(language, 'unexpected_error_retry')}
|
||||
</Button>
|
||||
label={tString(language, 'unexpected_error_retry')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-ignore
|
||||
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
||||
|
||||
import { withResponseCacheTags } from './lib/cache/response';
|
||||
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||
|
||||
/**
|
||||
@@ -9,8 +10,8 @@ import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const response = await withMiddlewareHeadersStorage(() =>
|
||||
nextOnPagesHandler.fetch(request, env, ctx)
|
||||
const response = await withResponseCacheTags(() =>
|
||||
withMiddlewareHeadersStorage(() => nextOnPagesHandler.fetch(request, env, ctx))
|
||||
);
|
||||
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loading } from '../primitives';
|
||||
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
|
||||
|
||||
/**
|
||||
* Summarise a page's content for use in a link preview
|
||||
*/
|
||||
export function AIPageLinkSummary(props: {
|
||||
currentSpaceId: string;
|
||||
currentPageId: string;
|
||||
currentPageTitle: string;
|
||||
targetSpaceId: string;
|
||||
targetPageId: string;
|
||||
linkPreview?: string;
|
||||
linkTitle?: string;
|
||||
showTrademark: boolean;
|
||||
}) {
|
||||
const {
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
showTrademark = true,
|
||||
} = props;
|
||||
|
||||
const [highlight, setHighlight] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
setHighlight('');
|
||||
|
||||
(async () => {
|
||||
const stream = await streamLinkPageSummary({
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
previousPageIds: [],
|
||||
});
|
||||
|
||||
for await (const highlight of stream) {
|
||||
if (canceled) return;
|
||||
setHighlight(highlight ?? '');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [currentSpaceId, currentPageId, targetSpaceId, targetPageId, linkPreview, linkTitle]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||
{showTrademark ? (
|
||||
<Loading className="size-4" busy={!highlight || highlight.length === 0} />
|
||||
) : (
|
||||
<Icon icon="sparkle" className="size-3" />
|
||||
)}
|
||||
<h6 className="text-tint">Page highlight</h6>
|
||||
</div>
|
||||
{highlight.length > 0 ? <p>{highlight}</p> : null}
|
||||
{highlight.length > 0 ? (
|
||||
<div className="text-tint-subtle text-xs">
|
||||
Based on your context. May contain mistakes.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AIPageLinkSummary';
|
||||
@@ -0,0 +1,124 @@
|
||||
'use server';
|
||||
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
|
||||
import type { GitBookBaseContext } from '@v2/lib/context';
|
||||
import { EventIterator } from 'event-iterator';
|
||||
import type { MaybePromise } from 'p-map';
|
||||
import * as partialJson from 'partial-json';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
import type { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
/**
|
||||
* Get the latest value from a stream and the response id.
|
||||
*/
|
||||
export async function generate<T>(
|
||||
promise: MaybePromise<{
|
||||
stream: EventIterator<T>;
|
||||
response: Promise<{ responseId: string }>;
|
||||
}>
|
||||
) {
|
||||
const input = await promise;
|
||||
let value: T | undefined;
|
||||
|
||||
for await (const event of input.stream) {
|
||||
value = event;
|
||||
}
|
||||
|
||||
const { responseId } = await input.response;
|
||||
return {
|
||||
responseId,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the generation of an object using the AI.
|
||||
*/
|
||||
export async function streamGenerateObject<T>(
|
||||
context: GitBookBaseContext,
|
||||
{
|
||||
organizationId,
|
||||
siteId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
},
|
||||
{
|
||||
schema,
|
||||
messages,
|
||||
model = AIModel.Fast,
|
||||
}: {
|
||||
schema: z.ZodSchema<T>;
|
||||
messages: AIMessageInput[];
|
||||
model?: AIModel;
|
||||
previousResponseId?: string;
|
||||
}
|
||||
) {
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
|
||||
const rawStream = apiClient.orgs.streamAiResponseInSite(organizationId, siteId, {
|
||||
input: messages,
|
||||
output: {
|
||||
type: 'object',
|
||||
schema: zodToJsonSchema(schema),
|
||||
},
|
||||
model,
|
||||
});
|
||||
|
||||
let json = '';
|
||||
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
|
||||
if (event.type === 'response_object') {
|
||||
json += event.jsonChunk;
|
||||
|
||||
const parsed = partialJson.parse(json, partialJson.ALL);
|
||||
return parsed;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stream from the API to extract the responseId.
|
||||
*/
|
||||
function parseResponse<T>(
|
||||
responseStream: EventIterator<AIStreamResponse>,
|
||||
parse: (response: AIStreamResponse) => T | undefined
|
||||
): {
|
||||
stream: EventIterator<T>;
|
||||
response: Promise<{ responseId: string }>;
|
||||
} {
|
||||
let resolveResponse: (value: { responseId: string }) => void;
|
||||
const response = new Promise<{ responseId: string }>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
|
||||
const stream = new EventIterator<T>((queue) => {
|
||||
(async () => {
|
||||
let foundResponse = false;
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (event.type === 'response_finish') {
|
||||
foundResponse = true;
|
||||
resolveResponse({ responseId: event.responseId });
|
||||
} else {
|
||||
const parsed = parse(event);
|
||||
if (parsed !== undefined) {
|
||||
queue.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundResponse) {
|
||||
throw new Error('No response found');
|
||||
}
|
||||
})().then(
|
||||
() => {
|
||||
queue.stop();
|
||||
},
|
||||
(error) => {
|
||||
queue.fail(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return { stream, response };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './streamLinkPageSummary';
|
||||
@@ -0,0 +1,127 @@
|
||||
'use server';
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import { isV2 } from '@/lib/v2';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
import { z } from 'zod';
|
||||
import { streamGenerateObject } from './api';
|
||||
|
||||
/**
|
||||
* Get a summary of a page, in the context of another page
|
||||
*/
|
||||
export async function* streamLinkPageSummary({
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
}: {
|
||||
currentSpaceId: string;
|
||||
currentPageId: string;
|
||||
targetSpaceId: string;
|
||||
targetPageId: string;
|
||||
linkPreview?: string;
|
||||
linkTitle?: string;
|
||||
previousPageIds?: string[];
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const [{ stream }] = await Promise.all([
|
||||
streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
highlight: z.string().describe('The most important content of the target page'),
|
||||
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||
}),
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# Role
|
||||
You are a documentation navigator. Your job is to help the user read documentation more efficiently. Your aim is to prevent the user from having to read the target page by giving them all the information they need to know.
|
||||
|
||||
# Task
|
||||
Using both the current page context and the target page content, produce a page highlight that:
|
||||
- Highlights the key facts from the target page.
|
||||
- Relates strongly to the topic the user is currently reading about.
|
||||
- Is very succinct and direct, using only one or two short sentences (each sentence using no more than one comma).
|
||||
- Remains strictly factual, without referring to “the page”.
|
||||
|
||||
# Instructions
|
||||
1. Identify the key paragraph surrounding the link's text (e.g., “change request”) from the current page.
|
||||
2. Extract and combine relevant information from the target page to address the link's context.
|
||||
3. Combine in one or two short sentences that are direct and brief.
|
||||
|
||||
# Examples
|
||||
## Example 1
|
||||
- Link context: “This feature is only available on the Ultimate plan.”
|
||||
- Link preview: “Pricing: Learn about our different pricing tiers.”
|
||||
- Response: “The Ultimate plan costs $25 per month. A Pro plan is available too.”
|
||||
|
||||
## Example 2
|
||||
- Link context: “You can use keyboard shortcuts to get to the Search menu faster.”
|
||||
- Link preview: “Keyboard shortcuts: A quick reference guide to all the keyboard shortcuts available.”
|
||||
- Response: “To open the Search menu, use the keyboard shortcut ⌘K or Ctrl+K.”
|
||||
|
||||
## Example 3
|
||||
- Link context: “This feature can only be enabled by an admin.”
|
||||
- Link preview: “Roles: An overview of the different roles on the platform.”
|
||||
- Response: “The admin role is reserved for the creator of the organisation.”`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# Context
|
||||
## Current page
|
||||
The content of the current page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page',
|
||||
spaceId: currentSpaceId,
|
||||
pageId: currentPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Target page
|
||||
The content of the target page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page',
|
||||
spaceId: targetSpaceId,
|
||||
pageId: targetPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Link preview
|
||||
The content of the link preview is:
|
||||
> ${linkPreview}`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: `I'm considering reading the link titled "${linkTitle}" to page ID ${targetPageId}. Give the most relevant information from this page. Relate it to my current page and in particular the paragraph I'm currently reading. Be very concise.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
fetchServerActionSiteContext(baseContext),
|
||||
]);
|
||||
|
||||
for await (const value of stream) {
|
||||
const highlight = value.highlight;
|
||||
if (!highlight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield highlight;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
onClick={() => {
|
||||
onUpdateState(true);
|
||||
}}
|
||||
>
|
||||
{t(language, 'cookies_accept')}
|
||||
</Button>
|
||||
label={tString(language, 'cookies_accept')}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
onClick={() => {
|
||||
onUpdateState(false);
|
||||
}}
|
||||
>
|
||||
{t(language, 'cookies_reject')}
|
||||
</Button>
|
||||
label={tString(language, 'cookies_reject')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
|
||||
import { List } from './List';
|
||||
import { ListItem } from './ListItem';
|
||||
import { BlockMath } from './Math';
|
||||
import { OpenAPI } from './OpenAPI';
|
||||
import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
|
||||
import { Paragraph } from './Paragraph';
|
||||
import { Quote } from './Quote';
|
||||
import { ReusableContent } from './ReusableContent';
|
||||
@@ -82,7 +82,9 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
return <Table {...props} block={block} />;
|
||||
case 'swagger':
|
||||
case 'openapi-operation':
|
||||
return <OpenAPI {...props} block={block} />;
|
||||
return <OpenAPIOperation {...props} block={block} />;
|
||||
case 'openapi-schemas':
|
||||
return <OpenAPISchemas {...props} block={block} />;
|
||||
case 'embed':
|
||||
return <Embed {...props} block={block} />;
|
||||
case 'blockquote':
|
||||
@@ -156,6 +158,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'table':
|
||||
case 'swagger':
|
||||
case 'openapi-operation':
|
||||
case 'openapi-schemas':
|
||||
case 'math':
|
||||
case 'divider':
|
||||
case 'content-ref':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import type { HighlightLine, RenderedInline } from './highlight';
|
||||
@@ -22,53 +22,82 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const processedRef = useRef(false);
|
||||
const isInViewportRef = useRef<boolean | null>(null);
|
||||
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block, []));
|
||||
const isInViewportRef = useRef(false);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
||||
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
||||
|
||||
// Preload the highlighter when the block is mounted.
|
||||
useEffect(() => {
|
||||
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
|
||||
}, [block]);
|
||||
|
||||
const runHighlight = useEventCallback(() => {
|
||||
if (processedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
import('./highlight').then(({ highlight }) => {
|
||||
highlight(block, inlines).then((lines) => {
|
||||
setLines(lines);
|
||||
processedRef.current = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
const debouncedRunHighlight = useDebounceCallback(runHighlight, 1000);
|
||||
// When user scrolls, we need to wait for the scroll to finish before running the highlight
|
||||
const isScrollingRef = useRef(false);
|
||||
const onFinishScrolling = useDebounceCallback(() => {
|
||||
isScrollingRef.current = false;
|
||||
|
||||
// If the block is in the viewport after the scroll, we need to run the highlight
|
||||
if (isInViewportRef.current) {
|
||||
setIsInViewport(true);
|
||||
}
|
||||
}, 100);
|
||||
useScrollListener(
|
||||
() => {
|
||||
isScrollingRef.current = true;
|
||||
onFinishScrolling();
|
||||
},
|
||||
useRef(typeof window !== 'undefined' ? window : null)
|
||||
);
|
||||
|
||||
// Detect when the block is in viewport
|
||||
useInViewportListener(
|
||||
blockRef,
|
||||
(isInViewport, disconnect) => {
|
||||
// Disconnect once in viewport
|
||||
if (isInViewport) {
|
||||
disconnect();
|
||||
// If it's initially in viewport, we need to run the highlight
|
||||
if (isInViewportRef.current === null) {
|
||||
runHighlight();
|
||||
}
|
||||
}
|
||||
isInViewportRef.current = isInViewport;
|
||||
if (isScrollingRef.current) {
|
||||
// If the user is scrolling, we don't want to run the highlight
|
||||
// because it will be run when the scroll is finished
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInViewport) {
|
||||
// Disconnect once in viewport
|
||||
disconnect();
|
||||
setIsInViewport(true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
const handleScroll = useDebounceCallback(() => {
|
||||
if (isInViewportRef.current) {
|
||||
debouncedRunHighlight();
|
||||
}
|
||||
}, 80);
|
||||
// When the block is visible or updated, we need to re-run the highlight
|
||||
useEffect(() => {
|
||||
if (isInViewport) {
|
||||
// If the block is in viewport, we need to run the highlight
|
||||
let cancelled = false;
|
||||
|
||||
useScrollListener(handleScroll, useRef(typeof window !== 'undefined' ? window : null));
|
||||
|
||||
return <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines} />;
|
||||
if (typeof window !== 'undefined') {
|
||||
import('./highlight').then(({ highlight }) => {
|
||||
highlight(block, inlines).then((lines) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLines(lines);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise if the block is not in viewport, we reset to the plain lines
|
||||
setLines(null);
|
||||
}, [isInViewport, block, inlines]);
|
||||
|
||||
return (
|
||||
<CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines ?? plainLines} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ it('should parse plain code', async () => {
|
||||
|
||||
it('should parse different code in parallel', async () => {
|
||||
await Promise.all(
|
||||
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
|
||||
['shell', 'scss', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
|
||||
highlight(
|
||||
{
|
||||
object: 'block',
|
||||
|
||||
@@ -35,14 +35,11 @@ export type RenderedInline = {
|
||||
|
||||
const theme = createCssVariablesTheme();
|
||||
|
||||
const highlighter = createSingletonShorthands(
|
||||
const { getSingletonHighlighter } = createSingletonShorthands(
|
||||
createdBundledHighlighter<any, any>({
|
||||
langs: bundledLanguages,
|
||||
themes: {},
|
||||
engine: () =>
|
||||
createJavaScriptRegexEngine({
|
||||
forgiving: true,
|
||||
}),
|
||||
engine: () => createJavaScriptRegexEngine({ forgiving: true, target: 'ES2018' }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -52,7 +49,7 @@ const highlighter = createSingletonShorthands(
|
||||
export async function preloadHighlight(block: DocumentBlockCode) {
|
||||
const langName = getBlockLang(block);
|
||||
if (langName) {
|
||||
await highlighter.getSingletonHighlighter({
|
||||
await getSingletonHighlighter({
|
||||
langs: [langName],
|
||||
themes: [theme],
|
||||
});
|
||||
@@ -74,7 +71,12 @@ export async function highlight(
|
||||
|
||||
const code = getPlainCodeBlock(block);
|
||||
|
||||
const lines = await highlighter.codeToTokensBase(code, {
|
||||
const highlighter = await getSingletonHighlighter({
|
||||
langs: [langName],
|
||||
themes: [theme],
|
||||
});
|
||||
|
||||
const lines = highlighter.codeToTokensBase(code, {
|
||||
lang: langName,
|
||||
theme,
|
||||
tokenizeMaxLineLength: 400,
|
||||
@@ -132,12 +134,6 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
|
||||
// Normalize the syntax to lowercase.
|
||||
syntax = syntax.toLowerCase();
|
||||
|
||||
// Temporary disable highlighting for C/C++ code blocks
|
||||
// @see https://github.com/shikijs/shiki/issues/893
|
||||
if (syntax === 'cpp' || syntax === 'c') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the syntax is a bundled language.
|
||||
if (checkIsBundledLanguage(syntax)) {
|
||||
return syntax;
|
||||
|
||||
@@ -104,8 +104,8 @@ const HINT_STYLES: {
|
||||
'[&_.can-override-text]:text-neutral-strong',
|
||||
],
|
||||
container:
|
||||
'bg-neutral theme-muted:bg-neutral-base theme-bold-tint:bg-neutral-base theme-gradient:bg-neutral-12/1 border-neutral',
|
||||
containerWithHeader: 'border-info-solid bg-neutral-subtle theme-gradient:bg-neutral-12/1',
|
||||
'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: {
|
||||
icon: 'circle-exclamation',
|
||||
@@ -113,11 +113,13 @@ const HINT_STYLES: {
|
||||
header: 'bg-warning-active',
|
||||
body: [
|
||||
'text-neutral-strong',
|
||||
'[&_a]:text-warning',
|
||||
'[&_a:hover]:text-warning-strong',
|
||||
'links-default:[&_a]:text-warning',
|
||||
'links-default:[&_a:hover]:text-warning-strong',
|
||||
'links-default:[&_a]:decoration-warning/6',
|
||||
'links-accent:[&_a]:decoration-warning',
|
||||
'decoration-warning/6',
|
||||
'[&_.can-override-bg]:bg-warning-active',
|
||||
'[&_.can-override-text]:text-warning-strong',
|
||||
'decoration-warning/6',
|
||||
],
|
||||
container: 'bg-warning border-warning',
|
||||
containerWithHeader: 'border-warning-solid bg-warning-subtle',
|
||||
@@ -128,11 +130,13 @@ const HINT_STYLES: {
|
||||
header: 'bg-danger-active',
|
||||
body: [
|
||||
'text-neutral-strong',
|
||||
'[&_a]:text-danger',
|
||||
'[&_a:hover]:text-danger-strong',
|
||||
'links-default:[&_a]:text-danger',
|
||||
'links-default:[&_a:hover]:text-danger-strong',
|
||||
'links-default:[&_a]:decoration-danger/6',
|
||||
'links-accent:[&_a]:decoration-danger',
|
||||
'decoration-danger/6',
|
||||
'[&_.can-override-bg]:bg-danger-active',
|
||||
'[&_.can-override-text]:text-danger-strong',
|
||||
'decoration-danger/6',
|
||||
],
|
||||
container: 'bg-danger border-danger',
|
||||
containerWithHeader: 'border-danger-solid bg-danger-subtle',
|
||||
@@ -143,11 +147,13 @@ const HINT_STYLES: {
|
||||
header: 'bg-success-active',
|
||||
body: [
|
||||
'text-neutral-strong',
|
||||
'[&_a]:text-success',
|
||||
'[&_a:hover]:text-success-strong',
|
||||
'links-default:[&_a]:text-success',
|
||||
'links-default:[&_a:hover]:text-success-strong',
|
||||
'links-default:[&_a]:decoration-success/6',
|
||||
'links-accent:[&_a]:decoration-success',
|
||||
'decoration-success/6',
|
||||
'[&_.can-override-bg]:bg-success-active',
|
||||
'[&_.can-override-text]:text-success-strong',
|
||||
'decoration-success/6',
|
||||
],
|
||||
container: 'bg-success border-success',
|
||||
containerWithHeader: 'border-success-solid bg-success-subtle',
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { StyledLink } from '../primitives';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { InlineLinkTooltip } from './InlineLinkTooltip';
|
||||
import { Inlines } from './Inlines';
|
||||
|
||||
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
const { inline, document, context, ancestorInlines } = props;
|
||||
|
||||
const resolved = context.contentContext
|
||||
? await resolveContentRef(inline.data.ref, context.contentContext)
|
||||
? await resolveContentRef(inline.data.ref, context.contentContext, {
|
||||
resolveAnchorText: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!resolved) {
|
||||
if (!context.contentContext || !resolved) {
|
||||
return (
|
||||
<span title="Broken link" className="underline">
|
||||
<Inlines
|
||||
@@ -25,8 +28,15 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isExternal = inline.data.ref.kind === 'url';
|
||||
|
||||
return (
|
||||
<InlineLinkTooltip
|
||||
inline={inline}
|
||||
document={document}
|
||||
context={context}
|
||||
ancestorInlines={ancestorInlines}
|
||||
>
|
||||
<StyledLink
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
@@ -43,6 +53,13 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
nodes={inline.nodes}
|
||||
ancestorInlines={[...ancestorInlines, inline]}
|
||||
/>
|
||||
{isExternal ? (
|
||||
<Icon
|
||||
icon="arrow-up-right"
|
||||
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
|
||||
/>
|
||||
) : null}
|
||||
</StyledLink>
|
||||
</InlineLinkTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { DocumentInlineLink } from '@gitbook/api';
|
||||
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { Fragment } from 'react';
|
||||
import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary';
|
||||
import { Button, StyledLink } from '../primitives';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { Inlines } from './Inlines';
|
||||
|
||||
export async function InlineLinkTooltip(
|
||||
props: InlineProps<DocumentInlineLink> & { children: React.ReactNode }
|
||||
) {
|
||||
const { inline, document, context, ancestorInlines, children } = props;
|
||||
|
||||
const resolved = context.contentContext
|
||||
? await resolveContentRef(inline.data.ref, context.contentContext, {
|
||||
resolveAnchorText: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!context.contentContext || !resolved) {
|
||||
return (
|
||||
<span title="Broken link" className="underline">
|
||||
<Inlines
|
||||
context={context}
|
||||
document={document}
|
||||
nodes={inline.nodes}
|
||||
ancestorInlines={[...ancestorInlines, inline]}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let breadcrumbs = resolved.ancestors;
|
||||
const isExternal = inline.data.ref.kind === 'url';
|
||||
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
|
||||
if (isExternal) {
|
||||
breadcrumbs = [
|
||||
{
|
||||
label: 'External link to',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (isSamePage) {
|
||||
breadcrumbs = [
|
||||
{
|
||||
label: 'Jump to section',
|
||||
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
|
||||
},
|
||||
];
|
||||
resolved.subText = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
|
||||
<div className="overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
|
||||
<div className="bg-tint-base p-4">
|
||||
<div className={tcls('flex gap-4')}>
|
||||
<div className="flex flex-col">
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const Tag = crumb.href ? StyledLink : 'div';
|
||||
|
||||
return (
|
||||
<Fragment key={crumb.label}>
|
||||
{index !== 0 ? (
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 text-tint-subtle"
|
||||
/>
|
||||
) : null}
|
||||
<Tag
|
||||
className={tcls(
|
||||
'flex gap-1',
|
||||
crumb.href &&
|
||||
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||
)}
|
||||
href={crumb.href ?? '#'}
|
||||
>
|
||||
{crumb.icon ? (
|
||||
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||
{crumb.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{crumb.label}
|
||||
</Tag>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-2 leading-snug',
|
||||
isExternal && 'text-sm [overflow-wrap:anywhere]'
|
||||
)}
|
||||
>
|
||||
{resolved.icon ? (
|
||||
<div className="mt-1 text-tint-subtle empty:hidden">
|
||||
{resolved.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h5 className="font-semibold">{resolved.text}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{!isSamePage && resolved.href ? (
|
||||
<Button
|
||||
className={tcls(
|
||||
'-mx-2 -my-2 ml-auto',
|
||||
breadcrumbs?.length === 0
|
||||
? null
|
||||
: 'place-self-start'
|
||||
)}
|
||||
variant="blank"
|
||||
href={resolved.href}
|
||||
target="_blank"
|
||||
label="Open in new tab"
|
||||
size="small"
|
||||
icon="arrow-up-right-from-square"
|
||||
iconOnly={true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{resolved.subText ? (
|
||||
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{'customization' in context.contentContext &&
|
||||
context.contentContext.customization.ai?.pageLinkSummaries.enabled &&
|
||||
!isExternal &&
|
||||
'page' in context.contentContext &&
|
||||
inline.data.ref.kind === 'page' ? (
|
||||
<div className="border-tint-subtle border-t bg-tint p-4">
|
||||
<AIPageLinkSummary
|
||||
currentPageId={context.contentContext.page.id}
|
||||
currentSpaceId={context.contentContext.space.id}
|
||||
currentPageTitle={context.contentContext.page.title}
|
||||
targetPageId={
|
||||
inline.data.ref.page ?? context.contentContext.page.id
|
||||
}
|
||||
targetSpaceId={
|
||||
inline.data.ref.space ?? context.contentContext.space.id
|
||||
}
|
||||
linkTitle={inline.nodes
|
||||
.map((node) => {
|
||||
if (node.object === 'text') {
|
||||
return node.leaves
|
||||
.map((leaf) => leaf.text)
|
||||
.join('');
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('')}
|
||||
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
|
||||
showTrademark={
|
||||
context.contentContext.customization.trademark.enabled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-tint-1" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
const { dataFetcher } = context.contentContext;
|
||||
|
||||
const initialInput: RenderIntegrationUI = {
|
||||
componentId: block.data.block,
|
||||
props: block.data.props,
|
||||
action: block.data.action,
|
||||
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 initialInput = {
|
||||
componentId: block.data.block,
|
||||
props: block.data.props,
|
||||
action: block.data.action,
|
||||
context: contentKitContext,
|
||||
};
|
||||
const initialOutput = await getDataOrNull(
|
||||
dataFetcher.renderIntegrationUi({
|
||||
integrationName: block.data.integration,
|
||||
request: initialInput,
|
||||
}),
|
||||
|
||||
const initialOutput = await ignoreAllThrownError(
|
||||
renderIntegrationUi(block.data.integration, 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>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user