mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 22:29:22 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba2a523722 | |||
| 57adb3e54a | |||
| 7377928c8a | |||
| 5f8a8fe9b1 | |||
| 29a0bf25c4 | |||
| 92bb016576 | |||
| c8810ea002 | |||
| aa8bbaddc4 | |||
| b8c136e9c0 | |||
| 46d54172b0 | |||
| 68dc2de190 | |||
| 81b372f372 | |||
| 966e520640 | |||
| 12702930ec | |||
| 6ce57bcda1 | |||
| d99b453bc4 | |||
| aaf5793cb7 | |||
| 9b1762d2ab | |||
| 8d38d000fd | |||
| 4cf9cdd56d | |||
| 39406bbf54 | |||
| 9c5c1e5b68 | |||
| 6929b0b98b | |||
| 7c26d491b1 | |||
| 97035a054a | |||
| db45c6d773 | |||
| e3f5f81e62 | |||
| f5df40e17d | |||
| e13c76efed | |||
| 4a11d8d9eb | |||
| 009ed8cc6e | |||
| 106cbbd987 | |||
| 56e5c3c3f8 | |||
| 1dc3234003 | |||
| 22dc0d4ff5 | |||
| 3a4caf062c | |||
| 8ad0657159 | |||
| d8a4ecd0c6 | |||
| 65c7968438 | |||
| 3914f11150 | |||
| d363908297 | |||
| 414556ac7a | |||
| c768e9a90a | |||
| 553f1f228f | |||
| 30986528a7 | |||
| c70c2ddad8 | |||
| cf74d00758 |
@@ -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)
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 }}
|
||||||
+4
-1
@@ -1 +1,4 @@
|
|||||||
.next
|
.next
|
||||||
|
|
||||||
|
# Generated
|
||||||
|
packages/emoji-codepoints/index.ts
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,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": {},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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> = {};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './Ad';
|
||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {
|
||||||
body: JSON.stringify(body),
|
...sharedTrackedProps,
|
||||||
});
|
spaceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await sendSpaceTrackPageViewRequest({ apiHost, spaceId, body: sharedTrackedProps });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to track page view', error);
|
console.error('Failed to track page view', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,108 +135,128 @@ 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) => (
|
|
||||||
<Link
|
|
||||||
key={question}
|
|
||||||
className={tcls(
|
|
||||||
'text-sm',
|
|
||||||
'font-medium',
|
|
||||||
'inline-flex',
|
|
||||||
'items-start',
|
|
||||||
'gap-2',
|
|
||||||
'px-4',
|
|
||||||
'py-1',
|
|
||||||
'text-primary-500',
|
|
||||||
'focus-within:text-primary-700',
|
|
||||||
'hover:bg-primary/2',
|
|
||||||
'dark:text-primary-400',
|
|
||||||
'dark:hover:bg-primary-500/3',
|
|
||||||
)}
|
|
||||||
{...getSearchLinkProps({
|
|
||||||
query: question,
|
|
||||||
ask: true,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<IconSearch
|
|
||||||
className={tcls(
|
|
||||||
'w-[15px]',
|
|
||||||
'h-[15px]',
|
|
||||||
'shrink-0',
|
|
||||||
'mt-0.5',
|
|
||||||
'[&>path]:[stroke-opacity:0.64]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{question}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{answer.sources.length > 0 ? (
|
{answer.sources.length > 0 ? (
|
||||||
<div
|
<AnswerSources
|
||||||
className={tcls(
|
hasAnswer={answer.hasAnswer}
|
||||||
'flex',
|
sources={answer.sources}
|
||||||
'flex-wrap',
|
language={language}
|
||||||
'gap-2',
|
/>
|
||||||
'mt-7',
|
|
||||||
'py-4',
|
|
||||||
'px-4',
|
|
||||||
'border-t',
|
|
||||||
'border-dark/2',
|
|
||||||
'dark:border-light/1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={tcls('text-sm')}>{t(language, 'search_ask_sources')}</span>
|
|
||||||
|
|
||||||
{answer.sources.map((source) => (
|
|
||||||
<span key={source.id} className={tcls()}>
|
|
||||||
<Link
|
|
||||||
onClick={onClose}
|
|
||||||
className={tcls(
|
|
||||||
'flex',
|
|
||||||
'text-sm',
|
|
||||||
'text-dark/7',
|
|
||||||
'hover:underline',
|
|
||||||
'focus-within:text-primary-700',
|
|
||||||
'dark:text-light/8',
|
|
||||||
)}
|
|
||||||
href={source.href}
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
<IconBox
|
|
||||||
className={tcls(
|
|
||||||
'stroke-dark/6',
|
|
||||||
'w-[15px]',
|
|
||||||
'h-[15px]',
|
|
||||||
'shrink-0',
|
|
||||||
'mt-0.5',
|
|
||||||
'mr-0.5',
|
|
||||||
'dark:stroke-light/6',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{source.title}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
|
||||||
|
const { followupQuestions } = props;
|
||||||
|
const getSearchLinkProps = useSearchLink();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={tcls('mt-7 mb-4', 'flex', 'flex-col', 'flex-wrap', 'gap-1')}>
|
||||||
|
{followupQuestions.map((question) => (
|
||||||
|
<Link
|
||||||
|
key={question}
|
||||||
|
className={tcls(
|
||||||
|
'text-sm',
|
||||||
|
'font-medium',
|
||||||
|
'inline-flex',
|
||||||
|
'items-start',
|
||||||
|
'gap-2',
|
||||||
|
'px-4',
|
||||||
|
'py-1',
|
||||||
|
'text-primary-500',
|
||||||
|
'focus-within:text-primary-700',
|
||||||
|
'hover:bg-primary/2',
|
||||||
|
'dark:text-primary-400',
|
||||||
|
'dark:hover:bg-primary-500/3',
|
||||||
|
)}
|
||||||
|
{...getSearchLinkProps({
|
||||||
|
query: question,
|
||||||
|
ask: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconSearch
|
||||||
|
className={tcls(
|
||||||
|
'w-[15px]',
|
||||||
|
'h-[15px]',
|
||||||
|
'shrink-0',
|
||||||
|
'mt-0.5',
|
||||||
|
'[&>path]:[stroke-opacity:0.64]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{question}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnswerSources(props: {
|
||||||
|
sources: AskAnswerSource[];
|
||||||
|
language: TranslationLanguage;
|
||||||
|
hasAnswer?: boolean;
|
||||||
|
}) {
|
||||||
|
const { sources, language, hasAnswer } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-wrap',
|
||||||
|
'gap-2',
|
||||||
|
'mt-7',
|
||||||
|
'py-4',
|
||||||
|
'px-4',
|
||||||
|
'border-t',
|
||||||
|
'border-dark/2',
|
||||||
|
'dark:border-light/1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={tcls('text-sm')}>
|
||||||
|
{t(language, hasAnswer ? 'search_ask_sources' : 'search_ask_sources_no_answer')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{sources.map((source) => (
|
||||||
|
<span key={source.id} className={tcls()}>
|
||||||
|
<Link
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'text-sm',
|
||||||
|
'text-dark/7',
|
||||||
|
'hover:underline',
|
||||||
|
'focus-within:text-primary-700',
|
||||||
|
'dark:text-light/8',
|
||||||
|
)}
|
||||||
|
href={source.href}
|
||||||
|
prefetch={false}
|
||||||
|
>
|
||||||
|
<IconBox
|
||||||
|
className={tcls(
|
||||||
|
'stroke-dark/6',
|
||||||
|
'w-[15px]',
|
||||||
|
'h-[15px]',
|
||||||
|
'shrink-0',
|
||||||
|
'mt-0.5',
|
||||||
|
'mr-0.5',
|
||||||
|
'dark:stroke-light/6',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{source.title}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -232,28 +232,23 @@ function SearchModalBody(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!state.ask || !withAsk ? (
|
{!state.ask || !withAsk ? (
|
||||||
<>
|
<SearchResults
|
||||||
<SearchResults
|
ref={resultsRef}
|
||||||
ref={resultsRef}
|
spaceId={spaceId}
|
||||||
spaceId={spaceId}
|
revisionId={revisionId}
|
||||||
revisionId={revisionId}
|
parent={state.global ? parent : null}
|
||||||
parent={state.global ? parent : null}
|
query={state.query}
|
||||||
query={state.query}
|
withAsk={withAsk}
|
||||||
withAsk={withAsk}
|
onSwitchToAsk={() => {
|
||||||
onSwitchToAsk={() => {
|
onChangeQuery({
|
||||||
onChangeQuery({
|
ask: true,
|
||||||
ask: true,
|
query: state.query,
|
||||||
query: state.query,
|
global: state.global,
|
||||||
global: state.global,
|
});
|
||||||
});
|
}}
|
||||||
}}
|
>
|
||||||
onClose={onClose}
|
{parent && state.query ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
||||||
>
|
</SearchResults>
|
||||||
{parent && state.query ? (
|
|
||||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
|
||||||
) : null}
|
|
||||||
</SearchResults>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{state.query && state.ask && withAsk ? (
|
{state.query && state.ask && withAsk ? (
|
||||||
<SearchAskAnswer spaceId={spaceId} query={state.query} />
|
<SearchAskAnswer spaceId={spaceId} query={state.query} />
|
||||||
|
|||||||
@@ -11,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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,36 +211,43 @@ function transformAnswer(
|
|||||||
.filter(filterOutNullable);
|
.filter(filterOutNullable);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body: (
|
body:
|
||||||
<DocumentView
|
answer.answer && 'document' in answer.answer ? (
|
||||||
document={answer.answer.document}
|
<DocumentView
|
||||||
context={{
|
document={answer.answer.document}
|
||||||
mode: 'default',
|
context={{
|
||||||
contentRefContext: null,
|
mode: 'default',
|
||||||
resolveContentRef: async () => null,
|
contentRefContext: null,
|
||||||
shouldHighlightCode: () => false,
|
resolveContentRef: async () => null,
|
||||||
}}
|
shouldHighlightCode: () => false,
|
||||||
style={['space-y-5']}
|
}}
|
||||||
/>
|
style={['space-y-5']}
|
||||||
),
|
/>
|
||||||
|
) : null,
|
||||||
followupQuestions: answer.followupQuestions,
|
followupQuestions: answer.followupQuestions,
|
||||||
sources,
|
sources,
|
||||||
|
hasAnswer: !!answer.answer && 'document' in answer.answer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 +1,2 @@
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
|
export * from './LogoIcon';
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: '次へ',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
};
|
||||||
@@ -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: '下一页',
|
||||||
|
|||||||
+72
-10
@@ -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(
|
||||||
signal: options.signal,
|
organizationId,
|
||||||
...noCacheFetchOptions,
|
siteId,
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
signal: options.signal,
|
||||||
|
...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(
|
||||||
...noCacheFetchOptions,
|
args.organizationId,
|
||||||
signal: options.signal,
|
args.siteId,
|
||||||
}),
|
{
|
||||||
|
...params,
|
||||||
|
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
signal: options.signal,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
data: response.data.items.map((siteSpace) => siteSpace),
|
data: response.data.items.map((siteSpace) => siteSpace),
|
||||||
tags: [getAPICacheTag({ tag: 'site', site: 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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+19
-1
@@ -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')]);
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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;
|
||||||
|
|||||||
Vendored
+5
-1
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+17
-13
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user