Compare commits

..

8 Commits

Author SHA1 Message Date
Zeno Kapitein d678a9162d Fix typing & translations 2025-05-23 16:54:28 +02:00
Zeno Kapitein af7cadc8e9 Added back and forth chat 2025-05-21 18:08:02 +02:00
Zeno Kapitein e2b2468475 More layout tweaks 2025-05-21 15:13:22 +02:00
Zeno Kapitein 11f4241709 Update layout and transitions 2025-05-21 14:02:48 +02:00
Zeno Kapitein 5a410feda0 Add chat component (non-functional) 2025-05-21 14:02:48 +02:00
Zeno Kapitein 31bfe77f74 Create Chat component 2025-05-21 14:02:48 +02:00
Zeno Kapitein 34dceb2f97 Toggleable layout 2025-05-21 14:02:48 +02:00
Zeno Kapitein cced4f0d78 Initial styling 2025-05-21 14:02:48 +02:00
80 changed files with 1281 additions and 1610 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
fix nested a tag causing hydration error
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
fix href being empty in TOC
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix navigation between sections/variants when previewing a site in v2
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Add authorization header for OAuth2
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook-v2": patch
---
add a force-revalidate api route to force bust the cache in case of errors
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Fix invalid HTML on an Announcement banner without a CTA.
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Indent JSON python code sample
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook": patch
"gitbook-v2": patch
---
cache fonts and static image used in OGImage in memory
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Handle nested deprecated properties in generateSchemaExample
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Deduplicate path parameters from OpenAPI spec
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook-v2": patch
---
remove trailing slash from linker
@@ -1,83 +0,0 @@
name: Gradual Deploy to Cloudflare
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
inputs:
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
middlewareVersionId:
description: 'Middleware version ID to deploy'
required: true
serverVersionId:
description: 'Server version ID to deploy'
required: true
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
@@ -28,7 +28,7 @@ inputs:
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
using: 'composite'
steps:
@@ -63,8 +63,8 @@ runs:
env:
GITBOOK_RUNTIME: cloudflare
shell: bash
- name: Upload the DO worker
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
@@ -72,67 +72,10 @@ runs:
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc
- id: upload_server
name: Upload server to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
- name: Extract server version worker ID
shell: bash
id: extract_server_version_id
run: |
version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- name: Run updateWrangler scripts
shell: bash
run: |
bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
- id: upload_middleware
name: Upload middleware to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
run: |
version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- name: Deploy server and middleware to Cloudflare
if: ${{ inputs.deploy == 'true' }}
uses: ./.github/actions/gradual-deploy-cloudflare
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
opServiceAccount: ${{ inputs.opServiceAccount }}
opItem: ${{ inputs.opItem }}
environment: ${{ inputs.environment }}
serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }}
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
deploy: ${{ inputs.deploy }}
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}"
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
-1
View File
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+25 -28
View File
@@ -1,29 +1,26 @@
import type { OpenNextConfig } from '@opennextjs/cloudflare';
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import {
softTagFilter,
withFilter,
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
export default {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/server').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
dangerous: {
enableCacheInterception: true,
},
edgeExternals: ['node:crypto'],
} satisfies OpenNextConfig;
export default defineCloudflareConfig({
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: withFilter({
tagCache: doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
},
}),
// We don't use `revalidatePath`, so we filter out soft tags
filterFn: softTagFilter,
}),
queue: () => import('./openNext/queue').then((m) => m.default),
// Performance improvements as we don't use PPR
enableCacheInterception: true,
});
@@ -1,36 +0,0 @@
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { DurableObject } from 'cloudflare:workers';
// Only needed to run locally, in prod we'll use the one from do.js
export class R2WriteBuffer extends DurableObject {
writePromise;
async write(cacheKey, value) {
// We are already writing to this key
if (this.writePromise) {
return;
}
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.ctx.waitUntil(
this.writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
export default {
async fetch(request, env, ctx) {
return runWithCloudflareRequestContext(request, env, ctx, async () => {
// We can't move the handler import to the top level, otherwise the runtime will not be properly initialized
const { handler } = await import(
'../../.open-next/server-functions/default/handler.mjs'
);
// - `Request`s are handled by the Next server
return handler(request, env, ctx);
});
},
};
@@ -1,163 +0,0 @@
{
"main": "default.js",
"name": "gitbook-open-v2-server",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": true
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"env": {
"dev": {
"vars": {
"STAGE": "dev"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-dev"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["R2WriteBuffer"]
}
]
},
"preview": {
"vars": {
"STAGE": "preview",
// Just as a test for the preview environment to check that everything works
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-preview"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-preview"
}
]
}
},
"staging": {
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-staging"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-staging"
}
]
},
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
]
},
"production": {
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-production"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-production"
}
]
},
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
]
}
}
}
@@ -1,38 +0,0 @@
// This worker only purposes it to host the different DO that we will need in the other workers.
import { DurableObject } from 'cloudflare:workers';
// `use cache` could cause multiple writes to the same key to happen concurrently, there is a limit of 1 write per key/second
// so we need to buffer writes to the R2 bucket to avoid hitting this limit.
export class R2WriteBuffer extends DurableObject {
writePromise;
async write(cacheKey, value) {
// We are already writing to this key
if (this.writePromise) {
return;
}
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.ctx.waitUntil(
this.writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default {
async fetch() {
// This worker does not handle any requests, it only provides Durable Objects
return new Response('This worker is not meant to handle requests directly', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
});
},
};
@@ -1,127 +0,0 @@
{
"main": "do.js",
"name": "gitbook-open-v2-do",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": true
},
"env": {
"preview": {
"vars": {
"STAGE": "preview",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
}
]
},
"staging": {
"vars": {
"STAGE": "staging",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
}
]
},
"production": {
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
"STAGE": "production"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
}
]
}
}
}
@@ -1,42 +0,0 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs';
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default class extends WorkerEntrypoint {
async fetch(request) {
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
// - `Request`s are handled by the Next server
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
if (reqOrResp instanceof Response) {
return reqOrResp;
}
if (this.env.STAGE !== 'preview') {
// https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity
reqOrResp.headers.set(
'Cloudflare-Workers-Version-Overrides',
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
);
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
cf: {
cacheEverything: false,
},
});
}
// If we are in preview mode, we need to send the request to the preview URL
const modifiedUrl = new URL(reqOrResp.url);
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
const nextRequest = new Request(modifiedUrl, reqOrResp);
return fetch(nextRequest, {
cf: {
cacheEverything: false,
},
});
});
}
}
@@ -1,216 +0,0 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"assets": {
"directory": "../../.open-next/assets",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"env": {
"dev": {
"vars": {
"STAGE": "dev",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-dev"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-dev"
}
]
},
"preview": {
"vars": {
"STAGE": "preview",
"PREVIEW_HOSTNAME": "TO_REPLACE",
"WORKER_VERSION_ID": "TO_REPLACE"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-preview"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-preview"
}
]
}
},
"staging": {
"vars": {
"STAGE": "staging",
"WORKER_VERSION_ID": "TO_REPLACE"
},
"routes": [
{
"pattern": "open-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
},
{
"pattern": "static-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-staging"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-staging"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
},
"production": {
"vars": {
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
// This means that we'll have up to 100 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "100",
// Temporary variable to find the issue once deployed
// TODO: remove this once the issue is fixed
"DEBUG_CLOUDFLARE": "true",
"WORKER_VERSION_ID": "TO_REPLACE",
"STAGE": "production"
},
"routes": [
{
"pattern": "open-2c.gitbook.com/*",
"zone_name": "gitbook.com"
},
{
"pattern": "static-2c.gitbook.com/*",
"zone_name": "gitbook.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-production"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-production"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
}
}
}
@@ -1,26 +0,0 @@
// In this script, we use the args from the cli to update the PREVIEW_URL vars in the wrangler config file for the middleware
import fs from 'node:fs';
import path from 'node:path';
const wranglerConfigPath = path.join(__dirname, '../middlewareWrangler.jsonc');
const file = fs.readFileSync(wranglerConfigPath, 'utf-8');
const args = process.argv.slice(2);
// The versionId is in the format xxx-xxx-xxx-xxx, we need the first part to reconstruct the preview URL
const versionId = args[0];
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
let updatedFile = file.replace(
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
`"PREVIEW_HOSTNAME": "${previewHostname}"`
);
updatedFile = updatedFile.replaceAll(
/"WORKER_VERSION_ID": "TO_REPLACE"/g,
`"WORKER_VERSION_ID": "${versionId}"`
);
fs.writeFileSync(wranglerConfigPath, updatedFile);
@@ -9,8 +9,6 @@ import type {
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
export const DEFAULT_PREFIX = 'incremental-cache';
@@ -81,10 +79,12 @@ class GitbookIncrementalCache implements IncrementalCache {
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
await this.writeToR2(cacheKey, JSON.stringify(value));
await r2.put(cacheKey, JSON.stringify(value));
//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
@@ -145,22 +145,6 @@ class GitbookIncrementalCache implements IncrementalCache {
);
}
async writeToR2(key: string, value: string): Promise<void> {
const env = getCloudflareContext().env as {
WRITE_BUFFER: DurableObjectNamespace<
Rpc.DurableObjectBranded & {
write: (key: string, value: string) => Promise<void>;
}
>;
};
const id = env.WRITE_BUFFER.idFromName(key);
// A stub is a client used to invoke methods on the Durable Object
const stub = env.WRITE_BUFFER.get(id);
await stub.write(key, value);
}
async getCacheInstance(): Promise<Cache> {
if (this.localCache) return this.localCache;
this.localCache = await caches.open('incremental-cache');
+17
View File
@@ -0,0 +1,17 @@
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import memoryQueue from '@opennextjs/cloudflare/overrides/queue/memory-queue';
interface Env {
IS_PREVIEW?: string;
}
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
const { ctx, env } = getCloudflareContext();
const isPreview = (env as Env).IS_PREVIEW === 'true';
ctx.waitUntil(isPreview ? memoryQueue.send(msg) : doQueue.send(msg));
},
} satisfies Queue;
@@ -1,21 +0,0 @@
import { trace } from '@/lib/tracing';
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import memoryQueue from '@opennextjs/cloudflare/overrides/queue/memory-queue';
interface Env {
STAGE?: string;
}
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
const { ctx, env } = getCloudflareContext();
const hasDurableObject =
(env as Env).STAGE !== 'dev' && (env as Env).STAGE !== 'preview';
ctx.waitUntil(hasDurableObject ? memoryQueue.send(msg) : doQueue.send(msg));
});
},
} satisfies Queue;
@@ -1,9 +0,0 @@
import type { Queue } from '@opennextjs/aws/types/overrides.js';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
// We should never reach this point in the server. If that's the case, we should log it.
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
},
} satisfies Queue;
@@ -1,78 +0,0 @@
import { trace } from '@/lib/tracing';
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
// We can probably increase this value even further
regionalCacheTtlSec: 60,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
},
});
export default {
name: 'GitbookTagCache',
mode: 'nextMode',
getLastRevalidated: async (tags: string[]) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
tags
);
return 0; // If no tags to check, return 0
}
return trace(
{
operation: 'gitbookTagCacheGetLastRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
return await originalTagCache.getLastRevalidated(tagsToCheck);
}
);
},
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
tags
);
return false; // If no tags to check, return false
}
return trace(
{
operation: 'gitbookTagCacheHasBeenRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
return result;
}
);
},
writeTags: async (tags: string[]) => {
return trace(
{
operation: 'gitbookTagCacheWriteTags',
name: tags.join(', '),
},
async () => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
console.warn('writeTags - No valid tags to write');
return; // If no tags to write, exit early
}
// Write only the filtered tags
await originalTagCache.writeTags(tagsToWrite);
}
);
},
} satisfies NextModeTagCache;
-2
View File
@@ -30,8 +30,6 @@
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare build",
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:v2:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:v2:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"unit": "bun test",
"typecheck": "tsc --noEmit"
}
+22 -1
View File
@@ -2,13 +2,14 @@ import { trace } from '@/lib/tracing';
import {
type ComputedContentSource,
GitBookAPI,
type GitBookAPIServiceBinding,
type HttpResponse,
type RenderIntegrationUI,
} from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { getCloudflareRequestGlobal } from './cloudflare';
import { getCloudflareContext, getCloudflareRequestGlobal } from './cloudflare';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
import type { GitBookDataFetcher } from './types';
@@ -827,16 +828,36 @@ async function* streamAIResponse(
}
}
let loggedServiceBinding = false;
/**
* Create a new API client.
*/
export function apiClient(input: DataFetcherInput = { apiToken: null }) {
const { apiToken } = input;
let serviceBinding: GitBookAPIServiceBinding | undefined;
const cloudflareContext = getCloudflareContext();
if (cloudflareContext) {
// @ts-expect-error
serviceBinding = cloudflareContext.env.GITBOOK_API as GitBookAPIServiceBinding | undefined;
if (!loggedServiceBinding) {
loggedServiceBinding = true;
if (serviceBinding) {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`using service binding for the API (${GITBOOK_API_URL})`);
} else {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
}
}
}
const api = new GitBookAPI({
authToken: apiToken || GITBOOK_API_TOKEN || undefined,
endpoint: GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
serviceBinding,
});
return api;
@@ -4,4 +4,5 @@ export * from './pages';
export * from './urls';
export * from './errors';
export * from './lookup';
export * from './proxy';
export * from './visitor';
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
tools?: api.AIToolCapabilities;
previousResponseId?: string;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import type { PublishedSiteContent } from '@gitbook/api';
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
/**
* Get the appropriate base path for the visitor authentication cookie.
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getImageResizingContextId } from './getImageResizingContextId';
describe('getImageResizingContextId', () => {
it('should return proxy identifier for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(getImageResizingContextId(proxyRequestURL)).toBe('sites/site_foo');
});
it('should return preview identifier for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo');
});
it('should return host for regular requests', () => {
const regularRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(getImageResizingContextId(regularRequestURL)).toBe('example.com');
});
});
@@ -1,5 +1,4 @@
import { getPreviewRequestIdentifier, isPreviewRequest } from '@v2/lib/preview';
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
import { getProxyRequestIdentifier, isProxyRequest } from '../data';
/**
* Get the site identifier to use for image resizing for an incoming request.
@@ -9,9 +8,6 @@ export function getImageResizingContextId(url: URL): string {
if (isProxyRequest(url)) {
return getProxyRequestIdentifier(url);
}
if (isPreviewRequest(url)) {
return getPreviewRequestIdentifier(url);
}
return url.host;
}
+1 -21
View File
@@ -19,7 +19,7 @@ const siteGitBookIO = createLinker({
siteBasePath: '/sitename/',
});
describe('toPathInSpace', () => {
describe('toPathInContent', () => {
it('should return the correct path', () => {
expect(root.toPathInSpace('some/path')).toBe('/some/path');
expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path');
@@ -29,16 +29,6 @@ describe('toPathInSpace', () => {
expect(root.toPathInSpace('/some/path')).toBe('/some/path');
expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path');
});
it('should remove the trailing slash', () => {
expect(root.toPathInSpace('some/path/')).toBe('/some/path');
expect(variantInSection.toPathInSpace('some/path/')).toBe('/section/variant/some/path');
});
it('should not add a trailing slash', () => {
expect(root.toPathInSpace('')).toBe('');
expect(variantInSection.toPathInSpace('')).toBe('/section/variant');
});
});
describe('toPathInSite', () => {
@@ -46,16 +36,6 @@ describe('toPathInSite', () => {
expect(root.toPathInSite('some/path')).toBe('/some/path');
expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path');
});
it('should remove the trailing slash', () => {
expect(root.toPathInSite('some/path/')).toBe('/some/path');
expect(siteGitBookIO.toPathInSite('some/path/')).toBe('/sitename/some/path');
});
it('should not add a trailing slash', () => {
expect(root.toPathInSite('')).toBe('');
expect(siteGitBookIO.toPathInSite('')).toBe('/sitename');
});
});
describe('toRelativePathInSite', () => {
+1 -5
View File
@@ -128,9 +128,5 @@ export function createLinker(
function joinPaths(prefix: string, path: string): string {
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
return removeTrailingSlash(prefixPath + suffixPath);
}
function removeTrailingSlash(path: string): string {
return path.endsWith('/') ? path.slice(0, -1) : path;
return prefixPath + suffixPath;
}
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
describe('isPreviewRequest', () => {
it('should return true for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
expect(isPreviewRequest(previewRequestURL)).toBe(true);
});
it('should return false for non-preview requests', () => {
const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false);
});
});
describe('getPreviewRequestIdentifier', () => {
it('should return the correct identifier for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
});
});
-13
View File
@@ -1,13 +0,0 @@
/**
* Check if the request to the site is a preview request.
*/
export function isPreviewRequest(requestURL: URL): boolean {
return requestURL.host === 'preview';
}
export function getPreviewRequestIdentifier(requestURL: URL): string {
// For preview requests, we extract the site ID from the pathname
// e.g. https://preview/site_id/...
const pathname = requestURL.pathname.slice(1).split('/');
return pathname[0];
}
+1 -2
View File
@@ -104,7 +104,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError(
resolve({
url: siteRequestURL.toString(),
@@ -291,7 +290,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return writeResponseCookies(response, cookies);
};
// For https://preview/<siteId> requests,
// For https://preview/<siteURL> requests,
if (siteRequestURL.hostname === 'preview') {
return serveWithQueryAPIToken(
// We scope the API token to the site ID.
@@ -1,49 +0,0 @@
import crypto from 'node:crypto';
import type { NextApiRequest, NextApiResponse } from 'next';
interface JsonBody {
// The paths need to be the rewritten one, `res.revalidate` call don't go through the middleware
paths: string[];
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const signatureHeader = req.headers['x-gitbook-signature'] as string | undefined;
if (!signatureHeader) {
return res.status(400).json({ error: 'Missing signature header' });
}
// We cannot use env from `@/v2/lib/env` here as it make it crash because of the import "server-only" in the file.
if (process.env.GITBOOK_SECRET) {
try {
const computedSignature = crypto
.createHmac('sha256', process.env.GITBOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (computedSignature === signatureHeader) {
const results = await Promise.allSettled(
(req.body as JsonBody).paths.map((path) => {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating path: ${path}`);
return res.revalidate(path);
})
);
return res.status(200).json({
success: results.every((result) => result.status === 'fulfilled'),
errors: results
.filter((result) => result.status === 'rejected')
.map((result) => (result as PromiseRejectedResult).reason),
});
}
return res.status(401).json({ error: 'Invalid signature' });
} catch (error) {
console.error('Error during revalidation:', error);
return res.status(400).json({ error: 'Invalid request or unable to parse JSON' });
}
}
// If no secret is set, we do not allow revalidation
return res.status(403).json({ error: 'Revalidation is disabled' });
}
@@ -1,5 +1,10 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import {
type AIMessageInput,
AIModel,
type AIStreamResponse,
type AIToolCapabilities,
} from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
@@ -51,6 +56,7 @@ export async function streamGenerateObject<T>(
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
tools?: AIToolCapabilities;
previousResponseId?: string;
}
) {
@@ -0,0 +1,166 @@
'use server';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
}: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
visitedPages?: Array<{ spaceId: string; pageId: string }>;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream } = await streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
highlight: z
.string()
.describe('The reason why the user should read the target page.'),
// questions: z.array(z.string().describe('The questions to sea')).max(3),
}),
messages: [
{
role: AIMessageRole.Developer,
content: `# 1. Role
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
# 2. Task
Extract a contextually-relevant fact that:
- Directly answers the specific need or question implied by the link's placement
- States a capability, limitation, or specification from the target page
- Connects precisely to the user's current paragraph or sentence
- Completes the user's understanding based on what they're currently reading
# 3. Instructions
1. First, identify the exact need, question, or gap in the current paragraph where the link appears
2. Find the specific fact in the target page that addresses this exact contextual need
3. Ensure the fact relates directly to the context of the paragraph containing the link
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
},
{
role: AIMessageRole.Developer,
content: `# 4. Current page
The content of the current page is:`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpaceId,
pageId: currentPageId,
},
],
},
...(visitedPages
? [
{
role: AIMessageRole.Developer,
content: '# 5. Previous pages',
},
...visitedPages.map(({ spaceId, pageId }) => ({
role: AIMessageRole.Developer,
content: `## Page ${pageId}`,
attachments: [
{
type: 'page' as const,
spaceId,
pageId,
},
],
})),
]
: []),
{
role: AIMessageRole.Developer,
content: `# 6. Target page
The content of the target page is:`,
attachments: [
{
type: 'page' as const,
spaceId: targetSpaceId,
pageId: targetPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `# 7. Link preview
The content of the link preview is:
> ${linkPreview}
> Page ID: ${targetPageId}`,
},
{
role: AIMessageRole.Developer,
content: `# 8. Guidelines & Examples
ALWAYS:
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
- ALWAYS connect target page information specifically to the current paragraph context
- ALWAYS focus on the gap in knowledge that the link is meant to fill
- ALWAYS consider user's navigation history to ensure contextual continuity
- ALWAYS use action verbs like "click", "select", "use", "create", "enable"
NEVER:
- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly.
- NEVER select general facts unrelated to the specific link context
- NEVER ignore the specific context where the link appears
- NEVER repeat the same fact in different words
## Examples
Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once."
Preview: "Multiple Select: Select multiple content blocks at once."
✓ "Shift selects content between two points, useful for reorganizing your current heading structure."
✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks."
Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]."
Preview: "Change Requests: Collaborative content editing workflow."
✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions."
✗ "Each reviewer receives an email notification and can approve or request changes."
Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]."
Preview: "Live Edit: Real-time collaborative editing."
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
},
{
role: AIMessageRole.User,
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
},
].filter(filterOutNullable),
}
);
for await (const value of stream) {
const highlight = value.highlight;
if (!highlight) {
continue;
}
yield highlight;
}
}
@@ -18,18 +18,35 @@ export function AnnouncementBanner(props: {
}) {
const { announcement, contentRef } = props;
const hasLink = contentRef?.href;
const hasLink = announcement.link && contentRef?.href;
const closeable = announcement.style !== 'danger';
const Tag = hasLink ? Link : 'div';
const style = BANNER_STYLES[announcement.style];
return (
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<AnnouncementBannerParent
announcement={announcement}
closeable={closeable}
contentRef={contentRef}
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
@@ -58,7 +75,7 @@ export function AnnouncementBanner(props: {
</div>
) : null}
</div>
</AnnouncementBannerParent>
</Tag>
{closeable ? (
<button
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
@@ -74,48 +91,6 @@ export function AnnouncementBanner(props: {
);
}
/**
* Render the appropriate parent for the announcement banner depending on the presence of a link.
*/
function AnnouncementBannerParent(props: {
announcement: CustomizationAnnouncement;
children: React.ReactNode;
closeable: boolean;
contentRef: ResolvedContentRef | null;
}) {
const { announcement, contentRef, closeable, children } = props;
const style = BANNER_STYLES[announcement.style];
const classNames = [
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
];
if (contentRef?.href) {
return (
<Link
href={contentRef.href}
className={tcls(classNames, closeable && 'pr-12', style.hover)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
{children}
</Link>
);
}
return <div className={tcls(classNames)}>{children}</div>;
}
/**
* Dismiss the announcement banner and store the dismissal state in local storage.
* @see AnnouncementScript
@@ -4,7 +4,7 @@ import {
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Link } from '@/components/primitives';
import { Image } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -44,6 +44,7 @@ export async function RecordCard(
<div
className={tcls(
'grid-area-1-1',
'z-0',
'relative',
'grid',
'bg-tint-base',
@@ -150,6 +151,7 @@ export async function RecordCard(
'rounded-md',
'straight-corners:rounded-none',
'dark:shadow-transparent',
'z-0',
'before:pointer-events-none',
'before:grid-area-1-1',
@@ -165,22 +167,19 @@ export async function RecordCard(
if (target && targetRef) {
return (
// We don't use `Link` directly here because we could end up in a situation where
// a link is rendered inside a link, which is not allowed in HTML.
// It causes an hydration error in React.
<LinkBox href={target.href} className={tcls(style, 'hover:before:ring-tint-12/5')}>
<LinkOverlay
href={target.href}
insights={{
type: 'link_click',
link: {
target: targetRef,
position: SiteInsightsLinkPosition.Content,
},
}}
/>
<Link
href={target.href}
className={tcls(style, 'hover:before:ring-tint-12/5')}
insights={{
type: 'link_click',
link: {
target: targetRef,
position: SiteInsightsLinkPosition.Content,
},
}}
>
{body}
</LinkBox>
</Link>
);
}
@@ -115,8 +115,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
}
const horizontalAlignment = getColumnAlignment(definition);
const childrenHorizontalAlignment = `[&_*]:${horizontalAlignment}`;
const horizontalAlignment = `[&_*]:${getColumnAlignment(definition)} ${getColumnAlignment(definition)}`;
return (
<Blocks
@@ -132,7 +131,6 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
'leading-normal',
verticalAlignment,
horizontalAlignment,
childrenHorizontalAlignment,
]}
context={context}
blockStyle={['w-full', 'max-w-[unset]']}
@@ -2,7 +2,6 @@ import type { SiteSpace } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { joinPath } from '@/lib/paths';
import type { GitBookSiteContext } from '@v2/lib/context';
import { DropdownChevron, DropdownMenu } from './DropdownMenu';
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
@@ -75,7 +74,7 @@ export function SpacesDropdown(props: {
title: otherSiteSpace.title,
url: otherSiteSpace.urls.published
? linker.toLinkForContent(otherSiteSpace.urls.published)
: getFallbackSiteSpaceURL(otherSiteSpace, context),
: otherSiteSpace.space.urls.app,
}}
active={otherSiteSpace.id === siteSpace.id}
/>
@@ -83,14 +82,3 @@ export function SpacesDropdown(props: {
</DropdownMenu>
);
}
/**
* When the site is not published yet, `urls.published` is not available.
* To ensure navigation works in preview, we compute a relative URL from the siteSpace path.
*/
function getFallbackSiteSpaceURL(siteSpace: SiteSpace, context: GitBookSiteContext) {
const { linker, sections } = context;
return linker.toPathInSite(
sections?.current ? joinPath(sections.current.path, siteSpace.path) : siteSpace.path
);
}
@@ -39,7 +39,7 @@
/* Light mode */
::-webkit-scrollbar {
@apply bg-tint-subtle;
@apply bg-tint-subtle z-50;
width: 8px;
height: 8px;
}
@@ -148,13 +148,6 @@
width: 100%;
}
}
.elevate-link {
& a[href]:not(.link-overlay) {
position: relative;
z-index: 20;
}
}
}
html {
@@ -18,8 +18,7 @@ export function HighlightQuery(props: {
'text-bold',
'bg-primary',
'text-contrast-primary',
'px-0.5',
'-mx-0.5',
'px-1',
'py-0.5',
'rounded',
'straight-corners:rounded-sm',
@@ -1,21 +1,19 @@
'use client';
import { Icon } from '@gitbook/icons';
import { readStreamableValue } from 'ai/rsc';
import React from 'react';
import { Loading } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { readStreamableValue } from 'ai/rsc';
import React from 'react';
import { motion } from 'framer-motion';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext';
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch';
export type SearchAskState =
| {
type: 'answer';
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
}, [setAskState]);
const loading = (
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
{[...Array(9)].map((_, index) => (
<div
key={index}
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
style={{
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
width: `${((index % 5) + 1) * 15}%`,
}}
/>
))}
</div>
);
return (
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
<motion.div className={tcls('mx-auto w-full max-w-prose')} layout="position">
{askState?.type === 'answer' ? (
<React.Suspense fallback={loading}>
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
@@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
) : null}
{askState?.type === 'loading' ? loading : null}
</div>
</motion.div>
);
}
@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
return (
<>
<div
data-testid="search-ask-answer"
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
>
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
{answer.body ?? t(language, 'search_ask_no_answer')}
{answer.followupQuestions.length > 0 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
@@ -182,7 +186,6 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
)}
{...getSearchLinkProps({
query: question,
ask: true,
})}
>
<Icon
@@ -21,7 +21,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
const onClick = () => {
setSearchState({
ask: false,
mode: 'both',
global: false,
query: '',
});
@@ -99,7 +99,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
);
}
function Shortcut() {
export function Shortcut() {
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
useEffect(() => {
@@ -0,0 +1,502 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef, useState } from 'react';
import { useVisitedPages } from '../Insights/useVisitedPages';
import { Button } from '../primitives';
import { Shortcut } from './SearchButton';
import { isQuestion } from './isQuestion';
import { streamAISearchAnswer, streamAISearchSummary } from './server-actions';
import { useSearch } from './useSearch';
// Types
type Message = {
role: 'assistant' | 'user';
content?: string;
context?: string;
fetching?: boolean;
};
// Loading animation component
function LoadingAnimation() {
return (
<div className="mt-2 flex flex-wrap gap-2">
{[...Array(9)].map((_, index) => (
<div
key={index}
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
style={{
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
width: `${((index % 5) + 1) * 15}%`,
}}
/>
))}
</div>
);
}
// Followup questions component
function FollowupQuestions({
questions,
onQuestionClick,
}: {
questions: string[];
onQuestionClick: (question: string) => void;
}) {
if (!questions || questions.length === 0) return null;
return (
<div className="mx-auto flex w-full max-w-prose flex-col">
{questions.map((question) => (
<button
type="button"
key={question}
className="-mx-4 flex items-center gap-4 rounded straight-corners:rounded-none px-4 py-2 text-tint hover:bg-tint-hover"
onClick={() => onQuestionClick(question)}
>
<Icon icon="search" className="size-4" /> {question}
</button>
))}
</div>
);
}
// Individual chat message component
function ChatMessage({
message,
}: {
message: Message;
}) {
const language = useLanguage();
const isUser = message.role === 'user';
return (
<div className={tcls('flex-col gap-1', isUser && 'items-end gap-1 self-end')}>
<h5 className="flex items-center gap-1 font-semibold text-tint-subtle text-xs">
{isUser ? (
(message.context ??
`You asked ${isQuestion(message.content ?? '') ? '' : 'about'}`)
) : (
<>
<Icon icon="sparkle" className="mt-0.5 size-3" />
{message.context ?? 'AI Answer'}
</>
)}
</h5>
{message.fetching ? (
<LoadingAnimation />
) : !message.content ? (
<div className="text-tint-subtle italic">{t(language, 'search_ask_no_answer')}</div>
) : (
<div className={tcls(isUser && 'rounded-lg bg-tint-active px-4 py-2')}>
{message.content}
</div>
)}
</div>
);
}
// Chat input component
function ChatInput({
onSendMessage,
disabled,
inputRef,
}: {
onSendMessage: (message: string) => void;
disabled: boolean;
inputRef?: React.RefObject<HTMLInputElement>;
}) {
const [inputValue, setInputValue] = useState('');
const handleSend = () => {
if (!inputValue.trim()) return;
onSendMessage(inputValue);
setInputValue('');
};
return (
<div className="flex gap-2">
<div className="relative flex grow">
<input
ref={inputRef}
type="text"
placeholder="Ask a follow-up question"
className="grow rounded px-4 py-1 ring-1 ring-tint-subtle"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={disabled}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
{!disabled && (
<div className="-translate-y-1/2 absolute top-1/2 right-2.5">
<Shortcut />
</div>
)}
</div>
<Button
label="Send"
iconOnly
icon="arrow-up"
size="medium"
className="shrink-0"
onClick={handleSend}
/>
</div>
);
}
// Custom hook for AI streaming
function useAIStream({
question,
previousResponseId,
}: {
question?: string;
previousResponseId?: string;
}) {
const [response, setResponse] = useState<{
content?: string;
responseId?: string;
followupQuestions?: string[];
fetching: boolean;
}>({
fetching: false,
});
useEffect(() => {
if (!question) return;
let cancelled = false;
setResponse({ fetching: true });
(async () => {
try {
const stream = await streamAISearchAnswer({
question,
previousResponseId,
});
for await (const rawData of stream) {
if (cancelled) break;
if (!rawData) continue;
// Use type assertion to handle the data
const data = rawData as any;
setResponse((prev) => {
const updated = { ...prev, fetching: false };
if (data.responseId) {
updated.responseId = String(data.responseId);
}
if (data.answer) {
updated.content = String(data.answer);
}
if (data.followupQuestions) {
updated.followupQuestions =
data.followupQuestions.filter(filterOutNullable);
}
return updated;
});
}
} catch (error) {
console.error('Error in AI stream:', error);
setResponse((prev) => ({ ...prev, fetching: false }));
}
})();
return () => {
cancelled = true;
};
}, [question, previousResponseId]);
return response;
}
// Summary hook
function useSummary(visitedPages: any[]) {
const [summary, setSummary] = useState('');
const [summaryResponseId, setSummaryResponseId] = useState<string | undefined>(undefined);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const stream = await streamAISearchSummary({ visitedPages });
for await (const rawData of stream) {
if (cancelled) break;
if (!rawData) continue;
// Use type assertion
const data = rawData as any;
if (data.responseId) {
setSummaryResponseId(String(data.responseId));
}
if (data.summary) {
setSummary(String(data.summary));
}
}
} catch (error) {
console.error('Error in summary stream:', error);
}
})();
return () => {
cancelled = true;
};
}, [visitedPages]);
return { summary, summaryResponseId };
}
// Main component
export function SearchChat(props: {
query: string;
chatInputRef?: React.RefObject<HTMLInputElement>;
}) {
const { query, chatInputRef } = props;
const visitedPages = useVisitedPages((state) => state.pages);
const [messages, setMessages] = useState<Message[]>([]);
const [followupQuestions, setFollowupQuestions] = useState<string[]>([]);
const [conversationResponseId, setConversationResponseId] = useState<string | undefined>();
const [searchState, setSearchState] = useSearch();
const latestMessageRef = useRef<HTMLDivElement>(null);
const isExpanded = searchState?.mode === 'chat';
// Get summary of visited pages
const { summary, summaryResponseId } = useSummary(visitedPages);
// Handle initial query
const initialResponse = useAIStream({
question: query,
previousResponseId: summaryResponseId,
});
// Set up initial query effect
useEffect(() => {
if (!query) return;
// Add initial assistant message
setMessages([
{
role: 'assistant',
context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`,
fetching: true,
},
]);
setFollowupQuestions([]);
setConversationResponseId(undefined);
}, [query]);
// Update message when initial response changes
useEffect(() => {
if (!query || !initialResponse) return;
if (initialResponse.content !== undefined) {
setMessages([
{
role: 'assistant',
context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`,
content: initialResponse.content,
fetching: initialResponse.fetching,
},
]);
}
if (initialResponse.followupQuestions) {
setFollowupQuestions(initialResponse.followupQuestions);
}
if (initialResponse.responseId) {
setConversationResponseId(initialResponse.responseId);
}
}, [initialResponse, query]);
// Handle follow-up messages
const handleSendMessage = (message: string) => {
// Add user message
const newMessages: Message[] = [
...messages,
{ role: 'user', content: message, fetching: false },
{ role: 'assistant', fetching: true },
];
setMessages(newMessages);
setFollowupQuestions([]);
if (!searchState?.manual) {
setSearchState((state) => (state ? { ...state, mode: 'chat' } : null));
}
// Get AI response
const cancelled = false;
(async () => {
try {
const stream = await streamAISearchAnswer({
question: message,
previousResponseId: conversationResponseId,
});
for await (const rawData of stream) {
if (cancelled) break;
if (!rawData) continue;
// Use type assertion
const data = rawData as any;
if (data.responseId) {
setConversationResponseId(String(data.responseId));
}
if (data.answer !== undefined) {
setMessages((prev) => [
...prev.slice(0, -1),
{ role: 'assistant', content: data.answer, fetching: false },
]);
}
if (data.followupQuestions && Array.isArray(data.followupQuestions)) {
setFollowupQuestions(data.followupQuestions.filter(filterOutNullable));
}
}
} catch (error) {
console.error('Error in follow-up stream:', error);
// Update the message to show an error state
setMessages((prev) => [
...prev.slice(0, -1),
{ role: 'assistant', fetching: false },
]);
}
})();
};
// Handle followup question click
const handleFollowupClick = (question: string) => {
handleSendMessage(question);
};
// Auto-scroll to latest message
useEffect(() => {
if (latestMessageRef.current) {
latestMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [messages]);
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Toggle button for showing search results */}
{searchState?.mode === 'chat' && (
<div
className="absolute top-2 animate-fadeIn max-md:right-4 md:top-4 md:left-4"
style={{ animationDelay: '500ms' }}
>
<Button
label="Show search results"
variant="secondary"
size="small"
iconOnly
icon="arrow-down-from-line"
className="md:hidden"
onClick={() => {
setSearchState((state) =>
state ? { ...state, mode: 'both', manual: true } : null
);
}}
/>
<Button
label="Show search results"
iconOnly
variant="blank"
size="default"
icon="sidebar"
className="hidden px-2 md:block"
onClick={() => {
setSearchState((state) =>
state ? { ...state, mode: 'both', manual: true } : null
);
}}
/>
</div>
)}
{/* Main chat area */}
<div
className={tcls(
'mx-auto flex w-full grow scroll-pt-8 flex-col gap-4 overflow-y-auto p-8 transition-all delay-200 duration-500',
isExpanded && 'md:px-16'
)}
ref={latestMessageRef}
>
{/* Summary section */}
<div className="mx-auto w-full max-w-prose">
<h5 className="mb-1 flex items-center gap-1 font-semibold text-tint-subtle text-xs">
<Icon icon="glasses-round" className="mt-0.5 size-3" /> Summary of what
you've read
</h5>
{summary ? summary : <LoadingAnimation />}
</div>
{/* Messages */}
{messages.map((message, index) => {
const isLast = index === messages.length - 1;
return (
<div
key={index}
ref={isLast ? latestMessageRef : undefined}
className={tcls(
'mx-auto flex flex w-full max-w-prose flex-col gap-4',
isLast && 'min-h-[calc(100%-2rem)]'
)}
>
<ChatMessage message={message} />
{isLast && followupQuestions && followupQuestions.length > 0 && (
<FollowupQuestions
questions={followupQuestions}
onQuestionClick={handleFollowupClick}
/>
)}
</div>
);
})}
</div>
{/* Input area */}
{query && (
<div
className={tcls(
'border-tint-subtle border-t bg-tint-subtle px-8 py-4 transition-all delay-200 duration-500',
isExpanded && 'md:px-16'
)}
>
<div className={tcls('mx-auto flex w-full max-w-prose flex-col gap-2')}>
<ChatInput
onSendMessage={handleSendMessage}
disabled={!conversationResponseId}
inputRef={chatInputRef}
/>
</div>
</div>
)}
</div>
);
}
@@ -1,6 +1,4 @@
'use client';
import { Icon } from '@gitbook/icons';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import React from 'react';
@@ -8,10 +6,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { LoadingPane } from '../primitives/LoadingPane';
import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
import { SearchChat } from './SearchChat';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import { SearchScopeToggle } from './SearchScopeToggle';
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
@@ -30,14 +27,21 @@ export function SearchModal(props: SearchModalProps) {
const searchAsk = useSearchAskState();
const [askState] = searchAsk;
const router = useRouter();
const chatInputRef = React.useRef<HTMLInputElement>(null);
useHotkeys(
'mod+k',
(e) => {
e.preventDefault();
setSearchState({ ask: false, query: '', global: false });
if (state !== null) {
// If search is already open, focus the chat input
chatInputRef.current?.focus();
} else {
// Otherwise open the search modal
setSearchState({ mode: 'both', query: '', global: false });
}
},
[]
[state]
);
// Add a global class on the body when the search modal is open
@@ -125,6 +129,7 @@ export function SearchModal(props: SearchModalProps) {
state={state}
setSearchState={setSearchState}
onClose={onClose}
chatInputRef={chatInputRef}
/>
</div>
</motion.div>
@@ -139,9 +144,11 @@ function SearchModalBody(
state: SearchState;
setSearchState: UpdateSearchState;
onClose: (to?: string) => void;
chatInputRef: React.RefObject<HTMLInputElement>;
}
) {
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose } = props;
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose, chatInputRef } =
props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
@@ -165,6 +172,12 @@ function SearchModalBody(
}, [onClose]);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// Handle second Cmd+K
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
chatInputRef.current?.focus();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
resultsRef.current?.moveUp();
@@ -179,7 +192,7 @@ function SearchModalBody(
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchState({
ask: false, // When typing, we go back to the default search mode
mode: 'both', // When typing, we go back to the default search mode
query: event.target.value,
global: state.global,
});
@@ -219,9 +232,10 @@ function SearchModalBody(
'flex',
'flex-col',
'bg-tint-base',
'max-w-prose',
'max-w-screen-lg',
'mx-auto',
'max-h-[70dvh]',
// 'min-h-[50dvh]',
'h-[70dvh]',
'w-full',
'rounded-lg',
'straight-corners:rounded-sm',
@@ -242,12 +256,10 @@ function SearchModalBody(
'flex-row',
'items-start',
state.query !== null ? 'border-b' : null,
'border-tint-subtle'
'border-tint-subtle',
'col-span-full'
)}
>
<div className={tcls('p-2', 'pl-4', 'pt-4')}>
<Icon icon="magnifying-glass" className={tcls('size-4', 'text-tint-subtle')} />
</div>
<div
className={tcls(
'w-full',
@@ -270,8 +282,8 @@ function SearchModalBody(
'flex',
'resize-none',
'flex-1',
'h-12',
'p-2',
'py-4',
'px-8',
'focus:outline-none',
'bg-transparent',
'whitespace-pre-line'
@@ -287,18 +299,38 @@ function SearchModalBody(
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
</div>
</div>
{!state.ask || !withAsk ? (
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={normalizedQuery}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
) : null}
{normalizedQuery && state.ask && withAsk ? (
<SearchAskAnswer query={normalizedQuery} />
) : null}
<div className={tcls('flex grow flex-col overflow-hidden md:flex-row')}>
<div
key="results"
className={tcls(
'h-full w-full flex-1 overflow-y-auto transition-all duration-500 ease-[cubic-bezier(0.85,0,0.15,1)] *:transition-opacity *:delay-200 *:duration-300',
state.mode === 'chat' && 'flex-[0] delay-200 *:opacity-0 *:delay-0'
)}
aria-hidden={state.mode === 'chat' ? 'true' : undefined}
>
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={normalizedQuery}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
</div>
<div
key="chat"
className={tcls(
'relative h-full w-full flex-1 overflow-y-auto overflow-x-hidden bg-tint-subtle transition-colors duration-500 *:transition-opacity *:delay-200 *:duration-300 max-md:border-t md:border-l',
state.mode === 'results' && 'flex-[0] *:opacity-0 *:delay-0',
state.mode === 'both'
? 'border-tint-subtle'
: 'border-transparent delay-500'
)}
aria-hidden={state.mode === 'results' ? 'true' : undefined}
>
<SearchChat query={normalizedQuery} chatInputRef={chatInputRef} />
</div>
</div>
</motion.div>
);
}
@@ -2,7 +2,9 @@ import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
import { Link } from '../primitives';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { Button, Link } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
import type { ComputedPageResult } from './server-actions';
@@ -14,6 +16,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
},
ref: React.Ref<HTMLAnchorElement>
) {
const language = useLanguage();
const { query, item, active } = props;
const breadcrumbs =
@@ -34,16 +37,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
'flex-row',
'items-center',
'p-4',
'border-t',
'border-tint-subtle',
'first:border-none',
'rounded-lg',
'straight-corners:rounded-none',
'text-base',
'font-medium',
'text-tint-strong',
'hover:bg-tint-hover',
'group',
active
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
: null
active && [
'is-active',
'bg-primary',
'text-primary-strong',
'hover:bg-primary-hover',
]
)}
insights={{
type: 'search_open_result',
@@ -56,8 +62,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
>
<div className="size-4">
<Icon
icon="file-lines"
className={tcls('size-4', active ? 'text-primary' : 'text-tint-subtle')}
icon="file"
className={tcls('size-4', active ? 'text-primary-subtle' : 'text-tint-subtle')}
/>
</div>
<div className={tcls('flex', 'flex-col', 'w-full')}>
@@ -65,7 +71,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<div
className={tcls(
'text-xs',
'opacity-6',
active ? 'text-primary-subtle' : 'text-neutral-subtle',
// 'opacity-6',
'contrast-more:opacity-11',
'font-normal',
'uppercase',
@@ -103,19 +110,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
) : null}
<HighlightQuery query={query} text={item.title} />
</div>
<div
className={tcls(
'p-2',
'rounded',
'straight-corners:rounded-none',
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
)}
>
<Icon
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
className={tcls('size-4')}
{active ? (
<Button
icon="arrow-turn-down-left"
size="small"
label={tString(language, 'view')}
/>
</div>
) : (
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
)}
</Link>
);
});
@@ -1,10 +1,10 @@
import { Icon } from '@gitbook/icons';
import React from 'react';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
import { Button, Link } from '../primitives';
import { useSearchLink } from './useSearch';
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
@@ -28,19 +28,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
className={tcls(
'flex',
'px-4',
recommended ? ['py-2', 'text-tint'] : 'py-4',
'py-2',
'text-tint',
'rounded-lg',
'straight-corners:rounded-none',
'hover:bg-tint-hover',
'first:mt-0',
'last:pb-3',
'gap-4',
active && [
'is-active',
'bg-primary',
'text-contrast-primary',
'text-primary-strong',
'hover:bg-primary-hover',
]
)}
{...getLinkProp({
ask: true,
query: question,
})}
>
@@ -50,7 +51,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
'size-4',
'shrink-0',
'mt-1.5',
'mr-4',
active ? ['text-primary'] : ['text-tint-subtle']
)}
/>
@@ -66,19 +66,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
</>
)}
</div>
<div
className={tcls(
'p-2',
'rounded',
'self-center',
'straight-corners:rounded-none',
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
<div className="self-center">
{active ? (
<Button
icon="arrow-turn-down-left"
size="small"
label={tString(language, 'search')}
/>
) : (
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
)}
>
<Icon
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
className={tcls('size-4')}
/>
</div>
</Link>
);
@@ -7,6 +7,7 @@ import React from 'react';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { AnimatePresence, motion } from 'framer-motion';
import { useTrackEvent } from '../Insights';
import { Loading } from '../primitives';
import { SearchPageResultItem } from './SearchPageResultItem';
@@ -18,6 +19,7 @@ import {
searchSiteSpaceContent,
streamRecommendedQuestions,
} from './server-actions';
import { type SearchState, useSearch } from './useSearch';
export interface SearchResultsRef {
moveUp(): void;
@@ -44,7 +46,6 @@ let cachedRecommendedQuestions: null | ResultType[] = null;
*/
export const SearchResults = React.forwardRef(function SearchResults(
props: {
children?: React.ReactNode;
query: string;
global: boolean;
withAsk: boolean;
@@ -52,7 +53,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
},
ref: React.Ref<SearchResultsRef>
) {
const { children, query, withAsk, global, onSwitchToAsk } = props;
const { query, withAsk, global, onSwitchToAsk } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
@@ -62,6 +63,39 @@ export const SearchResults = React.forwardRef(function SearchResults(
}>({ results: [], fetching: true });
const [cursor, setCursor] = React.useState<number | null>(null);
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
const [searchState, setSearchState] = useSearch();
const manualStateRef = React.useRef(false);
React.useEffect(() => {
if (searchState?.manual !== undefined) {
manualStateRef.current = searchState.manual;
}
}, [searchState?.manual]);
const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]);
React.useEffect(() => {
if (!query) {
// Reset the cursor when there's no query
setCursor(null);
} else if (!searchState?.manual && !resultsState.fetching && results.length === 0) {
setSearchState((prev) => {
const newState: SearchState | null = prev
? { ...prev, mode: 'chat' as const }
: null;
return newState;
});
} else if (results.length > 0) {
// Auto-focus the first result
setSearchState((prev) => {
const newState: SearchState | null = prev
? { ...prev, mode: 'both' as const }
: null;
return newState;
});
setCursor(0);
}
}, [results, query, setSearchState, resultsState.fetching, searchState?.manual]);
React.useEffect(() => {
if (!query) {
@@ -78,7 +112,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
let cancelled = false;
// Silently fetch the recommended questions, instead of showing a spinner
setResultsState({ results: [], fetching: false });
// setResultsState({ results: [], fetching: false });
// We currently have a bug where the same question can be returned multiple times.
// This is a workaround to avoid that.
@@ -150,23 +184,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
};
}, [query, global, withAsk, trackEvent]);
const results: ResultType[] = React.useMemo(() => {
if (!withAsk) {
return resultsState.results;
}
return withQuestionResult(resultsState.results, query);
}, [resultsState.results, query, withAsk]);
React.useEffect(() => {
if (!query) {
// Reset the cursor when there's no query
setCursor(null);
} else if (results.length > 0) {
// Auto-focus the first result
setCursor(0);
}
}, [results, query]);
// Scroll to the active result.
React.useEffect(() => {
if (cursor === null || !refs.current[cursor]) {
@@ -214,106 +231,93 @@ export const SearchResults = React.forwardRef(function SearchResults(
[moveBy, select]
);
if (resultsState.fetching) {
return (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
<Loading className={tcls('w-6', 'text-primary')} />
</div>
);
}
const loading = (
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
<Loading className={tcls('w-6', 'text-primary-subtle')} />
</div>
);
const noResults = (
<div className={tcls('text', 'text-tint', 'p-8', 'text-center')}>
{t(language, 'search_no_results', query)}
<div className={tcls('text', 'text-tint', 'text-center', 'p-8')}>
<div className="animate-fadeIn" style={{ animationDelay: '0.5s' }}>
{t(language, 'search_no_results', query)}
</div>
</div>
);
return (
<div className={tcls('overflow-auto')}>
{children}
{results.length === 0 ? (
query ? (
noResults
) : null
<AnimatePresence initial={false} mode="wait">
{resultsState.fetching ? (
loading
) : query && results.length === 0 ? (
noResults
) : (
<>
<div data-testid="search-results">
{results.map((item, index) => {
switch (item.type) {
case 'page': {
return (
<SearchPageResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
query={query}
item={item}
active={index === cursor}
/>
);
}
case 'question': {
return (
<SearchQuestionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
question={query}
active={index === cursor}
onClick={onSwitchToAsk}
/>
);
}
case 'recommended-question': {
return (
<SearchQuestionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
question={item.question}
active={index === cursor}
onClick={onSwitchToAsk}
recommended
/>
);
}
case 'section': {
return (
<SearchSectionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
query={query}
item={item}
active={index === cursor}
/>
);
}
default:
assertNever(item);
<motion.div
layout="position"
className="flex flex-col gap-2 p-4"
data-testid="search-results"
>
{results.map((item, index) => {
switch (item.type) {
case 'page': {
return (
<SearchPageResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
query={query}
item={item}
active={index === cursor}
/>
);
}
})}
</div>
{!results.some((result) => result.type !== 'question') && noResults}
</>
case 'question': {
return (
<SearchQuestionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
question={query}
active={index === cursor}
onClick={onSwitchToAsk}
/>
);
}
case 'recommended-question': {
return (
<SearchQuestionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
question={item.question}
active={index === cursor}
onClick={onSwitchToAsk}
recommended
/>
);
}
case 'section': {
return (
<SearchSectionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
query={query}
item={item}
active={index === cursor}
/>
);
}
default:
assertNever(item);
}
})}
</motion.div>
)}
</div>
</AnimatePresence>
);
});
/**
* Add a "Ask <question>" item at the top of the results list.
*/
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
const without = results.filter((result) => result.type !== 'question');
if (query.length === 0) {
return without;
}
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
}
@@ -3,7 +3,8 @@ import React from 'react';
import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
import { tString, useLanguage } from '@/intl/client';
import { Button, Link } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
import type { ComputedSectionResult } from './server-actions';
@@ -16,13 +17,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
ref: React.Ref<HTMLAnchorElement>
) {
const { query, item, active } = props;
const language = useLanguage();
return (
<Link
ref={ref}
href={item.href}
className={tcls(
'[&:has(+:not(&))]:mb-6',
// '[&:has(+:not(&))]:mb-6',
'-mt-2',
'flex',
'items-center',
'pl-6',
@@ -33,6 +36,8 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
'font-normal',
'py-2',
'group',
'rounded-lg',
'straight-corners:rounded-none',
active && [
'is-active',
'bg-primary',
@@ -72,20 +77,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
</p>
) : null}
</div>
<div
className={tcls(
'p-2',
'rounded',
'straight-corners:rounded-none',
'bg-primary-solid',
'text-contrast-primary-solid',
'hidden',
'sm:block',
active ? ['opacity-11', 'block'] : ['opacity-0']
)}
>
<Icon icon="arrow-turn-down-left" className={tcls('size-4')} />
</div>
{active ? (
<Button
icon="arrow-turn-down-left"
size="small"
label={tString(language, 'view')}
/>
) : (
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
)}
</Link>
);
});
@@ -28,7 +28,7 @@ const questionWords = new Set([
* Return true if an input query looks like a question.
*/
export function isQuestion(query: string): boolean {
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
if ((query.length > 25 && query.includes(' ')) || query.includes('?')) {
return true;
}
@@ -4,15 +4,16 @@ import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import type {
RevisionPage,
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
Space,
import {
AIMessageRole,
type RevisionPage,
type SearchAIAnswer,
type SearchAIRecommendedQuestionStream,
type SearchPageResult,
type SearchSpaceResult,
type SiteSection,
type SiteSectionGroup,
type Space,
} from '@gitbook/api';
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
@@ -24,6 +25,8 @@ import { isV2 } from '@/lib/v2';
import type { IconName } from '@gitbook/icons';
import { throwIfDataError } from '@v2/lib/data';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { z } from 'zod';
import { streamGenerateObject } from '../Adaptive/server-actions/api';
import { DocumentView } from '../DocumentView';
export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult;
@@ -410,3 +413,140 @@ async function transformSitePageResult(
return [page, ...pageSections];
}
/**
* Get an AI-generated answer to a search query.
*/
export async function* streamAISearchSummary({
visitedPages,
}: {
visitedPages: { spaceId: string; pageId: string }[];
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream, response } = await streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
summary: z
.string()
.describe(
'A summary of the most important information the user has learned from the provided context.'
),
}),
messages: [
{
role: AIMessageRole.Developer,
content:
'Summarise the most important information the user has learned from the provided context. Be concise and focus on facts. Do not add commentary, adjectives or other empty descriptors.',
attachments: visitedPages.map(({ spaceId, pageId }) => ({
type: 'page' as const,
spaceId,
pageId,
})),
},
].filter(filterOutNullable),
}
);
// Get the responseId asynchronously in the background
let responseId: string | null = null;
const responseIdPromise = response
.then((r) => {
responseId = r.responseId;
})
.catch((error) => {
console.error('Error getting responseId:', error);
});
for await (const value of stream) {
const summary = value.summary;
if (!summary) {
continue;
}
yield { summary };
}
// Wait for the responseId to be available and yield one final time
await responseIdPromise;
yield { responseId };
}
/**
* Get an AI-generated answer to a search query.
*/
export async function* streamAISearchAnswer({
question,
previousResponseId,
}: {
question: string;
previousResponseId?: string;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream, response } = await streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
answer: z.string().describe('The answer to the question.'),
followupQuestions: z
.array(z.string())
.describe(
'Follow-up questions to the question, based on the provided content only. Keep questions very short and use pronouns to refer to known concepts.'
)
.max(3),
}),
tools: {
search: true,
getPageContent: true,
},
previousResponseId: previousResponseId,
messages: [
{
role: AIMessageRole.Developer,
content: `Answer the following question by using the provided documentation or by searching. Format the answer in Markdown. If you cannot answer the question using the context provided, provide an empty string. Always list related follow-up questions using the provided context. Check first that you can answer the question given the provided context before listing it as a follow-up question. If you can't answer a question, don't include it in the follow up questions. If there is no provided context, do not list follow-up questions. List the sources used to answer the question in the "sources" field. Only list the sources that were directly used for the content of the answer.`,
},
{
role: AIMessageRole.User,
content: question,
},
].filter(filterOutNullable),
}
);
// Get the responseId asynchronously in the background
let responseId: string | null = null;
const responseIdPromise = response
.then((r) => {
responseId = r.responseId;
})
.catch((error) => {
console.error('Error getting responseId:', error);
});
for await (const value of stream) {
const answer = value.answer;
const followupQuestions = value.followupQuestions;
if (answer === undefined) {
continue;
}
yield { answer, followupQuestions };
}
// Wait for the responseId to be available and yield one final time
await responseIdPromise;
yield { responseId };
}
@@ -1,19 +1,21 @@
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';
import React from 'react';
import type { LinkProps } from '../primitives';
export interface SearchState {
query: string;
ask: boolean;
global: boolean;
mode: 'results' | 'chat' | 'both';
manual?: boolean;
}
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
const keyMap = {
q: parseAsString,
ask: parseAsBoolean,
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
global: parseAsBoolean,
manual: parseAsBoolean,
};
export type UpdateSearchState = (
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
return null;
}
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
return {
query: rawState.q,
mode: rawState.mode,
global: !!rawState.global,
manual: !!rawState.manual,
};
}, [rawState]);
const stateRef = React.useRef(state);
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
if (update === null) {
return setRawState({
q: null,
ask: null,
mode: null,
global: null,
manual: null,
});
}
return setRawState({
q: update.query,
ask: update.ask ? true : null,
mode: update.mode,
global: update.global ? true : null,
manual: update.manual ? true : null,
});
},
[setRawState]
@@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
(query) => {
const searchParams = new URLSearchParams();
searchParams.set('q', query.query ?? '');
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode');
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
searchParams.delete('manual');
return {
href: `?${searchParams.toString()}`,
prefetch: false,
@@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
event.preventDefault();
setSearch((prev) => ({
query: '',
ask: false,
mode: 'both',
global: false,
...(prev ?? {}),
...query,
@@ -53,8 +53,6 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection) {
description: section.description,
icon: section.icon,
object: section.object,
url: section.urls.published
? linker.toLinkForContent(section.urls.published)
: linker.toPathInSite(section.path),
url: section.urls.published ? linker.toLinkForContent(section.urls.published) : '',
};
}
@@ -17,11 +17,7 @@ export async function PageDocumentItem(props: {
context: GitBookSiteContext;
}) {
const { rootPages, page, context } = props;
let href = context.linker.toPathForPage({ pages: rootPages, page });
// toPathForPage can returns an empty path, this will cause all links to point to the current page.
if (href === '') {
href = '/';
}
const href = context.linker.toPathForPage({ pages: rootPages, page });
return (
<li className="flex flex-col">
@@ -3,7 +3,6 @@
import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { type TrackEventInput, useTrackEvent } from '../Insights';
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
@@ -76,46 +75,6 @@ export const Link = React.forwardRef(function Link(
);
});
/**
* A box used to contain a link overlay.
* It is used to create a clickable area that can contain other elements.
*/
export const LinkBox = React.forwardRef(function LinkBox(
props: React.BaseHTMLAttributes<HTMLDivElement>,
ref: React.Ref<HTMLDivElement>
) {
const { children, className, ...domProps } = props;
return (
<div ref={ref} {...domProps} className={tcls('elevate-link relative', className)}>
{children}
</div>
);
});
/**
* A link overlay that can be used to create a clickable area on top of other elements.
* It is used to create a link that covers the entire area of the element without encapsulating it in a link tag.
* This is useful to avoid nesting links inside links.
*/
export const LinkOverlay = React.forwardRef(function LinkOverlay(
props: LinkProps,
ref: React.Ref<HTMLAnchorElement>
) {
const { children, className, ...domProps } = props;
return (
<Link
ref={ref}
{...domProps}
className={tcls(
'link-overlay static before:absolute before:top-0 before:left-0 before:z-10 before:h-full before:w-full',
className
)}
>
{children}
</Link>
);
});
/**
* Check if a link is external, compared to an origin.
*/
@@ -6,6 +6,7 @@ export const de = {
switch_to_light_theme: 'Zum hellen Modus wechseln',
switch_to_system_theme: 'Zum Systemmodus wechseln',
search: 'Suche',
view: 'Anzeigen',
search_or_ask: 'Fragen oder Suchen',
search_input_placeholder: 'Inhalt durchsuchen',
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
@@ -6,6 +6,7 @@ export const en = {
switch_to_light_theme: 'Switch to light theme',
switch_to_system_theme: 'Switch to system theme',
search: 'Search',
view: 'View',
search_or_ask: 'Ask or search',
search_input_placeholder: 'Search content',
search_ask_input_placeholder: 'Search content or ask a question',
@@ -8,6 +8,7 @@ export const es: TranslationLanguage = {
switch_to_light_theme: 'Cambiar a tema claro',
switch_to_system_theme: 'Cambiar a tema del sistema',
search: 'Buscar',
view: 'Ver',
search_or_ask: 'Preguntar o Buscar',
search_input_placeholder: 'Buscar contenido',
search_ask_input_placeholder: 'Buscar contenido o hacer una pregunta',
@@ -8,6 +8,7 @@ export const fr: TranslationLanguage = {
switch_to_light_theme: 'Passer au thème clair',
switch_to_system_theme: 'Passer au thème système',
search: 'Rechercher',
view: 'Voir',
search_or_ask: 'Demander ou rechercher',
search_input_placeholder: 'Rechercher le contenu',
search_ask_input_placeholder: 'Rechercher du contenu ou poser une question',
@@ -8,6 +8,7 @@ export const ja: TranslationLanguage = {
switch_to_light_theme: 'ライトテーマに切り替え',
switch_to_system_theme: 'システムのテーマに切り替え',
search: '検索',
view: '表示',
search_or_ask: '質問または検索',
search_input_placeholder: 'コンテンツを検索',
search_ask_input_placeholder: 'コンテンツを検索するか質問をする',
+4 -3
View File
@@ -8,6 +8,7 @@ export const nl: TranslationLanguage = {
switch_to_light_theme: 'Schakel over naar lichte modus',
switch_to_system_theme: 'Schakel over naar systeemmodus',
search: 'Zoeken',
view: 'Bekijken',
search_or_ask: 'Zoek of vraag',
search_input_placeholder: 'Zoek inhoud',
search_ask_input_placeholder: 'Zoek inhoud of stel een vraag',
@@ -17,7 +18,7 @@ export const nl: TranslationLanguage = {
search_ask: 'Vraag "${1}"',
search_ask_description: 'Vind het antwoord met AI',
search_ask_sources: 'Bronnen',
search_ask_sources_no_answer: 'Gerelateerde paginas',
search_ask_sources_no_answer: "Gerelateerde pagina's",
search_ask_no_answer:
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
@@ -54,9 +55,9 @@ export const nl: TranslationLanguage = {
pdf_print: 'Print of opslaan als PDF',
pdf_page_of: '${1} van ${2}',
pdf_mode_only_page: 'Alleen deze pagina',
pdf_mode_all: 'Alle paginas',
pdf_mode_all: "Alle pagina's",
pdf_limit_reached: "Kon de PDF niet genereren voor ${1} pagina's, generatie gestopt bij ${2}.",
pdf_limit_reached_continue: 'Verleng met ${1} extra paginas.',
pdf_limit_reached_continue: "Verleng met ${1} extra pagina's.",
more: 'Meer',
link_tooltip_external_link: 'Externe link naar',
link_tooltip_page_anchor: 'Spring naar sectie',
@@ -8,6 +8,7 @@ export const no: TranslationLanguage = {
switch_to_light_theme: 'Bytt til lyst tema',
switch_to_system_theme: 'Bytt til systemtema',
search: 'Søk',
view: 'Vis',
search_or_ask: 'Spør eller søk',
search_input_placeholder: 'Søk i innhold',
search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål',
@@ -6,6 +6,7 @@ export const pt_br = {
switch_to_light_theme: 'Mudar para modo claro',
switch_to_system_theme: 'Mudar para configuração do sistema',
search: 'Busca',
view: 'Ver',
search_or_ask: 'Perguntar ou buscar',
search_input_placeholder: 'Buscar conteúdo',
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
@@ -8,6 +8,7 @@ export const zh: TranslationLanguage = {
switch_to_light_theme: '切换到浅色主题',
switch_to_system_theme: '切换到系统主题',
search: '搜索',
view: '查看',
search_or_ask: '询问或搜索',
search_input_placeholder: '搜索内容',
search_ask_input_placeholder: '搜索内容或提问',
+31 -23
View File
@@ -9,6 +9,7 @@ import { getAssetURL } from '@/lib/assets';
import { filterOutNullable } from '@/lib/typescript';
import { getCacheTag } from '@gitbook/cache-tags';
import type { GitBookSiteContext } from '@v2/lib/context';
import { getCloudflareContext } from '@v2/lib/data/cloudflare';
import { getResizedImageURL } from '@v2/lib/images';
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
@@ -72,12 +73,8 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
const fonts = (
await Promise.all([
getWithCache(`google-font:${fontFamily}:400`, () =>
loadGoogleFont({ fontFamily, text: regularText, weight: 400 })
),
getWithCache(`google-font:${fontFamily}:700`, () =>
loadGoogleFont({ fontFamily, text: boldText, weight: 700 })
),
loadGoogleFont({ fontFamily, text: regularText, weight: 400 }),
loadGoogleFont({ fontFamily, text: boldText, weight: 700 }),
])
).filter(filterOutNullable);
@@ -325,6 +322,24 @@ function logOnCloudflareOnly(message: string) {
}
}
/**
* Fetch a resource from the function itself.
* To avoid error with worker to worker requests in the same zone, we use the `WORKER_SELF_REFERENCE` binding.
*/
async function fetchSelf(url: string) {
const cloudflare = getCloudflareContext();
if (cloudflare?.env.WORKER_SELF_REFERENCE) {
logOnCloudflareOnly(`Fetching self: ${url}`);
return await cloudflare.env.WORKER_SELF_REFERENCE.fetch(
// `getAssetURL` can return a relative URL, so we need to make it absolute
// the URL doesn't matter, as we're using the worker-self-reference binding
new URL(url, 'https://worker-self-reference/')
);
}
return await fetch(url);
}
/**
* Read an image from a response as a base64 encoded string.
*/
@@ -342,34 +357,27 @@ async function readImage(response: Response) {
return `data:${contentType};base64,${base64}`;
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const staticCache = new Map<string, any>();
// Do we need to limit the in-memory cache size? I think given the usage, we should be fine.
async function getWithCache<T>(key: string, fn: () => Promise<T>) {
const cached = staticCache.get(key) as T;
if (cached) {
return Promise.resolve(cached);
}
const result = await fn();
staticCache.set(key, result);
return result;
}
const staticImagesCache = new Map<string, string>();
/**
* Read a static image and cache it in memory.
*/
async function readStaticImage(url: string) {
logOnCloudflareOnly(`Reading static image: ${url}, cache size: ${staticCache.size}`);
return getWithCache(`static-image:${url}`, () => readSelfImage(url));
const cached = staticImagesCache.get(url);
if (cached) {
return cached;
}
const image = await readSelfImage(url);
staticImagesCache.set(url, image);
return image;
}
/**
* Read an image from GitBook itself.
*/
async function readSelfImage(url: string) {
const response = await fetch(url);
const response = await fetchSelf(url);
const image = await readImage(response);
return image;
}
@@ -312,11 +312,6 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
[name]: 'YOUR_API_KEY',
};
}
case 'oauth2': {
return {
Authorization: 'Bearer YOUR_OAUTH2_TOKEN',
};
}
default: {
return {};
}
+1 -21
View File
@@ -18,7 +18,7 @@ export function OpenAPISpec(props: {
const { operation } = data;
const parameters = deduplicateParameters(operation.parameters ?? []);
const parameters = operation.parameters ?? [];
const parameterGroups = groupParameters(parameters, context);
const securities = 'securities' in data ? data.securities : [];
@@ -113,23 +113,3 @@ function getParameterGroupName(paramIn: string, context: OpenAPIClientContext):
return paramIn;
}
}
/** Deduplicate parameters by name and in.
* Some specs have both parameters define at path and operation level.
* We only want to display one of them.
*/
function deduplicateParameters(parameters: OpenAPI.Parameters): OpenAPI.Parameters {
const seen = new Set();
return parameters.filter((param) => {
const key = `${param.name}:${param.in}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
@@ -400,7 +400,7 @@ describe('python code sample generator', () => {
const output = generator?.generate(input);
expect(output).toBe(
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={\n "key": "value"\n }\n)\n\ndata = response.json()'
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={"key":"value"}\n)\n\ndata = response.json()'
);
});
@@ -422,7 +422,7 @@ describe('python code sample generator', () => {
const output = generator?.generate(input);
expect(output).toBe(
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({\n "key": "value",\n "truethy": True,\n "falsey": False,\n "nullish": None\n })\n)\n\ndata = response.json()'
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({"key":"value","truethy":True,"falsey":False,"nullish":None})\n)\n\ndata = response.json()'
);
});
+12 -16
View File
@@ -356,22 +356,18 @@ const BodyGenerators = {
// Convert JSON to XML if needed
body = JSON.stringify(convertBodyToXML(body));
} else {
body = stringifyOpenAPI(
body,
(_key, value) => {
switch (value) {
case true:
return '$$__TRUE__$$';
case false:
return '$$__FALSE__$$';
case null:
return '$$__NULL__$$';
default:
return value;
}
},
2
)
body = stringifyOpenAPI(body, (_key, value) => {
switch (value) {
case true:
return '$$__TRUE__$$';
case false:
return '$$__FALSE__$$';
case null:
return '$$__NULL__$$';
default:
return value;
}
})
.replaceAll('"$$__TRUE__$$"', 'True')
.replaceAll('"$$__FALSE__$$"', 'False')
.replaceAll('"$$__NULL__$$"', 'None');
@@ -1017,24 +1017,4 @@ describe('generateSchemaExample', () => {
},
});
});
it('handles deprecated properties', () => {
expect(
generateSchemaExample({
type: 'object',
deprecated: true,
})
).toBeUndefined();
});
it('handle nested deprecated properties', () => {
expect(
generateSchemaExample({
type: 'array',
items: {
deprecated: true,
},
})
).toBeUndefined();
});
});
@@ -167,7 +167,7 @@ const getExampleFromSchema = (
const makeUpRandomData = !!options?.emptyString;
// If the property is deprecated we don't show it in examples.
if (schema.deprecated || (schema.type === 'array' && schema.items?.deprecated)) {
if (schema.deprecated) {
return undefined;
}