Compare commits

..

47 Commits

Author SHA1 Message Date
github-actions[bot] ba2a523722 Version Packages (#2387)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-19 08:28:14 +02:00
Samy Pessé 57adb3e54a Fix publish with changeset (#2386) 2024-07-19 08:23:03 +02:00
github-actions[bot] 7377928c8a Version Packages (#2385)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-07-18 21:05:22 +02:00
Samy Pessé 5f8a8fe9b1 Setup Changeset to publish packages to NPM (#2384) 2024-07-18 21:00:50 +02:00
Scott Cazan 29a0bf25c4 Update Site search endpoint (#2382)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-07-18 10:36:12 +02:00
spastorelli 92bb016576 Show additional context when linking to a page group & a page in another space (#2381) 2024-07-18 10:22:15 +02:00
Samy Pessé c8810ea002 Hide pages from table of contents when set as hidden (#2383)
Co-authored-by: Scott Cazan <scottcazan@gmail.com>
2024-07-18 09:50:46 +02:00
spastorelli aa8bbaddc4 Fallback redirect to root when switching variants (#2376) 2024-07-16 09:24:31 +02:00
Samy Pessé b8c136e9c0 Fix render of one of when alternatives are primitives (#2378) 2024-07-15 10:15:42 +02:00
Samy Pessé 46d54172b0 Fix CI step to verify formatting (#2379) 2024-07-14 23:53:55 +02:00
Brett Jephson 68dc2de190 Remove Page/Space label from BlockContentRef (#2374) 2024-07-12 15:02:05 +01:00
Samy Pessé 81b372f372 Fix response example when response example is a string (#2377) 2024-07-12 10:35:22 +02:00
Scott Cazan 966e520640 Fix Source links in Ask AI not navigating to the page (#2375) 2024-07-11 15:39:28 +02:00
spastorelli 12702930ec Optimize SpacesDropdownMenuItem client side props (#2373) 2024-07-11 10:42:53 +02:00
spastorelli 6ce57bcda1 Fix issue where nav route/path is not retained when switching variants (#2372) 2024-07-10 17:10:19 +02:00
spastorelli d99b453bc4 Fix issue where published content crashes when localstorage is disabled (#2371) 2024-07-10 14:56:21 +02:00
Samy Pessé aaf5793cb7 Use schema.example if defined, even if value is falsy (#2370) 2024-07-05 16:34:48 +02:00
Samy Pessé 9b1762d2ab Fix invalid api response from carbon (#2369) 2024-07-05 15:55:35 +02:00
Samy Pessé 8d38d000fd Fix RND-4344 (#2368) 2024-07-04 17:04:35 +02:00
Scott Cazan 4cf9cdd56d Fix ToC not re-rendering when linking to a variant from SearchResults (#2366) 2024-07-03 12:22:08 +02:00
Scott Cazan 39406bbf54 Use published URL for site when displaying results for a Site space (#2364) 2024-07-02 16:14:39 +02:00
Scott Cazan 9c5c1e5b68 Show sources with no answer (#2363)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-06-28 15:25:01 +02:00
Johan Preynat 6929b0b98b Prevent SVG images to be resized and rendered on the /~gitbook/image endpoint (#2362) 2024-06-27 12:24:46 +02:00
Johan Preynat 7c26d491b1 Fix passing the correct nonce for iframely script and preload it (#2360) 2024-06-25 17:11:19 +02:00
Johan Preynat 97035a054a Tree shake unused code for Sentry (#2359) 2024-06-25 15:55:48 +02:00
Johan Preynat db45c6d773 Rework context to actually share memory cache between middleware and handlers for all requests (#2358) 2024-06-25 14:30:38 +02:00
Johan Preynat e3f5f81e62 Use fnv1a instead of SHA256 to create images signatures (#2353) 2024-06-24 13:31:18 +02:00
Johan Preynat f5df40e17d Use a singleton to store the memory cache in an internal WeakMap for the current request (#2356) 2024-06-24 11:13:03 +02:00
Johan Preynat e13c76efed Set preconnect links to main domains on render (#2354) 2024-06-21 17:02:02 +02:00
Johan Preynat 4a11d8d9eb Share memory cache between middleware and request handler (#2347) 2024-06-21 11:13:06 +02:00
spastorelli 009ed8cc6e Call trackViewInSiteById API endpoint when tracking view for sites (#2350) 2024-06-21 10:01:35 +02:00
vibhanshub 106cbbd987 Add support for examples in OpenAPI block (#2314) 2024-06-20 16:02:58 +05:30
Johan Preynat 56e5c3c3f8 Parse cache tags returned from the API set them in response (#2349) 2024-06-20 10:09:02 +02:00
Taran Vohra 1dc3234003 Fix page metadata title (#2348) 2024-06-18 20:52:12 +05:30
Johan Preynat 22dc0d4ff5 Fix passing revalidateBefore option when calling cacheResponse to ensure these go through background revalidation (#2342) 2024-06-17 17:16:12 +02:00
Taran Vohra 3a4caf062c Add getContentTitle to properly render the title for sites & legacy published content (#2343) 2024-06-17 20:28:21 +05:30
Taran Vohra 8ad0657159 Use site's share key as context while fetching site spaces (#2341) 2024-06-14 20:59:05 +05:30
Taran Vohra d8a4ecd0c6 Pass site url as context to listSiteSpaces API (#2339) 2024-06-14 10:40:18 +05:30
David Burghoff 65c7968438 Add German Locale support (#2317)
Co-authored-by: David Burghoff <david.burghoff@swot.de>
Co-authored-by: taranvohra <taranvohra@outlook.com>
2024-06-13 15:04:49 +02:00
Rodrigo Castro 3914f11150 Add Brazilian Portuguese translation (#2285)
Co-authored-by: taranvohra <taranvohra@outlook.com>
2024-06-13 14:54:47 +02:00
Addison d363908297 Add contributors to readme, bump API (#2333) 2024-06-12 15:24:18 +02:00
Greg Bergé 414556ac7a Remove cache client-side (#2337) 2024-06-10 11:17:48 +02:00
Steven H c768e9a90a Add another space to ignore list whilst we debug issues. (#2334) 2024-05-28 19:02:07 +01:00
Taran Vohra 553f1f228f Bump @gitbook/api to 0.48.0 (#2328) 2024-05-24 20:10:25 +05:30
Taran Vohra 30986528a7 bump @gitbook/api to 0.47 (#2327) 2024-05-24 19:07:54 +05:30
Steven H c70c2ddad8 Bump next-on-pages version to fix SSRF attack. (#2319) 2024-05-16 17:05:58 +01:00
Marc Laventure cf74d00758 chore: update scalar client (#2316) 2024-05-14 11:52:09 -07:00
83 changed files with 1918 additions and 446 deletions
+8
View File
@@ -0,0 +1,8 @@
# 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
@@ -0,0 +1,11 @@
{
"$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.0.33 bun-version: 1.1.18
- 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.0.33 bun-version: 1.1.18
- 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.0.33 bun-version: 1.1.18
- 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.0.33 bun-version: 1.1.18
- 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.0.33 bun-version: 1.1.18
- 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.0.33 bun-version: 1.1.18
- 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.0.33 bun-version: 1.1.18
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
+35
View File
@@ -0,0 +1,35 @@
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 }}
+3
View File
@@ -1 +1,4 @@
.next .next
# Generated
packages/emoji-codepoints/index.ts
+6
View File
@@ -119,6 +119,12 @@ 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,6 +106,93 @@ 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',
@@ -276,6 +363,11 @@ 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',
+19 -4
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) { webpack(config, { dev, webpack }) {
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,
@@ -19,6 +19,21 @@ 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;
}, },
@@ -45,9 +60,9 @@ module.exports = withSentryConfig(
{ {
protocol: 'https', protocol: 'https',
hostname: '*.gitbook.io', hostname: '*.gitbook.io',
} },
] ],
} },
}, },
{ {
silent: true, silent: true,
+8 -4
View File
@@ -11,8 +11,10 @@
"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}/**/*.test.ts", "unit": "bun test {src,packages}",
"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": [
@@ -20,10 +22,11 @@
], ],
"dependencies": { "dependencies": {
"@geist-ui/icons": "^1.0.2", "@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.46.0", "@gitbook/api": "^0.56.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1", "@sentry/nextjs": "^7.94.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1", "@upstash/redis": "^1.27.1",
@@ -40,7 +43,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.15.4", "nuqs": "^1.17.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",
@@ -64,7 +67,8 @@
}, },
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^2.0.0", "@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "^1.9.0", "@changesets/cli": "^2.27.7",
"@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
@@ -0,0 +1,13 @@
# @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,6 +1,7 @@
{ {
"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
@@ -0,0 +1,13 @@
# @gitbook/react-contentkit
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
+2 -1
View File
@@ -1,9 +1,10 @@
{ {
"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.46.0", "@gitbook/api": "^0.51.0",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"peerDependencies": { "peerDependencies": {
+13
View File
@@ -0,0 +1,13 @@
# @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,6 +1,7 @@
{ {
"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
@@ -0,0 +1,13 @@
# @gitbook/react-openapi
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
+3 -2
View File
@@ -1,9 +1,10 @@
{ {
"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.2.9", "@scalar/api-client-react": "^0.3.7",
"@scalar/oas-utils": "0.1.1", "@scalar/oas-utils": "0.1.6",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"flatted": "^3.2.9", "flatted": "^3.2.9",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
@@ -62,7 +62,10 @@ export function OpenAPIResponseExample(props: {
return ( return (
<InteractiveSection header="Response" className="openapi-response-example"> <InteractiveSection header="Response" className="openapi-response-example">
<context.CodeBlock code={JSON.stringify(example, null, 2)} syntax="json" /> <context.CodeBlock
code={typeof example === 'string' ? example : JSON.stringify(example, null, 2)}
syntax="json"
/>
</InteractiveSection> </InteractiveSection>
); );
} }
+17 -7
View File
@@ -14,7 +14,6 @@ interface OpenAPISchemaPropertyEntry {
propertyName?: string; propertyName?: string;
required?: boolean; required?: boolean;
schema: OpenAPIV3.SchemaObject; schema: OpenAPIV3.SchemaObject;
example?: string;
} }
/** /**
@@ -48,6 +47,13 @@ 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}
@@ -93,10 +99,11 @@ export function OpenAPISchemaProperty(
className="openapi-schema-description" className="openapi-schema-description"
/> />
) : null} ) : null}
{schema.example ? (<Markdown {shouldDisplayExample(schema) ? (
source={'Example: ' + schema.example} <span className="openapi-schema-example">
className="openapi-schema-description" Example: <code>{JSON.stringify(schema.example)}</code>
/>) : null} </span>
) : null}
</div> </div>
} }
> >
@@ -175,6 +182,8 @@ 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;
@@ -183,12 +192,13 @@ 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={getSchemaProperties(schema) ?? []} properties={subProperties ?? [{ schema }]}
circularRefs={new Map(circularRefs).set(schema, id)} circularRefs={subProperties ? new Map(circularRefs).set(schema, id) : circularRefs}
context={context} context={context}
/> />
); );
+10 -1
View File
@@ -7,6 +7,7 @@ import {
getRequestFromOperation, getRequestFromOperation,
Query, Query,
Header, Header,
RequestBody,
} from '@scalar/oas-utils'; } from '@scalar/oas-utils';
import React from 'react'; import React from 'react';
@@ -98,7 +99,15 @@ export function ScalarApiClient(props: { children: React.ReactNode }) {
{ {
url: operationData.path, url: operationData.path,
}, },
getRequestFromOperation(operation, { requiredOnly: false }), getRequestFromOperation(
{
...operation,
information: {
requestBody: operationData.operation.requestBody as RequestBody,
},
},
{ requiredOnly: false },
),
); );
return { return {
@@ -19,7 +19,7 @@ export function generateSchemaExample(
return undefined; return undefined;
} }
if (schema.example) { if (typeof schema.example !== 'undefined') {
return schema.example; return schema.example;
} }
+14 -2
View File
@@ -1,8 +1,15 @@
import * as Sentry from '@sentry/nextjs'; import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
Sentry.init({ // To tree shake default integrations that we don't use
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
const client = new BrowserClient({
debug: false, debug: false,
dsn, dsn,
integrations: [], integrations: [],
@@ -11,5 +18,10 @@ 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 * as Sentry from '@sentry/nextjs'; import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
Sentry.init({ init({
debug: false, debug: false,
dsn, dsn,
+2 -2
View File
@@ -1,8 +1,8 @@
import * as Sentry from '@sentry/nextjs'; import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
Sentry.init({ init({
debug: false, debug: false,
dsn, dsn,
+16 -2
View File
@@ -1,6 +1,11 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { verifyImageSignature, resizeImage, CloudflareImageOptions } from '@/lib/images'; import {
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';
@@ -13,6 +18,9 @@ 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 });
} }
@@ -24,8 +32,14 @@ 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); const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
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,5 +1,8 @@
'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';
/** /**
@@ -9,5 +12,29 @@ 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,9 +6,10 @@ 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 } from '@/lib/pages'; import { getPagePath, resolveFirstDocument } 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';
@@ -18,14 +19,29 @@ export const runtime = 'edge';
/** /**
* Fetch and render a page. * Fetch and render a page.
*/ */
export default async function Page(props: { params: PagePathParams }) { export default async function Page(props: {
const { params } = props; params: PagePathParams;
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 rawPathname = getPathnameParam(params);
const { contentTarget, space, customization, pages, page, document } =
await fetchPageData(params);
const linksContext: PageHrefContext = {}; const linksContext: PageHrefContext = {};
const rawPathname = getPathnameParam(params);
if (!page) { if (!page) {
const pathname = normalizePathname(rawPathname); const pathname = normalizePathname(rawPathname);
if (pathname !== rawPathname) { if (pathname !== rawPathname) {
@@ -62,6 +78,7 @@ export default async function Page(props: { params: PagePathParams }) {
<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}
@@ -75,6 +92,7 @@ export default async function Page(props: { params: PagePathParams }) {
{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}
@@ -103,14 +121,24 @@ export async function generateViewport({ params }: { params: PagePathParams }):
}; };
} }
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> { export async function generateMetadata({
const { space, pages, page, customization, parent } = await fetchPageData(params); 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, customization.title ?? space.title, parent?.title] title: [page.title, getContentTitle(space, customization, parent)]
.filter(Boolean) .filter(Boolean)
.join(' | '), .join(' | '),
description: page.description ?? '', description: page.description ?? '',
@@ -125,3 +153,34 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
}, },
}; };
} }
/**
* 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,
};
}
+9 -1
View File
@@ -8,10 +8,13 @@ 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';
@@ -38,6 +41,11 @@ 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',
@@ -104,7 +112,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: `${parent ? parent.title : customization.title ?? space.title}`, title: getContentTitle(space, customization, parent),
generator: `GitBook (${buildVersion()})`, generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()), metadataBase: new URL(baseUrl()),
icons: { icons: {
@@ -14,6 +14,7 @@ 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';
@@ -54,7 +55,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 = parent?.title ?? customization.title ?? space.title; const contentTitle = getContentTitle(space, customization, parent);
return new ImageResponse( return new ImageResponse(
( (
@@ -3,6 +3,8 @@ 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';
@@ -31,7 +33,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">
{parent?.title ?? customization.title ?? space.title} {getContentTitle(space, customization, parent)}
</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>
+23 -6
View File
@@ -4,6 +4,7 @@ import {
Revision, Revision,
RevisionPageDocument, RevisionPageDocument,
RevisionPageGroup, RevisionPageGroup,
RevisionPageLink,
SiteCustomizationSettings, SiteCustomizationSettings,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
@@ -313,9 +314,17 @@ function selectPages(
): FlatPageEntry[] => { ): FlatPageEntry[] => {
return [ return [
{ page, depth }, { page, depth },
...page.pages.flatMap((child) => ...page.pages.flatMap((child) => {
child.type === 'link' ? [] : flattenPage(child, depth + 1), if (child.type === 'link') {
), return [];
}
if (child.hidden) {
return [];
}
return flattenPage(child, depth + 1);
}),
]; ];
}; };
@@ -340,8 +349,16 @@ function selectPages(
return limitTo(flattenPage(found.page, 0)); return limitTo(flattenPage(found.page, 0));
} }
const allPages = rootPages.flatMap((page) => const allPages = rootPages.flatMap((page) => {
page.type === 'link' ? [] : flattenPage(page, 0), if (page.type === 'link') {
); 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 * as Sentry from '@sentry/nextjs'; import { captureException } 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(() => {
Sentry.captureException(error); captureException(error);
}, [error]); }, [error]);
return ( return (
+22 -4
View File
@@ -40,6 +40,7 @@ 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');
} }
@@ -48,6 +49,7 @@ 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,
@@ -71,7 +73,14 @@ 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)],
); );
@@ -102,7 +111,11 @@ 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(content.organizationId, content.siteId) ? fetchParentSite({
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,
]); ]);
@@ -173,10 +186,15 @@ async function fetchParentCollection(space: Space) {
return { parent: collection, spaces }; return { parent: collection, spaces };
} }
async function fetchParentSite(organizationId: string, siteId: string) { async function fetchParentSite(args: {
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), getSiteSpaces({ organizationId, siteId, siteShareKey }),
]); ]);
const spaces: Record<string, Space> = {}; const spaces: Record<string, Space> = {};
+2 -2
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import * as Sentry from '@sentry/nextjs'; import { captureException } 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(() => {
Sentry.captureException(error); captureException(error);
}, [error]); }, [error]);
return ( return (
+200
View File
@@ -0,0 +1,200 @@
'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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1 @@
export * from './Ad';
+51
View File
@@ -0,0 +1,51 @@
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,27 +1,72 @@
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} /> : null} leadingIcon={resolved.emoji ? <Emoji code={resolved.emoji} style="text-xl" /> : 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,6 +7,7 @@ const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r', 'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf', 'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL', 'e3jwbMOrr4RhKtZ9C0XL',
'ryjzVNizLfd6pYogN3dm',
]; ];
/** /**
+6 -2
View File
@@ -1,5 +1,7 @@
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';
@@ -11,6 +13,9 @@ 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 })
@@ -25,8 +30,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
__html: embed.html, __html: embed.html,
}} }}
/> />
{/* We load the iframely script to resize the embed iframes dynamically */} <Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} />
<Script src="https://cdn.iframe.ly/embed.js" defer async />
</> </>
) : embed.type === 'integration' ? ( ) : embed.type === 'integration' ? (
<IntegrationBlock <IntegrationBlock
+97 -73
View File
@@ -1,115 +1,139 @@
.light .scalar-modal-layout, .light .scalar-modal-layout,
.light .scalar { .light .scalar {
--theme-color-1: color-mix( --scalar-color-1: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-300, 180 180 180)), rgb(var(--primary-base-300, 180 180 180)),
rgb(var(--dark-base, 23 23 23)) 96% rgb(var(--dark-base, 23 23 23)) 96%
); );
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72)); --scalar-color-2: color-mix(
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4)); in srgb,
--theme-color-accent: #007d9c; 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;
--theme-background-1: rgb(var(--light-base, 255 255 255)); --scalar-background-1: rgb(var(--light-base, 255 255 255));
--theme-background-2: color-mix( --scalar-background-2: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-800, 30 30 30)), rgb(var(--primary-base-800, 30 30 30)),
var(--theme-background-1) 96% var(--scalar-background-1) 96%
); );
--theme-background-3: color-mix( --scalar-background-3: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-800, 30 30 30)), rgb(var(--primary-base-800, 30 30 30)),
var(--theme-background-1) 90% var(--scalar-background-1) 90%
); );
--theme-background-accent: #007d9c1f; --scalar-background-accent: #007d9c1f;
--theme-code-language-color-supersede: var(--theme-color-1); --scalar-code-language-color-supersede: var(--scalar-color-1);
--theme-code-languages-background-supersede: var(--theme-background-1); --scalar-code-languages-background-supersede: var(--scalar-background-1);
--theme-border-color: color-mix( --scalar-border-color: color-mix(
in srgb, in srgb,
var(--theme-color-1), var(--scalar-color-1),
transparent calc(100% - 100% * 0.08) transparent calc(100% - 100% * 0.08)
); );
--theme-color-green: #0a6355; --scalar-color-green: #0a6355;
--theme-color-red: #dc1b19; --scalar-color-red: #dc1b19;
--theme-color-yellow: #ffc90d; --scalar-color-yellow: #ffc90d;
--theme-color-blue: rgb(var(--primary-color-500, 52 109 219)); --scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
--theme-color-orange: #ff8d4d; --scalar-color-orange: #ff8d4d;
--theme-color-purple: #8250df; --scalar-color-purple: #8250df;
--theme-scrollbar-color: rgba(255, 255, 255, 0.24); --scalar-scrollbar-color: rgba(255, 255, 255, 0.24);
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48); --scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48);
--scalar-button-1: rgb(49 53 56);
--scalar-button-1-color: #fff;
--scalar-button-1-hover: rgb(28 31 33);
} }
.dark .scalar-modal-layout, .dark .scalar-modal-layout,
.dark .scalar { .dark .scalar {
--theme-color-1: color-mix( --scalar-color-1: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-700, 70 70 70)), rgb(var(--primary-base-700, 70 70 70)),
rgb(var(--light-base, 255 255 255)) 100% rgb(var(--light-base, 255 255 255)) 100%
); );
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64)); --scalar-color-2: color-mix(
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4)); in srgb,
--theme-color-accent: #50b7e0; 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;
--theme-background-1: rgb(var(--dark-base, 22 22 22)); --scalar-background-1: rgb(var(--dark-base, 22 22 22));
--theme-background-2: color-mix( --scalar-background-2: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-200, 200 200 200)), rgb(var(--primary-base-200, 200 200 200)),
var(--theme-background-1) 92% var(--scalar-background-1) 92%
); );
--theme-background-3: color-mix( --scalar-background-3: color-mix(
in srgb, in srgb,
rgb(var(--primary-base-200, 200 200 200)), rgb(var(--primary-base-200, 200 200 200)),
var(--theme-background-1) 88% var(--scalar-background-1) 88%
); );
--theme-background-accent: #8ab4f81f; --scalar-background-accent: #8ab4f81f;
--theme-code-languages-background-supersede: var(--theme-background-1); --scalar-code-languages-background-supersede: var(--scalar-background-1);
--theme-border-color: color-mix( --scalar-border-color: color-mix(
in srgb, in srgb,
var(--theme-color-1), var(--scalar-color-1),
transparent calc(100% - 100% * 0.08) transparent calc(100% - 100% * 0.08)
); );
--theme-color-green: #56b6c2; --scalar-color-green: #56b6c2;
--theme-color-red: rgb(245 124 97); --scalar-color-red: rgb(245 124 97);
--theme-color-yellow: #edbe20; --scalar-color-yellow: #edbe20;
--theme-color-blue: rgb(var(--primary-color-400, 93 138 226)); --scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
--theme-color-orange: #d19a66; --scalar-color-orange: #d19a66;
--theme-color-purple: #5203d1; --scalar-color-purple: #5203d1;
--theme-scrollbar-color: rgba(0, 0, 0, 0.18); --scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36); --scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
--scalar-button-1: #f6f6f6;
--scalar-button-1-color: #000;
--scalar-button-1-hover: #e7e7e7;
} }
.scalar-modal-layout, .scalar-modal-layout,
.scalar { .scalar {
--theme-font: initial; --scalar-font: initial;
--theme-font-code: var(--font-mono); --scalar-font-code: var(--font-mono);
--theme-paragraph: 16px; --scalar-paragraph: 16px;
--theme-small: 14px; --scalar-small: 14px;
--theme-mini: 13px; --scalar-mini: 13px;
--theme-micro: 12px; --scalar-micro: 12px;
--theme-bold: 600; --scalar-bold: 600;
--theme-semibold: 500; --scalar-semibold: 500;
--theme-regular: 400; --scalar-regular: 400;
/* Font sizes for interactive applications (not rendered text content) */ /* Font sizes for interactive applications (not rendered text content) */
--theme-font-size-1: 24px; --scalar-font-size-1: 24px;
--theme-font-size-2: 16px; --scalar-font-size-2: 16px;
--theme-font-size-3: 14px; --scalar-font-size-3: 14px;
--theme-font-size-4: 13px; --scalar-font-size-4: 13px;
--theme-font-size-5: 12px; --scalar-font-size-5: 12px;
--theme-line-height-1: 32px; --scalar-line-height-1: 32px;
--theme-line-height-2: 24px; --scalar-line-height-2: 24px;
--theme-line-height-3: 20px; --scalar-line-height-3: 20px;
--theme-line-height-4: 18px; --scalar-line-height-4: 18px;
--theme-line-height-5: 16px; --scalar-line-height-5: 16px;
--scalar-app-header-height: 35px; --scalar-app-header-height: 35px;
} }
.scalar input::placeholder { .scalar input::placeholder {
color: var(--theme-color-3); color: var(--scalar-color-3);
} }
.scalar .scalar-app-header { .scalar .scalar-app-header {
width: 100%; width: 100%;
@@ -134,15 +158,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
background: transparent; background: transparent;
color: var(--theme-color-1); color: var(--scalar-color-1);
font-size: var(--theme-small); font-size: var(--scalar-small);
font-weight: var(--theme-semibold); font-weight: var(--scalar-semibold);
} }
.scalar-api-client__close:hover { .scalar-api-client__close:hover {
cursor: pointer; cursor: pointer;
} }
.scalar .scalar-app { .scalar .scalar-app {
background: var(--theme-background-3); background: var(--scalar-background-3);
height: calc(100dvh - 100px); height: calc(100dvh - 100px);
max-width: 1280px; max-width: 1280px;
width: 100%; width: 100%;
@@ -227,10 +251,10 @@
line-height: normal; line-height: normal;
} }
.scalar .scalar-app-header span { .scalar .scalar-app-header span {
color: var(--theme-color-3); color: var(--scalar-color-3);
} }
.scalar .scalar-app-header a { .scalar .scalar-app-header a {
color: var(--theme-color-1); color: var(--scalar-color-1);
} }
.scalar .scalar-app-header a:hover { .scalar .scalar-app-header a:hover {
text-decoration: underline; text-decoration: underline;
@@ -251,7 +275,7 @@
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
color: var(--theme-color-blue); color: var(--scalar-color-blue);
appearance: none; appearance: none;
outline: none; outline: none;
border: none; border: none;
@@ -261,7 +285,7 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.scalar-activate:hover .scalar-activate-button { .scalar-activate:hover .scalar-activate-button {
background: var(--theme-background-3); background: var(--scalar-background-3);
border-radius: 3px; border-radius: 3px;
} }
@@ -293,12 +317,12 @@
scrollbar-color: rgba(255, 255, 255, 0.24) transparent; scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
} }
.scalar .custom-scroll:hover::-webkit-scrollbar-thumb { .scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color)); background: var(--scalar-scrollbar-color, var(--default-theme-scrollbar-color));
background-clip: content-box; background-clip: content-box;
border: 3px solid transparent; border: 3px solid transparent;
} }
.scalar .custom-scroll::-webkit-scrollbar-thumb:active { .scalar .custom-scroll::-webkit-scrollbar-thumb:active {
background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active)); background: var(--scalar-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
background-clip: content-box; background-clip: content-box;
border: 3px solid transparent; border: 3px solid transparent;
} }
@@ -172,6 +172,10 @@
@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 {
+11 -31
View File
@@ -8,9 +8,11 @@ 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';
@@ -94,40 +96,18 @@ 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 : null; const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : undefined;
const customEmoji = 'emoji' in customization.favicon ? customization.favicon.emoji : undefined;
return ( return (
<> <>
<Image <LogoIcon
alt="Logo" icon={customIcon}
sources={ emoji={customEmoji}
customIcon alt=""
? { sizes={[{ width: 32 }]}
light: { src: customIcon.light, aspectRatio: '1' }, style={['object-contain', 'size-8']}
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',
@@ -145,7 +125,7 @@ function LogoFallback(props: HeaderLogoProps) {
: 'text-header-link', : 'text-header-link',
)} )}
> >
{parent ? parent.title : customization.title ?? space.title} {getContentTitle(space, customization, parent)}
</h1> </h1>
</> </>
); );
+12 -9
View File
@@ -1,8 +1,9 @@
import { Collection, Space } from '@gitbook/api'; import { Space } from '@gitbook/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown'; import { Dropdown, DropdownChevron, DropdownMenu } 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;
@@ -29,14 +30,16 @@ export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
)} )}
> >
<DropdownMenu> <DropdownMenu>
{spaces.map((otherSpace) => ( {spaces.map((otherSpace, index) => (
<DropdownMenuItem <SpacesDropdownMenuItem
key={otherSpace.id} key={`${otherSpace.id}-${index}`}
href={otherSpace.urls.published ?? otherSpace.urls.app} variantSpace={{
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>
@@ -0,0 +1,30 @@
'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>
);
}
+23 -3
View File
@@ -6,6 +6,7 @@ import {
CustomizationSettings, CustomizationSettings,
JSONDocument, JSONDocument,
RevisionPageDocument, RevisionPageDocument,
Site,
SiteCustomizationSettings, SiteCustomizationSettings,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
@@ -17,9 +18,10 @@ 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 { getPDFUrl, getPDFUrlSearchParams } from '@/lib/urls'; import { getPDFUrlSearchParams } from '@/lib/urls';
import { ScrollSectionsList } from './ScrollSectionsList'; import { ScrollSectionsList } from './ScrollSectionsList';
import { Ad } from '../Ads';
import { PageFeedbackForm } from '../PageFeedback'; import { PageFeedbackForm } from '../PageFeedback';
/** /**
@@ -27,6 +29,7 @@ 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;
@@ -35,8 +38,16 @@ export async function PageAside(props: {
withFullPageCover: boolean; withFullPageCover: boolean;
withPageFeedback: boolean; withPageFeedback: boolean;
}) { }) {
const { space, page, document, customization, withHeaderOffset, withPageFeedback, context } = const {
props; space,
site,
page,
document,
customization,
withHeaderOffset,
withPageFeedback,
context,
} = props;
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
return ( return (
@@ -172,6 +183,15 @@ 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>
); );
} }
+22 -4
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 { ContentTarget, api } from '@/lib/api'; import { ContentPointer, ContentTarget, SiteContentPointer, 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,6 +25,7 @@ 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;
@@ -32,13 +33,25 @@ export function PageBody(props: {
context: ContentRefContext; context: ContentRefContext;
withPageFeedback: boolean; withPageFeedback: boolean;
}) { }) {
const { space, contentTarget, customization, context, page, document, withPageFeedback } = const {
props; space,
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 (
<> <>
@@ -129,7 +142,12 @@ export function PageBody(props: {
</div> </div>
</main> </main>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<TrackPageView spaceId={space.id} pageId={page.id} apiHost={api().endpoint} /> <TrackPageView
sitePointer={sitePointer}
spaceId={space.id}
pageId={page.id}
apiHost={api().endpoint}
/>
</React.Suspense> </React.Suspense>
</> </>
); );
+59 -15
View File
@@ -1,28 +1,66 @@
'use client'; 'use client';
import type { RequestSpaceTrackPageView } from '@gitbook/api'; import type { RequestSiteTrackPageView, 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, spaceId, pageId } = props; const { apiHost, sitePointer, spaceId, pageId } = props;
React.useEffect(() => { React.useEffect(() => {
trackPageView(apiHost, spaceId, pageId); trackPageView({ apiHost, sitePointer, spaceId, pageId });
}, [apiHost, spaceId, pageId]); }, [apiHost, spaceId, pageId, sitePointer]);
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;
/** /**
@@ -30,7 +68,13 @@ 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(apiHost: string, spaceId: string, pageId: string | undefined) { async function trackPageView(args: {
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;
@@ -39,7 +83,7 @@ async function trackPageView(apiHost: string, spaceId: string, pageId: string |
latestPageId = pageId; latestPageId = pageId;
const visitorId = await getVisitorId(); const visitorId = await getVisitorId();
const body: RequestSpaceTrackPageView = { const sharedTrackedProps = {
url: window.location.href, url: window.location.href,
pageId, pageId,
visitor: { visitor: {
@@ -51,17 +95,17 @@ async function trackPageView(apiHost: string, spaceId: string, pageId: string |
referrer: document.referrer, referrer: document.referrer,
}; };
const url = new URL(apiHost);
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
try { try {
await fetch(url, { sitePointer
method: 'POST', ? await sendSiteTrackPageViewRequest({
headers: { apiHost,
'Content-Type': 'application/json', sitePointer,
body: {
...sharedTrackedProps,
spaceId,
}, },
body: JSON.stringify(body), })
}); : 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);
} }
+43 -17
View File
@@ -6,10 +6,11 @@ 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, streamAskQuestion } from './server-actions'; import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch'; import { useSearch, useSearchLink } from './useSearch';
import { Link } from '../primitives'; import { Link } from '../primitives';
@@ -94,6 +95,11 @@ 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(
@@ -129,25 +135,37 @@ 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.body} {answer.hasAnswer ? answer.body : t(language, 'search_ask_no_answer')}
</div> </div>
{answer.followupQuestions.length > 0 ? ( {answer.followupQuestions.length > 0 ? (
<div className={tcls('mt-7', 'flex', 'flex-col', 'flex-wrap', 'gap-1')}> <AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
{answer.followupQuestions.map((question) => ( ) : null}
{answer.sources.length > 0 ? (
<AnswerSources
hasAnswer={answer.hasAnswer}
sources={answer.sources}
language={language}
/>
) : 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 <Link
key={question} key={question}
className={tcls( className={tcls(
@@ -182,8 +200,17 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
</Link> </Link>
))} ))}
</div> </div>
) : null} );
{answer.sources.length > 0 ? ( }
function AnswerSources(props: {
sources: AskAnswerSource[];
language: TranslationLanguage;
hasAnswer?: boolean;
}) {
const { sources, language, hasAnswer } = props;
return (
<div <div
className={tcls( className={tcls(
'flex', 'flex',
@@ -197,12 +224,13 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
'dark:border-light/1', 'dark:border-light/1',
)} )}
> >
<span className={tcls('text-sm')}>{t(language, 'search_ask_sources')}</span> <span className={tcls('text-sm')}>
{t(language, hasAnswer ? 'search_ask_sources' : 'search_ask_sources_no_answer')}
</span>
{answer.sources.map((source) => ( {sources.map((source) => (
<span key={source.id} className={tcls()}> <span key={source.id} className={tcls()}>
<Link <Link
onClick={onClose}
className={tcls( className={tcls(
'flex', 'flex',
'text-sm', 'text-sm',
@@ -230,7 +258,5 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
</span> </span>
))} ))}
</div> </div>
) : null}
</>
); );
} }
+1 -6
View File
@@ -232,7 +232,6 @@ function SearchModalBody(
</div> </div>
</div> </div>
{!state.ask || !withAsk ? ( {!state.ask || !withAsk ? (
<>
<SearchResults <SearchResults
ref={resultsRef} ref={resultsRef}
spaceId={spaceId} spaceId={spaceId}
@@ -247,13 +246,9 @@ function SearchModalBody(
global: state.global, global: state.global,
}); });
}} }}
onClose={onClose}
> >
{parent && state.query ? ( {parent && state.query ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
</SearchResults> </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,20 +11,15 @@ 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, onClick } = props; const { query, item, active } = 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',
+2 -4
View File
@@ -43,11 +43,10 @@ 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, onClose } = props; const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk } = props;
const language = useLanguage(); const language = useLanguage();
const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null); const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null);
@@ -97,6 +96,7 @@ 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,7 +211,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query} query={query}
item={item} item={item}
active={index === cursor} active={index === cursor}
onClick={onClose}
/> />
); );
} }
@@ -252,7 +251,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query} query={query}
item={item} item={item}
active={index === cursor} active={index === cursor}
onClick={onClose}
/> />
); );
} }
@@ -11,20 +11,15 @@ 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, onClick } = props; const { query, item, active } = 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',
+117 -33
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,9 +46,65 @@ 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 [];
} }
/** /**
@@ -59,6 +115,16 @@ 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();
} }
@@ -71,30 +137,18 @@ export async function searchParentContent(
query: string, query: string,
): Promise<OrderedComputedResult[]> { ): Promise<OrderedComputedResult[]> {
const pointer = getContentPointer(); const pointer = getContentPointer();
const isSite = 'siteId' in pointer;
const [data, collectionSpaces, siteSpaces] = await Promise.all([ if (isSite) {
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[] = []; let spaces: Space[] = collectionSpaces ? collectionSpaces : [];
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) => {
@@ -108,12 +162,15 @@ 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.api().spaces.streamAskInSpace(spaceId, { query, format: 'document' }); const stream = api
.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);
} }
}); });
@@ -130,7 +187,7 @@ function transformAnswer(
answer: SearchAIAnswer | undefined, answer: SearchAIAnswer | undefined,
pages: RevisionPage[], pages: RevisionPage[],
): AskAnswerResult | null { ): AskAnswerResult | null {
if (!answer || !('document' in answer.answer)) { if (!answer) {
return null; return null;
} }
@@ -154,7 +211,8 @@ 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={{
@@ -165,25 +223,31 @@ function transformAnswer(
}} }}
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 transformPageResult(item: SearchPageResult, space?: Space) { function transformSectionsAndPage(args: {
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) => { const getURL = (path: string, spaceURL?: string) => {
if (space) { if (spaceURL) {
let url = space.urls.published ?? space.urls.app; if (!spaceURL.endsWith('/')) {
if (!url.endsWith('/')) { spaceURL += '/';
url += '/';
} }
if (path.startsWith('/')) { if (path.startsWith('/')) {
path = path.slice(1); path = path.slice(1);
} }
return url + path; return spaceURL + path;
} else { } else {
return absoluteHref(path); return absoluteHref(path);
} }
@@ -194,7 +258,7 @@ function transformPageResult(item: SearchPageResult, space?: Space) {
type: 'section', type: 'section',
id: item.id + '/' + section.id, id: item.id + '/' + section.id,
title: section.title, title: section.title,
href: getURL(section.path), href: getURL(section.path, spaceURL),
body: section.body, body: section.body,
})) ?? []; })) ?? [];
@@ -206,5 +270,25 @@ function transformPageResult(item: SearchPageResult, space?: Space) {
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];
} }
+8 -2
View File
@@ -19,6 +19,14 @@ 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
@@ -29,8 +37,6 @@ 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
@@ -0,0 +1,49 @@
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 +1,2 @@
export * from './types'; export * from './types';
export * from './LogoIcon';
+56
View File
@@ -0,0 +1,56 @@
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.',
};
+3 -1
View File
@@ -13,7 +13,9 @@ 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_no_answer: 'No answer could be found for your question, try with another one.', search_ask_sources_no_answer: 'Related pages',
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',
+3 -1
View File
@@ -15,7 +15,9 @@ 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_no_answer: 'No se pudo encontrar una respuesta a tu pregunta, intenta con otra.', search_ask_sources_no_answer: 'Páginas relacionadas',
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',
+3 -1
View File
@@ -15,7 +15,9 @@ 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_no_answer: 'Aucune réponse trouvée à votre question, essayez-en une autre.', search_ask_sources_no_answer: 'Pages connexes',
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,9 +1,11 @@
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';
@@ -12,9 +14,11 @@ 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,
}; };
+2 -1
View File
@@ -15,8 +15,9 @@ 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
@@ -0,0 +1,56 @@
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.',
};
+2 -1
View File
@@ -15,7 +15,8 @@ export const zh: TranslationLanguage = {
search_scope_all: '所有内容', search_scope_all: '所有内容',
search_ask: '询问"${1}"', search_ask: '询问"${1}"',
search_ask_sources: '来源', search_ask_sources: '来源',
search_ask_no_answer: '找不到您的问题的答案,请尝试另一个问题。', search_ask_sources_no_answer: '相关页面',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。', search_ask_error: '出了些问题。请稍后再试。',
on_this_page: '在本页', on_this_page: '在本页',
next_page: '下一页', next_page: '下一页',
+68 -6
View File
@@ -47,6 +47,11 @@ 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;
} }
/** /**
@@ -610,6 +615,7 @@ const getSiteSpaceCustomizationFromAPI = cache(
organizationId, organizationId,
siteId, siteId,
siteSpaceId, siteSpaceId,
{},
{ {
signal: options.signal, signal: options.signal,
...noCacheFetchOptions, ...noCacheFetchOptions,
@@ -633,10 +639,15 @@ 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(organizationId, siteId, { const response = await api().orgs.getSiteCustomizationById(
organizationId,
siteId,
{},
{
signal: options.signal, signal: options.signal,
...noCacheFetchOptions, ...noCacheFetchOptions,
}); },
);
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [ tags: [
@@ -730,18 +741,34 @@ export const getSite = cache(
*/ */
export const getSiteSpaces = cache( export const getSiteSpaces = cache(
'api.getSiteSpaces', 'api.getSiteSpaces',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { async (
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(organizationId, siteId, params, { api().orgs.listSiteSpaces(
args.organizationId,
args.siteId,
{
...params,
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
},
{
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, 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: siteId })], tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
}); });
}, },
); );
@@ -1019,6 +1046,41 @@ 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, getGlobalContext } from './waitUntil'; import { waitUntil, getRequestContext } 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 getGlobalContext(); const ctx = await getRequestContext();
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 getGlobalContext(); const ctx = await getRequestContext();
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 getGlobalContext(); const ctx = await getRequestContext();
const current = states.get(ctx); const current = states.get(ctx);
return current?.has(key) ?? false; return current?.has(key) ?? false;
}; };
+19 -1
View File
@@ -7,21 +7,24 @@ 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', async () => { it('should only execute once for same argument if there is a ttl', 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']);
@@ -34,6 +37,21 @@ 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
waitUntil(revalidate(key, undefined, ...args)); await waitUntil(revalidate(key, undefined, ...args));
} }
return savedEntry.data; return savedEntry.data;
+5 -1
View File
@@ -26,9 +26,12 @@ 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']) {
@@ -50,6 +53,7 @@ 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,
}; };
+17 -13
View File
@@ -1,11 +1,12 @@
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 = getMemoryCache(); const memoryCache = await getMemoryCache();
const memoryEntry = memoryCache.get(key); const memoryEntry = memoryCache.get(key);
if (!memoryEntry) { if (!memoryEntry) {
@@ -21,7 +22,7 @@ export const memoryCache: CacheBackend = {
return null; return null;
}, },
async set(key, entry) { async set(key, entry) {
const memoryCache = getMemoryCache(); const memoryCache = await 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 =
@@ -41,11 +42,11 @@ export const memoryCache: CacheBackend = {
memoryCache.set(key, { ...entry, meta }); memoryCache.set(key, { ...entry, meta });
}, },
async del(keys) { async del(keys) {
const memoryCache = getMemoryCache(); const memoryCache = await getMemoryCache();
keys.forEach((key) => memoryCache.delete(key)); keys.forEach((key) => memoryCache.delete(key));
}, },
async revalidateTags(tags) { async revalidateTags(tags) {
const memoryCache = getMemoryCache(); const memoryCache = await getMemoryCache();
const keys: string[] = []; const keys: string[] = [];
memoryCache.forEach((entry, key) => { memoryCache.forEach((entry, key) => {
@@ -63,16 +64,19 @@ export const memoryCache: CacheBackend = {
}; };
/** /**
* With next-on-pages, the code seems to be isolated between the middleware and the handler. * In memory cache shared globally.
* To share the cache between the two, we use a global variable.
*/ */
function getMemoryCache(): Map<string, CacheEntry> { async function getMemoryCache(): Promise<Map<string, CacheEntry>> {
// @ts-ignore const ctx: Awaited<ReturnType<typeof getGlobalContext>> & {
if (!globalThis.gitbookMemoryCache) { gitbookMemoryCache?: Map<string, CacheEntry>;
// @ts-ignore } = await getGlobalContext();
globalThis.gitbookMemoryCache = new Map();
if (ctx.gitbookMemoryCache) {
return ctx.gitbookMemoryCache;
} }
// @ts-ignore const gitbookMemoryCache = new Map<string, CacheEntry>();
return globalThis.gitbookMemoryCache; ctx.gitbookMemoryCache = gitbookMemoryCache;
return gitbookMemoryCache;
} }
+9
View File
@@ -16,6 +16,15 @@ 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 ${assetsDomain}; connect-src * 'self' integrations.gitbook.com app.gitbook.com api.gitbook.com srv.buysellads.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';
+38 -8
View File
@@ -1,5 +1,7 @@
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';
@@ -39,7 +41,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): boolean { export function checkIsHttpURL(input: string | URL): boolean {
if (!URL.canParse(input)) { if (!URL.canParse(input)) {
return false; return false;
} }
@@ -52,11 +54,16 @@ export function checkIsHttpURL(input: string): 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.
*/ */
function checkIsSizableImageURL(input: string): boolean { export function checkIsSizableImageURL(input: string): boolean {
if (input.endsWith('.svg')) { if (!URL.canParse(input)) {
return false; return false;
} }
return checkIsHttpURL(input);
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg')) {
return false;
}
return checkIsHttpURL(parsed);
} }
/** /**
@@ -73,7 +80,7 @@ export async function getResizedImageURL(
return null; return null;
} }
const signature = await generateSignature(input); const signature = await generateSignatureV1(input);
return (options) => { return (options) => {
const url = new URL('/~gitbook/image', rootUrl()); const url = new URL('/~gitbook/image', rootUrl());
@@ -93,6 +100,7 @@ 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();
}; };
@@ -101,8 +109,12 @@ export async function getResizedImageURL(
/** /**
* Verify a signature of an image URL * Verify a signature of an image URL
*/ */
export async function verifyImageSignature(input: string, signature: string): Promise<boolean> { export async function verifyImageSignature(
const expectedSignature = await generateSignature(input); input: string,
{ 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;
} }
@@ -201,7 +213,25 @@ function stringifyOptions(options: CloudflareImageOptions): string {
}, ''); }, '');
} }
async function generateSignature(input: string): Promise<string> { // Reused buffer for FNV-1a hashing
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));
+14 -3
View File
@@ -75,12 +75,13 @@ 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); const flat = flattenPages(rootPages, (page) => !page.hidden);
const currentIndex = flat.findIndex((p) => p.id === page.id); const currentIndex = flat.findIndex((p) => p.id === page.id);
if (currentIndex === -1) { if (currentIndex === -1) {
@@ -152,17 +153,27 @@ 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)); result.push(...flattenPages(page.pages, filter));
} }
return result; return result;
+26 -4
View File
@@ -18,6 +18,8 @@ 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 */
@@ -103,10 +105,14 @@ export async function resolveContentRef(
return resolveContentRefInSpace(contentRef.space, contentRef); return resolveContentRefInSpace(contentRef.space, contentRef);
} }
const page = const resolvePageResult =
!contentRef.page || contentRef.page === activePage?.id !contentRef.page || contentRef.page === activePage?.id
? activePage ? activePage
: resolvePageId(pages, contentRef.page)?.page; ? { page: activePage, ancestors: [] }
: undefined
: resolvePageId(pages, contentRef.page);
const page = resolvePageResult?.page;
if (!page) { if (!page) {
return null; return null;
} }
@@ -134,7 +140,13 @@ export async function resolveContentRef(
} }
} }
} else { } else {
text = page.title; const parentPage = (resolvePageResult?.ancestors || []).slice(-1).pop();
// 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;
} }
@@ -164,6 +176,7 @@ 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}`),
@@ -244,12 +257,21 @@ async function resolveContentRefInSpace(spaceId: string, contentRef: ContentRef)
baseUrl += '/'; baseUrl += '/';
} }
return resolveContentRef(contentRef, { const resolved = await 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 * as Sentry from '@sentry/nextjs'; import { startSpan } 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 Sentry.startSpan( return await startSpan(
{ {
name: completeName, name: completeName,
op: operation, op: operation,
+25
View File
@@ -0,0 +1,25 @@
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;
}
+22 -7
View File
@@ -1,10 +1,28 @@
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 getGlobalContext(): Promise<object> { export async function getRequestContext(): Promise<IncomingRequestCfProperties | 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;
@@ -29,12 +47,9 @@ export async function waitUntil(promise: Promise<unknown>) {
return; return;
} }
// We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'. const cloudflareContext = await getGlobalContext();
const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages'); if ('waitUntil' in cloudflareContext) {
cloudflareContext.waitUntil(promise);
const cloudflare = getOptionalRequestContext();
if (cloudflare) {
cloudflare.ctx.waitUntil(promise);
} else { } else {
await promise; await promise;
} }
+16 -7
View File
@@ -1,5 +1,5 @@
import { GitBookAPI } from '@gitbook/api'; import { GitBookAPI } from '@gitbook/api';
import * as Sentry from '@sentry/nextjs'; import { setTag, setContext } 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);
Sentry.setTag('url', url.toString()); setTag('url', url.toString());
Sentry.setContext('request', { 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);
} }
Sentry.setTag('space', resolved.space); setTag('space', resolved.space);
Sentry.setContext('content', { setContext('content', {
space: resolved.space, space: resolved.space,
changeRequest: resolved.changeRequest, changeRequest: resolved.changeRequest,
revision: resolved.revision, revision: resolved.revision,
@@ -208,7 +208,11 @@ 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);
} }
@@ -261,7 +265,7 @@ export async function middleware(request: NextRequest) {
); );
} else { } else {
if (resolved.cacheMaxAge) { if (resolved.cacheMaxAge) {
const cacheControl = `public, max-age=60, s-maxage=${resolved.cacheMaxAge}, stale-while-revalidate=60, stale-if-error=0`; const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`;
if ( if (
process.env.GITBOOK_OUTPUT_CACHE === 'true' && process.env.GITBOOK_OUTPUT_CACHE === 'true' &&
@@ -660,7 +664,12 @@ 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;
}); });