Compare commits

..

1 Commits

Author SHA1 Message Date
Samy Pessé 0a4c3a98ea Set cache-control to no-cache for adaptive content 2024-12-20 23:22:22 +01:00
510 changed files with 10403 additions and 82351 deletions
+1
View File
@@ -0,0 +1 @@
1.1.40
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Adapt code to pull token from customer backend generated custom cookies
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve contrast of search box placeholder
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track events for site insights using the new dedicated API.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve colour contrast of list item decoration
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-contentkit': minor
'gitbook': minor
---
Send redirectOnError param to getPublishedContent when token is pulled from cookie
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix emojis display
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix variant selector contrast for non-default themes
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Updated scalar depdenency
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix bullet list display on full size blocks
@@ -1,58 +0,0 @@
name: 'Deploy vercel'
description: 'Deploy GitBook to Vercel'
inputs:
vercel-org:
description: 'Vercel organization'
required: true
vercel-project:
description: 'Vercel project'
required: true
vercel-token:
description: 'Vercel token'
required: true
environment:
description: 'Environment to deploy to'
required: true
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
shell: bash
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for environment
shell: bash
run: |
echo "VERCEL_ENVIRONMENT=${{ inputs.environment }}" >> $GITHUB_ENV
- name: Pull Vercel Environment Information
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }}
shell: bash
env:
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
- name: Build Project Artifacts
run: bun run vercel build --target=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }}
shell: bash
env:
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
run: |
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }})
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
env:
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
- name: Outputs
shell: bash
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+1 -1
View File
@@ -15,4 +15,4 @@ runs:
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: 'package.json'
bun-version-file: '.bun-version'
+31 -204
View File
@@ -18,8 +18,7 @@ jobs:
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -39,17 +38,15 @@ jobs:
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build Next.js with next-on-pages
run: bun run turbo gitbook#build:cloudflare
run: bun run 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
uses: cloudflare/wrangler-action@v3.11.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -75,156 +72,31 @@ jobs:
-H "Accept: application/vnd.github.v3+json" \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
deploy-v2-vercel:
name: Deploy v2 to Vercel
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-token: ${{ secrets.VERCEL_TOKEN }}
deploy-v2-vercel-staging:
name: Deploy v2 to Vercel (staging)
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
if: startsWith(github.ref, 'refs/heads/main')
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy staging
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: staging
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-token: ${{ secrets.VERCEL_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_URL=https://open-2c.gitbook.com" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static-2c.gitbook.com" >> $GITHUB_ENV
if: startsWith(github.ref, 'refs/heads/main')
- name: Sets env vars for preview
run: |
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build worker
run: bun run turbo build:v2: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
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ./
wranglerVersion: '3.82.0'
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy
- deploy-v2-vercel
- deploy-v2-cloudflare
steps:
- name: Find GitHub Comment
uses: peter-evans/find-comment@v3
id: fc
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
body-includes: GitBook Preview
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Summary of the deployments:
### Version 1 (production)
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
### Version 2 (experimental)
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | v1 | v2 |
| --- | --- | --- |
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
**GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
name: Visual Testing
needs: deploy
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -237,68 +109,14 @@ jobs:
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
visual-testing-v2:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2'
visual-testing-customers:
runs-on: ubuntu-latest
name: Visual Testing Customers
needs: deploy
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers'
visual-testing-customers-v2:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results
path: packages/gitbook/test-results/
retention-days: 3
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
@@ -315,12 +133,11 @@ jobs:
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
name: Format
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -331,10 +148,22 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format:check
lint:
runs-on: ubuntu-latest
name: Lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun lint --no-cache
test:
runs-on: ubuntu-latest
name: Test
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -349,7 +178,6 @@ jobs:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build (Open Source)
timeout-minutes: 6
env:
NPM_TOKEN_READONLY: ''
steps:
@@ -365,7 +193,6 @@ jobs:
typecheck:
runs-on: ubuntu-latest
name: Typecheck
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -2
View File
@@ -28,12 +28,11 @@ jobs:
uses: changesets/action@v1
with:
publish: npm run release
version: npm run changeset-version
env:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview:
-3
View File
@@ -22,6 +22,3 @@ yarn-error.log*
# Env files
.env.local
# TypeScript
*.tsbuildinfo
-7
View File
@@ -1,6 +1,5 @@
.next
.vercel
.open-next
# Generated
packages/emoji-codepoints/index.ts
@@ -9,9 +8,3 @@ packages/icons/src/data/*.json
# Build files
dist/
README.md
CHANGELOG.md
# v2
packages/gitbook-v2/public/~gitbook/static/
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["biomejs.biome"]
}
+1 -6
View File
@@ -7,10 +7,5 @@
["style \\=([^;]*);", "\"([^\"]*)\""],
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"]
}
+2 -24
View File
@@ -35,10 +35,8 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >=20.6)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
- Node.js (Version: >=18.x)
- Bun
### Set up
@@ -80,26 +78,6 @@ Any published GitBook site can be accessed through your local development instan
All pull-requests will be tested against both visual and performances testing to prevent regressions.
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
-168
View File
@@ -1,168 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": [
"**/node_modules/**/*",
"**/dist/**/*",
"**/build/**/*",
"**/public/**/*",
"**/.next/**/*",
"**/.open-next/**/*",
"**/.turbo/**/*",
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/openapi-parser/src/fixtures/**/*"
]
},
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noDelete": "warn"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": {
"noForEach": "off",
"noUselessFragments": "warn",
"noBannedTypes": "warn"
},
"correctness": {
"noUndeclaredVariables": "error",
"noUnusedVariables": "error",
"useArrayLiterals": "error",
"useHookAtTopLevel": "error",
"noUnusedImports": "error",
"noVoidElementsWithChildren": "warn",
"useJsxKeyInIterable": "warn",
"useExhaustiveDependencies": "warn",
"noUnknownFunction": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"noParameterAssign": "off",
"useThrowOnlyError": "error"
},
"suspicious": {
"noConsole": "warn",
"noExplicitAny": "warn",
"noImplicitAnyLet": "warn",
"noConfusingVoidType": "warn",
"noControlCharactersInRegex": "warn",
"noPrototypeBuiltins": "warn",
"noAssignInExpressions": "warn",
"noArrayIndexKey": "warn"
},
"a11y": {
"useSemanticElements": "warn",
"useKeyWithClickEvents": "warn",
"noSvgWithoutTitle": "warn",
"useButtonType": "warn",
"useIframeTitle": "warn",
"useAltText": "warn",
"noPositiveTabindex": "warn",
"useFocusableInteractive": "warn",
"useAriaPropsForRole": "warn",
"useValidAnchor": "warn",
"noLabelWithoutControl": "warn",
"noNoninteractiveTabindex": "warn"
},
"nursery": {
"useSortedClasses": {
"level": "error",
"fix": "safe",
"options": {
"attributes": ["class", "className", "style"],
"functions": ["clsx", "tw"]
}
}
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": [
"packages/gitbook/**/*",
"packages/gitbook-v2/**/*",
"packages/react-openapi/**/*",
"packages/react-math/**/*",
"packages/react-contentkit/**/*",
"packages/icons/**/*"
],
"javascript": {
"globals": ["React"]
}
},
{
"include": ["packages/gitbook/**/*"],
"javascript": {
"globals": ["React", "GitBookIntegrationEvent"]
}
},
{
"include": ["*.css"],
"javascript": {
"globals": ["theme"]
}
},
{
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
"javascript": {
"globals": ["Bun"]
}
},
{
"include": [
"packages/cache-do/**/*",
"packages/gitbook/cf-env.d.ts",
"packages/gitbook/src/cloudflare-entrypoint.ts"
],
"javascript": {
"globals": [
"DurableObjectLocationHint",
"DurableObjectNamespace",
"DurableObjectStub",
"ContinentCode",
"Fetcher",
"ExportedHandler"
]
}
}
]
}
-5484
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
# Caching
## Revalidating the cache
Invalidate cache can be done at two levels using tags:
- Data fetching cache
- Rendering cache
To invalidate and refetch the data cache, you can execute a POST request to `/~/gitbook/revalidate`:
```bash
curl --location --request POST 'https://gitbook/mycompany.com/~gitbook/revalidate' \
--header 'Content-Type: application/json' \
--data-raw '{"tags": ["space.id"]}'
```
To invalidate the rendering cache, the implementation mainly depends on the infrastructure serving the content, GitBook outputs a `Cache-Tag` header on every requests. The value of the header is a comma separated list of tags.
## Purging the cache
Purging the cache, without revalidating, is done by passing `"purge": true` in the request body.
+15 -21
View File
@@ -2,40 +2,34 @@
"name": "gitbook",
"version": "0.1.0",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.4.4",
"vercel": "^39.3.0"
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.1.2"
},
"packageManager": "bun@1.2.4",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1"
"packageManager": "bun@1.1.18",
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
},
"private": true,
"scripts": {
"dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build",
"build:v2": "turbo run build:v2",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
"format": "biome check --write ./",
"format:check": "biome check --diagnostic-level=error ./",
"format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"changeset-version": "changeset version && bun run format",
"release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean"
},
"workspaces": ["packages/*"],
"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"
}
"workspaces": [
"packages/*"
]
}
+9 -2
View File
@@ -21,7 +21,7 @@
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^3.109.2"
"wrangler": "3.82.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
@@ -31,5 +31,12 @@
"release": "wrangler deploy",
"release:preview": "wrangler deploy && wrangler deploy --env preview"
},
"files": ["dist", "src", "bin", "data", "README.md", "CHANGELOG.md"]
"files": [
"dist",
"src",
"bin",
"data",
"README.md",
"CHANGELOG.md"
]
}
+15 -8
View File
@@ -1,5 +1,5 @@
import { encode, decode } from '@msgpack/msgpack';
import { DurableObject } from 'cloudflare:workers';
import { decode, encode } from '@msgpack/msgpack';
import { LRUMap } from 'lru_map';
export interface CacheObjectDescriptor {
@@ -137,7 +137,7 @@ export class CacheObject extends DurableObject {
*/
public async purge() {
return this.logOperation({ operation: 'purge' }, async (setLog) => {
const result = new Set<string>();
let result = new Set<string>();
try {
// List all the keys in the cache object.
@@ -149,7 +149,11 @@ export class CacheObject extends DurableObject {
entries.forEach((exp) => {
result.add(exp.k);
});
} catch (_error) {}
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during purge, resetting the cache object', error);
}
await this.reset();
return Array.from(result);
@@ -170,7 +174,7 @@ export class CacheObject extends DurableObject {
const toDeleteSet = new Set<string>();
for (const [key, exp] of entries) {
const timestamp = Number.parseInt(key.split('.')[1]);
const timestamp = parseInt(key.split('.')[1]);
if (timestamp < Date.now()) {
toDeleteSet.add(key);
for (let i = 0; i < exp.c; i++) {
@@ -190,7 +194,10 @@ export class CacheObject extends DurableObject {
if (toDelete.length) {
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
} catch (_error) {
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during alarm, reset the cache object', error);
await this.reset();
}
});
@@ -211,10 +218,10 @@ export class CacheObject extends DurableObject {
*/
async logOperation<T>(
log: Record<string, unknown>,
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>,
): Promise<T> {
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
const update: Record<string, unknown> = {};
let update: Record<string, unknown> = {};
const start = performance.now();
try {
return await fn((arg) => {
@@ -258,7 +265,7 @@ function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
const chunks = Array.from(entries.entries())
.map(([key, value]) => {
const index = Number.parseInt(key.split('.').pop()!);
const index = parseInt(key.split('.').pop()!);
return [index, value] as const;
})
.sort(([a], [b]) => a - b)
+3 -3
View File
@@ -1,4 +1,4 @@
import type { CacheObject } from './CacheObject';
import type { CacheObject, CacheObjectDescriptor } from './CacheObject';
export type CacheLocationId = ContinentCode;
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
@@ -30,7 +30,7 @@ export class CacheObjectStub {
/** ID of the location to target */
private locationId: CacheLocationId,
/** Name of the tag */
private tag: string
private tag: string,
) {
const groupId = getCacheObjectIdName(this.locationId, this.tag);
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
@@ -85,7 +85,7 @@ export class CacheObjectStub {
});
const locationkeys = await cacheGroup.purge();
locationkeys.forEach((key) => keys.add(key));
})
}),
);
return keys;
-1
View File
@@ -1 +0,0 @@
dist/
-7
View File
@@ -1,7 +0,0 @@
# @gitbook/cache-tags
## 0.1.0
### Minor Changes
- 05ffd0e: Initial version of the package
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/cache-tags`
Utility to generate cache tags for GitBook Open.
-25
View File
@@ -1,25 +0,0 @@
{
"name": "@gitbook/cache-tags",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "0.96.1",
"assert-never": "^1.2.1"
},
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
-175
View File
@@ -1,175 +0,0 @@
import type { ComputedContentSource } from '@gitbook/api';
import assertNever from 'assert-never';
/**
* Get a stringified cache tag for a given object.
*/
export function getCacheTag(
spec: /**
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'user';
user: string;
}
/**
* All data related to a space
*/
| {
tag: 'space';
space: string;
}
/**
* All data related to an integration.
*/
| {
tag: 'integration';
integration: string;
}
/**
* All data related to a change request
*/
| {
tag: 'change-request';
space: string;
changeRequest: string;
}
/**
* Immutable data related to a revision
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'revision';
space: string;
revision: string;
}
/**
* Immutable data related to a document
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'document';
space: string;
document: string;
}
/**
* Immutable data related to a computed document
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'computed-document';
space: string;
integration: string;
}
/**
* All data related to the URL of a content
*/
| {
tag: 'url';
hostname: string;
}
/**
* All data related to a site
*/
| {
tag: 'site';
site: string;
}
/**
* All data related to an OpenAPI spec
*/
| {
tag: 'openapi';
organization: string;
openAPISpec: string;
}
): string {
switch (spec.tag) {
case 'user':
return `user:${spec.user}`;
case 'url':
return `url:${spec.hostname}`;
case 'space':
return `space:${spec.space}`;
case 'change-request':
return `space:${spec.space}:change-request:${spec.changeRequest}`;
case 'revision':
return `space:${spec.space}:revision:${spec.revision}`;
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.integration}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
default:
assertNever(spec);
}
}
/**
* Get the tags for a computed content source.
*/
export function getComputedContentSourceCacheTags(
inContext: {
spaceId: string;
organizationId: string;
},
source: ComputedContentSource
) {
const tags: string[] = [];
// We add the dependencies as tags, to ensure that the computed content is invalidated
// when the dependencies are updated.
const dependencies = Object.values(source.dependencies ?? {});
if (dependencies.length > 0) {
dependencies.forEach((dependency) => {
switch (dependency.ref.kind) {
case 'space':
tags.push(
getCacheTag({
tag: 'space',
space: dependency.ref.space,
})
);
break;
case 'openapi':
tags.push(
getCacheTag({
tag: 'openapi',
organization: inContext.organizationId,
openAPISpec: dependency.ref.spec,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
break;
}
});
} else {
// Push a dummy tag, as the v1 is only using the first tag
tags.push(
getCacheTag({
tag: 'computed-document',
space: inContext.spaceId,
integration: source.integration,
})
);
}
// We invalidate the computed content when a new version of the integration is deployed.
tags.push(
getCacheTag({
tag: 'integration',
integration: source.integration,
})
);
return tags;
}
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
-1
View File
@@ -1 +0,0 @@
dist/
-7
View File
@@ -1,7 +0,0 @@
# @gitbook/colors
## 0.2.0
### Minor Changes
- 445baaa: Initial release
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/colors`
A set of default colors and transformation functions used throughout the GitBook Open and app.
-21
View File
@@ -1,21 +0,0 @@
{
"name": "@gitbook/colors",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.2.0",
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
-39
View File
@@ -1,39 +0,0 @@
/**
* Default primary color throughout the GitBook ecosystem.
*/
export const DEFAULT_PRIMARY_COLOR = '#346DDB';
/**
* The darkest color that exists in GitBook, used as the relative minimum of every generated color scale.
*/
export const DARK_BASE = '#1D1D1D';
/**
* The lightest color that exists in GitBook, used as the relative maximum of every generated color scale.
*/
export const LIGHT_BASE = '#FFFFFF';
/**
* Used as the basis of all UI elements that are not colored by the primary color. Neutral gray by default, overridden by site customization.
*/
export const DEFAULT_TINT_COLOR = '#787878';
/**
* Used for informational messages and neutral alerts.
*/
export const DEFAULT_HINT_INFO_COLOR = '#787878';
/**
* Used for showing important information or non-critical warnings.
*/
export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00';
/**
* Used for destructive actions or raising attention to critical information.
*/
export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36';
/**
* Used for showing positive actions or achievements.
*/
export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950';
-2
View File
@@ -1,2 +0,0 @@
export * from './colors';
export * from './transformations';
-408
View File
@@ -1,408 +0,0 @@
import { DARK_BASE, DEFAULT_TINT_COLOR, LIGHT_BASE } from './colors';
type ColorShades = {
[key: string]: string;
};
type RGBColor = [number, number, number];
type OKLABColor = { L: number; A: number; B: number };
type OKLCHColor = { L: number; C: number; H: number };
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
export enum ColorCategory {
backgrounds = 'backgrounds',
components = 'components',
borders = 'borders',
accents = 'accents',
text = 'text',
}
type ColorSubScale = {
[key: string]: number;
};
/**
* Main color scale object.
*
* Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation.
* Each subitem maps a semantic name within that category to a step in the scale.
*/
export const scale: Record<ColorCategory, ColorSubScale> = {
[ColorCategory.backgrounds]: {
/** Base background */
base: 1,
/** Accent background */
subtle: 2,
},
[ColorCategory.components]: {
/** Component background */
DEFAULT: 3,
/** Component hover background */
hover: 4,
/** Component active background */
active: 5,
},
[ColorCategory.borders]: {
/** Subtle borders, separators */
subtle: 6,
/** Element border, focus rings */
DEFAULT: 7,
/** Element hover border */
hover: 8,
},
[ColorCategory.accents]: {
/** Solid backgrounds */
solid: 9,
/** Hovered solid backgrounds */
'solid-hover': 10,
},
[ColorCategory.text]: {
/** Very low-contrast text
* Caution: this contrast does not meet accessiblity guidelines.
* Always check if you need to include a mitigating contrast-more style for users who need it. */
subtle: 9,
/** Low-contrast text */
DEFAULT: 11,
/** High-contrast text */
strong: 12,
},
};
/**
* The mix of foreground and background for every step in a colour scale.
* 0: 100% of the background color's luminosity, white in light mode
* 1: 100% of the foreground color's luminosity, black in light mode
*/
export const colorMixMapping = {
// bgs |components |borders |solid |text
light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1],
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
};
/**
* Convert a hex color to an RGB color.
*/
export function hexToRgb(hex: string): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGB values separated by spaces
return `${r} ${g} ${b}`;
}
/**
* Convert a hex color to a RGBA color.
*/
export function hexToRgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGBA values separated by spaces
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Generate Tailwind-compatible shades from a single color
* @param {string} hex The hex code to generate shades from
* @param {boolean} halfShades Generate additional shades, e.g. at 150
* @returns {{[key: number]: string}}
*/
export function shadesOfColor(hex: string, halfShades = false) {
const baseColor = hex;
const shades = [
50,
100,
200,
300,
400,
500,
600,
700,
800,
900,
...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []),
].sort();
const result: ColorShades = {};
for (const shade of shades) {
const key = shade.toString();
if (shade === 500) {
result[key] = hex;
continue;
}
let shadeIndex = shade;
const isDarkShade = shadeIndex > 500;
if (isDarkShade) {
shadeIndex -= 500;
}
const percentage = shadeIndex / 500;
const startColor = isDarkShade ? DARK_BASE : baseColor;
const endColor = isDarkShade ? baseColor : LIGHT_BASE;
result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor));
}
return result;
}
export type ColorScaleOptions = {
/** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */
darkMode?: boolean;
/** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
background?: string;
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
foreground?: string;
mix?: {
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
color: string;
/** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */
ratio: number;
};
};
/**
* Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour.
* @param {string} hex The hex code to generate shades from
* @param {object} options
*/
export function colorScale(
hex: string,
{
darkMode = false,
background = darkMode ? DARK_BASE : LIGHT_BASE,
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
mix,
}: ColorScaleOptions = {}
) {
const baseColor = rgbToOklch(hexToRgbArray(hex));
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
const backgroundColor = rgbToOklch(hexToRgbArray(background));
if (mixColor && mix?.ratio && mix.ratio > 0) {
// If defined, we mix in a (tiny) bit of the mix color with the base color.
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
}
const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
const result = [];
for (let index = 0; index < mapping.length; index++) {
const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
// Original colour is close enough to target, so let's use the original colour as step 9.
result.push(hex);
continue;
}
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
const shade = {
L: targetL, // Blend lightness
C: baseColor.C * chromaRatio,
H: baseColor.H, // Maintain the hue from the base color
};
const newHex = rgbArrayToHex(oklchToRgb(shade));
result.push(newHex);
}
return result;
}
/**
* Convert a hex color to an RGB color set.
*/
export function hexToRgbArray(hex: string): RGBColor {
const originalHex = hex;
let value = hex.replace('#', '');
if (hex.length === 3) value = value + value;
const r = value.substring(0, 2);
const g = value.substring(2, 4);
const b = value.substring(4, 6);
const rgb = [r, g, b].map((channel) => {
try {
const channelInt = Number.parseInt(channel, 16);
if (channelInt < 0 || channelInt > 255) throw new Error();
return channelInt;
} catch {
throw new Error(`Invalid hex color provided: ${originalHex}`);
}
});
return rgb as RGBColor;
}
/**
* Convert a RGB color set to a hex color.
*/
export function rgbArrayToHex(rgb: RGBColor): string {
return `#${rgb
.map((channel) => {
const component = channel.toString(16);
if (component.length === 1) return `0${component}`;
return component;
})
.join('')}`;
}
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index] - channel));
});
return rgbArrayToHex(rgb as RGBColor);
}
// Utility constants and helper functions
export function rgbToLinear(rgb: RGBColor): [number, number, number] {
return rgb.map((v) => {
const scaled = v / 255;
return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
}
export function linearToRgb(linear: [number, number, number]): RGBColor {
return linear.map((v) => {
const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return Math.round(Math.max(0, Math.min(1, scaled)) * 255);
}) as RGBColor;
}
export function rgbToOklab(rgb: RGBColor): OKLABColor {
const [r, g, b] = rgbToLinear(rgb);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const lRoot = Math.cbrt(l);
const mRoot = Math.cbrt(m);
const sRoot = Math.cbrt(s);
return {
L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
};
}
export function oklabToRgb(oklab: OKLABColor): RGBColor {
const { L, A, B } = oklab;
const lRoot = L + 0.3963377774 * A + 0.2158037573 * B;
const mRoot = L - 0.1055613458 * A - 0.0638541728 * B;
const sRoot = L - 0.0894841775 * A - 1.291485548 * B;
const l = lRoot ** 3;
const m = mRoot ** 3;
const s = sRoot ** 3;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return linearToRgb([r, g, b]);
}
export function oklabToOklch(oklab: OKLABColor): OKLCHColor {
const { L, A, B } = oklab;
const C = Math.sqrt(A ** 2 + B ** 2);
const H = (Math.atan2(B, A) * 180) / Math.PI;
return { L, C, H: H < 0 ? H + 360 : H };
}
export function oklchToOklab(oklch: OKLCHColor): OKLABColor {
const { L, C, H } = oklch;
const rad = (H * Math.PI) / 180;
return {
L,
A: C * Math.cos(rad),
B: C * Math.sin(rad),
};
}
export function rgbToOklch(rgb: RGBColor): OKLCHColor {
return oklabToOklch(rgbToOklab(rgb));
}
export function oklchToRgb(oklch: OKLCHColor): RGBColor {
return oklabToRgb(oklchToOklab(oklch));
}
export function rgbToXyz(rgb: RGBColor): [number, number, number] {
const [r, g, b] = rgbToLinear(rgb);
return [
(r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
(r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100,
(r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100,
];
}
export function xyzToLab65(xyz: [number, number, number]): {
L: number;
A: number;
B: number;
} {
const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[i];
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
});
return {
L: 116 * y - 16,
A: 500 * (x - y),
B: 200 * (y - z),
};
}
export function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } {
return xyzToLab65(rgbToXyz(rgb));
}
/*
Delta Phi Star perceptual lightness contrast by Andrew Somers:
https://github.com/Myndex/deltaphistar
*/
export const PHI = 0.5 + Math.sqrt(1.25);
export function dpsContrast(a: RGBColor, b: RGBColor) {
const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI);
const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40;
return contrast < 7.5 ? 0 : contrast;
}
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = {
color: undefined,
contrast: 0,
};
for (const color of foreground) {
const c = hexToRgbArray(color);
const contrast = dpsContrast(c, bg);
if (contrast > best.contrast) {
best.color = c;
best.contrast = contrast;
}
}
return best.color ? rgbArrayToHex(best.color) : foreground[0];
}
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+2 -1
View File
@@ -17,11 +17,12 @@ Object.entries(emojis).forEach(([key, value]) => {
if (emoji && key !== emoji) {
output[key] = emoji;
} else if (!emoji) {
console.log('No emoji for', key);
}
});
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
fs.writeFileSync(
path.resolve(__dirname, 'dist/index.ts'),
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
);
-11
View File
@@ -1,11 +0,0 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
# Symbolic links
public
-21
View File
@@ -1,21 +0,0 @@
# gitbook-v2
## 0.1.2
### Patch Changes
- 05ffd0e: Improving data cache management for computed content
- Updated dependencies [05ffd0e]
- @gitbook/cache-tags@0.1.0
## 0.1.1
### Patch Changes
- 3e11678: fix: lost section groups
## 0.1.0
### Minor Changes
- cfccc44: Setup structure and deployment for new version
View File
-5
View File
@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-36
View File
@@ -1,36 +0,0 @@
// @ts-check
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
useCache: true,
},
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
GITBOOK_URL: process.env.GITBOOK_URL,
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,
// Used to detect if the app is running in V2 mode
GITBOOK_V2: 'true',
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
};
export default nextConfig;
-30
View File
@@ -1,30 +0,0 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import cache from '@opennextjs/cloudflare/kvCache';
const config = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
dangerous: {
enableCacheInterception: false,
},
};
export default config;
-30
View File
@@ -1,30 +0,0 @@
{
"name": "gitbook-v2",
"version": "0.1.2",
"private": true,
"dependencies": {
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "0.96.1",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.3",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
"scripts": {
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
"dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,5 +0,0 @@
import { SitePageSkeleton } from '@/components/SitePage';
export default function Loading() {
return <SitePageSkeleton />;
}
@@ -1,5 +0,0 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -1,38 +0,0 @@
import {
SitePage,
generateSitePageMetadata,
generateSitePageViewport,
} from '@/components/SitePage';
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next';
type PageProps = {
params: Promise<RouteParams>;
searchParams: Promise<{ fallback?: string }>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getDynamicSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
fallback: !!searchParams.fallback,
});
}
@@ -1,45 +0,0 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} 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';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function SiteDynamicLayout({
params,
children,
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const context = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={visitorAuthToken}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
const context = await getDynamicSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
const context = await getDynamicSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -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);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveIcon } from '@/routes/icon';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
return serveIcon(context, request);
}
@@ -1,13 +0,0 @@
import type { NextRequest } from 'next/server';
import type { PageIdParams } from '@/components/SitePage';
import { serveOGImage } from '@/routes/ogimage';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const context = await getDynamicSiteContext(await params);
return serveOGImage(context, await params);
}
@@ -1,5 +0,0 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -1,49 +0,0 @@
import {
SitePage,
generateSitePageMetadata,
generateSitePageViewport,
} from '@/components/SitePage';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next';
import { unstable_cacheTag as cacheTag } from 'next/cache';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
'use cache';
const params = await props.params;
const context = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
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 pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
});
}
@@ -1,52 +0,0 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { unstable_cacheTag as cacheTag } from 'next/cache';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function SiteStaticLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
'use cache';
const context = await getStaticSiteContext(await params);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout
context={context}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={null}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
const context = await getStaticSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const context = await getStaticSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
return servePagesSitemap(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
return serveRootSitemap(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveIcon } from '@/routes/icon';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
return serveIcon(context, request);
}
@@ -1,15 +0,0 @@
import type { NextRequest } from 'next/server';
import type { PageIdParams } from '@/components/SitePage';
import { serveOGImage } from '@/routes/ogimage';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const context = await getStaticSiteContext(await params);
return serveOGImage(context, await params);
}
-76
View File
@@ -1,76 +0,0 @@
import {
fetchSiteContextByURL,
fetchSiteContextByURLLookup,
getBaseContext,
} from '@v2/lib/context';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
export type RouteParamMode = 'url-host' | 'url';
export type RouteLayoutParams = {
mode: string;
/** URL encoded site URL */
siteURL: string;
};
export type RouteParams = RouteLayoutParams & {
pagePath: string;
};
/**
* Get the static context when rendering statically a site.
*/
export function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
return fetchSiteContextByURL(
getBaseContext({
siteURL,
urlMode: getModeFromParams(params.mode),
}),
{
url: siteURL.toString(),
visitorAuthToken: null,
redirectOnError: false,
}
);
}
/**
* Get the site context when rendering dynamically.
* The context will depend on the request.
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = await getSiteURLDataFromMiddleware();
return fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
}
/**
* Get the decoded page path from the params.
*/
export function getPagePathFromParams(params: RouteParams) {
const decoded = decodeURIComponent(params.pagePath);
return decoded;
}
function getSiteURLFromParams(params: RouteLayoutParams) {
const decoded = decodeURIComponent(params.siteURL);
const url = new URL(`https://${decoded}`);
return url;
}
function getModeFromParams(mode: string): RouteParamMode {
if (mode === 'url-host') {
return 'url-host';
}
return 'url';
}
-374
View File
@@ -1,374 +0,0 @@
import { getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
PublishedSiteContentLookup,
RevisionPage,
RevisionPageDocument,
Site,
SiteCustomizationSettings,
SiteIntegrationScript,
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
Space,
} from '@gitbook/api';
import { type GitBookDataFetcher, createDataFetcher } from '@v2/lib/data';
import { redirect } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookSpaceLinker, createLinker } from './links';
/**
* Generic context when rendering content.
*/
export type GitBookBaseContext = {
/**
* Data fetcher to fetch data from GitBook.
*/
dataFetcher: GitBookDataFetcher;
/**
* Linker to generate links in the current space.
*/
linker: GitBookSpaceLinker;
/**
* Image resizer to resize images.
*/
imageResizer?: ImageResizer;
};
/**
* Any context when rendering content.
*/
export type GitBookAnyContext = GitBookSpaceContext | GitBookSiteContext | GitBookPageContext;
/**
* Context when rendering a space content.
*/
export type GitBookSpaceContext = GitBookBaseContext & {
organizationId: string;
space: Space;
changeRequest: ChangeRequest | null;
/** ID of the current revision. */
revisionId: string;
/** Pages of the space. */
pages: RevisionPage[];
/** Share key of the space. */
shareKey: string | undefined;
};
export type SiteSections = {
list: (SiteSectionGroup | SiteSection)[];
current: SiteSection;
};
/**
* Context when rendering a site.
*/
export type GitBookSiteContext = GitBookSpaceContext & {
site: Site;
/** Current site space. */
siteSpace: SiteSpace;
/** All site spaces in the current section / or entire site */
siteSpaces: SiteSpace[];
/** Sections of the site. */
sections: null | SiteSections;
/** Customizations of the site. */
customization: SiteCustomizationSettings;
/** Structure of the site. */
structure: SiteStructure;
/** Scripts to load for the site. */
scripts: SiteIntegrationScript[];
};
/**
* Context when rendering a page.
*/
export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
page: RevisionPageDocument;
};
/**
* Get the base context for a request.
*/
export function getBaseContext(input: {
siteURL: URL | string;
urlMode: 'url' | 'url-host';
apiToken?: string | null;
}) {
const url = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const urlMode = input.urlMode;
const dataFetcher = createDataFetcher({
apiToken: input.apiToken ?? GITBOOK_API_TOKEN,
apiEndpoint: GITBOOK_API_URL,
});
const gitbookURL = new URL(GITBOOK_URL);
const linker =
urlMode === 'url-host'
? createLinker({
host: url.host,
pathname: url.pathname,
})
: createLinker({
protocol: gitbookURL.protocol,
host: gitbookURL.host,
pathname: `/url/${url.host}${url.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}`;
};
}
const imageResizer = createImageResizer({
host: urlMode === 'url-host' ? url.host : gitbookURL.host,
// To ensure image resizing work for proxied sites,
// we serve images from the root of the site.
linker: linker,
});
return {
dataFetcher,
linker,
imageResizer,
};
}
/**
* 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 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
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
if ('redirect' in data) {
redirect(data.redirect);
}
return await fetchSiteContextByIds(
{
...baseContext,
dataFetcher: dataFetcher.withToken({
apiToken: data.apiToken,
}),
},
{
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
space: data.space,
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
}
);
}
/**
* Fetch a site context by IDs.
*/
export async function fetchSiteContextByIds(
baseContext: GitBookBaseContext,
ids: {
organization: string;
site: string;
siteSection: string | undefined;
siteSpace: string | undefined;
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
await Promise.all([
dataFetcher.getPublishedContentSite({
organizationId: ids.organization,
siteId: ids.site,
siteShareKey: ids.shareKey,
}),
fetchSpaceContextByIds(baseContext, ids),
]);
// override the title with the customization title
// TODO: remove this hack once we have a proper way to handle site customizations
const site = {
...orgSite,
...(customizations.site?.title ? { title: customizations.site.title } : {}),
};
const sections = ids.siteSection
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
: null;
const siteSpace = (
siteStructure.type === 'siteSpaces' && siteStructure.structure
? siteStructure.structure
: sections?.current.siteSpaces
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) {
throw new Error('Site space not found');
}
const siteSpaces =
siteStructure.type === 'siteSpaces'
? siteStructure.structure
: (sections?.current.siteSpaces ?? []);
const customization = (() => {
if (ids.siteSpace) {
const siteSpaceSettings = customizations.siteSpaces[ids.siteSpace];
if (siteSpaceSettings) {
return siteSpaceSettings;
}
// We got the pointer from an API and customizations from another.
// It's possible that the two are unsynced leading to not found customizations for the space.
// It's better to fallback on customization of the site that displaying an error.
console.warn('Customization not found for site space', ids.siteSpace);
}
return customizations.site;
})();
return {
...spaceContext,
organizationId: ids.organization,
site,
siteSpaces,
siteSpace,
customization,
structure: siteStructure,
sections,
scripts,
};
}
/**
* Fetch a space context by IDs.
*/
export async function fetchSpaceContextByIds(
baseContext: GitBookBaseContext,
ids: {
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
}
): Promise<GitBookSpaceContext> {
const { dataFetcher } = baseContext;
const [space, changeRequest] = await Promise.all([
dataFetcher.getSpace({
spaceId: ids.space,
shareKey: ids.shareKey,
}),
ids.changeRequest
? dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
})
: null,
]);
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
const pages = await 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,
});
return {
...baseContext,
organizationId: space.organization,
space,
pages,
changeRequest,
revisionId,
shareKey: ids.shareKey,
};
}
/**
* Check if the context is the root one for a site.
* Meaning we are on the default section / space.
*/
export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
const { structure } = context;
switch (structure.type) {
case 'sections': {
return getSiteStructureSections(structure, { ignoreGroups: true }).some(
(structure) =>
structure.default &&
structure.id === context.sections?.current.id &&
structure.siteSpaces.some(
(siteSpace) => siteSpace.default && siteSpace.id === context.siteSpace.id
)
);
}
case 'siteSpaces': {
return structure.structure.some(
(siteSpace) => siteSpace.default && siteSpace.id === context.siteSpace.id
);
}
}
}
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
const section = parseCurrentSection(structure, siteSectionId);
assert(section, 'A section must be defined when there are multiple sections');
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
}
function parseCurrentSection(structure: SiteStructure, siteSectionId: string) {
const sections = getSiteStructureSections(structure, { ignoreGroups: true });
return sections.find((section) => section.id === siteSectionId);
}
-587
View File
@@ -1,587 +0,0 @@
import { type ComputedContentSource, GitBookAPI } 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 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 {
return {
apiEndpoint: input.apiEndpoint,
async api() {
return getAPI(input);
},
withToken({ apiToken }) {
return createDataFetcher({
...input,
apiToken,
});
},
//
// API that are tied to the token
//
getPublishedContentSite(params) {
return getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
});
},
getSiteRedirectBySource(params) {
return getSiteRedirectBySource(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
source: params.source,
});
},
getRevision(params) {
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
});
},
getRevisionPages(params) {
return getRevisionPages(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
});
},
getRevisionFile(params) {
return getRevisionFile(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
fileId: params.fileId,
});
},
getRevisionPageByPath(params) {
return getRevisionPageByPath(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
});
},
getReusableContent(params) {
return getReusableContent(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
reusableContentId: params.reusableContentId,
});
},
getLatestOpenAPISpecVersionContent(params) {
return getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
slug: params.slug,
});
},
getSpace(params) {
return getSpace(input, {
spaceId: params.spaceId,
shareKey: params.shareKey,
});
},
getChangeRequest(params) {
return getChangeRequest(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
});
},
getDocument(params) {
return getDocument(input, {
spaceId: params.spaceId,
documentId: params.documentId,
});
},
getComputedDocument(params) {
return getComputedDocument(input, {
organizationId: params.organizationId,
spaceId: params.spaceId,
source: params.source,
});
},
getEmbedByUrl(params) {
return getEmbedByUrl(input, {
url: params.url,
spaceId: params.spaceId,
});
},
searchSiteContent(params) {
return 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);
},
getPublishedContentByUrl(params) {
return getPublishedContentByUrl(commonInput, {
url: params.url,
visitorAuthToken: params.visitorAuthToken,
redirectOnError: params.redirectOnError,
});
},
};
}
async function getUserById(input: DataFetcherInput, userId: string) {
'use cache';
cacheLife('days');
try {
const res = await getAPI(input).users.getUserById(userId);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getSpace(
input: DataFetcherInput,
params: {
spaceId: string;
shareKey: string | undefined;
}
) {
'use cache';
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
});
return res.data;
}
async function getChangeRequest(
input: DataFetcherInput,
params: {
spaceId: string;
changeRequestId: string;
}
) {
'use cache';
cacheLife('minutes');
try {
const res = await getAPI(input).spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId
);
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getRevision(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}
) {
'use cache';
cacheLife('max');
const res = await getAPI(input).spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
});
return res.data;
}
async function getRevisionPages(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}
) {
'use cache';
cacheLife('max');
const res = await getAPI(input).spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
}
);
return res.data.pages;
}
async function getRevisionFile(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
fileId: string;
}
) {
'use cache';
cacheLife('max');
try {
const res = await getAPI(input).spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
path: string;
}
) {
'use cache';
cacheLife('max');
const encodedPath = encodeURIComponent(params.path);
try {
const res = await getAPI(input).spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
documentId: string;
}
) {
'use cache';
cacheLife('max');
const res = await getAPI(input).spaces.getDocumentById(params.spaceId, params.documentId);
return res.data;
}
async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
organizationId: string;
source: ComputedContentSource;
}
) {
'use cache';
cacheLife('days');
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
source: params.source,
});
return res.data;
}
async function getReusableContent(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}
) {
'use cache';
cacheLife('max');
try {
const res = await getAPI(input).spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
}
) {
'use cache';
cacheTag(
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
cacheLife('days');
try {
const res = await getAPI(input).orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
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');
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(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}
) {
'use cache';
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
const res = await getAPI(input).orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
}
);
return res.data;
}
async function getSiteRedirectBySource(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
}
) {
'use cache';
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
try {
const res = await getAPI(input).orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
}
);
return res.data;
} catch (error) {
// 422 is returned when the source is invalid
// we don't want to throw but just return null
if (checkHasErrorCode(error, 422)) {
return null;
}
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getEmbedByUrl(
input: DataFetcherInput,
params: {
url: string;
spaceId: string;
}
) {
'use cache';
cacheLife('weeks');
const api = getAPI(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data;
}
async function searchSiteContent(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) {
'use cache';
const { organizationId, siteId, query, scope } = params;
cacheLife('days');
const res = await getAPI(input).orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
});
return res.data.items;
}
function getAPI(input: DataFetcherInput) {
const { apiEndpoint, apiToken } = input;
const api = new GitBookAPI({
authToken: apiToken ?? undefined,
endpoint: apiEndpoint,
userAgent: GITBOOK_USER_AGENT,
});
return api;
}
function checkHasErrorCode(error: unknown, code: number) {
return error instanceof Error && 'code' in error && error.code === code;
}
@@ -1,4 +0,0 @@
export * from './api';
export * from './types';
export * from './utils';
export * from './urls';
-157
View File
@@ -1,157 +0,0 @@
import type * as api from '@gitbook/api';
/**
* Generic fetcher for GitBook data.
* It is used between v1 and v2.
*/
export interface GitBookDataFetcher {
/**
* Endpoint of the API.
*/
apiEndpoint: string;
/**
* Get an API client for the current context.
*/
api(): Promise<api.GitBookAPI>;
/**
* Create a data fetcher authenticated with a specific token.
*/
withToken(input: {
apiToken: string;
}): GitBookDataFetcher;
/**
* Get a user by its ID.
*/
getUserById(userId: string): Promise<api.User | null>;
/**
* Get a published content by its URL.
*/
getPublishedContentByUrl(params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<api.PublishedSiteContentLookup>;
/**
* Get a published content site by its organization ID and site ID.
*/
getPublishedContentSite(params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}): Promise<api.PublishedContentSite>;
/**
* Get a space by its ID.
*/
getSpace(params: { spaceId: string; shareKey: string | undefined }): Promise<api.Space>;
/**
* Get a change request by its space ID and change request ID.
*/
getChangeRequest(params: {
spaceId: string;
changeRequestId: string;
}): Promise<api.ChangeRequest | null>;
/**
* Get the revision by its space ID and revision ID.
*/
getRevision(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<api.Revision>;
/**
* Get the revision pages by its space ID and revision ID.
*/
getRevisionPages(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<api.RevisionPage[]>;
/**
* Get a revision file by its space ID, revision ID and file ID.
*/
getRevisionFile(params: {
spaceId: string;
revisionId: string;
fileId: string;
}): Promise<api.RevisionFile | null>;
/**
* Get a revision page by its path.
*/
getRevisionPageByPath(params: {
spaceId: string;
revisionId: string;
path: string;
}): Promise<api.RevisionPageDocument | api.RevisionPageGroup | null>;
/**
* Get a document by its space ID and document ID.
*/
getDocument(params: { spaceId: string; documentId: string }): Promise<api.JSONDocument>;
/**
* Get a computed document by its space ID and computed source.
*/
getComputedDocument(params: {
organizationId: string;
spaceId: string;
source: api.ComputedContentSource;
}): Promise<api.JSONDocument>;
/**
* Get a reusable content by its space ID, revision ID and reusable content ID.
*/
getReusableContent(params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}): Promise<api.RevisionReusableContent | null>;
/**
* Get the latest OpenAPI spec version content by its organization ID and slug.
*/
getLatestOpenAPISpecVersionContent(params: {
organizationId: string;
slug: string;
}): Promise<api.OpenAPISpecContent | null>;
/**
* Get a site redirect by its source path.
*/
getSiteRedirectBySource(params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
}): Promise<{ redirect: api.SiteRedirect | null; target: string } | null>;
/**
* Get an embed by its URL.
*/
getEmbedByUrl(params: { url: string; spaceId: string }): Promise<api.Embed>;
/**
* Search content in a site.
*/
searchSiteContent(params: {
organizationId: string;
siteId: string;
query: string;
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
/** Cache bust to ensure the search results are fresh when the space is updated. */
cacheBust?: string;
}): Promise<api.SearchSpaceResult[]>;
}
-248
View File
@@ -1,248 +0,0 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath } from '@/lib/paths';
import { GitBookAPI, type GitBookAPIError, type PublishedSiteContentLookup } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
/**
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<
| { data: PublishedSiteContentLookup; error?: undefined }
| { data?: undefined; error: Error | GitBookAPIError }
> {
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 callResult = await tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
cache: true,
},
{
signal,
headers: {
'x-gitbook-force-cache': 'true',
},
}
)
);
if (callResult.error) {
if (alternative.primary) {
// We only return an error for the primary alternative (full URL),
// as other parts could result in errors due to the URL being incomplete (share links, etc).
return { error: callResult.error };
}
return null;
}
const {
data: { data },
} = callResult;
if ('redirect' in data) {
if (alternative.primary) {
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {
if (data.target === 'content') {
const redirect = new URL(data.redirect);
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
data.redirect = redirect.toString();
} else {
const redirect = new URL(data.redirect);
if (redirect.searchParams.has('location')) {
redirect.searchParams.set(
'location',
joinPath(
redirect.searchParams.get('location') ?? '',
alternative.extraPath
)
);
data.redirect = redirect.toString();
}
}
}
return { data };
}
return null;
}
/**
* We use the following criteria to determine if the lookup result is the right one:
* - the primary alternative was resolved (because that's the longest or most inclusive path)
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
*
* 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 siteResult: PublishedSiteContentLookup = {
...data,
changeRequest: data.changeRequest ?? lookup.changeRequest,
revision: data.revision ?? lookup.revision,
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
};
return { data: siteResult };
}
return null;
});
if (!result) {
return {
error: new Error('No content found'),
};
}
return result;
}
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
* The approach is optimized to aim at reusing cached lookup results as much as possible.
*
* The cases can be:
*
* Custom hostname
* - Public content has a custom hostname: docs.company.com/<path>
* - Public content has a custom hostname with a share-link: docs.company.com/<link>/<path>
* - Public content has a custom hostname with a variant: docs.company.com/v/<variant>/<path>
* - Public content has a custom hostname with a variant and a share-link: docs.company.com/<link>/v/<variant>/<path>
* - Revisions in a custom hostname: docs.company.com/~/revisions/<id>/<path>
* - Changes in a custom hostname: docs.company.com/~/changes/<id>/<path>
*
* Custom hostname in the organization or GitBook domain (company.gitbook.io)
* - Public content has a custom hostname in the organization: docs.company.com/<space>/<path>
* - Public content has a custom hostname in the organization with a share-link: docs.company.com/<space>/<link>/<path>
* - Public content has a custom hostname in the organization with a variant: docs.company.com/<space>/v/<variant>/<path>
* - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com/<space>/<link>/v/<variant>/<path>
*/
export function getURLLookupAlternatives(input: URL) {
const url = normalizeURL(input);
let basePath: string | undefined = undefined;
let changeRequest: string | undefined = undefined;
let revision: string | undefined = undefined;
const alternatives: Array<{ url: string; extraPath: string; primary: boolean }> = [];
const pushAlternative = (adding: URL, extraPath: string) => {
const existing = alternatives.find((alt) => alt.url === adding.toString());
if (existing) {
if (existing.extraPath !== extraPath) {
throw new Error(
`Invalid extraPath ${extraPath} for url ${adding.toString()}, already set to ${
existing.extraPath
}`
);
}
return;
}
alternatives.push({
url: adding.toString(),
extraPath,
primary: adding.toString() === url.toString(),
});
};
const pathSegments = url.pathname.slice(1).split('/');
const tildeIndex = pathSegments.indexOf('~');
// URL looks like a specific content url (with ~/revisions/ or ~/changes/ in the path)
// We only start matching after the ~/revisions/ or ~/changes/ segment and we ignore everything before it
if (
tildeIndex >= 0 &&
(pathSegments[tildeIndex + 1] === 'revisions' || pathSegments[tildeIndex + 1] === 'changes')
) {
const tildeIndex = pathSegments.indexOf('~');
const revisionOrChangeIdIndex = tildeIndex + 2;
basePath = pathSegments.slice(tildeIndex, revisionOrChangeIdIndex + 1).join('/');
if (pathSegments[tildeIndex + 1] === 'revisions') {
revision = pathSegments[revisionOrChangeIdIndex];
} else {
changeRequest = pathSegments[revisionOrChangeIdIndex];
}
// Match up to the tilde
const contentURL = new URL(url);
contentURL.pathname = pathSegments.slice(0, tildeIndex).join('/');
pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/'));
}
// URL looks like a collection url (with /v/ in the path)
// We only start matching after the /v/ segment and we ignore everything before it
// to avoid potentially matching as a page not found under the default space in the collection
else if (pathSegments.includes('v')) {
const collectionURL = new URL(url);
const vIndex = pathSegments.indexOf('v');
collectionURL.pathname = pathSegments.slice(0, vIndex + 2).join('/');
pushAlternative(collectionURL, pathSegments.slice(vIndex + 2).join('/'));
} else {
// Match only with the host, if it can be a custom hostname
// It should cover most cases of custom domains, and with caching, it should be fast.
if (!url.hostname.includes('.gitbook.io') || pathSegments.length === 0) {
const noPathURL = new URL(url);
noPathURL.pathname = '/';
pushAlternative(noPathURL, url.pathname.slice(1));
}
// Otherwise match with the first four segments of the path
for (let i = 1; i <= 4; i++) {
if (pathSegments.length >= i) {
const shortURL = new URL(url);
shortURL.pathname = pathSegments.slice(0, i).join('/');
pushAlternative(shortURL, pathSegments.slice(i).join('/'));
}
}
}
// Mark the longuest entry to lookup as primary
alternatives.sort((a, b) => b.extraPath.length - a.extraPath.length);
if (alternatives.length > 0) {
alternatives[alternatives.length - 1].primary = true;
}
return { urls: alternatives, basePath, changeRequest, revision };
}
/**
* Normalize a URL to remove duplicate slashes and trailing slashes
* and transform the pathname to lowercase.
*/
export function normalizeURL(url: URL) {
const result = new URL(url);
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
return result;
}
/**
* Strip the search params from a URL
*/
export function stripURLSearch(url: URL): URL {
const stripped = new URL(url.toString());
stripped.search = '';
return stripped;
}
-24
View File
@@ -1,24 +0,0 @@
import type { RevisionPageDocument, Space } from '@gitbook/api';
import type { GitBookDataFetcher } from './types';
/**
* Get the document for a page.
*/
export async function getPageDocument(
dataFetcher: GitBookDataFetcher,
space: Space,
page: RevisionPageDocument
) {
if (page.documentId) {
return dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId });
}
if (page.computed) {
return dataFetcher.getComputedDocument({
organizationId: space.organization,
spaceId: space.id,
source: page.computed,
});
}
return null;
}
-60
View File
@@ -1,60 +0,0 @@
/**
* Main host on which GitBook is running.
*/
export const GITBOOK_URL =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: ((process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ??
process.env.GITBOOK_URL ??
'');
/**
* URL at which static assets are served.
*/
export const GITBOOK_ASSETS_URL =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: (process.env.GITBOOK_ASSETS_PREFIX ?? GITBOOK_URL);
/**
* GitBook app URL.
*/
export const GITBOOK_APP_URL = process.env.NEXT_PUBLIC_GITBOOK_APP_URL ?? 'https://app.gitbook.com';
/**
* Default GitBook API URL endpoint.
*/
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
/**
* Default GitBook API token.
* It can be use to avoid rate-limiting.
*/
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
/**
* User agent to use for API requests.
*/
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
/**
* Whether to disable tracking of events into site insights.
* This is used to disable tracking in development mode.
*/
export const GITBOOK_DISABLE_TRACKING = Boolean(
!!process.env.GITBOOK_DISABLE_TRACKING || process.env.NODE_ENV !== 'production'
);
/**
* Hostname serving the integrations.
*/
export const GITBOOK_INTEGRATIONS_HOST =
process.env.GITBOOK_INTEGRATIONS_HOST ?? 'integrations.gitbook.com';
/**
* Endpoint to use for resizing images.
* It should be a Cloudflare domain with image resizing enabled.
*/
export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ?? null;
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
@@ -1,244 +0,0 @@
import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookSpaceLinker } from '../links';
import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types';
interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
/**
* Create an image resizer for a rendering context.
*/
export function createImageResizer({
host,
linker,
}: {
/** The linker to use to create URLs. */
linker: GitBookSpaceLinker;
/** The host name of the current site. */
host: string;
}): ImageResizer {
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
return createNoopImageResizer();
}
return {
getResizedImageURL: (urlInput) => {
if (!checkIsSizableImageURL(urlInput)) {
return null;
}
let cachedSignature: {
signature: string;
version: SignatureVersion;
} | null = null;
return async (options) => {
cachedSignature ??= await generateImageSignature({
host,
url: urlInput,
});
const url = new URL(
linker.toAbsoluteURL(linker.toPathInContent('/~gitbook/image'))
);
url.searchParams.set('url', getImageAPIUrl(urlInput));
if (options.width) {
url.searchParams.set('width', options.width.toString());
}
if (options.height) {
url.searchParams.set('height', options.height.toString());
}
if (options.dpr) {
url.searchParams.set('dpr', options.dpr.toString());
}
if (options.quality) {
url.searchParams.set('quality', options.quality.toString());
}
url.searchParams.set('sign', cachedSignature.signature);
url.searchParams.set('sv', cachedSignature.version);
return url.toString();
};
},
getImageSize: async (input, options) => {
if (!checkIsSizableImageURL(input)) {
return null;
}
return getImageSize(input, options);
},
};
}
/**
* Create an image resizer that doesn't do any resizing.
*/
export function createNoopImageResizer(): ImageResizer {
return {
getResizedImageURL: () => null,
getImageSize: async (_input) => null,
};
}
/**
* Check if a URL is an HTTP URL.
*/
export function checkIsHttpURL(input: string | URL): boolean {
if (!URL.canParse(input)) {
return false;
}
const parsed = new URL(input);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}
if (input.includes('/~gitbook/image')) {
return false;
}
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}
return true;
}
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (_error) {
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
}
if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
}
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
}
return await fetch(
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`,
{
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
}
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
* the token in the image API.
*/
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
/**
* Prepare a URL for the GitBook Open Image API.
*/
export function getImageAPIUrl(url: string): string {
return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
}
export function parseImageAPIURL(url: string): string {
return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
}
@@ -1,4 +0,0 @@
export * from './types';
export * from './createImageResizer';
export * from './signatures';
export * from './utils';
@@ -1,112 +0,0 @@
import 'server-only';
import fnv1a from '@sindresorhus/fnv1a';
import type { MaybePromise } from 'p-map';
import { assert } from 'ts-essentials';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY } from '../env';
/**
* GitBook has supported different version of image signing in the past. To maintain backwards
* compatibility, we retain the ability to verify older signatures.
*/
export type SignatureVersion = '0' | '1' | '2';
/**
* The current version of the signature.
*/
export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = {
url: string;
host: string;
};
type SignFn = (input: SignFnInput) => MaybePromise<string>;
/**
* Verify a signature of an image URL
*/
export async function verifyImageSignature(
input: SignFnInput,
{ signature, version }: { signature: string; version: SignatureVersion }
): Promise<boolean> {
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input);
return generated === signature;
}
/**
* Generate an image signature. Also returns the version of the image signing algorithm that was used.
*
* This function is sync. If you need to implement an async version of image signing, you'll need to change
* ths signature of this fn and where it's used.
*/
export async function generateImageSignature(input: SignFnInput): Promise<{
signature: string;
version: SignatureVersion;
}> {
const result = await generateSignatureV2(input);
return { signature: result, version: CURRENT_SIGNATURE_VERSION };
}
// Reused buffer for FNV-1a hashing in the v2 algorithm
const fnv1aUtf8Buffer = new Uint8Array(512);
/**
* Generate a signature for an image.
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
*/
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
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
]
.filter(Boolean)
.join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
};
// Reused buffer for FNV-1a hashing in the v1 algorithm
const fnv1aUtf8BufferV1 = new Uint8Array(512);
/**
* New and faster algorithm to generate a signature for an image.
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
* to know that it was the algorithm that was used.
*/
const generateSignatureV1: SignFn = async (input) => {
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
const all = [input.url, GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16);
};
/**
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
* We still need it to validate older signatures that were generated without versioning
* but still exist in previously generated and cached content.
*/
const generateSignatureV0: SignFn = async (input) => {
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
const all = [input.url, GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
// Convert ArrayBuffer to hex string
const hashArray = Array.from(new Uint8Array(hash));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
};
/**
* A mapping of signature versions to signature functions.
*/
const IMAGE_SIGNATURE_FUNCTIONS: Record<SignatureVersion, SignFn> = {
'0': generateSignatureV0,
'1': generateSignatureV1,
'2': generateSignatureV2,
};
export function isSignatureVersion(input: string): input is SignatureVersion {
return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input);
}
@@ -1,29 +0,0 @@
export type GetImageSizeOptions = {
dpr?: number;
};
export type ResizeImageOptions = GetImageSizeOptions & {
width?: number;
height?: number;
dpr?: number;
quality?: number;
};
interface ImageSize {
width: number;
height: number;
}
export type ImageResizer = {
/**
* Resize an image.
* @param input - The image URL to resize.
* @param options - The options to resize the image.
*/
getResizedImageURL(imageURL: string): null | ((options: ResizeImageOptions) => Promise<string>);
/**
* Get the size of an image.
*/
getImageSize(imageURL: string, options: GetImageSizeOptions): Promise<ImageSize | null>;
};
@@ -1,17 +0,0 @@
import type { ImageResizer, ResizeImageOptions } from './types';
/**
* Quick utility to get a resized image URL.
*/
export async function getResizedImageURL(
resizer: ImageResizer | undefined,
url: string,
options: ResizeImageOptions
) {
const getURL = resizer?.getResizedImageURL(url);
if (!getURL) {
return url;
}
return await getURL(options);
}
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { appendBasePathToLinker, createLinker } from './links';
const root = createLinker({
host: 'docs.company.com',
pathname: '/',
});
const variantInSection = createLinker({
host: 'docs.company.com',
pathname: '/section/variant',
});
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');
});
it('should handle leading slash', () => {
expect(root.toPathInContent('/some/path')).toBe('/some/path');
expect(variantInSection.toPathInContent('/some/path')).toBe('/section/variant/some/path');
});
});
describe('toAbsoluteURL', () => {
it('should return the correct path', () => {
expect(root.toAbsoluteURL('some/path')).toBe('https://docs.company.com/some/path');
expect(variantInSection.toAbsoluteURL('some/path')).toBe(
'https://docs.company.com/some/path'
);
});
});
describe('appendBasePathToLinker', () => {
const prefixedRoot = appendBasePathToLinker(root, '/section/variant');
const prefixedVariantInSection = appendBasePathToLinker(variantInSection, '/base');
describe('toPathInContent', () => {
it('should return the correct path', () => {
expect(prefixedRoot.toPathInContent('some/path')).toBe('/section/variant/some/path');
expect(prefixedVariantInSection.toPathInContent('some/path')).toBe(
'/section/variant/base/some/path'
);
});
});
describe('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'
);
});
});
});
-113
View File
@@ -1,113 +0,0 @@
import { getPagePath } from '@/lib/pages';
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
/**
* Generic interface to generate links based on a given context.
*
* URL levels:
*
* https://docs.company.com/section/variant/page
*
* toPathInContent('some/path') => /section/variant/some/path
* toPathForPage({ pages, page }) => /section/variant/some/path
* toAbsoluteURL('some/path') => https://docs.company.com/some/path
*/
export interface GitBookSpaceLinker {
/**
* Generate an absolute path for a relative path to the current content.
*/
toPathInContent(relativePath: string): string;
/**
* Generate an absolute path for a page in the current content.
* The result should NOT be passed to `toPathInContent`.
*/
toPathForPage(input: {
pages: RevisionPage[];
page: RevisionPageDocument | RevisionPageGroup;
anchor?: string;
}): string;
/**
* Generate an absolute URL for a given path relative to the host of the current content.
*/
toAbsoluteURL(absolutePath: string): string;
/**
* Generate a link (URL or path) for a GitBook content URL (url of another site)
*/
toLinkForContent(url: string): string;
}
/**
* Create a linker to resolve links in a context being served on a specific URL.
*/
export function createLinker(
/** Where the top of the space is served on */
servedOn: {
protocol?: string;
host: string;
pathname: string;
}
): GitBookSpaceLinker {
if (servedOn.host.includes('/')) {
throw new Error('Host cannot include a slash');
}
const linker: GitBookSpaceLinker = {
toPathInContent(relativePath: string): string {
return joinPaths(servedOn.pathname, relativePath);
},
toAbsoluteURL(absolutePath: string): string {
return `${servedOn.protocol ?? 'https:'}//${joinPaths(servedOn.host, absolutePath)}`;
},
toPathForPage({ pages, page, anchor }) {
return linker.toPathInContent(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
},
toLinkForContent(url: string): string {
return url;
},
};
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;
return prefixPath + suffixPath;
}
-119
View File
@@ -1,119 +0,0 @@
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
import { headers } from 'next/headers';
export enum MiddlewareHeaders {
/**
* Type of the route (static or dynamic)
*/
RouteType = 'x-gitbook-route-type',
/**
* The URL of the site (without the pathname)
*/
SiteURL = 'x-gitbook-site-url',
/**
* The data associated with the URL.
*/
SiteURLData = 'x-gitbook-site-url-data',
/**
* The mode of the URL (url or url-host)
*/
URLMode = 'x-gitbook-url-mode',
/**
* The theme of the page (light or dark)
*/
Theme = 'x-gitbook-theme',
/**
* The customization override to apply.
*/
Customization = 'x-gitbook-customization',
/**
* Token to use for the API.
*/
APIToken = 'x-gitbook-token',
/**
* The visitor token used to access this content
*/
VisitorAuthToken = 'x-gitbook-visitor-token',
}
/**
* Get the URL mode from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
const headersList = await headers();
const mode = headersList.get(MiddlewareHeaders.URLMode);
if (!mode) {
throw new Error('URL mode is not set by the middleware');
}
return mode as '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> {
const headersList = await headers();
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
if (!siteURLData) {
throw new Error(
'Site URL data is not set by the middleware. This should only be called in a server action or a dynamic route.'
);
}
return JSON.parse(siteURLData);
}
/**
* Get the URL from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getSiteURLFromMiddleware(): Promise<string> {
const headersList = await headers();
const siteURL = headersList.get(MiddlewareHeaders.SiteURL);
if (!siteURL) {
throw new Error('URL mode is not set by the middleware');
}
return siteURL;
}
/**
* For preview, the theme can be set via query string (?theme=light).
* This function should only be called in a dynamic route.
*/
export async function getThemeFromMiddleware() {
const headersList = await headers();
const queryStringTheme = headersList.get(MiddlewareHeaders.Theme);
if (!queryStringTheme) {
return null;
}
return queryStringTheme === 'light'
? CustomizationThemeMode.Light
: 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;
}
@@ -1,31 +0,0 @@
import { type GitBookBaseContext, fetchSiteContextByURLLookup, getBaseContext } from './context';
import {
getSiteURLDataFromMiddleware,
getSiteURLFromMiddleware,
getURLModeFromMiddleware,
} from './middleware';
/**
* Get the base context for a server action.
* This function should only be called in a server action.
*/
export async function getServerActionBaseContext() {
const siteURL = await getSiteURLFromMiddleware();
const siteURLData = await getSiteURLDataFromMiddleware();
const urlMode = await getURLModeFromMiddleware();
return getBaseContext({
siteURL,
urlMode,
apiToken: siteURLData.apiToken,
});
}
/**
* Fetch the context for a site in a server action.
* The server action is always dynamic and the request is passed through the middleware.
*/
export async function fetchServerActionSiteContext(baseContext: GitBookBaseContext) {
const siteURLData = await getSiteURLDataFromMiddleware();
return fetchSiteContextByURLLookup(baseContext, siteURLData);
}
-187
View File
@@ -1,187 +0,0 @@
import { GitBookAPIError } from '@gitbook/api';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getContentSecurityPolicy } from '@/lib/csp';
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
import { serveResizedImage } from '@/routes/image';
import { getPublishedContentByURL } from '@v2/lib/data';
import { MiddlewareHeaders } from '@v2/lib/middleware';
export const config = {
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
};
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
export async function middleware(request: NextRequest) {
try {
/**
* Serve image resizing requests (all requests containing `/~gitbook/image`).
* All URLs containing `/~gitbook/image` are rewritten to `/~gitbook/image`
* and serve from a single route handler.
*
* 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 (request.nextUrl.pathname.endsWith('/~gitbook/image')) {
return serveResizedImage(request);
}
// Route all requests to a site
const extracted = extractURL(request);
if (extracted) {
return serveSiteByURL(request, extracted);
}
// Handle the rest with the router default logic
return NextResponse.next();
} catch (error) {
return serveErrorResponse(error as Error);
}
}
/**
* Serve site by URL.
*/
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
const dynamicHeaders = getDynamicHeaders(request);
const { url, mode } = urlWithMode;
const result = await getPublishedContentByURL({
url: url.toString(),
visitorAuthToken: null,
redirectOnError: false,
});
if (result.error) {
throw result.error;
}
const { data } = result;
if ('redirect' in data) {
return NextResponse.redirect(data.redirect);
}
const routeType = dynamicHeaders ? 'dynamic' : 'static';
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(MiddlewareHeaders.SiteURL, `${url.origin}${data.basePath}`);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
if (dynamicHeaders) {
for (const [key, value] of Object.entries(dynamicHeaders)) {
requestHeaders.set(key, value);
}
}
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const route = [
'sites',
routeType,
mode,
encodeURIComponent(url.host + data.basePath),
encodePathInSiteContent(data.pathname),
].join('/');
console.log(`rewriting to ${route}`);
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
const response = NextResponse.rewrite(rewrittenURL, {
request: {
headers: requestHeaders,
},
});
// Add Content Security Policy header
response.headers.set('content-security-policy', getContentSecurityPolicy());
return response;
}
/**
* Serve an error response.
*/
function serveErrorResponse(error: Error) {
if (error instanceof GitBookAPIError) {
return NextResponse.json(
{ error: error.message },
{ status: 500, headers: { 'content-type': 'application/json' } }
);
}
throw error;
}
/**
* The URL of the GitBook content can be passed in 3 different ways:
* - The request URL is in the `X-GitBook-URL` header.
* - Hostname is in the `X-GitBook-Host` header and the pathname is the path in the request URL.
* - The request URL is matching `/url/:url`
*/
function extractURL(request: NextRequest): URLWithMode | null {
const xGitbookUrl = request.headers.get('x-gitbook-url');
if (xGitbookUrl) {
return {
url: new URL(xGitbookUrl),
mode: 'url-host',
};
}
const xGitbookHost = request.headers.get('x-gitbook-host');
if (xGitbookHost) {
return {
url: new URL(`https://${xGitbookHost}${request.nextUrl.pathname}`),
mode: 'url-host',
};
}
const prefix = '/url/';
if (request.nextUrl.pathname.startsWith(prefix)) {
return {
url: new URL(`https://${request.nextUrl.pathname.slice(prefix.length)}`),
mode: 'url',
};
}
return null;
}
/**
* Evaluate if a request is dynamic or static.
*/
function getDynamicHeaders(_request: NextRequest): null | Record<string, string> {
// TODO:
// - check token in query string
// - check token in cookies
// - check special headers or query string
return null;
}
/**
* Encode path in a site content.
* Special paths are not encoded and passed to be handled by the route handlers.
*/
function encodePathInSiteContent(rawPathname: string) {
const pathname = removeLeadingSlash(removeTrailingSlash(rawPathname));
if (pathname.match(/^~gitbook\/ogimage\/\S+$/)) {
return pathname;
}
switch (pathname) {
case '~gitbook/icon':
case '~gitbook/image':
case 'llms.txt':
case 'sitemap.xml':
case 'robots.txt':
return pathname;
default:
return encodeURIComponent(pathname || '/');
}
}
-10
View File
@@ -1,10 +0,0 @@
import config from '../gitbook/tailwind.config';
export default {
...config,
content: [
'../gitbook/src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'../gitbook/src/components/**/*.{js,ts,jsx,tsx,mdx}',
'../gitbook/src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
};
-44
View File
@@ -1,44 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@v2/*": ["./src/*"],
"@/*": ["../gitbook/src/*"]
},
"types": [
"bun-types" // add Bun global
]
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
"../gitbook/types/**/*.d.ts",
"../gitbook/cf-env.d.ts"
],
"exclude": [
"node_modules",
"packages/openapi-parser",
"packages/react-openapi",
"packages/react-math",
"packages/gitbook"
]
}
-5
View File
@@ -1,5 +0,0 @@
main = ".open-next/worker.js"
name = "gitbook-v2"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "next/core-web-vitals",
"plugins": ["import"],
"rules": {
"import/order": [
"error",
{
"groups": ["builtin", "external", ["internal", "parent", "sibling", "index"]],
"newlines-between": "always",
"distinctGroup": true,
"pathGroups": [
{
"pattern": "@/**",
"group": "external",
"position": "after"
},
{
"pattern": "@gitbook/**",
"group": "external",
"position": "after"
}
],
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
],
"@next/next/no-img-element": ["off"]
}
}
-236
View File
@@ -1,241 +1,5 @@
# gitbook
## 0.6.5
### Patch Changes
- 05ffd0e: Improving data cache management for computed content
- 8beb5d6: Add input elements to ContentKit
- Updated dependencies [53f5dbe]
- Updated dependencies [05ffd0e]
- Updated dependencies [8beb5d6]
- @gitbook/openapi-parser@2.0.2
- @gitbook/cache-tags@0.1.0
- @gitbook/react-contentkit@0.6.1
- @gitbook/react-openapi@1.0.5
## 0.6.4
### Patch Changes
- 9b914d1: Fix getProxyModeBasePath that was computing incorrect base path in some scenarios
- 2ae76f9: Change how a site in proxy mode is resolved
- 027a859: Add support for links style customization option
- 3e11678: fix: lost section groups
- 3319375: Support OpenAPI operation block
- Updated dependencies [722f02e]
- Updated dependencies [0924259]
- @gitbook/react-openapi@1.0.4
- @gitbook/openapi-parser@2.0.1
## 0.6.3
### Patch Changes
- a820739: Remove unused search api method from gitbook/api/lib
- a054554: Implement a trusted mode to speed up OpenAPI spec validation
- 66d0fc0: Update design for hint block: use semantic colors (info, warning, danger, success) and add alternative styling for hints with headings
- 9f0de74: Add support for new OpenAPI ref
- da55fac: Render GitBook blocks in OpenAPI operation description
- Updated dependencies [c808bb1]
- Updated dependencies [dc2dbc5]
- Updated dependencies [f1d1d2f]
- Updated dependencies [e24206e]
- Updated dependencies [a054554]
- Updated dependencies [05e1d8c]
- Updated dependencies [b4a12d6]
- Updated dependencies [9f0de74]
- Updated dependencies [da55fac]
- @gitbook/openapi-parser@2.0.0
- @gitbook/react-openapi@1.0.3
## 0.6.2
### Patch Changes
- 359bb97: Fix opening external links when the GitBook page is embedded in an iframe
- 6157583: Improve Markdown parsing
- 82cd9f2: Add support for anchor links in OpenAPI blocks
- Updated dependencies [445baaa]
- Updated dependencies [bb5c6a4]
- Updated dependencies [a3f1fea]
- Updated dependencies [6157583]
- Updated dependencies [7419ee7]
- Updated dependencies [82cd9f2]
- @gitbook/colors@0.2.0
- @gitbook/react-openapi@1.0.2
- @gitbook/openapi-parser@1.0.1
## 0.6.1
### Patch Changes
- dddb4ec: Fix long tab group description
- Updated dependencies [f8d4c76]
- Updated dependencies [dddb4ec]
- Updated dependencies [f8d4c76]
- @gitbook/react-openapi@1.0.1
## 0.6.0
### Minor Changes
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
- 95f2aa4: Track new events for site insights when ads are being clicked
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
- 1138d59: Add support for sidebar background styles
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
- 7059c2b: Add support for computed content by fetching computed documents for pages.
- c71d159: Track events for site insights using the new dedicated API.
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
- 7ee9158: Restyle PageAside to use sidebar list styles
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
- 1417279: Track clicks on links (header, footer, content) for site insights.
- 9eca010: Improve the display of recommended questions by streaming them.
- 160fca1: new OpenAPI blocks design
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
- 1823101: Fix internal properties appearing in OpenAPI docs.
- 6a073e1: Add antialiasing for text rendering
- 8126a83: Improve readability of tables with hover style and vertical dividers
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
### Patch Changes
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
- c30bc24: Fix empty sitemap
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
- 5b4e710: Support llms.txt
- b6c3870: Add support for keyboard marks
- 6059efe: Fix search no results error showing while there are results
- c77142a: Log component stack in Sentry
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
- 32aa1f9: Handle security issue with cookies on Safari
- d935fb1: Don't add extra page scroll when footer is not present
- 53de5b1: Fix site section URL resolution in Ask AI sources
- 24f5249: Fix vertical section overflow color
- 1762f85: Reduce gap between subsequent header buttons
- c1e27cc: Fix pass Sentry release properly
- 5ae1b88: Fix shrinking page icons
- 8f046a9: Start using tint in more places, TOC and PageAside
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
To reduce the load on the API and also avoid errors.
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
- 8cfa67c: Fix default outline list styling
- d66c184: Ignore errors from event flushing
- 6088fa5: Simplify search results logic to investigate a bug
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
- 09c7c30: Try to fix error on og image generation
- ae99f87: Improve emoji setup, align with GitBook app
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
- 3a7210d: Fix zoom image view transition on Safari
- 718a8a5: Position the variant picker in the ToC
- e5dc05e: Update footer styling and allow for more than 4 footer groups
- 8276ba0: Make cookies access safer
- 1b8a456: Fix Image blocks zoomable behaviour
- 56c52e0: Handle Firefox security error on localStorage
- 0510b6f: Add section description to SectionGroupTile
- 1fcc807: Fix errors from customization not found
- 46edde9: Improve the OpenAPI package API
- 8af1abc: Improve contrast of search box placeholder
- 92b7668: Improve header offset
- d9c8d57: Do not dereference before caching OpenAPI spec.
- 94876e3: Fix regression issue with page icons for multi-line titles
- 47971dc: Fix OG image generation for non-latin characters
- 82dc9c4: Simplify the `useHash` algo used.
- 128ad20: Ignore cache invalidation error from local backend
- ff05e20: Improvements to inline images in headings
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
- 48ab59f: Improve colour contrast of list item decoration
- d2bc567: Set Sentry release
- 37d13d8: Avoid error on fetch by passing a string URL
- d3e573c: Generate sitemap for all sections and spaces
- f7b801b: Add feedback form to page rating control
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
- 46f63cb: Fix code format overriding inline link styles
- 5950657: Fix emojis display
- 528a053: Fix server actions stability leading to no results found sometimes on search
- eac1314: Lazy load iframely script to make page more responsive
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
- 6f54826: New highlight colors
- 5c87ec7: Implement a safer way to interact with localStorage.
If it's disabled on the browser it should not throw error.
- 02d876e: Fix search UI behaviour
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
- aaab157: Visual fix for section group in Safari
- cbe6139: Fix dynamic tabs infinite loop
- 65cc4af: Fix error when accessing a change request not found
- 727bde2: Improve and split OpenAPI parser into its own package
- 0b6ddca: Fix variant selector contrast for non-default themes
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
- fde32e2: Force route handler to be dynamic to avoid errors
- a025118: Change card layout depending on cover aspect ratio
- 300f7bf: Fix search loading state
- 29d5979: Disable C/C++ highlight temporarily
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
- 1c97536: Fix Sentry instrumentation
- b0bd871: Even safer localStorage
- b950a64: Avoid errors on legacy browsers
- 38061bd: Add section groups to section tabs
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
- 6691492: Fix viewing PDF from space
- e8e64bf: Fix bullet list display on full size blocks
- 16194c5: Vertical orientation for sections list on sites without header
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
- 1429384: Fix error when accessing some not found pages.
- 21cbd9e: Change link color to primary-subtle
- 5dab70f: Fix "Parser" language syntax highlighting
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
- 44a20fe: Improve smoothness of scroll listener
- 5664e5a: Fix variant dropdown styling in header
- 6b50360: Fix view transition error on Safari
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
- 5112e3e: Fix Sentry instrumentation server-side
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
- Updated dependencies [d9029c7]
- Updated dependencies [6e54a06]
- Updated dependencies [162b4b7]
- Updated dependencies [e4e2f52]
- Updated dependencies [0c03676]
- Updated dependencies [3e5e458]
- Updated dependencies [46edde9]
- Updated dependencies [d9c8d57]
- Updated dependencies [ccf2cff]
- Updated dependencies [dda0cc6]
- Updated dependencies [eb7c22f]
- Updated dependencies [ea1468c]
- Updated dependencies [648f0e9]
- Updated dependencies [160fca1]
- Updated dependencies [f92e906]
- Updated dependencies [e721f17]
- Updated dependencies [727bde2]
- Updated dependencies [dff08ae]
- Updated dependencies [fc7b16f]
- Updated dependencies [fe8acc9]
- Updated dependencies [1823101]
- Updated dependencies [a652958]
- Updated dependencies [2f73db7]
- Updated dependencies [160fca1]
- Updated dependencies [12c7862]
- Updated dependencies [b41d425]
- @gitbook/react-openapi@1.0.0
- @gitbook/openapi-parser@1.0.0
- @gitbook/react-contentkit@0.6.0
## 0.5.0
### Minor Changes
-235
View File
@@ -1,235 +0,0 @@
import { type TestsCase, runTestCases } from './util';
/** A list of test cases to run on the customers' docs sites. */
const testCases: TestsCase[] = [
{
name: 'Snyk',
baseUrl: 'https://docs.snyk.io',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
],
},
{
name: 'Nexthink',
baseUrl: 'https://docs.nexthink.com',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'asiksupport-stg.dto.kemkes.go.id',
baseUrl: 'https://asiksupport-stg.dto.kemkes.go.id',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'jasons-tutorials.gitbook.io',
baseUrl: 'https://jasons-tutorials.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'faq.deltaemulator.com',
baseUrl: 'https://faq.deltaemulator.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dify.ai',
baseUrl: 'https://docs.dify.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'seeddao.gitbook.io',
baseUrl: 'https://seeddao.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'faq.altstore.io',
baseUrl: 'https://faq.altstore.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'support.audacityteam.org',
baseUrl: 'https://support.audacityteam.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.gmgn.ai',
baseUrl: 'https://docs.gmgn.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.spicychat.ai',
baseUrl: 'https://docs.spicychat.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.portainer.io',
baseUrl: 'https://docs.portainer.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.chirptoken.io',
baseUrl: 'https://docs.chirptoken.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dexscreener.com',
baseUrl: 'https://docs.dexscreener.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.pancakeswap.finance',
baseUrl: 'https://docs.pancakeswap.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'book.character.ai',
baseUrl: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.tradeonnova.io',
baseUrl: 'https://docs.tradeonnova.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'azcoiner.gitbook.io',
baseUrl: 'https://azcoiner.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.midas.app',
baseUrl: 'https://docs.midas.app',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.keeper.io',
baseUrl: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'adiblar.gitbook.io',
baseUrl: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.gradient.network',
baseUrl: 'https://docs.gradient.network',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'mygate-network.gitbook.io',
baseUrl: 'https://mygate-network.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'treasurenft.gitbook.io',
baseUrl: 'https://treasurenft.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'browndust2.gitbook.io',
baseUrl: 'https://browndust2.gitbook.io',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'junookyo.gitbook.io',
baseUrl: 'https://junookyo.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'meshnet.nordvpn.com',
baseUrl: 'https://meshnet.nordvpn.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'manual.bubble.io',
baseUrl: 'https://manual.bubble.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.tickettool.xyz',
baseUrl: 'https://docs.tickettool.xyz',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'wiki.redmodding.org',
baseUrl: 'https://wiki.redmodding.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cherry-ai.com',
baseUrl: 'https://docs.cherry-ai.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.snyk.io',
baseUrl: 'https://docs.snyk.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.realapp.link',
baseUrl: 'https://docs.realapp.link',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.plaza.finance',
baseUrl: 'https://docs.plaza.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.publicai.io',
baseUrl: 'https://docs.publicai.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'hyperliquid.gitbook.io',
baseUrl: 'https://hyperliquid.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.umbraco.com',
baseUrl: 'https://docs.umbraco.com',
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'sosovalue-white-paper.gitbook.io',
baseUrl: 'https://sosovalue-white-paper.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.revrobotics.com',
baseUrl: 'https://docs.revrobotics.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'chartschool.stockcharts.com',
baseUrl: 'https://chartschool.stockcharts.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.soniclabs.com',
baseUrl: 'https://docs.soniclabs.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.meshchain.ai',
baseUrl: 'https://docs.meshchain.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.thousandeyes.com',
baseUrl: 'https://docs.thousandeyes.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.raydium.io',
baseUrl: 'https://docs.raydium.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.fluentbit.io',
baseUrl: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/' }],
},
];
runTestCases(testCases);
File diff suppressed because it is too large Load Diff
-354
View File
@@ -1,354 +0,0 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationFont,
type CustomizationHeaderItem,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationTheme,
CustomizationThemeMode,
type CustomizationThemedColor,
type SiteCustomizationSettings,
} from '@gitbook/api';
import { type BrowserContext, type Page, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
import { getContentTestURL } from '../tests/utils';
export interface Test {
name: string;
/**
* URL to visit for testing.
*/
url: string;
cookies?: Parameters<BrowserContext['addCookies']>[0];
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
fullPage?: boolean;
/**
* Whether to take a screenshot of the test or set a threshold for the screenshot.
*/
screenshot?:
| false
| {
/**
* Screenshot threshold.
* From 0 to 1, where 0 is the most strict and 1 is the most permissive.
* @default 0.5
*/
threshold?: number;
/**
* Whether to wait for the table of contents to finish scrolling before taking the screenshot.
*/
waitForTOCScrolling?: boolean;
};
/**
* Whether to only run this test.
*/
only?: boolean;
}
export interface TestsCase {
name: string;
baseUrl: string;
tests: Array<Test>;
}
export const allLocales: CustomizationLocale[] = [
CustomizationLocale.Fr,
CustomizationLocale.Es,
CustomizationLocale.Ja,
CustomizationLocale.Zh,
];
export const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
export const allTintColors: Array<{
label: string;
value: CustomizationThemedColor | undefined;
}> = [
{
label: 'Off',
value: undefined,
},
{ label: 'Primary', value: { light: '#346DDB', dark: '#346DDB' } },
{ label: 'Custom', value: { light: '#C62C68', dark: '#EF96B8' } },
];
export const allThemes: CustomizationTheme[] = [
CustomizationTheme.Clean,
CustomizationTheme.Muted,
CustomizationTheme.Bold,
CustomizationTheme.Gradient,
];
export const allDeprecatedThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Default,
CustomizationHeaderPreset.Bold,
CustomizationHeaderPreset.Contrast,
CustomizationHeaderPreset.Custom,
];
export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
CustomizationSidebarBackgroundStyle.Default,
CustomizationSidebarBackgroundStyle.Filled,
];
// Common customization settings
export const headerLinks: CustomizationHeaderItem[] = [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
links: [],
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
links: [],
},
];
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();
}
/**
* Transform test cases into Playwright tests and run it.
*/
export function runTestCases(testCases: TestsCase[]) {
for (const testCase of testCases) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, baseURL, context }) => {
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
const url = getContentTestURL(contentUrl.toString(), baseURL);
if (testEntry.cookies) {
await context.addCookies(
testEntry.cookies.map((cookie) => ({
...cookie,
domain: new URL(url).host,
path: '/',
}))
);
}
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async () => {
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
}
});
}
});
}
}
/**
* Create a URL with customization settings.
*/
export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
// We replicate the theme migration logic from the API to the tests, because the don't get these settings from the API.
// We can remove this once the migration to the new themes have been completed and the new theme styles are verified
// Map the theme preset (+ tint) to one of the new themes
const newTheme = (() => {
if (partial.styling?.theme) {
return partial.styling.theme;
}
switch (partial.header?.preset) {
case CustomizationHeaderPreset.Bold:
case CustomizationHeaderPreset.Contrast:
case CustomizationHeaderPreset.Custom:
return CustomizationTheme.Bold;
case CustomizationHeaderPreset.None:
case CustomizationHeaderPreset.Default:
if (partial.styling?.tint) {
return CustomizationTheme.Muted;
}
return CustomizationTheme.Clean;
default:
return CustomizationTheme.Clean;
}
})();
/**
* Default customization settings.
*
* The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults.
*/
const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = {
styling: {
theme: newTheme,
primaryColor: { light: '#346DDB', dark: '#346DDB' },
infoColor: { light: '#787878', dark: '#787878' },
warningColor: { light: '#FE9A00', dark: '#FE9A00' },
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
successColor: { light: '#00C950', dark: '#00C950' },
corners: CustomizationCorners.Rounded,
font: CustomizationFont.Inter,
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
links: CustomizationLinksStyle.Default,
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
},
},
internationalization: {
locale: CustomizationLocale.En,
},
favicon: {},
header: {
preset: CustomizationHeaderPreset.Default,
links: [],
},
footer: {
groups: [],
},
themes: {
default: CustomizationThemeMode.Light,
toggeable: true,
},
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
aiSearch: {
enabled: true,
},
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
trademark: {
enabled: true,
},
privacyPolicy: {
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
return `?${searchParams.toString()}`;
}
/**
* 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;
});
}
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
await Promise.all(
icons.map(async (icon) => {
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
throw new Error('No mask-image');
}
await loadImage(url);
})
);
});
}
/**
* Wait for TOC to be correctly scrolled into view.
*/
async function waitForTOCScrolling(page: Page) {
const viewport = await page.viewportSize();
if (viewport && viewport.width >= 1024) {
const toc = page.getByTestId('table-of-contents');
await expect(toc).toBeVisible();
await page.evaluate(() => {
const tocScrollContainer = document.querySelector(
'[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]'
);
if (!tocScrollContainer) {
throw new Error('TOC scroll container not found');
}
tocScrollContainer.scrollTo(0, 0);
});
}
}
+9 -7
View File
@@ -4,13 +4,11 @@ module.exports = withSentryConfig(
{
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
},
webpack(config, { dev, webpack }) {
@@ -34,7 +32,7 @@ module.exports = withSentryConfig(
__RRWEB_EXCLUDE_IFRAME__: true,
__RRWEB_EXCLUDE_SHADOW_DOM__: true,
__SENTRY_EXCLUDE_REPLAY_WORKER__: true,
})
}),
);
}
@@ -69,15 +67,19 @@ module.exports = withSentryConfig(
},
},
{
release: process.env.SENTRY_RELEASE,
silent: true,
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,
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring',
// Don't hide source maps from generated client bundles
hideSourceMaps: false,
disableLogger: true,
}
automaticVercelMonitors: false,
},
);
+26 -24
View File
@@ -1,81 +1,80 @@
{
"name": "gitbook",
"version": "0.6.5",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
"build": "next build",
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"e2e": "playwright test e2e/internal.spec.ts",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"e2e": "playwright test",
"unit": "bun test {src,packages}",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/api": "^0.84.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@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",
"@sentry/nextjs": "^7.94.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"content-security-policy-merger": "^1.0.0",
"framer-motion": "^10.16.14",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"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",
"next": "14.2.15",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"postcss-color-contrast": "^1.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hotkeys-hook": "^4.4.1",
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.27.2",
"shiki": "^1.23.1",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0",
"ai": "^4.1.46"
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@argos-ci/playwright": "^3.10.0",
"@cloudflare/next-on-pages": "1.13.7",
"vercel": "^39.3.0",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "^1.13.5",
"@cloudflare/workers-types": "^4.20240725.0",
"@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
@@ -86,6 +85,9 @@
"autoprefixer": "^10",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
"eslint": "^8",
"eslint-config-next": "^14.2.5",
"eslint-plugin-import": "^2.29.1",
"jsonwebtoken": "^9.0.2",
"postcss": "^8",
"psi": "^4.1.0",
+18 -9
View File
@@ -1,18 +1,27 @@
import * as Sentry from '@sentry/browser';
import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
// To tree shake default integrations that we don't use
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
const client = new BrowserClient({
debug: false,
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
integrations: [],
sampleRate: 0.1,
enableTracing: false,
beforeSendTransaction: () => {
return null;
},
transport: makeFetchTransport,
stackParser: defaultStackParser,
});
getCurrentScope().setClient(client);
client.init();
}
@@ -3,11 +3,13 @@ import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
init({
debug: false,
dsn,
release: process.env.SENTRY_RELEASE,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
enableTracing: false,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
@@ -16,5 +18,3 @@ if (dsn) {
},
});
}
export * from '@sentry/nextjs';
+20
View File
@@ -0,0 +1,20 @@
import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
init({
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
// 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;
},
});
}

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