Compare commits

..

41 Commits

Author SHA1 Message Date
Samy Pessé c0b339e406 Version Packages (#3075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-02 13:36:04 +02:00
Nolann B. da485f5144 Fix read-only in generateSchemaExample (#3069) 2025-04-02 13:03:51 +02:00
Nolann B. 139a8050a5 Fix OpenAPI enum display (#3077) 2025-04-02 10:15:19 +02:00
Nolann B. da7b369ffd Fix missing headers in OpenAPIResponses (#3074) 2025-04-01 21:57:36 +02:00
Samy Pessé 432da64919 Version Packages (#3073)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 18:13:52 +02:00
Nolann B. 7d0b422200 Handle grouped OpenAPISchemas (#3071) 2025-04-01 17:24:43 +02:00
Zeno Kapitein fb90eb0acc Limit tinted background on bold theme to sites with filled sidebar (#3072) 2025-04-01 16:43:08 +02:00
Samy Pessé b647538e41 Version Packages (#3033)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 16:35:09 +02:00
Samy Pessé e50797a5c1 Adapt new computed content and add tag for translation (#3070) 2025-04-01 15:10:55 +02:00
Nolann B. 5b1e01c786 Support for x-stability property (#3064) 2025-03-31 13:49:37 +02:00
Nolann B. 813b2af06b Support for x-enumDescriptions and x-gitbook-enum (#3066) 2025-03-31 12:18:57 +02:00
Nolann B. 6f71da88d8 Fix padding in OpenAPI schemas (#3067) 2025-03-31 12:03:53 +02:00
Samy Pessé fa91eb7eaf Fix dark mode PDF (#3065) 2025-03-28 18:00:33 +01:00
Zeno Kapitein c756761591 Add breadcrumbs to search results (#2772) 2025-03-28 13:29:05 +01:00
Samy Pessé 434af90366 Fix image resizing when using proxy by passing signing identifier in site URL data (#3063)
Co-authored-by: Taran Vohra <taranvohra@outlook.com>
2025-03-28 12:28:12 +01:00
Valentino Hudhra 0f41e194cb Fix dismissed annoucement banner ignored when switching sites (#3057) 2025-03-28 10:56:18 +01:00
Nolann B. bd353489e9 Fix missing alternative schemas (#3058) 2025-03-28 06:53:28 +01:00
Samy Pessé 77fd393f15 Fix click on search items when embedded in an iframe and track announcement links (#3061) 2025-03-27 20:13:14 +01:00
Taran Vohra 8701b5e078 Use the site canonical URL when rewriting in the middleware (#3060) 2025-03-27 19:39:39 +01:00
Nolann B. 61db166f92 Add OpenAPI write-only indicator (#3059) 2025-03-27 18:21:38 +01:00
Zeno Kapitein 0d9b9cea0f Fix announcement banner margin (#3056) 2025-03-27 15:59:47 +00:00
Nolann B. 7bb37c7f0a Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser (#3054) 2025-03-27 15:53:41 +01:00
Taran Vohra 5b2bf828e3 Use stable site URL data for route rewrite in the middleware (#3053) 2025-03-27 18:26:06 +05:30
Samy Pessé c85d3f9752 Serve a markdown version of the page when adding a .md extension (#3055)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-27 12:35:18 +00:00
Zeno Kapitein 4d7088bbf0 Stop variant selector from growing too large (#3043) 2025-03-27 10:42:18 +01:00
Samy Pessé dd6ab6044c Fix crash when using user mention or accessing invalid change requests (#3051) 2025-03-27 01:04:00 +01:00
Samy Pessé d236bf029c Fix flash for site with dark default mode (#3050) 2025-03-26 18:12:45 +01:00
Zeno Kapitein d70d566abe Support site announcement banner (#3008)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
2025-03-26 16:37:05 +01:00
Nolann B. a25fdedea9 Replace $ref with $reference in json-decycle (#3049) 2025-03-26 14:16:03 +00:00
Nolann B. 373183a2cd Safe parse OpenAPI JSON schema (#3046) 2025-03-26 13:55:22 +01:00
Samy Pessé e9fa50dfb5 Trim search query before doing search (#3042) 2025-03-26 10:18:18 +00:00
Samy Pessé 57ca4e0e88 Fix crash when integration is not installed on the space (#3040) 2025-03-26 10:19:57 +01:00
Samy Pessé bc90adb73a Allow indexation of images to ensure favicons display on Google (#3041) 2025-03-26 10:17:57 +01:00
Nolann B. 1505ddbd5a Fix multiple request examples selector not showing (#3039) 2025-03-25 20:48:57 +01:00
Greg Bergé cd99ed57fb Fix OpenAPI spec rendering (#3037) 2025-03-25 15:13:18 +00:00
Samy Pessé 27de1cd01d Default to the first ID as being active in scroll detection (#3036) 2025-03-25 15:39:45 +01:00
Nolann B. ae78fc5394 Fix XML in code sample (#3030) 2025-03-25 15:11:45 +01:00
Samy Pessé 40e8e69c6d Disallow crawling by web-robots of search/ask URLs (#3035) 2025-03-25 13:51:09 +00:00
Nolann B. e84a46a558 Fix OpenAPI tabs indicator overflow (#3032) 2025-03-25 10:03:58 +01:00
Samy Pessé 18a72d4466 v2: improve caching (#3031) 2025-03-25 09:40:54 +01:00
Zeno Kapitein d7aa5fa003 Remove loading state for recommended questions (#3029) 2025-03-24 19:19:12 +01:00
112 changed files with 2227 additions and 766 deletions
+18 -11
View File
@@ -49,7 +49,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.8.1",
"version": "0.8.2",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/cache-do": "workspace:*",
@@ -80,7 +80,7 @@
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"memoizee": "^0.4.17",
"next": "14.2.25",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
@@ -136,13 +136,14 @@
},
"packages/gitbook-v2": {
"name": "gitbook-v2",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"jwt-decode": "^4.0.0",
"next": "^15.2.3",
"p-memoize": "^7.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
@@ -161,7 +162,7 @@
"name": "@gitbook/icons",
"version": "0.2.0",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js"
"gitbook-icons": "./bin/gitbook-icons.js",
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0",
@@ -179,7 +180,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "2.1.0",
"version": "2.1.1",
"dependencies": {
"@scalar/openapi-parser": "^0.10.10",
"@scalar/openapi-types": "^0.1.9",
@@ -218,7 +219,7 @@
"name": "@gitbook/react-math",
"version": "0.6.0",
"bin": {
"gitbook-math": "./bin/gitbook-math.js"
"gitbook-math": "./bin/gitbook-math.js",
},
"dependencies": {
"object-hash": "^3.0.0",
@@ -233,7 +234,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.1.5",
"version": "1.1.6",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.2.5",
@@ -261,7 +262,7 @@
},
"overrides": {
"@codemirror/state": "6.4.1",
"@gitbook/api": "0.102.0",
"@gitbook/api": "0.106.0",
"react": "18.3.1",
"react-dom": "18.3.1",
},
@@ -622,7 +623,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.102.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9wPr5kyCHhTTwkaCYEMT4q3JjEl1q9mytgvpeusmeREfG4E0RZHYPEL8bmumO7T9wH/0vHjpfW2LSTUUs60AwQ=="],
"@gitbook/api": ["@gitbook/api@0.106.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-2qA/w18JwHe2fwR2A45q1pEdCZArxqUloegfNibu0xngDhea+iKTXSBrN94wD6Lwkh7cqBp9MvtxC+YMz3hx1g=="],
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
@@ -2280,7 +2281,7 @@
"mime-types": ["mime-types@3.0.0", "", { "dependencies": { "mime-db": "^1.53.0" } }, "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
@@ -2392,6 +2393,8 @@
"p-map": ["p-map@7.0.2", "", {}, "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q=="],
"p-memoize": ["p-memoize@7.1.1", "", { "dependencies": { "mimic-fn": "^4.0.0", "type-fest": "^3.0.0" } }, "sha512-DZ/bONJILHkQ721hSr/E9wMz5Am/OTJ9P6LhLFo2Tu+jL8044tgc9LwHO8g4PiaYePnlVVRAJcKmgy8J9MVFrA=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json": ["package-json@6.5.0", "", { "dependencies": { "got": "^9.6.0", "registry-auth-token": "^4.0.0", "registry-url": "^5.0.0", "semver": "^6.2.0" } }, "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ=="],
@@ -2802,7 +2805,7 @@
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"type-is": ["type-is@2.0.0", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw=="],
@@ -4042,6 +4045,8 @@
"mdast-util-gfm-task-list-item/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="],
"meow/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"micro/arg": ["arg@4.1.0", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="],
"micro/content-type": ["content-type@1.0.4", "", {}, "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="],
@@ -4070,6 +4075,8 @@
"normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"package-json/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+1 -1
View File
@@ -12,7 +12,7 @@
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.102.0"
"@gitbook/api": "0.106.0"
},
"private": true,
"scripts": {
+32 -9
View File
@@ -60,7 +60,7 @@ export function getCacheTag(
| {
tag: 'computed-document';
space: string;
integration: string;
sourceType: string;
}
/**
* All data related to the URL of a content
@@ -84,6 +84,14 @@ export function getCacheTag(
organization: string;
openAPISpec: string;
}
/**
* All data related to a translation
*/
| {
tag: 'translation';
organization: string;
translationSettings: string;
}
): string {
switch (spec.tag) {
case 'user':
@@ -99,13 +107,15 @@ export function getCacheTag(
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.integration}`;
return `space:${spec.space}:computed-document:${spec.sourceType}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
default:
assertNever(spec);
}
@@ -157,6 +167,15 @@ export function getComputedContentSourceCacheTags(
})
);
break;
case 'translation-language':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
@@ -169,18 +188,22 @@ export function getComputedContentSourceCacheTags(
getCacheTag({
tag: 'computed-document',
space: inContext.spaceId,
integration: source.integration,
sourceType: source.type,
})
);
}
// We invalidate the computed content when a new version of the integration is deployed.
tags.push(
getCacheTag({
tag: 'integration',
integration: source.integration,
})
);
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
tags.push(
getCacheTag({
tag: 'integration',
integration,
})
);
}
return tags;
}
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/colors
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0
### Minor Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.3.0",
"version": "0.3.1",
"devDependencies": {
"typescript": "^5.5.3"
},
+1 -1
View File
@@ -220,7 +220,7 @@ export function colorScale(
continue;
}
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
const chromaRatio = index < 8 ? index * 0.05 : 1;
const shade = {
L: targetL, // Blend lightness
+6
View File
@@ -1,5 +1,11 @@
# gitbook-v2
## 0.2.3
### Patch Changes
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
## 0.2.2
### Patch Changes
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook-v2",
"version": "0.2.2",
"version": "0.2.3",
"private": true,
"dependencies": {
"next": "^15.2.3",
@@ -12,7 +12,8 @@
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0"
"jwt-decode": "^4.0.0",
"p-memoize": "^7.1.1"
},
"devDependencies": {
"gitbook": "*",
@@ -20,7 +20,7 @@ export default async function SiteDynamicLayout({
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return servePagesSitemap(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveRootSitemap(context);
}
@@ -10,5 +10,5 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -0,0 +1,11 @@
import { servePageMarkdown } from '@/routes/markdownPage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageMarkdown(context, pathname);
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import type { SiteAPIToken } from '@gitbook/api';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation';
import rison from 'rison';
@@ -98,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
}
+51 -8
View File
@@ -13,12 +13,42 @@ import type {
SiteStructure,
Space,
} from '@gitbook/api';
import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
import {
type GitBookDataFetcher,
createDataFetcher,
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker } from './links';
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
*/
export type SiteURLData = Pick<
PublishedSiteContent,
| 'organization'
| 'apiToken'
| 'site'
| 'siteSpace'
| 'space'
| 'revision'
| 'changeRequest'
| 'shareKey'
| 'siteSection'
| 'siteBasePath'
| 'basePath'
> & {
/**
* Identifier used for image resizing.
*/
imagesContextId: string;
};
/**
* Generic context when rendering content.
*/
@@ -105,7 +135,7 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
*/
export function getBaseContext(input: {
siteURL: URL | string;
siteURLData: PublishedSiteContent;
siteURLData: SiteURLData;
urlMode: 'url' | 'url-host';
}) {
const { urlMode, siteURLData } = input;
@@ -139,7 +169,7 @@ export function getBaseContext(input: {
}
const imageResizer = createImageResizer({
host: siteURL.host,
imagesContextId: siteURLData.imagesContextId,
// To ensure image resizing work for proxied sites,
// we serve images from the root of the site.
linker: linker,
@@ -157,7 +187,7 @@ export function getBaseContext(input: {
*/
export async function fetchSiteContextByURLLookup(
baseContext: GitBookBaseContext,
data: PublishedSiteContent
data: SiteURLData
): Promise<GitBookSiteContext> {
return await fetchSiteContextByIds(baseContext, {
organization: data.organization,
@@ -277,7 +307,7 @@ export async function fetchSpaceContextByIds(
})
),
ids.changeRequest
? throwIfDataError(
? getDataOrNull(
dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
@@ -286,17 +316,30 @@ export async function fetchSpaceContextByIds(
: null,
]);
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
if (ids.changeRequest && !changeRequest) {
// When trying to render a change request with an invalid / non-existing ID,
// we should return a 404.
notFound();
}
const pages = await throwIfDataError(
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
const pages = await getDataOrNull(
dataFetcher.getRevisionPages({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
})
}),
// When trying to render a revision with an invalid / non-existing ID,
// we should handle gracefully the 404 and throw notFound.
ids.revision ? [404] : undefined
);
if (!pages) {
notFound();
}
return {
...baseContext,
+296 -202
View File
@@ -8,7 +8,8 @@ import {
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { wrapDataFetcherError } from './errors';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import { memoize } from './memoize';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
@@ -94,6 +95,15 @@ export function createDataFetcher(
})
);
},
getRevisionPageMarkdown(params) {
return trace('getRevisionPageMarkdown', () =>
getRevisionPageMarkdown(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
})
);
},
getReusableContent(params) {
return trace('getReusableContent', () =>
getReusableContent(input, {
@@ -165,29 +175,31 @@ export function createDataFetcher(
})
);
},
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) {
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
return trace('getUserById', () => getUserById(input, { userId }));
},
};
}
async function getUserById(input: DataFetcherInput, params: { userId: string }) {
const getUserById = memoize(async function getUserById(
input: DataFetcherInput,
params: { userId: string }
) {
'use cache';
cacheLife('days');
return trace('getUserById.uncached', () => {
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.users.getUserById(params.userId);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.users.getUserById(params.userId);
return res.data;
});
});
}
});
async function getSpace(
const getSpace = memoize(async function getSpace(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -196,24 +208,26 @@ async function getSpace(
) {
'use cache';
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
return trace('getSpace.uncached', () => {
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
});
return res.data;
});
return res.data;
});
}
});
async function getChangeRequest(
const getChangeRequest = memoize(async function getChangeRequest(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -222,23 +236,28 @@ async function getChangeRequest(
) {
'use cache';
cacheLife('minutes');
return trace('getChangeRequest.uncached', () => {
cacheLife('minutes');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getChangeRequestById(params.spaceId, params.changeRequestId);
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId
);
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
});
});
}
});
async function getRevision(
const getRevision = memoize(async function getRevision(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -248,18 +267,20 @@ async function getRevision(
) {
'use cache';
cacheLife('max');
return trace('getRevision.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
});
return res.data;
});
return res.data;
});
}
});
async function getRevisionPages(
const getRevisionPages = memoize(async function getRevisionPages(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -269,18 +290,24 @@ async function getRevisionPages(
) {
'use cache';
cacheLife('max');
return trace('getRevisionPages.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.listPagesInRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
}
);
return res.data.pages;
});
return res.data.pages;
});
}
});
async function getRevisionFile(
const getRevisionFile = memoize(async function getRevisionFile(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -290,21 +317,56 @@ async function getRevisionFile(
) {
'use cache';
cacheLife('max');
return trace('getRevisionFile.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{}
);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{}
);
return res.data;
});
});
}
});
async function getRevisionPageByPath(
const getRevisionPageMarkdown = memoize(async function getRevisionPageMarkdown(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
pageId: string;
}
) {
'use cache';
return trace('getRevisionPageMarkdown.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
format: 'markdown',
}
);
if (!('markdown' in res.data)) {
throw new DataFetcherError('Page is not a document', 404);
}
return res.data.markdown;
});
});
});
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -314,23 +376,25 @@ async function getRevisionPageByPath(
) {
'use cache';
cacheLife('max');
return trace('getRevisionPageByPath.uncached', () => {
cacheLife('max');
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{}
);
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{}
);
return res.data;
return res.data;
});
});
}
});
async function getDocument(
const getDocument = memoize(async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -339,16 +403,18 @@ async function getDocument(
) {
'use cache';
cacheLife('max');
return trace('getDocument.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
return res.data;
});
});
}
});
async function getComputedDocument(
const getComputedDocument = memoize(async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -359,29 +425,31 @@ async function getComputedDocument(
) {
'use cache';
cacheLife('days');
return trace('getComputedDocument.uncached', () => {
cacheLife('days');
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getComputedDocument(params.spaceId, {
source: params.source,
seed: params.seed,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getComputedDocument(params.spaceId, {
source: params.source,
seed: params.seed,
});
return res.data;
});
return res.data;
});
}
});
async function getReusableContent(
const getReusableContent = memoize(async function getReusableContent(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -391,48 +459,54 @@ async function getReusableContent(
) {
'use cache';
cacheLife('max');
return trace('getReusableContent.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return res.data;
});
});
}
});
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
const getLatestOpenAPISpecVersionContent = memoize(
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
}
) {
'use cache';
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
cacheTag(
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug
);
return res.data;
});
});
}
) {
'use cache';
);
cacheTag(
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug
);
return res.data;
});
}
async function getPublishedContentSite(
const getPublishedContentSite = memoize(async function getPublishedContentSite(
input: DataFetcherInput,
params: {
organizationId: string;
@@ -442,24 +516,32 @@ async function getPublishedContentSite(
) {
'use cache';
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
return trace('getPublishedContentSite.uncached', () => {
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite(params.organizationId, params.siteId, {
shareKey: params.siteShareKey,
return trace('getPublishedContentSite', () => {
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
}
);
return res.data;
});
});
return res.data;
});
}
});
async function getSiteRedirectBySource(
const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
input: DataFetcherInput,
params: {
organizationId: string;
@@ -470,26 +552,32 @@ async function getSiteRedirectBySource(
) {
'use cache';
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
return trace('getSiteRedirectBySource.uncached', () => {
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getSiteRedirectBySource(params.organizationId, params.siteId, {
shareKey: params.siteShareKey,
source: params.source,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
}
);
return res.data;
});
return res.data;
});
}
});
async function getEmbedByUrl(
const getEmbedByUrl = memoize(async function getEmbedByUrl(
input: DataFetcherInput,
params: {
url: string;
@@ -498,36 +586,40 @@ async function getEmbedByUrl(
) {
'use cache';
cacheLife('weeks');
return trace('getEmbedByUrl.uncached', () => {
cacheLife('weeks');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data;
});
});
}
});
async function searchSiteContent(
const searchSiteContent = memoize(async function searchSiteContent(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) {
'use cache';
const { organizationId, siteId, query, scope } = params;
return trace('searchSiteContent.uncached', () => {
const { organizationId, siteId, query, scope } = params;
cacheLife('days');
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
});
return res.data.items;
});
return res.data.items;
});
}
});
async function renderIntegrationUi(
const renderIntegrationUi = memoize(async function renderIntegrationUi(
input: DataFetcherInput,
params: {
integrationName: string;
@@ -536,18 +628,20 @@ async function renderIntegrationUi(
) {
'use cache';
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
cacheLife('days');
return trace('renderIntegrationUi.uncached', () => {
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost(
params.integrationName,
params.request
);
return res.data;
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost(
params.integrationName,
params.request
);
return res.data;
});
});
}
});
let loggedServiceBinding = false;
+9 -1
View File
@@ -1,5 +1,5 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath } from '@/lib/paths';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api';
import { apiClient } from './api';
@@ -36,6 +36,13 @@ export async function getPublishedContentByURL(input: {
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
// As this endpoint is cached by our API, we version the request
// to void getting stale data with missing properties.
// this could be improved by ensuring our API cache layer is versioned
// or invalidated when needed
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{
signal,
@@ -100,6 +107,7 @@ export async function getPublishedContentByURL(input: {
const siteResult: PublishedSiteContentLookup = {
...data,
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}),
@@ -0,0 +1,39 @@
import { describe, expect, it, mock } from 'bun:test';
import { memoize } from './memoize';
describe('memoize', () => {
it('should memoize the function', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
expect(await memoized()).toBe(await memoized());
});
it('should memoize the function with different arguments', async () => {
const fn = mock(async (a: number, b: number) => a + b);
const memoized = memoize(fn);
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
expect(fn.mock.calls.length).toBe(2);
});
it('should memoize a function complex object', async () => {
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
const memoized = memoize(fn);
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
await memoized({ foo: 'foo', bar: 2 })
);
expect(fn.mock.calls.length).toBe(2);
});
it('should wrap concurrent async calls', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
const promise1 = memoized();
const promise2 = memoized();
expect(await promise1).toBe(await promise2);
expect(fn.mock.calls.length).toBe(1);
});
});
@@ -0,0 +1,43 @@
import pMemoize from 'p-memoize';
/**
* We wrap 'use cache' calls in a p-memoize function to avoid
* executing the function multiple times when doing concurrent calls.
*
* Hopefully one day this can be done directly by 'use cache'.
*/
export function memoize<F extends (...args: any[]) => any>(f: F): F {
return pMemoize(f, {
cacheKey: (args) => {
return JSON.stringify(deepSortValue(args));
},
});
}
function deepSortValue(value: unknown): unknown {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (Array.isArray(value)) {
return value.map(deepSortValue);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, subValue]) => {
return [key, deepSortValue(subValue)] as const;
})
.sort((a, b) => {
return a[0].localeCompare(b[0]);
});
}
return value;
}
@@ -97,6 +97,15 @@ export interface GitBookDataFetcher {
path: string;
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
/**
* Get the markdown content of a page by its path.
*/
getRevisionPageMarkdown(params: {
spaceId: string;
revisionId: string;
pageId: string;
}): Promise<DataFetcherResponse<string>>;
/**
* Get a document by its space ID and document ID.
*/
@@ -33,13 +33,13 @@ export interface CloudflareImageOptions {
* Create an image resizer for a rendering context.
*/
export function createImageResizer({
host,
imagesContextId,
linker,
}: {
/** The linker to use to create URLs. */
linker: GitBookLinker;
/** The host name of the current site. */
host: string;
/** The site identifier to use for verifying the image signature. */
imagesContextId: string;
}): ImageResizer {
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
return createNoopImageResizer();
@@ -58,7 +58,7 @@ export function createImageResizer({
return async (options) => {
cachedSignature ??= await generateImageSignature({
host,
imagesContextId,
url: urlInput,
});
@@ -0,0 +1,14 @@
/**
* Get the site identifier to use for image resizing for an incoming request.
* This identifier can be obtained before resolving the request URL.
*/
export function getImageResizingContextId(url: URL): string {
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = url.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
}
return url.host;
}
@@ -2,3 +2,4 @@ export * from './types';
export * from './createImageResizer';
export * from './signatures';
export * from './utils';
export * from './getImageResizingContextId';
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = {
url: string;
host: string;
imagesContextId: string;
};
type SignFn = (input: SignFnInput) => MaybePromise<string>;
@@ -60,7 +60,7 @@ const generateSignatureV2: SignFn = async (input) => {
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
const all = [
input.url,
input.host, // The hostname is used to avoid serving images from other sites on the same domain
input.imagesContextId, // The hostname is used to avoid serving images from other sites on the same domain
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
]
.filter(Boolean)
+3 -2
View File
@@ -1,5 +1,6 @@
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
import { CustomizationThemeMode } from '@gitbook/api';
import { headers } from 'next/headers';
import type { SiteURLData } from './context';
export enum MiddlewareHeaders {
/**
@@ -57,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
* Get the site URL data from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
export async function getSiteURLDataFromMiddleware(): Promise<SiteURLData> {
const headersList = await headers();
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
+63 -14
View File
@@ -21,7 +21,9 @@ import {
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { getImageResizingContextId } from '@v2/lib/images';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import type { SiteURLData } from './lib/context';
export const config = {
matcher: [
@@ -63,7 +65,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return null;
}
const { url: siteURL, mode } = match;
const { url: siteRequestURL, mode } = match;
const imagesContextId = getImageResizingContextId(siteRequestURL);
/**
* Serve image resizing requests (all requests containing `/~gitbook/image`).
@@ -73,9 +76,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
*/
if (siteURL.pathname.endsWith('/~gitbook/image')) {
if (siteRequestURL.pathname.endsWith('/~gitbook/image')) {
return await serveResizedImage(request, {
host: siteURL.host,
imagesContextId: imagesContextId,
});
}
@@ -85,13 +88,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// @ts-ignore - request typing
const visitorToken = getVisitorToken({
cookies: request.cookies.getAll(),
url: siteURL,
url: siteRequestURL,
});
const withAPIToken = async (apiToken: string | null) => {
const siteURLData = await throwIfDataError(
getPublishedContentByURL({
url: siteURL.toString(),
url: siteRequestURL.toString(),
visitorAuthToken: visitorToken?.token ?? null,
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
@@ -136,11 +139,15 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
// We use the host/origin from the canonical URL to ensure the links are
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const incomingURL = mode === 'url' ? requestURL : siteURL;
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (
requestURLWithoutToken !== incomingURL &&
@@ -163,16 +170,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${siteURLData.basePath}`);
requestHeaders.set(
MiddlewareHeaders.SiteURL,
`${siteCanonicalURL.origin}${siteURLData.basePath}`
);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
// Preview of customization/theme
const customization = siteURL.searchParams.get('customization');
const customization = siteRequestURL.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Customization, customization);
}
const theme = siteURL.searchParams.get('theme');
const theme = siteRequestURL.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, theme);
@@ -188,18 +198,44 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
siteURLData.pathname
);
routeType = routeTypeFromPathname ?? routeType;
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const route = [
'sites',
routeType,
mode,
encodeURIComponent(siteURLWithoutProtocol),
encodeURIComponent(rison.encode(siteURLData)),
encodeURIComponent(
rison.encode(
// rison can't encode undefined values
Object.fromEntries(
Object.entries(stableSiteURLData).filter(
([_, v]) => typeof v !== 'undefined'
)
)
)
),
pathname,
].join('/');
@@ -226,10 +262,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
};
// For https://preview/<siteURL> requests,
if (siteURL.hostname === 'preview') {
if (siteRequestURL.hostname === 'preview') {
return serveWithQueryAPIToken(
// We scope the API token to the site ID.
`${siteURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
request,
withAPIToken
);
@@ -382,13 +418,26 @@ function encodePathInSiteContent(rawPathname: string): {
return { pathname };
}
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(/\.md$/)) {
const pagePathWithoutMD = pathname.slice(0, -3);
return {
pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
switch (pathname) {
case '~gitbook/icon':
return { pathname };
case 'llms.txt':
case 'sitemap.xml':
case 'sitemap-pages.xml':
case 'robots.txt':
return { pathname };
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
// as they only depend on the site structure / pages.
return { pathname, routeType: 'static' };
case '~gitbook/pdf':
// PDF routes are always dynamic as they depend on the search params.
return { pathname, routeType: 'dynamic' };
+61
View File
@@ -1,5 +1,66 @@
# gitbook
## 0.9.2
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- 139a805: Fix OpenAPI enum display
- Updated dependencies [da7b369]
- Updated dependencies [da485f5]
- Updated dependencies [139a805]
- @gitbook/react-openapi@1.1.9
## 0.9.1
### Patch Changes
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
- 7d0b422: Handle grouped OpenAPISchemas
- Updated dependencies [7d0b422]
- Updated dependencies [fb90eb0]
- @gitbook/react-openapi@1.1.8
- @gitbook/colors@0.3.1
## 0.9.0
### Minor Changes
- 77fd393: Track event when visitor is opening a search result.
- d70d566: Support site announcement banner
- 77fd393: Track event when clicking announcement banner link.
### Patch Changes
- e84a46a: Fix OpenAPI tabs indicator overflow
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
- 434af90: Fix image resizing when using the proxy feature in a site.
- c756761: Add breadcrumbs to search results
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 6f71da8: Fix padding in schemas
- fa91eb7: Fix PDF generation when user has dark mode configured.
- 5b1e01c: Support for x-stability property
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
- d236bf0: Fix flash when loading sites with dark mode as default theme
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- e9fa50d: Trim the search query to avoid showing a loading state when typing
- Updated dependencies [bd35348]
- Updated dependencies [ae78fc5]
- Updated dependencies [7bb37c7]
- Updated dependencies [373183a]
- Updated dependencies [1505ddb]
- Updated dependencies [61db166]
- Updated dependencies [5b1e01c]
- Updated dependencies [cd99ed5]
- Updated dependencies [813b2af]
- Updated dependencies [a25fded]
- @gitbook/react-openapi@1.1.7
- @gitbook/openapi-parser@2.1.2
## 0.8.2
### Patch Changes
+29
View File
@@ -28,6 +28,7 @@ import {
headerLinks,
runTestCases,
waitForCookiesDialog,
waitForNotFound,
} from './util';
const testCases: TestsCase[] = [
@@ -308,6 +309,18 @@ const testCases: TestsCase[] = [
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
run: waitForCookiesDialog,
},
{
name: 'Invalid revision',
url: '~/revisions/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
{
name: 'Invalid change request',
url: '~/changes/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
],
},
{
@@ -390,6 +403,22 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Markdown page',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Text page',
url: 'text-page.md',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+9 -4
View File
@@ -16,7 +16,7 @@ import {
type CustomizationThemedColor,
type SiteCustomizationSettings,
} from '@gitbook/api';
import { type BrowserContext, type Page, expect, test } from '@playwright/test';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -33,7 +33,7 @@ export interface Test {
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
@@ -138,6 +138,11 @@ export async function waitForCookiesDialog(page: Page) {
await expect(dialog).toBeVisible();
}
export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response).not.toBeNull();
expect(response?.status()).toBe(404);
}
/**
* Transform test cases into Playwright tests and run it.
*/
@@ -183,9 +188,9 @@ export function runTestCases(testCases: TestsCase[]) {
}
});
await page.goto(url);
const response = await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
await testEntry.run(page, response);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.8.2",
"version": "0.9.2",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -45,7 +45,7 @@
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"memoizee": "^0.4.17",
"next": "14.2.25",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
@@ -0,0 +1,32 @@
import { resolveContentRef } from '@/lib/references';
import type { GitBookSiteContext } from '@v2/lib/context';
import { AnnouncementBanner } from './AnnouncementBanner';
/**
* Server-side component to resolve content refs and pass down to client-side component
*/
export async function Announcement(props: {
context: GitBookSiteContext;
}) {
const { context } = props;
const { customization } = context;
if (
!customization.announcement ||
!customization.announcement.enabled ||
!customization.announcement.message
) {
return null;
}
const resolvedContentRef = customization.announcement?.link
? await resolveContentRef(customization.announcement?.link?.to, context)
: null;
return (
<AnnouncementBanner
announcement={customization.announcement}
contentRef={resolvedContentRef}
/>
);
}
@@ -0,0 +1,140 @@
'use client';
import * as storage from '@/lib/local-storage';
import type { ResolvedContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { CONTAINER_STYLE } from '../layout';
import { Link, linkStyles } from '../primitives';
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
/**
* Client-side component to enable closing the banner
*/
export function AnnouncementBanner(props: {
announcement: CustomizationAnnouncement;
contentRef: ResolvedContentRef | null;
}) {
const { announcement, contentRef } = props;
const hasLink = announcement.link && contentRef?.href;
const closeable = announcement.style !== 'danger';
const Tag = hasLink ? Link : 'div';
const style = BANNER_STYLES[announcement.style];
return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<button
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
type="button"
onClick={dismissAnnouncement}
>
<Icon icon="close" className="size-4" />
</button>
) : null}
</div>
</div>
</div>
);
}
/**
* Dismiss the announcement banner and store the dismissal state in local storage.
* @see AnnouncementScript
*/
function dismissAnnouncement() {
storage.setItem(ANNOUNCEMENT_STORAGE_KEY, {
visible: false,
at: Date.now(),
});
document.documentElement.classList.add(ANNOUNCEMENT_CSS_CLASS);
}
const BANNER_STYLES = {
info: {
container: 'bg-info ring-info-subtle',
hover: 'hover:bg-info-hover active:bg-info-active',
icon: 'circle-info',
iconColor: 'text-info-subtle',
close: 'hover:bg-tint-base hover:ring-info-subtle',
link: '',
},
warning: {
container: 'bg-warning decoration-warning/6 ring-warning-subtle',
hover: 'hover:bg-warning-hover',
icon: 'circle-exclamation',
iconColor: 'text-warning-subtle',
close: 'hover:bg-tint-base hover:ring-warning-subtle',
link: 'links-default:text-warning links-default:hover:text-warning-strong links-default:decoration-warning/6 links-accent:decoration-warning',
},
danger: {
container: 'bg-danger decoration-danger/6 ring-danger-subtle',
hover: 'hover:bg-danger-hover',
icon: 'triangle-exclamation',
iconColor: 'text-danger-subtle',
close: 'hover:bg-tint-base hover:ring-danger-subtle',
link: 'links-default:text-danger links-default:hover:text-danger-strong links-default:decoration-danger/6 links-accent:decoration-danger',
},
success: {
container: 'bg-success decoration-success/6 ring-success-subtle',
hover: 'hover:bg-success-hover',
icon: 'circle-check',
iconColor: 'text-success-subtle',
close: 'hover:bg-tint-base hover:ring-success-subtle',
link: 'links-default:text-success links-default:hover:text-success-strong links-default:decoration-success/6 links-accent:decoration-success',
},
};
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import {
ANNOUNCEMENT_CSS_CLASS,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_STORAGE_KEY,
} from './constants';
import { checkStorageForDismissedScript } from './script';
/**
* Inject a script to read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element as early as possible.
* Bypasses react state to prevent flickering.
*/
export function AnnouncementDismissedScript() {
const scriptArgs = JSON.stringify([
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS,
]).slice(1, -1);
// makes sure the banner dismissed state is kept when navigating between sites
React.useEffect(() => {
checkStorageForDismissedScript(
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS
);
}, []);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${checkStorageForDismissedScript.toString()})(${scriptArgs})`,
}}
/>
);
}
@@ -0,0 +1,12 @@
/**
* The local storage key for the announcement banner.
*/
export const ANNOUNCEMENT_STORAGE_KEY = '@gitbook/announcement';
/**
* The CSS class to hide the announcement banner. Applies to the <html> element.
*/
export const ANNOUNCEMENT_CSS_CLASS = 'announcement-hidden';
/**
* The number of days until the announcement banner resets.
*/
export const ANNOUNCEMENT_DAYS_TILL_RESET = 7;
@@ -0,0 +1,2 @@
export * from './Announcement';
export * from './AnnouncementDismissedScript';
@@ -0,0 +1,34 @@
/**
* Read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element.
*
* NOTE: this script is stringified and run in the browser, so it must be self-contained and have syntax supported in all browsers.
*/
export function checkStorageForDismissedScript(
storageKey: string,
daysTillReset: number,
cssClass: string
) {
let showBanner = true;
try {
const announcementStateStr = window.localStorage.getItem(storageKey);
const announcementState = announcementStateStr
? JSON.parse(announcementStateStr)
: undefined;
if (announcementState && !announcementState.visible) {
const dismissedAt = announcementState.at;
const nowTime = new Date().getTime();
// Check if enough days have passed since dismissal
const daysSinceDismissal = Math.floor((nowTime - dismissedAt) / (1000 * 60 * 60 * 24));
if (daysSinceDismissal < daysTillReset) {
showBanner = false;
}
}
} catch {}
if (!showBanner) {
document.documentElement.classList.add(cssClass);
}
}
@@ -54,7 +54,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;
let isOffscreen = defaultIsOffscreen;
return nodes.map((node) => {
return nodes.map((node, index) => {
isOffscreen =
isOffscreen ||
isBlockOffscreen({
@@ -65,7 +65,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
return (
<Block
key={node.key}
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'mx-auto w-full decoration-primary/6',
@@ -104,7 +104,7 @@ const HINT_STYLES: {
'[&_.can-override-text]:text-neutral-strong',
],
container:
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2',
'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
containerWithHeader: 'border-info-solid bg-info-subtle',
},
warning: {
@@ -24,14 +24,16 @@ export function Inlines<T extends DocumentInline | DocumentText>(
) {
const { nodes, document, ancestorInlines, ...contextProps } = props;
return nodes.map((node) => {
return nodes.map((node, index) => {
const key = node.key || `key-${index}`;
if (node.object === 'text') {
return <Text key={node.key} text={node} />;
return <Text key={key} text={node} />;
}
return (
<Inline
key={node.key}
key={key}
inline={node}
document={document}
ancestorInlines={ancestorInlines}
@@ -34,7 +34,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
dataFetcher.renderIntegrationUi({
integrationName: block.data.integration,
request: initialInput,
})
}),
// The API can respond with a 400 error if the integration is not installed
// and 404 if the integration is not found.
[404, 400]
);
if (!initialOutput || initialOutput.type === 'complete') {
return null;
@@ -77,7 +77,12 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
ancestorBlocks={props.ancestorBlocks}
isEstimatedOffscreen={props.isEstimatedOffscreen}
context={props.context}
style={headingProps.deprecated ? 'line-through' : undefined}
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:mt-0'
: undefined,
])}
block={{
object: 'block',
key: `${block.key}-heading`,
@@ -50,6 +50,7 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
return (
<BaseOpenAPISchemas
data={data}
grouped={block.data.grouped}
context={{
specUrl,
icons: {
@@ -20,10 +20,31 @@
@apply flex flex-col items-start justify-start gap-3;
}
.openapi-deprecated {
.openapi-summary-tags {
@apply flex flex-row gap-2 mt-[0.75em];
}
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
}
.openapi-stability-stable {
@apply text-green-600 dark:text-green-300 bg-green-50 dark:bg-green-900/6 ring-green-500/5;
}
.openapi-stability-alpha {
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
}
.openapi-stability-beta {
@apply text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/6 ring-blue-500/5;
}
.openapi-stability-experimental {
@apply text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-900/6 ring-violet-500/5;
}
.openapi-deprecated-sunset-date {
@apply font-semibold font-mono truncate;
}
@@ -131,7 +152,7 @@
/* Schema Structure */
.openapi-schema-root {
@apply mt-2;
/* unstyled */
}
.openapi-schema-properties {
@@ -209,6 +230,10 @@
@apply text-primary-subtle/9 text-[0.813rem];
}
.openapi-schema-writeonly {
@apply text-success dark:text-success-subtle/9 text-[0.813rem];
}
.openapi-schema-type {
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
}
@@ -240,11 +265,11 @@
/* Schema Enum */
.openapi-schema-enum {
@apply flex flex-row text-sm leading-relaxed flex-wrap text-tint;
@apply text-sm leading-relaxed max-w-full text-tint;
}
.openapi-schema-enum-value {
@apply text-sm;
@apply text-sm mr-1.5;
}
.openapi-schema-enum-value:first-child {
@@ -467,13 +492,9 @@
/* Common Elements */
.openapi-select {
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 border border-tint-subtle bg-tint;
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
}
.openapi-select {
min-width: fit-content;
max-width: fit-content;
}
.openapi-select:focus {
width: auto;
}
@@ -521,7 +542,7 @@
/* Tabs */
.openapi-tabs-list {
@apply flex flex-row gap-1.5 py-1.5 px-2.5 border-b border-tint-subtle w-full overflow-x-scroll;
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -531,11 +552,12 @@
}
.openapi-tabs-tab[aria-selected="true"] {
@apply text-primary after:absolute after:-bottom-2 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
}
.openapi-tabs-panel {
@apply flex-1 text-sm focus-visible:outline-none;
@apply flex-1 text-sm relative focus-visible:outline-none;
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
.openapi-tabs-footer {
@@ -654,7 +676,8 @@
@apply border border-tint-subtle rounded-lg;
}
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema {
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema,
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root {
@apply p-2.5;
}
@@ -691,3 +714,7 @@
opacity: 0;
}
}
.openapi-copy-button {
@apply hover:brightness-95;
}
@@ -12,7 +12,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const tabs: TabsItem[] = [];
const tabsBody: React.ReactNode[] = [];
block.nodes.forEach((tab) => {
block.nodes.forEach((tab, index) => {
tabs.push({
id: tab.meta?.id ?? tab.key!,
title: tab.data.title ?? '',
@@ -20,6 +20,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
tabsBody.push(
<Blocks
key={tab.key ?? index}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
@@ -38,7 +38,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'theme-bold-tint:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
@@ -63,7 +63,7 @@ export function SpacesDropdown(props: {
className
)}
>
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
<span className={tcls('line-clamp-1', 'grow')}>{siteSpace.title}</span>
<DropdownChevron />
</div>
)}
@@ -1,5 +1,4 @@
import {
type CustomizationSettings,
type Revision,
type RevisionPageDocument,
type RevisionPageGroup,
@@ -24,7 +23,7 @@ import { tString } from '@/intl/translate';
import { getPagePDFContainerId } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { PageControlButtons } from './PageControlButtons';
@@ -57,7 +56,7 @@ export async function PDFPage(props: {
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomizationForSpace();
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = getSpaceLanguage(customization);
// Compute the pages to render
@@ -180,7 +179,7 @@ export async function PDFPage(props: {
async function PDFSpaceIntro(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
}) {
const { space, customization } = props;
@@ -1,5 +1,6 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import { CustomizationThemeMode } from '@gitbook/api';
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
/**
@@ -14,8 +15,9 @@ export async function PDFRootLayout(props: {
return (
<CustomizationRootLayout
customization={
'customization' in context ? context.customization : defaultCustomizationForSpace()
'customization' in context ? context.customization : defaultCustomization()
}
forcedTheme={CustomizationThemeMode.Light}
>
{children}
</CustomizationRootLayout>
@@ -228,7 +228,7 @@ export function PageAside(props: {
) : null}
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-bold-tint:bg-tint-subtle theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex',
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'page-api-block:xl:max-2xl:bg-transparent'
)}
>
@@ -111,7 +111,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'hover:bg-primary-hover',
'theme-muted:hover:bg-primary-active',
'theme-bold-tint:hover:bg-primary-active',
'[html.sidebar-filled.theme-bold.tint_&]:hover:bg-primary-active',
'theme-gradient:hover:bg-primary-active',
'tint:font-semibold',
@@ -2,9 +2,9 @@ import {
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationIconsStyle,
type CustomizationSettings,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
type CustomizationThemedColor,
type CustomizationTint,
type SiteCustomizationSettings,
@@ -34,16 +34,18 @@ import { ClientContexts } from './ClientContexts';
import '@gitbook/icons/style.css';
import './globals.css';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
import { AnnouncementDismissedScript } from '../Announcement';
/**
* Layout shared between the content and the PDF renderer.
* It takes care of setting the theme and the language.
*/
export async function CustomizationRootLayout(props: {
customization: SiteCustomizationSettings | CustomizationSettings;
forcedTheme?: CustomizationThemeMode | null;
customization: SiteCustomizationSettings;
children: React.ReactNode;
}) {
const { customization, children } = props;
const { customization, forcedTheme, children } = props;
const language = getSpaceLanguage(customization);
const tintColor = getTintColor(customization);
@@ -85,7 +87,12 @@ export async function CustomizationRootLayout(props: {
'links' in customization.styling && `links-${customization.styling.links}`,
fontNotoColorEmoji.variable,
ibmPlexMono.variable,
fontData.type === 'default' ? fontData.variable : 'font-custom'
fontData.type === 'default' ? fontData.variable : 'font-custom',
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
? 'dark'
: ''
)}
>
<head>
@@ -96,6 +103,11 @@ export async function CustomizationRootLayout(props: {
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
{/* Inject a script to detect if the announcmeent banner has been dismissed */}
{'announcement' in customization && customization.announcement?.enabled ? (
<AnnouncementDismissedScript />
) : null}
<style
nonce={
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
@@ -141,9 +153,9 @@ export async function CustomizationRootLayout(props: {
</head>
<body
className={tcls(
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'bg-tint-base',
'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint'
@@ -175,7 +187,7 @@ export async function CustomizationRootLayout(props: {
* If the tint color is not set or it is a space customization, it will return the default color.
*/
function getTintColor(
customization: CustomizationSettings | SiteCustomizationSettings
customization: SiteCustomizationSettings
): CustomizationTint['color'] | undefined {
if ('tint' in customization.styling && customization.styling.tint) {
return {
@@ -222,7 +234,7 @@ function getTintMixColor(
* If it is a space customization, it will return the default styles.
*/
function getSidebarStyles(
customization: CustomizationSettings | SiteCustomizationSettings
customization: SiteCustomizationSettings
): SiteCustomizationSettings['styling']['sidebar'] {
if ('sidebar' in customization.styling) {
return {
@@ -242,7 +254,7 @@ function getSidebarStyles(
* If it is a space customization, it will return the default styles.
*/
function getSemanticColors(
customization: CustomizationSettings | SiteCustomizationSettings
customization: SiteCustomizationSettings
): Pick<
SiteCustomizationSettings['styling'],
'infoColor' | 'successColor' | 'warningColor' | 'dangerColor'
@@ -160,3 +160,7 @@ html {
html.dark {
color-scheme: dark light;
}
html.announcement-hidden .announcement-banner {
@apply hidden;
}
@@ -189,6 +189,9 @@ function SearchModalBody(
setSearchState((state) => (state ? { ...state, ask: true } : null));
};
// We trim the query to avoid invalidating the search when the user is typing between words.
const normalizedQuery = state.query.trim();
return (
<motion.div
transition={{
@@ -288,12 +291,14 @@ function SearchModalBody(
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={state.query}
query={normalizedQuery}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
) : null}
{state.query && state.ask && withAsk ? <SearchAskAnswer query={state.query} /> : null}
{normalizedQuery && state.ask && withAsk ? (
<SearchAskAnswer query={normalizedQuery} />
) : null}
</motion.div>
);
}
@@ -1,7 +1,6 @@
import { Icon } from '@gitbook/icons';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
import { Link } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
@@ -17,6 +16,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
) {
const { query, item, active } = props;
const breadcrumbs =
item.breadcrumbs?.map((crumb) => (
<span key={crumb.label} className="flex items-center gap-1">
{crumb.icon ? <Icon className="size-3" icon={crumb.icon as IconName} /> : null}
{crumb.label}
</span>
)) ?? [];
return (
<Link
ref={ref}
@@ -38,6 +45,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
: null
)}
insights={{
type: 'search_open_result',
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}}
>
<div className="size-4">
<Icon
@@ -46,18 +61,44 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
/>
</div>
<div className={tcls('flex', 'flex-col', 'w-full')}>
{item.spaceTitle ? (
{breadcrumbs.length > 0 ? (
<div
className={tcls(
'text-xs',
'opacity-6',
'contrast-more:opacity-11',
'font-normal',
'uppercase',
'tracking-wider',
'mb-1'
'mb-1',
'flex',
'flex-wrap',
'gap-x-2',
'gap-y-1',
'items-center'
)}
>
{item.spaceTitle}
{(breadcrumbs.length > 3
? [
...breadcrumbs.slice(0, 2),
<Icon key="ellipsis" icon="ellipsis-h" className="size-3" />,
...breadcrumbs.slice(-1),
]
: breadcrumbs
).map((crumb, index) => (
<>
{index !== 0 ? (
<Icon
key={`${crumb.key}-icon`}
icon="chevron-right"
className="size-3"
/>
) : null}
<span key={crumb.key} className="line-clamp-1">
{crumb}
</span>
</>
))}
</div>
) : null}
<HighlightQuery query={query} text={item.title} />
@@ -77,7 +77,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
let cancelled = false;
setResultsState({ results: [], fetching: true });
// Silently fetch the recommended questions, instead of showing a spinner
setResultsState({ results: [], fetching: false });
// We currently have a bug where the same question can be returned multiple times.
// This is a workaround to avoid that.
@@ -40,6 +40,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
'hover:bg-primary-hover',
]
)}
insights={{
type: 'search_open_result',
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}}
>
<div
className={tcls(
@@ -1,11 +1,17 @@
'use server';
import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import type {
RevisionPage,
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
SearchPageResult,
SiteSpace,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
Space,
} from '@gitbook/api';
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
@@ -13,13 +19,9 @@ import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/li
import { createStreamableValue } from 'ai/rsc';
import type * as React from 'react';
import { getAbsoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import { joinPathWithBaseURL } from '@/lib/paths';
import { isV2 } from '@/lib/v2';
import type { IconName } from '@gitbook/icons';
import { throwIfDataError } from '@v2/lib/data';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { DocumentView } from '../DocumentView';
@@ -30,18 +32,24 @@ export interface ComputedSectionResult {
type: 'section';
id: string;
title: string;
href: string;
body: string;
href: string;
pageId: string;
spaceId: string;
}
export interface ComputedPageResult {
type: 'page';
id: string;
title: string;
href: string;
/** When part of a multi-spaces search, the title of the space */
spaceTitle?: string;
pageId: string;
spaceId: string;
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
}
export interface AskAnswerSource {
@@ -252,11 +260,27 @@ async function searchSiteContent(
return (
await Promise.all(
searchResults.map(async (spaceItem) => {
const sections = getSiteStructureSections(structure).flatMap((item) =>
item.object === 'site-section-group' ? [item, ...item.sections] : item
);
const siteSpace = findSiteSpaceById(structure, spaceItem.id);
const siteSection = sections.find(
(section) => section.id === siteSpace?.section
) as SiteSection;
const siteSectionGroup = siteSection?.sectionGroup
? sections.find((sectionGroup) => sectionGroup.id === siteSection.sectionGroup)
: null;
return Promise.all(
spaceItem.pages.map((item) =>
transformSitePageResult(item, siteSpace ?? undefined)
spaceItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem,
space: siteSpace?.space,
spaceURL: siteSpace?.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
)
);
})
@@ -297,7 +321,7 @@ async function transformAnswer(
const spaceURL = siteSpace?.urls.published;
const href = spaceURL
? await getURLWithSections(page.page.path, spaceURL)
? joinPathWithBaseURL(spaceURL, page.page.path)
: context.linker.toPathForPage({
pages,
page: page.page,
@@ -330,55 +354,59 @@ async function transformAnswer(
};
}
async function transformSectionsAndPage(args: {
item: SearchPageResult;
space?: Space;
spaceURL?: string;
}): Promise<[ComputedPageResult, ComputedSectionResult[]]> {
const { item, space, spaceURL } = args;
const sections = await Promise.all(
item.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${item.id}/${section.id}`,
title: section.title,
href: await getURLWithSections(section.path, spaceURL),
body: section.body,
})) ?? []
);
async function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): Promise<OrderedComputedResult[]> {
const { pageItem, spaceItem, space, spaceURL, siteSection, siteSectionGroup } = args;
const { linker } = context;
const page: ComputedPageResult = {
type: 'page',
id: item.id,
title: item.title,
href: await getURLWithSections(item.path, spaceURL),
spaceTitle: space?.title,
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
},
(!siteSection || siteSection?.siteSpaces.length > 1) && space
? {
label: space?.title,
}
: undefined,
].filter((item) => item !== undefined),
};
return [page, sections];
}
const pageSections = await Promise.all(
pageItem.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? []
);
async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteSpace) {
const [page, sections] = await transformSectionsAndPage({
item,
space: siteSpace?.space,
spaceURL: siteSpace?.urls.published,
});
return [page, ...sections];
}
// Resolve a relative path to an absolute URL
// if the search result is relative to another space, we use the space URL
async function getURLWithSections(path: string, spaceURL?: string) {
if (spaceURL) {
if (!spaceURL.endsWith('/')) {
spaceURL += '/';
}
if (path.startsWith('/')) {
path = path.slice(1);
}
return spaceURL + path;
}
return getAbsoluteHref(path);
return [page, ...pageSections];
}
@@ -32,7 +32,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
<nav
aria-label="Sections"
className={tcls(
'-mx-5 before:contents[] relative border-tint-subtle border-b from-transparent sidebar-filled:to-tint-subtle theme-bold-tint:to-tint-subtle theme-muted:to-tint-subtle to-tint-base text-sm text-tint before:pointer-events-none before:absolute before:right-2 before:bottom-0 before:left-0 before:h-12 before:bg-gradient-to-b [html.sidebar-filled.theme-bold.tint_&]:to-tint-base [html.sidebar-filled.theme-muted_&]:to-tint-base',
'-mx-5 before:contents[] relative border-tint-subtle border-b from-transparent sidebar-filled:to-tint-subtle theme-muted:to-tint-subtle to-tint-base text-sm text-tint before:pointer-events-none before:absolute before:right-2 before:bottom-0 before:left-0 before:h-12 before:bg-gradient-to-b [html.sidebar-filled.theme-bold.tint_&]:to-tint-base [html.sidebar-filled.theme-bold.tint_&]:to-tint-subtle [html.sidebar-filled.theme-muted_&]:to-tint-base',
className
)}
>
@@ -13,6 +13,7 @@ import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
@@ -62,6 +63,7 @@ export function SpaceLayout(props: {
spaceId={context.space.id}
visitorAuthClaims={visitorAuthClaims}
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} />
<div className="scroll-nojump">
<div
@@ -40,7 +40,7 @@ export function PageGroupItem(props: {
'bg-tint-base',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
@@ -75,7 +75,7 @@ export function TableOfContents(props: {
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'page-no-toc:!bg-transparent',
@@ -1,5 +1,4 @@
import type {
CustomizationSettings,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
@@ -16,7 +15,7 @@ import { Link } from '../primitives';
*/
export function Trademark(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
placement: SiteInsightsTrademarkPlacement;
}) {
return (
@@ -53,7 +52,7 @@ export function Trademark(props: {
'before:to-tint-base',
'sidebar-filled:before:to-tint-subtle',
'theme-muted:before:to-tint-subtle',
'theme-bold-tint:before:to-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
'page-no-toc:before:!to-transparent'
@@ -69,7 +68,7 @@ export function Trademark(props: {
*/
export function TrademarkLink(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
placement: SiteInsightsTrademarkPlacement;
}) {
const { space, customization, placement } = props;
@@ -79,7 +79,7 @@ function ThemeButton(props: {
active && [
'bg-primary',
'theme-muted:bg-primary-hover',
'theme-bold-tint:bg-primary-hover',
'[html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover',
'hover:bg-primary',
'text-primary-strong',
'contrast-more:text-primary-strong',
@@ -12,11 +12,12 @@ export function useScrollActiveId(
) {
const { rootMargin, threshold = 0.5 } = options;
const [activeId, setActiveId] = React.useState<string | null>(null);
const [activeId, setActiveId] = React.useState<string>(ids[0]);
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
React.useEffect(() => {
setActiveId(null);
const defaultActiveId = ids[0];
setActiveId((activeId) => (ids.indexOf(activeId) !== -1 ? activeId : defaultActiveId));
if (typeof IntersectionObserver === 'undefined') {
return;
@@ -16,7 +16,8 @@ export type LinkInsightsProps = {
insights?:
| TrackEventInput<'ad_click'>
| TrackEventInput<'link_click'>
| TrackEventInput<'trademark_click'>;
| TrackEventInput<'trademark_click'>
| TrackEventInput<'search_open_result'>;
};
export type LinkProps = Omit<BaseLinkProps, 'href'> &
@@ -2,6 +2,26 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { Link, type LinkProps } from '../primitives/Link';
export const linkStyles = [
'underline',
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
'underline-offset-2',
'links-accent:underline-offset-4',
'links-default:decoration-primary/6',
'links-default:text-primary-subtle',
'links-default:hover:text-primary-strong',
'links-default:contrast-more:text-primary',
'links-default:contrast-more:hover:text-primary-strong',
'links-accent:decoration-primary-subtle',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:[text-decoration-skip-ink:none]',
'transition-all',
'duration-100',
];
/**
* Styled version of Link component.
*/
@@ -9,29 +29,7 @@ export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValu
const { style, ...rest } = props;
return (
<Link
{...rest}
className={tcls(
'underline',
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
'underline-offset-2',
'links-accent:underline-offset-4',
'links-default:decoration-primary/6',
'links-default:text-primary-subtle',
'links-default:hover:text-primary-strong',
'links-default:contrast-more:text-primary',
'links-default:contrast-more:hover:text-primary-strong',
'links-accent:decoration-primary-subtle',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:[text-decoration-skip-ink:none]',
'transition-all',
'duration-100',
style
)}
>
<Link {...rest} className={tcls(linkStyles, style)}>
{props.children}
</Link>
);
@@ -6,6 +6,7 @@ import { generateFontFacesCSS, getFontSourcesToPreload } from './custom';
const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
basic: {
id: 'open-sans',
custom: true,
fontFamily: 'Open Sans',
fontFaces: [
{
@@ -31,6 +32,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
multiWeight: {
id: 'roboto',
custom: true,
fontFamily: 'Roboto',
fontFaces: [
{
@@ -83,6 +85,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
multiSource: {
id: 'lato',
custom: true,
fontFamily: 'Lato',
fontFaces: [
{
@@ -100,6 +103,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
missingFormat: {
id: 'source-sans',
custom: true,
fontFamily: 'Source Sans Pro',
fontFaces: [
{
@@ -117,12 +121,14 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
empty: {
id: 'empty-font',
custom: true,
fontFamily: 'Empty Font',
fontFaces: [],
},
specialChars: {
id: 'special-font',
custom: true,
fontFamily: 'Special Font & Co.',
fontFaces: [
{
@@ -134,6 +140,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
complex: {
id: 'complex-font',
custom: true,
fontFamily: 'Complex Font',
fontFaces: [
{
@@ -155,6 +162,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
variousURLs: {
id: 'various-urls',
custom: true,
fontFamily: 'Various URLs Font',
fontFaces: [
{
+2 -4
View File
@@ -1,4 +1,4 @@
import type { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { type TranslationLanguage, languages } from './translations';
@@ -7,9 +7,7 @@ export * from './translate';
/**
* Create the translation context for a space to use in the server components.
*/
export function getSpaceLanguage(
customization: CustomizationSettings | SiteCustomizationSettings
): TranslationLanguage {
export function getSpaceLanguage(customization: SiteCustomizationSettings): TranslationLanguage {
const fallback = languages.en;
const { locale } = customization.internationalization;
+3 -2
View File
@@ -1,4 +1,5 @@
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import type { SiteAPIToken } from '@gitbook/api';
import type { SiteURLData } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
/**
@@ -9,7 +10,7 @@ export type VisitorAuthClaims = Record<string, any>;
/**
* Get the visitor auth claims from the API response obtained from `getPublishedContentByUrl`.
*/
export function getVisitorAuthClaims(siteData: PublishedSiteContent): VisitorAuthClaims {
export function getVisitorAuthClaims(siteData: SiteURLData): VisitorAuthClaims {
const { apiToken } = siteData;
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(apiToken));
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
OpenAPISchemasBlock,
ResolveOpenAPIBlockArgs,
} from '@/lib/openapi/types';
import { memoize } from '@v2/lib/data/memoize';
import { assert } from 'ts-essentials';
import { resolveContentRef } from '../references';
import { isV2 } from '../v2';
@@ -65,7 +66,7 @@ const fetchFilesystemV1 = cache({
},
});
async function fetchFilesystemV2(url: string) {
const fetchFilesystemV2 = memoize(async function fetchFilesystemV2(url: string) {
'use cache';
// TODO: add cache lifetime once we can use next.js 15 code here
@@ -73,7 +74,7 @@ async function fetchFilesystemV2(url: string) {
const response = await fetchFilesystemUncached(url);
return response;
}
});
async function fetchFilesystemUncached(
url: string,
+1 -1
View File
@@ -104,7 +104,7 @@ export function resolvePrevNextPages(
}
/**
* Resolve a page to its canonical path.
* Resolve a page to its canonical path. The first page of the site will return "".
* The path will NOT start with "/".
*/
export function getPagePath(
+17
View File
@@ -5,6 +5,23 @@ export function joinPath(...parts: string[]): string {
return parts.join('/').replace(/\/+/g, '/');
}
/**
* Join a path with a base URL.
*/
export function joinPathWithBaseURL(baseURL: string, path: string): string {
if (!path) {
return baseURL;
}
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
if (path.startsWith('/')) {
path = path.slice(1);
}
return baseURL + path;
}
/**
* Remove the trailing slash from a path.
*/
+7 -2
View File
@@ -28,16 +28,21 @@ export async function trace<T>(
};
const start = now();
let failed = false;
try {
return await fn(span);
} catch (error) {
span.setAttribute('error', true);
failed = true;
throw error;
} finally {
if (process.env.SILENT !== 'true') {
if (process.env.SILENT !== 'true' && process.env.NODE_ENV !== 'development') {
const end = now();
// biome-ignore lint/suspicious/noConsole: we want to log performance data
console.log(`trace ${completeName} ${end - start}ms`, attributes);
console.log(
`trace ${completeName} ${failed ? 'failed' : 'succeeded'} in ${end - start}ms`,
attributes
);
}
}
}
+32 -20
View File
@@ -1,22 +1,30 @@
import * as api from '@gitbook/api';
/**
* Return the customizations with the default values for a space.
* Return the default customization settings for a site.
*/
export function defaultCustomizationForSpace(): api.CustomizationSettings {
export function defaultCustomization(): api.SiteCustomizationSettings {
return {
internationalization: {
inherit: false,
locale: api.CustomizationLocale.En,
},
styling: {
primaryColor: {
dark: '#346DDB',
light: '#346DDB',
},
theme: api.CustomizationTheme.Clean,
primaryColor: { light: '#346DDB', dark: '#346DDB' },
infoColor: { light: '#787878', dark: '#787878' },
warningColor: { light: '#FE9A00', dark: '#FE9A00' },
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
successColor: { light: '#00C950', dark: '#00C950' },
corners: api.CustomizationCorners.Rounded,
font: api.CustomizationDefaultFont.Inter,
background: api.CustomizationBackground.Plain,
icons: api.CustomizationIconsStyle.Regular,
links: api.CustomizationLinksStyle.Default,
sidebar: {
background: api.CustomizationSidebarBackgroundStyle.Default,
list: api.CustomizationSidebarListStyle.Default,
},
search: api.CustomizationSearchStyle.Subtle,
},
internationalization: {
locale: api.CustomizationLocale.En,
},
favicon: {},
header: {
@@ -28,28 +36,32 @@ export function defaultCustomizationForSpace(): api.CustomizationSettings {
},
themes: {
default: api.CustomizationThemeMode.Light,
toggeable: false,
toggeable: true,
},
trademark: {
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
pdf: {
enabled: true,
},
aiSearch: {
enabled: true,
},
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
privacyPolicy: {},
socialPreview: {},
git: {
showEditLink: false,
trademark: {
enabled: true,
},
inherit: false,
privacyPolicy: {
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
};
}
+5 -1
View File
@@ -59,7 +59,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
const dataFetcher = await getDataFetcherV1();
const imageResizer = createImageResizer({
host,
imagesContextId: host,
// In V1, we always resize at the top level of the hostname, not relative to the content.
linker: createLinker({
host,
@@ -153,6 +153,10 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
});
},
getRevisionPageMarkdown() {
throw new Error('Not implemented in v1');
},
getDocument(params) {
return wrapDataFetcherError(async () => {
const document = await getDocument(params.spaceId, params.documentId);
+1
View File
@@ -564,6 +564,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
apiEndpoint,
contextId,
cookies,
canonicalUrl: url.toString(),
};
}
+6 -6
View File
@@ -17,9 +17,9 @@ export async function serveResizedImage(
request: Request,
requestOptions: {
/**
* The host to use for the image.
* The site identifier to use for verifying the image signature.
*/
host?: string;
imagesContextId?: string;
} = {}
) {
const requestURL = new URL(request.url);
@@ -45,8 +45,8 @@ export async function serveResizedImage(
}
// Verify the signature
const host =
requestOptions.host ??
const imagesContextId =
requestOptions.imagesContextId ??
request.headers.get('x-gitbook-host') ?? // Only for v1, to be removed
request.headers.get('x-forwarded-host') ??
request.headers.get('host') ??
@@ -54,13 +54,13 @@ export async function serveResizedImage(
const verified = await verifyImageSignature(
{
url,
host,
imagesContextId,
},
{ signature, version: signatureVersion }
);
if (!verified) {
return new Response(
`Invalid signature "${signature ?? ''}" (version ${signatureVersion}) for "${url}" on "${host}"`,
`Invalid signature "${signature ?? ''}" (version ${signatureVersion}) for "${url}" on identifier "${imagesContextId}"`,
{ status: 400 }
);
}
+43 -14
View File
@@ -1,19 +1,27 @@
import { joinPath } from '@/lib/paths';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/sites';
import type { SiteSection, SiteSpace } from '@gitbook/api';
import { type GitBookSiteContext, checkIsRootSiteContext } from '@v2/lib/context';
import { throwIfDataError } from '@v2/lib/data';
import assertNever from 'assert-never';
import type { ListItem, Paragraph, Root, RootContent } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/sites';
import { type GitBookSiteContext, checkIsRootSiteContext } from '@v2/lib/context';
import { throwIfDataError } from '@v2/lib/data';
/**
* Generate a llms.txt file for the site.
*/
export async function serveLLMsTxt(context: GitBookSiteContext) {
export async function serveLLMsTxt(
context: GitBookSiteContext,
{
withMarkdownPages = false,
}: {
/**
* If true, a markdown extension will be added to the page path.
*/
withMarkdownPages?: boolean;
} = {}
) {
const { site } = context;
if (!checkIsRootSiteContext(context)) {
@@ -28,7 +36,7 @@ export async function serveLLMsTxt(context: GitBookSiteContext) {
depth: 1,
children: [{ type: 'text', value: site.title }],
},
...(await getNodesFromSiteStructure(context)),
...(await getNodesFromSiteStructure(context, { withMarkdownPages })),
],
};
@@ -47,15 +55,24 @@ export async function serveLLMsTxt(context: GitBookSiteContext) {
/**
* Get MDAST nodes from site structure.
*/
async function getNodesFromSiteStructure(context: GitBookSiteContext): Promise<RootContent[]> {
async function getNodesFromSiteStructure(
context: GitBookSiteContext,
options: {
withMarkdownPages: boolean;
}
): Promise<RootContent[]> {
switch (context.structure.type) {
case 'sections':
return getNodesFromSections(
context,
getSiteStructureSections(context.structure, { ignoreGroups: true })
getSiteStructureSections(context.structure, { ignoreGroups: true }),
{ withMarkdownPages: options.withMarkdownPages }
);
case 'siteSpaces':
return getNodesFromSiteSpaces(context, context.structure.structure, { heading: true });
return getNodesFromSiteSpaces(context, context.structure.structure, {
heading: true,
withMarkdownPages: options.withMarkdownPages,
});
default:
assertNever(context.structure);
}
@@ -66,12 +83,16 @@ async function getNodesFromSiteStructure(context: GitBookSiteContext): Promise<R
*/
async function getNodesFromSections(
context: GitBookSiteContext,
siteSections: SiteSection[]
siteSections: SiteSection[],
options: {
withMarkdownPages: boolean;
}
): Promise<RootContent[]> {
const all = await Promise.all(
siteSections.map(async (siteSection): Promise<RootContent[]> => {
const siteSpaceNodes = await getNodesFromSiteSpaces(context, siteSection.siteSpaces, {
heading: false,
withMarkdownPages: options.withMarkdownPages,
});
return [
{
@@ -97,6 +118,11 @@ async function getNodesFromSiteSpaces(
* Includes a heading for each site space.
*/
heading?: boolean;
/**
* If true, a markdown extension will be added to the page path.
*/
withMarkdownPages: boolean;
}
): Promise<RootContent[]> {
const { dataFetcher, linker } = context;
@@ -118,7 +144,10 @@ async function getNodesFromSiteSpaces(
const listChildren = await Promise.all(
pages.map(async ({ page }): Promise<ListItem> => {
const pageURL = new URL(siteSpaceUrl);
pageURL.pathname = joinPath(pageURL.pathname, getPagePath(rootPages, page));
pageURL.pathname = joinPath(pageURL.pathname, page.path);
if (options.withMarkdownPages) {
pageURL.pathname = `${pageURL.pathname}.md`;
}
const url = linker.toLinkForContent(pageURL.toString());
const children: Paragraph['children'] = [
@@ -0,0 +1,34 @@
import { resolvePagePath } from '@/lib/pages';
import { RevisionPageType } from '@gitbook/api';
import type { GitBookSiteContext } from '@v2/lib/context';
import { throwIfDataError } from '@v2/lib/data';
/**
* Generate a markdown version of a page.
*/
export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) {
const pageLookup = resolvePagePath(context.pages, pagePath);
if (!pageLookup) {
return new Response(`Page "${pagePath}" not found`, { status: 404 });
}
const { page } = pageLookup;
if (page.type !== RevisionPageType.Document) {
return new Response(`Page "${pagePath}" is not a document`, { status: 404 });
}
const markdown = await throwIfDataError(
context.dataFetcher.getRevisionPageMarkdown({
spaceId: context.space.id,
revisionId: context.revisionId,
pageId: page.id,
})
);
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
},
});
}
+2 -1
View File
@@ -10,7 +10,8 @@ export async function serveRobotsTxt(context: GitBookSiteContext) {
const isRoot = checkIsRootSiteContext(context);
const lines = [
'User-agent: *',
'Disallow: /~gitbook/',
// Disallow dynamic routes / search queries
'Disallow: /*?',
...((await isSiteIndexable(context))
? [
'Allow: /',
+9
View File
@@ -1,3 +1,12 @@
declare module 'memoizee' {
declare function memoizee<F extends (...args: any[]) => any>(
f: F,
options?: { normalizer?: (args: any[]) => string }
): F;
export = memoizee;
}
declare module 'memoizee/weak' {
declare function memoizee<F extends (...args: any[]) => any>(f: F): F;
+8
View File
@@ -1,5 +1,13 @@
# @gitbook/openapi-parser
## 2.1.2
### Patch Changes
- 7bb37c7: Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser
- 5b1e01c: Support for x-stability property
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
## 2.1.1
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js"
}
},
"version": "2.1.1",
"version": "2.1.2",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.10.10",
+1
View File
@@ -6,4 +6,5 @@ export type * from '@scalar/openapi-types';
export * from './error';
export * from './helpers/shouldIgnoreEntity';
export * from './traverse';
export * from './schemas';
export type * from './types';
+27
View File
@@ -0,0 +1,27 @@
import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
import type { OpenAPISchema } from './types';
/**
* Extract selected schemas from the OpenAPI document.
*/
export function filterSelectedOpenAPISchemas(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
selectedSchemas: string[]
): OpenAPISchema[] {
const componentsSchemas = schema.components?.schemas ?? {};
// Preserve the order of the selected schemas
return selectedSchemas
.map((name) => {
const schema = componentsSchemas[name];
if (schema && !shouldIgnoreEntity(schema)) {
return {
name,
schema,
};
}
return null;
})
.filter((schema): schema is OpenAPISchema => !!schema);
}
+28
View File
@@ -47,8 +47,31 @@ export interface OpenAPICustomOperationProperties {
* Description in Document format.
*/
'x-gitbook-description-document'?: object;
/**
* Enums with name and description
*/
'x-enumDescriptions'?: object;
/**
* Enums with name and description
*/
'x-gitbook-enum'?: {
[key: string]: {
description?: string;
name?: string;
};
};
/**
* Stability of the operation.
* @enum 'experimental' | 'alpha' | 'beta' | 'stable'
*/
'x-stability'?: OpenAPIStability;
}
export type OpenAPIStability = 'experimental' | 'alpha' | 'beta' | 'stable';
/**
* Custom code samples that can be defined at the operation level.
* It follows the spec defined by Redocly.
@@ -79,3 +102,8 @@ export type FilesystemEntry<T extends AnyObject> = {
filename: string;
specification: T;
};
export type OpenAPISchema = {
name: string;
schema: OpenAPIV3.SchemaObject;
};
+33
View File
@@ -1,5 +1,38 @@
# @gitbook/react-openapi
## 1.1.9
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- da485f5: Fix read-only in generateSchemaExample
- 139a805: Fix OpenAPI enum display
## 1.1.8
### Patch Changes
- 7d0b422: Handle grouped OpenAPISchemas
## 1.1.7
### Patch Changes
- bd35348: Fix missing alternative schemas
- ae78fc5: Fix XML in code sample
- 7bb37c7: Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser
- 373183a: Safe parse OpenAPI JSON schema
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 5b1e01c: Support for x-stability property
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- a25fded: Replace $ref with $reference in json-decycle
- Updated dependencies [7bb37c7]
- Updated dependencies [5b1e01c]
- Updated dependencies [813b2af]
- @gitbook/openapi-parser@2.1.2
## 1.1.6
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "1.1.6",
"version": "1.1.9",
"sideEffects": false,
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
@@ -32,8 +32,6 @@ export function InteractiveSection(props: {
defaultTab?: string;
/** Content of the header */
header?: React.ReactNode;
/** Body of the section */
children?: React.ReactNode;
/** Children to display within the container */
overlay?: React.ReactNode;
}) {
@@ -45,7 +43,6 @@ export function InteractiveSection(props: {
tabs = [],
defaultTab = tabs[0]?.key,
header,
children,
overlay,
toggleIcon = '▶',
} = props;
@@ -83,7 +80,7 @@ export function InteractiveSection(props: {
className={className}
>
<SectionHeaderContent className={className}>
{(children || selectedTab?.body) && toggeable ? (
{selectedTab?.body && toggeable ? (
<button
{...mergeProps(buttonProps, focusProps)}
ref={triggerRef}
@@ -131,9 +128,8 @@ export function InteractiveSection(props: {
</div>
</SectionHeader>
) : null}
{(!toggeable || state.isExpanded) && (children || selectedTab?.body) ? (
{(!toggeable || state.isExpanded) && selectedTab?.body ? (
<SectionBody ref={panelRef} {...panelProps} className={className}>
{children}
{selectedTab?.body}
</SectionBody>
) : null}
@@ -148,7 +148,13 @@ function generateCodeSamples(props: {
return {
key: `default-${generator.id}`,
label: generator.label,
body: <OpenAPIMediaTypeExamplesBody data={data} renderers={renderers} />,
body: (
<OpenAPIMediaTypeExamplesBody
method={data.method}
path={data.path}
renderers={renderers}
/>
),
footer: (
<OpenAPICodeSampleFooter renderers={renderers} data={data} context={context} />
),
@@ -189,9 +195,10 @@ function OpenAPICodeSampleFooter(props: {
const { method, path } = data;
const { specUrl } = context;
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
const hasMediaTypes = renderers.length > 0;
const hasMultipleMediaTypes =
renderers.length > 1 || renderers.some((renderer) => renderer.examples.length > 0);
if (hideTryItPanel && !hasMediaTypes) {
if (hideTryItPanel && !hasMultipleMediaTypes) {
return null;
}
@@ -201,8 +208,12 @@ function OpenAPICodeSampleFooter(props: {
return (
<div className="openapi-codesample-footer">
{hasMediaTypes ? (
<OpenAPIMediaTypeExamplesSelector data={data} renderers={renderers} />
{hasMultipleMediaTypes ? (
<OpenAPIMediaTypeExamplesSelector
method={data.method}
path={data.path}
renderers={renderers}
/>
) : (
<span />
)}
@@ -3,10 +3,17 @@ import clsx from 'clsx';
import { useCallback } from 'react';
import { useStore } from 'zustand';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import type { OpenAPIOperationData } from './types';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
function useMediaTypeState(data: OpenAPIOperationData, defaultKey: string) {
type MediaTypeState = {
mediaType: string;
setMediaType: (mediaType: string) => void;
};
function useMediaTypeState(
data: { method: string; path: string },
defaultKey: string
): MediaTypeState {
const { method, path } = data;
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.tabKey !== 'string') {
@@ -18,7 +25,7 @@ function useMediaTypeState(data: OpenAPIOperationData, defaultKey: string) {
};
}
function useMediaTypeSampleIndexState(data: OpenAPIOperationData, mediaType: string) {
function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) {
const { method, path } = data;
const store = useStore(
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
@@ -33,40 +40,57 @@ function useMediaTypeSampleIndexState(data: OpenAPIOperationData, mediaType: str
}
export function OpenAPIMediaTypeExamplesSelector(props: {
data: OpenAPIOperationData;
method: string;
path: string;
renderers: MediaTypeRenderer[];
}) {
const { data, renderers } = props;
const { method, path, renderers } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const state = useMediaTypeState(data, renderers[0].mediaType);
const state = useMediaTypeState({ method, path }, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === state.mediaType) || renderers[0];
return (
<div className="openapi-codesample-selectors">
<select
className={clsx('openapi-select')}
value={state.mediaType}
onChange={(e) => state.setMediaType(e.target.value)}
>
{renderers.map((renderer) => (
<option key={renderer.mediaType} value={renderer.mediaType}>
{renderer.mediaType}
</option>
))}
</select>
<ExamplesSelector data={data} renderer={selected} />
<MediaTypeSelector state={state} renderers={renderers} />
<ExamplesSelector method={method} path={path} renderer={selected} />
</div>
);
}
function MediaTypeSelector(props: {
state: MediaTypeState;
renderers: MediaTypeRenderer[];
}) {
const { renderers, state } = props;
if (renderers.length < 2) {
return null;
}
return (
<select
className={clsx('openapi-select')}
value={state.mediaType}
onChange={(e) => state.setMediaType(e.target.value)}
>
{renderers.map((renderer) => (
<option key={renderer.mediaType} value={renderer.mediaType}>
{renderer.mediaType}
</option>
))}
</select>
);
}
function ExamplesSelector(props: {
data: OpenAPIOperationData;
method: string;
path: string;
renderer: MediaTypeRenderer;
}) {
const { data, renderer } = props;
const state = useMediaTypeSampleIndexState(data, renderer.mediaType);
const { method, path, renderer } = props;
const state = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
if (renderer.examples.length < 2) {
return null;
}
@@ -87,25 +111,26 @@ function ExamplesSelector(props: {
}
export function OpenAPIMediaTypeExamplesBody(props: {
data: OpenAPIOperationData;
method: string;
path: string;
renderers: MediaTypeRenderer[];
}) {
const { renderers, data } = props;
const { renderers, method, path } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const mediaTypeState = useMediaTypeState(data, renderers[0].mediaType);
const mediaTypeState = useMediaTypeState({ method, path }, renderers[0].mediaType);
const selected =
renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0];
if (selected.examples.length === 0) {
return selected.element;
}
return <ExamplesBody data={data} renderer={selected} />;
return <ExamplesBody method={method} path={path} renderer={selected} />;
}
function ExamplesBody(props: { data: OpenAPIOperationData; renderer: MediaTypeRenderer }) {
const { data, renderer } = props;
const exampleState = useMediaTypeSampleIndexState(data, renderer.mediaType);
function ExamplesBody(props: { method: string; path: string; renderer: MediaTypeRenderer }) {
const { method, path, renderer } = props;
const exampleState = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
const example = renderer.examples[exampleState.index] ?? renderer.examples[0];
if (!example) {
throw new Error(`No example found for index ${exampleState.index}`);
@@ -6,10 +6,16 @@ import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-co
export function OpenAPICopyButton(
props: ButtonProps & {
value: string;
children: React.ReactNode;
label?: string;
/**
* Whether to show a tooltip.
* @default true
*/
withTooltip?: boolean;
}
) {
const { value } = props;
const { children, onPress, className } = props;
const { value, label, children, onPress, className, withTooltip = true } = props;
const [copied, setCopied] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@@ -21,12 +27,19 @@ export function OpenAPICopyButton(
setTimeout(() => {
setCopied(false);
setIsOpen(false);
}, 2000);
});
};
return (
<TooltipTrigger isOpen={isOpen} onOpenChange={setIsOpen} closeDelay={200} delay={200}>
<TooltipTrigger
isOpen={isOpen}
onOpenChange={setIsOpen}
isDisabled={!withTooltip}
closeDelay={200}
delay={200}
>
<Button
type="button"
preventFocusOnPress
@@ -47,7 +60,7 @@ export function OpenAPICopyButton(
offset={4}
className="openapi-tooltip"
>
{copied ? 'Copied' : 'Copy to clipboard'}{' '}
{copied ? 'Copied' : label || 'Copy to clipboard'}
</Tooltip>
</TooltipTrigger>
);
@@ -1,6 +1,10 @@
import clsx from 'clsx';
import type { OpenAPICustomOperationProperties, OpenAPIV3 } from '@gitbook/openapi-parser';
import type {
OpenAPICustomOperationProperties,
OpenAPIStability,
OpenAPIV3,
} from '@gitbook/openapi-parser';
import { Markdown } from './Markdown';
import { OpenAPICodeSample } from './OpenAPICodeSample';
import { OpenAPIPath } from './OpenAPIPath';
@@ -29,14 +33,24 @@ export function OpenAPIOperation(props: {
return (
<div className={clsx('openapi-operation', className)}>
<div className="openapi-summary" id={operation.summary ? undefined : context.id}>
{(operation.deprecated || operation['x-stability']) && (
<div className="openapi-summary-tags">
{operation.deprecated && (
<div className="openapi-deprecated">Deprecated</div>
)}
{operation['x-stability'] && (
<OpenAPIOperationStability stability={operation['x-stability']} />
)}
</div>
)}
{operation.summary
? context.renderHeading({
deprecated: operation.deprecated ?? false,
stability: operation['x-stability'],
title: operation.summary,
})
: null}
<OpenAPIPath data={data} context={context} />
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
</div>
<div className="openapi-columns">
<div className="openapi-column-spec">
@@ -89,3 +103,26 @@ function OpenAPIOperationDescription(props: {
</div>
);
}
const stabilityEnum = {
experimental: 'Experimental',
alpha: 'Alpha',
beta: 'Beta',
stable: 'Stable',
} as const;
function OpenAPIOperationStability(props: { stability: OpenAPIStability }) {
const { stability } = props;
const foundStability = stabilityEnum[stability];
if (!foundStability) {
return null;
}
return (
<div className={`openapi-stability openapi-stability-${foundStability.toLowerCase()}`}>
{foundStability}
</div>
);
}
+2 -2
View File
@@ -47,7 +47,7 @@ function formatPath(path: string) {
parts.push(path.slice(lastIndex, offset));
}
parts.push(
<span key={offset} className="openapi-path-variable">
<span key={`offset-${offset}`} className="openapi-path-variable">
{match}
</span>
);
@@ -61,7 +61,7 @@ function formatPath(path: string) {
const formattedPath = parts.map((part, index) => {
if (typeof part === 'string') {
return <span key={index}>{part}</span>;
return <span key={`part-${index}`}>{part}</span>;
}
return part;
});
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRootSchema } from './OpenAPISchema';
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
import type { OpenAPIClientContext } from './types';
import { checkIsReference } from './utils';
+13 -11
View File
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISchemaProperties } from './OpenAPISchemaServer';
import type { OpenAPIClientContext } from './types';
import { parameterToProperty, resolveDescription } from './utils';
@@ -29,20 +29,22 @@ export function OpenAPIResponse(props: {
{headers.length > 0 ? (
<OpenAPIDisclosure context={context} label="Headers">
<OpenAPISchemaProperties
properties={headers.map(([name, header]) => {
return parameterToProperty({ name, ...header });
})}
properties={headers.map(([name, header]) =>
parameterToProperty({ name, ...header })
)}
context={context}
/>
</OpenAPIDisclosure>
) : null}
<div className="openapi-responsebody">
<OpenAPISchemaProperties
id={`response-${context.blockKey}`}
properties={mediaType.schema ? [{ schema: mediaType.schema }] : []}
context={context}
/>
</div>
{mediaType.schema && (
<div className="openapi-responsebody">
<OpenAPISchemaProperties
id={`response-${context.blockKey}`}
properties={[{ schema: mediaType.schema }]}
context={context}
/>
</div>
)}
</div>
);
}
@@ -268,6 +268,7 @@ function getExamplesFromMediaTypeObject(args: {
value: {
[root]: generateSchemaExample(mediaTypeObject.schema, {
xml: mediaType === 'application/xml',
mode: 'read',
}),
},
},
@@ -277,7 +278,11 @@ function getExamplesFromMediaTypeObject(args: {
return [
{
key: 'default',
example: { value: generateSchemaExample(mediaTypeObject.schema) },
example: {
value: generateSchemaExample(mediaTypeObject.schema, {
mode: 'read',
}),
},
},
];
}
+38 -17
View File
@@ -21,16 +21,48 @@ export function OpenAPIResponses(props: {
icon={context.icons.chevronRight}
groups={Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const content = Object.entries(response.content ?? {});
const tabs = (() => {
// If there is no content, but there are headers, we need to show the headers
if (
(!response.content || !Object.keys(response.content).length) &&
response.headers &&
Object.keys(response.headers).length
) {
return [
{
id: 'default',
body: (
<OpenAPIResponse
response={response}
mediaType={{}}
context={context}
/>
),
},
];
}
return Object.entries(response.content ?? {}).map(
([contentType, mediaType]) => ({
id: contentType,
label: contentType,
body: (
<OpenAPIResponse
response={response}
mediaType={mediaType}
context={context}
/>
),
})
);
})();
const description = response.description;
return {
id: statusCode,
label: (
<div
className="openapi-response-tab-content"
key={`response-${statusCode}`}
>
<div className="openapi-response-tab-content">
<span className="openapi-response-statuscode">
{statusCode}
</span>
@@ -42,18 +74,7 @@ export function OpenAPIResponses(props: {
) : null}
</div>
),
tabs: content.map(([contentType, mediaType]) => ({
id: contentType,
label: contentType,
body: (
<OpenAPIResponse
key={`$response-${statusCode}-${contentType}`}
response={response}
mediaType={mediaType}
context={context}
/>
),
})),
tabs,
};
}
)}

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