Compare commits

..

2 Commits

Author SHA1 Message Date
Samy Pessé 0f7ac7ca08 Merge branch 'main' into fix-iframely-csp 2024-04-18 08:39:09 -07:00
Samy Pessé 119c22af11 Pass a nonce to the script for cdn.iframe.ly/embed.js 2024-04-18 08:25:11 -07:00
97 changed files with 546 additions and 2205 deletions
-8
View File
@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+9 -13
View File
@@ -1,9 +1,5 @@
on: [push]
name: CI name: CI
on:
pull_request:
push:
branches:
- main
jobs: jobs:
deploy: deploy:
@@ -20,7 +16,7 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -82,7 +78,7 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Setup Playwright - name: Setup Playwright
@@ -102,7 +98,7 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -121,12 +117,12 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format:check - run: bun format --check .
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Lint name: Lint
@@ -136,7 +132,7 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -151,7 +147,7 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
@@ -166,7 +162,7 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: 1.1.18 bun-version: 1.0.33
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
-35
View File
@@ -1,35 +0,0 @@
name: Publish
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: npm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -4
View File
@@ -1,4 +1 @@
.next .next
# Generated
packages/emoji-codepoints/index.ts
+2 -8
View File
@@ -75,7 +75,7 @@ GitBook's rendering engine is fully open-source and built on top of [Next.js](ht
### Types of contributions ### Types of contributions
We encourage you to contribute to GitBook to help us build the best tool for documenting technical knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions. We encourage you to contribute to GitBook to help us build the best tool for doucmenting techincal knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
#### Translations #### Translations
@@ -92,7 +92,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
> >
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._ > _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
> >
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project. > Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintainance, or updates to forked and self-hosted instances of this project.
> >
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version. > We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
@@ -119,12 +119,6 @@ GitBook wouldn't be possible without these projects:
- [Tailwind CSS](https://tailwindcss.com/) - [Tailwind CSS](https://tailwindcss.com/)
- [Framer Motion](https://www.npmjs.com/package/framer-motion) - [Framer Motion](https://www.npmjs.com/package/framer-motion)
## Contributors
<a href="https://github.com/gitbookIO/gitbook/graphs/contributors">
<img src="https://contrib.rocks/image?repo=gitbookIO/gitbook" />
</a>
## Legacy GitBook (Deprecated) ## Legacy GitBook (Deprecated)
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy). Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
BIN
View File
Binary file not shown.
-92
View File
@@ -106,93 +106,6 @@ const testCases: TestsCase[] = [
}, },
], ],
}, },
{
name: 'GitBook Site (Navigation when switching variant)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/',
tests: [
{
name: 'Keep navigation path/route when switching variant (Public)',
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
await spaceDrowpdown.click();
// Click the second variant in the dropdown
await page
.getByRole('link', {
name: '2.0',
})
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/v/2.0/reference/api-reference/pets?fallback=true',
);
},
},
{
name: 'Keep navigation path/route when switching variant (Share link)',
url: 'api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
await spaceDrowpdown.click();
// Click the second variant in the dropdown
await page
.getByRole('link', {
name: '2.0',
})
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/v/2.0/reference/api-reference/pets?fallback=true',
);
},
},
{
name: 'Keep navigation path/route when switching variant (VA)',
screenshot: false,
url: (() => {
const privateKey = 'c26190fc-74b2-4b54-9fc7-df9941104953';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
})(),
run: async (page) => {
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
await spaceDrowpdown.click();
// Click the second variant in the dropdown
await page
.getByRole('link', {
name: '2.0',
})
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/v/2.0/reference/api-reference/pets?fallback=true',
);
},
},
],
},
{ {
name: 'GitBook', name: 'GitBook',
baseUrl: 'https://docs.gitbook.com', baseUrl: 'https://docs.gitbook.com',
@@ -363,11 +276,6 @@ const testCases: TestsCase[] = [
name: 'Page options', name: 'Page options',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/', baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: [ tests: [
{
name: 'Hidden',
url: 'page-options/page-hidden',
run: waitForCookiesDialog,
},
{ {
name: 'With cover', name: 'With cover',
url: 'page-options/page-with-cover', url: 'page-options/page-with-cover',
+1 -25
View File
@@ -9,7 +9,7 @@ module.exports = withSentryConfig(
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX, GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
}, },
webpack(config, { dev, webpack }) { webpack(config) {
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,
@@ -19,21 +19,6 @@ module.exports = withSentryConfig(
http: false, http: false,
}; };
// Tree shake debug code for Sentry
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-with-nextjs
if (!dev) {
config.plugins.push(
new webpack.DefinePlugin({
__SENTRY_DEBUG__: false,
// We always init Sentry with enableTracing: false for now, so this is useless
__SENTRY_TRACING__: false,
__RRWEB_EXCLUDE_IFRAME__: true,
__RRWEB_EXCLUDE_SHADOW_DOM__: true,
__SENTRY_EXCLUDE_REPLAY_WORKER__: true,
}),
);
}
return config; return config;
}, },
@@ -54,15 +39,6 @@ module.exports = withSentryConfig(
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX, assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false, poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
}, },
{ {
silent: true, silent: true,
+4 -8
View File
@@ -11,10 +11,8 @@
"format": "prettier ./ --ignore-unknown --write", "format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different", "format:check": "prettier ./ --ignore-unknown --list-different",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"unit": "bun test {src,packages}", "unit": "bun test {src,packages}/**/*.test.ts",
"e2e": "playwright test", "e2e": "playwright test",
"changeset": "changeset",
"release": "changeset publish",
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2" "postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2"
}, },
"workspaces": [ "workspaces": [
@@ -22,11 +20,10 @@
], ],
"dependencies": { "dependencies": {
"@geist-ui/icons": "^1.0.2", "@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.56.0", "@gitbook/api": "^0.43.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1", "@sentry/nextjs": "^7.94.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1", "@upstash/redis": "^1.27.1",
@@ -43,7 +40,7 @@
"memoizee": "^0.4.15", "memoizee": "^0.4.15",
"next": "^14.1.3", "next": "^14.1.3",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nuqs": "^1.17.4", "nuqs": "^1.15.4",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.0", "p-map": "^7.0.0",
@@ -67,8 +64,7 @@
}, },
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^2.0.0", "@argos-ci/playwright": "^2.0.0",
"@changesets/cli": "^2.27.7", "@cloudflare/next-on-pages": "^1.9.0",
"@cloudflare/next-on-pages": "^1.11.3",
"@cloudflare/workers-types": "^4.20231218.0", "@cloudflare/workers-types": "^4.20231218.0",
"@playwright/test": "^1.42.1", "@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/emoji-codepoints
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
-1
View File
@@ -1,7 +1,6 @@
{ {
"name": "@gitbook/emoji-codepoints", "name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints", "description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.0",
"private": true, "private": true,
"exports": "./index.ts", "exports": "./index.ts",
"dependencies": {}, "dependencies": {},
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/react-contentkit
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
+1 -2
View File
@@ -1,10 +1,9 @@
{ {
"name": "@gitbook/react-contentkit", "name": "@gitbook/react-contentkit",
"version": "0.2.0",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"dependencies": { "dependencies": {
"classnames": "^2.5.1", "classnames": "^2.5.1",
"@gitbook/api": "^0.51.0", "@gitbook/api": "^0.41.0",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"peerDependencies": { "peerDependencies": {
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/react-math
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
-1
View File
@@ -1,7 +1,6 @@
{ {
"name": "@gitbook/react-math", "name": "@gitbook/react-math",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"version": "0.2.0",
"dependencies": { "dependencies": {
"object-hash": "^3.0.0" "object-hash": "^3.0.0"
}, },
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/react-openapi
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
+2 -3
View File
@@ -1,10 +1,9 @@
{ {
"name": "@gitbook/react-openapi", "name": "@gitbook/react-openapi",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"version": "0.2.0",
"dependencies": { "dependencies": {
"@scalar/api-client-react": "^0.3.7", "@scalar/api-client-react": "^0.2.9",
"@scalar/oas-utils": "0.1.6", "@scalar/oas-utils": "0.1.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"flatted": "^3.2.9", "flatted": "^3.2.9",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
@@ -62,10 +62,7 @@ export function OpenAPIResponseExample(props: {
return ( return (
<InteractiveSection header="Response" className="openapi-response-example"> <InteractiveSection header="Response" className="openapi-response-example">
<context.CodeBlock <context.CodeBlock code={JSON.stringify(example, null, 2)} syntax="json" />
code={typeof example === 'string' ? example : JSON.stringify(example, null, 2)}
syntax="json"
/>
</InteractiveSection> </InteractiveSection>
); );
} }
+2 -17
View File
@@ -47,13 +47,6 @@ export function OpenAPISchemaProperty(
? null ? null
: getSchemaAlternatives(schema, new Set(circularRefs.keys())); : getSchemaAlternatives(schema, new Set(circularRefs.keys()));
const shouldDisplayExample = (schema: OpenAPIV3.SchemaObject): boolean => {
return (
typeof schema.example === 'string' ||
typeof schema.example === 'number' ||
typeof schema.example === 'boolean'
);
};
return ( return (
<InteractiveSection <InteractiveSection
id={id} id={id}
@@ -99,11 +92,6 @@ export function OpenAPISchemaProperty(
className="openapi-schema-description" className="openapi-schema-description"
/> />
) : null} ) : null}
{shouldDisplayExample(schema) ? (
<span className="openapi-schema-example">
Example: <code>{JSON.stringify(schema.example)}</code>
</span>
) : null}
</div> </div>
} }
> >
@@ -182,8 +170,6 @@ export function OpenAPIRootSchema(props: {
/** /**
* Render a tab for an alternative schema. * Render a tab for an alternative schema.
* It renders directly the properties if relevant;
* for primitives, it renders the schema itself.
*/ */
function OpenAPISchemaAlternative(props: { function OpenAPISchemaAlternative(props: {
schema: OpenAPIV3.SchemaObject; schema: OpenAPIV3.SchemaObject;
@@ -192,13 +178,12 @@ function OpenAPISchemaAlternative(props: {
}) { }) {
const { schema, circularRefs, context } = props; const { schema, circularRefs, context } = props;
const id = useId(); const id = useId();
const subProperties = getSchemaProperties(schema);
return ( return (
<OpenAPISchemaProperties <OpenAPISchemaProperties
id={id} id={id}
properties={subProperties ?? [{ schema }]} properties={getSchemaProperties(schema) ?? []}
circularRefs={subProperties ? new Map(circularRefs).set(schema, id) : circularRefs} circularRefs={new Map(circularRefs).set(schema, id)}
context={context} context={context}
/> />
); );
@@ -48,7 +48,6 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext
// Description of the parameter is defined at the parameter level // Description of the parameter is defined at the parameter level
// we use display it if the schema doesn't override it // we use display it if the schema doesn't override it
description: parameter.description, description: parameter.description,
example: parameter.example,
...(noReference(parameter.schema) ?? {}), ...(noReference(parameter.schema) ?? {}),
}, },
required: parameter.required, required: parameter.required,
+1 -10
View File
@@ -7,7 +7,6 @@ import {
getRequestFromOperation, getRequestFromOperation,
Query, Query,
Header, Header,
RequestBody,
} from '@scalar/oas-utils'; } from '@scalar/oas-utils';
import React from 'react'; import React from 'react';
@@ -99,15 +98,7 @@ export function ScalarApiClient(props: { children: React.ReactNode }) {
{ {
url: operationData.path, url: operationData.path,
}, },
getRequestFromOperation( getRequestFromOperation(operation, { requiredOnly: false }),
{
...operation,
information: {
requestBody: operationData.operation.requestBody as RequestBody,
},
},
{ requiredOnly: false },
),
); );
return { return {
@@ -19,7 +19,7 @@ export function generateSchemaExample(
return undefined; return undefined;
} }
if (typeof schema.example !== 'undefined') { if (schema.example) {
return schema.example; return schema.example;
} }
+2 -14
View File
@@ -1,15 +1,8 @@
import { import * as Sentry from '@sentry/nextjs';
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
// To tree shake default integrations that we don't use Sentry.init({
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
const client = new BrowserClient({
debug: false, debug: false,
dsn, dsn,
integrations: [], integrations: [],
@@ -18,10 +11,5 @@ if (dsn) {
beforeSendTransaction: () => { beforeSendTransaction: () => {
return null; return null;
}, },
transport: makeFetchTransport,
stackParser: defaultStackParser,
}); });
getCurrentScope().setClient(client);
client.init();
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { init } from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
init({ Sentry.init({
debug: false, debug: false,
dsn, dsn,
+2 -2
View File
@@ -1,8 +1,8 @@
import { init } from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
init({ Sentry.init({
debug: false, debug: false,
dsn, dsn,
+4 -21
View File
@@ -1,12 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { import { verifyImageSignature, resizeImage, CloudflareImageOptions } from '@/lib/images';
verifyImageSignature,
resizeImage,
CloudflareImageOptions,
checkIsSizableImageURL,
} from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -16,30 +10,19 @@ export const runtime = 'edge';
* Fetch and resize an image. * Fetch and resize an image.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url'); const url = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign'); const signature = request.nextUrl.searchParams.get('sign');
// The current signature algorithm sets version as 1, but we need to support the older version as well if (!url || !signature) {
// for previously generated content. In this case, we default to version 0.
const signatureVersion = (request.nextUrl.searchParams.get('sv') as '1') || '0';
if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 }); return new Response('Missing url/sign parameters', { status: 400 });
} }
const url = parseImageAPIURL(urlParam);
// Prevent infinite loops // Prevent infinite loops
if (url.includes('/~gitbook/image')) { if (url.includes('/~gitbook/image')) {
return new Response('Invalid url parameter', { status: 400 }); return new Response('Invalid url parameter', { status: 400 });
} }
// Check again if the image can be sized, even though we checked when rendering the Image component
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
if (!checkIsSizableImageURL(url)) {
return new Response('Invalid url parameter', { status: 400 });
}
// Verify the signature // Verify the signature
const verified = await verifyImageSignature(url, { signature, version: signatureVersion }); const verified = await verifyImageSignature(url, signature);
if (!verified) { if (!verified) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 }); return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
} }
@@ -1,8 +1,5 @@
'use client'; 'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
import { useScrollToHash } from '@/components/hooks'; import { useScrollToHash } from '@/components/hooks';
/** /**
@@ -12,29 +9,5 @@ export function PageClientLayout(props: {}) {
// We use this hook in the page layout to ensure the elements for the blocks // We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to the hash. // are rendered before we scroll to the hash.
useScrollToHash(); useScrollToHash();
useStripFallbackQueryParam();
return null; return null;
} }
/**
* Strip the fallback query parameter from current URL.
*
* When the user switches variants using the space dropdown, we pass a fallback=true parameter.
* This parameter indicates that we should redirect to the root page if the path from the
* previous variant doesn't exist in the new variant. If the path does exist, no redirect occurs,
* so we need to remove the fallback parameter.
*/
function useStripFallbackQueryParam() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
React.useEffect(() => {
if (searchParams.has('fallback')) {
const params = new URLSearchParams(searchParams.toString());
params.delete('fallback');
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
}
}, [router, pathname, searchParams]);
}
@@ -6,10 +6,9 @@ import React from 'react';
import { PageAside } from '@/components/PageAside'; import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody'; import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links'; import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages'; import { getPagePath } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references'; import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { PageClientLayout } from './PageClientLayout'; import { PageClientLayout } from './PageClientLayout';
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch'; import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
@@ -19,29 +18,14 @@ export const runtime = 'edge';
/** /**
* Fetch and render a page. * Fetch and render a page.
*/ */
export default async function Page(props: { export default async function Page(props: { params: PagePathParams }) {
params: PagePathParams; const { params } = props;
searchParams: { fallback?: string };
}) {
const { params, searchParams } = props;
const {
content: contentPointer,
contentTarget,
space,
parent,
customization,
pages,
page,
document,
} = await getPageDataWithFallback({
pagePathParams: params,
searchParams,
redirectOnFallback: true,
});
const linksContext: PageHrefContext = {};
const rawPathname = getPathnameParam(params); const rawPathname = getPathnameParam(params);
const { contentTarget, space, customization, pages, page, document } =
await fetchPageData(params);
const linksContext: PageHrefContext = {};
if (!page) { if (!page) {
const pathname = normalizePathname(rawPathname); const pathname = normalizePathname(rawPathname);
if (pathname !== rawPathname) { if (pathname !== rawPathname) {
@@ -78,7 +62,6 @@ export default async function Page(props: {
<div className={tcls('flex', 'flex-row')}> <div className={tcls('flex', 'flex-row')}>
<PageBody <PageBody
space={space} space={space}
contentPointer={contentPointer}
contentTarget={contentTarget} contentTarget={contentTarget}
customization={customization} customization={customization}
context={contentRefContext} context={contentRefContext}
@@ -92,7 +75,6 @@ export default async function Page(props: {
{page.layout.outline ? ( {page.layout.outline ? (
<PageAside <PageAside
space={space} space={space}
site={parent?.object === 'site' ? parent : undefined}
customization={customization} customization={customization}
page={page} page={page}
document={document} document={document}
@@ -121,24 +103,14 @@ export async function generateViewport({ params }: { params: PagePathParams }):
}; };
} }
export async function generateMetadata({ export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
params, const { space, pages, page, customization, parent } = await fetchPageData(params);
searchParams,
}: {
params: PagePathParams;
searchParams: { fallback?: string };
}): Promise<Metadata> {
const { space, pages, page, customization, parent } = await getPageDataWithFallback({
pagePathParams: params,
searchParams,
});
if (!page) { if (!page) {
notFound(); notFound();
} }
return { return {
title: [page.title, getContentTitle(space, customization, parent)] title: [page.title, customization.title ?? space.title, parent?.title]
.filter(Boolean) .filter(Boolean)
.join(' | '), .join(' | '),
description: page.description ?? '', description: page.description ?? '',
@@ -153,34 +125,3 @@ export async function generateMetadata({
}, },
}; };
} }
/**
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
*/
async function getPageDataWithFallback(args: {
pagePathParams: PagePathParams;
searchParams: { fallback?: string };
redirectOnFallback?: boolean;
}) {
const { pagePathParams, searchParams, redirectOnFallback = false } = args;
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams);
let page = targetPage;
const canFallback = !!searchParams.fallback;
if (!page && canFallback) {
const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(pageHref(pages, rootPage?.page));
}
page = rootPage?.page;
}
return {
...otherPageData,
pages,
page,
};
}
+1 -9
View File
@@ -8,13 +8,10 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies'; import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations'; import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout'; import { SpaceLayout } from '@/components/SpaceLayout';
import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build'; import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp'; import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { absoluteHref, baseUrl } from '@/lib/links'; import { absoluteHref, baseUrl } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo'; import { shouldIndexSpace } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
import { ClientContexts } from './ClientContexts'; import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector'; import { RocketLoaderDetector } from './RocketLoaderDetector';
@@ -41,11 +38,6 @@ export default async function ContentLayout(props: { children: React.ReactNode }
scripts, scripts,
} = await fetchSpaceData(); } = await fetchSpaceData();
ReactDOM.preconnect(api().endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
scripts.forEach(({ script }) => { scripts.forEach(({ script }) => {
ReactDOM.preload(script, { ReactDOM.preload(script, {
as: 'script', as: 'script',
@@ -112,7 +104,7 @@ export async function generateMetadata(): Promise<Metadata> {
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return { return {
title: getContentTitle(space, customization, parent), title: `${parent ? parent.title : customization.title ?? space.title}`,
generator: `GitBook (${buildVersion()})`, generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()), metadataBase: new URL(baseUrl()),
icons: { icons: {
@@ -14,7 +14,6 @@ import {
} from '@/lib/api'; } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis'; import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -55,7 +54,7 @@ export async function GET(req: NextRequest) {
: space.visibility === ContentVisibility.InCollection && space.parent : space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent) ? await getCollection(space.parent)
: null; : null;
const contentTitle = getContentTitle(space, customization, parent); const contentTitle = parent?.title ?? customization.title ?? space.title;
return new ImageResponse( return new ImageResponse(
( (
@@ -3,8 +3,6 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import React from 'react'; import React from 'react';
import { getContentTitle } from '@/lib/utils';
import { PageIdParams, fetchPageData } from '../../../../fetch'; import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -33,7 +31,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
}} }}
> >
<h2 tw="text-7xl font-bold tracking-tight text-left"> <h2 tw="text-7xl font-bold tracking-tight text-left">
{getContentTitle(space, customization, parent)} {parent?.title ?? customization.title ?? space.title}
</h2> </h2>
<div tw="flex flex-1"> <div tw="flex flex-1">
<p tw="text-4xl">{page ? page.title : 'Not found'}</p> <p tw="text-4xl">{page ? page.title : 'Not found'}</p>
+6 -23
View File
@@ -4,7 +4,6 @@ import {
Revision, Revision,
RevisionPageDocument, RevisionPageDocument,
RevisionPageGroup, RevisionPageGroup,
RevisionPageLink,
SiteCustomizationSettings, SiteCustomizationSettings,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
@@ -314,17 +313,9 @@ function selectPages(
): FlatPageEntry[] => { ): FlatPageEntry[] => {
return [ return [
{ page, depth }, { page, depth },
...page.pages.flatMap((child) => { ...page.pages.flatMap((child) =>
if (child.type === 'link') { child.type === 'link' ? [] : flattenPage(child, depth + 1),
return []; ),
}
if (child.hidden) {
return [];
}
return flattenPage(child, depth + 1);
}),
]; ];
}; };
@@ -349,16 +340,8 @@ function selectPages(
return limitTo(flattenPage(found.page, 0)); return limitTo(flattenPage(found.page, 0));
} }
const allPages = rootPages.flatMap((page) => { const allPages = rootPages.flatMap((page) =>
if (page.type === 'link') { page.type === 'link' ? [] : flattenPage(page, 0),
return []; );
}
if (page.hidden) {
return [];
}
return flattenPage(page, 0);
});
return limitTo(allPages); return limitTo(allPages);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { captureException } from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import React from 'react'; import React from 'react';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -15,7 +15,7 @@ export default function ErrorPage(props: {
const language = useLanguage(); const language = useLanguage();
React.useEffect(() => { React.useEffect(() => {
captureException(error); Sentry.captureException(error);
}, [error]); }, [error]);
return ( return (
+4 -22
View File
@@ -40,7 +40,6 @@ export function getContentPointer(): ContentPointer | SiteContentPointer {
if (siteId) { if (siteId) {
const organizationId = headerSet.get('x-gitbook-content-organization'); const organizationId = headerSet.get('x-gitbook-content-organization');
const siteSpaceId = headerSet.get('x-gitbook-content-site-space'); const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
if (!organizationId) { if (!organizationId) {
throw new Error('Missing site content headers'); throw new Error('Missing site content headers');
} }
@@ -49,7 +48,6 @@ export function getContentPointer(): ContentPointer | SiteContentPointer {
siteId, siteId,
spaceId, spaceId,
siteSpaceId: siteSpaceId ?? undefined, siteSpaceId: siteSpaceId ?? undefined,
siteShareKey: siteShareKey ?? undefined,
organizationId, organizationId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined, revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined, changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
@@ -73,14 +71,7 @@ export async function fetchSpaceData() {
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all( const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
'siteId' in content 'siteId' in content
? [ ? [getCurrentSiteData(content), fetchParentSite(content.organizationId, content.siteId)]
getCurrentSiteData(content),
fetchParentSite({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
]
: [getSpaceData(content)], : [getSpaceData(content)],
); );
@@ -111,11 +102,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
const page = await resolvePage(contentTarget, pages, params); const page = await resolvePage(contentTarget, pages, params);
const [parent, document] = await Promise.all([ const [parent, document] = await Promise.all([
'siteId' in content 'siteId' in content
? fetchParentSite({ ? fetchParentSite(content.organizationId, content.siteId)
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
})
: fetchParentCollection(space), : fetchParentCollection(space),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null, page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]); ]);
@@ -186,15 +173,10 @@ async function fetchParentCollection(space: Space) {
return { parent: collection, spaces }; return { parent: collection, spaces };
} }
async function fetchParentSite(args: { async function fetchParentSite(organizationId: string, siteId: string) {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [site, siteSpaces] = await Promise.all([ const [site, siteSpaces] = await Promise.all([
getSite(organizationId, siteId), getSite(organizationId, siteId),
getSiteSpaces({ organizationId, siteId, siteShareKey }), getSiteSpaces(organizationId, siteId),
]); ]);
const spaces: Record<string, Space> = {}; const spaces: Record<string, Space> = {};
+2 -2
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import { captureException } from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import Error from 'next/error'; import Error from 'next/error';
import { useEffect } from 'react'; import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error }) { export default function GlobalError({ error }: { error: Error }) {
useEffect(() => { useEffect(() => {
captureException(error); Sentry.captureException(error);
}, [error]); }, [error]);
return ( return (
-200
View File
@@ -1,200 +0,0 @@
'use client';
import IconHeart from '@geist-ui/icons/heart';
import * as React from 'react';
import { ClassValue, tcls } from '@/lib/tailwind';
import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering';
import { AdItem, AdsResponse } from './types';
/**
* Fetch and render the Ad placement.
* https://docs.buysellads.com/ad-serving-api
*/
export function Ad({
zoneId,
spaceId,
placement,
ignore,
style,
mode = 'auto',
}: {
zoneId: string;
spaceId: string;
placement: string;
ignore: boolean;
style?: ClassValue;
mode?: 'classic' | 'auto' | 'cover';
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [failed, setFailed] = React.useState(false);
const [ad, setAd] = React.useState<AdItem | undefined>(undefined);
// Observe the container visibility
React.useEffect(() => {
if (!containerRef.current) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
}
},
{
root: null,
rootMargin: '0px',
threshold: 0.1,
},
);
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, []);
// When the container is visible,
// track an impression on the ad and fetch it
React.useEffect(() => {
if (!visible) {
return;
}
let cancelled = false;
(async () => {
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
url.searchParams.set('v', 'true');
if (ignore) {
url.searchParams.set('ignore', 'true');
}
try {
const res = await fetch(url);
const json: AdsResponse = await res.json();
if (cancelled) {
return;
}
const first = json.ads[0];
if (first && 'active' in first) {
setAd(first);
}
} catch (error) {
console.error(
'Failed to fetch ad, it might have been blocked by a ad-blocker',
error,
);
setFailed(true);
}
})();
return () => {
cancelled = true;
};
}, [visible, zoneId, ignore, placement]);
const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content');
viaUrl.searchParams.set('utm_medium', 'ads');
viaUrl.searchParams.set('utm_campaign', spaceId);
return (
<div ref={containerRef} className={tcls(style)}>
{ad && ad.description && ad.statlink ? (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
<p
className={tcls(
'mt-2',
'mr-2',
'text-xs',
'text-right',
'text-dark/5',
'dark:text-light/5',
)}
>
<a
target="_blank"
href={viaUrl.toString()}
className={tcls('hover:underline')}
>
Ads via GitBook
</a>
</p>
</>
) : failed ? (
<AdBlockerPlaceholder />
) : null}
</div>
);
}
/**
* Render attribution or verification pixels.
* https://docs.buysellads.com/ad-serving-api#pixels
*/
function AdPixels({ rawPixel }: { rawPixel: string }) {
const pixels = rawPixel.split('||');
const time = String(Math.round(Date.now() / 1e4) | 0);
return (
<div className={tcls('hidden')}>
{pixels.map((pixel, index) => {
return (
<img
key={index}
src={pixel.replace('[timestamp]', time)}
width="1"
height="1"
style={{ display: 'none' }}
alt="Ads tracking pixel"
/>
);
})}
</div>
);
}
/**
* Placeholder when visitor has an ad-blocker.
*/
function AdBlockerPlaceholder() {
return (
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'rounded-lg',
'p-4',
)}
>
<div className={tcls('flex', 'flex-row', 'gap-2', 'items-center')}>
<IconHeart className={tcls('w-4', 'h-4', 'text-primary-500')} />
<p className={tcls('text-xs', 'font-semibold')}>Ad disabled</p>
</div>
<p className={tcls('text-xs')}>
{`It looks like you're using an adblocker. Whitelist this site to help support this
project.`}
</p>
</div>
);
}
-47
View File
@@ -1,47 +0,0 @@
import * as React from 'react';
import { tcls } from '@/lib/tailwind';
import { AdItem } from './types';
/**
* Classic rendering for an ad.
*/
export function AdClassicRendering({ ad }: { ad: AdItem }) {
return (
<a
className={tcls(
'flex',
'flex-col',
'gap-4',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg',
'p-4',
)}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
{'smallImage' in ad ? (
<div>
<img alt="Ads logo" className={tcls('rounded-md')} src={ad.smallImage} />
</div>
) : (
<div
className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }}
>
<img alt="Ads logo" src={ad.logo} />
</div>
)}
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
</a>
);
}
-108
View File
@@ -1,108 +0,0 @@
import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import { AdCover } from './types';
/**
* Cover rendering for an ad.
*/
export function AdCoverRendering({ ad }: { ad: AdCover }) {
return (
<a
className={tcls(
'group/ad',
'relative',
'flex',
'flex-col',
'gap-4',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg',
'p-4',
'overflow-hidden',
'shadow-sm',
)}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
<div
className={tcls(
'absolute',
'inset-0',
'bg-center',
'bg-cover',
'bg-no-repeat',
'z-0',
)}
style={{
backgroundImage: `url(${ad.largeImage})`,
}}
/>
<div className={tcls('z-[2]')}>
<img
alt="Large image"
src={ad.largeImage}
className={tcls(
'rounded-md',
'shadow-md',
'max-h-32',
'group-hover/ad:max-h-16',
'transition-all',
)}
/>
</div>
<div className={tcls('z-[2]')}>
<img alt={ad.company} src={ad.logo} className={tcls('max-w-36', 'max-h-12')} />
</div>
<div className={tcls('flex', 'flex-col', 'z-[2]')}>
<div className={tcls('text-sm', 'font-semibold', 'mb-2')}>{ad.companyTagline}</div>
<div
className={tcls(
'text-xs',
'h-0',
'opacity-0',
'group-hover/ad:h-16',
'group-hover/ad:opacity-10',
'transition-all',
)}
>
{ad.description}
</div>
</div>
<div className={tcls('z-[2]')}>
<span
className={tcls(
'text-sm',
'font-semibold',
'shadow-lg',
'rounded-md',
'bg-white',
'py-2',
'px-4',
)}
style={{
backgroundColor: ad.ctaBackgroundColor,
color: ad.ctaTextColor ?? ad.backgroundColor,
}}
>
{ad.callToAction}
</span>
</div>
<div
className={tcls('absolute', 'inset-0', 'backdrop-blur', 'z-[1]')}
style={{
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}}
/>
</a>
);
}
-1
View File
@@ -1 +0,0 @@
export * from './Ad';
-51
View File
@@ -1,51 +0,0 @@
export interface AdGeneric {
active: string;
ad_via_link: string;
bannerid: string;
creativeid: string;
evenodd: string;
external_id: string;
height: string;
i: string;
identifier: string;
longimp: string;
longlink: string;
num_slots: string;
statimp: string;
statlink: string;
timestamp: string;
width: string;
zoneid: string;
zonekey: string;
rendering: 'carbon';
pixel?: string;
}
export interface AdClassic extends AdGeneric {
description: string;
smallImage: string;
}
export interface AdCover extends AdGeneric {
backgroundColor: string;
backgroundHoverColor?: string;
textColor?: string;
textColorHover?: string;
callToAction: string;
company: string;
companyTagline: string;
description: string;
largeImage: string;
image?: string;
logo: string;
ctaBackgroundColor?: string;
ctaBackgroundHoverColor?: string;
ctaTextColor?: string;
ctaTextColorHover?: string;
}
export type AdItem = AdClassic | AdCover;
export interface AdsResponse {
ads: Array<AdItem | {}>;
}
@@ -1,72 +1,27 @@
import { DocumentBlockContentRef } from '@gitbook/api'; import { DocumentBlockContentRef } from '@gitbook/api';
import { LogoIcon } from '@/components/icons';
import { Card, Emoji } from '@/components/primitives'; import { Card, Emoji } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) { export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props; const { block, context, style } = props;
const kind = block?.data?.ref?.kind;
const resolved = await context.resolveContentRef(block.data.ref, { const resolved = await context.resolveContentRef(block.data.ref, {
resolveAnchorText: true, resolveAnchorText: true,
}); });
if (!resolved) { if (!resolved) {
return null; return null;
} }
const isContentInOtherSpace =
context.contentRefContext?.space &&
'space' in block.data.ref &&
context.contentRefContext.space.id !== block.data.ref.space;
const kind = block?.data?.ref?.kind;
if ((resolved.active && kind === 'space') || isContentInOtherSpace) {
return <SpaceRefCard {...props} resolved={resolved} />;
}
return ( return (
<Card <Card
leadingIcon={resolved.emoji ? <Emoji code={resolved.emoji} style="text-xl" /> : null} leadingIcon={resolved.emoji ? <Emoji code={resolved.emoji} /> : null}
href={resolved.href} href={resolved.href}
preTitle={kind}
title={resolved.text} title={resolved.text}
style={style} style={style}
/> />
); );
} }
async function SpaceRefCard(
props: { resolved: ResolvedContentRef } & BlockProps<DocumentBlockContentRef>,
) {
const { context, style, resolved } = props;
const spaceId = context.contentRefContext?.space.id;
if (!spaceId) {
return null;
}
const spaceCustomization = await ignoreAPIError(getSpaceCustomization(spaceId));
const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
return (
<Card
leadingIcon={
<LogoIcon
icon={customIcon}
emoji={customEmoji}
alt=""
sizes={[{ width: 24 }]}
style={['object-contain', 'size-6']}
/>
}
href={resolved.href}
title={resolved.text}
postTitle={resolved.subText}
style={style}
/>
);
}
@@ -15,7 +15,7 @@ import './theme.css';
*/ */
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) { export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props; const { block, document, style, context } = props;
const withHighlighting = context.shouldHighlightCode(context.content?.spaceId); const withHighlighting = context.shouldHighlightCode();
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block); const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
const id = block.key!; const id = block.key!;
@@ -1,15 +1,5 @@
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50; const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
/**
* Spaces where we'll always use plain highlighting.
*/
const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL',
'ryjzVNizLfd6pYogN3dm',
];
/** /**
* Protect against memory issues when highlighting a large number of code blocks. * Protect against memory issues when highlighting a large number of code blocks.
* This context only allows 50 code blocks per render to be highlighted. * This context only allows 50 code blocks per render to be highlighted.
@@ -19,11 +9,7 @@ const PLAIN_HIGHLIGHTING_SPACES: string[] = [
*/ */
export function createHighlightingContext() { export function createHighlightingContext() {
let count = 0; let count = 0;
return (spaceId?: string | undefined) => { return () => {
if (!spaceId || PLAIN_HIGHLIGHTING_SPACES.includes(spaceId)) {
return false;
}
count += 1; count += 1;
return count < CODE_HIGHLIGHT_BLOCK_LIMIT; return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
}; };
+1 -1
View File
@@ -45,7 +45,7 @@ export interface DocumentContext {
* *
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a * https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/ */
shouldHighlightCode: (spaceId: string | undefined) => boolean; shouldHighlightCode: () => boolean;
} }
export interface DocumentContextProps { export interface DocumentContextProps {
+12 -44
View File
@@ -1,25 +1,18 @@
import * as gitbookAPI from '@gitbook/api'; import { DocumentBlockEmbed } from '@gitbook/api';
import { headers } from 'next/headers';
import Script from 'next/script'; import Script from 'next/script';
import ReactDOM from 'react-dom';
import { Card } from '@/components/primitives'; import { Card } from '@/components/primitives';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { Caption } from './Caption'; import { Caption } from './Caption';
import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) { export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props; const { block } = props;
const nonce = headers().get('x-nonce') || undefined;
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce }); const { data: embed } = await api().urls.getEmbedByUrl({ url: block.data.url });
const { data: embed } = await (context.content
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
: api().urls.getEmbedByUrl({ url: block.data.url }));
return ( return (
<Caption {...props}> <Caption {...props}>
@@ -30,14 +23,14 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
__html: embed.html, __html: embed.html,
}} }}
/> />
<Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} /> {/* We load the iframely script to resize the embed iframes dynamically */}
<Script
src="https://cdn.iframe.ly/embed.js"
defer
async
nonce={getContentSecurityPolicyNonce()}
/>
</> </>
) : embed.type === 'integration' ? (
<IntegrationBlock
{...otherProps}
context={context}
block={createIntegrationBlock(block.data.url, embed.integration, embed.block)}
/>
) : ( ) : (
<Card <Card
leadingIcon={ leadingIcon={
@@ -53,28 +46,3 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
</Caption> </Caption>
); );
} }
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
function createIntegrationBlock(
url: string,
integration: string,
block: gitbookAPI.IntegrationBlock,
): gitbookAPI.DocumentBlockIntegration {
return {
object: 'block',
type: 'integration',
isVoid: true,
data: {
integration,
block: block.id,
props: {},
action: {
action: '@link.unfurl',
url,
},
url,
},
};
}
+41 -6
View File
@@ -1,10 +1,12 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { DocumentBlockFile } from '@gitbook/api'; import { DocumentBlockFile } from '@gitbook/api';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) { export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context, style } = props; const { block, context, style } = props;
@@ -16,7 +18,42 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return null; return null;
} }
const contentType = getSimplifiedContentType(file.contentType); const contentType = (() => {
switch (file.contentType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'image/png':
case 'image/jpeg':
case 'image/gif':
case 'image/webp':
case 'image/tiff':
case 'image/svg+xml':
return 'image';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
})();
const icon = (() => {
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
})();
return ( return (
<a <a
@@ -66,9 +103,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'dark:border-light/2', 'dark:border-light/2',
)} )}
> >
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}> <div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>{icon}</div>
<FileIcon contentType={contentType} />
</div>
<div <div
className={tcls( className={tcls(
'text-xs', 'text-xs',
-24
View File
@@ -1,24 +0,0 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { SimplifiedFileType } from '@/lib/files';
/**
* Render an appropriate icon for a file.
*/
export function FileIcon(props: { contentType: SimplifiedFileType | null }) {
const { contentType } = props;
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
}
@@ -70,7 +70,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const initialInput = { const initialInput = {
componentId: block.data.block, componentId: block.data.block,
props: block.data.props, props: block.data.props,
action: block.data.action,
context: contentKitContext, context: contentKitContext,
}; };
+73 -97
View File
@@ -1,139 +1,115 @@
.light .scalar-modal-layout, .light .scalar-modal-layout,
.light .scalar { .light .scalar {
--scalar-color-1: color-mix( --theme-color-1: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-300, 180 180 180)), rgb(var(--primary-base-300, 180 180 180)),
rgb(var(--dark-base, 23 23 23)) 96% rgb(var(--dark-base, 23 23 23)) 96%
); );
--scalar-color-2: color-mix( --theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72));
in srgb, --theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
var(--scalar-color-1), --theme-color-accent: #007d9c;
transparent calc(100% - 100% * 0.72)
);
--scalar-color-3: color-mix(
in srgb,
var(--scalar-color-1),
transparent calc(100% - 100% * 0.4)
);
--scalar-color-accent: #007d9c;
--scalar-background-1: rgb(var(--light-base, 255 255 255)); --theme-background-1: rgb(var(--light-base, 255 255 255));
--scalar-background-2: color-mix( --theme-background-2: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-800, 30 30 30)), rgb(var(--primary-base-800, 30 30 30)),
var(--scalar-background-1) 96% var(--theme-background-1) 96%
); );
--scalar-background-3: color-mix( --theme-background-3: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-800, 30 30 30)), rgb(var(--primary-base-800, 30 30 30)),
var(--scalar-background-1) 90% var(--theme-background-1) 90%
); );
--scalar-background-accent: #007d9c1f; --theme-background-accent: #007d9c1f;
--scalar-code-language-color-supersede: var(--scalar-color-1); --theme-code-language-color-supersede: var(--theme-color-1);
--scalar-code-languages-background-supersede: var(--scalar-background-1); --theme-code-languages-background-supersede: var(--theme-background-1);
--scalar-border-color: color-mix( --theme-border-color: color-mix(
in srgb, in srgb,
var(--scalar-color-1), var(--theme-color-1),
transparent calc(100% - 100% * 0.08) transparent calc(100% - 100% * 0.08)
); );
--scalar-color-green: #0a6355; --theme-color-green: #0a6355;
--scalar-color-red: #dc1b19; --theme-color-red: #dc1b19;
--scalar-color-yellow: #ffc90d; --theme-color-yellow: #ffc90d;
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219)); --theme-color-blue: rgb(var(--primary-color-500, 52 109 219));
--scalar-color-orange: #ff8d4d; --theme-color-orange: #ff8d4d;
--scalar-color-purple: #8250df; --theme-color-purple: #8250df;
--scalar-scrollbar-color: rgba(255, 255, 255, 0.24); --theme-scrollbar-color: rgba(255, 255, 255, 0.24);
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48); --theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
--scalar-button-1: rgb(49 53 56);
--scalar-button-1-color: #fff;
--scalar-button-1-hover: rgb(28 31 33);
} }
.dark .scalar-modal-layout, .dark .scalar-modal-layout,
.dark .scalar { .dark .scalar {
--scalar-color-1: color-mix( --theme-color-1: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-700, 70 70 70)), rgb(var(--primary-base-700, 70 70 70)),
rgb(var(--light-base, 255 255 255)) 100% rgb(var(--light-base, 255 255 255)) 100%
); );
--scalar-color-2: color-mix( --theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64));
in srgb, --theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
var(--scalar-color-1), --theme-color-accent: #50b7e0;
transparent calc(100% - 100% * 0.64)
);
--scalar-color-3: color-mix(
in srgb,
var(--scalar-color-1),
transparent calc(100% - 100% * 0.4)
);
--scalar-color-accent: #50b7e0;
--scalar-background-1: rgb(var(--dark-base, 22 22 22)); --theme-background-1: rgb(var(--dark-base, 22 22 22));
--scalar-background-2: color-mix( --theme-background-2: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-200, 200 200 200)), rgb(var(--primary-base-200, 200 200 200)),
var(--scalar-background-1) 92% var(--theme-background-1) 92%
); );
--scalar-background-3: color-mix( --theme-background-3: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-200, 200 200 200)), rgb(var(--primary-base-200, 200 200 200)),
var(--scalar-background-1) 88% var(--theme-background-1) 88%
); );
--scalar-background-accent: #8ab4f81f; --theme-background-accent: #8ab4f81f;
--scalar-code-languages-background-supersede: var(--scalar-background-1); --theme-code-languages-background-supersede: var(--theme-background-1);
--scalar-border-color: color-mix( --theme-border-color: color-mix(
in srgb, in srgb,
var(--scalar-color-1), var(--theme-color-1),
transparent calc(100% - 100% * 0.08) transparent calc(100% - 100% * 0.08)
); );
--scalar-color-green: #56b6c2; --theme-color-green: #56b6c2;
--scalar-color-red: rgb(245 124 97); --theme-color-red: rgb(245 124 97);
--scalar-color-yellow: #edbe20; --theme-color-yellow: #edbe20;
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226)); --theme-color-blue: rgb(var(--primary-color-400, 93 138 226));
--scalar-color-orange: #d19a66; --theme-color-orange: #d19a66;
--scalar-color-purple: #5203d1; --theme-color-purple: #5203d1;
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18); --theme-scrollbar-color: rgba(0, 0, 0, 0.18);
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36); --theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
--scalar-button-1: #f6f6f6;
--scalar-button-1-color: #000;
--scalar-button-1-hover: #e7e7e7;
} }
.scalar-modal-layout, .scalar-modal-layout,
.scalar { .scalar {
--scalar-font: initial; --theme-font: initial;
--scalar-font-code: var(--font-mono); --theme-font-code: var(--font-mono);
--scalar-paragraph: 16px; --theme-paragraph: 16px;
--scalar-small: 14px; --theme-small: 14px;
--scalar-mini: 13px; --theme-mini: 13px;
--scalar-micro: 12px; --theme-micro: 12px;
--scalar-bold: 600; --theme-bold: 600;
--scalar-semibold: 500; --theme-semibold: 500;
--scalar-regular: 400; --theme-regular: 400;
/* Font sizes for interactive applications (not rendered text content) */ /* Font sizes for interactive applications (not rendered text content) */
--scalar-font-size-1: 24px; --theme-font-size-1: 24px;
--scalar-font-size-2: 16px; --theme-font-size-2: 16px;
--scalar-font-size-3: 14px; --theme-font-size-3: 14px;
--scalar-font-size-4: 13px; --theme-font-size-4: 13px;
--scalar-font-size-5: 12px; --theme-font-size-5: 12px;
--scalar-line-height-1: 32px; --theme-line-height-1: 32px;
--scalar-line-height-2: 24px; --theme-line-height-2: 24px;
--scalar-line-height-3: 20px; --theme-line-height-3: 20px;
--scalar-line-height-4: 18px; --theme-line-height-4: 18px;
--scalar-line-height-5: 16px; --theme-line-height-5: 16px;
--scalar-app-header-height: 35px; --scalar-app-header-height: 35px;
} }
.scalar input::placeholder { .scalar input::placeholder {
color: var(--scalar-color-3); color: var(--theme-color-3);
} }
.scalar .scalar-app-header { .scalar .scalar-app-header {
width: 100%; width: 100%;
@@ -158,15 +134,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
background: transparent; background: transparent;
color: var(--scalar-color-1); color: var(--theme-color-1);
font-size: var(--scalar-small); font-size: var(--theme-small);
font-weight: var(--scalar-semibold); font-weight: var(--theme-semibold);
} }
.scalar-api-client__close:hover { .scalar-api-client__close:hover {
cursor: pointer; cursor: pointer;
} }
.scalar .scalar-app { .scalar .scalar-app {
background: var(--scalar-background-3); background: var(--theme-background-3);
height: calc(100dvh - 100px); height: calc(100dvh - 100px);
max-width: 1280px; max-width: 1280px;
width: 100%; width: 100%;
@@ -251,10 +227,10 @@
line-height: normal; line-height: normal;
} }
.scalar .scalar-app-header span { .scalar .scalar-app-header span {
color: var(--scalar-color-3); color: var(--theme-color-3);
} }
.scalar .scalar-app-header a { .scalar .scalar-app-header a {
color: var(--scalar-color-1); color: var(--theme-color-1);
} }
.scalar .scalar-app-header a:hover { .scalar .scalar-app-header a:hover {
text-decoration: underline; text-decoration: underline;
@@ -275,7 +251,7 @@
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
color: var(--scalar-color-blue); color: var(--theme-color-blue);
appearance: none; appearance: none;
outline: none; outline: none;
border: none; border: none;
@@ -285,7 +261,7 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.scalar-activate:hover .scalar-activate-button { .scalar-activate:hover .scalar-activate-button {
background: var(--scalar-background-3); background: var(--theme-background-3);
border-radius: 3px; border-radius: 3px;
} }
@@ -317,12 +293,12 @@
scrollbar-color: rgba(255, 255, 255, 0.24) transparent; scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
} }
.scalar .custom-scroll:hover::-webkit-scrollbar-thumb { .scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
background: var(--scalar-scrollbar-color, var(--default-theme-scrollbar-color)); background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color));
background-clip: content-box; background-clip: content-box;
border: 3px solid transparent; border: 3px solid transparent;
} }
.scalar .custom-scroll::-webkit-scrollbar-thumb:active { .scalar .custom-scroll::-webkit-scrollbar-thumb:active {
background: var(--scalar-scrollbar-color-active, var(--default-theme-scrollbar-color-active)); background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
background-clip: content-box; background-clip: content-box;
border: 3px solid transparent; border: 3px solid transparent;
} }
@@ -172,10 +172,6 @@
@apply prose-sm; @apply prose-sm;
} }
.openapi-schema-example {
@apply prose-sm mt-2 text-dark/10 dark:text-light/10;
}
/** Authentication */ /** Authentication */
.openapi-securities { .openapi-securities {
@@ -4,9 +4,7 @@ import assertNever from 'assert-never';
import { Checkbox, Emoji } from '@/components/primitives'; import { Checkbox, Emoji } from '@/components/primitives';
import { StyledLink } from '@/components/primitives'; import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document'; import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript'; import { filterOutNullable } from '@/lib/typescript';
@@ -14,7 +12,6 @@ import { TableRecordKV } from './Table';
import { getColumnAlignment } from './utils'; import { getColumnAlignment } from './utils';
import { BlockProps } from '../Block'; import { BlockProps } from '../Block';
import { Blocks } from '../Blocks'; import { Blocks } from '../Blocks';
import { FileIcon } from '../FileIcon';
/** /**
* Render the value for a column in a record. * Render the value for a column in a record.
@@ -128,41 +125,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return ( return (
<Tag className={tcls('text-base')}> <Tag className={tcls('text-base')}>
{files.filter(filterOutNullable).map((ref, index) => { {files.filter(filterOutNullable).map((file, index) => (
const contentType = ref.file <StyledLink key={index} href={file.href}>
? getSimplifiedContentType(ref.file.contentType) {file.text}
: null; </StyledLink>
))}
return (
<StyledLink
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
>
{contentType === 'image' ? (
<Image
style={['max-h-[1lh]', 'h-[1lh]']}
alt={ref.text}
sizes={[{ width: 24 }]}
sources={{
light: {
src: ref.href,
size: {
width: 24,
height: 24,
},
},
}}
priority="lazy"
/>
) : (
<FileIcon contentType={contentType} />
)}
{ref.text}
</StyledLink>
);
})}
</Tag> </Tag>
); );
case 'content-ref': { case 'content-ref': {
+31 -11
View File
@@ -8,11 +8,9 @@ import {
} from '@gitbook/api'; } from '@gitbook/api';
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu'; import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
import { LogoIcon } from '@/components/icons';
import { Image } from '@/components/utils'; import { Image } from '@/components/utils';
import { absoluteHref } from '@/lib/links'; import { absoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { Link } from '../primitives'; import { Link } from '../primitives';
@@ -96,18 +94,40 @@ export function HeaderLogo(props: HeaderLogoProps) {
function LogoFallback(props: HeaderLogoProps) { function LogoFallback(props: HeaderLogoProps) {
const { parent, space, customization } = props; const { parent, space, customization } = props;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : undefined; const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
const customEmoji = 'emoji' in customization.favicon ? customization.favicon.emoji : undefined;
return ( return (
<> <>
<LogoIcon <Image
icon={customIcon} alt="Logo"
emoji={customEmoji} sources={
alt="" customIcon
sizes={[{ width: 32 }]} ? {
style={['object-contain', 'size-8']} light: { src: customIcon.light, aspectRatio: '1' },
dark: customIcon.dark
? { src: customIcon.dark, aspectRatio: '1' }
: null,
}
: {
light: {
src: absoluteHref('~gitbook/icon?size=medium&theme=light', true),
size: { width: 256, height: 256 },
},
dark: {
src: absoluteHref('~gitbook/icon?size=medium&theme=dark', true),
size: { width: 256, height: 256 },
},
}
}
sizes={[
{
width: 32,
},
]}
fetchPriority="high" fetchPriority="high"
style={['w-8', 'h-8', 'object-contain']}
/> />
<h1 <h1
className={tcls( className={tcls(
'text-pretty', 'text-pretty',
@@ -125,7 +145,7 @@ function LogoFallback(props: HeaderLogoProps) {
: 'text-header-link', : 'text-header-link',
)} )}
> >
{getContentTitle(space, customization, parent)} {parent ? parent.title : customization.title ?? space.title}
</h1> </h1>
</> </>
); );
+9 -12
View File
@@ -1,9 +1,8 @@
import { Space } from '@gitbook/api'; import { Collection, Space } from '@gitbook/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Dropdown, DropdownChevron, DropdownMenu } from './Dropdown'; import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
export function SpacesDropdown(props: { space: Space; spaces: Space[] }) { export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
const { space, spaces } = props; const { space, spaces } = props;
@@ -30,16 +29,14 @@ export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
)} )}
> >
<DropdownMenu> <DropdownMenu>
{spaces.map((otherSpace, index) => ( {spaces.map((otherSpace) => (
<SpacesDropdownMenuItem <DropdownMenuItem
key={`${otherSpace.id}-${index}`} key={otherSpace.id}
variantSpace={{ href={otherSpace.urls.published ?? otherSpace.urls.app}
id: otherSpace.id,
title: otherSpace.title,
url: otherSpace.urls.published ?? otherSpace.urls.app,
}}
active={otherSpace.id === space.id} active={otherSpace.id === space.id}
/> >
{otherSpace.title}
</DropdownMenuItem>
))} ))}
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
@@ -1,30 +0,0 @@
'use client';
import { Space } from '@gitbook/api';
import { useSelectedLayoutSegment } from 'next/navigation';
import { DropdownMenuItem } from './Dropdown';
function useVariantSpaceHref(variantSpaceUrl: string) {
const currentPathname = useSelectedLayoutSegment() ?? '';
const targetUrl = new URL(variantSpaceUrl);
targetUrl.pathname += `/${currentPathname}`;
targetUrl.pathname = targetUrl.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
targetUrl.searchParams.set('fallback', 'true');
return targetUrl.toString();
}
export function SpacesDropdownMenuItem(props: {
variantSpace: { id: Space['id']; title: Space['title']; url: string };
active: boolean;
}) {
const { variantSpace, active } = props;
const variantHref = useVariantSpaceHref(variantSpace.url);
return (
<DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}>
{variantSpace.title}
</DropdownMenuItem>
);
}
+3 -23
View File
@@ -6,7 +6,6 @@ import {
CustomizationSettings, CustomizationSettings,
JSONDocument, JSONDocument,
RevisionPageDocument, RevisionPageDocument,
Site,
SiteCustomizationSettings, SiteCustomizationSettings,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
@@ -18,10 +17,9 @@ import { getDocumentSections } from '@/lib/document';
import { absoluteHref } from '@/lib/links'; import { absoluteHref } from '@/lib/links';
import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { getPDFUrlSearchParams } from '@/lib/urls'; import { getPDFUrl, getPDFUrlSearchParams } from '@/lib/urls';
import { ScrollSectionsList } from './ScrollSectionsList'; import { ScrollSectionsList } from './ScrollSectionsList';
import { Ad } from '../Ads';
import { PageFeedbackForm } from '../PageFeedback'; import { PageFeedbackForm } from '../PageFeedback';
/** /**
@@ -29,7 +27,6 @@ import { PageFeedbackForm } from '../PageFeedback';
*/ */
export async function PageAside(props: { export async function PageAside(props: {
space: Space; space: Space;
site: Site | undefined;
customization: CustomizationSettings | SiteCustomizationSettings; customization: CustomizationSettings | SiteCustomizationSettings;
page: RevisionPageDocument; page: RevisionPageDocument;
document: JSONDocument | null; document: JSONDocument | null;
@@ -38,16 +35,8 @@ export async function PageAside(props: {
withFullPageCover: boolean; withFullPageCover: boolean;
withPageFeedback: boolean; withPageFeedback: boolean;
}) { }) {
const { const { space, page, document, customization, withHeaderOffset, withPageFeedback, context } =
space, props;
site,
page,
document,
customization,
withHeaderOffset,
withPageFeedback,
context,
} = props;
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
return ( return (
@@ -183,15 +172,6 @@ export async function PageAside(props: {
) : null} ) : null}
</div> </div>
</div> </div>
{site?.ads ? (
<Ad
zoneId={site.ads.zone}
placement="page.aside"
spaceId={space.id}
ignore={process.env.NODE_ENV !== 'production'}
style={tcls('mt-4')}
/>
) : null}
</aside> </aside>
); );
} }
+4 -22
View File
@@ -9,7 +9,7 @@ import React from 'react';
import { getSpaceLanguage } from '@/intl/server'; import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate'; import { t } from '@/intl/translate';
import { ContentPointer, ContentTarget, SiteContentPointer, api } from '@/lib/api'; import { ContentTarget, api } from '@/lib/api';
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document'; import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -25,7 +25,6 @@ import { DateRelative } from '../primitives';
export function PageBody(props: { export function PageBody(props: {
space: Space; space: Space;
contentPointer: ContentPointer | SiteContentPointer;
contentTarget: ContentTarget; contentTarget: ContentTarget;
customization: CustomizationSettings | SiteCustomizationSettings; customization: CustomizationSettings | SiteCustomizationSettings;
page: RevisionPageDocument; page: RevisionPageDocument;
@@ -33,25 +32,13 @@ export function PageBody(props: {
context: ContentRefContext; context: ContentRefContext;
withPageFeedback: boolean; withPageFeedback: boolean;
}) { }) {
const { const { space, contentTarget, customization, context, page, document, withPageFeedback } =
space, props;
contentPointer,
contentTarget,
customization,
context,
page,
document,
withPageFeedback,
} = props;
const asFullWidth = document ? hasFullWidthBlock(document) : false; const asFullWidth = document ? hasFullWidthBlock(document) : false;
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
const updatedAt = page.updatedAt ?? page.createdAt; const updatedAt = page.updatedAt ?? page.createdAt;
const shouldHighlightCode = createHighlightingContext(); const shouldHighlightCode = createHighlightingContext();
const sitePointer =
'siteId' in contentPointer
? { organizationId: contentPointer.organizationId, siteId: contentPointer.siteId }
: undefined;
return ( return (
<> <>
@@ -142,12 +129,7 @@ export function PageBody(props: {
</div> </div>
</main> </main>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<TrackPageView <TrackPageView spaceId={space.id} pageId={page.id} apiHost={api().endpoint} />
sitePointer={sitePointer}
spaceId={space.id}
pageId={page.id}
apiHost={api().endpoint}
/>
</React.Suspense> </React.Suspense>
</> </>
); );
+16 -60
View File
@@ -1,66 +1,28 @@
'use client'; 'use client';
import type { RequestSiteTrackPageView, RequestSpaceTrackPageView } from '@gitbook/api'; import type { RequestSpaceTrackPageView } from '@gitbook/api';
import cookies from 'js-cookie'; import cookies from 'js-cookie';
import * as React from 'react'; import * as React from 'react';
import { getVisitorId } from '@/lib/analytics'; import { getVisitorId } from '@/lib/analytics';
import { SiteContentPointer } from '@/lib/api';
/** /**
* Track the page view for the current page to integrations. * Track the page view for the current page to integrations.
*/ */
export function TrackPageView(props: { export function TrackPageView(props: {
apiHost: string; apiHost: string;
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
spaceId: string; spaceId: string;
pageId: string | undefined; pageId: string | undefined;
}) { }) {
const { apiHost, sitePointer, spaceId, pageId } = props; const { apiHost, spaceId, pageId } = props;
React.useEffect(() => { React.useEffect(() => {
trackPageView({ apiHost, sitePointer, spaceId, pageId }); trackPageView(apiHost, spaceId, pageId);
}, [apiHost, spaceId, pageId, sitePointer]); }, [apiHost, spaceId, pageId]);
return null; return null;
} }
async function sendSpaceTrackPageViewRequest(args: {
apiHost: string;
spaceId: string;
body: RequestSpaceTrackPageView;
}) {
const { apiHost, spaceId, body } = args;
const url = new URL(apiHost);
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
async function sendSiteTrackPageViewRequest(args: {
apiHost: string;
sitePointer: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
body: RequestSiteTrackPageView;
}) {
const { apiHost, sitePointer, body } = args;
const url = new URL(apiHost);
url.pathname = `/v1/orgs/${sitePointer.organizationId}/sites/${sitePointer.siteId}/insights/track_view`;
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
let latestPageId: string | undefined | null = null; let latestPageId: string | undefined | null = null;
/** /**
@@ -68,13 +30,7 @@ let latestPageId: string | undefined | null = null;
* We don't use the API client to avoid shipping 80kb of JS to the client. * We don't use the API client to avoid shipping 80kb of JS to the client.
* And instead use a simple fetch. * And instead use a simple fetch.
*/ */
async function trackPageView(args: { async function trackPageView(apiHost: string, spaceId: string, pageId: string | undefined) {
apiHost: string;
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
spaceId: string;
pageId: string | undefined;
}) {
const { apiHost, sitePointer, pageId, spaceId } = args;
if (pageId === latestPageId) { if (pageId === latestPageId) {
// The hook can be called multiple times, we only want to track once. // The hook can be called multiple times, we only want to track once.
return; return;
@@ -83,7 +39,7 @@ async function trackPageView(args: {
latestPageId = pageId; latestPageId = pageId;
const visitorId = await getVisitorId(); const visitorId = await getVisitorId();
const sharedTrackedProps = { const body: RequestSpaceTrackPageView = {
url: window.location.href, url: window.location.href,
pageId, pageId,
visitor: { visitor: {
@@ -95,17 +51,17 @@ async function trackPageView(args: {
referrer: document.referrer, referrer: document.referrer,
}; };
const url = new URL(apiHost);
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
try { try {
sitePointer await fetch(url, {
? await sendSiteTrackPageViewRequest({ method: 'POST',
apiHost, headers: {
sitePointer, 'Content-Type': 'application/json',
body: { },
...sharedTrackedProps, body: JSON.stringify(body),
spaceId, });
},
})
: await sendSpaceTrackPageViewRequest({ apiHost, spaceId, body: sharedTrackedProps });
} catch (error) { } catch (error) {
console.error('Failed to track page view', error); console.error('Failed to track page view', error);
} }
+90 -116
View File
@@ -6,11 +6,10 @@ import { atom, useRecoilState } from 'recoil';
import { Loading } from '@/components/primitives'; import { Loading } from '@/components/primitives';
import { useLanguage } from '@/intl/client'; import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate'; import { t } from '@/intl/translate';
import { TranslationLanguage } from '@/intl/translations';
import { iterateStreamResponse } from '@/lib/actions'; import { iterateStreamResponse } from '@/lib/actions';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-actions'; import { AskAnswerResult, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch'; import { useSearch, useSearchLink } from './useSearch';
import { Link } from '../primitives'; import { Link } from '../primitives';
@@ -95,11 +94,6 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
}; };
}, [setState]); }, [setState]);
let hasAnswer = false;
if (state && 'answer' in state) {
hasAnswer = !!state?.answer?.body;
}
return ( return (
<div <div
className={tcls( className={tcls(
@@ -135,128 +129,108 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
function AnswerBody(props: { answer: AskAnswerResult }) { function AnswerBody(props: { answer: AskAnswerResult }) {
const { answer } = props; const { answer } = props;
const getSearchLinkProps = useSearchLink();
const language = useLanguage(); const language = useLanguage();
const [, setSearchState] = useSearch();
const onClose = () => {
setSearchState(null);
};
return ( return (
<> <>
<div <div
data-test="search-ask-answer" data-test="search-ask-answer"
className={tcls('mt-4', 'px-4', 'text-dark/9', 'dark:text-light/8')} className={tcls('mt-4', 'px-4', 'text-dark/9', 'dark:text-light/8')}
> >
{answer.hasAnswer ? answer.body : t(language, 'search_ask_no_answer')} {answer.body}
</div> </div>
{answer.followupQuestions.length > 0 ? ( {answer.followupQuestions.length > 0 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} /> <div className={tcls('mt-7', 'flex', 'flex-col', 'flex-wrap', 'gap-1')}>
{answer.followupQuestions.map((question) => (
<Link
key={question}
className={tcls(
'text-sm',
'font-medium',
'inline-flex',
'items-start',
'gap-2',
'px-4',
'py-1',
'text-primary-500',
'focus-within:text-primary-700',
'hover:bg-primary/2',
'dark:text-primary-400',
'dark:hover:bg-primary-500/3',
)}
{...getSearchLinkProps({
query: question,
ask: true,
})}
>
<IconSearch
className={tcls(
'w-[15px]',
'h-[15px]',
'shrink-0',
'mt-0.5',
'[&>path]:[stroke-opacity:0.64]',
)}
/>
<span>{question}</span>
</Link>
))}
</div>
) : null} ) : null}
{answer.sources.length > 0 ? ( {answer.sources.length > 0 ? (
<AnswerSources <div
hasAnswer={answer.hasAnswer} className={tcls(
sources={answer.sources} 'flex',
language={language} 'flex-wrap',
/> 'gap-2',
'mt-7',
'py-4',
'px-4',
'border-t',
'border-dark/2',
'dark:border-light/1',
)}
>
<span className={tcls('text-sm')}>{t(language, 'search_ask_sources')}</span>
{answer.sources.map((source) => (
<span key={source.id} className={tcls()}>
<Link
onClick={onClose}
className={tcls(
'flex',
'text-sm',
'text-dark/7',
'hover:underline',
'focus-within:text-primary-700',
'dark:text-light/8',
)}
href={source.href}
prefetch={false}
>
<IconBox
className={tcls(
'stroke-dark/6',
'w-[15px]',
'h-[15px]',
'shrink-0',
'mt-0.5',
'mr-0.5',
'dark:stroke-light/6',
)}
/>
{source.title}
</Link>
</span>
))}
</div>
) : null} ) : null}
</> </>
); );
} }
function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
const { followupQuestions } = props;
const getSearchLinkProps = useSearchLink();
return (
<div className={tcls('mt-7 mb-4', 'flex', 'flex-col', 'flex-wrap', 'gap-1')}>
{followupQuestions.map((question) => (
<Link
key={question}
className={tcls(
'text-sm',
'font-medium',
'inline-flex',
'items-start',
'gap-2',
'px-4',
'py-1',
'text-primary-500',
'focus-within:text-primary-700',
'hover:bg-primary/2',
'dark:text-primary-400',
'dark:hover:bg-primary-500/3',
)}
{...getSearchLinkProps({
query: question,
ask: true,
})}
>
<IconSearch
className={tcls(
'w-[15px]',
'h-[15px]',
'shrink-0',
'mt-0.5',
'[&>path]:[stroke-opacity:0.64]',
)}
/>
<span>{question}</span>
</Link>
))}
</div>
);
}
function AnswerSources(props: {
sources: AskAnswerSource[];
language: TranslationLanguage;
hasAnswer?: boolean;
}) {
const { sources, language, hasAnswer } = props;
return (
<div
className={tcls(
'flex',
'flex-wrap',
'gap-2',
'mt-7',
'py-4',
'px-4',
'border-t',
'border-dark/2',
'dark:border-light/1',
)}
>
<span className={tcls('text-sm')}>
{t(language, hasAnswer ? 'search_ask_sources' : 'search_ask_sources_no_answer')}
</span>
{sources.map((source) => (
<span key={source.id} className={tcls()}>
<Link
className={tcls(
'flex',
'text-sm',
'text-dark/7',
'hover:underline',
'focus-within:text-primary-700',
'dark:text-light/8',
)}
href={source.href}
prefetch={false}
>
<IconBox
className={tcls(
'stroke-dark/6',
'w-[15px]',
'h-[15px]',
'shrink-0',
'mt-0.5',
'mr-0.5',
'dark:stroke-light/6',
)}
/>
{source.title}
</Link>
</span>
))}
</div>
);
}
+22 -17
View File
@@ -232,23 +232,28 @@ function SearchModalBody(
</div> </div>
</div> </div>
{!state.ask || !withAsk ? ( {!state.ask || !withAsk ? (
<SearchResults <>
ref={resultsRef} <SearchResults
spaceId={spaceId} ref={resultsRef}
revisionId={revisionId} spaceId={spaceId}
parent={state.global ? parent : null} revisionId={revisionId}
query={state.query} parent={state.global ? parent : null}
withAsk={withAsk} query={state.query}
onSwitchToAsk={() => { withAsk={withAsk}
onChangeQuery({ onSwitchToAsk={() => {
ask: true, onChangeQuery({
query: state.query, ask: true,
global: state.global, query: state.query,
}); global: state.global,
}} });
> }}
{parent && state.query ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null} onClose={onClose}
</SearchResults> >
{parent && state.query ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
</SearchResults>
</>
) : null} ) : null}
{state.query && state.ask && withAsk ? ( {state.query && state.ask && withAsk ? (
<SearchAskAnswer spaceId={spaceId} query={state.query} /> <SearchAskAnswer spaceId={spaceId} query={state.query} />
@@ -11,15 +11,20 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
query: string; query: string;
item: ComputedPageResult; item: ComputedPageResult;
active: boolean; active: boolean;
onClick: (to: string) => void;
}, },
ref: React.Ref<HTMLAnchorElement>, ref: React.Ref<HTMLAnchorElement>,
) { ) {
const { query, item, active } = props; const { query, item, active, onClick } = props;
return ( return (
<Link <Link
ref={ref} ref={ref}
href={item.href} href={item.href}
onClick={(event) => {
event.preventDefault();
onClick(item.href);
}}
className={tcls( className={tcls(
'flex', 'flex',
'flex-row', 'flex-row',
+4 -2
View File
@@ -43,10 +43,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
parent: Site | Collection | null; parent: Site | Collection | null;
withAsk: boolean; withAsk: boolean;
onSwitchToAsk: () => void; onSwitchToAsk: () => void;
onClose: (to?: string) => void;
}, },
ref: React.Ref<SearchResultsRef>, ref: React.Ref<SearchResultsRef>,
) { ) {
const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk } = props; const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk, onClose } = props;
const language = useLanguage(); const language = useLanguage();
const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null); const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null);
@@ -96,7 +97,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
const fetchedResults = await (parent const fetchedResults = await (parent
? searchParentContent(parent, query) ? searchParentContent(parent, query)
: searchSpaceContent(spaceId, revisionId, query)); : searchSpaceContent(spaceId, revisionId, query));
setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults); setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults);
}, 250); }, 250);
@@ -211,6 +211,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query} query={query}
item={item} item={item}
active={index === cursor} active={index === cursor}
onClick={onClose}
/> />
); );
} }
@@ -251,6 +252,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query} query={query}
item={item} item={item}
active={index === cursor} active={index === cursor}
onClick={onClose}
/> />
); );
} }
@@ -11,15 +11,20 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
query: string; query: string;
item: ComputedSectionResult; item: ComputedSectionResult;
active: boolean; active: boolean;
onClick: (to: string) => void;
}, },
ref: React.Ref<HTMLAnchorElement>, ref: React.Ref<HTMLAnchorElement>,
) { ) {
const { query, item, active } = props; const { query, item, active, onClick } = props;
return ( return (
<Link <Link
ref={ref} ref={ref}
href={item.href} href={item.href}
onClick={(event) => {
event.preventDefault();
onClick(item.href);
}}
className={tcls( className={tcls(
'search-section-result-item', 'search-section-result-item',
'[&:has(+:not(&))]:mb-6', '[&:has(+:not(&))]:mb-6',
+43 -127
View File
@@ -6,9 +6,9 @@ import {
SearchAIAnswer, SearchAIAnswer,
SearchPageResult, SearchPageResult,
Site, Site,
SiteSpace,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import { headers } from 'next/headers';
import { getContentPointer } from '@/app/(space)/fetch'; import { getContentPointer } from '@/app/(space)/fetch';
import { streamResponse } from '@/lib/actions'; import { streamResponse } from '@/lib/actions';
@@ -46,65 +46,9 @@ export interface AskAnswerSource {
} }
export interface AskAnswerResult { export interface AskAnswerResult {
body?: React.ReactNode; body: React.ReactNode;
followupQuestions: string[]; followupQuestions: string[];
sources: AskAnswerSource[]; sources: AskAnswerSource[];
hasAnswer: boolean;
}
export async function searchSiteContent(args: {
query: string;
siteSpaceIds?: string[];
cacheBust?: string;
}): Promise<OrderedComputedResult[]> {
const { siteSpaceIds, query, cacheBust } = args;
const pointer = getContentPointer();
if (siteSpaceIds?.length === 0) {
// if we have no siteSpaces to search in then we won't find anything. skip the call.
return [];
}
if ('siteId' in pointer && 'organizationId' in pointer) {
const [searchResults, allSiteSpaces] = await Promise.all([
api.searchSiteContent(
pointer.organizationId,
pointer.siteId,
query,
siteSpaceIds,
cacheBust,
),
siteSpaceIds
? null
: api.getSiteSpaces({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
}),
]);
if (!siteSpaceIds) {
// We are searching all of this Site's content
return searchResults.items
.map((spaceItem) => {
const siteSpace = allSiteSpaces?.find(
(siteSpace) => siteSpace.space.id === spaceItem.id,
);
return spaceItem.pages.map((item) => transformSitePageResult(item, siteSpace));
})
.flat(2);
}
return searchResults.items
.map((spaceItem) => {
return spaceItem.pages.map((item) => transformPageResult(item));
})
.flat(2);
}
// This should never happen
return [];
} }
/** /**
@@ -115,16 +59,6 @@ export async function searchSpaceContent(
revisionId: string, revisionId: string,
query: string, query: string,
): Promise<OrderedComputedResult[]> { ): Promise<OrderedComputedResult[]> {
const pointer = getContentPointer();
if ('siteId' in pointer && 'organizationId' in pointer) {
const siteSpaceIds = pointer.siteSpaceId ? [pointer.siteSpaceId] : []; // if we don't have a siteSpaceID search all content
// This is a site so use a different function which we can eventually call directly
// We also want to break cache for this specific space if the revisionId is different so use it as a cache busting key
return await searchSiteContent({ siteSpaceIds, query, cacheBust: revisionId });
}
const data = await api.searchSpaceContent(spaceId, revisionId, query); const data = await api.searchSpaceContent(spaceId, revisionId, query);
return data.items.map((item) => transformPageResult(item, undefined)).flat(); return data.items.map((item) => transformPageResult(item, undefined)).flat();
} }
@@ -137,18 +71,30 @@ export async function searchParentContent(
query: string, query: string,
): Promise<OrderedComputedResult[]> { ): Promise<OrderedComputedResult[]> {
const pointer = getContentPointer(); const pointer = getContentPointer();
const isSite = 'siteId' in pointer;
if (isSite) { const [data, collectionSpaces, siteSpaces] = await Promise.all([
return searchSiteContent({ query });
}
const [data, collectionSpaces] = await Promise.all([
api.searchParentContent(parent.id, query), api.searchParentContent(parent.id, query),
parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null, parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null,
parent.object === 'site' && 'organizationId' in pointer
? api.getSiteSpaces(pointer.organizationId, parent.id)
: null,
]); ]);
let spaces: Space[] = collectionSpaces ? collectionSpaces : []; let spaces: Space[] = [];
if (collectionSpaces) {
spaces = collectionSpaces;
} else if (siteSpaces) {
spaces = Object.values(
siteSpaces.reduce(
(acc, siteSpace) => {
acc[siteSpace.space.id] = siteSpace.space;
return acc;
},
{} as Record<string, Space>,
),
);
}
return data.items return data.items
.map((spaceItem) => { .map((spaceItem) => {
@@ -162,15 +108,12 @@ export async function searchParentContent(
* Server action to ask a question in a space. * Server action to ask a question in a space.
*/ */
export const streamAskQuestion = streamResponse(async function* (spaceId: string, query: string) { export const streamAskQuestion = streamResponse(async function* (spaceId: string, query: string) {
const stream = api const stream = api.api().spaces.streamAskInSpace(spaceId, { query, format: 'document' });
.api()
.spaces.streamAskInSpace(spaceId, { query, format: 'document', details: true });
const pagesPromise = api.getSpaceContentData({ spaceId }); const pagesPromise = api.getSpaceContentData({ spaceId });
for await (const chunk of stream) { for await (const chunk of stream) {
// We run the AI search and fetch the pages in parallel // We run the AI search and fetch the pages in parallel
const { pages } = await pagesPromise; const { pages } = await pagesPromise;
yield transformAnswer(chunk.answer, pages); yield transformAnswer(chunk.answer, pages);
} }
}); });
@@ -187,7 +130,7 @@ function transformAnswer(
answer: SearchAIAnswer | undefined, answer: SearchAIAnswer | undefined,
pages: RevisionPage[], pages: RevisionPage[],
): AskAnswerResult | null { ): AskAnswerResult | null {
if (!answer) { if (!answer || !('document' in answer.answer)) {
return null; return null;
} }
@@ -211,43 +154,36 @@ function transformAnswer(
.filter(filterOutNullable); .filter(filterOutNullable);
return { return {
body: body: (
answer.answer && 'document' in answer.answer ? ( <DocumentView
<DocumentView document={answer.answer.document}
document={answer.answer.document} context={{
context={{ mode: 'default',
mode: 'default', contentRefContext: null,
contentRefContext: null, resolveContentRef: async () => null,
resolveContentRef: async () => null, shouldHighlightCode: () => false,
shouldHighlightCode: () => false, }}
}} style={['space-y-5']}
style={['space-y-5']} />
/> ),
) : null,
followupQuestions: answer.followupQuestions, followupQuestions: answer.followupQuestions,
sources, sources,
hasAnswer: !!answer.answer && 'document' in answer.answer,
}; };
} }
function transformSectionsAndPage(args: { function transformPageResult(item: SearchPageResult, space?: Space) {
item: SearchPageResult;
space?: Space;
spaceURL?: string;
}): [ComputedPageResult, ComputedSectionResult[]] {
const { item, space, spaceURL } = args;
// Resolve a relative path to an absolute URL // Resolve a relative path to an absolute URL
// if the search result is relative to another space, we use the space URL // if the search result is relative to another space, we use the space URL
const getURL = (path: string, spaceURL?: string) => { const getURL = (path: string) => {
if (spaceURL) { if (space) {
if (!spaceURL.endsWith('/')) { let url = space.urls.published ?? space.urls.app;
spaceURL += '/'; if (!url.endsWith('/')) {
url += '/';
} }
if (path.startsWith('/')) { if (path.startsWith('/')) {
path = path.slice(1); path = path.slice(1);
} }
return spaceURL + path; return url + path;
} else { } else {
return absoluteHref(path); return absoluteHref(path);
} }
@@ -258,7 +194,7 @@ function transformSectionsAndPage(args: {
type: 'section', type: 'section',
id: item.id + '/' + section.id, id: item.id + '/' + section.id,
title: section.title, title: section.title,
href: getURL(section.path, spaceURL), href: getURL(section.path),
body: section.body, body: section.body,
})) ?? []; })) ?? [];
@@ -270,25 +206,5 @@ function transformSectionsAndPage(args: {
spaceTitle: space?.title, spaceTitle: space?.title,
}; };
return [page, sections];
}
function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteSpace) {
const [page, sections] = transformSectionsAndPage({
item,
space: siteSpace?.space,
spaceURL: siteSpace?.urls.published,
});
return [page, ...sections];
}
function transformPageResult(item: SearchPageResult, space?: Space) {
const [page, sections] = transformSectionsAndPage({
item,
space,
spaceURL: space?.urls.published ?? space?.urls.app,
});
return [page, ...sections]; return [page, ...sections];
} }
+2 -8
View File
@@ -19,14 +19,6 @@ export function PagesList(props: {
return ( return (
<ul className={tcls('flex', 'flex-1', 'flex-col', 'gap-y-0.5', style)}> <ul className={tcls('flex', 'flex-1', 'flex-col', 'gap-y-0.5', style)}>
{pages.map((page) => { {pages.map((page) => {
if (page.type === 'link') {
return <PageLinkItem key={page.id} page={page} context={context} />;
}
if (page.hidden) {
return null;
}
if (page.type === 'group') { if (page.type === 'group') {
return ( return (
<PageGroupItem <PageGroupItem
@@ -37,6 +29,8 @@ export function PagesList(props: {
context={context} context={context}
/> />
); );
} else if (page.type === 'link') {
return <PageLinkItem key={page.id} page={page} context={context} />;
} }
return ( return (
@@ -75,7 +75,7 @@ export function TableOfContents(props: {
'dark:group-hover:[&::-webkit-scrollbar-thumb]:bg-light/3', 'dark:group-hover:[&::-webkit-scrollbar-thumb]:bg-light/3',
'navigation-open:flex', // can be auto height animated as such https://stackoverflow.com/a/76944290 'navigation-open:flex', // can be auto height animated as such https://stackoverflow.com/a/76944290
'lg:-ml-5', 'lg:-ml-5',
customization.trademark.enabled ? 'lg:pb-20' : 'lg:pb-4', customization.trademark.enabled ? 'lg:pb-16' : 'lg:pb-4',
)} )}
> >
<PagesList <PagesList
+3 -7
View File
@@ -84,20 +84,16 @@ export function TrademarkLink(props: {
'flex-row', 'flex-row',
'items-center', 'items-center',
'hover:bg-dark/1', 'hover:bg-dark/1',
'bg-light',
'dark:bg-dark',
'px-4', 'px-4',
'py-4', 'py-2',
'rounded-lg', 'rounded-md',
'straight-corners:rounded-none', 'straight-corners:rounded-none',
'hover:backdrop-blur-sm', 'hover:backdrop-blur-sm',
'lg:ring-0', 'lg:ring-0',
'tracking-[-0.016em]', 'tracking-[-0.016em]',
'dark:hover:bg-light/1', 'dark:hover:bg-light/1',
'dark:ring-light/1', 'dark:ring-light/1',
'border', 'dark:font-normal',
'border-dark/2',
'dark:border-light/2',
)} )}
> >
<IconLogo className={tcls('w-5', 'h-5', 'mr-3')} /> <IconLogo className={tcls('w-5', 'h-5', 'mr-3')} />
-49
View File
@@ -1,49 +0,0 @@
import { CustomizationThemedURL } from '@gitbook/api';
import { Image } from '@/components/utils';
import { absoluteHref } from '@/lib/links';
import { Emoji } from '../primitives';
export function LogoIcon(
props: { icon?: CustomizationThemedURL; emoji?: string } & Omit<
React.ComponentProps<typeof Image>,
'sources'
>,
) {
const { icon, emoji, alt, ...imageProps } = props;
if (emoji && !icon) {
return <Emoji code={emoji} style="text-xl" />;
}
return (
<Image
alt={alt ?? ''}
sources={
icon
? {
light: {
src: icon.light,
aspectRatio: '1',
},
dark: {
src: icon.dark,
aspectRatio: '1',
},
}
: {
light: {
src: absoluteHref('~gitbook/icon?size=medium&theme=light', true),
size: { width: 256, height: 256 },
},
dark: {
src: absoluteHref('~gitbook/icon?size=medium&theme=dark', true),
size: { width: 256, height: 256 },
},
}
}
{...imageProps}
/>
);
}
-1
View File
@@ -1,2 +1 @@
export * from './types'; export * from './types';
export * from './LogoIcon';
+3 -6
View File
@@ -1,16 +1,14 @@
import { ClassValue, tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Link, LinkProps } from '../primitives/Link'; import { Link, LinkProps } from '../primitives/Link';
/** /**
* Styled version of Link component. * Styled version of Link component.
*/ */
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) { export function StyledLink(props: LinkProps) {
const { style, ...rest } = props;
return ( return (
<Link <Link
{...rest} {...props}
className={tcls( className={tcls(
'underline', 'underline',
'underline-offset-2', 'underline-offset-2',
@@ -18,7 +16,6 @@ export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValu
'text-primary', 'text-primary',
'hover:text-primary-700', 'hover:text-primary-700',
'transition-colors', 'transition-colors',
style,
)} )}
> >
{props.children} {props.children}
-56
View File
@@ -1,56 +0,0 @@
export const de = {
locale: 'de',
powered_by_gitbook: 'Bereitgestellt von GitBook',
switch_to_dark_theme: 'Zum dunklen Modus wechseln',
switch_to_light_theme: 'Zum hellen Modus wechseln',
switch_to_system_theme: 'Zum Systemmodus wechseln',
search: 'Suche',
search_or_ask: 'Fragen oder Suchen',
search_input_placeholder: 'Inhalt durchsuchen',
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
search_no_results: 'Keine Ergebnisse für "${1}".',
search_scope_space: 'Nur in ${1}',
search_scope_all: 'Alle Inhalte',
search_ask: 'Fragen "${1}"',
search_ask_sources: 'Quellen',
search_ask_sources_no_answer: 'Verwandte Seiten',
search_ask_no_answer:
'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.',
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
on_this_page: 'Auf dieser Seite',
next_page: 'Nächste',
previous_page: 'Vorherige',
page_last_modified: 'Zuletzt aktualisiert ${1}',
was_this_helpful: 'War das hilfreich?',
was_this_helpful_positive: 'Ja, das war es!',
was_this_helpful_neutral: 'Nicht sicher',
was_this_helpful_negative: 'Nein',
was_this_helpful_thank_you: 'Danke!',
annotation_button_label: 'Kommentar öffnen',
code_copied: 'Kopiert!',
code_copy: 'Kopieren',
table_of_contents_button_label: 'Inhaltsverzeichnis öffnen',
cookies_title: 'Cookies',
cookies_prompt:
'Diese Website verwendet Cookies, um ihre Dienste bereitzustellen und den Datenverkehr zu analysieren. Durch die Nutzung dieser Website akzeptieren Sie die ${1}.',
cookies_prompt_privacy: 'Datenschutzrichtlinie',
cookies_accept: 'Akzeptieren',
cookies_reject: 'Ablehnen',
cookies_close: 'Schließen',
edit_on_git: 'Bearbeiten auf ${1}',
notfound_title: 'Seite nicht gefunden',
notfound: 'Die gesuchte Seite existiert nicht.',
unexpected_error_title: 'Ein Fehler ist aufgetreten',
unexpected_error:
'Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
unexpected_error_retry: 'Erneut versuchen',
pdf_download: 'Als PDF exportieren',
pdf_goback: 'Zurück zum Inhalt',
pdf_print: 'Drucken oder als PDF speichern',
pdf_page_of: '${1} von ${2}',
pdf_mode_only_page: 'Nur diese Seite',
pdf_mode_all: 'Alle Seiten',
pdf_limit_reached:
'Das PDF konnte für ${1} Seiten nicht generiert werden, Generierung wurde bei ${2} gestoppt.',
pdf_limit_reached_continue: 'Mit ${1} weiteren Seiten erweitern.',
};
+1 -3
View File
@@ -13,9 +13,7 @@ export const en = {
search_scope_all: 'All the content', search_scope_all: 'All the content',
search_ask: 'Ask "${1}"', search_ask: 'Ask "${1}"',
search_ask_sources: 'Sources', search_ask_sources: 'Sources',
search_ask_sources_no_answer: 'Related pages', search_ask_no_answer: 'No answer could be found for your question, try with another one.',
search_ask_no_answer:
'An answer could not be found for your question. You could try rephrasing it, or be more specific.',
search_ask_error: 'Something went wrong. Please try again later.', search_ask_error: 'Something went wrong. Please try again later.',
on_this_page: 'On this page', on_this_page: 'On this page',
next_page: 'Next', next_page: 'Next',
+1 -3
View File
@@ -15,9 +15,7 @@ export const es: TranslationLanguage = {
search_scope_all: 'Todo el contenido', search_scope_all: 'Todo el contenido',
search_ask: 'Preguntar "${1}"', search_ask: 'Preguntar "${1}"',
search_ask_sources: 'Fuentes', search_ask_sources: 'Fuentes',
search_ask_sources_no_answer: 'Páginas relacionadas', search_ask_no_answer: 'No se pudo encontrar una respuesta a tu pregunta, intenta con otra.',
search_ask_no_answer:
'No se pudo encontrar una respuesta para su pregunta. Puede intentar reformularla o ser más específico.',
search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.', search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.',
on_this_page: 'En esta página', on_this_page: 'En esta página',
next_page: 'Siguiente', next_page: 'Siguiente',
+1 -3
View File
@@ -15,9 +15,7 @@ export const fr: TranslationLanguage = {
search_scope_all: 'Tout le contenu', search_scope_all: 'Tout le contenu',
search_ask: 'Demander "${1}"', search_ask: 'Demander "${1}"',
search_ask_sources: 'Sources', search_ask_sources: 'Sources',
search_ask_sources_no_answer: 'Pages connexes', search_ask_no_answer: 'Aucune réponse trouvée à votre question, essayez-en une autre.',
search_ask_no_answer:
"Aucune réponse n'a pu être trouvée pour votre question. Essayez de la reformuler ou d'être plus précis",
search_ask_error: 'Quelque chose a mal fonctionné. Veuillez réessayer plus tard.', search_ask_error: 'Quelque chose a mal fonctionné. Veuillez réessayer plus tard.',
on_this_page: 'Sur cette page', on_this_page: 'Sur cette page',
next_page: 'Suivant', next_page: 'Suivant',
-4
View File
@@ -1,11 +1,9 @@
import { CustomizationLocale } from '@gitbook/api'; import { CustomizationLocale } from '@gitbook/api';
import { de } from './de';
import { en } from './en'; import { en } from './en';
import { es } from './es'; import { es } from './es';
import { fr } from './fr'; import { fr } from './fr';
import { ja } from './ja'; import { ja } from './ja';
import { pt_br } from './pt-br';
import { TranslationLanguage } from './types'; import { TranslationLanguage } from './types';
import { zh } from './zh'; import { zh } from './zh';
@@ -14,11 +12,9 @@ export * from './types';
export const languages: { export const languages: {
[locale in CustomizationLocale]: TranslationLanguage; [locale in CustomizationLocale]: TranslationLanguage;
} = { } = {
de,
en, en,
fr, fr,
es, es,
zh, zh,
ja, ja,
'pt-br': pt_br,
}; };
+1 -2
View File
@@ -15,9 +15,8 @@ export const ja: TranslationLanguage = {
search_scope_all: '全てのコンテンツ', search_scope_all: '全てのコンテンツ',
search_ask: '"${1}" を質問する', search_ask: '"${1}" を質問する',
search_ask_sources: '情報源', search_ask_sources: '情報源',
search_ask_sources_no_answer: '関連ページ',
search_ask_no_answer: search_ask_no_answer:
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。', 'ご質問に答えられる答えが見つかりませんでした、他の質問を試してください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。', search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
on_this_page: 'このページ内', on_this_page: 'このページ内',
next_page: '次へ', next_page: '次へ',
-56
View File
@@ -1,56 +0,0 @@
export const pt_br = {
locale: 'pt-br',
powered_by_gitbook: 'Powered by GitBook',
switch_to_dark_theme: 'Mudar para modo escuro',
switch_to_light_theme: 'Mudar para modo claro',
switch_to_system_theme: 'Mudar para configuração do sistema',
search: 'Busca',
search_or_ask: 'Perguntar ou buscar',
search_input_placeholder: 'Buscar conteúdo',
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
search_no_results: 'Sem resultados para "${1}".',
search_scope_space: 'Somente em ${1}',
search_scope_all: 'Todo o conteúdo',
search_ask: 'Perguntar "${1}"',
search_ask_sources: 'Fontes',
search_ask_sources_no_answer: 'Páginas relacionadas',
search_ask_no_answer:
'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo deu errado. Por favor tente novamente mais tarde.',
on_this_page: 'Nesta página',
next_page: 'Próximo',
previous_page: 'Anterior',
page_last_modified: 'Atualizado ${1}',
was_this_helpful: 'Isto foi útil?',
was_this_helpful_positive: 'Sim, foi!',
was_this_helpful_neutral: 'Não sei',
was_this_helpful_negative: 'Não',
was_this_helpful_thank_you: 'Obrigado!',
annotation_button_label: 'Abrir anotação',
code_copied: 'Copiado!',
code_copy: 'Copiar',
table_of_contents_button_label: 'Abrir o índice',
cookies_title: 'Cookies',
cookies_prompt:
'Este site usa cookies para entregar suas funcionalidades e analizar o tráfego. Ao navegar neste site, você aceita a ${1}.',
cookies_prompt_privacy: 'política de privacidade',
cookies_accept: 'Aceitar',
cookies_reject: 'Rejeitar',
cookies_close: 'Fechar',
edit_on_git: 'Editar no ${1}',
notfound_title: 'Página não encontrada',
notfound: 'A página que você está procurando não existe.',
unexpected_error_title: 'Aconteceu um erro',
unexpected_error:
'Desculpe, aconteceu um erro inesperado. Por favor tente novamente mais tarde.',
unexpected_error_retry: ' Tentar novamente',
pdf_download: 'Exportar como PDF',
pdf_goback: 'Voltar ao conteúdo',
pdf_print: 'Imprimir ou salvar como PDF',
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Somente esta página',
pdf_mode_all: 'Todas as páginas',
pdf_limit_reached:
'Não foi possível gerar o PDF para ${1} páginas, generation stopped at ${2}.',
pdf_limit_reached_continue: 'Extender com mais ${1} páginas.',
};
+1 -2
View File
@@ -15,8 +15,7 @@ export const zh: TranslationLanguage = {
search_scope_all: '所有内容', search_scope_all: '所有内容',
search_ask: '询问"${1}"', search_ask: '询问"${1}"',
search_ask_sources: '来源', search_ask_sources: '来源',
search_ask_sources_no_answer: '相关页面', search_ask_no_answer: '找不到您的问题的答案,请尝试另一个问题。',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。', search_ask_error: '出了些问题。请稍后再试。',
on_this_page: '在本页', on_this_page: '在本页',
next_page: '下一页', next_page: '下一页',
+10 -72
View File
@@ -47,11 +47,6 @@ export interface SiteContentPointer extends ContentPointer {
* ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews) * ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews)
*/ */
siteSpaceId: string | undefined; siteSpaceId: string | undefined;
/**
* Share key of the site that was used in lookup. Only set for `multi` and `multi-path` modes
* where an URL with the share-link key is involved in the lookup/resolution.
*/
siteShareKey: string | undefined;
} }
/** /**
@@ -615,7 +610,6 @@ const getSiteSpaceCustomizationFromAPI = cache(
organizationId, organizationId,
siteId, siteId,
siteSpaceId, siteSpaceId,
{},
{ {
signal: options.signal, signal: options.signal,
...noCacheFetchOptions, ...noCacheFetchOptions,
@@ -639,15 +633,10 @@ const getSiteSpaceCustomizationFromAPI = cache(
const getSiteCustomizationFromAPI = cache( const getSiteCustomizationFromAPI = cache(
'api.getSiteCustomizationById', 'api.getSiteCustomizationById',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteCustomizationById( const response = await api().orgs.getSiteCustomizationById(organizationId, siteId, {
organizationId, signal: options.signal,
siteId, ...noCacheFetchOptions,
{}, });
{
signal: options.signal,
...noCacheFetchOptions,
},
);
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [ tags: [
@@ -741,34 +730,18 @@ export const getSite = cache(
*/ */
export const getSiteSpaces = cache( export const getSiteSpaces = cache(
'api.getSiteSpaces', 'api.getSiteSpaces',
async ( async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
args: {
organizationId: string;
siteId: string;
/** Site share key that can be used as context to resolve site space published urls */
siteShareKey: string | undefined;
},
options: CacheFunctionOptions,
) => {
const response = await getAll((params) => const response = await getAll((params) =>
api().orgs.listSiteSpaces( api().orgs.listSiteSpaces(organizationId, siteId, params, {
args.organizationId, ...noCacheFetchOptions,
args.siteId, signal: options.signal,
{ }),
...params,
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
},
{
...noCacheFetchOptions,
signal: options.signal,
},
),
); );
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
data: response.data.items.map((siteSpace) => siteSpace), data: response.data.items.map((siteSpace) => siteSpace),
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })], tags: [getAPICacheTag({ tag: 'site', site: siteId })],
}); });
}, },
); );
@@ -1046,41 +1019,6 @@ export const searchParentContent = cache(
}, },
); );
/**
* Search content in a Site or specific SiteSpaces.
*/
export const searchSiteContent = cache(
'api.searchSiteContent',
async (
organizationId: string,
siteId: string,
query: string,
siteSpaceIds?: string[],
/** A cache bust param to avoid revalidating lot of cache entries by tags */
cacheBust?: string,
options?: CacheFunctionOptions,
) => {
const response = await api().orgs.searchSiteContent(
organizationId,
siteId,
{
query,
siteSpaceIds,
},
undefined,
{
...noCacheFetchOptions,
signal: options?.signal,
},
);
return cacheResponse(response, {
ttl: 60 * 60,
tags: [],
});
},
);
/** /**
* Get a list of recommended questions in a space. * Get a list of recommended questions in a space.
*/ */
+4 -4
View File
@@ -1,6 +1,6 @@
import { MaybePromise } from 'p-map'; import { MaybePromise } from 'p-map';
import { waitUntil, getRequestContext } from './waitUntil'; import { waitUntil, getGlobalContext } from './waitUntil';
/** /**
* Execute a function for each input in parallel and return the first result. * Execute a function for each input in parallel and return the first result.
@@ -238,7 +238,7 @@ export function singleton<R>(execute: () => Promise<R>): () => Promise<R> {
} }
// Promises are not shared between requests in Cloudflare Workers // Promises are not shared between requests in Cloudflare Workers
const ctx = await getRequestContext(); const ctx = await getGlobalContext();
const current = states.get(ctx); const current = states.get(ctx);
if (current) { if (current) {
return current; return current;
@@ -269,7 +269,7 @@ export function singletonMap<Key extends string, Args extends any[], Result>(
const states = new WeakMap<object, Map<string, Promise<Result>>>(); const states = new WeakMap<object, Map<string, Promise<Result>>>();
const fn: SingletonFunction<Key, Args, Result> = async (key, ...args) => { const fn: SingletonFunction<Key, Args, Result> = async (key, ...args) => {
const ctx = await getRequestContext(); const ctx = await getGlobalContext();
let current = states.get(ctx); let current = states.get(ctx);
if (current) { if (current) {
const existing = current.get(key); const existing = current.get(key);
@@ -292,7 +292,7 @@ export function singletonMap<Key extends string, Args extends any[], Result>(
}; };
fn.isRunning = async (key: string) => { fn.isRunning = async (key: string) => {
const ctx = await getRequestContext(); const ctx = await getGlobalContext();
const current = states.get(ctx); const current = states.get(ctx);
return current?.has(key) ?? false; return current?.has(key) ?? false;
}; };
+1 -19
View File
@@ -7,24 +7,21 @@ describe('cache', () => {
let fn: CacheFunction<[string], string>; let fn: CacheFunction<[string], string>;
let testId = 0; let testId = 0;
let getTtl: () => number;
beforeEach(() => { beforeEach(() => {
impl.mockClear(); impl.mockClear();
testId += 1; testId += 1;
getTtl = () => 1000;
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => { fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
return { return {
data: impl(arg), data: impl(arg),
ttl: getTtl(),
}; };
}); });
}); });
it('should only execute once for same argument if there is a ttl', async () => { it('should only execute once for same argument', async () => {
const result = await Promise.all([fn('a'), fn('a')]); const result = await Promise.all([fn('a'), fn('a')]);
expect(result).toEqual(['test-a', 'test-a']); expect(result).toEqual(['test-a', 'test-a']);
@@ -37,21 +34,6 @@ describe('cache', () => {
expect(impl).toHaveBeenCalledTimes(1); expect(impl).toHaveBeenCalledTimes(1);
}); });
it('should execute multiple times for same argument if ttl is 0 or undefined', async () => {
getTtl = () => 0;
const result = await Promise.all([fn('a'), fn('a')]);
expect(result).toEqual(['test-a', 'test-a']);
expect(impl).toHaveBeenCalled();
expect(impl).toHaveBeenCalledTimes(1);
expect(await fn('a')).toEqual('test-a');
expect(impl).toHaveBeenCalledTimes(2);
});
it('should execute for different arguments', async () => { it('should execute for different arguments', async () => {
const result = await Promise.all([fn('a'), fn('b')]); const result = await Promise.all([fn('a'), fn('b')]);
+1 -1
View File
@@ -202,7 +202,7 @@ export function cache<Args extends any[], Result>(
if (savedEntry.meta.revalidatesAt && savedEntry.meta.revalidatesAt < Date.now()) { if (savedEntry.meta.revalidatesAt && savedEntry.meta.revalidatesAt < Date.now()) {
// Revalidate in the background // Revalidate in the background
await waitUntil(revalidate(key, undefined, ...args)); waitUntil(revalidate(key, undefined, ...args));
} }
return savedEntry.data; return savedEntry.data;
+1 -5
View File
@@ -26,12 +26,9 @@ export function parseCacheResponse(response: Response): {
const cacheControlHeader = response.headers.get('cache-control'); const cacheControlHeader = response.headers.get('cache-control');
const cacheControl = cacheControlHeader ? parseCacheControl(cacheControlHeader) : null; const cacheControl = cacheControlHeader ? parseCacheControl(cacheControlHeader) : null;
const cacheTagHeader = response.headers.get('x-gitbook-cache-tag');
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
const entry = { const entry = {
ttl: 60 * 60 * 24, ttl: 60 * 60 * 24,
tags, tags: [],
}; };
if (cacheControl && cacheControl['max-age']) { if (cacheControl && cacheControl['max-age']) {
@@ -53,7 +50,6 @@ export function cacheResponse<Result, DefaultData = Result>(
return { return {
ttl: defaultEntry.ttl ?? parsed.ttl, ttl: defaultEntry.ttl ?? parsed.ttl,
tags: [...(defaultEntry.tags ?? []), ...parsed.tags], tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
revalidateBefore: defaultEntry.revalidateBefore,
// @ts-ignore // @ts-ignore
data: defaultEntry.data ?? response.data, data: defaultEntry.data ?? response.data,
}; };
+13 -17
View File
@@ -1,12 +1,11 @@
import { CacheBackend, CacheEntry } from './types'; import { CacheBackend, CacheEntry } from './types';
import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils'; import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
import { getGlobalContext } from '../waitUntil';
export const memoryCache: CacheBackend = { export const memoryCache: CacheBackend = {
name: 'memory', name: 'memory',
replication: 'local', replication: 'local',
async get(key) { async get(key) {
const memoryCache = await getMemoryCache(); const memoryCache = getMemoryCache();
const memoryEntry = memoryCache.get(key); const memoryEntry = memoryCache.get(key);
if (!memoryEntry) { if (!memoryEntry) {
@@ -22,7 +21,7 @@ export const memoryCache: CacheBackend = {
return null; return null;
}, },
async set(key, entry) { async set(key, entry) {
const memoryCache = await getMemoryCache(); const memoryCache = getMemoryCache();
// When the entry is immutable, we can cache it for the entire duration. // When the entry is immutable, we can cache it for the entire duration.
// Else we cache it for a very short time. // Else we cache it for a very short time.
const expiresAt = const expiresAt =
@@ -42,11 +41,11 @@ export const memoryCache: CacheBackend = {
memoryCache.set(key, { ...entry, meta }); memoryCache.set(key, { ...entry, meta });
}, },
async del(keys) { async del(keys) {
const memoryCache = await getMemoryCache(); const memoryCache = getMemoryCache();
keys.forEach((key) => memoryCache.delete(key)); keys.forEach((key) => memoryCache.delete(key));
}, },
async revalidateTags(tags) { async revalidateTags(tags) {
const memoryCache = await getMemoryCache(); const memoryCache = getMemoryCache();
const keys: string[] = []; const keys: string[] = [];
memoryCache.forEach((entry, key) => { memoryCache.forEach((entry, key) => {
@@ -64,19 +63,16 @@ export const memoryCache: CacheBackend = {
}; };
/** /**
* In memory cache shared globally. * With next-on-pages, the code seems to be isolated between the middleware and the handler.
* To share the cache between the two, we use a global variable.
*/ */
async function getMemoryCache(): Promise<Map<string, CacheEntry>> { function getMemoryCache(): Map<string, CacheEntry> {
const ctx: Awaited<ReturnType<typeof getGlobalContext>> & { // @ts-ignore
gitbookMemoryCache?: Map<string, CacheEntry>; if (!globalThis.gitbookMemoryCache) {
} = await getGlobalContext(); // @ts-ignore
globalThis.gitbookMemoryCache = new Map();
if (ctx.gitbookMemoryCache) {
return ctx.gitbookMemoryCache;
} }
const gitbookMemoryCache = new Map<string, CacheEntry>(); // @ts-ignore
ctx.gitbookMemoryCache = gitbookMemoryCache; return globalThis.gitbookMemoryCache;
return gitbookMemoryCache;
} }
-9
View File
@@ -16,15 +16,6 @@ export function hexToRgb(hex: string): string {
return `${r} ${g} ${b}`; return `${r} ${g} ${b}`;
} }
/**
* Convert a hex color to a RGBA color.
*/
export function hexToRgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGBA values separated by spaces
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** /**
* Generate Tailwind-compatible shades from a single color * Generate Tailwind-compatible shades from a single color
* @param {string} hex The hex code to generate shades from * @param {string} hex The hex code to generate shades from
+1 -1
View File
@@ -38,7 +38,7 @@ export function getContentSecurityPolicy(scripts: SpaceIntegrationScript[], nonc
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' 'unsafe-eval' ${assetsDomain} https://integrations.gitbook.com https://cdn.iframe.ly; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' 'unsafe-eval' ${assetsDomain} https://integrations.gitbook.com https://cdn.iframe.ly;
style-src 'self' ${assetsDomain} fonts.googleapis.com 'unsafe-inline'; style-src 'self' ${assetsDomain} fonts.googleapis.com 'unsafe-inline';
img-src * 'self' blob: data: files.gitbook.com ${assetsDomain}; img-src * 'self' blob: data: files.gitbook.com ${assetsDomain};
connect-src * 'self' integrations.gitbook.com app.gitbook.com api.gitbook.com srv.buysellads.com ${assetsDomain}; connect-src * 'self' integrations.gitbook.com app.gitbook.com api.gitbook.com ${assetsDomain};
font-src 'self' fonts.gstatic.com ${assetsDomain}; font-src 'self' fonts.gstatic.com ${assetsDomain};
frame-src *; frame-src *;
object-src 'none'; object-src 'none';
-25
View File
@@ -1,25 +0,0 @@
export type SimplifiedFileType = 'image' | 'pdf' | 'archive';
/**
* Get a simplified content type for the given mime type.
*/
export function getSimplifiedContentType(mimeType: string): SimplifiedFileType | null {
if (mimeType.startsWith('image')) {
return 'image';
}
switch (mimeType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
}
+9 -40
View File
@@ -1,11 +1,8 @@
import 'server-only'; import 'server-only';
import fnv1a from '@sindresorhus/fnv1a';
import { noCacheFetchOptions } from '@/lib/cache/http'; import { noCacheFetchOptions } from '@/lib/cache/http';
import { rootUrl } from './links'; import { rootUrl } from './links';
import { getImageAPIUrl } from './urls';
export interface CloudflareImageJsonFormat { export interface CloudflareImageJsonFormat {
width: number; width: number;
@@ -41,7 +38,7 @@ export function isImageResizingEnabled(): boolean {
/** /**
* Check if a URL is an HTTP URL. * Check if a URL is an HTTP URL.
*/ */
export function checkIsHttpURL(input: string | URL): boolean { export function checkIsHttpURL(input: string): boolean {
if (!URL.canParse(input)) { if (!URL.canParse(input)) {
return false; return false;
} }
@@ -54,16 +51,11 @@ export function checkIsHttpURL(input: string | URL): boolean {
* Skip it for non-http(s) URLs (data, etc). * Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs. * Skip it for SVGs.
*/ */
export function checkIsSizableImageURL(input: string): boolean { function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) { if (input.endsWith('.svg')) {
return false; return false;
} }
return checkIsHttpURL(input);
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg')) {
return false;
}
return checkIsHttpURL(parsed);
} }
/** /**
@@ -80,11 +72,11 @@ export async function getResizedImageURL(
return null; return null;
} }
const signature = await generateSignatureV1(input); const signature = await generateSignature(input);
return (options) => { return (options) => {
const url = new URL('/~gitbook/image', rootUrl()); const url = new URL('/~gitbook/image', rootUrl());
url.searchParams.set('url', getImageAPIUrl(input)); url.searchParams.set('url', input);
if (options.width) { if (options.width) {
url.searchParams.set('width', options.width.toString()); url.searchParams.set('width', options.width.toString());
@@ -100,7 +92,6 @@ export async function getResizedImageURL(
} }
url.searchParams.set('sign', signature); url.searchParams.set('sign', signature);
url.searchParams.set('sv', '1');
return url.toString(); return url.toString();
}; };
@@ -109,12 +100,8 @@ export async function getResizedImageURL(
/** /**
* Verify a signature of an image URL * Verify a signature of an image URL
*/ */
export async function verifyImageSignature( export async function verifyImageSignature(input: string, signature: string): Promise<boolean> {
input: string, const expectedSignature = await generateSignature(input);
{ signature, version }: { signature: string; version: '1' | '0' },
): Promise<boolean> {
const expectedSignature =
version === '1' ? await generateSignatureV1(input) : await generateSignatureV0(input);
return expectedSignature === signature; return expectedSignature === signature;
} }
@@ -213,25 +200,7 @@ function stringifyOptions(options: CloudflareImageOptions): string {
}, ''); }, '');
} }
// Reused buffer for FNV-1a hashing async function generateSignature(input: string): Promise<string> {
const fnv1aUtf8Buffer = new Uint8Array(512);
/**
* New and faster algorithm to generate a signature for an image.
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
* to know that it was the algorithm that was used.
*/
async function generateSignatureV1(input: string): Promise<string> {
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
}
/**
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
* We still need it to validate older signatures that were generated without versioning
* but still exist in previously generated and cached content.
*/
async function generateSignatureV0(input: string): Promise<string> {
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':'); const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all)); const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
+3 -14
View File
@@ -75,13 +75,12 @@ export function resolvePageId(
/** /**
* Resolve the next/previous page before another one. * Resolve the next/previous page before another one.
* It ignores hidden pages as this is used for navigation purpose.
*/ */
export function resolvePrevNextPages( export function resolvePrevNextPages(
rootPages: Revision['pages'], rootPages: Revision['pages'],
page: RevisionPageDocument, page: RevisionPageDocument,
): { previous?: RevisionPageDocument; next?: RevisionPageDocument } { ): { previous?: RevisionPageDocument; next?: RevisionPageDocument } {
const flat = flattenPages(rootPages, (page) => !page.hidden); const flat = flattenPages(rootPages);
const currentIndex = flat.findIndex((p) => p.id === page.id); const currentIndex = flat.findIndex((p) => p.id === page.id);
if (currentIndex === -1) { if (currentIndex === -1) {
@@ -153,27 +152,17 @@ function resolvePageDocument(
return { page, ancestors }; return { page, ancestors };
} }
/** function flattenPages(pages: RevisionPage[]): RevisionPageDocument[] {
* Flatten a list of pages into a list of page documents.
*/
function flattenPages(
pages: RevisionPage[],
filter?: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
): RevisionPageDocument[] {
const result: RevisionPageDocument[] = []; const result: RevisionPageDocument[] = [];
for (const page of pages) { for (const page of pages) {
if (page.type === 'link') { if (page.type === 'link') {
continue; continue;
} }
if (filter && !filter(page)) {
continue;
}
if (page.type === 'document') { if (page.type === 'document') {
result.push(page); result.push(page);
} }
result.push(...flattenPages(page.pages, filter)); result.push(...flattenPages(page.pages));
} }
return result; return result;
+4 -26
View File
@@ -18,8 +18,6 @@ import { getPagePath, resolvePageId } from './pages';
export interface ResolvedContentRef { export interface ResolvedContentRef {
/** Text to render in the content ref */ /** Text to render in the content ref */
text: string; text: string;
/** Additional sub text to render in the content ref */
subText?: string;
/** Emoji associated with the reference */ /** Emoji associated with the reference */
emoji?: string; emoji?: string;
/** URL to open for the content ref */ /** URL to open for the content ref */
@@ -105,14 +103,10 @@ export async function resolveContentRef(
return resolveContentRefInSpace(contentRef.space, contentRef); return resolveContentRefInSpace(contentRef.space, contentRef);
} }
const resolvePageResult = const page =
!contentRef.page || contentRef.page === activePage?.id !contentRef.page || contentRef.page === activePage?.id
? activePage ? activePage
? { page: activePage, ancestors: [] } : resolvePageId(pages, contentRef.page)?.page;
: undefined
: resolvePageId(pages, contentRef.page);
const page = resolvePageResult?.page;
if (!page) { if (!page) {
return null; return null;
} }
@@ -140,13 +134,7 @@ export async function resolveContentRef(
} }
} }
} else { } else {
const parentPage = (resolvePageResult?.ancestors || []).slice(-1).pop(); text = page.title;
// When the looked up ref was a page group we use the page group title as resolved ref text.
// Otherwise use the resolved page title.
text =
parentPage && contentRef.page === parentPage.id && parentPage.type === 'group'
? parentPage.title
: page.title;
emoji = isCurrentPage ? undefined : page.emoji; emoji = isCurrentPage ? undefined : page.emoji;
} }
@@ -176,7 +164,6 @@ export async function resolveContentRef(
contentRef.space === space.id contentRef.space === space.id
? space ? space
: await ignoreAPIError(getSpace(contentRef.space)); : await ignoreAPIError(getSpace(contentRef.space));
if (!targetSpace) { if (!targetSpace) {
return { return {
href: gitbookAppHref(`/s/${contentRef.space}`), href: gitbookAppHref(`/s/${contentRef.space}`),
@@ -257,21 +244,12 @@ async function resolveContentRefInSpace(spaceId: string, contentRef: ContentRef)
baseUrl += '/'; baseUrl += '/';
} }
const resolved = await resolveContentRef(contentRef, { return resolveContentRef(contentRef, {
space, space,
revisionId: space.revision, revisionId: space.revision,
pages, pages,
baseUrl, baseUrl,
}); });
if (!resolved) {
return null;
}
return {
...resolved,
subText: space.title,
};
} }
export function resolveContentRefWithFiles( export function resolveContentRefWithFiles(
+2 -2
View File
@@ -1,4 +1,4 @@
import { startSpan } from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
export interface TraceSpan { export interface TraceSpan {
setAttribute: (label: string, value: boolean | string | number) => void; setAttribute: (label: string, value: boolean | string | number) => void;
@@ -22,7 +22,7 @@ export async function trace<T>(
typeof name === 'string' ? { operation: name, name: undefined } : name; typeof name === 'string' ? { operation: name, name: undefined } : name;
const completeName = executionName ? `${operation}(${executionName})` : operation; const completeName = executionName ? `${operation}(${executionName})` : operation;
return await startSpan( return await Sentry.startSpan(
{ {
name: completeName, name: completeName,
op: operation, op: operation,
-18
View File
@@ -65,21 +65,3 @@ export function getPDFUrlSearchParams(
return searchParams; return searchParams;
} }
/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
* the token in the image API.
*/
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
/**
* Prepare a URL for the GitBook Open Image API.
*/
export function getImageAPIUrl(url: string): string {
return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
}
export function parseImageAPIURL(url: string): string {
return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
}
-25
View File
@@ -1,25 +0,0 @@
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
/**
* Get the title to display for a content.
*/
export function getContentTitle(
space: Space,
customization: CustomizationSettings | SiteCustomizationSettings,
parent: Site | Collection | null,
) {
// When we are rendering a site, always give priority to the customization title first
// and then fallback to the site title
if (parent?.object === 'site') {
return customization.title ?? parent.title ?? space.title;
}
// Otherwise the legacy behavior is not changed to avoid regressions
return parent ? parent.title : customization.title ?? space.title;
}
+4 -21
View File
@@ -2,7 +2,6 @@ import { it, describe, expect } from 'bun:test';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { import {
VisitorAuthCookieValue,
getVisitorAuthCookieName, getVisitorAuthCookieName,
getVisitorAuthCookieValue, getVisitorAuthCookieValue,
getVisitorAuthToken, getVisitorAuthToken,
@@ -18,18 +17,14 @@ describe('getVisitorAuthToken', () => {
const request = nextRequest('https://example.com', { const request = nextRequest('https://example.com', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') }, [getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
}); });
const visitorAuth = getVisitorAuthToken(request, request.nextUrl); expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
}); });
it('should return the token from the cookie root basepath for a sub-path', () => { it('should return the token from the cookie root basepath for a sub-path', () => {
const request = nextRequest('https://example.com/hello/world', { const request = nextRequest('https://example.com/hello/world', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') }, [getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
}); });
const visitorAuth = getVisitorAuthToken(request, request.nextUrl); expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
}); });
it('should return the closest token from the path', () => { it('should return the closest token from the path', () => {
@@ -39,9 +34,7 @@ describe('getVisitorAuthToken', () => {
value: getVisitorAuthCookieValue('/hello/', '123'), value: getVisitorAuthCookieValue('/hello/', '123'),
}, },
}); });
const visitorAuth = getVisitorAuthToken(request, request.nextUrl); expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
}); });
it('should return the token from the cookie in a collection type url', () => { it('should return the token from the cookie in a collection type url', () => {
@@ -50,9 +43,7 @@ describe('getVisitorAuthToken', () => {
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'), value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
}, },
}); });
const visitorAuth = getVisitorAuthToken(request, request.nextUrl); expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
}); });
it('should return undefined if no cookie and no query param', () => { it('should return undefined if no cookie and no query param', () => {
@@ -61,14 +52,6 @@ describe('getVisitorAuthToken', () => {
}); });
}); });
function assertVisitorAuthCookieValue(value: unknown): asserts value is VisitorAuthCookieValue {
if (value && typeof value === 'object' && 'token' in value) {
return;
}
throw new Error('Expected a VisitorAuthCookieValue');
}
function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) { function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) {
const nextUrl = new URL(url); const nextUrl = new URL(url);
// @ts-ignore // @ts-ignore
+11 -20
View File
@@ -16,10 +16,7 @@ export type VisitorAuthCookieValue = {
* Get the visitor authentication token for the request. This token can either be in the * Get the visitor authentication token for the request. This token can either be in the
* query parameters or stored as a cookie. * query parameters or stored as a cookie.
*/ */
export function getVisitorAuthToken( export function getVisitorAuthToken(request: NextRequest, url: URL): string | undefined {
request: NextRequest,
url: URL,
): string | VisitorAuthCookieValue | undefined {
return url.searchParams.get(VISITOR_AUTH_PARAM) ?? getVisitorAuthTokenFromCookies(request, url); return url.searchParams.get(VISITOR_AUTH_PARAM) ?? getVisitorAuthTokenFromCookies(request, url);
} }
@@ -71,10 +68,7 @@ function getUrlBasePathCombinations(url: URL): string[] {
* checking all cookies for a matching "visitor authentication cookie" and returning the * checking all cookies for a matching "visitor authentication cookie" and returning the
* best possible match for the current URL. * best possible match for the current URL.
*/ */
function getVisitorAuthTokenFromCookies( function getVisitorAuthTokenFromCookies(request: NextRequest, url: URL): string | undefined {
request: NextRequest,
url: URL,
): VisitorAuthCookieValue | undefined {
const urlBasePaths = getUrlBasePathCombinations(url); const urlBasePaths = getUrlBasePathCombinations(url);
// Try to find a visitor authentication token for the current URL. The request // Try to find a visitor authentication token for the current URL. The request
// for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/` // for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/`
@@ -96,17 +90,14 @@ function getVisitorAuthTokenFromCookies(
function findVisitorAuthCookieForBasePath( function findVisitorAuthCookieForBasePath(
request: NextRequest, request: NextRequest,
basePath: string, basePath: string,
): VisitorAuthCookieValue | undefined { ): string | undefined {
return Array.from(request.cookies).reduce<VisitorAuthCookieValue | undefined>( return Array.from(request.cookies).reduce<string | undefined>((acc, [name, cookie]) => {
(acc, [name, cookie]) => { if (name === getVisitorAuthCookieName(basePath)) {
if (name === getVisitorAuthCookieName(basePath)) { const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue; if (value.basePath === basePath) {
if (value.basePath === basePath) { acc = value.token;
acc = value;
}
} }
return acc; }
}, return acc;
undefined, }, undefined);
);
} }
+7 -22
View File
@@ -1,28 +1,10 @@
import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
let pendings: Array<Promise<unknown>> = []; let pendings: Array<Promise<unknown>> = [];
/**
* Get a global context object for the current execution.
* This object can be used to store data that should be shared between the middleware and the handler
* and re-used for all requests.
*/
export async function getGlobalContext(): Promise<ExecutionContext | object> {
if (process.env.NODE_ENV === 'test') {
// Do not try loading the next-on-pages package in tests as it'll fail
return globalThis;
}
// We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'.
const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages');
return getOptionalRequestContext()?.ctx ?? globalThis;
}
/** /**
* Get a global context object for the current request. * Get a global context object for the current request.
* This object can be used as a key to store request-specific data in a WeakMap. * This object can be used as a key to store request-specific data in a WeakMap.
*/ */
export async function getRequestContext(): Promise<IncomingRequestCfProperties | object> { export async function getGlobalContext(): Promise<object> {
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
// Do not try loading the next-on-pages package in tests as it'll fail // Do not try loading the next-on-pages package in tests as it'll fail
return globalThis; return globalThis;
@@ -47,9 +29,12 @@ export async function waitUntil(promise: Promise<unknown>) {
return; return;
} }
const cloudflareContext = await getGlobalContext(); // We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'.
if ('waitUntil' in cloudflareContext) { const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages');
cloudflareContext.waitUntil(promise);
const cloudflare = getOptionalRequestContext();
if (cloudflare) {
cloudflare.ctx.waitUntil(promise);
} else { } else {
await promise; await promise;
} }
+28 -61
View File
@@ -1,5 +1,5 @@
import { GitBookAPI } from '@gitbook/api'; import { GitBookAPI } from '@gitbook/api';
import { setTag, setContext } from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import assertNever from 'assert-never'; import assertNever from 'assert-never';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'; import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies';
@@ -22,7 +22,6 @@ import { buildVersion } from '@/lib/build';
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp'; import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware'; import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware';
import { import {
VisitorAuthCookieValue,
getVisitorAuthCookieName, getVisitorAuthCookieName,
getVisitorAuthCookieValue, getVisitorAuthCookieValue,
getVisitorAuthToken, getVisitorAuthToken,
@@ -80,7 +79,6 @@ interface ContentAPITokenPayload {
spaces: string[]; spaces: string[];
collection?: string; collection?: string;
site?: string; site?: string;
siteSpace?: string;
} }
/** /**
@@ -94,8 +92,8 @@ interface ContentAPITokenPayload {
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { url, mode } = getInputURL(request); const { url, mode } = getInputURL(request);
setTag('url', url.toString()); Sentry.setTag('url', url.toString());
setContext('request', { Sentry.setContext('request', {
method: request.method, method: request.method,
url: url.toString(), url: url.toString(),
rawRequestURL: request.url, rawRequestURL: request.url,
@@ -145,8 +143,8 @@ export async function middleware(request: NextRequest) {
return writeCookies(NextResponse.redirect(normalizedVA.toString()), resolved.cookies); return writeCookies(NextResponse.redirect(normalizedVA.toString()), resolved.cookies);
} }
setTag('space', resolved.space); Sentry.setTag('space', resolved.space);
setContext('content', { Sentry.setContext('content', {
space: resolved.space, space: resolved.space,
changeRequest: resolved.changeRequest, changeRequest: resolved.changeRequest,
revision: resolved.revision, revision: resolved.revision,
@@ -208,11 +206,7 @@ export async function middleware(request: NextRequest) {
if (resolved.siteSpace) { if (resolved.siteSpace) {
headers.set('x-gitbook-content-site-space', resolved.siteSpace); headers.set('x-gitbook-content-site-space', resolved.siteSpace);
} }
if (resolved.shareKey) {
headers.set('x-gitbook-content-site-share-key', resolved.shareKey);
}
} }
if (resolved.revision) { if (resolved.revision) {
headers.set('x-gitbook-content-revision', resolved.revision); headers.set('x-gitbook-content-revision', resolved.revision);
} }
@@ -265,7 +259,7 @@ export async function middleware(request: NextRequest) {
); );
} else { } else {
if (resolved.cacheMaxAge) { if (resolved.cacheMaxAge) {
const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`; const cacheControl = `public, max-age=60, s-maxage=${resolved.cacheMaxAge}, stale-while-revalidate=60, stale-if-error=0`;
if ( if (
process.env.GITBOOK_OUTPUT_CACHE === 'true' && process.env.GITBOOK_OUTPUT_CACHE === 'true' &&
@@ -490,15 +484,15 @@ async function lookupSpaceInMultiIdMode(request: NextRequest, url: URL): Promise
}; };
} }
const { organization, site, siteSpace } = jwt.decode(apiToken) as ContentAPITokenPayload; const decoded = jwt.decode(apiToken) as ContentAPITokenPayload;
const siteLookupResult = const siteLookupResult =
typeof organization === 'string' && organization && typeof site === 'string' && site typeof decoded.site === 'string' &&
? { decoded.site &&
organization, typeof decoded.organization === 'string' &&
site, decoded.organization
...(typeof siteSpace === 'string' && siteSpace ? { siteSpace } : {}), ? { site: decoded.site, organization: decoded.organization }
}
: {}; : {};
return { return {
space: spaceId, space: spaceId,
changeRequest: changeRequestId, changeRequest: changeRequestId,
@@ -593,7 +587,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
*/ */
async function lookupSpaceByAPI( async function lookupSpaceByAPI(
lookupURL: URL, lookupURL: URL,
visitorAuthToken: ReturnType<typeof getVisitorAuthToken>, visitorAuthToken: string | undefined,
): Promise<LookupResult> { ): Promise<LookupResult> {
const url = stripURLSearch(lookupURL); const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url); const lookup = getURLLookupAlternatives(url);
@@ -603,17 +597,9 @@ async function lookupSpaceByAPI(
); );
const result = await race(lookup.urls, async (alternative, { signal }) => { const result = await race(lookup.urls, async (alternative, { signal }) => {
const data = await getPublishedContentByUrl( const data = await getPublishedContentByUrl(alternative.url, visitorAuthToken, {
alternative.url, signal,
typeof visitorAuthToken === 'undefined' });
? undefined
: typeof visitorAuthToken === 'string'
? visitorAuthToken
: visitorAuthToken.token,
{
signal,
},
);
if ('error' in data) { if ('error' in data) {
if (alternative.primary) { if (alternative.primary) {
@@ -664,12 +650,7 @@ async function lookupSpaceByAPI(
cacheMaxAge: data.cacheMaxAge, cacheMaxAge: data.cacheMaxAge,
cacheTags: data.cacheTags, cacheTags: data.cacheTags,
...('site' in data ...('site' in data
? { ? { site: data.site, siteSpace: data.siteSpace, organization: data.organization }
site: data.site,
siteSpace: data.siteSpace,
organization: data.organization,
shareKey: data.shareKey,
}
: {}), : {}),
} as PublishedContentWithCache; } as PublishedContentWithCache;
}); });
@@ -690,36 +671,22 @@ async function lookupSpaceByAPI(
*/ */
function getLookupResultForVisitorAuth( function getLookupResultForVisitorAuth(
basePath: string, basePath: string,
visitorAuthToken: string | VisitorAuthCookieValue, visitorAuthToken: string,
): Partial<LookupResult> { ): Partial<LookupResult> {
return { return {
// No caching for content served with visitor auth // No caching for content served with visitor auth
cacheMaxAge: undefined, cacheMaxAge: undefined,
cacheTags: [], cacheTags: [],
cookies: { cookies: {
/** [getVisitorAuthCookieName(basePath)]: {
* If the visitorAuthToken has been retrieved from a cookie, we set it back only value: getVisitorAuthCookieValue(basePath, visitorAuthToken),
* if the basePath matches the current one. This is to avoid setting cookie for options: {
* different base paths. httpOnly: true,
*/ sameSite: 'none',
...(typeof visitorAuthToken === 'string' || visitorAuthToken.basePath === basePath secure: process.env.NODE_ENV === 'production',
? { maxAge: 7 * 24 * 60 * 60,
[getVisitorAuthCookieName(basePath)]: { },
value: getVisitorAuthCookieValue( },
basePath,
typeof visitorAuthToken === 'string'
? visitorAuthToken
: visitorAuthToken.token,
),
options: {
httpOnly: true,
sameSite: 'none',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60,
},
},
}
: {}),
}, },
}; };
} }