Compare commits

..

1 Commits

Author SHA1 Message Date
Scott Cazan ee26206cdd bump sentry to latest 2024-05-15 16:35:05 +02:00
83 changed files with 371 additions and 1832 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": []
}
+8 -8
View File
@@ -20,7 +20,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 +82,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 +102,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 +121,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 +136,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 +151,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 +166,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
-6
View File
@@ -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',
+4 -19
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;
}, },
@@ -60,9 +45,9 @@ module.exports = withSentryConfig(
{ {
protocol: 'https', protocol: 'https',
hostname: '*.gitbook.io', hostname: '*.gitbook.io',
}, }
], ]
}, }
}, },
{ {
silent: true, silent: true,
+5 -9
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.46.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": "^8.0.0",
"@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.46.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
-1
View File
@@ -1,7 +1,6 @@
{ {
"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.3.7",
"@scalar/oas-utils": "0.1.6", "@scalar/oas-utils": "0.1.6",
@@ -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,
@@ -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,
+2 -16
View File
@@ -1,11 +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'; import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -18,9 +13,6 @@ export const runtime = 'edge';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url'); let urlParam = 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
// 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) { if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 }); return new Response('Missing url/sign parameters', { status: 400 });
} }
@@ -32,14 +24,8 @@ export async function GET(request: NextRequest) {
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}
/>
);
}
@@ -7,7 +7,6 @@ const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r', 'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf', 'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL', 'e3jwbMOrr4RhKtZ9C0XL',
'ryjzVNizLfd6pYogN3dm',
]; ];
/** /**
+2 -6
View File
@@ -1,7 +1,5 @@
import * as gitbookAPI from '@gitbook/api'; import * as gitbookAPI 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';
@@ -13,9 +11,6 @@ import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) { export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props; const { block, context, ...otherProps } = props;
const nonce = headers().get('x-nonce') || undefined;
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
const { data: embed } = await (context.content const { data: embed } = await (context.content
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url }) ? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
@@ -30,7 +25,8 @@ 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 />
</> </>
) : embed.type === 'integration' ? ( ) : embed.type === 'integration' ? (
<IntegrationBlock <IntegrationBlock
+5 -21
View File
@@ -5,16 +5,8 @@
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( --scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.72));
in srgb, --scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
var(--scalar-color-1),
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-color-accent: #007d9c;
--scalar-background-1: rgb(var(--light-base, 255 255 255)); --scalar-background-1: rgb(var(--light-base, 255 255 255));
@@ -58,16 +50,8 @@
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( --scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.64));
in srgb, --scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
var(--scalar-color-1),
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-color-accent: #50b7e0;
--scalar-background-1: rgb(var(--dark-base, 22 22 22)); --scalar-background-1: rgb(var(--dark-base, 22 22 22));
@@ -346,4 +330,4 @@
.scalar .custom-scroll { .scalar .custom-scroll {
padding-right: 12px; padding-right: 12px;
} }
} }
@@ -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 {
+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 (
-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';
-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';
+8 -38
View File
@@ -1,7 +1,5 @@
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';
@@ -41,7 +39,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 +52,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,7 +73,7 @@ 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());
@@ -100,7 +93,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 +101,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 +201,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,
-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;
}
+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;
} }
+7 -16
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';
@@ -94,8 +94,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 +145,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 +208,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 +261,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' &&
@@ -664,12 +660,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;
}); });