Compare commits

..

16 Commits

Author SHA1 Message Date
Greg Bergé 5fdb854400 Add loading indicator 2025-03-26 21:48:33 +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
61 changed files with 1214 additions and 428 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix OpenAPI tabs indicator overflow
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix XML in code sample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Disallow crawling by web-robots of search/ask URLs
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Safe parse OpenAPI JSON schema
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Fix multiple request examples selector not showing
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support site announcement banner
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix a crash when a page contains a block of an integration that is no longer installed
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix flash when loading sites with dark mode as default theme
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Fix spec properties rendering and missing keys
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Replace $ref with $reference in json-decycle
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Trim the search query to avoid showing a loading state when typing
+19 -9
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:*",
@@ -74,13 +74,14 @@
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"framer-motion": "^10.16.14",
"holy-loader": "^2.3.13",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"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 +137,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 +163,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 +181,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 +220,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 +235,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",
@@ -1956,6 +1958,8 @@
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"holy-loader": ["holy-loader@2.3.13", "", { "peerDependencies": { "react": ">= 16.0.0" } }, "sha512-SnQ06sfMgbhiRGkHZ0RFMbexSpZN3i6tgItt3q8GWNba3mOdsq1RNJXkoqsWkqPIAmyroj2/xIOHu1hCFnjkBQ=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"hookified": ["hookified@1.8.1", "", {}, "sha512-GrO2l93P8xCWBSTBX9l2BxI78VU/MAAYag+pG8curS3aBGy0++ZlxrQ7PdUOUVMbn5BwkGb6+eRrnf43ipnFEA=="],
@@ -2280,7 +2284,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 +2396,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 +2808,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 +4048,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 +4078,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=="],
+2 -1
View File
@@ -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}
+251 -196
View File
@@ -9,6 +9,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
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 { memoize } from './memoize';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
@@ -175,19 +176,24 @@ export function createDataFetcher(
};
}
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 +202,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 +230,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 +261,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 +284,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 +311,23 @@ 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 getRevisionPageByPath = memoize(async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
@@ -314,23 +337,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 +364,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 +386,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 +420,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 +477,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 +513,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 +547,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 +589,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;
@@ -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;
}
+8 -1
View File
@@ -199,7 +199,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
routeType,
mode,
encodeURIComponent(siteURLWithoutProtocol),
encodeURIComponent(rison.encode(siteURLData)),
encodeURIComponent(
rison.encode({
...siteURLData,
// The pathname is passed as the next segment of the route and should not cause this segment to change
// based on the page being visited
pathname: '<DO_NOT_USE>',
})
),
pathname,
].join('/');
+2 -1
View File
@@ -39,13 +39,14 @@
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"framer-motion": "^10.16.14",
"holy-loader": "^2.3.13",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"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,126 @@
'use client';
import * as storage from '@/lib/local-storage';
import type { ResolvedContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import type { CustomizationAnnouncement } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import Link from 'next/link';
import { CONTAINER_STYLE } from '../layout';
import { 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 scroll-nojump theme-bold:bg-header-background pt-4 pb-2">
<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
)}
>
<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>
);
}
/**
* 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,29 @@
'use client';
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);
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',
@@ -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;
@@ -467,13 +467,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 +517,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 +527,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 {
@@ -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,5 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
/**
@@ -14,7 +14,7 @@ export async function PDFRootLayout(props: {
return (
<CustomizationRootLayout
customization={
'customization' in context ? context.customization : defaultCustomizationForSpace()
'customization' in context ? context.customization : defaultCustomization()
}
>
{children}
@@ -2,9 +2,9 @@ import {
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationIconsStyle,
type CustomizationSettings,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
type CustomizationThemedColor,
type CustomizationTint,
type SiteCustomizationSettings,
@@ -21,6 +21,7 @@ import {
hexToRgb,
} from '@gitbook/colors';
import { IconStyle, IconsProvider } from '@gitbook/icons';
import HolyLoader from 'holy-loader';
import * as ReactDOM from 'react-dom';
import { getFontData } from '@/fonts';
@@ -28,22 +29,23 @@ import { fontNotoColorEmoji, ibmPlexMono } from '@/fonts/default';
import { getSpaceLanguage } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
import { tcls } from '@/lib/tailwind';
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
@@ -139,6 +151,7 @@ export async function CustomizationRootLayout(props: {
}
`}</style>
</head>
<HolyLoader color="rgb(var(--primary-9))" height={1} />
<body
className={tcls(
'bg-tint-base',
@@ -175,7 +188,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 +235,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 +255,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>
);
}
@@ -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.
@@ -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
@@ -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 (
@@ -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;
@@ -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;
@@ -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>
);
+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
@@ -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,
+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: {},
};
}
+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;
@@ -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}`);
+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';
@@ -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,9 +29,9 @@ 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>
@@ -27,10 +27,7 @@ export function OpenAPIResponses(props: {
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>
@@ -47,7 +44,6 @@ export function OpenAPIResponses(props: {
label: contentType,
body: (
<OpenAPIResponse
key={`$response-${statusCode}-${contentType}`}
response={response}
mediaType={mediaType}
context={context}
+101 -45
View File
@@ -1,3 +1,7 @@
'use client';
// This component does not use any client feature but we don't want to
// render it server-side because it has recursion.
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { useId } from 'react';
@@ -5,12 +9,13 @@ import clsx from 'clsx';
import { Markdown } from './Markdown';
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
import { OpenAPISchemaName } from './OpenAPISchemaName';
import { retrocycle } from './decycle';
import type { OpenAPIClientContext } from './types';
import { checkIsReference, resolveDescription, resolveFirstExample } from './utils';
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
interface OpenAPISchemaPropertyEntry {
export interface OpenAPISchemaPropertyEntry {
propertyName?: string | undefined;
required?: boolean | undefined;
schema: OpenAPIV3.SchemaObject;
@@ -22,15 +27,10 @@ interface OpenAPISchemaPropertyEntry {
function OpenAPISchemaProperty(props: {
property: OpenAPISchemaPropertyEntry;
context: OpenAPIClientContext;
circularRefs?: CircularRefsIds;
circularRefs: CircularRefsIds;
className?: string;
}) {
const {
property,
circularRefs: parentCircularRefs = new Map<OpenAPIV3.SchemaObject, string>(),
context,
className,
} = props;
const { circularRefs: parentCircularRefs, context, className, property } = props;
const { schema } = property;
@@ -40,37 +40,43 @@ function OpenAPISchemaProperty(props: {
<div id={id} className={clsx('openapi-schema', className)}>
<OpenAPISchemaPresentation property={property} />
{(() => {
const parentCircularRef = parentCircularRefs.get(schema);
const circularRefId = parentCircularRefs.get(schema);
// Avoid recursing infinitely, and instead render a link to the parent schema
if (parentCircularRef) {
return <OpenAPISchemaCircularRef id={parentCircularRef} schema={schema} />;
if (circularRefId) {
return <OpenAPISchemaCircularRef id={circularRefId} schema={schema} />;
}
const circularRefs = parentCircularRefs.set(schema, id);
const circularRefs = new Map(parentCircularRefs);
circularRefs.set(schema, id);
const properties = getSchemaProperties(schema);
const alternatives = getSchemaAlternatives(schema, new Set(circularRefs.keys()));
return (
<>
{alternatives?.map((schema, index) => (
<OpenAPISchemaAlternative
key={index}
schema={schema}
if (properties) {
return (
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
<OpenAPISchemaProperties
properties={properties}
circularRefs={circularRefs}
context={context}
/>
))}
{properties?.length ? (
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
<OpenAPISchemaProperties
properties={properties}
circularRefs={circularRefs}
context={context}
/>
</OpenAPIDisclosure>
) : null}
</>
);
</OpenAPIDisclosure>
);
}
const ancestors = new Set(circularRefs.keys());
const alternatives = getSchemaAlternatives(schema, ancestors);
if (alternatives) {
return alternatives.map((schema, index) => (
<OpenAPISchemaAlternative
key={index}
schema={schema}
circularRefs={circularRefs}
context={context}
/>
));
}
return null;
})()}
</div>
);
@@ -79,41 +85,77 @@ function OpenAPISchemaProperty(props: {
/**
* Render a set of properties of an OpenAPI schema.
*/
export function OpenAPISchemaProperties(props: {
function OpenAPISchemaProperties(props: {
id?: string;
properties: OpenAPISchemaPropertyEntry[];
circularRefs?: CircularRefsIds;
context: OpenAPIClientContext;
}) {
const { id, properties, circularRefs, context } = props;
const {
id,
properties,
circularRefs = new Map<OpenAPIV3.SchemaObject, string>(),
context,
} = props;
return (
<div id={id} className="openapi-schema-properties">
{properties.map((property, index) => (
<OpenAPISchemaProperty
key={index}
circularRefs={circularRefs}
property={property}
context={context}
/>
))}
{properties.map((property, index) => {
return (
<OpenAPISchemaProperty
key={index}
circularRefs={circularRefs}
property={property}
context={context}
/>
);
})}
</div>
);
}
export function OpenAPISchemaPropertiesFromServer(props: {
id?: string;
properties: string;
context: OpenAPIClientContext;
}) {
return (
<OpenAPISchemaProperties
id={props.id}
properties={JSON.parse(props.properties, retrocycle())}
context={props.context}
/>
);
}
/**
* Render a root schema (such as the request body or response body).
*/
export function OpenAPIRootSchema(props: {
function OpenAPIRootSchema(props: {
schema: OpenAPIV3.SchemaObject;
context: OpenAPIClientContext;
circularRefs?: CircularRefsIds;
}) {
const { schema, context } = props;
const {
schema,
context,
circularRefs: parentCircularRefs = new Map<OpenAPIV3.SchemaObject, string>(),
} = props;
const id = useId();
const properties = getSchemaProperties(schema);
if (properties?.length) {
return <OpenAPISchemaProperties properties={properties} context={context} />;
const circularRefs = new Map(parentCircularRefs);
circularRefs.set(schema, id);
return (
<OpenAPISchemaProperties
properties={properties}
circularRefs={circularRefs}
context={context}
/>
);
}
return (
@@ -121,6 +163,19 @@ export function OpenAPIRootSchema(props: {
className="openapi-schema-root"
property={{ schema }}
context={context}
circularRefs={parentCircularRefs}
/>
);
}
export function OpenAPIRootSchemaFromServer(props: {
schema: string;
context: OpenAPIClientContext;
}) {
return (
<OpenAPIRootSchema
schema={JSON.parse(props.schema, retrocycle())}
context={props.context}
/>
);
}
@@ -136,6 +191,7 @@ function OpenAPISchemaAlternative(props: {
context: OpenAPIClientContext;
}) {
const { schema, circularRefs, context } = props;
const description = resolveDescription(schema);
const properties = getSchemaProperties(schema);
@@ -0,0 +1,34 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import {
OpenAPIRootSchemaFromServer,
OpenAPISchemaPropertiesFromServer,
type OpenAPISchemaPropertyEntry,
} from './OpenAPISchema';
import { decycle } from './decycle';
import type { OpenAPIClientContext } from './types';
export function OpenAPISchemaProperties(props: {
id?: string;
properties: OpenAPISchemaPropertyEntry[];
context: OpenAPIClientContext;
}) {
return (
<OpenAPISchemaPropertiesFromServer
id={props.id}
properties={JSON.stringify(props.properties, decycle())}
context={props.context}
/>
);
}
export function OpenAPIRootSchema(props: {
schema: OpenAPIV3.SchemaObject;
context: OpenAPIClientContext;
}) {
return (
<OpenAPIRootSchemaFromServer
schema={JSON.stringify(props.schema, decycle())}
context={props.context}
/>
);
}
+13 -11
View File
@@ -2,18 +2,12 @@ import type { OpenAPI } from '@gitbook/openapi-parser';
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
import { OpenAPIResponses } from './OpenAPIResponses';
import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISchemaProperties } from './OpenAPISchemaServer';
import { OpenAPISecurities } from './OpenAPISecurities';
import { StaticSection } from './StaticSection';
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { parameterToProperty } from './utils';
/**
* Client component to render the spec for the request and response.
*
* We use a client component as rendering recursive JSON schema in the server is expensive
* (the entire schema is rendered at once, while the client component only renders the visible part)
*/
export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAPIClientContext }) {
const { data, context } = props;
@@ -25,13 +19,13 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
return (
<>
{securities.length > 0 ? (
<OpenAPISecurities securities={securities} context={context} />
<OpenAPISecurities key="securities" securities={securities} context={context} />
) : null}
{parameterGroups.map((group) => {
return (
<StaticSection
key={group.key}
key={`parameter-${group.key}`}
className="openapi-parameters"
header={group.label}
>
@@ -44,10 +38,18 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
})}
{operation.requestBody ? (
<OpenAPIRequestBody requestBody={operation.requestBody} context={context} />
<OpenAPIRequestBody
key="body"
requestBody={operation.requestBody}
context={context}
/>
) : null}
{operation.responses ? (
<OpenAPIResponses responses={operation.responses} context={context} />
<OpenAPIResponses
key="responses"
responses={operation.responses}
context={context}
/>
) : null}
</>
);
+3 -13
View File
@@ -137,21 +137,11 @@ export function OpenAPITabsPanels() {
const key = selectedTab.key.toString();
return (
<TabPanel key={key} id={key} className="openapi-tabs-panel">
{selectedTab.body}
<TabPanel id={key} className="openapi-tabs-panel">
<div className="openapi-tabs-body">{selectedTab.body}</div>
{selectedTab.footer ? (
<OpenAPITabsPanelFooter>{selectedTab.footer}</OpenAPITabsPanelFooter>
<div className="openapi-tabs-footer">{selectedTab.footer}</div>
) : null}
</TabPanel>
);
}
/**
* The OpenAPI Tabs panel footer component.
* This component should be used as a child of the OpenAPITabs component.
*/
function OpenAPITabsPanelFooter(props: { children: React.ReactNode }) {
const { children } = props;
return <div className="openapi-tabs-footer">{children}</div>;
}
@@ -110,6 +110,23 @@ describe('curL code sample generator', () => {
);
});
it('should convert json to xml body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
url: 'https://example.com/path',
headers: {
'Content-Type': 'application/xml',
},
body: '{ "key": "value" }',
};
const output = generator?.generate(input);
expect(output).toBe(
"curl -L \\\n --url 'https://example.com/path' \\\n --header 'Content-Type: application/xml' \\\n --data-binary $'<?xml version=1.0?>\n <key>value</key>\n '"
);
});
it('should format application/graphql body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
@@ -258,6 +275,23 @@ describe('javascript code sample generator', () => {
);
});
it('should convert json to xml body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
url: 'https://example.com/path',
headers: {
'Content-Type': 'application/xml',
},
body: '{ "key": "value" }',
};
const output = generator?.generate(input);
expect(output).toBe(
'const xml = `\n <?xml version=1.0?>\n <key>value</key>\n`;\n\nconst response = await fetch(\'https://example.com/path\', {\n method: \'GET\',\n headers: {\n "Content-Type": "application/xml"\n },\n body: xml\n});\n\nconst data = await response.json();'
);
});
it('should format application/graphql body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
@@ -406,6 +440,23 @@ describe('python code sample generator', () => {
);
});
it('should convert json to xml body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
url: 'https://example.com/path',
headers: {
'Content-Type': 'application/xml',
},
body: '{ "key": "value" }',
};
const output = generator?.generate(input);
expect(output).toBe(
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/xml"},\n data="<?xml version=1.0?>\\n<key>value</key>\\n"\n)\n\ndata = response.json()'
);
});
it('should format application/graphql body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
@@ -514,7 +565,7 @@ describe('http code sample generator', () => {
const output = generator?.generate(input);
expect(output).toBe(
'GET /path HTTP/1.1\nHost: example.com\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 15\nAccept: */*\n\n"key=value"'
'GET /path HTTP/1.1\nHost: example.com\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 15\nAccept: */*\n\n"key=\'value\'"'
);
});
@@ -554,6 +605,23 @@ describe('http code sample generator', () => {
);
});
it('should convert json to xml body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
url: 'https://example.com/path',
headers: {
'Content-Type': 'application/xml',
},
body: '{ "key": "value" }',
};
const output = generator?.generate(input);
expect(output).toBe(
'GET /path HTTP/1.1\nHost: example.com\nContent-Type: application/xml\nContent-Length: 24\nAccept: */*\n\n"<?xml version=1.0?>\n<key>value</key>\n"'
);
});
it('should format application/graphql body properly', () => {
const input: CodeSampleInput = {
method: 'GET',
+45 -9
View File
@@ -8,6 +8,7 @@ import {
isText,
isXML,
} from './contentTypeChecks';
import { json2xml } from './json2xml';
import { stringifyOpenAPI } from './stringifyOpenAPI';
export interface CodeSampleInput {
@@ -238,7 +239,10 @@ const BodyGenerators = {
: String(body);
} else if (isText(contentType)) {
body = `--data '${String(body).replace(/"/g, '')}'`;
} else if (isXML(contentType) || isCSV(contentType)) {
} else if (isXML(contentType)) {
// Convert to XML and ensure proper formatting
body = `--data-binary $'${convertBodyToXML(body)}'`;
} else if (isCSV(contentType)) {
// We use --data-binary to avoid cURL converting newlines to \r\n
body = `--data-binary $'${stringifyOpenAPI(body).replace(/"/g, '').replace(/\\n/g, '\n')}'`;
} else if (isGraphQL(contentType)) {
@@ -312,7 +316,9 @@ const BodyGenerators = {
body = 'formData';
} else if (isXML(contentType)) {
code += 'const xml = `\n';
code += indent(String(body), 4);
// Convert JSON to XML if needed
code += indent(convertBodyToXML(body), 4);
code += '`;\n\n';
body = 'xml';
} else if (isText(contentType)) {
@@ -346,6 +352,11 @@ const BodyGenerators = {
body = 'files';
}
if (isXML(contentType)) {
// Convert JSON to XML if needed
body = convertBodyToXML(body);
}
return { body, code, headers };
},
getHTTPBody: (body: any, headers?: Record<string, string>) => {
@@ -358,23 +369,48 @@ const BodyGenerators = {
formUrlEncoded: () => {
const encoded = isPlainObject(body)
? Object.entries(body)
.map(([key, value]) => `${key}=${String(value)}`)
.map(([key, value]) => `${key}=${stringifyOpenAPI(value)}`)
.join('&')
: String(body);
return `"${encoded}"`;
: stringifyOpenAPI(body);
return `"${encoded.replace(/"/g, "'")}"`;
},
text: () => `"${String(body)}"`,
xmlOrCsv: () => `"${stringifyOpenAPI(body).replace(/"/g, '')}"`,
xml: () => {
// Convert JSON to XML if needed
return `"${convertBodyToXML(body)}"`;
},
csv: () => `"${stringifyOpenAPI(body).replace(/"/g, '')}"`,
default: () => `${stringifyOpenAPI(body, null, 2)}`,
};
if (isPDF(contentType)) return typeHandlers.pdf();
if (isFormUrlEncoded(contentType)) return typeHandlers.formUrlEncoded();
if (isText(contentType)) return typeHandlers.text();
if (isXML(contentType) || isCSV(contentType)) {
return typeHandlers.xmlOrCsv();
}
if (isXML(contentType)) return typeHandlers.xml();
if (isCSV(contentType)) return typeHandlers.csv();
return typeHandlers.default();
},
};
/**
* Converts a body to XML format
*/
function convertBodyToXML(body: any): string {
// If body is already a string and looks like XML, return it as is
if (typeof body === 'string' && body.trim().startsWith('<')) {
return body;
}
// If body is not an object, try to parse it as JSON
if (typeof body !== 'object' || body === null) {
try {
body = JSON.parse(body);
} catch {
// If parsing fails, return the original body
return body;
}
}
return json2xml(body).replace(/"/g, '').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}
+68
View File
@@ -0,0 +1,68 @@
// Forked from: https://github.com/YChebotaev/json-decycle/blob/master/src/index.ts
// Replaced `$ref` with `$reference` to avoid conflicts with OpenAPI
const isObject = (value: any): value is object =>
typeof value === 'object' &&
value != null &&
!(value instanceof Boolean) &&
!(value instanceof Date) &&
!(value instanceof Number) &&
!(value instanceof RegExp) &&
!(value instanceof String);
const toPointer = (parts: string[]) =>
`#${parts.map((part) => String(part).replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}`;
export const decycle = () => {
const paths = new WeakMap();
return function replacer(this: any, key: string | symbol, value: any) {
if (key !== '$reference' && isObject(value)) {
const seen = paths.has(value);
if (seen) {
return { $reference: toPointer(paths.get(value)) };
}
paths.set(value, [...(paths.get(this) ?? []), key]);
}
return value;
};
};
export function retrocycle() {
const parents = new WeakMap();
const keys = new WeakMap();
const refs = new Set();
function dereference(this: { [k: string]: any }, ref: { $reference: string }) {
const parts = ref.$reference.slice(1).split('/');
let key: any;
let value = this;
for (let i = 0; i < parts.length; i++) {
key = parts[i]?.replace(/~1/g, '/').replace(/~0/g, '~');
value = value[key];
}
const parent = parents.get(ref);
parent[keys.get(ref)] = value;
}
return function reviver(this: object, key: string | symbol, value: any) {
if (key === '$reference') {
refs.add(this);
} else if (isObject(value)) {
const isRoot = key === '' && Object.keys(this).length === 1;
if (isRoot) {
refs.forEach(dereference as any, this);
} else {
parents.set(value, this);
keys.set(value, key);
}
}
return value;
};
}
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import { OpenAPIDisclosureGroup } from '../OpenAPIDisclosureGroup';
import { OpenAPIRootSchema } from '../OpenAPISchema';
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
import { Section, SectionBody } from '../StaticSection';
import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPISchemasData } from '../types';