Compare commits

...

65 Commits

Author SHA1 Message Date
Valentino Hudhra 9ef655c946 Fix tracking page views when running inside an iframe 2024-07-15 16:04:03 +02:00
Samy Pessé b8c136e9c0 Fix render of one of when alternatives are primitives (#2378) 2024-07-15 10:15:42 +02:00
Samy Pessé 46d54172b0 Fix CI step to verify formatting (#2379) 2024-07-14 23:53:55 +02:00
Brett Jephson 68dc2de190 Remove Page/Space label from BlockContentRef (#2374) 2024-07-12 15:02:05 +01:00
Samy Pessé 81b372f372 Fix response example when response example is a string (#2377) 2024-07-12 10:35:22 +02:00
Scott Cazan 966e520640 Fix Source links in Ask AI not navigating to the page (#2375) 2024-07-11 15:39:28 +02:00
spastorelli 12702930ec Optimize SpacesDropdownMenuItem client side props (#2373) 2024-07-11 10:42:53 +02:00
spastorelli 6ce57bcda1 Fix issue where nav route/path is not retained when switching variants (#2372) 2024-07-10 17:10:19 +02:00
spastorelli d99b453bc4 Fix issue where published content crashes when localstorage is disabled (#2371) 2024-07-10 14:56:21 +02:00
Samy Pessé aaf5793cb7 Use schema.example if defined, even if value is falsy (#2370) 2024-07-05 16:34:48 +02:00
Samy Pessé 9b1762d2ab Fix invalid api response from carbon (#2369) 2024-07-05 15:55:35 +02:00
Samy Pessé 8d38d000fd Fix RND-4344 (#2368) 2024-07-04 17:04:35 +02:00
Scott Cazan 4cf9cdd56d Fix ToC not re-rendering when linking to a variant from SearchResults (#2366) 2024-07-03 12:22:08 +02:00
Scott Cazan 39406bbf54 Use published URL for site when displaying results for a Site space (#2364) 2024-07-02 16:14:39 +02:00
Scott Cazan 9c5c1e5b68 Show sources with no answer (#2363)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-06-28 15:25:01 +02:00
Johan Preynat 6929b0b98b Prevent SVG images to be resized and rendered on the /~gitbook/image endpoint (#2362) 2024-06-27 12:24:46 +02:00
Johan Preynat 7c26d491b1 Fix passing the correct nonce for iframely script and preload it (#2360) 2024-06-25 17:11:19 +02:00
Johan Preynat 97035a054a Tree shake unused code for Sentry (#2359) 2024-06-25 15:55:48 +02:00
Johan Preynat db45c6d773 Rework context to actually share memory cache between middleware and handlers for all requests (#2358) 2024-06-25 14:30:38 +02:00
Johan Preynat e3f5f81e62 Use fnv1a instead of SHA256 to create images signatures (#2353) 2024-06-24 13:31:18 +02:00
Johan Preynat f5df40e17d Use a singleton to store the memory cache in an internal WeakMap for the current request (#2356) 2024-06-24 11:13:03 +02:00
Johan Preynat e13c76efed Set preconnect links to main domains on render (#2354) 2024-06-21 17:02:02 +02:00
Johan Preynat 4a11d8d9eb Share memory cache between middleware and request handler (#2347) 2024-06-21 11:13:06 +02:00
spastorelli 009ed8cc6e Call trackViewInSiteById API endpoint when tracking view for sites (#2350) 2024-06-21 10:01:35 +02:00
vibhanshub 106cbbd987 Add support for examples in OpenAPI block (#2314) 2024-06-20 16:02:58 +05:30
Johan Preynat 56e5c3c3f8 Parse cache tags returned from the API set them in response (#2349) 2024-06-20 10:09:02 +02:00
Taran Vohra 1dc3234003 Fix page metadata title (#2348) 2024-06-18 20:52:12 +05:30
Johan Preynat 22dc0d4ff5 Fix passing revalidateBefore option when calling cacheResponse to ensure these go through background revalidation (#2342) 2024-06-17 17:16:12 +02:00
Taran Vohra 3a4caf062c Add getContentTitle to properly render the title for sites & legacy published content (#2343) 2024-06-17 20:28:21 +05:30
Taran Vohra 8ad0657159 Use site's share key as context while fetching site spaces (#2341) 2024-06-14 20:59:05 +05:30
Taran Vohra d8a4ecd0c6 Pass site url as context to listSiteSpaces API (#2339) 2024-06-14 10:40:18 +05:30
David Burghoff 65c7968438 Add German Locale support (#2317)
Co-authored-by: David Burghoff <david.burghoff@swot.de>
Co-authored-by: taranvohra <taranvohra@outlook.com>
2024-06-13 15:04:49 +02:00
Rodrigo Castro 3914f11150 Add Brazilian Portuguese translation (#2285)
Co-authored-by: taranvohra <taranvohra@outlook.com>
2024-06-13 14:54:47 +02:00
Addison d363908297 Add contributors to readme, bump API (#2333) 2024-06-12 15:24:18 +02:00
Greg Bergé 414556ac7a Remove cache client-side (#2337) 2024-06-10 11:17:48 +02:00
Steven H c768e9a90a Add another space to ignore list whilst we debug issues. (#2334) 2024-05-28 19:02:07 +01:00
Taran Vohra 553f1f228f Bump @gitbook/api to 0.48.0 (#2328) 2024-05-24 20:10:25 +05:30
Taran Vohra 30986528a7 bump @gitbook/api to 0.47 (#2327) 2024-05-24 19:07:54 +05:30
Steven H c70c2ddad8 Bump next-on-pages version to fix SSRF attack. (#2319) 2024-05-16 17:05:58 +01:00
Marc Laventure cf74d00758 chore: update scalar client (#2316) 2024-05-14 11:52:09 -07:00
Steven H c2256bfd14 Fix an issue where attackers could use the Nextjs image endpoint to redirect to any URL. (#2312) 2024-05-14 11:34:33 +01:00
fuyangpengqi 5d73a0f062 chore: fix some typos in README.md (#2290)
Signed-off-by: fuyangpengqi <995764973@qq.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-05-13 16:22:07 +02:00
Samy Pessé 8f5f6c61f8 Setup the pull_request workflow on actions (#2311) 2024-05-13 16:10:15 +02:00
Taran Vohra 9a86965ba1 Fix VA cookie to be only set if basePaths match (#2300) 2024-05-06 21:02:48 +05:30
Scott Cazan c8d5f825fc Update GitBook trademark (#2305) 2024-05-02 12:18:02 +02:00
Steven H 5db3cbc2ce Render Embed blocks that can be handled by an integration in the Space. (#2302) 2024-04-25 15:25:53 +01:00
Steven H f8d148bec4 Use plain code highlighting on some spaces that are currently crashing. (#2301) 2024-04-25 09:50:08 +01:00
Steven H 198dd9f158 Investigate an issue where images with 127.0.0.1 in the URL are not correctly resolved. (#2298) 2024-04-19 17:21:22 +01:00
Steven H 2b63bc158a Improve rendering of File column in tables. (#2297) 2024-04-19 16:58:46 +01:00
spastorelli 4a8097d968 Use site space context from token in multi-id mode when provided (#2295) 2024-04-19 16:51:12 +02:00
spastorelli f1440ca7ef Fix preview crashing when token do not include site data (#2292) 2024-04-18 17:36:16 +02:00
Taran Vohra 7ef2eeed13 fix site cache tag to exclude org id (#2291) 2024-04-18 20:28:39 +05:30
Taran Vohra 4005895337 Fix previews for sites in multi-id mode (#2289) 2024-04-18 18:19:52 +05:30
Steven H bf450e5cc7 Support GitHub-flavoured Markdown when parsing OpenAPI descriptions. (#2288) 2024-04-17 14:21:07 +01:00
Taran Vohra 4c12722c0a Use appropriate customization settings for sites (#2279) 2024-04-17 17:28:14 +05:30
Taran Vohra ac124a7ea5 Add e2e tests for site variants (#2286) 2024-04-16 20:59:58 +05:30
spastorelli 1de25056fa Fix space dropdown showing for single variant sites and update E2E tests (#2284) 2024-04-16 15:29:15 +02:00
Steven H bf3d2032ab Avoid crashing Cloudflare Worker when syntax highlighting too many lines of code. (#2283) 2024-04-15 11:57:22 +01:00
spastorelli bfb2342ab3 Add support for integration inject script for sites (#2277) 2024-04-11 10:28:24 +02:00
Samy Pessé 76cbc3c10f Print signature and url when having error with image resizing (#2275) 2024-04-09 11:22:36 +02:00
Taran Vohra c80c76d50d Override site's spaces published URLs (#2274) 2024-04-09 09:59:25 +05:30
Johan Preynat 263c89391e Add HSTS and other security headers (#2271) 2024-04-08 19:44:57 +02:00
Samy Pessé ebf742e81a Use a sampleRate of 0.1 for edge/node runtimes (#2273) 2024-04-08 15:05:01 +02:00
Samy Pessé 685317f508 Fix full-width for code-blocks and embeds (#2272) 2024-04-08 14:45:42 +02:00
Taran Vohra 0c224861e3 Sites support (#2262) 2024-04-08 17:06:22 +05:30
107 changed files with 2590 additions and 657 deletions
+13 -9
View File
@@ -1,5 +1,9 @@
on: [push]
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
deploy:
@@ -16,7 +20,7 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -78,7 +82,7 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
@@ -98,7 +102,7 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -117,12 +121,12 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format --check .
- run: bun format:check
lint:
runs-on: ubuntu-latest
name: Lint
@@ -132,7 +136,7 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -147,7 +151,7 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -162,7 +166,7 @@ jobs:
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+4 -1
View File
@@ -1 +1,4 @@
.next
.next
# Generated
packages/emoji-codepoints/index.ts
+8 -2
View File
@@ -75,7 +75,7 @@ GitBook's rendering engine is fully open-source and built on top of [Next.js](ht
### Types of contributions
We encourage you to contribute to GitBook to help us build the best tool for doucmenting techincal knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
We encourage you to contribute to GitBook to help us build the best tool for documenting technical knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
#### Translations
@@ -92,7 +92,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
>
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
>
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintainance, or updates to forked and self-hosted instances of this project.
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
>
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
@@ -119,6 +119,12 @@ GitBook wouldn't be possible without these projects:
- [Tailwind CSS](https://tailwindcss.com/)
- [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)
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
BIN
View File
Binary file not shown.
+160 -1
View File
@@ -40,6 +40,159 @@ async function waitForCookiesDialog(page: Page) {
}
const testCases: TestsCase[] = [
{
name: 'GitBook Site (Single Variant)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Home',
url: '',
run: waitForCookiesDialog,
},
{
name: 'No variants dropdown',
url: '',
run: async (page) => {
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
0,
);
},
},
{
name: 'Search',
url: '?q=',
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
{
name: 'Not found',
url: 'content-not-found',
run: waitForCookiesDialog,
},
],
},
{
name: 'GitBook Site (Multi Variants)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
tests: [
{
name: 'Variants dropdown',
url: '',
run: async (page) => {
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
await spaceDrowpdown.waitFor();
},
},
{
name: 'Default variant',
url: '',
},
{
name: 'RFC variant',
url: 'v/rfcs',
},
],
},
{
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',
);
},
},
{
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',
);
},
},
{
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',
);
},
},
],
},
{
name: 'GitBook',
baseUrl: 'https://docs.gitbook.com',
@@ -177,6 +330,11 @@ const testCases: TestsCase[] = [
url: 'blocks/lists',
fullPage: true,
},
{
name: 'Code',
url: 'blocks/code',
fullPage: true,
},
{
name: 'Cards',
url: 'blocks/cards',
@@ -189,6 +347,7 @@ const testCases: TestsCase[] = [
{
name: 'Embeds',
url: 'blocks/embeds',
fullPage: true,
},
{
name: 'Annotations',
@@ -476,7 +635,7 @@ for (const testCase of testCases) {
}
if (testEntry.screenshot !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-13', 'iphone-x', 'ipad-2'],
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
+25 -1
View File
@@ -9,7 +9,7 @@ module.exports = withSentryConfig(
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
},
webpack(config) {
webpack(config, { dev, webpack }) {
config.resolve.fallback = {
...config.resolve.fallback,
@@ -19,6 +19,21 @@ module.exports = withSentryConfig(
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;
},
@@ -39,6 +54,15 @@ module.exports = withSentryConfig(
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
},
{
silent: true,
+6 -4
View File
@@ -11,7 +11,7 @@
"format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different",
"typecheck": "tsc --noEmit",
"unit": "bun test {src,packages}/**/*.test.ts",
"unit": "bun test {src,packages}",
"e2e": "playwright test",
"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"
},
@@ -20,10 +20,11 @@
],
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.39.0",
"@gitbook/api": "^0.53.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1",
@@ -40,7 +41,7 @@
"memoizee": "^0.4.15",
"next": "^14.1.3",
"next-themes": "^0.2.1",
"nuqs": "^1.15.4",
"nuqs": "^1.17.4",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
@@ -51,6 +52,7 @@
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
@@ -63,7 +65,7 @@
},
"devDependencies": {
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "^1.9.0",
"@cloudflare/next-on-pages": "^1.11.3",
"@cloudflare/workers-types": "^4.20231218.0",
"@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6",
+1 -1
View File
@@ -3,7 +3,7 @@
"exports": "./src/index.ts",
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "^0.36.0",
"@gitbook/api": "^0.51.0",
"assert-never": "^1.2.1"
},
"peerDependencies": {
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "@gitbook/react-openapi",
"exports": "./src/index.ts",
"dependencies": {
"@scalar/api-client-react": "^0.2.9",
"@scalar/oas-utils": "0.1.1",
"@scalar/api-client-react": "^0.3.7",
"@scalar/oas-utils": "0.1.6",
"classnames": "^2.5.1",
"flatted": "^3.2.9",
"openapi-types": "^12.1.3",
@@ -62,7 +62,10 @@ export function OpenAPIResponseExample(props: {
return (
<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>
);
}
+17 -2
View File
@@ -47,6 +47,13 @@ export function OpenAPISchemaProperty(
? null
: 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 (
<InteractiveSection
id={id}
@@ -92,6 +99,11 @@ export function OpenAPISchemaProperty(
className="openapi-schema-description"
/>
) : null}
{shouldDisplayExample(schema) ? (
<span className="openapi-schema-example">
Example: <code>{JSON.stringify(schema.example)}</code>
</span>
) : null}
</div>
}
>
@@ -170,6 +182,8 @@ export function OpenAPIRootSchema(props: {
/**
* Render a tab for an alternative schema.
* It renders directly the properties if relevant;
* for primitives, it renders the schema itself.
*/
function OpenAPISchemaAlternative(props: {
schema: OpenAPIV3.SchemaObject;
@@ -178,12 +192,13 @@ function OpenAPISchemaAlternative(props: {
}) {
const { schema, circularRefs, context } = props;
const id = useId();
const subProperties = getSchemaProperties(schema);
return (
<OpenAPISchemaProperties
id={id}
properties={getSchemaProperties(schema) ?? []}
circularRefs={new Map(circularRefs).set(schema, id)}
properties={subProperties ?? [{ schema }]}
circularRefs={subProperties ? new Map(circularRefs).set(schema, id) : circularRefs}
context={context}
/>
);
@@ -48,6 +48,7 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext
// Description of the parameter is defined at the parameter level
// we use display it if the schema doesn't override it
description: parameter.description,
example: parameter.example,
...(noReference(parameter.schema) ?? {}),
},
required: parameter.required,
+10 -1
View File
@@ -7,6 +7,7 @@ import {
getRequestFromOperation,
Query,
Header,
RequestBody,
} from '@scalar/oas-utils';
import React from 'react';
@@ -98,7 +99,15 @@ export function ScalarApiClient(props: { children: React.ReactNode }) {
{
url: operationData.path,
},
getRequestFromOperation(operation, { requiredOnly: false }),
getRequestFromOperation(
{
...operation,
information: {
requestBody: operationData.operation.requestBody as RequestBody,
},
},
{ requiredOnly: false },
),
);
return {
@@ -19,7 +19,7 @@ export function generateSchemaExample(
return undefined;
}
if (schema.example) {
if (typeof schema.example !== 'undefined') {
return schema.example;
}
+14 -2
View File
@@ -1,8 +1,15 @@
import * as Sentry from '@sentry/nextjs';
import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
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,
dsn,
integrations: [],
@@ -11,5 +18,10 @@ if (dsn) {
beforeSendTransaction: () => {
return null;
},
transport: makeFetchTransport,
stackParser: defaultStackParser,
});
getCurrentScope().setClient(client);
client.init();
}
+4 -2
View File
@@ -1,11 +1,13 @@
import * as Sentry from '@sentry/nextjs';
import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
init({
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
+4 -2
View File
@@ -1,11 +1,13 @@
import * as Sentry from '@sentry/nextjs';
import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
init({
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
+22 -5
View File
@@ -1,6 +1,12 @@
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';
export const runtime = 'edge';
@@ -10,21 +16,32 @@ export const runtime = 'edge';
* Fetch and resize an image.
*/
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url');
let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign');
if (!url || !signature) {
// 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) {
return new Response('Missing url/sign parameters', { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Prevent infinite loops
if (url.includes('/~gitbook/image')) {
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
const verified = await verifyImageSignature(url, signature);
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
if (!verified) {
return new Response('Invalid signature', { status: 400 });
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
}
// Cloudflare-specific options are in the cf object.
@@ -1,11 +1,14 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getSpaceLayoutData } from '@/lib/api';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { getContentPointer } from '../../fetch';
export default async function NotFound() {
const { customization } = await getSpaceLayoutData(getContentPointer().spaceId);
const pointer = getContentPointer();
const { customization } = await ('siteId' in pointer
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const language = getSpaceLanguage(customization);
@@ -9,6 +9,7 @@ import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { PageClientLayout } from './PageClientLayout';
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
@@ -22,8 +23,16 @@ export default async function Page(props: { params: PagePathParams }) {
const { params } = props;
const rawPathname = getPathnameParam(params);
const { contentTarget, space, customization, pages, page, document } =
await fetchPageData(params);
const {
content: contentPointer,
contentTarget,
space,
parent,
customization,
pages,
page,
document,
} = await fetchPageData(params);
const linksContext: PageHrefContext = {};
if (!page) {
@@ -62,6 +71,7 @@ export default async function Page(props: { params: PagePathParams }) {
<div className={tcls('flex', 'flex-row')}>
<PageBody
space={space}
contentPointer={contentPointer}
contentTarget={contentTarget}
customization={customization}
context={contentRefContext}
@@ -75,6 +85,7 @@ export default async function Page(props: { params: PagePathParams }) {
{page.layout.outline ? (
<PageAside
space={space}
site={parent?.object === 'site' ? parent : undefined}
customization={customization}
page={page}
document={document}
@@ -104,13 +115,13 @@ export async function generateViewport({ params }: { params: PagePathParams }):
}
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
const { space, pages, page, customization, collection } = await fetchPageData(params);
const { space, pages, page, customization, parent } = await fetchPageData(params);
if (!page) {
notFound();
}
return {
title: [page.title, customization.title ?? space.title, collection?.title]
title: [page.title, getContentTitle(space, customization, parent)]
.filter(Boolean)
.join(' | '),
description: page.description ?? '',
+15 -7
View File
@@ -8,10 +8,13 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { absoluteHref, baseUrl } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
import { ClientContexts } from './ClientContexts';
import { RocketLoaderDetector } from './RocketLoaderDetector';
@@ -32,12 +35,17 @@ export default async function ContentLayout(props: { children: React.ReactNode }
contentTarget,
customization,
pages,
collection,
collectionSpaces,
parent,
spaces,
ancestors,
scripts,
} = await fetchSpaceData();
ReactDOM.preconnect(api().endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
scripts.forEach(({ script }) => {
ReactDOM.preload(script, {
as: 'script',
@@ -56,8 +64,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
<SpaceLayout
space={space}
contentTarget={contentTarget}
collection={collection}
collectionSpaces={collectionSpaces}
parent={parent}
spaces={spaces}
customization={customization}
pages={pages}
ancestors={ancestors}
@@ -100,11 +108,11 @@ export async function generateViewport(): Promise<Viewport> {
}
export async function generateMetadata(): Promise<Metadata> {
const { space, collection, customization } = await fetchSpaceData();
const { space, parent, customization } = await fetchSpaceData();
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return {
title: `${collection ? collection.title : customization.title ?? space.title}`,
title: getContentTitle(space, customization, parent),
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()),
icons: {
@@ -125,7 +133,7 @@ export async function generateMetadata(): Promise<Metadata> {
},
],
},
robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow',
robots: shouldIndexSpace({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
};
}
+10 -7
View File
@@ -1,7 +1,7 @@
import { ContentVisibility } from '@gitbook/api';
import { NextRequest } from 'next/server';
import { getCollection, getSpace } from '@/lib/api';
import { getCollection, getSite, getSpace } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo';
@@ -13,16 +13,19 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
const space = await getSpace(getContentPointer().spaceId);
const collection =
space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const pointer = getContentPointer();
const space = await getSpace(pointer.spaceId);
const parent =
'siteId' in pointer
? await getSite(pointer.organizationId, pointer.siteId)
: space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...(shouldIndexSpace({ space, collection })
...(shouldIndexSpace({ space, parent })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
+18 -8
View File
@@ -5,9 +5,16 @@ import { NextRequest } from 'next/server';
import React from 'react';
import { getContentPointer } from '@/app/(space)/fetch';
import { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
import {
getCollection,
getCurrentSiteCustomization,
getSite,
getSpace,
getSpaceCustomization,
} from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
export const runtime = 'edge';
@@ -35,17 +42,20 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const spaceId = getContentPointer().spaceId;
const pointer = getContentPointer();
const spaceId = pointer.spaceId;
const [space, customization] = await Promise.all([
getSpace(spaceId),
getSpaceCustomization(spaceId),
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
]);
const collection =
space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const contentTitle = collection?.title ?? customization.title ?? space.title;
const parent =
'siteId' in pointer
? await getSite(pointer.organizationId, pointer.siteId)
: space.visibility === ContentVisibility.InCollection && space.parent
? await getCollection(space.parent)
: null;
const contentTitle = getContentTitle(space, customization, parent);
return new ImageResponse(
(
@@ -3,6 +3,8 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
import { getContentTitle } from '@/lib/utils';
import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge';
@@ -11,7 +13,7 @@ export const runtime = 'edge';
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, collection } = await fetchPageData(params);
const { space, page, customization, parent } = await fetchPageData(params);
const url = new URL(space.urls.published ?? space.urls.app);
if (customization.socialPreview.url) {
@@ -31,7 +33,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
}}
>
<h2 tw="text-7xl font-bold tracking-tight text-left">
{collection?.title ?? customization.title ?? space.title}
{getContentTitle(space, customization, parent)}
</h2>
<div tw="flex flex-1">
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
+21 -5
View File
@@ -4,6 +4,7 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { Metadata } from 'next';
@@ -11,12 +12,18 @@ import { notFound } from 'next/navigation';
import * as React from 'react';
import { getContentPointer } from '@/app/(space)/fetch';
import { DocumentView } from '@/components/DocumentView';
import { DocumentView, createHighlightingContext } from '@/components/DocumentView';
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
import { PolymorphicComponentProp } from '@/components/utils/types';
import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate';
import { getDocument, getSpace, getSpaceCustomization, getSpaceContentData } from '@/lib/api';
import {
getDocument,
getSpace,
getSpaceCustomization,
getSpaceContentData,
getCurrentSiteCustomization,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -35,7 +42,9 @@ export async function generateMetadata(): Promise<Metadata> {
const contentPointer = getContentPointer();
const [space, customization] = await Promise.all([
getSpace(contentPointer.spaceId),
getSpaceCustomization(contentPointer.spaceId),
'siteId' in contentPointer
? getCurrentSiteCustomization(contentPointer)
: getSpaceCustomization(contentPointer.spaceId),
]);
return {
@@ -59,7 +68,9 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
// Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
getSpaceCustomization(contentPointer.spaceId),
'siteId' in contentPointer
? getCurrentSiteCustomization(contentPointer)
: getSpaceCustomization(contentPointer.spaceId),
getSpaceContentData(contentPointer),
]);
const language = getSpaceLanguage(customization);
@@ -172,7 +183,10 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
);
}
async function PDFSpaceIntro(props: { space: Space; customization: CustomizationSettings }) {
async function PDFSpaceIntro(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, customization } = props;
return (
@@ -216,6 +230,7 @@ async function PDFPageDocument(props: {
const { space, page, refContext } = props;
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
const shouldHighlightCode = createHighlightingContext();
return (
<PrintPage id={pagePDFContainerId(page)}>
@@ -238,6 +253,7 @@ async function PDFPageDocument(props: {
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
shouldHighlightCode,
}}
/>
) : null}
+2 -2
View File
@@ -1,6 +1,6 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { captureException } from '@sentry/nextjs';
import React from 'react';
import { Button } from '@/components/primitives/Button';
@@ -15,7 +15,7 @@ export default function ErrorPage(props: {
const language = useLanguage();
React.useEffect(() => {
Sentry.captureException(error);
captureException(error);
}, [error]);
return (
+86 -16
View File
@@ -9,6 +9,10 @@ import {
getDocument,
getSpaceData,
ContentTarget,
SiteContentPointer,
getCurrentSiteData,
getSite,
getSiteSpaces,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
@@ -23,7 +27,7 @@ export interface PageIdParams {
/**
* Get the current content pointer from the params.
*/
export function getContentPointer() {
export function getContentPointer(): ContentPointer | SiteContentPointer {
const headerSet = headers();
const spaceId = headerSet.get('x-gitbook-content-space');
if (!spaceId) {
@@ -32,13 +36,33 @@ export function getContentPointer() {
);
}
const content: ContentPointer = {
spaceId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
const siteId = headerSet.get('x-gitbook-content-site');
if (siteId) {
const organizationId = headerSet.get('x-gitbook-content-organization');
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
if (!organizationId) {
throw new Error('Missing site content headers');
}
return content;
const siteContent: SiteContentPointer = {
siteId,
spaceId,
siteSpaceId: siteSpaceId ?? undefined,
siteShareKey: siteShareKey ?? undefined,
organizationId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
return siteContent;
} else {
const content: ContentPointer = {
spaceId,
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
};
return content;
}
}
/**
@@ -46,8 +70,21 @@ export function getContentPointer() {
*/
export async function fetchSpaceData() {
const content = getContentPointer();
const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content);
const collection = await fetchParentCollection(space);
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
'siteId' in content
? [
getCurrentSiteData(content),
fetchParentSite({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
]
: [getSpaceData(content)],
);
const parent = await (parentSite ?? fetchParentCollection(space));
return {
content,
@@ -57,7 +94,7 @@ export async function fetchSpaceData() {
customization,
scripts,
ancestors: [],
...collection,
...parent,
};
}
@@ -67,11 +104,19 @@ export async function fetchSpaceData() {
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getContentPointer();
const { space, contentTarget, pages, customization, scripts } = await getSpaceData(content);
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
? getCurrentSiteData(content)
: getSpaceData(content));
const page = await resolvePage(contentTarget, pages, params);
const [collection, document] = await Promise.all([
fetchParentCollection(space),
const [parent, document] = await Promise.all([
'siteId' in content
? fetchParentSite({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
})
: fetchParentCollection(space),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
@@ -84,7 +129,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
scripts,
ancestors: [],
...page,
...collection,
...parent,
document,
};
}
@@ -133,12 +178,37 @@ async function resolvePage(
async function fetchParentCollection(space: Space) {
const parentCollectionId =
space.visibility === ContentVisibility.InCollection ? space.parent : undefined;
const [collection, collectionSpaces] = await Promise.all([
const [collection, spaces] = await Promise.all([
parentCollectionId ? getCollection(parentCollectionId) : null,
parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]),
]);
return { collection, collectionSpaces };
return { parent: collection, spaces };
}
async function fetchParentSite(args: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [site, siteSpaces] = await Promise.all([
getSite(organizationId, siteId),
getSiteSpaces({ organizationId, siteId, siteShareKey }),
]);
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return { parent: site, spaces: Object.values(spaces) };
}
/**
+7 -3
View File
@@ -3,6 +3,7 @@ import {
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationSettings,
SiteCustomizationSettings,
} from '@gitbook/api';
import assertNever from 'assert-never';
import colors from 'tailwindcss/colors';
@@ -10,7 +11,7 @@ import colors from 'tailwindcss/colors';
import { emojiFontClassName } from '@/components/primitives';
import { fonts, ibmPlexMono } from '@/fonts';
import { getSpaceLanguage } from '@/intl/server';
import { getSpaceLayoutData } from '@/lib/api';
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
import { hexToRgb, shadesOfColor } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
@@ -25,7 +26,10 @@ import { getContentPointer } from './fetch';
export default async function SpaceRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const { customization } = await getSpaceLayoutData(getContentPointer().spaceId);
const pointer = getContentPointer();
const { customization } = await ('siteId' in pointer
? getCurrentSiteLayoutData(pointer)
: getSpaceLayoutData(pointer.spaceId));
const headerTheme = generateHeaderTheme(customization);
const language = getSpaceLanguage(customization);
@@ -120,7 +124,7 @@ function generateColorVariable(name: string, color: ColorInput) {
.join('\n');
}
function generateHeaderTheme(customization: CustomizationSettings): {
function generateHeaderTheme(customization: CustomizationSettings | SiteCustomizationSettings): {
backgroundColor: { light: ColorInput; dark: ColorInput };
linkColor: { light: ColorInput; dark: ColorInput };
} {
+2 -2
View File
@@ -1,12 +1,12 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { captureException } from '@sentry/nextjs';
import Error from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error }) {
useEffect(() => {
Sentry.captureException(error);
captureException(error);
}, [error]);
return (
+200
View File
@@ -0,0 +1,200 @@
'use client';
import IconHeart from '@geist-ui/icons/heart';
import * as React from 'react';
import { ClassValue, tcls } from '@/lib/tailwind';
import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering';
import { AdItem, AdsResponse } from './types';
/**
* Fetch and render the Ad placement.
* https://docs.buysellads.com/ad-serving-api
*/
export function Ad({
zoneId,
spaceId,
placement,
ignore,
style,
mode = 'auto',
}: {
zoneId: string;
spaceId: string;
placement: string;
ignore: boolean;
style?: ClassValue;
mode?: 'classic' | 'auto' | 'cover';
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [failed, setFailed] = React.useState(false);
const [ad, setAd] = React.useState<AdItem | undefined>(undefined);
// Observe the container visibility
React.useEffect(() => {
if (!containerRef.current) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
}
},
{
root: null,
rootMargin: '0px',
threshold: 0.1,
},
);
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, []);
// When the container is visible,
// track an impression on the ad and fetch it
React.useEffect(() => {
if (!visible) {
return;
}
let cancelled = false;
(async () => {
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
url.searchParams.set('v', 'true');
if (ignore) {
url.searchParams.set('ignore', 'true');
}
try {
const res = await fetch(url);
const json: AdsResponse = await res.json();
if (cancelled) {
return;
}
const first = json.ads[0];
if (first && 'active' in first) {
setAd(first);
}
} catch (error) {
console.error(
'Failed to fetch ad, it might have been blocked by a ad-blocker',
error,
);
setFailed(true);
}
})();
return () => {
cancelled = true;
};
}, [visible, zoneId, ignore, placement]);
const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content');
viaUrl.searchParams.set('utm_medium', 'ads');
viaUrl.searchParams.set('utm_campaign', spaceId);
return (
<div ref={containerRef} className={tcls(style)}>
{ad && ad.description && ad.statlink ? (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
<p
className={tcls(
'mt-2',
'mr-2',
'text-xs',
'text-right',
'text-dark/5',
'dark:text-light/5',
)}
>
<a
target="_blank"
href={viaUrl.toString()}
className={tcls('hover:underline')}
>
Ads via GitBook
</a>
</p>
</>
) : failed ? (
<AdBlockerPlaceholder />
) : null}
</div>
);
}
/**
* Render attribution or verification pixels.
* https://docs.buysellads.com/ad-serving-api#pixels
*/
function AdPixels({ rawPixel }: { rawPixel: string }) {
const pixels = rawPixel.split('||');
const time = String(Math.round(Date.now() / 1e4) | 0);
return (
<div className={tcls('hidden')}>
{pixels.map((pixel, index) => {
return (
<img
key={index}
src={pixel.replace('[timestamp]', time)}
width="1"
height="1"
style={{ display: 'none' }}
alt="Ads tracking pixel"
/>
);
})}
</div>
);
}
/**
* Placeholder when visitor has an ad-blocker.
*/
function AdBlockerPlaceholder() {
return (
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'rounded-lg',
'p-4',
)}
>
<div className={tcls('flex', 'flex-row', 'gap-2', 'items-center')}>
<IconHeart className={tcls('w-4', 'h-4', 'text-primary-500')} />
<p className={tcls('text-xs', 'font-semibold')}>Ad disabled</p>
</div>
<p className={tcls('text-xs')}>
{`It looks like you're using an adblocker. Whitelist this site to help support this
project.`}
</p>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react';
import { tcls } from '@/lib/tailwind';
import { AdItem } from './types';
/**
* Classic rendering for an ad.
*/
export function AdClassicRendering({ ad }: { ad: AdItem }) {
return (
<a
className={tcls(
'flex',
'flex-col',
'gap-4',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg',
'p-4',
)}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
{'smallImage' in ad ? (
<div>
<img alt="Ads logo" className={tcls('rounded-md')} src={ad.smallImage} />
</div>
) : (
<div
className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }}
>
<img alt="Ads logo" src={ad.logo} />
</div>
)}
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
</a>
);
}
+108
View File
@@ -0,0 +1,108 @@
import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import { AdCover } from './types';
/**
* Cover rendering for an ad.
*/
export function AdCoverRendering({ ad }: { ad: AdCover }) {
return (
<a
className={tcls(
'group/ad',
'relative',
'flex',
'flex-col',
'gap-4',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg',
'p-4',
'overflow-hidden',
'shadow-sm',
)}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
<div
className={tcls(
'absolute',
'inset-0',
'bg-center',
'bg-cover',
'bg-no-repeat',
'z-0',
)}
style={{
backgroundImage: `url(${ad.largeImage})`,
}}
/>
<div className={tcls('z-[2]')}>
<img
alt="Large image"
src={ad.largeImage}
className={tcls(
'rounded-md',
'shadow-md',
'max-h-32',
'group-hover/ad:max-h-16',
'transition-all',
)}
/>
</div>
<div className={tcls('z-[2]')}>
<img alt={ad.company} src={ad.logo} className={tcls('max-w-36', 'max-h-12')} />
</div>
<div className={tcls('flex', 'flex-col', 'z-[2]')}>
<div className={tcls('text-sm', 'font-semibold', 'mb-2')}>{ad.companyTagline}</div>
<div
className={tcls(
'text-xs',
'h-0',
'opacity-0',
'group-hover/ad:h-16',
'group-hover/ad:opacity-10',
'transition-all',
)}
>
{ad.description}
</div>
</div>
<div className={tcls('z-[2]')}>
<span
className={tcls(
'text-sm',
'font-semibold',
'shadow-lg',
'rounded-md',
'bg-white',
'py-2',
'px-4',
)}
style={{
backgroundColor: ad.ctaBackgroundColor,
color: ad.ctaTextColor ?? ad.backgroundColor,
}}
>
{ad.callToAction}
</span>
</div>
<div
className={tcls('absolute', 'inset-0', 'backdrop-blur', 'z-[1]')}
style={{
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}}
/>
</a>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './Ad';
+51
View File
@@ -0,0 +1,51 @@
export interface AdGeneric {
active: string;
ad_via_link: string;
bannerid: string;
creativeid: string;
evenodd: string;
external_id: string;
height: string;
i: string;
identifier: string;
longimp: string;
longlink: string;
num_slots: string;
statimp: string;
statlink: string;
timestamp: string;
width: string;
zoneid: string;
zonekey: string;
rendering: 'carbon';
pixel?: string;
}
export interface AdClassic extends AdGeneric {
description: string;
smallImage: string;
}
export interface AdCover extends AdGeneric {
backgroundColor: string;
backgroundHoverColor?: string;
textColor?: string;
textColorHover?: string;
callToAction: string;
company: string;
companyTagline: string;
description: string;
largeImage: string;
image?: string;
logo: string;
ctaBackgroundColor?: string;
ctaBackgroundHoverColor?: string;
ctaTextColor?: string;
ctaTextColorHover?: string;
}
export type AdItem = AdClassic | AdCover;
export interface AdsResponse {
ads: Array<AdItem | {}>;
}
+3
View File
@@ -76,6 +76,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
</p>
<button
onClick={() => setShow(false)}
aria-label={tString(language, 'cookies_close')}
className={tcls(
'absolute',
'top-3',
@@ -97,6 +98,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
variant="primary"
size="small"
aria-label={tString(language, 'cookies_accept')}
onClick={() => {
onUpdateState(true);
}}
@@ -106,6 +108,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
variant="secondary"
size="small"
aria-label={tString(language, 'cookies_reject')}
onClick={() => {
onUpdateState(false);
}}
@@ -1,25 +1,65 @@
import { DocumentBlockContentRef } from '@gitbook/api';
import { LogoIcon } from '@/components/icons';
import { Card, Emoji } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references';
import { BlockProps } from './Block';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props;
const kind = block?.data?.ref?.kind;
const resolved = await context.resolveContentRef(block.data.ref, {
resolveAnchorText: true,
});
if (!resolved) {
return null;
}
const kind = block?.data?.ref?.kind;
if (resolved.active && kind === 'space') {
return <SpaceRefCard {...props} resolved={resolved} />;
}
return (
<Card
leadingIcon={resolved.emoji ? <Emoji code={resolved.emoji} /> : null}
leadingIcon={resolved.emoji ? <Emoji code={resolved.emoji} style="text-xl" /> : null}
href={resolved.href}
title={resolved.text}
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}
preTitle={kind}
title={resolved.text}
style={style}
/>
+9 -1
View File
@@ -34,7 +34,15 @@ export function Blocks<T extends DocumentBlock, Tag extends React.ElementType =
<Block
key={node.key}
block={node}
style={['max-w-3xl', 'w-full', 'mx-auto', 'decoration-primary/6', blockStyle]}
style={[
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
'w-full',
'mx-auto',
'decoration-primary/6',
blockStyle,
]}
{...contextProps}
/>
))}
@@ -3,7 +3,7 @@ import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import { highlight, HighlightLine, HighlightToken } from './highlight';
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
import { BlockProps } from '../Block';
import { DocumentContext } from '../DocumentView';
import { Inline } from '../Inline';
@@ -15,16 +15,14 @@ import './theme.css';
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const lines = await highlight(block);
const withHighlighting = context.shouldHighlightCode(context.content?.spaceId);
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const fullWidth = block.data.fullWidth;
const fullWidthStyle = fullWidth ? 'max-w-4xl' : 'max-w-3xl';
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
@@ -32,7 +30,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', fullWidthStyle, style)}>
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div
className={tcls(
'flex',
@@ -50,6 +50,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => true,
}}
block={block}
ancestorBlocks={[]}
@@ -0,0 +1,30 @@
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
/**
* Spaces where we'll always use plain highlighting.
*/
const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL',
'ryjzVNizLfd6pYogN3dm',
];
/**
* Protect against memory issues when highlighting a large number of code blocks.
* This context only allows 50 code blocks per render to be highlighted.
* Once highlighting can scale up to a large number of code blocks, it can be removed.
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
export function createHighlightingContext() {
let count = 0;
return (spaceId?: string | undefined) => {
if (!spaceId || PLAIN_HIGHLIGHTING_SPACES.includes(spaceId)) {
return false;
}
count += 1;
return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
};
}
@@ -1,10 +1,12 @@
import { DocumentBlockCode, DocumentBlockCodeLine, DocumentInlineAnnotation } from '@gitbook/api';
import {
loadWasm,
bundledLanguages,
ThemedToken,
getHighlighter,
createCssVariablesTheme,
HighlighterGeneric,
bundledLanguages,
bundledThemes,
} from 'shiki';
// @ts-ignore - onigWasm is a Wasm module
import onigWasm from 'shiki/onig.wasm?module';
@@ -13,6 +15,8 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
import { trace } from '@/lib/tracing';
import { DocumentContext } from '../DocumentView';
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -45,12 +49,14 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
});
const highlighter = await loadHighlighter();
await loadHighlighterLanguage(langName);
await loadHighlighterLanguage(highlighter, langName);
const lines = highlighter.codeToTokensBase(code, {
lang: langName,
tokenizeMaxLineLength: 120,
});
let currentIndex = 0;
let currentIndex = 0;
return lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
@@ -315,9 +321,15 @@ const loadHighlighter = singleton(async () => {
});
const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage(lang: keyof typeof bundledLanguages) {
async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<keyof typeof bundledLanguages, keyof typeof bundledThemes>,
lang: keyof typeof bundledLanguages,
) {
await loadLanguagesMutex.runBlocking(async () => {
const highlighter = await loadHighlighter();
if (highlighter.getLoadedLanguages().includes(lang)) {
return;
}
await trace(
`highlighting.loadLanguage(${lang})`,
async () => await highlighter.loadLanguage(lang),
@@ -1,2 +1,3 @@
export * from './CodeBlock';
export * from './PlainCodeBlock';
export * from './createHighlightingContext';
@@ -36,6 +36,16 @@ export interface DocumentContext {
* Transform an ID to be added to the DOM.
*/
getId?: (id: string) => string;
/**
* Returns true if the given code block should be highlighted.
* This function was added to protect against memory issues when highlighting
* a large number of code blocks.
* Once highlighting can scale up to a large number of code blocks, it can be removed.
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
shouldHighlightCode: (spaceId: string | undefined) => boolean;
}
export interface DocumentContextProps {
+52 -11
View File
@@ -1,26 +1,42 @@
import { DocumentBlockEmbed } from '@gitbook/api';
import * as gitbookAPI from '@gitbook/api';
import { headers } from 'next/headers';
import Script from 'next/script';
import ReactDOM from 'react-dom';
import { Card } from '@/components/primitives';
import { api } from '@/lib/api';
import { getNodeFragmentByName, isNodeEmpty } from '@/lib/document';
import { ClassValue, tcls } from '@/lib/tailwind';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { Inlines } from './Inlines';
import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
const { block } = props;
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props;
const nonce = headers().get('x-nonce') || undefined;
const { data: embed } = await api().urls.getEmbedByUrl({ url: block.data.url });
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
const { data: embed } = await (context.content
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
: api().urls.getEmbedByUrl({ url: block.data.url }));
return (
<Caption {...props}>
{embed.type === 'rich' ? (
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
<>
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
/>
<Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
{...otherProps}
context={context}
block={createIntegrationBlock(block.data.url, embed.integration, embed.block)}
/>
) : (
<Card
@@ -37,3 +53,28 @@ export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
</Caption>
);
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
function createIntegrationBlock(
url: string,
integration: string,
block: gitbookAPI.IntegrationBlock,
): gitbookAPI.DocumentBlockIntegration {
return {
object: 'block',
type: 'integration',
isVoid: true,
data: {
integration,
block: block.id,
props: {},
action: {
action: '@link.unfurl',
url,
},
url,
},
};
}
+6 -41
View File
@@ -1,12 +1,10 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { DocumentBlockFile } from '@gitbook/api';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context, style } = props;
@@ -18,42 +16,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return null;
}
const contentType = (() => {
switch (file.contentType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'image/png':
case 'image/jpeg':
case 'image/gif':
case 'image/webp':
case 'image/tiff':
case 'image/svg+xml':
return 'image';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
})();
const icon = (() => {
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
})();
const contentType = getSimplifiedContentType(file.contentType);
return (
<a
@@ -103,7 +66,9 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'dark:border-light/2',
)}
>
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>{icon}</div>
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>
<FileIcon contentType={contentType} />
</div>
<div
className={tcls(
'text-xs',
+24
View File
@@ -0,0 +1,24 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { SimplifiedFileType } from '@/lib/files';
/**
* Render an appropriate icon for a file.
*/
export function FileIcon(props: { contentType: SimplifiedFileType | null }) {
const { contentType } = props;
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
}
-1
View File
@@ -31,7 +31,6 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
align === 'right' && 'justify-end',
align === 'left' && 'justify-start',
isMultipleImages && ['grid', 'grid-flow-col', 'max-w-none'],
block.data.fullWidth ? 'max-w-screen-2xl' : null,
)}
>
{block.nodes.map((node: any, i: number) => (
@@ -70,6 +70,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const initialInput = {
componentId: block.data.block,
props: block.data.props,
action: block.data.action,
context: contentKitContext,
};
+97 -73
View File
@@ -1,115 +1,139 @@
.light .scalar-modal-layout,
.light .scalar {
--theme-color-1: color-mix(
--scalar-color-1: color-mix(
in srgb,
rgb(var(--primary-base-300, 180 180 180)),
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));
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
--theme-color-accent: #007d9c;
--scalar-color-2: color-mix(
in srgb,
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));
--theme-background-2: color-mix(
--scalar-background-1: rgb(var(--light-base, 255 255 255));
--scalar-background-2: color-mix(
in srgb,
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,
rgb(var(--primary-base-800, 30 30 30)),
var(--theme-background-1) 90%
var(--scalar-background-1) 90%
);
--theme-background-accent: #007d9c1f;
--theme-code-language-color-supersede: var(--theme-color-1);
--theme-code-languages-background-supersede: var(--theme-background-1);
--theme-border-color: color-mix(
--scalar-background-accent: #007d9c1f;
--scalar-code-language-color-supersede: var(--scalar-color-1);
--scalar-code-languages-background-supersede: var(--scalar-background-1);
--scalar-border-color: color-mix(
in srgb,
var(--theme-color-1),
var(--scalar-color-1),
transparent calc(100% - 100% * 0.08)
);
--theme-color-green: #0a6355;
--theme-color-red: #dc1b19;
--theme-color-yellow: #ffc90d;
--theme-color-blue: rgb(var(--primary-color-500, 52 109 219));
--theme-color-orange: #ff8d4d;
--theme-color-purple: #8250df;
--scalar-color-green: #0a6355;
--scalar-color-red: #dc1b19;
--scalar-color-yellow: #ffc90d;
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
--scalar-color-orange: #ff8d4d;
--scalar-color-purple: #8250df;
--theme-scrollbar-color: rgba(255, 255, 255, 0.24);
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
--scalar-scrollbar-color: rgba(255, 255, 255, 0.24);
--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 {
--theme-color-1: color-mix(
--scalar-color-1: color-mix(
in srgb,
rgb(var(--primary-base-700, 70 70 70)),
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));
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
--theme-color-accent: #50b7e0;
--scalar-color-2: color-mix(
in srgb,
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));
--theme-background-2: color-mix(
--scalar-background-1: rgb(var(--dark-base, 22 22 22));
--scalar-background-2: color-mix(
in srgb,
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,
rgb(var(--primary-base-200, 200 200 200)),
var(--theme-background-1) 88%
var(--scalar-background-1) 88%
);
--theme-background-accent: #8ab4f81f;
--theme-code-languages-background-supersede: var(--theme-background-1);
--theme-border-color: color-mix(
--scalar-background-accent: #8ab4f81f;
--scalar-code-languages-background-supersede: var(--scalar-background-1);
--scalar-border-color: color-mix(
in srgb,
var(--theme-color-1),
var(--scalar-color-1),
transparent calc(100% - 100% * 0.08)
);
--theme-color-green: #56b6c2;
--theme-color-red: rgb(245 124 97);
--theme-color-yellow: #edbe20;
--theme-color-blue: rgb(var(--primary-color-400, 93 138 226));
--theme-color-orange: #d19a66;
--theme-color-purple: #5203d1;
--scalar-color-green: #56b6c2;
--scalar-color-red: rgb(245 124 97);
--scalar-color-yellow: #edbe20;
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
--scalar-color-orange: #d19a66;
--scalar-color-purple: #5203d1;
--theme-scrollbar-color: rgba(0, 0, 0, 0.18);
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
--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 {
--theme-font: initial;
--theme-font-code: var(--font-mono);
--scalar-font: initial;
--scalar-font-code: var(--font-mono);
--theme-paragraph: 16px;
--theme-small: 14px;
--theme-mini: 13px;
--theme-micro: 12px;
--scalar-paragraph: 16px;
--scalar-small: 14px;
--scalar-mini: 13px;
--scalar-micro: 12px;
--theme-bold: 600;
--theme-semibold: 500;
--theme-regular: 400;
--scalar-bold: 600;
--scalar-semibold: 500;
--scalar-regular: 400;
/* Font sizes for interactive applications (not rendered text content) */
--theme-font-size-1: 24px;
--theme-font-size-2: 16px;
--theme-font-size-3: 14px;
--theme-font-size-4: 13px;
--theme-font-size-5: 12px;
--scalar-font-size-1: 24px;
--scalar-font-size-2: 16px;
--scalar-font-size-3: 14px;
--scalar-font-size-4: 13px;
--scalar-font-size-5: 12px;
--theme-line-height-1: 32px;
--theme-line-height-2: 24px;
--theme-line-height-3: 20px;
--theme-line-height-4: 18px;
--theme-line-height-5: 16px;
--scalar-line-height-1: 32px;
--scalar-line-height-2: 24px;
--scalar-line-height-3: 20px;
--scalar-line-height-4: 18px;
--scalar-line-height-5: 16px;
--scalar-app-header-height: 35px;
}
.scalar input::placeholder {
color: var(--theme-color-3);
color: var(--scalar-color-3);
}
.scalar .scalar-app-header {
width: 100%;
@@ -134,15 +158,15 @@
display: flex;
align-items: center;
background: transparent;
color: var(--theme-color-1);
font-size: var(--theme-small);
font-weight: var(--theme-semibold);
color: var(--scalar-color-1);
font-size: var(--scalar-small);
font-weight: var(--scalar-semibold);
}
.scalar-api-client__close:hover {
cursor: pointer;
}
.scalar .scalar-app {
background: var(--theme-background-3);
background: var(--scalar-background-3);
height: calc(100dvh - 100px);
max-width: 1280px;
width: 100%;
@@ -227,10 +251,10 @@
line-height: normal;
}
.scalar .scalar-app-header span {
color: var(--theme-color-3);
color: var(--scalar-color-3);
}
.scalar .scalar-app-header a {
color: var(--theme-color-1);
color: var(--scalar-color-1);
}
.scalar .scalar-app-header a:hover {
text-decoration: underline;
@@ -251,7 +275,7 @@
display: flex;
gap: 6px;
align-items: center;
color: var(--theme-color-blue);
color: var(--scalar-color-blue);
appearance: none;
outline: none;
border: none;
@@ -261,7 +285,7 @@
padding: 0 0.5rem;
}
.scalar-activate:hover .scalar-activate-button {
background: var(--theme-background-3);
background: var(--scalar-background-3);
border-radius: 3px;
}
@@ -293,12 +317,12 @@
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.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;
border: 3px solid transparent;
}
.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;
border: 3px solid transparent;
}
@@ -172,6 +172,10 @@
@apply prose-sm;
}
.openapi-schema-example {
@apply prose-sm mt-2 text-dark/10 dark:text-light/10;
}
/** Authentication */
.openapi-securities {
@@ -4,7 +4,9 @@ import assertNever from 'assert-never';
import { Checkbox, Emoji } from '@/components/primitives';
import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
@@ -12,6 +14,7 @@ import { TableRecordKV } from './Table';
import { getColumnAlignment } from './utils';
import { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { FileIcon } from '../FileIcon';
/**
* Render the value for a column in a record.
@@ -125,11 +128,41 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return (
<Tag className={tcls('text-base')}>
{files.filter(filterOutNullable).map((file, index) => (
<StyledLink key={index} href={file.href}>
{file.text}
</StyledLink>
))}
{files.filter(filterOutNullable).map((ref, index) => {
const contentType = ref.file
? getSimplifiedContentType(ref.file.contentType)
: null;
return (
<StyledLink
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
>
{contentType === 'image' ? (
<Image
style={['max-h-[1lh]', 'h-[1lh]']}
alt={ref.text}
sizes={[{ width: 24 }]}
sources={{
light: {
src: ref.href,
size: {
width: 24,
height: 24,
},
},
}}
priority="lazy"
/>
) : (
<FileIcon contentType={contentType} />
)}
{ref.text}
</StyledLink>
);
})}
</Tag>
);
case 'content-ref': {
@@ -12,14 +12,12 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
<div
className={tcls(
style,
'max-w-full',
'md:max-w-3xl',
'inline-grid',
'gap-4',
'grid-cols-1',
'min-[432px]:grid-cols-2',
view.cardSize === 'large' ? 'md:grid-cols-2' : 'md:grid-cols-3',
block.data.fullWidth ? ['max-w-full', 'large:flex-column'] : null,
block.data.fullWidth ? 'large:flex-column' : null,
)}
>
{records.map((record) => {
+1 -26
View File
@@ -14,7 +14,6 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
const tableWrapper = columnsOverThreshold
? [
// has over X columns
'w-full',
'overflow-x-auto',
'overflow-y-hidden',
'mx-auto',
@@ -22,32 +21,8 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
'border',
'border-dark/3',
'dark:border-light/2',
block.data.fullWidth
? [
// has over X columns, and is full width
'max-w-full',
]
: [
// NOT full width, but has over X columns
'max-w-4xl',
],
]
: [
'w-full',
'overflow-x-auto',
'overflow-y-hidden',
'mx-auto',
// has under X columns
block.data.fullWidth
? [
// has under X columns, and is full width
'max-w-full',
]
: [
// NOT full width, but has under X columns
'max-w-3xl',
],
];
: ['overflow-x-auto', 'overflow-y-hidden', 'mx-auto'];
const tableTR = columnsOverThreshold
? ['[&>*+*]:border-l', '[&>*]:px-4']
+1
View File
@@ -1 +1,2 @@
export * from './DocumentView';
export { createHighlightingContext } from './CodeBlock';
+2 -2
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, Space } from '@gitbook/api';
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import React from 'react';
import { Image } from '@/components/utils';
@@ -12,7 +12,7 @@ import { ThemeToggler } from '../ThemeToggler';
export function Footer(props: {
space: Space;
context: ContentRefContext;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { context, customization } = props;
+12 -6
View File
@@ -1,4 +1,10 @@
import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { t } from '@/intl/server';
@@ -13,11 +19,11 @@ import { SearchButton } from '../Search';
*/
export function CompactHeader(props: {
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
parent: Site | Collection | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, collection, customization } = props;
const { space, parent, customization } = props;
return (
<div
@@ -34,7 +40,7 @@ export function CompactHeader(props: {
)}
>
<div className={tcls('flex-grow-0', 'mt-5')}>
<HeaderLogo collection={collection} space={space} customization={customization} />
<HeaderLogo parent={parent} space={space} customization={customization} />
</div>
<div
className={tcls(
+17 -18
View File
@@ -1,4 +1,10 @@
import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react';
@@ -7,26 +13,29 @@ import { t, getSpaceLanguage } from '@/intl/server';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { CollectionSpacesDropdown } from './CollectionSpacesDropdown';
import { HeaderLink } from './HeaderLink';
import { HeaderLinks } from './HeaderLinks';
import { HeaderLogo } from './HeaderLogo';
import { SpacesDropdown } from './SpacesDropdown';
import { SearchButton } from '../Search';
/**
* Render the header for the space.
*/
export function Header(props: {
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
parent: Site | Collection | null;
spaces: Space[];
context: ContentRefContext;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
withTopHeader?: boolean;
}) {
const { context, space, collection, collectionSpaces, customization, withTopHeader } = props;
const { context, space, parent, spaces, customization, withTopHeader } = props;
const isCustomizationDefault =
customization.header.preset === CustomizationHeaderPreset.Default;
const isMultiVariants =
parent?.object === 'collection' ||
(parent && parent.object === 'site' && spaces.length > 1);
return (
<header
@@ -66,19 +75,9 @@ export function Header(props: {
CONTAINER_STYLE,
)}
>
<HeaderLogo
collection={collection}
space={space}
customization={customization}
/>
<HeaderLogo parent={parent} space={space} customization={customization} />
<span>
{collection ? (
<CollectionSpacesDropdown
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
/>
) : null}
{isMultiVariants ? <SpacesDropdown space={space} spaces={spaces} /> : null}
</span>
<HeaderLinks>
{customization.header.links.map((link, index) => {
+2 -1
View File
@@ -3,6 +3,7 @@ import {
CustomizationHeaderLink,
CustomizationSettings,
CustomizationHeaderPreset,
SiteCustomizationSettings,
} from '@gitbook/api';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -20,7 +21,7 @@ import { Link } from '../primitives';
export async function HeaderLink(props: {
context: ContentRefContext;
link: CustomizationHeaderLink;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { context, link, customization } = props;
+22 -35
View File
@@ -1,16 +1,25 @@
import { Collection, CustomizationHeaderPreset, CustomizationSettings, Space } from '@gitbook/api';
import {
Collection,
CustomizationHeaderPreset,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
import { LogoIcon } from '@/components/icons';
import { Image } from '@/components/utils';
import { absoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { Link } from '../primitives';
interface HeaderLogoProps {
collection: Collection | null;
parent: Site | Collection | null;
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
}
/**
@@ -86,41 +95,19 @@ export function HeaderLogo(props: HeaderLogoProps) {
}
function LogoFallback(props: HeaderLogoProps) {
const { collection, space, customization } = props;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
const { parent, space, customization } = props;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : undefined;
const customEmoji = 'emoji' in customization.favicon ? customization.favicon.emoji : undefined;
return (
<>
<Image
alt="Logo"
sources={
customIcon
? {
light: { src: customIcon.light, aspectRatio: '1' },
dark: customIcon.dark
? { src: customIcon.dark, aspectRatio: '1' }
: null,
}
: {
light: {
src: absoluteHref('~gitbook/icon?size=medium&theme=light', true),
size: { width: 256, height: 256 },
},
dark: {
src: absoluteHref('~gitbook/icon?size=medium&theme=dark', true),
size: { width: 256, height: 256 },
},
}
}
sizes={[
{
width: 32,
},
]}
<LogoIcon
icon={customIcon}
emoji={customEmoji}
alt=""
sizes={[{ width: 32 }]}
style={['object-contain', 'size-8']}
fetchPriority="high"
style={['w-8', 'h-8', 'object-contain']}
/>
<h1
className={tcls(
'text-pretty',
@@ -138,7 +125,7 @@ function LogoFallback(props: HeaderLogoProps) {
: 'text-header-link',
)}
>
{collection ? collection.title : customization.title ?? space.title}
{getContentTitle(space, customization, parent)}
</h1>
</>
);
@@ -1,21 +1,19 @@
import { Collection, Space } from '@gitbook/api';
import { Space } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
import { Dropdown, DropdownChevron, DropdownMenu } from './Dropdown';
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
export function CollectionSpacesDropdown(props: {
space: Space;
collection: Collection;
collectionSpaces: Space[];
}) {
const { space, collectionSpaces } = props;
export function SpacesDropdown(props: { space: Space; spaces: Space[] }) {
const { space, spaces } = props;
return (
<Dropdown
button={(buttonProps) => (
<div
{...buttonProps}
data-testid="space-dropdown-button"
className={tcls(
'justify-self-start',
'flex',
@@ -32,14 +30,16 @@ export function CollectionSpacesDropdown(props: {
)}
>
<DropdownMenu>
{collectionSpaces.map((otherSpace) => (
<DropdownMenuItem
key={otherSpace.id}
href={otherSpace.urls.published ?? otherSpace.urls.app}
{spaces.map((otherSpace, index) => (
<SpacesDropdownMenuItem
key={`${otherSpace.id}-${index}`}
variantSpace={{
id: otherSpace.id,
title: otherSpace.title,
url: otherSpace.urls.published ?? otherSpace.urls.app,
}}
active={otherSpace.id === space.id}
>
{otherSpace.title}
</DropdownMenuItem>
/>
))}
</DropdownMenu>
</Dropdown>
@@ -0,0 +1,29 @@
'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(/\/$/, '');
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>
);
}
+31 -5
View File
@@ -2,7 +2,14 @@ import { Menu } from '@geist-ui/icons';
import DownloadCloud from '@geist-ui/icons/downloadCloud';
import Github from '@geist-ui/icons/github';
import Gitlab from '@geist-ui/icons/gitlab';
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import {
CustomizationSettings,
JSONDocument,
RevisionPageDocument,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import urlJoin from 'url-join';
@@ -11,9 +18,10 @@ import { getDocumentSections } from '@/lib/document';
import { absoluteHref } from '@/lib/links';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { getPDFUrl, getPDFUrlSearchParams } from '@/lib/urls';
import { getPDFUrlSearchParams } from '@/lib/urls';
import { ScrollSectionsList } from './ScrollSectionsList';
import { Ad } from '../Ads';
import { PageFeedbackForm } from '../PageFeedback';
/**
@@ -21,7 +29,8 @@ import { PageFeedbackForm } from '../PageFeedback';
*/
export async function PageAside(props: {
space: Space;
customization: CustomizationSettings;
site: Site | undefined;
customization: CustomizationSettings | SiteCustomizationSettings;
page: RevisionPageDocument;
document: JSONDocument | null;
context: ContentRefContext;
@@ -29,8 +38,16 @@ export async function PageAside(props: {
withFullPageCover: boolean;
withPageFeedback: boolean;
}) {
const { space, page, document, customization, withHeaderOffset, withPageFeedback, context } =
props;
const {
space,
site,
page,
document,
customization,
withHeaderOffset,
withPageFeedback,
context,
} = props;
const language = getSpaceLanguage(customization);
return (
@@ -166,6 +183,15 @@ export async function PageAside(props: {
) : null}
</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>
);
}
+33 -7
View File
@@ -1,9 +1,15 @@
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import {
CustomizationSettings,
JSONDocument,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
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 { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -13,25 +19,39 @@ import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { TrackPageView } from './TrackPageView';
import { DocumentView } from '../DocumentView';
import { DocumentView, createHighlightingContext } from '../DocumentView';
import { PageFeedbackForm } from '../PageFeedback';
import { DateRelative } from '../primitives';
export function PageBody(props: {
space: Space;
contentPointer: ContentPointer | SiteContentPointer;
contentTarget: ContentTarget;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
page: RevisionPageDocument;
document: JSONDocument | null;
context: ContentRefContext;
withPageFeedback: boolean;
}) {
const { space, contentTarget, customization, context, page, document, withPageFeedback } =
props;
const {
space,
contentPointer,
contentTarget,
customization,
context,
page,
document,
withPageFeedback,
} = props;
const asFullWidth = document ? hasFullWidthBlock(document) : false;
const language = getSpaceLanguage(customization);
const updatedAt = page.updatedAt ?? page.createdAt;
const shouldHighlightCode = createHighlightingContext();
const sitePointer =
'siteId' in contentPointer
? { organizationId: contentPointer.organizationId, siteId: contentPointer.siteId }
: undefined;
return (
<>
@@ -73,6 +93,7 @@ export function PageBody(props: {
contentRefContext: context,
resolveContentRef: (ref, options) =>
resolveContentRef(ref, context, options),
shouldHighlightCode,
}}
/>
) : (
@@ -121,7 +142,12 @@ export function PageBody(props: {
</div>
</main>
<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>
</>
);
@@ -1,6 +1,12 @@
import ChevronLeft from '@geist-ui/icons/chevronLeft';
import ChevronRight from '@geist-ui/icons/chevronRight';
import { CustomizationSettings, Revision, RevisionPageDocument, Space } from '@gitbook/api';
import {
CustomizationSettings,
Revision,
RevisionPageDocument,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { t, getSpaceLanguage } from '@/intl/server';
@@ -15,7 +21,7 @@ import { Link } from '../primitives';
*/
export function PageFooterNavigation(props: {
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages'];
page: RevisionPageDocument;
}) {
+76 -16
View File
@@ -1,36 +1,96 @@
'use client';
import type { RequestSpaceTrackPageView } from '@gitbook/api';
import type { RequestSiteTrackPageView, RequestSpaceTrackPageView } from '@gitbook/api';
import cookies from 'js-cookie';
import * as React from 'react';
import { getVisitorId } from '@/lib/analytics';
import { SiteContentPointer } from '@/lib/api';
/**
* Track the page view for the current page to integrations.
*/
export function TrackPageView(props: {
apiHost: string;
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
spaceId: string;
pageId: string | undefined;
}) {
const { apiHost, spaceId, pageId } = props;
const { apiHost, sitePointer, spaceId, pageId } = props;
React.useEffect(() => {
trackPageView(apiHost, spaceId, pageId);
}, [apiHost, spaceId, pageId]);
trackPageView({ apiHost, sitePointer, spaceId, pageId });
}, [apiHost, spaceId, pageId, sitePointer]);
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;
/**
* Check if the current page is in an iframe.
* This is used to avoid tracking page views in iframes.
*/
function isInIframe(): boolean {
try {
return window.self !== window.top || window.frameElement !== null;
} catch (e) {
return true;
}
}
/**
* Track the page view for the current page to GitBook.
* We don't use the API client to avoid shipping 80kb of JS to the client.
* 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 (isInIframe()) {
return;
}
if (pageId === latestPageId) {
// The hook can be called multiple times, we only want to track once.
return;
@@ -39,7 +99,7 @@ async function trackPageView(apiHost: string, spaceId: string, pageId: string |
latestPageId = pageId;
const visitorId = await getVisitorId();
const body: RequestSpaceTrackPageView = {
const sharedTrackedProps = {
url: window.location.href,
pageId,
visitor: {
@@ -51,17 +111,17 @@ async function trackPageView(apiHost: string, spaceId: string, pageId: string |
referrer: document.referrer,
};
const url = new URL(apiHost);
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
try {
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
sitePointer
? await sendSiteTrackPageViewRequest({
apiHost,
sitePointer,
body: {
...sharedTrackedProps,
spaceId,
},
})
: await sendSpaceTrackPageViewRequest({ apiHost, spaceId, body: sharedTrackedProps });
} catch (error) {
console.error('Failed to track page view', error);
}
+116 -90
View File
@@ -6,10 +6,11 @@ import { atom, useRecoilState } from 'recoil';
import { Loading } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { TranslationLanguage } from '@/intl/translations';
import { iterateStreamResponse } from '@/lib/actions';
import { tcls } from '@/lib/tailwind';
import { AskAnswerResult, streamAskQuestion } from './server-actions';
import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch';
import { Link } from '../primitives';
@@ -94,6 +95,11 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
};
}, [setState]);
let hasAnswer = false;
if (state && 'answer' in state) {
hasAnswer = !!state?.answer?.body;
}
return (
<div
className={tcls(
@@ -129,108 +135,128 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) {
function AnswerBody(props: { answer: AskAnswerResult }) {
const { answer } = props;
const getSearchLinkProps = useSearchLink();
const language = useLanguage();
const [, setSearchState] = useSearch();
const onClose = () => {
setSearchState(null);
};
return (
<>
<div
data-test="search-ask-answer"
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>
{answer.followupQuestions.length > 0 ? (
<div className={tcls('mt-7', 'flex', 'flex-col', 'flex-wrap', 'gap-1')}>
{answer.followupQuestions.map((question) => (
<Link
key={question}
className={tcls(
'text-sm',
'font-medium',
'inline-flex',
'items-start',
'gap-2',
'px-4',
'py-1',
'text-primary-500',
'focus-within:text-primary-700',
'hover:bg-primary/2',
'dark:text-primary-400',
'dark:hover:bg-primary-500/3',
)}
{...getSearchLinkProps({
query: question,
ask: true,
})}
>
<IconSearch
className={tcls(
'w-[15px]',
'h-[15px]',
'shrink-0',
'mt-0.5',
'[&>path]:[stroke-opacity:0.64]',
)}
/>
<span>{question}</span>
</Link>
))}
</div>
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
) : null}
{answer.sources.length > 0 ? (
<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, '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>
<AnswerSources
hasAnswer={answer.hasAnswer}
sources={answer.sources}
language={language}
/>
) : null}
</>
);
}
function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
const { followupQuestions } = props;
const getSearchLinkProps = useSearchLink();
return (
<div className={tcls('mt-7 mb-4', 'flex', 'flex-col', 'flex-wrap', 'gap-1')}>
{followupQuestions.map((question) => (
<Link
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>
);
}
+21 -33
View File
@@ -1,6 +1,7 @@
'use client';
import IconSearch from '@geist-ui/icons/search';
import { Collection, Site } from '@gitbook/api';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import React from 'react';
@@ -20,7 +21,7 @@ interface SearchModalProps {
spaceId: string;
revisionId: string;
spaceTitle: string;
collectionId: string | null;
parent: Site | Collection | null;
withAsk: boolean;
}
@@ -135,16 +136,8 @@ function SearchModalBody(
onClose: (to?: string) => void;
},
) {
const {
spaceId,
revisionId,
spaceTitle,
withAsk,
collectionId,
state,
onChangeQuery,
onClose,
} = props;
const { spaceId, revisionId, spaceTitle, withAsk, parent, state, onChangeQuery, onClose } =
props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
@@ -239,28 +232,23 @@ function SearchModalBody(
</div>
</div>
{!state.ask || !withAsk ? (
<>
<SearchResults
ref={resultsRef}
spaceId={spaceId}
revisionId={revisionId}
collectionId={state.global ? collectionId : null}
query={state.query}
withAsk={withAsk}
onSwitchToAsk={() => {
onChangeQuery({
ask: true,
query: state.query,
global: state.global,
});
}}
onClose={onClose}
>
{collectionId && state.query ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
</SearchResults>
</>
<SearchResults
ref={resultsRef}
spaceId={spaceId}
revisionId={revisionId}
parent={state.global ? parent : null}
query={state.query}
withAsk={withAsk}
onSwitchToAsk={() => {
onChangeQuery({
ask: true,
query: state.query,
global: state.global,
});
}}
>
{parent && state.query ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
</SearchResults>
) : null}
{state.query && state.ask && withAsk ? (
<SearchAskAnswer spaceId={spaceId} query={state.query} />
@@ -11,20 +11,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
query: string;
item: ComputedPageResult;
active: boolean;
onClick: (to: string) => void;
},
ref: React.Ref<HTMLAnchorElement>,
) {
const { query, item, active, onClick } = props;
const { query, item, active } = props;
return (
<Link
ref={ref}
href={item.href}
onClick={(event) => {
event.preventDefault();
onClick(item.href);
}}
className={tcls(
'flex',
'flex-row',
+7 -10
View File
@@ -1,3 +1,4 @@
import { Collection, Site } from '@gitbook/api';
import assertNever from 'assert-never';
import React from 'react';
@@ -11,7 +12,7 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
import {
getRecommendedQuestions,
OrderedComputedResult,
searchCollectionContent,
searchParentContent,
searchSpaceContent,
} from './server-actions';
import { Loading } from '../primitives';
@@ -39,15 +40,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
query: string;
spaceId: string;
revisionId: string;
collectionId: string | null;
parent: Site | Collection | null;
withAsk: boolean;
onSwitchToAsk: () => void;
onClose: (to?: string) => void;
},
ref: React.Ref<SearchResultsRef>,
) {
const { children, query, spaceId, revisionId, collectionId, withAsk, onSwitchToAsk, onClose } =
props;
const { children, query, spaceId, revisionId, parent, withAsk, onSwitchToAsk } = props;
const language = useLanguage();
const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null);
@@ -94,8 +93,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
debounceTimeout.current = setTimeout(async () => {
setCursor(null);
const fetchedResults = await (collectionId
? searchCollectionContent(collectionId, query)
const fetchedResults = await (parent
? searchParentContent(parent, query)
: searchSpaceContent(spaceId, revisionId, query));
setResults(withAsk ? withQuestionResult(fetchedResults, query) : fetchedResults);
}, 250);
@@ -107,7 +106,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
}
};
}
}, [query, spaceId, revisionId, collectionId, withAsk]);
}, [query, spaceId, revisionId, parent, withAsk]);
// Scroll to the active result.
React.useEffect(() => {
@@ -211,7 +210,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query}
item={item}
active={index === cursor}
onClick={onClose}
/>
);
}
@@ -252,7 +250,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query}
item={item}
active={index === cursor}
onClick={onClose}
/>
);
}
@@ -11,20 +11,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
query: string;
item: ComputedSectionResult;
active: boolean;
onClick: (to: string) => void;
},
ref: React.Ref<HTMLAnchorElement>,
) {
const { query, item, active, onClick } = props;
const { query, item, active } = props;
return (
<Link
ref={ref}
href={item.href}
onClick={(event) => {
event.preventDefault();
onClick(item.href);
}}
className={tcls(
'search-section-result-item',
'[&:has(+:not(&))]:mb-6',
+65 -22
View File
@@ -1,7 +1,16 @@
'use server';
import { RevisionPage, SearchAIAnswer, SearchPageResult, Space } from '@gitbook/api';
import {
Collection,
RevisionPage,
SearchAIAnswer,
SearchPageResult,
Site,
Space,
} from '@gitbook/api';
import { headers } from 'next/headers';
import { getContentPointer } from '@/app/(space)/fetch';
import { streamResponse } from '@/lib/actions';
import * as api from '@/lib/api';
import { absoluteHref, pageHref } from '@/lib/links';
@@ -37,9 +46,10 @@ export interface AskAnswerSource {
}
export interface AskAnswerResult {
body: React.ReactNode;
body?: React.ReactNode;
followupQuestions: string[];
sources: AskAnswerSource[];
hasAnswer: boolean;
}
/**
@@ -55,20 +65,47 @@ export async function searchSpaceContent(
}
/**
* Server action to search content in a collection
* Server action to search content in a parent (site or collection)
*/
export async function searchCollectionContent(
collectionId: string,
export async function searchParentContent(
parent: Site | Collection,
query: string,
): Promise<OrderedComputedResult[]> {
const [data, collectionSpaces] = await Promise.all([
api.searchCollectionContent(collectionId, query),
api.getCollectionSpaces(collectionId),
const pointer = getContentPointer();
const [data, collectionSpaces, siteSpaces] = await Promise.all([
api.searchParentContent(parent.id, query),
parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null,
parent.object === 'site' && 'organizationId' in pointer
? api.getSiteSpaces({
organizationId: pointer.organizationId,
siteId: parent.id,
siteShareKey: pointer.siteShareKey,
})
: null,
]);
let spaces: Space[] = [];
if (collectionSpaces) {
spaces = collectionSpaces;
} else if (siteSpaces) {
spaces = Object.values(
siteSpaces.reduce(
(acc, siteSpace) => {
acc[siteSpace.space.id] = siteSpace.space;
// replace the published url for the "space" with the site's published url
acc[siteSpace.space.id].urls.published = siteSpace.urls.published;
return acc;
},
{} as Record<string, Space>,
),
);
}
return data.items
.map((spaceItem) => {
const space = collectionSpaces.find((space) => space.id === spaceItem.id);
const space = spaces.find((space) => space.id === spaceItem.id);
return spaceItem.pages.map((item) => transformPageResult(item, space));
})
.flat(2);
@@ -78,12 +115,15 @@ export async function searchCollectionContent(
* Server action to ask a question in a space.
*/
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 });
for await (const chunk of stream) {
// We run the AI search and fetch the pages in parallel
const { pages } = await pagesPromise;
yield transformAnswer(chunk.answer, pages);
}
});
@@ -100,7 +140,7 @@ function transformAnswer(
answer: SearchAIAnswer | undefined,
pages: RevisionPage[],
): AskAnswerResult | null {
if (!answer || !('document' in answer.answer)) {
if (!answer) {
return null;
}
@@ -124,19 +164,22 @@ function transformAnswer(
.filter(filterOutNullable);
return {
body: (
<DocumentView
document={answer.answer.document}
context={{
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
}}
style={['space-y-5']}
/>
),
body:
answer.answer && 'document' in answer.answer ? (
<DocumentView
document={answer.answer.document}
context={{
mode: 'default',
contentRefContext: null,
resolveContentRef: async () => null,
shouldHighlightCode: () => false,
}}
style={['space-y-5']}
/>
) : null,
followupQuestions: answer.followupQuestions,
sources,
hasAnswer: !!answer.answer && 'document' in answer.answer,
};
}
+12 -10
View File
@@ -5,6 +5,8 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -26,9 +28,9 @@ export function SpaceLayout(props: {
content: ContentPointer;
contentTarget: ContentTarget;
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
parent: Site | Collection | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages'];
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
children: React.ReactNode;
@@ -36,8 +38,8 @@ export function SpaceLayout(props: {
const {
space,
contentTarget,
collection,
collectionSpaces,
parent,
spaces,
content,
pages,
customization,
@@ -59,8 +61,8 @@ export function SpaceLayout(props: {
<Header
withTopHeader={withTopHeader}
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
parent={parent}
spaces={spaces}
context={contentRefContext}
customization={customization}
/>
@@ -89,8 +91,8 @@ export function SpaceLayout(props: {
withTopHeader ? null : (
<CompactHeader
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
parent={parent}
spaces={spaces}
customization={customization}
/>
)
@@ -114,7 +116,7 @@ export function SpaceLayout(props: {
revisionId={contentTarget.revisionId}
spaceTitle={customization.title ?? space.title}
withAsk={customization.aiSearch.enabled}
collectionId={collection && collectionSpaces.length > 1 ? collection.id : null}
parent={parent && spaces.length > 1 ? parent : null}
/>
</React.Suspense>
</>
@@ -3,6 +3,7 @@ import {
Revision,
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -16,7 +17,7 @@ import { Trademark } from './Trademark';
export function TableOfContents(props: {
space: Space;
customization: CustomizationSettings;
customization: CustomizationSettings | SiteCustomizationSettings;
content: ContentPointer;
context: ContentRefContext;
pages: Revision['pages'];
@@ -74,7 +75,7 @@ export function TableOfContents(props: {
'dark:group-hover:[&::-webkit-scrollbar-thumb]:bg-light/3',
'navigation-open:flex', // can be auto height animated as such https://stackoverflow.com/a/76944290
'lg:-ml-5',
customization.trademark.enabled ? 'lg:pb-16' : 'lg:pb-4',
customization.trademark.enabled ? 'lg:pb-20' : 'lg:pb-4',
)}
>
<PagesList
+16 -6
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings, Space } from '@gitbook/api';
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import { t, getSpaceLanguage } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
@@ -8,7 +8,10 @@ import { IconLogo } from '../icons/IconLogo';
/**
* Trademark link to the GitBook.
*/
export function Trademark(props: { space: Space; customization: CustomizationSettings }) {
export function Trademark(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
return (
<div
className={tcls(
@@ -54,7 +57,10 @@ export function Trademark(props: { space: Space; customization: CustomizationSet
/**
* Trademark link to the GitBook.
*/
export function TrademarkLink(props: { space: Space; customization: CustomizationSettings }) {
export function TrademarkLink(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, customization } = props;
const language = getSpaceLanguage(customization);
@@ -78,16 +84,20 @@ export function TrademarkLink(props: { space: Space; customization: Customizatio
'flex-row',
'items-center',
'hover:bg-dark/1',
'bg-light',
'dark:bg-dark',
'px-4',
'py-2',
'rounded-md',
'py-4',
'rounded-lg',
'straight-corners:rounded-none',
'hover:backdrop-blur-sm',
'lg:ring-0',
'tracking-[-0.016em]',
'dark:hover:bg-light/1',
'dark:ring-light/1',
'dark:font-normal',
'border',
'border-dark/2',
'dark:border-light/2',
)}
>
<IconLogo className={tcls('w-5', 'h-5', 'mr-3')} />
+49
View File
@@ -0,0 +1,49 @@
import { CustomizationThemedURL } from '@gitbook/api';
import { Image } from '@/components/utils';
import { absoluteHref } from '@/lib/links';
import { Emoji } from '../primitives';
export function LogoIcon(
props: { icon?: CustomizationThemedURL; emoji?: string } & Omit<
React.ComponentProps<typeof Image>,
'sources'
>,
) {
const { icon, emoji, alt, ...imageProps } = props;
if (emoji && !icon) {
return <Emoji code={emoji} style="text-xl" />;
}
return (
<Image
alt={alt ?? ''}
sources={
icon
? {
light: {
src: icon.light,
aspectRatio: '1',
},
dark: {
src: icon.dark,
aspectRatio: '1',
},
}
: {
light: {
src: absoluteHref('~gitbook/icon?size=medium&theme=light', true),
size: { width: 256, height: 256 },
},
dark: {
src: absoluteHref('~gitbook/icon?size=medium&theme=dark', true),
size: { width: 256, height: 256 },
},
}
}
{...imageProps}
/>
);
}
+1
View File
@@ -1 +1,2 @@
export * from './types';
export * from './LogoIcon';
+6 -3
View File
@@ -1,14 +1,16 @@
import { tcls } from '@/lib/tailwind';
import { ClassValue, tcls } from '@/lib/tailwind';
import { Link, LinkProps } from '../primitives/Link';
/**
* Styled version of Link component.
*/
export function StyledLink(props: LinkProps) {
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
return (
<Link
{...props}
{...rest}
className={tcls(
'underline',
'underline-offset-2',
@@ -16,6 +18,7 @@ export function StyledLink(props: LinkProps) {
'text-primary',
'hover:text-primary-700',
'transition-colors',
style,
)}
>
{props.children}
+4 -2
View File
@@ -1,4 +1,4 @@
import { CustomizationSettings } from '@gitbook/api';
import { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api';
import { languages, TranslationLanguage } from './translations';
@@ -7,7 +7,9 @@ export * from './translate';
/**
* Create the translation context for a space to use in the server components.
*/
export function getSpaceLanguage(customization: CustomizationSettings): TranslationLanguage {
export function getSpaceLanguage(
customization: CustomizationSettings | SiteCustomizationSettings,
): TranslationLanguage {
const fallback = languages.en;
const { locale } = customization.internationalization;
+56
View File
@@ -0,0 +1,56 @@
export const de = {
locale: 'de',
powered_by_gitbook: 'Bereitgestellt von GitBook',
switch_to_dark_theme: 'Zum dunklen Modus wechseln',
switch_to_light_theme: 'Zum hellen Modus wechseln',
switch_to_system_theme: 'Zum Systemmodus wechseln',
search: 'Suche',
search_or_ask: 'Fragen oder Suchen',
search_input_placeholder: 'Inhalt durchsuchen',
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
search_no_results: 'Keine Ergebnisse für "${1}".',
search_scope_space: 'Nur in ${1}',
search_scope_all: 'Alle Inhalte',
search_ask: 'Fragen "${1}"',
search_ask_sources: 'Quellen',
search_ask_sources_no_answer: 'Verwandte Seiten',
search_ask_no_answer:
'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.',
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
on_this_page: 'Auf dieser Seite',
next_page: 'Nächste',
previous_page: 'Vorherige',
page_last_modified: 'Zuletzt aktualisiert ${1}',
was_this_helpful: 'War das hilfreich?',
was_this_helpful_positive: 'Ja, das war es!',
was_this_helpful_neutral: 'Nicht sicher',
was_this_helpful_negative: 'Nein',
was_this_helpful_thank_you: 'Danke!',
annotation_button_label: 'Kommentar öffnen',
code_copied: 'Kopiert!',
code_copy: 'Kopieren',
table_of_contents_button_label: 'Inhaltsverzeichnis öffnen',
cookies_title: 'Cookies',
cookies_prompt:
'Diese Website verwendet Cookies, um ihre Dienste bereitzustellen und den Datenverkehr zu analysieren. Durch die Nutzung dieser Website akzeptieren Sie die ${1}.',
cookies_prompt_privacy: 'Datenschutzrichtlinie',
cookies_accept: 'Akzeptieren',
cookies_reject: 'Ablehnen',
cookies_close: 'Schließen',
edit_on_git: 'Bearbeiten auf ${1}',
notfound_title: 'Seite nicht gefunden',
notfound: 'Die gesuchte Seite existiert nicht.',
unexpected_error_title: 'Ein Fehler ist aufgetreten',
unexpected_error:
'Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
unexpected_error_retry: 'Erneut versuchen',
pdf_download: 'Als PDF exportieren',
pdf_goback: 'Zurück zum Inhalt',
pdf_print: 'Drucken oder als PDF speichern',
pdf_page_of: '${1} von ${2}',
pdf_mode_only_page: 'Nur diese Seite',
pdf_mode_all: 'Alle Seiten',
pdf_limit_reached:
'Das PDF konnte für ${1} Seiten nicht generiert werden, Generierung wurde bei ${2} gestoppt.',
pdf_limit_reached_continue: 'Mit ${1} weiteren Seiten erweitern.',
};
+3 -1
View File
@@ -13,7 +13,9 @@ export const en = {
search_scope_all: 'All the content',
search_ask: 'Ask "${1}"',
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.',
on_this_page: 'On this page',
next_page: 'Next',
+3 -1
View File
@@ -15,7 +15,9 @@ export const es: TranslationLanguage = {
search_scope_all: 'Todo el contenido',
search_ask: 'Preguntar "${1}"',
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.',
on_this_page: 'En esta página',
next_page: 'Siguiente',
+3 -1
View File
@@ -15,7 +15,9 @@ export const fr: TranslationLanguage = {
search_scope_all: 'Tout le contenu',
search_ask: 'Demander "${1}"',
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.',
on_this_page: 'Sur cette page',
next_page: 'Suivant',
+4
View File
@@ -1,9 +1,11 @@
import { CustomizationLocale } from '@gitbook/api';
import { de } from './de';
import { en } from './en';
import { es } from './es';
import { fr } from './fr';
import { ja } from './ja';
import { pt_br } from './pt-br';
import { TranslationLanguage } from './types';
import { zh } from './zh';
@@ -12,9 +14,11 @@ export * from './types';
export const languages: {
[locale in CustomizationLocale]: TranslationLanguage;
} = {
de,
en,
fr,
es,
zh,
ja,
'pt-br': pt_br,
};
+2 -1
View File
@@ -15,8 +15,9 @@ export const ja: TranslationLanguage = {
search_scope_all: '全てのコンテンツ',
search_ask: '"${1}" を質問する',
search_ask_sources: '情報源',
search_ask_sources_no_answer: '関連ページ',
search_ask_no_answer:
'ご質問に答えられる答えが見つかりませんでした、他の質問を試してください。',
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
on_this_page: 'このページ内',
next_page: '次へ',
+56
View File
@@ -0,0 +1,56 @@
export const pt_br = {
locale: 'pt-br',
powered_by_gitbook: 'Powered by GitBook',
switch_to_dark_theme: 'Mudar para modo escuro',
switch_to_light_theme: 'Mudar para modo claro',
switch_to_system_theme: 'Mudar para configuração do sistema',
search: 'Busca',
search_or_ask: 'Perguntar ou buscar',
search_input_placeholder: 'Buscar conteúdo',
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
search_no_results: 'Sem resultados para "${1}".',
search_scope_space: 'Somente em ${1}',
search_scope_all: 'Todo o conteúdo',
search_ask: 'Perguntar "${1}"',
search_ask_sources: 'Fontes',
search_ask_sources_no_answer: 'Páginas relacionadas',
search_ask_no_answer:
'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo deu errado. Por favor tente novamente mais tarde.',
on_this_page: 'Nesta página',
next_page: 'Próximo',
previous_page: 'Anterior',
page_last_modified: 'Atualizado ${1}',
was_this_helpful: 'Isto foi útil?',
was_this_helpful_positive: 'Sim, foi!',
was_this_helpful_neutral: 'Não sei',
was_this_helpful_negative: 'Não',
was_this_helpful_thank_you: 'Obrigado!',
annotation_button_label: 'Abrir anotação',
code_copied: 'Copiado!',
code_copy: 'Copiar',
table_of_contents_button_label: 'Abrir o índice',
cookies_title: 'Cookies',
cookies_prompt:
'Este site usa cookies para entregar suas funcionalidades e analizar o tráfego. Ao navegar neste site, você aceita a ${1}.',
cookies_prompt_privacy: 'política de privacidade',
cookies_accept: 'Aceitar',
cookies_reject: 'Rejeitar',
cookies_close: 'Fechar',
edit_on_git: 'Editar no ${1}',
notfound_title: 'Página não encontrada',
notfound: 'A página que você está procurando não existe.',
unexpected_error_title: 'Aconteceu um erro',
unexpected_error:
'Desculpe, aconteceu um erro inesperado. Por favor tente novamente mais tarde.',
unexpected_error_retry: ' Tentar novamente',
pdf_download: 'Exportar como PDF',
pdf_goback: 'Voltar ao conteúdo',
pdf_print: 'Imprimir ou salvar como PDF',
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Somente esta página',
pdf_mode_all: 'Todas as páginas',
pdf_limit_reached:
'Não foi possível gerar o PDF para ${1} páginas, generation stopped at ${2}.',
pdf_limit_reached_continue: 'Extender com mais ${1} páginas.',
};
+2 -1
View File
@@ -15,7 +15,8 @@ export const zh: TranslationLanguage = {
search_scope_all: '所有内容',
search_ask: '询问"${1}"',
search_ask_sources: '来源',
search_ask_no_answer: '找不到您的问题的答案,请尝试另一个问题。',
search_ask_sources_no_answer: '相关页面',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。',
on_this_page: '在本页',
next_page: '下一页',
+287 -5
View File
@@ -9,8 +9,10 @@ import {
HttpResponse,
List,
PublishedContentLookup,
PublishedSiteContentLookup,
RequestRenderIntegrationUI,
RevisionFile,
SiteCustomizationSettings,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { headers } from 'next/headers';
@@ -35,6 +37,23 @@ export interface ContentPointer {
revisionId?: string;
}
/**
* Pointer to a relative content, it might change overtime, the pointer is relative in the content history.
*/
export interface SiteContentPointer extends ContentPointer {
organizationId: string;
siteId: string;
/**
* ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews)
*/
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;
}
/**
* Pointer to a content that is immutable, it will never change.
*/
@@ -109,7 +128,7 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
}
export type PublishedContentWithCache =
| (PublishedContentLookup & {
| ((PublishedContentLookup | PublishedSiteContentLookup) & {
cacheMaxAge?: number;
cacheTags?: string[];
})
@@ -581,6 +600,262 @@ export const getDocument = cache(
},
);
/**
* Get the customization settings for a site-space from the API.
*/
const getSiteSpaceCustomizationFromAPI = cache(
'api.getSiteSpaceCustomizationById',
async (
organizationId: string,
siteId: string,
siteSpaceId: string,
options: CacheFunctionOptions,
) => {
const response = await api().orgs.getSiteSpaceCustomizationById(
organizationId,
siteId,
siteSpaceId,
{},
{
signal: options.signal,
...noCacheFetchOptions,
},
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'site',
site: siteId,
}),
],
});
},
);
/**
* Get the customization settings for a site from the API.
*/
const getSiteCustomizationFromAPI = cache(
'api.getSiteCustomizationById',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteCustomizationById(
organizationId,
siteId,
{},
{
signal: options.signal,
...noCacheFetchOptions,
},
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'site',
site: siteId,
}),
],
});
},
);
/**
* Get the customization settings for a site space from the API.
*/
async function getSiteSpaceCustomization(args: {
organizationId: string;
siteId: string;
siteSpaceId: string;
}): Promise<SiteCustomizationSettings> {
const headersList = headers();
const raw = await getSiteSpaceCustomizationFromAPI(
args.organizationId,
args.siteId,
args.siteSpaceId,
);
const extend = headersList.get('x-gitbook-customization');
if (extend) {
try {
const parsed = rison.decode_object<Partial<SiteCustomizationSettings>>(extend);
return { ...raw, ...parsed };
} catch (error) {
console.error(
`Failed to parse x-gitbook-customization header (ignored): ${
(error as Error).stack ?? (error as Error).message ?? error
}`,
);
}
}
return raw;
}
/**
* Get the customization settings for a site space from the API.
*/
async function getSiteCustomization(args: {
organizationId: string;
siteId: string;
}): Promise<SiteCustomizationSettings> {
const headersList = headers();
const raw = await getSiteCustomizationFromAPI(args.organizationId, args.siteId);
const extend = headersList.get('x-gitbook-customization');
if (extend) {
try {
const parsed = rison.decode_object<Partial<SiteCustomizationSettings>>(extend);
return { ...raw, ...parsed };
} catch (error) {
console.error(
`Failed to parse x-gitbook-customization header (ignored): ${
(error as Error).stack ?? (error as Error).message ?? error
}`,
);
}
}
return raw;
}
/**
* Get the infos about a site by its ID.
*/
export const getSite = cache(
'api.getSite',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteById(organizationId, siteId, {
...noCacheFetchOptions,
signal: options.signal,
});
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
});
},
);
/**
* List all the site-spaces variants published in a site.
*/
export const getSiteSpaces = cache(
'api.getSiteSpaces',
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) =>
api().orgs.listSiteSpaces(
args.organizationId,
args.siteId,
{
...params,
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
},
{
...noCacheFetchOptions,
signal: options.signal,
},
),
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
data: response.data.items.map((siteSpace) => siteSpace),
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
});
},
);
/**
* List the scripts to load for the site.
*/
export const getSiteIntegrationScripts = cache(
'api.getSiteIntegrationScripts',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
...noCacheFetchOptions,
signal: options.signal,
});
return cacheResponse(response, {
revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
});
},
);
/**
* Fetch all the data to render the current site at once.
*/
export async function getCurrentSiteData(pointer: SiteContentPointer) {
const [{ space, pages, contentTarget }, { customization, scripts }] = await Promise.all([
getSpaceData(pointer),
getCurrentSiteLayoutData(pointer),
]);
return {
space,
pages,
contentTarget,
customization,
scripts,
};
}
/**
* Fetch all the layout data about the current site at once.
*/
export async function getCurrentSiteLayoutData(args: {
organizationId: string;
siteId: string;
siteSpaceId: string | undefined;
}) {
const [customization, scripts] = await Promise.all([
args.siteSpaceId
? getSiteSpaceCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
siteSpaceId: args.siteSpaceId,
})
: getSiteCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
}),
getSiteIntegrationScripts(args.organizationId, args.siteId),
]);
return {
customization,
scripts,
};
}
/**
* Get the customization settings for the current site from the API.
*/
export async function getCurrentSiteCustomization(args: {
organizationId: string;
siteId: string;
siteSpaceId: string | undefined;
}): Promise<SiteCustomizationSettings> {
return args.siteSpaceId
? getSiteSpaceCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
siteSpaceId: args.siteSpaceId,
})
: getSiteCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
});
}
/**
* Get the customization settings for a space from the API.
*/
@@ -752,11 +1027,11 @@ export const searchSpaceContent = cache(
);
/**
* Search content accross all spaces in a collection.
* Search content accross all spaces in a parent (site or collection).
*/
export const searchCollectionContent = cache(
'api.searchCollectionContent',
async (collectionId: string, query: string, options: CacheFunctionOptions) => {
export const searchParentContent = cache(
'api.searchParentContent',
async (parentId: string, query: string, options: CacheFunctionOptions) => {
const response = await api().search.searchContent(
{ query },
{
@@ -834,6 +1109,11 @@ export function getAPICacheTag(
| {
tag: 'synced-block';
syncedBlock: string;
}
// All data related to a site
| {
tag: 'site';
site: string;
},
): string {
switch (spec.tag) {
@@ -845,6 +1125,8 @@ export function getAPICacheTag(
return `collection:${spec.collection}`;
case 'synced-block':
return `synced-block:${spec.syncedBlock}`;
case 'site':
return `site:${spec.site}`;
default:
assertNever(spec);
}
+4 -4
View File
@@ -1,6 +1,6 @@
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.
@@ -238,7 +238,7 @@ export function singleton<R>(execute: () => Promise<R>): () => Promise<R> {
}
// Promises are not shared between requests in Cloudflare Workers
const ctx = await getGlobalContext();
const ctx = await getRequestContext();
const current = states.get(ctx);
if (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 fn: SingletonFunction<Key, Args, Result> = async (key, ...args) => {
const ctx = await getGlobalContext();
const ctx = await getRequestContext();
let current = states.get(ctx);
if (current) {
const existing = current.get(key);
@@ -292,7 +292,7 @@ export function singletonMap<Key extends string, Args extends any[], Result>(
};
fn.isRunning = async (key: string) => {
const ctx = await getGlobalContext();
const ctx = await getRequestContext();
const current = states.get(ctx);
return current?.has(key) ?? false;
};
+19 -1
View File
@@ -7,21 +7,24 @@ describe('cache', () => {
let fn: CacheFunction<[string], string>;
let testId = 0;
let getTtl: () => number;
beforeEach(() => {
impl.mockClear();
testId += 1;
getTtl = () => 1000;
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => {
await new Promise((resolve) => setTimeout(resolve, 20));
return {
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')]);
expect(result).toEqual(['test-a', 'test-a']);
@@ -34,6 +37,21 @@ describe('cache', () => {
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 () => {
const result = await Promise.all([fn('a'), fn('b')]);
+1 -1
View File
@@ -202,7 +202,7 @@ export function cache<Args extends any[], Result>(
if (savedEntry.meta.revalidatesAt && savedEntry.meta.revalidatesAt < Date.now()) {
// Revalidate in the background
waitUntil(revalidate(key, undefined, ...args));
await waitUntil(revalidate(key, undefined, ...args));
}
return savedEntry.data;
+5 -1
View File
@@ -26,9 +26,12 @@ export function parseCacheResponse(response: Response): {
const cacheControlHeader = response.headers.get('cache-control');
const cacheControl = cacheControlHeader ? parseCacheControl(cacheControlHeader) : null;
const cacheTagHeader = response.headers.get('x-gitbook-cache-tag');
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
const entry = {
ttl: 60 * 60 * 24,
tags: [],
tags,
};
if (cacheControl && cacheControl['max-age']) {
@@ -50,6 +53,7 @@ export function cacheResponse<Result, DefaultData = Result>(
return {
ttl: defaultEntry.ttl ?? parsed.ttl,
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
revalidateBefore: defaultEntry.revalidateBefore,
// @ts-ignore
data: defaultEntry.data ?? response.data,
};
+17 -13
View File
@@ -1,11 +1,12 @@
import { CacheBackend, CacheEntry } from './types';
import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
import { getGlobalContext } from '../waitUntil';
export const memoryCache: CacheBackend = {
name: 'memory',
replication: 'local',
async get(key) {
const memoryCache = getMemoryCache();
const memoryCache = await getMemoryCache();
const memoryEntry = memoryCache.get(key);
if (!memoryEntry) {
@@ -21,7 +22,7 @@ export const memoryCache: CacheBackend = {
return null;
},
async set(key, entry) {
const memoryCache = getMemoryCache();
const memoryCache = await getMemoryCache();
// When the entry is immutable, we can cache it for the entire duration.
// Else we cache it for a very short time.
const expiresAt =
@@ -41,11 +42,11 @@ export const memoryCache: CacheBackend = {
memoryCache.set(key, { ...entry, meta });
},
async del(keys) {
const memoryCache = getMemoryCache();
const memoryCache = await getMemoryCache();
keys.forEach((key) => memoryCache.delete(key));
},
async revalidateTags(tags) {
const memoryCache = getMemoryCache();
const memoryCache = await getMemoryCache();
const keys: string[] = [];
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.
* To share the cache between the two, we use a global variable.
* In memory cache shared globally.
*/
function getMemoryCache(): Map<string, CacheEntry> {
// @ts-ignore
if (!globalThis.gitbookMemoryCache) {
// @ts-ignore
globalThis.gitbookMemoryCache = new Map();
async function getMemoryCache(): Promise<Map<string, CacheEntry>> {
const ctx: Awaited<ReturnType<typeof getGlobalContext>> & {
gitbookMemoryCache?: Map<string, CacheEntry>;
} = await getGlobalContext();
if (ctx.gitbookMemoryCache) {
return ctx.gitbookMemoryCache;
}
// @ts-ignore
return globalThis.gitbookMemoryCache;
const gitbookMemoryCache = new Map<string, CacheEntry>();
ctx.gitbookMemoryCache = gitbookMemoryCache;
return gitbookMemoryCache;
}
+9
View File
@@ -16,6 +16,15 @@ export function hexToRgb(hex: string): string {
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
* @param {string} hex The hex code to generate shades from
+1 -1
View File
@@ -38,7 +38,7 @@ export function getContentSecurityPolicy(scripts: SpaceIntegrationScript[], nonc
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' 'unsafe-eval' ${assetsDomain} https://integrations.gitbook.com https://cdn.iframe.ly;
style-src 'self' ${assetsDomain} fonts.googleapis.com 'unsafe-inline';
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};
frame-src *;
object-src 'none';
+25
View File
@@ -0,0 +1,25 @@
export type SimplifiedFileType = 'image' | 'pdf' | 'archive';
/**
* Get a simplified content type for the given mime type.
*/
export function getSimplifiedContentType(mimeType: string): SimplifiedFileType | null {
if (mimeType.startsWith('image')) {
return 'image';
}
switch (mimeType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
}
+40 -9
View File
@@ -1,8 +1,11 @@
import 'server-only';
import fnv1a from '@sindresorhus/fnv1a';
import { noCacheFetchOptions } from '@/lib/cache/http';
import { rootUrl } from './links';
import { getImageAPIUrl } from './urls';
export interface CloudflareImageJsonFormat {
width: number;
@@ -38,7 +41,7 @@ export function isImageResizingEnabled(): boolean {
/**
* 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)) {
return false;
}
@@ -51,11 +54,16 @@ export function checkIsHttpURL(input: string): boolean {
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
*/
function checkIsSizableImageURL(input: string): boolean {
if (input.endsWith('.svg')) {
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}
return checkIsHttpURL(input);
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg')) {
return false;
}
return checkIsHttpURL(parsed);
}
/**
@@ -72,11 +80,11 @@ export async function getResizedImageURL(
return null;
}
const signature = await generateSignature(input);
const signature = await generateSignatureV1(input);
return (options) => {
const url = new URL('/~gitbook/image', rootUrl());
url.searchParams.set('url', input);
url.searchParams.set('url', getImageAPIUrl(input));
if (options.width) {
url.searchParams.set('width', options.width.toString());
@@ -92,6 +100,7 @@ export async function getResizedImageURL(
}
url.searchParams.set('sign', signature);
url.searchParams.set('sv', '1');
return url.toString();
};
@@ -100,8 +109,12 @@ export async function getResizedImageURL(
/**
* Verify a signature of an image URL
*/
export async function verifyImageSignature(input: string, signature: string): Promise<boolean> {
const expectedSignature = await generateSignature(input);
export async function verifyImageSignature(
input: string,
{ signature, version }: { signature: string; version: '1' | '0' },
): Promise<boolean> {
const expectedSignature =
version === '1' ? await generateSignatureV1(input) : await generateSignatureV0(input);
return expectedSignature === signature;
}
@@ -200,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 hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'bun:test';
import { parseMarkdown } from './markdown';
describe('parseMarkdown', () => {
it('should parse a simple table', async () => {
const result = await parseMarkdown(`## Table
| a | b | c | d |
| - | :- | -: | :-: |`);
expect(result).toContain('<table>');
});
it('should parse a complex table', async () => {
const result =
await parseMarkdown(`Returns information for all non-fungible tokens for an account.
## Ordering
When considering NFTs, their order is governed by a combination of their numerical **token.Id** and **serialnumber** values, with **token.id** being the parent column.
A serialnumbers value governs its order within the given token.id
In that regard, if a user acquired a set of NFTs in the order (2-2, 2-4 1-5, 1-1, 1-3, 3-3, 3-4), the following layouts illustrate the ordering expectations for ownership listing
1. **All NFTs in ASC order**: 1-1, 1-3, 1-5, 2-2, 2-4, 3-3, 3-4
2. **All NFTs in DESC order**: 3-4, 3-3, 2-4, 2-2, 1-5, 1-3, 1-1
3. **NFTs above 1-1 in ASC order**: 1-3, 1-5, 2-2, 2-4, 3-3, 3-4
4. **NFTs below 3-3 in ASC order**: 1-1, 1-3, 1-5, 2-2, 2-4
5. **NFTs between 1-3 and 3-3 inclusive in DESC order**: 3-4, 3-3, 2-4, 2-2, 1-5, 1-3
Note: The default order for this API is currently DESC
## Filtering
When filtering there are some restrictions enforced to ensure correctness and scalability.
**The table below defines the restrictions and support for the NFT ownership endpoint**
| Query Param | Comparison Operator | Support | Description | Example |
| ------------- | ------------------- | ------- | --------------------- | ------- |
| token.id | eq | Y | Single occurrence only. | ?token.id=X |
| | ne | N | | |
| | lt(e) | Y | Single occurrence only. | ?token.id=lte:X |
| | gt(e) | Y | Single occurrence only. | ?token.id=gte:X |
| serialnumber | eq | Y | Single occurrence only. Requires the presence of a **token.id** query | ?serialnumber=Y |
| | ne | N | | |
| | lt(e) | Y | Single occurrence only. Requires the presence of an **lte** or **eq** **token.id** query | ?token.id=lte:X&serialnumber=lt:Y |
| | gt(e) | Y | Single occurrence only. Requires the presence of an **gte** or **eq** **token.id** query | ?token.id=gte:X&serialnumber=gt:Y |
| spender.id | eq | Y | | ?spender.id=Z |
| | ne | N | | |
| | lt(e) | Y | | ?spender.id=lt:Z |
| | gt(e) | Y | | ?spender.id=gt:Z |
Note: When searching across a range for individual NFTs a **serialnumber** with an additional **token.id** query filter must be provided.
Both filters must be a single occurrence of **gt(e)** or **lt(e)** which provide a lower and or upper boundary for search.`);
expect(result).toContain('<table>');
});
});
+2
View File
@@ -1,5 +1,6 @@
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
@@ -10,6 +11,7 @@ import { unified } from 'unified';
export async function parseMarkdown(markdown: string): Promise<string> {
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
+1
View File
@@ -164,6 +164,7 @@ export async function resolveContentRef(
contentRef.space === space.id
? space
: await ignoreAPIError(getSpace(contentRef.space));
if (!targetSpace) {
return {
href: gitbookAppHref(`/s/${contentRef.space}`),
+11 -5
View File
@@ -1,4 +1,4 @@
import { Collection, ContentVisibility, Space } from '@gitbook/api';
import { Collection, ContentVisibility, Site, SiteVisibility, Space } from '@gitbook/api';
import { headers } from 'next/headers';
/**
@@ -6,10 +6,10 @@ import { headers } from 'next/headers';
*/
export function shouldIndexSpace({
space,
collection,
parent,
}: {
space: Space;
collection: Collection | null;
parent: Site | Collection | null;
}) {
const headerSet = headers();
@@ -28,12 +28,18 @@ export function shouldIndexSpace({
return false;
}
if (parent && parent.object === 'site') {
return shouldIndexVisibility(parent.visibility);
}
if (space.visibility === ContentVisibility.InCollection) {
return collection ? shouldIndexVisibility(collection.visibility) : false;
return parent && parent.object === 'collection'
? shouldIndexVisibility(parent.visibility)
: false;
}
return shouldIndexVisibility(space.visibility);
}
function shouldIndexVisibility(visibility: ContentVisibility) {
function shouldIndexVisibility(visibility: ContentVisibility | SiteVisibility) {
return visibility === ContentVisibility.Public;
}

Some files were not shown because too many files have changed in this diff Show More