mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 06:09:21 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b647538e41 | |||
| e50797a5c1 | |||
| 5b1e01c786 | |||
| 813b2af06b | |||
| 6f71da88d8 | |||
| fa91eb7eaf | |||
| c756761591 | |||
| 434af90366 | |||
| 0f41e194cb | |||
| bd353489e9 | |||
| 77fd393f15 | |||
| 8701b5e078 | |||
| 61db166f92 | |||
| 0d9b9cea0f | |||
| 7bb37c7f0a | |||
| 5b2bf828e3 | |||
| c85d3f9752 | |||
| 4d7088bbf0 | |||
| dd6ab6044c | |||
| d236bf029c | |||
| d70d566abe | |||
| a25fdedea9 | |||
| 373183a2cd | |||
| e9fa50dfb5 | |||
| 57ca4e0e88 | |||
| bc90adb73a | |||
| 1505ddbd5a | |||
| cd99ed57fb | |||
| 27de1cd01d | |||
| ae78fc5394 | |||
| 40e8e69c6d | |||
| e84a46a558 | |||
| 18a72d4466 | |||
| d7aa5fa003 |
@@ -49,7 +49,7 @@
|
|||||||
},
|
},
|
||||||
"packages/gitbook": {
|
"packages/gitbook": {
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "*",
|
||||||
"@gitbook/cache-do": "workspace:*",
|
"@gitbook/cache-do": "workspace:*",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"memoizee": "^0.4.15",
|
"memoizee": "^0.4.17",
|
||||||
"next": "14.2.25",
|
"next": "14.2.25",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
@@ -136,13 +136,14 @@
|
|||||||
},
|
},
|
||||||
"packages/gitbook-v2": {
|
"packages/gitbook-v2": {
|
||||||
"name": "gitbook-v2",
|
"name": "gitbook-v2",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"next": "^15.2.3",
|
"next": "^15.2.3",
|
||||||
|
"p-memoize": "^7.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"rison": "^0.1.1",
|
"rison": "^0.1.1",
|
||||||
@@ -161,7 +162,7 @@
|
|||||||
"name": "@gitbook/icons",
|
"name": "@gitbook/icons",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"gitbook-icons": "./bin/gitbook-icons.js"
|
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
@@ -179,7 +180,7 @@
|
|||||||
},
|
},
|
||||||
"packages/openapi-parser": {
|
"packages/openapi-parser": {
|
||||||
"name": "@gitbook/openapi-parser",
|
"name": "@gitbook/openapi-parser",
|
||||||
"version": "2.1.0",
|
"version": "2.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/openapi-parser": "^0.10.10",
|
"@scalar/openapi-parser": "^0.10.10",
|
||||||
"@scalar/openapi-types": "^0.1.9",
|
"@scalar/openapi-types": "^0.1.9",
|
||||||
@@ -218,7 +219,7 @@
|
|||||||
"name": "@gitbook/react-math",
|
"name": "@gitbook/react-math",
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"gitbook-math": "./bin/gitbook-math.js"
|
"gitbook-math": "./bin/gitbook-math.js",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
@@ -233,7 +234,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react-openapi": {
|
"packages/react-openapi": {
|
||||||
"name": "@gitbook/react-openapi",
|
"name": "@gitbook/react-openapi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
"@scalar/api-client-react": "^1.2.5",
|
"@scalar/api-client-react": "^1.2.5",
|
||||||
@@ -261,7 +262,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"@gitbook/api": "0.102.0",
|
"@gitbook/api": "0.106.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "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=="],
|
"@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"],
|
"@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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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/arg": ["arg@4.1.0", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="],
|
||||||
|
|
||||||
"micro/content-type": ["content-type@1.0.4", "", {}, "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"package-json/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"@gitbook/api": "0.102.0"
|
"@gitbook/api": "0.106.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function getCacheTag(
|
|||||||
| {
|
| {
|
||||||
tag: 'computed-document';
|
tag: 'computed-document';
|
||||||
space: string;
|
space: string;
|
||||||
integration: string;
|
sourceType: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* All data related to the URL of a content
|
* All data related to the URL of a content
|
||||||
@@ -84,6 +84,14 @@ export function getCacheTag(
|
|||||||
organization: string;
|
organization: string;
|
||||||
openAPISpec: string;
|
openAPISpec: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a translation
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'translation';
|
||||||
|
organization: string;
|
||||||
|
translationSettings: string;
|
||||||
|
}
|
||||||
): string {
|
): string {
|
||||||
switch (spec.tag) {
|
switch (spec.tag) {
|
||||||
case 'user':
|
case 'user':
|
||||||
@@ -99,13 +107,15 @@ export function getCacheTag(
|
|||||||
case 'document':
|
case 'document':
|
||||||
return `space:${spec.space}:document:${spec.document}`;
|
return `space:${spec.space}:document:${spec.document}`;
|
||||||
case 'computed-document':
|
case 'computed-document':
|
||||||
return `space:${spec.space}:computed-document:${spec.integration}`;
|
return `space:${spec.space}:computed-document:${spec.sourceType}`;
|
||||||
case 'site':
|
case 'site':
|
||||||
return `site:${spec.site}`;
|
return `site:${spec.site}`;
|
||||||
case 'integration':
|
case 'integration':
|
||||||
return `integration:${spec.integration}`;
|
return `integration:${spec.integration}`;
|
||||||
case 'openapi':
|
case 'openapi':
|
||||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
|
case 'translation':
|
||||||
|
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
||||||
default:
|
default:
|
||||||
assertNever(spec);
|
assertNever(spec);
|
||||||
}
|
}
|
||||||
@@ -157,6 +167,15 @@ export function getComputedContentSourceCacheTags(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'translation-language':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'translation',
|
||||||
|
organization: inContext.organizationId,
|
||||||
|
translationSettings: dependency.ref.translationSettings,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Do not throw for unknown dependency types
|
// Do not throw for unknown dependency types
|
||||||
// as it might mean we are lacking behind the API version
|
// as it might mean we are lacking behind the API version
|
||||||
@@ -169,18 +188,22 @@ export function getComputedContentSourceCacheTags(
|
|||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'computed-document',
|
tag: 'computed-document',
|
||||||
space: inContext.spaceId,
|
space: inContext.spaceId,
|
||||||
integration: source.integration,
|
sourceType: source.type,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We invalidate the computed content when a new version of the integration is deployed.
|
// We invalidate the computed content when a new version of the integration is deployed.
|
||||||
tags.push(
|
|
||||||
getCacheTag({
|
if (source.type.startsWith('integration:')) {
|
||||||
tag: 'integration',
|
const integration = source.type.split(':')[1];
|
||||||
integration: source.integration,
|
tags.push(
|
||||||
})
|
getCacheTag({
|
||||||
);
|
tag: 'integration',
|
||||||
|
integration,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# gitbook-v2
|
# gitbook-v2
|
||||||
|
|
||||||
|
## 0.2.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
|
||||||
|
|
||||||
## 0.2.2
|
## 0.2.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook-v2",
|
"name": "gitbook-v2",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.2.3",
|
"next": "^15.2.3",
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"warn-once": "^0.1.1",
|
"warn-once": "^0.1.1",
|
||||||
"rison": "^0.1.1",
|
"rison": "^0.1.1",
|
||||||
"jwt-decode": "^4.0.0"
|
"jwt-decode": "^4.0.0",
|
||||||
|
"p-memoize": "^7.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gitbook": "*",
|
"gitbook": "*",
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export default async function SiteDynamicLayout({
|
|||||||
const forcedTheme = await getThemeFromMiddleware();
|
const forcedTheme = await getThemeFromMiddleware();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
|
|||||||
-12
@@ -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);
|
|
||||||
}
|
|
||||||
-12
@@ -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);
|
|
||||||
}
|
|
||||||
-12
@@ -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);
|
|
||||||
}
|
|
||||||
-12
@@ -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);
|
|
||||||
}
|
|
||||||
+1
-1
@@ -10,5 +10,5 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
) {
|
) {
|
||||||
const { context } = await getStaticSiteContext(await params);
|
const { context } = await getStaticSiteContext(await params);
|
||||||
return serveLLMsTxt(context);
|
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||||
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
|
import type { SiteAPIToken } from '@gitbook/api';
|
||||||
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { jwtDecode } from 'jwt-decode';
|
||||||
import { forbidden } from 'next/navigation';
|
import { forbidden } from 'next/navigation';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
@@ -98,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
|
|||||||
/**
|
/**
|
||||||
* Get the decoded site data from the params.
|
* Get the decoded site data from the params.
|
||||||
*/
|
*/
|
||||||
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
|
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||||
const decoded = decodeURIComponent(params.siteData);
|
const decoded = decodeURIComponent(params.siteData);
|
||||||
return rison.decode(decoded);
|
return rison.decode(decoded);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,42 @@ import type {
|
|||||||
SiteStructure,
|
SiteStructure,
|
||||||
Space,
|
Space,
|
||||||
} from '@gitbook/api';
|
} 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 { assert } from 'ts-essentials';
|
||||||
import { GITBOOK_URL } from './env';
|
import { GITBOOK_URL } from './env';
|
||||||
import { type ImageResizer, createImageResizer } from './images';
|
import { type ImageResizer, createImageResizer } from './images';
|
||||||
import { type GitBookLinker, createLinker } from './links';
|
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.
|
* Generic context when rendering content.
|
||||||
*/
|
*/
|
||||||
@@ -105,7 +135,7 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
|
|||||||
*/
|
*/
|
||||||
export function getBaseContext(input: {
|
export function getBaseContext(input: {
|
||||||
siteURL: URL | string;
|
siteURL: URL | string;
|
||||||
siteURLData: PublishedSiteContent;
|
siteURLData: SiteURLData;
|
||||||
urlMode: 'url' | 'url-host';
|
urlMode: 'url' | 'url-host';
|
||||||
}) {
|
}) {
|
||||||
const { urlMode, siteURLData } = input;
|
const { urlMode, siteURLData } = input;
|
||||||
@@ -139,7 +169,7 @@ export function getBaseContext(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageResizer = createImageResizer({
|
const imageResizer = createImageResizer({
|
||||||
host: siteURL.host,
|
imagesContextId: siteURLData.imagesContextId,
|
||||||
// To ensure image resizing work for proxied sites,
|
// To ensure image resizing work for proxied sites,
|
||||||
// we serve images from the root of the site.
|
// we serve images from the root of the site.
|
||||||
linker: linker,
|
linker: linker,
|
||||||
@@ -157,7 +187,7 @@ export function getBaseContext(input: {
|
|||||||
*/
|
*/
|
||||||
export async function fetchSiteContextByURLLookup(
|
export async function fetchSiteContextByURLLookup(
|
||||||
baseContext: GitBookBaseContext,
|
baseContext: GitBookBaseContext,
|
||||||
data: PublishedSiteContent
|
data: SiteURLData
|
||||||
): Promise<GitBookSiteContext> {
|
): Promise<GitBookSiteContext> {
|
||||||
return await fetchSiteContextByIds(baseContext, {
|
return await fetchSiteContextByIds(baseContext, {
|
||||||
organization: data.organization,
|
organization: data.organization,
|
||||||
@@ -277,7 +307,7 @@ export async function fetchSpaceContextByIds(
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
ids.changeRequest
|
ids.changeRequest
|
||||||
? throwIfDataError(
|
? getDataOrNull(
|
||||||
dataFetcher.getChangeRequest({
|
dataFetcher.getChangeRequest({
|
||||||
spaceId: ids.space,
|
spaceId: ids.space,
|
||||||
changeRequestId: ids.changeRequest,
|
changeRequestId: ids.changeRequest,
|
||||||
@@ -286,17 +316,30 @@ export async function fetchSpaceContextByIds(
|
|||||||
: null,
|
: 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({
|
dataFetcher.getRevisionPages({
|
||||||
spaceId: ids.space,
|
spaceId: ids.space,
|
||||||
revisionId,
|
revisionId,
|
||||||
// We only care about the Git metadata when the Git sync is enabled,
|
// We only care about the Git metadata when the Git sync is enabled,
|
||||||
// otherwise we can optimize performance by not fetching it
|
// otherwise we can optimize performance by not fetching it
|
||||||
metadata: !!space.gitSync,
|
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 {
|
return {
|
||||||
...baseContext,
|
...baseContext,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
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 { 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';
|
import type { GitBookDataFetcher } from './types';
|
||||||
|
|
||||||
interface DataFetcherInput {
|
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) {
|
getReusableContent(params) {
|
||||||
return trace('getReusableContent', () =>
|
return trace('getReusableContent', () =>
|
||||||
getReusableContent(input, {
|
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) {
|
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';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('days');
|
return trace('getUserById.uncached', () => {
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.users.getUserById(params.userId);
|
const res = await api.users.getUserById(params.userId);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getSpace(
|
const getSpace = memoize(async function getSpace(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -196,24 +208,26 @@ async function getSpace(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('days');
|
return trace('getSpace.uncached', () => {
|
||||||
cacheTag(
|
cacheLife('days');
|
||||||
getCacheTag({
|
cacheTag(
|
||||||
tag: 'space',
|
getCacheTag({
|
||||||
space: params.spaceId,
|
tag: 'space',
|
||||||
})
|
space: params.spaceId,
|
||||||
);
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getSpaceById(params.spaceId, {
|
const res = await api.spaces.getSpaceById(params.spaceId, {
|
||||||
shareKey: params.shareKey,
|
shareKey: params.shareKey,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
});
|
});
|
||||||
return res.data;
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getChangeRequest(
|
const getChangeRequest = memoize(async function getChangeRequest(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -222,23 +236,28 @@ async function getChangeRequest(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('minutes');
|
return trace('getChangeRequest.uncached', () => {
|
||||||
|
cacheLife('minutes');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getChangeRequestById(params.spaceId, params.changeRequestId);
|
const res = await api.spaces.getChangeRequestById(
|
||||||
cacheTag(
|
params.spaceId,
|
||||||
getCacheTag({
|
params.changeRequestId
|
||||||
tag: 'change-request',
|
);
|
||||||
space: params.spaceId,
|
cacheTag(
|
||||||
changeRequest: res.data.id,
|
getCacheTag({
|
||||||
})
|
tag: 'change-request',
|
||||||
);
|
space: params.spaceId,
|
||||||
return res.data;
|
changeRequest: res.data.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getRevision(
|
const getRevision = memoize(async function getRevision(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -248,18 +267,20 @@ async function getRevision(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('max');
|
return trace('getRevision.uncached', () => {
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||||
metadata: params.metadata,
|
metadata: params.metadata,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
});
|
});
|
||||||
return res.data;
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getRevisionPages(
|
const getRevisionPages = memoize(async function getRevisionPages(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -269,18 +290,24 @@ async function getRevisionPages(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('max');
|
return trace('getRevisionPages.uncached', () => {
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.listPagesInRevisionById(params.spaceId, params.revisionId, {
|
const res = await api.spaces.listPagesInRevisionById(
|
||||||
metadata: params.metadata,
|
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,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -290,21 +317,56 @@ async function getRevisionFile(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('max');
|
return trace('getRevisionFile.uncached', () => {
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getFileInRevisionById(
|
const res = await api.spaces.getFileInRevisionById(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
params.revisionId,
|
params.revisionId,
|
||||||
params.fileId,
|
params.fileId,
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
return res.data;
|
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,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -314,23 +376,25 @@ async function getRevisionPageByPath(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('max');
|
return trace('getRevisionPageByPath.uncached', () => {
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(params.path);
|
const encodedPath = encodeURIComponent(params.path);
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getPageInRevisionByPath(
|
const res = await api.spaces.getPageInRevisionByPath(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
params.revisionId,
|
params.revisionId,
|
||||||
encodedPath,
|
encodedPath,
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getDocument(
|
const getDocument = memoize(async function getDocument(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -339,16 +403,18 @@ async function getDocument(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('max');
|
return trace('getDocument.uncached', () => {
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
|
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
|
||||||
return res.data;
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getComputedDocument(
|
const getComputedDocument = memoize(async function getComputedDocument(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -359,29 +425,31 @@ async function getComputedDocument(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('days');
|
return trace('getComputedDocument.uncached', () => {
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
cacheTag(
|
cacheTag(
|
||||||
...getComputedContentSourceCacheTags(
|
...getComputedContentSourceCacheTags(
|
||||||
{
|
{
|
||||||
spaceId: params.spaceId,
|
spaceId: params.spaceId,
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
},
|
},
|
||||||
params.source
|
params.source
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
||||||
source: params.source,
|
source: params.source,
|
||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
});
|
});
|
||||||
return res.data;
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getReusableContent(
|
const getReusableContent = memoize(async function getReusableContent(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -391,48 +459,54 @@ async function getReusableContent(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('max');
|
return trace('getReusableContent.uncached', () => {
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getReusableContentInRevisionById(
|
const res = await api.spaces.getReusableContentInRevisionById(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
params.revisionId,
|
params.revisionId,
|
||||||
params.reusableContentId
|
params.reusableContentId
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function getLatestOpenAPISpecVersionContent(
|
const getLatestOpenAPISpecVersionContent = memoize(
|
||||||
input: DataFetcherInput,
|
async function getLatestOpenAPISpecVersionContent(
|
||||||
params: {
|
input: DataFetcherInput,
|
||||||
organizationId: string;
|
params: {
|
||||||
slug: string;
|
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(
|
const getPublishedContentSite = memoize(async function getPublishedContentSite(
|
||||||
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(
|
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -442,24 +516,32 @@ async function getPublishedContentSite(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('days');
|
return trace('getPublishedContentSite.uncached', () => {
|
||||||
cacheTag(
|
cacheLife('days');
|
||||||
getCacheTag({
|
cacheTag(
|
||||||
tag: 'site',
|
getCacheTag({
|
||||||
site: params.siteId,
|
tag: 'site',
|
||||||
})
|
site: params.siteId,
|
||||||
);
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return trace('getPublishedContentSite', () => {
|
||||||
const api = await apiClient(input);
|
return wrapDataFetcherError(async () => {
|
||||||
const res = await api.orgs.getPublishedContentSite(params.organizationId, params.siteId, {
|
const api = await apiClient(input);
|
||||||
shareKey: params.siteShareKey,
|
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,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -470,26 +552,32 @@ async function getSiteRedirectBySource(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheTag(
|
return trace('getSiteRedirectBySource.uncached', () => {
|
||||||
getCacheTag({
|
cacheTag(
|
||||||
tag: 'site',
|
getCacheTag({
|
||||||
site: params.siteId,
|
tag: 'site',
|
||||||
})
|
site: params.siteId,
|
||||||
);
|
})
|
||||||
cacheLife('days');
|
);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.orgs.getSiteRedirectBySource(params.organizationId, params.siteId, {
|
const res = await api.orgs.getSiteRedirectBySource(
|
||||||
shareKey: params.siteShareKey,
|
params.organizationId,
|
||||||
source: params.source,
|
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,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -498,36 +586,40 @@ async function getEmbedByUrl(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('weeks');
|
return trace('getEmbedByUrl.uncached', () => {
|
||||||
|
cacheLife('weeks');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
||||||
return res.data;
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function searchSiteContent(
|
const searchSiteContent = memoize(async function searchSiteContent(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
||||||
) {
|
) {
|
||||||
'use cache';
|
'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 () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
||||||
query,
|
query,
|
||||||
...scope,
|
...scope,
|
||||||
|
});
|
||||||
|
return res.data.items;
|
||||||
});
|
});
|
||||||
return res.data.items;
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
async function renderIntegrationUi(
|
const renderIntegrationUi = memoize(async function renderIntegrationUi(
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
integrationName: string;
|
integrationName: string;
|
||||||
@@ -536,18 +628,20 @@ async function renderIntegrationUi(
|
|||||||
) {
|
) {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
|
return trace('renderIntegrationUi.uncached', () => {
|
||||||
cacheLife('days');
|
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = await apiClient(input);
|
||||||
const res = await api.integrations.renderIntegrationUiWithPost(
|
const res = await api.integrations.renderIntegrationUiWithPost(
|
||||||
params.integrationName,
|
params.integrationName,
|
||||||
params.request
|
params.request
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
let loggedServiceBinding = false;
|
let loggedServiceBinding = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { race, tryCatch } from '@/lib/async';
|
import { race, tryCatch } from '@/lib/async';
|
||||||
import { joinPath } from '@/lib/paths';
|
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||||
import { trace } from '@/lib/tracing';
|
import { trace } from '@/lib/tracing';
|
||||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
||||||
import { apiClient } from './api';
|
import { apiClient } from './api';
|
||||||
@@ -36,6 +36,13 @@ export async function getPublishedContentByURL(input: {
|
|||||||
url: alternative.url,
|
url: alternative.url,
|
||||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||||
redirectOnError: input.redirectOnError,
|
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,
|
signal,
|
||||||
@@ -100,6 +107,7 @@ export async function getPublishedContentByURL(input: {
|
|||||||
|
|
||||||
const siteResult: PublishedSiteContentLookup = {
|
const siteResult: PublishedSiteContentLookup = {
|
||||||
...data,
|
...data,
|
||||||
|
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
|
||||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||||
...(changeRequest ? { changeRequest } : {}),
|
...(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;
|
path: string;
|
||||||
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
|
}): 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.
|
* 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.
|
* Create an image resizer for a rendering context.
|
||||||
*/
|
*/
|
||||||
export function createImageResizer({
|
export function createImageResizer({
|
||||||
host,
|
imagesContextId,
|
||||||
linker,
|
linker,
|
||||||
}: {
|
}: {
|
||||||
/** The linker to use to create URLs. */
|
/** The linker to use to create URLs. */
|
||||||
linker: GitBookLinker;
|
linker: GitBookLinker;
|
||||||
/** The host name of the current site. */
|
/** The site identifier to use for verifying the image signature. */
|
||||||
host: string;
|
imagesContextId: string;
|
||||||
}): ImageResizer {
|
}): ImageResizer {
|
||||||
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
||||||
return createNoopImageResizer();
|
return createNoopImageResizer();
|
||||||
@@ -58,7 +58,7 @@ export function createImageResizer({
|
|||||||
|
|
||||||
return async (options) => {
|
return async (options) => {
|
||||||
cachedSignature ??= await generateImageSignature({
|
cachedSignature ??= await generateImageSignature({
|
||||||
host,
|
imagesContextId,
|
||||||
url: urlInput,
|
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 './createImageResizer';
|
||||||
export * from './signatures';
|
export * from './signatures';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
export * from './getImageResizingContextId';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
|
|||||||
|
|
||||||
type SignFnInput = {
|
type SignFnInput = {
|
||||||
url: string;
|
url: string;
|
||||||
host: string;
|
imagesContextId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SignFn = (input: SignFnInput) => MaybePromise<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');
|
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||||
const all = [
|
const all = [
|
||||||
input.url,
|
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,
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
|
import { CustomizationThemeMode } from '@gitbook/api';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
import type { SiteURLData } from './context';
|
||||||
|
|
||||||
export enum MiddlewareHeaders {
|
export enum MiddlewareHeaders {
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
|
|||||||
* Get the site URL data from the middleware headers.
|
* Get the site URL data from the middleware headers.
|
||||||
* This function should only be called in a server action or a dynamic route.
|
* 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 headersList = await headers();
|
||||||
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
throwIfDataError,
|
throwIfDataError,
|
||||||
} from '@v2/lib/data';
|
} from '@v2/lib/data';
|
||||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
||||||
|
import { getImageResizingContextId } from '@v2/lib/images';
|
||||||
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
||||||
|
import type { SiteURLData } from './lib/context';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
@@ -63,7 +65,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
return null;
|
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`).
|
* 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 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)
|
* 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, {
|
return await serveResizedImage(request, {
|
||||||
host: siteURL.host,
|
imagesContextId: imagesContextId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +88,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
// @ts-ignore - request typing
|
// @ts-ignore - request typing
|
||||||
const visitorToken = getVisitorToken({
|
const visitorToken = getVisitorToken({
|
||||||
cookies: request.cookies.getAll(),
|
cookies: request.cookies.getAll(),
|
||||||
url: siteURL,
|
url: siteRequestURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
const withAPIToken = async (apiToken: string | null) => {
|
const withAPIToken = async (apiToken: string | null) => {
|
||||||
const siteURLData = await throwIfDataError(
|
const siteURLData = await throwIfDataError(
|
||||||
getPublishedContentByURL({
|
getPublishedContentByURL({
|
||||||
url: siteURL.toString(),
|
url: siteRequestURL.toString(),
|
||||||
visitorAuthToken: visitorToken?.token ?? null,
|
visitorAuthToken: visitorToken?.token ?? null,
|
||||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
// 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.
|
// 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));
|
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
|
// 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
|
// 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);
|
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||||
if (
|
if (
|
||||||
requestURLWithoutToken !== incomingURL &&
|
requestURLWithoutToken !== incomingURL &&
|
||||||
@@ -163,16 +170,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
const requestHeaders = new Headers(request.headers);
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
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));
|
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
||||||
|
|
||||||
// Preview of customization/theme
|
// Preview of customization/theme
|
||||||
const customization = siteURL.searchParams.get('customization');
|
const customization = siteRequestURL.searchParams.get('customization');
|
||||||
if (customization && validateSerializedCustomization(customization)) {
|
if (customization && validateSerializedCustomization(customization)) {
|
||||||
routeType = 'dynamic';
|
routeType = 'dynamic';
|
||||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||||
}
|
}
|
||||||
const theme = siteURL.searchParams.get('theme');
|
const theme = siteRequestURL.searchParams.get('theme');
|
||||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||||
routeType = 'dynamic';
|
routeType = 'dynamic';
|
||||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
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('x-forwarded-host', request.nextUrl.host);
|
||||||
requestHeaders.set('origin', request.nextUrl.origin);
|
requestHeaders.set('origin', request.nextUrl.origin);
|
||||||
|
|
||||||
const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
|
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
|
||||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||||
siteURLData.pathname
|
siteURLData.pathname
|
||||||
);
|
);
|
||||||
routeType = routeTypeFromPathname ?? routeType;
|
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 = [
|
const route = [
|
||||||
'sites',
|
'sites',
|
||||||
routeType,
|
routeType,
|
||||||
mode,
|
mode,
|
||||||
encodeURIComponent(siteURLWithoutProtocol),
|
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,
|
pathname,
|
||||||
].join('/');
|
].join('/');
|
||||||
|
|
||||||
@@ -226,10 +262,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// For https://preview/<siteURL> requests,
|
// For https://preview/<siteURL> requests,
|
||||||
if (siteURL.hostname === 'preview') {
|
if (siteRequestURL.hostname === 'preview') {
|
||||||
return serveWithQueryAPIToken(
|
return serveWithQueryAPIToken(
|
||||||
// We scope the API token to the site ID.
|
// 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,
|
request,
|
||||||
withAPIToken
|
withAPIToken
|
||||||
);
|
);
|
||||||
@@ -382,13 +418,26 @@ function encodePathInSiteContent(rawPathname: string): {
|
|||||||
return { pathname };
|
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) {
|
switch (pathname) {
|
||||||
case '~gitbook/icon':
|
case '~gitbook/icon':
|
||||||
|
return { pathname };
|
||||||
case 'llms.txt':
|
case 'llms.txt':
|
||||||
case 'sitemap.xml':
|
case 'sitemap.xml':
|
||||||
case 'sitemap-pages.xml':
|
case 'sitemap-pages.xml':
|
||||||
case 'robots.txt':
|
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':
|
case '~gitbook/pdf':
|
||||||
// PDF routes are always dynamic as they depend on the search params.
|
// PDF routes are always dynamic as they depend on the search params.
|
||||||
return { pathname, routeType: 'dynamic' };
|
return { pathname, routeType: 'dynamic' };
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 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
|
## 0.8.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
headerLinks,
|
headerLinks,
|
||||||
runTestCases,
|
runTestCases,
|
||||||
waitForCookiesDialog,
|
waitForCookiesDialog,
|
||||||
|
waitForNotFound,
|
||||||
} from './util';
|
} from './util';
|
||||||
|
|
||||||
const testCases: TestsCase[] = [
|
const testCases: TestsCase[] = [
|
||||||
@@ -308,6 +309,18 @@ const testCases: TestsCase[] = [
|
|||||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||||||
run: waitForCookiesDialog,
|
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',
|
name: 'Content tests',
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type CustomizationThemedColor,
|
type CustomizationThemedColor,
|
||||||
type SiteCustomizationSettings,
|
type SiteCustomizationSettings,
|
||||||
} from '@gitbook/api';
|
} 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 deepMerge from 'deepmerge';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import type { DeepPartial } from 'ts-essentials';
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
@@ -33,7 +33,7 @@ export interface Test {
|
|||||||
/**
|
/**
|
||||||
* Test to run
|
* Test to run
|
||||||
*/
|
*/
|
||||||
run?: (page: Page) => Promise<unknown>;
|
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||||
/**
|
/**
|
||||||
* Whether the test should be fullscreened during testing.
|
* Whether the test should be fullscreened during testing.
|
||||||
*/
|
*/
|
||||||
@@ -138,6 +138,11 @@ export async function waitForCookiesDialog(page: Page) {
|
|||||||
await expect(dialog).toBeVisible();
|
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.
|
* 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) {
|
if (testEntry.run) {
|
||||||
await testEntry.run(page);
|
await testEntry.run(page, response);
|
||||||
}
|
}
|
||||||
const screenshotOptions = testEntry.screenshot;
|
const screenshotOptions = testEntry.screenshot;
|
||||||
if (screenshotOptions !== false) {
|
if (screenshotOptions !== false) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.8.2",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"memoizee": "^0.4.15",
|
"memoizee": "^0.4.17",
|
||||||
"next": "14.2.25",
|
"next": "14.2.25",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"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;
|
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;
|
||||||
|
|
||||||
let isOffscreen = defaultIsOffscreen;
|
let isOffscreen = defaultIsOffscreen;
|
||||||
return nodes.map((node) => {
|
return nodes.map((node, index) => {
|
||||||
isOffscreen =
|
isOffscreen =
|
||||||
isOffscreen ||
|
isOffscreen ||
|
||||||
isBlockOffscreen({
|
isBlockOffscreen({
|
||||||
@@ -65,7 +65,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Block
|
<Block
|
||||||
key={node.key}
|
key={node.key || `${node.type}-${index}`}
|
||||||
block={node}
|
block={node}
|
||||||
style={[
|
style={[
|
||||||
'mx-auto w-full decoration-primary/6',
|
'mx-auto w-full decoration-primary/6',
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ export function Inlines<T extends DocumentInline | DocumentText>(
|
|||||||
) {
|
) {
|
||||||
const { nodes, document, ancestorInlines, ...contextProps } = props;
|
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') {
|
if (node.object === 'text') {
|
||||||
return <Text key={node.key} text={node} />;
|
return <Text key={key} text={node} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Inline
|
<Inline
|
||||||
key={node.key}
|
key={key}
|
||||||
inline={node}
|
inline={node}
|
||||||
document={document}
|
document={document}
|
||||||
ancestorInlines={ancestorInlines}
|
ancestorInlines={ancestorInlines}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
|||||||
dataFetcher.renderIntegrationUi({
|
dataFetcher.renderIntegrationUi({
|
||||||
integrationName: block.data.integration,
|
integrationName: block.data.integration,
|
||||||
request: initialInput,
|
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') {
|
if (!initialOutput || initialOutput.type === 'complete') {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
|||||||
ancestorBlocks={props.ancestorBlocks}
|
ancestorBlocks={props.ancestorBlocks}
|
||||||
isEstimatedOffscreen={props.isEstimatedOffscreen}
|
isEstimatedOffscreen={props.isEstimatedOffscreen}
|
||||||
context={props.context}
|
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={{
|
block={{
|
||||||
object: 'block',
|
object: 'block',
|
||||||
key: `${block.key}-heading`,
|
key: `${block.key}-heading`,
|
||||||
|
|||||||
@@ -20,10 +20,31 @@
|
|||||||
@apply flex flex-col items-start justify-start gap-3;
|
@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;
|
@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 {
|
.openapi-deprecated-sunset-date {
|
||||||
@apply font-semibold font-mono truncate;
|
@apply font-semibold font-mono truncate;
|
||||||
}
|
}
|
||||||
@@ -131,7 +152,7 @@
|
|||||||
|
|
||||||
/* Schema Structure */
|
/* Schema Structure */
|
||||||
.openapi-schema-root {
|
.openapi-schema-root {
|
||||||
@apply mt-2;
|
/* unstyled */
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-properties {
|
.openapi-schema-properties {
|
||||||
@@ -209,6 +230,10 @@
|
|||||||
@apply text-primary-subtle/9 text-[0.813rem];
|
@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 {
|
.openapi-schema-type {
|
||||||
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
|
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
|
||||||
}
|
}
|
||||||
@@ -240,7 +265,11 @@
|
|||||||
|
|
||||||
/* Schema Enum */
|
/* Schema Enum */
|
||||||
.openapi-schema-enum {
|
.openapi-schema-enum {
|
||||||
@apply flex flex-row text-sm leading-relaxed flex-wrap text-tint;
|
@apply flex flex-row text-sm leading-relaxed gap-2 flex-wrap text-tint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schema-enum-list {
|
||||||
|
@apply flex flex-row gap-1.5 items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-enum-value {
|
.openapi-schema-enum-value {
|
||||||
@@ -467,13 +496,9 @@
|
|||||||
|
|
||||||
/* Common Elements */
|
/* Common Elements */
|
||||||
.openapi-select {
|
.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 {
|
.openapi-select:focus {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -521,7 +546,7 @@
|
|||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
.openapi-tabs-list {
|
.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;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
@@ -531,11 +556,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tabs-tab[aria-selected="true"] {
|
.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 {
|
.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 {
|
.openapi-tabs-footer {
|
||||||
@@ -654,7 +680,8 @@
|
|||||||
@apply border border-tint-subtle rounded-lg;
|
@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;
|
@apply p-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,3 +718,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-copy-button {
|
||||||
|
@apply hover:brightness-95;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
|||||||
const tabs: TabsItem[] = [];
|
const tabs: TabsItem[] = [];
|
||||||
const tabsBody: React.ReactNode[] = [];
|
const tabsBody: React.ReactNode[] = [];
|
||||||
|
|
||||||
block.nodes.forEach((tab) => {
|
block.nodes.forEach((tab, index) => {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
id: tab.meta?.id ?? tab.key!,
|
id: tab.meta?.id ?? tab.key!,
|
||||||
title: tab.data.title ?? '',
|
title: tab.data.title ?? '',
|
||||||
@@ -20,6 +20,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
|||||||
|
|
||||||
tabsBody.push(
|
tabsBody.push(
|
||||||
<Blocks
|
<Blocks
|
||||||
|
key={tab.key ?? index}
|
||||||
nodes={tab.nodes}
|
nodes={tab.nodes}
|
||||||
document={document}
|
document={document}
|
||||||
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function SpacesDropdown(props: {
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
|
<span className={tcls('line-clamp-1', 'grow')}>{siteSpace.title}</span>
|
||||||
<DropdownChevron />
|
<DropdownChevron />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
type CustomizationSettings,
|
|
||||||
type Revision,
|
type Revision,
|
||||||
type RevisionPageDocument,
|
type RevisionPageDocument,
|
||||||
type RevisionPageGroup,
|
type RevisionPageGroup,
|
||||||
@@ -24,7 +23,7 @@ import { tString } from '@/intl/translate';
|
|||||||
import { getPagePDFContainerId } from '@/lib/links';
|
import { getPagePDFContainerId } from '@/lib/links';
|
||||||
import { resolvePageId } from '@/lib/pages';
|
import { resolvePageId } from '@/lib/pages';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { defaultCustomizationForSpace } from '@/lib/utils';
|
import { defaultCustomization } from '@/lib/utils';
|
||||||
import { type PDFSearchParams, getPDFSearchParams } from './urls';
|
import { type PDFSearchParams, getPDFSearchParams } from './urls';
|
||||||
|
|
||||||
import { PageControlButtons } from './PageControlButtons';
|
import { PageControlButtons } from './PageControlButtons';
|
||||||
@@ -57,7 +56,7 @@ export async function PDFPage(props: {
|
|||||||
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
||||||
|
|
||||||
const customization =
|
const customization =
|
||||||
'customization' in baseContext ? baseContext.customization : defaultCustomizationForSpace();
|
'customization' in baseContext ? baseContext.customization : defaultCustomization();
|
||||||
const language = getSpaceLanguage(customization);
|
const language = getSpaceLanguage(customization);
|
||||||
|
|
||||||
// Compute the pages to render
|
// Compute the pages to render
|
||||||
@@ -180,7 +179,7 @@ export async function PDFPage(props: {
|
|||||||
|
|
||||||
async function PDFSpaceIntro(props: {
|
async function PDFSpaceIntro(props: {
|
||||||
space: Space;
|
space: Space;
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
customization: SiteCustomizationSettings;
|
||||||
}) {
|
}) {
|
||||||
const { space, customization } = props;
|
const { space, customization } = props;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
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';
|
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,8 +15,9 @@ export async function PDFRootLayout(props: {
|
|||||||
return (
|
return (
|
||||||
<CustomizationRootLayout
|
<CustomizationRootLayout
|
||||||
customization={
|
customization={
|
||||||
'customization' in context ? context.customization : defaultCustomizationForSpace()
|
'customization' in context ? context.customization : defaultCustomization()
|
||||||
}
|
}
|
||||||
|
forcedTheme={CustomizationThemeMode.Light}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</CustomizationRootLayout>
|
</CustomizationRootLayout>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import {
|
|||||||
CustomizationCorners,
|
CustomizationCorners,
|
||||||
CustomizationHeaderPreset,
|
CustomizationHeaderPreset,
|
||||||
CustomizationIconsStyle,
|
CustomizationIconsStyle,
|
||||||
type CustomizationSettings,
|
|
||||||
CustomizationSidebarBackgroundStyle,
|
CustomizationSidebarBackgroundStyle,
|
||||||
CustomizationSidebarListStyle,
|
CustomizationSidebarListStyle,
|
||||||
|
CustomizationThemeMode,
|
||||||
type CustomizationThemedColor,
|
type CustomizationThemedColor,
|
||||||
type CustomizationTint,
|
type CustomizationTint,
|
||||||
type SiteCustomizationSettings,
|
type SiteCustomizationSettings,
|
||||||
@@ -34,16 +34,18 @@ import { ClientContexts } from './ClientContexts';
|
|||||||
import '@gitbook/icons/style.css';
|
import '@gitbook/icons/style.css';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
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.
|
* Layout shared between the content and the PDF renderer.
|
||||||
* It takes care of setting the theme and the language.
|
* It takes care of setting the theme and the language.
|
||||||
*/
|
*/
|
||||||
export async function CustomizationRootLayout(props: {
|
export async function CustomizationRootLayout(props: {
|
||||||
customization: SiteCustomizationSettings | CustomizationSettings;
|
forcedTheme?: CustomizationThemeMode | null;
|
||||||
|
customization: SiteCustomizationSettings;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { customization, children } = props;
|
const { customization, forcedTheme, children } = props;
|
||||||
|
|
||||||
const language = getSpaceLanguage(customization);
|
const language = getSpaceLanguage(customization);
|
||||||
const tintColor = getTintColor(customization);
|
const tintColor = getTintColor(customization);
|
||||||
@@ -85,7 +87,12 @@ export async function CustomizationRootLayout(props: {
|
|||||||
'links' in customization.styling && `links-${customization.styling.links}`,
|
'links' in customization.styling && `links-${customization.styling.links}`,
|
||||||
fontNotoColorEmoji.variable,
|
fontNotoColorEmoji.variable,
|
||||||
ibmPlexMono.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>
|
<head>
|
||||||
@@ -96,6 +103,11 @@ export async function CustomizationRootLayout(props: {
|
|||||||
{/* Inject custom font @font-face rules */}
|
{/* Inject custom font @font-face rules */}
|
||||||
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
|
{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
|
<style
|
||||||
nonce={
|
nonce={
|
||||||
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
|
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
|
||||||
@@ -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.
|
* If the tint color is not set or it is a space customization, it will return the default color.
|
||||||
*/
|
*/
|
||||||
function getTintColor(
|
function getTintColor(
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings
|
customization: SiteCustomizationSettings
|
||||||
): CustomizationTint['color'] | undefined {
|
): CustomizationTint['color'] | undefined {
|
||||||
if ('tint' in customization.styling && customization.styling.tint) {
|
if ('tint' in customization.styling && customization.styling.tint) {
|
||||||
return {
|
return {
|
||||||
@@ -222,7 +234,7 @@ function getTintMixColor(
|
|||||||
* If it is a space customization, it will return the default styles.
|
* If it is a space customization, it will return the default styles.
|
||||||
*/
|
*/
|
||||||
function getSidebarStyles(
|
function getSidebarStyles(
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings
|
customization: SiteCustomizationSettings
|
||||||
): SiteCustomizationSettings['styling']['sidebar'] {
|
): SiteCustomizationSettings['styling']['sidebar'] {
|
||||||
if ('sidebar' in customization.styling) {
|
if ('sidebar' in customization.styling) {
|
||||||
return {
|
return {
|
||||||
@@ -242,7 +254,7 @@ function getSidebarStyles(
|
|||||||
* If it is a space customization, it will return the default styles.
|
* If it is a space customization, it will return the default styles.
|
||||||
*/
|
*/
|
||||||
function getSemanticColors(
|
function getSemanticColors(
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings
|
customization: SiteCustomizationSettings
|
||||||
): Pick<
|
): Pick<
|
||||||
SiteCustomizationSettings['styling'],
|
SiteCustomizationSettings['styling'],
|
||||||
'infoColor' | 'successColor' | 'warningColor' | 'dangerColor'
|
'infoColor' | 'successColor' | 'warningColor' | 'dangerColor'
|
||||||
|
|||||||
@@ -160,3 +160,7 @@ html {
|
|||||||
html.dark {
|
html.dark {
|
||||||
color-scheme: dark light;
|
color-scheme: dark light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.announcement-hidden .announcement-banner {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ function SearchModalBody(
|
|||||||
setSearchState((state) => (state ? { ...state, ask: true } : null));
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
transition={{
|
transition={{
|
||||||
@@ -288,12 +291,14 @@ function SearchModalBody(
|
|||||||
<SearchResults
|
<SearchResults
|
||||||
ref={resultsRef}
|
ref={resultsRef}
|
||||||
global={isMultiVariants && state.global}
|
global={isMultiVariants && state.global}
|
||||||
query={state.query}
|
query={normalizedQuery}
|
||||||
withAsk={withAsk}
|
withAsk={withAsk}
|
||||||
onSwitchToAsk={onSwitchToAsk}
|
onSwitchToAsk={onSwitchToAsk}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{state.query && state.ask && withAsk ? <SearchAskAnswer query={state.query} /> : null}
|
{normalizedQuery && state.ask && withAsk ? (
|
||||||
|
<SearchAskAnswer query={normalizedQuery} />
|
||||||
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
import { Link } from '../primitives';
|
||||||
import { HighlightQuery } from './HighlightQuery';
|
import { HighlightQuery } from './HighlightQuery';
|
||||||
@@ -17,6 +16,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
) {
|
) {
|
||||||
const { query, item, active } = props;
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -38,6 +45,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
|
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
|
insights={{
|
||||||
|
type: 'search_open_result',
|
||||||
|
query,
|
||||||
|
result: {
|
||||||
|
pageId: item.pageId,
|
||||||
|
spaceId: item.spaceId,
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="size-4">
|
<div className="size-4">
|
||||||
<Icon
|
<Icon
|
||||||
@@ -46,18 +61,44 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
||||||
{item.spaceTitle ? (
|
{breadcrumbs.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
'opacity-6',
|
'opacity-6',
|
||||||
|
'contrast-more:opacity-11',
|
||||||
'font-normal',
|
'font-normal',
|
||||||
'uppercase',
|
'uppercase',
|
||||||
'tracking-wider',
|
'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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<HighlightQuery query={query} text={item.title} />
|
<HighlightQuery query={query} text={item.title} />
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
|
|
||||||
let cancelled = false;
|
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.
|
// We currently have a bug where the same question can be returned multiple times.
|
||||||
// This is a workaround to avoid that.
|
// This is a workaround to avoid that.
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
|||||||
'hover:bg-primary-hover',
|
'hover:bg-primary-hover',
|
||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
|
insights={{
|
||||||
|
type: 'search_open_result',
|
||||||
|
query,
|
||||||
|
result: {
|
||||||
|
pageId: item.pageId,
|
||||||
|
spaceId: item.spaceId,
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
'use server';
|
'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 {
|
import type {
|
||||||
RevisionPage,
|
RevisionPage,
|
||||||
SearchAIAnswer,
|
SearchAIAnswer,
|
||||||
SearchAIRecommendedQuestionStream,
|
SearchAIRecommendedQuestionStream,
|
||||||
SearchPageResult,
|
SearchPageResult,
|
||||||
SiteSpace,
|
SearchSpaceResult,
|
||||||
|
SiteSection,
|
||||||
|
SiteSectionGroup,
|
||||||
Space,
|
Space,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
|
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
|
||||||
@@ -13,13 +19,9 @@ import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/li
|
|||||||
import { createStreamableValue } from 'ai/rsc';
|
import { createStreamableValue } from 'ai/rsc';
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { getAbsoluteHref } from '@/lib/links';
|
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||||
import { resolvePageId } from '@/lib/pages';
|
|
||||||
import { findSiteSpaceById } from '@/lib/sites';
|
|
||||||
import { filterOutNullable } from '@/lib/typescript';
|
|
||||||
import { getV1BaseContext } from '@/lib/v1';
|
|
||||||
|
|
||||||
import { isV2 } from '@/lib/v2';
|
import { isV2 } from '@/lib/v2';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
import { throwIfDataError } from '@v2/lib/data';
|
import { throwIfDataError } from '@v2/lib/data';
|
||||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||||
import { DocumentView } from '../DocumentView';
|
import { DocumentView } from '../DocumentView';
|
||||||
@@ -30,18 +32,24 @@ export interface ComputedSectionResult {
|
|||||||
type: 'section';
|
type: 'section';
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
|
||||||
body: string;
|
body: string;
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputedPageResult {
|
export interface ComputedPageResult {
|
||||||
type: 'page';
|
type: 'page';
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
href: string;
|
href: string;
|
||||||
|
|
||||||
/** When part of a multi-spaces search, the title of the space */
|
pageId: string;
|
||||||
spaceTitle?: string;
|
spaceId: string;
|
||||||
|
|
||||||
|
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AskAnswerSource {
|
export interface AskAnswerSource {
|
||||||
@@ -252,11 +260,27 @@ async function searchSiteContent(
|
|||||||
return (
|
return (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
searchResults.map(async (spaceItem) => {
|
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 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(
|
return Promise.all(
|
||||||
spaceItem.pages.map((item) =>
|
spaceItem.pages.map((pageItem) =>
|
||||||
transformSitePageResult(item, siteSpace ?? undefined)
|
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 spaceURL = siteSpace?.urls.published;
|
||||||
|
|
||||||
const href = spaceURL
|
const href = spaceURL
|
||||||
? await getURLWithSections(page.page.path, spaceURL)
|
? joinPathWithBaseURL(spaceURL, page.page.path)
|
||||||
: context.linker.toPathForPage({
|
: context.linker.toPathForPage({
|
||||||
pages,
|
pages,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
@@ -330,55 +354,59 @@ async function transformAnswer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transformSectionsAndPage(args: {
|
async function transformSitePageResult(
|
||||||
item: SearchPageResult;
|
context: GitBookBaseContext,
|
||||||
space?: Space;
|
args: {
|
||||||
spaceURL?: string;
|
pageItem: SearchPageResult;
|
||||||
}): Promise<[ComputedPageResult, ComputedSectionResult[]]> {
|
spaceItem: SearchSpaceResult;
|
||||||
const { item, space, spaceURL } = args;
|
space?: Space;
|
||||||
|
spaceURL?: string;
|
||||||
const sections = await Promise.all(
|
siteSection?: SiteSection;
|
||||||
item.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
|
siteSectionGroup?: SiteSectionGroup;
|
||||||
type: 'section',
|
}
|
||||||
id: `${item.id}/${section.id}`,
|
): Promise<OrderedComputedResult[]> {
|
||||||
title: section.title,
|
const { pageItem, spaceItem, space, spaceURL, siteSection, siteSectionGroup } = args;
|
||||||
href: await getURLWithSections(section.path, spaceURL),
|
const { linker } = context;
|
||||||
body: section.body,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
const page: ComputedPageResult = {
|
const page: ComputedPageResult = {
|
||||||
type: 'page',
|
type: 'page',
|
||||||
id: item.id,
|
id: `${spaceItem.id}/${pageItem.id}`,
|
||||||
title: item.title,
|
title: pageItem.title,
|
||||||
href: await getURLWithSections(item.path, spaceURL),
|
href: spaceURL
|
||||||
spaceTitle: space?.title,
|
? 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) {
|
return [page, ...pageSections];
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
|
|
||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
||||||
|
import { Announcement } from '../Announcement';
|
||||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||||
import { InsightsProvider } from '../Insights';
|
import { InsightsProvider } from '../Insights';
|
||||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||||
@@ -62,6 +63,7 @@ export function SpaceLayout(props: {
|
|||||||
spaceId={context.space.id}
|
spaceId={context.space.id}
|
||||||
visitorAuthClaims={visitorAuthClaims}
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
>
|
>
|
||||||
|
<Announcement context={context} />
|
||||||
<Header withTopHeader={withTopHeader} context={context} />
|
<Header withTopHeader={withTopHeader} context={context} />
|
||||||
<div className="scroll-nojump">
|
<div className="scroll-nojump">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
CustomizationSettings,
|
|
||||||
SiteCustomizationSettings,
|
SiteCustomizationSettings,
|
||||||
SiteInsightsTrademarkPlacement,
|
SiteInsightsTrademarkPlacement,
|
||||||
Space,
|
Space,
|
||||||
@@ -16,7 +15,7 @@ import { Link } from '../primitives';
|
|||||||
*/
|
*/
|
||||||
export function Trademark(props: {
|
export function Trademark(props: {
|
||||||
space: Space;
|
space: Space;
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
customization: SiteCustomizationSettings;
|
||||||
placement: SiteInsightsTrademarkPlacement;
|
placement: SiteInsightsTrademarkPlacement;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +68,7 @@ export function Trademark(props: {
|
|||||||
*/
|
*/
|
||||||
export function TrademarkLink(props: {
|
export function TrademarkLink(props: {
|
||||||
space: Space;
|
space: Space;
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
customization: SiteCustomizationSettings;
|
||||||
placement: SiteInsightsTrademarkPlacement;
|
placement: SiteInsightsTrademarkPlacement;
|
||||||
}) {
|
}) {
|
||||||
const { space, customization, placement } = props;
|
const { space, customization, placement } = props;
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ export function useScrollActiveId(
|
|||||||
) {
|
) {
|
||||||
const { rootMargin, threshold = 0.5 } = options;
|
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());
|
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setActiveId(null);
|
const defaultActiveId = ids[0];
|
||||||
|
setActiveId((activeId) => (ids.indexOf(activeId) !== -1 ? activeId : defaultActiveId));
|
||||||
|
|
||||||
if (typeof IntersectionObserver === 'undefined') {
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export type LinkInsightsProps = {
|
|||||||
insights?:
|
insights?:
|
||||||
| TrackEventInput<'ad_click'>
|
| TrackEventInput<'ad_click'>
|
||||||
| TrackEventInput<'link_click'>
|
| TrackEventInput<'link_click'>
|
||||||
| TrackEventInput<'trademark_click'>;
|
| TrackEventInput<'trademark_click'>
|
||||||
|
| TrackEventInput<'search_open_result'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LinkProps = Omit<BaseLinkProps, 'href'> &
|
export type LinkProps = Omit<BaseLinkProps, 'href'> &
|
||||||
|
|||||||
@@ -2,6 +2,26 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
|||||||
|
|
||||||
import { Link, type LinkProps } from '../primitives/Link';
|
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.
|
* Styled version of Link component.
|
||||||
*/
|
*/
|
||||||
@@ -9,29 +29,7 @@ export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValu
|
|||||||
const { style, ...rest } = props;
|
const { style, ...rest } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link {...rest} className={tcls(linkStyles, style)}>
|
||||||
{...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
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { generateFontFacesCSS, getFontSourcesToPreload } from './custom';
|
|||||||
const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
||||||
basic: {
|
basic: {
|
||||||
id: 'open-sans',
|
id: 'open-sans',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Open Sans',
|
fontFamily: 'Open Sans',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
|||||||
|
|
||||||
multiWeight: {
|
multiWeight: {
|
||||||
id: 'roboto',
|
id: 'roboto',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Roboto',
|
fontFamily: 'Roboto',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
@@ -83,6 +85,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
|||||||
|
|
||||||
multiSource: {
|
multiSource: {
|
||||||
id: 'lato',
|
id: 'lato',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Lato',
|
fontFamily: 'Lato',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
@@ -100,6 +103,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
|||||||
|
|
||||||
missingFormat: {
|
missingFormat: {
|
||||||
id: 'source-sans',
|
id: 'source-sans',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Source Sans Pro',
|
fontFamily: 'Source Sans Pro',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
@@ -117,12 +121,14 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
|||||||
|
|
||||||
empty: {
|
empty: {
|
||||||
id: 'empty-font',
|
id: 'empty-font',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Empty Font',
|
fontFamily: 'Empty Font',
|
||||||
fontFaces: [],
|
fontFaces: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
specialChars: {
|
specialChars: {
|
||||||
id: 'special-font',
|
id: 'special-font',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Special Font & Co.',
|
fontFamily: 'Special Font & Co.',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
@@ -134,6 +140,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
|||||||
|
|
||||||
complex: {
|
complex: {
|
||||||
id: 'complex-font',
|
id: 'complex-font',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Complex Font',
|
fontFamily: 'Complex Font',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
@@ -155,6 +162,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
|||||||
|
|
||||||
variousURLs: {
|
variousURLs: {
|
||||||
id: 'various-urls',
|
id: 'various-urls',
|
||||||
|
custom: true,
|
||||||
fontFamily: 'Various URLs Font',
|
fontFamily: 'Various URLs Font',
|
||||||
fontFaces: [
|
fontFaces: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api';
|
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||||
|
|
||||||
import { type TranslationLanguage, languages } from './translations';
|
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.
|
* Create the translation context for a space to use in the server components.
|
||||||
*/
|
*/
|
||||||
export function getSpaceLanguage(
|
export function getSpaceLanguage(customization: SiteCustomizationSettings): TranslationLanguage {
|
||||||
customization: CustomizationSettings | SiteCustomizationSettings
|
|
||||||
): TranslationLanguage {
|
|
||||||
const fallback = languages.en;
|
const fallback = languages.en;
|
||||||
|
|
||||||
const { locale } = customization.internationalization;
|
const { locale } = customization.internationalization;
|
||||||
|
|||||||
@@ -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';
|
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`.
|
* 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;
|
const { apiToken } = siteData;
|
||||||
|
|
||||||
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(apiToken));
|
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(apiToken));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
OpenAPISchemasBlock,
|
OpenAPISchemasBlock,
|
||||||
ResolveOpenAPIBlockArgs,
|
ResolveOpenAPIBlockArgs,
|
||||||
} from '@/lib/openapi/types';
|
} from '@/lib/openapi/types';
|
||||||
|
import { memoize } from '@v2/lib/data/memoize';
|
||||||
import { assert } from 'ts-essentials';
|
import { assert } from 'ts-essentials';
|
||||||
import { resolveContentRef } from '../references';
|
import { resolveContentRef } from '../references';
|
||||||
import { isV2 } from '../v2';
|
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';
|
'use cache';
|
||||||
|
|
||||||
// TODO: add cache lifetime once we can use next.js 15 code here
|
// 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);
|
const response = await fetchFilesystemUncached(url);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
});
|
||||||
|
|
||||||
async function fetchFilesystemUncached(
|
async function fetchFilesystemUncached(
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -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 "/".
|
* The path will NOT start with "/".
|
||||||
*/
|
*/
|
||||||
export function getPagePath(
|
export function getPagePath(
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ export function joinPath(...parts: string[]): string {
|
|||||||
return parts.join('/').replace(/\/+/g, '/');
|
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.
|
* Remove the trailing slash from a path.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,16 +28,21 @@ export async function trace<T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const start = now();
|
const start = now();
|
||||||
|
let failed = false;
|
||||||
try {
|
try {
|
||||||
return await fn(span);
|
return await fn(span);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
span.setAttribute('error', true);
|
span.setAttribute('error', true);
|
||||||
|
failed = true;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (process.env.SILENT !== 'true') {
|
if (process.env.SILENT !== 'true' && process.env.NODE_ENV !== 'development') {
|
||||||
const end = now();
|
const end = now();
|
||||||
// biome-ignore lint/suspicious/noConsole: we want to log performance data
|
// 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import * as api from '@gitbook/api';
|
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 {
|
return {
|
||||||
internationalization: {
|
|
||||||
inherit: false,
|
|
||||||
locale: api.CustomizationLocale.En,
|
|
||||||
},
|
|
||||||
styling: {
|
styling: {
|
||||||
primaryColor: {
|
theme: api.CustomizationTheme.Clean,
|
||||||
dark: '#346DDB',
|
primaryColor: { light: '#346DDB', dark: '#346DDB' },
|
||||||
light: '#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,
|
corners: api.CustomizationCorners.Rounded,
|
||||||
font: api.CustomizationDefaultFont.Inter,
|
font: api.CustomizationDefaultFont.Inter,
|
||||||
background: api.CustomizationBackground.Plain,
|
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: {},
|
favicon: {},
|
||||||
header: {
|
header: {
|
||||||
@@ -28,28 +36,32 @@ export function defaultCustomizationForSpace(): api.CustomizationSettings {
|
|||||||
},
|
},
|
||||||
themes: {
|
themes: {
|
||||||
default: api.CustomizationThemeMode.Light,
|
default: api.CustomizationThemeMode.Light,
|
||||||
toggeable: false,
|
toggeable: true,
|
||||||
},
|
},
|
||||||
trademark: {
|
pdf: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
feedback: {
|
feedback: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
pdf: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
aiSearch: {
|
aiSearch: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
advancedCustomization: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
showEditLink: false,
|
||||||
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
privacyPolicy: {},
|
trademark: {
|
||||||
socialPreview: {},
|
enabled: true,
|
||||||
git: {
|
|
||||||
showEditLink: false,
|
|
||||||
},
|
},
|
||||||
inherit: false,
|
privacyPolicy: {
|
||||||
|
url: 'https://www.gitbook.com/privacy',
|
||||||
|
},
|
||||||
|
socialPreview: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
|
|||||||
const dataFetcher = await getDataFetcherV1();
|
const dataFetcher = await getDataFetcherV1();
|
||||||
|
|
||||||
const imageResizer = createImageResizer({
|
const imageResizer = createImageResizer({
|
||||||
host,
|
imagesContextId: host,
|
||||||
// In V1, we always resize at the top level of the hostname, not relative to the content.
|
// In V1, we always resize at the top level of the hostname, not relative to the content.
|
||||||
linker: createLinker({
|
linker: createLinker({
|
||||||
host,
|
host,
|
||||||
@@ -153,6 +153,10 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRevisionPageMarkdown() {
|
||||||
|
throw new Error('Not implemented in v1');
|
||||||
|
},
|
||||||
|
|
||||||
getDocument(params) {
|
getDocument(params) {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const document = await getDocument(params.spaceId, params.documentId);
|
const document = await getDocument(params.spaceId, params.documentId);
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
|
|||||||
apiEndpoint,
|
apiEndpoint,
|
||||||
contextId,
|
contextId,
|
||||||
cookies,
|
cookies,
|
||||||
|
canonicalUrl: url.toString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export async function serveResizedImage(
|
|||||||
request: Request,
|
request: Request,
|
||||||
requestOptions: {
|
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);
|
const requestURL = new URL(request.url);
|
||||||
@@ -45,8 +45,8 @@ export async function serveResizedImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
const host =
|
const imagesContextId =
|
||||||
requestOptions.host ??
|
requestOptions.imagesContextId ??
|
||||||
request.headers.get('x-gitbook-host') ?? // Only for v1, to be removed
|
request.headers.get('x-gitbook-host') ?? // Only for v1, to be removed
|
||||||
request.headers.get('x-forwarded-host') ??
|
request.headers.get('x-forwarded-host') ??
|
||||||
request.headers.get('host') ??
|
request.headers.get('host') ??
|
||||||
@@ -54,13 +54,13 @@ export async function serveResizedImage(
|
|||||||
const verified = await verifyImageSignature(
|
const verified = await verifyImageSignature(
|
||||||
{
|
{
|
||||||
url,
|
url,
|
||||||
host,
|
imagesContextId,
|
||||||
},
|
},
|
||||||
{ signature, version: signatureVersion }
|
{ signature, version: signatureVersion }
|
||||||
);
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
return new Response(
|
return new Response(
|
||||||
`Invalid signature "${signature ?? ''}" (version ${signatureVersion}) for "${url}" on "${host}"`,
|
`Invalid signature "${signature ?? ''}" (version ${signatureVersion}) for "${url}" on identifier "${imagesContextId}"`,
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 assertNever from 'assert-never';
|
||||||
import type { ListItem, Paragraph, Root, RootContent } from 'mdast';
|
import type { ListItem, Paragraph, Root, RootContent } from 'mdast';
|
||||||
import { toMarkdown } from 'mdast-util-to-markdown';
|
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.
|
* 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;
|
const { site } = context;
|
||||||
|
|
||||||
if (!checkIsRootSiteContext(context)) {
|
if (!checkIsRootSiteContext(context)) {
|
||||||
@@ -28,7 +36,7 @@ export async function serveLLMsTxt(context: GitBookSiteContext) {
|
|||||||
depth: 1,
|
depth: 1,
|
||||||
children: [{ type: 'text', value: site.title }],
|
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.
|
* 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) {
|
switch (context.structure.type) {
|
||||||
case 'sections':
|
case 'sections':
|
||||||
return getNodesFromSections(
|
return getNodesFromSections(
|
||||||
context,
|
context,
|
||||||
getSiteStructureSections(context.structure, { ignoreGroups: true })
|
getSiteStructureSections(context.structure, { ignoreGroups: true }),
|
||||||
|
{ withMarkdownPages: options.withMarkdownPages }
|
||||||
);
|
);
|
||||||
case 'siteSpaces':
|
case 'siteSpaces':
|
||||||
return getNodesFromSiteSpaces(context, context.structure.structure, { heading: true });
|
return getNodesFromSiteSpaces(context, context.structure.structure, {
|
||||||
|
heading: true,
|
||||||
|
withMarkdownPages: options.withMarkdownPages,
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
assertNever(context.structure);
|
assertNever(context.structure);
|
||||||
}
|
}
|
||||||
@@ -66,12 +83,16 @@ async function getNodesFromSiteStructure(context: GitBookSiteContext): Promise<R
|
|||||||
*/
|
*/
|
||||||
async function getNodesFromSections(
|
async function getNodesFromSections(
|
||||||
context: GitBookSiteContext,
|
context: GitBookSiteContext,
|
||||||
siteSections: SiteSection[]
|
siteSections: SiteSection[],
|
||||||
|
options: {
|
||||||
|
withMarkdownPages: boolean;
|
||||||
|
}
|
||||||
): Promise<RootContent[]> {
|
): Promise<RootContent[]> {
|
||||||
const all = await Promise.all(
|
const all = await Promise.all(
|
||||||
siteSections.map(async (siteSection): Promise<RootContent[]> => {
|
siteSections.map(async (siteSection): Promise<RootContent[]> => {
|
||||||
const siteSpaceNodes = await getNodesFromSiteSpaces(context, siteSection.siteSpaces, {
|
const siteSpaceNodes = await getNodesFromSiteSpaces(context, siteSection.siteSpaces, {
|
||||||
heading: false,
|
heading: false,
|
||||||
|
withMarkdownPages: options.withMarkdownPages,
|
||||||
});
|
});
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -97,6 +118,11 @@ async function getNodesFromSiteSpaces(
|
|||||||
* Includes a heading for each site space.
|
* Includes a heading for each site space.
|
||||||
*/
|
*/
|
||||||
heading?: boolean;
|
heading?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, a markdown extension will be added to the page path.
|
||||||
|
*/
|
||||||
|
withMarkdownPages: boolean;
|
||||||
}
|
}
|
||||||
): Promise<RootContent[]> {
|
): Promise<RootContent[]> {
|
||||||
const { dataFetcher, linker } = context;
|
const { dataFetcher, linker } = context;
|
||||||
@@ -118,7 +144,10 @@ async function getNodesFromSiteSpaces(
|
|||||||
const listChildren = await Promise.all(
|
const listChildren = await Promise.all(
|
||||||
pages.map(async ({ page }): Promise<ListItem> => {
|
pages.map(async ({ page }): Promise<ListItem> => {
|
||||||
const pageURL = new URL(siteSpaceUrl);
|
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 url = linker.toLinkForContent(pageURL.toString());
|
||||||
const children: Paragraph['children'] = [
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ export async function serveRobotsTxt(context: GitBookSiteContext) {
|
|||||||
const isRoot = checkIsRootSiteContext(context);
|
const isRoot = checkIsRootSiteContext(context);
|
||||||
const lines = [
|
const lines = [
|
||||||
'User-agent: *',
|
'User-agent: *',
|
||||||
'Disallow: /~gitbook/',
|
// Disallow dynamic routes / search queries
|
||||||
|
'Disallow: /*?',
|
||||||
...((await isSiteIndexable(context))
|
...((await isSiteIndexable(context))
|
||||||
? [
|
? [
|
||||||
'Allow: /',
|
'Allow: /',
|
||||||
|
|||||||
Vendored
+9
@@ -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 module 'memoizee/weak' {
|
||||||
declare function memoizee<F extends (...args: any[]) => any>(f: F): F;
|
declare function memoizee<F extends (...args: any[]) => any>(f: F): F;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# @gitbook/openapi-parser
|
# @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
|
## 2.1.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/openapi-parser": "^0.10.10",
|
"@scalar/openapi-parser": "^0.10.10",
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export type * from '@scalar/openapi-types';
|
|||||||
export * from './error';
|
export * from './error';
|
||||||
export * from './helpers/shouldIgnoreEntity';
|
export * from './helpers/shouldIgnoreEntity';
|
||||||
export * from './traverse';
|
export * from './traverse';
|
||||||
|
export * from './schemas';
|
||||||
export type * from './types';
|
export type * from './types';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -47,8 +47,31 @@ export interface OpenAPICustomOperationProperties {
|
|||||||
* Description in Document format.
|
* Description in Document format.
|
||||||
*/
|
*/
|
||||||
'x-gitbook-description-document'?: object;
|
'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.
|
* Custom code samples that can be defined at the operation level.
|
||||||
* It follows the spec defined by Redocly.
|
* It follows the spec defined by Redocly.
|
||||||
@@ -79,3 +102,8 @@ export type FilesystemEntry<T extends AnyObject> = {
|
|||||||
filename: string;
|
filename: string;
|
||||||
specification: T;
|
specification: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpenAPISchema = {
|
||||||
|
name: string;
|
||||||
|
schema: OpenAPIV3.SchemaObject;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# @gitbook/react-openapi
|
# @gitbook/react-openapi
|
||||||
|
|
||||||
|
## 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
|
## 1.1.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export function InteractiveSection(props: {
|
|||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
/** Content of the header */
|
/** Content of the header */
|
||||||
header?: React.ReactNode;
|
header?: React.ReactNode;
|
||||||
/** Body of the section */
|
|
||||||
children?: React.ReactNode;
|
|
||||||
/** Children to display within the container */
|
/** Children to display within the container */
|
||||||
overlay?: React.ReactNode;
|
overlay?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -45,7 +43,6 @@ export function InteractiveSection(props: {
|
|||||||
tabs = [],
|
tabs = [],
|
||||||
defaultTab = tabs[0]?.key,
|
defaultTab = tabs[0]?.key,
|
||||||
header,
|
header,
|
||||||
children,
|
|
||||||
overlay,
|
overlay,
|
||||||
toggleIcon = '▶',
|
toggleIcon = '▶',
|
||||||
} = props;
|
} = props;
|
||||||
@@ -83,7 +80,7 @@ export function InteractiveSection(props: {
|
|||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
<SectionHeaderContent className={className}>
|
<SectionHeaderContent className={className}>
|
||||||
{(children || selectedTab?.body) && toggeable ? (
|
{selectedTab?.body && toggeable ? (
|
||||||
<button
|
<button
|
||||||
{...mergeProps(buttonProps, focusProps)}
|
{...mergeProps(buttonProps, focusProps)}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
@@ -131,9 +128,8 @@ export function InteractiveSection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
) : null}
|
) : null}
|
||||||
{(!toggeable || state.isExpanded) && (children || selectedTab?.body) ? (
|
{(!toggeable || state.isExpanded) && selectedTab?.body ? (
|
||||||
<SectionBody ref={panelRef} {...panelProps} className={className}>
|
<SectionBody ref={panelRef} {...panelProps} className={className}>
|
||||||
{children}
|
|
||||||
{selectedTab?.body}
|
{selectedTab?.body}
|
||||||
</SectionBody>
|
</SectionBody>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -148,7 +148,13 @@ function generateCodeSamples(props: {
|
|||||||
return {
|
return {
|
||||||
key: `default-${generator.id}`,
|
key: `default-${generator.id}`,
|
||||||
label: generator.label,
|
label: generator.label,
|
||||||
body: <OpenAPIMediaTypeExamplesBody data={data} renderers={renderers} />,
|
body: (
|
||||||
|
<OpenAPIMediaTypeExamplesBody
|
||||||
|
method={data.method}
|
||||||
|
path={data.path}
|
||||||
|
renderers={renderers}
|
||||||
|
/>
|
||||||
|
),
|
||||||
footer: (
|
footer: (
|
||||||
<OpenAPICodeSampleFooter renderers={renderers} data={data} context={context} />
|
<OpenAPICodeSampleFooter renderers={renderers} data={data} context={context} />
|
||||||
),
|
),
|
||||||
@@ -189,9 +195,10 @@ function OpenAPICodeSampleFooter(props: {
|
|||||||
const { method, path } = data;
|
const { method, path } = data;
|
||||||
const { specUrl } = context;
|
const { specUrl } = context;
|
||||||
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,8 +208,12 @@ function OpenAPICodeSampleFooter(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="openapi-codesample-footer">
|
<div className="openapi-codesample-footer">
|
||||||
{hasMediaTypes ? (
|
{hasMultipleMediaTypes ? (
|
||||||
<OpenAPIMediaTypeExamplesSelector data={data} renderers={renderers} />
|
<OpenAPIMediaTypeExamplesSelector
|
||||||
|
method={data.method}
|
||||||
|
path={data.path}
|
||||||
|
renderers={renderers}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import clsx from 'clsx';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
import type { MediaTypeRenderer } from './OpenAPICodeSample';
|
import type { MediaTypeRenderer } from './OpenAPICodeSample';
|
||||||
import type { OpenAPIOperationData } from './types';
|
|
||||||
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
|
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 { method, path } = data;
|
||||||
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
|
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
|
||||||
if (typeof store.tabKey !== 'string') {
|
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 { method, path } = data;
|
||||||
const store = useStore(
|
const store = useStore(
|
||||||
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
|
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
|
||||||
@@ -33,40 +40,57 @@ function useMediaTypeSampleIndexState(data: OpenAPIOperationData, mediaType: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OpenAPIMediaTypeExamplesSelector(props: {
|
export function OpenAPIMediaTypeExamplesSelector(props: {
|
||||||
data: OpenAPIOperationData;
|
method: string;
|
||||||
|
path: string;
|
||||||
renderers: MediaTypeRenderer[];
|
renderers: MediaTypeRenderer[];
|
||||||
}) {
|
}) {
|
||||||
const { data, renderers } = props;
|
const { method, path, renderers } = props;
|
||||||
if (!renderers[0]) {
|
if (!renderers[0]) {
|
||||||
throw new Error('No renderers provided');
|
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];
|
const selected = renderers.find((r) => r.mediaType === state.mediaType) || renderers[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="openapi-codesample-selectors">
|
<div className="openapi-codesample-selectors">
|
||||||
<select
|
<MediaTypeSelector state={state} renderers={renderers} />
|
||||||
className={clsx('openapi-select')}
|
<ExamplesSelector method={method} path={path} renderer={selected} />
|
||||||
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} />
|
|
||||||
</div>
|
</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: {
|
function ExamplesSelector(props: {
|
||||||
data: OpenAPIOperationData;
|
method: string;
|
||||||
|
path: string;
|
||||||
renderer: MediaTypeRenderer;
|
renderer: MediaTypeRenderer;
|
||||||
}) {
|
}) {
|
||||||
const { data, renderer } = props;
|
const { method, path, renderer } = props;
|
||||||
const state = useMediaTypeSampleIndexState(data, renderer.mediaType);
|
const state = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
|
||||||
if (renderer.examples.length < 2) {
|
if (renderer.examples.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -87,25 +111,26 @@ function ExamplesSelector(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OpenAPIMediaTypeExamplesBody(props: {
|
export function OpenAPIMediaTypeExamplesBody(props: {
|
||||||
data: OpenAPIOperationData;
|
method: string;
|
||||||
|
path: string;
|
||||||
renderers: MediaTypeRenderer[];
|
renderers: MediaTypeRenderer[];
|
||||||
}) {
|
}) {
|
||||||
const { renderers, data } = props;
|
const { renderers, method, path } = props;
|
||||||
if (!renderers[0]) {
|
if (!renderers[0]) {
|
||||||
throw new Error('No renderers provided');
|
throw new Error('No renderers provided');
|
||||||
}
|
}
|
||||||
const mediaTypeState = useMediaTypeState(data, renderers[0].mediaType);
|
const mediaTypeState = useMediaTypeState({ method, path }, renderers[0].mediaType);
|
||||||
const selected =
|
const selected =
|
||||||
renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0];
|
renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0];
|
||||||
if (selected.examples.length === 0) {
|
if (selected.examples.length === 0) {
|
||||||
return selected.element;
|
return selected.element;
|
||||||
}
|
}
|
||||||
return <ExamplesBody data={data} renderer={selected} />;
|
return <ExamplesBody method={method} path={path} renderer={selected} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExamplesBody(props: { data: OpenAPIOperationData; renderer: MediaTypeRenderer }) {
|
function ExamplesBody(props: { method: string; path: string; renderer: MediaTypeRenderer }) {
|
||||||
const { data, renderer } = props;
|
const { method, path, renderer } = props;
|
||||||
const exampleState = useMediaTypeSampleIndexState(data, renderer.mediaType);
|
const exampleState = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
|
||||||
const example = renderer.examples[exampleState.index] ?? renderer.examples[0];
|
const example = renderer.examples[exampleState.index] ?? renderer.examples[0];
|
||||||
if (!example) {
|
if (!example) {
|
||||||
throw new Error(`No example found for index ${exampleState.index}`);
|
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(
|
export function OpenAPICopyButton(
|
||||||
props: ButtonProps & {
|
props: ButtonProps & {
|
||||||
value: string;
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* Whether to show a tooltip.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
withTooltip?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { value } = props;
|
const { value, label, children, onPress, className, withTooltip = true } = props;
|
||||||
const { children, onPress, className } = props;
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -21,12 +27,19 @@ export function OpenAPICopyButton(
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
|
setIsOpen(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger isOpen={isOpen} onOpenChange={setIsOpen} closeDelay={200} delay={200}>
|
<TooltipTrigger
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
isDisabled={!withTooltip}
|
||||||
|
closeDelay={200}
|
||||||
|
delay={200}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
preventFocusOnPress
|
preventFocusOnPress
|
||||||
@@ -47,7 +60,7 @@ export function OpenAPICopyButton(
|
|||||||
offset={4}
|
offset={4}
|
||||||
className="openapi-tooltip"
|
className="openapi-tooltip"
|
||||||
>
|
>
|
||||||
{copied ? 'Copied' : 'Copy to clipboard'}{' '}
|
{copied ? 'Copied' : label || 'Copy to clipboard'}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import clsx from 'clsx';
|
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 { Markdown } from './Markdown';
|
||||||
import { OpenAPICodeSample } from './OpenAPICodeSample';
|
import { OpenAPICodeSample } from './OpenAPICodeSample';
|
||||||
import { OpenAPIPath } from './OpenAPIPath';
|
import { OpenAPIPath } from './OpenAPIPath';
|
||||||
@@ -29,14 +33,24 @@ export function OpenAPIOperation(props: {
|
|||||||
return (
|
return (
|
||||||
<div className={clsx('openapi-operation', className)}>
|
<div className={clsx('openapi-operation', className)}>
|
||||||
<div className="openapi-summary" id={operation.summary ? undefined : context.id}>
|
<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
|
{operation.summary
|
||||||
? context.renderHeading({
|
? context.renderHeading({
|
||||||
deprecated: operation.deprecated ?? false,
|
deprecated: operation.deprecated ?? false,
|
||||||
|
stability: operation['x-stability'],
|
||||||
title: operation.summary,
|
title: operation.summary,
|
||||||
})
|
})
|
||||||
: null}
|
: null}
|
||||||
<OpenAPIPath data={data} context={context} />
|
<OpenAPIPath data={data} context={context} />
|
||||||
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="openapi-columns">
|
<div className="openapi-columns">
|
||||||
<div className="openapi-column-spec">
|
<div className="openapi-column-spec">
|
||||||
@@ -89,3 +103,26 @@ function OpenAPIOperationDescription(props: {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function formatPath(path: string) {
|
|||||||
parts.push(path.slice(lastIndex, offset));
|
parts.push(path.slice(lastIndex, offset));
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
<span key={offset} className="openapi-path-variable">
|
<span key={`offset-${offset}`} className="openapi-path-variable">
|
||||||
{match}
|
{match}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -61,7 +61,7 @@ function formatPath(path: string) {
|
|||||||
|
|
||||||
const formattedPath = parts.map((part, index) => {
|
const formattedPath = parts.map((part, index) => {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
return <span key={index}>{part}</span>;
|
return <span key={`part-${index}`}>{part}</span>;
|
||||||
}
|
}
|
||||||
return part;
|
return part;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||||
import { InteractiveSection } from './InteractiveSection';
|
import { InteractiveSection } from './InteractiveSection';
|
||||||
import { OpenAPIRootSchema } from './OpenAPISchema';
|
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
|
||||||
import type { OpenAPIClientContext } from './types';
|
import type { OpenAPIClientContext } from './types';
|
||||||
import { checkIsReference } from './utils';
|
import { checkIsReference } from './utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||||
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
|
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
|
||||||
import { OpenAPISchemaProperties } from './OpenAPISchema';
|
import { OpenAPISchemaProperties } from './OpenAPISchemaServer';
|
||||||
import type { OpenAPIClientContext } from './types';
|
import type { OpenAPIClientContext } from './types';
|
||||||
import { parameterToProperty, resolveDescription } from './utils';
|
import { parameterToProperty, resolveDescription } from './utils';
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ export function OpenAPIResponse(props: {
|
|||||||
{headers.length > 0 ? (
|
{headers.length > 0 ? (
|
||||||
<OpenAPIDisclosure context={context} label="Headers">
|
<OpenAPIDisclosure context={context} label="Headers">
|
||||||
<OpenAPISchemaProperties
|
<OpenAPISchemaProperties
|
||||||
properties={headers.map(([name, header]) => {
|
properties={headers.map(([name, header]) =>
|
||||||
return parameterToProperty({ name, ...header });
|
parameterToProperty({ name, ...header })
|
||||||
})}
|
)}
|
||||||
context={context}
|
context={context}
|
||||||
/>
|
/>
|
||||||
</OpenAPIDisclosure>
|
</OpenAPIDisclosure>
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ export function OpenAPIResponses(props: {
|
|||||||
return {
|
return {
|
||||||
id: statusCode,
|
id: statusCode,
|
||||||
label: (
|
label: (
|
||||||
<div
|
<div className="openapi-response-tab-content">
|
||||||
className="openapi-response-tab-content"
|
|
||||||
key={`response-${statusCode}`}
|
|
||||||
>
|
|
||||||
<span className="openapi-response-statuscode">
|
<span className="openapi-response-statuscode">
|
||||||
{statusCode}
|
{statusCode}
|
||||||
</span>
|
</span>
|
||||||
@@ -47,7 +44,6 @@ export function OpenAPIResponses(props: {
|
|||||||
label: contentType,
|
label: contentType,
|
||||||
body: (
|
body: (
|
||||||
<OpenAPIResponse
|
<OpenAPIResponse
|
||||||
key={`$response-${statusCode}-${contentType}`}
|
|
||||||
response={response}
|
response={response}
|
||||||
mediaType={mediaType}
|
mediaType={mediaType}
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
'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 { OpenAPICustomOperationProperties, OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
|
import { OpenAPICopyButton } from './OpenAPICopyButton';
|
||||||
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
|
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
|
||||||
import { OpenAPISchemaName } from './OpenAPISchemaName';
|
import { OpenAPISchemaName } from './OpenAPISchemaName';
|
||||||
|
import { retrocycle } from './decycle';
|
||||||
import type { OpenAPIClientContext } from './types';
|
import type { OpenAPIClientContext } from './types';
|
||||||
import { checkIsReference, resolveDescription, resolveFirstExample } from './utils';
|
import { checkIsReference, resolveDescription, resolveFirstExample } from './utils';
|
||||||
|
|
||||||
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
|
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
|
||||||
|
|
||||||
interface OpenAPISchemaPropertyEntry {
|
export interface OpenAPISchemaPropertyEntry {
|
||||||
propertyName?: string | undefined;
|
propertyName?: string | undefined;
|
||||||
required?: boolean | undefined;
|
required?: boolean | undefined;
|
||||||
schema: OpenAPIV3.SchemaObject;
|
schema: OpenAPIV3.SchemaObject;
|
||||||
@@ -22,15 +28,10 @@ interface OpenAPISchemaPropertyEntry {
|
|||||||
function OpenAPISchemaProperty(props: {
|
function OpenAPISchemaProperty(props: {
|
||||||
property: OpenAPISchemaPropertyEntry;
|
property: OpenAPISchemaPropertyEntry;
|
||||||
context: OpenAPIClientContext;
|
context: OpenAPIClientContext;
|
||||||
circularRefs?: CircularRefsIds;
|
circularRefs: CircularRefsIds;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { circularRefs: parentCircularRefs, context, className, property } = props;
|
||||||
property,
|
|
||||||
circularRefs: parentCircularRefs = new Map<OpenAPIV3.SchemaObject, string>(),
|
|
||||||
context,
|
|
||||||
className,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const { schema } = property;
|
const { schema } = property;
|
||||||
|
|
||||||
@@ -40,37 +41,43 @@ function OpenAPISchemaProperty(props: {
|
|||||||
<div id={id} className={clsx('openapi-schema', className)}>
|
<div id={id} className={clsx('openapi-schema', className)}>
|
||||||
<OpenAPISchemaPresentation property={property} />
|
<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
|
// Avoid recursing infinitely, and instead render a link to the parent schema
|
||||||
if (parentCircularRef) {
|
if (circularRefId) {
|
||||||
return <OpenAPISchemaCircularRef id={parentCircularRef} schema={schema} />;
|
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 properties = getSchemaProperties(schema);
|
||||||
const alternatives = getSchemaAlternatives(schema, new Set(circularRefs.keys()));
|
if (properties?.length) {
|
||||||
return (
|
return (
|
||||||
<>
|
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
|
||||||
{alternatives?.map((schema, index) => (
|
<OpenAPISchemaProperties
|
||||||
<OpenAPISchemaAlternative
|
properties={properties}
|
||||||
key={index}
|
|
||||||
schema={schema}
|
|
||||||
circularRefs={circularRefs}
|
circularRefs={circularRefs}
|
||||||
context={context}
|
context={context}
|
||||||
/>
|
/>
|
||||||
))}
|
</OpenAPIDisclosure>
|
||||||
{properties?.length ? (
|
);
|
||||||
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
|
}
|
||||||
<OpenAPISchemaProperties
|
|
||||||
properties={properties}
|
const ancestors = new Set(circularRefs.keys());
|
||||||
circularRefs={circularRefs}
|
const alternatives = getSchemaAlternatives(schema, ancestors);
|
||||||
context={context}
|
|
||||||
/>
|
if (alternatives) {
|
||||||
</OpenAPIDisclosure>
|
return alternatives.map((schema, index) => (
|
||||||
) : null}
|
<OpenAPISchemaAlternative
|
||||||
</>
|
key={index}
|
||||||
);
|
schema={schema}
|
||||||
|
circularRefs={circularRefs}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -79,41 +86,77 @@ function OpenAPISchemaProperty(props: {
|
|||||||
/**
|
/**
|
||||||
* Render a set of properties of an OpenAPI schema.
|
* Render a set of properties of an OpenAPI schema.
|
||||||
*/
|
*/
|
||||||
export function OpenAPISchemaProperties(props: {
|
function OpenAPISchemaProperties(props: {
|
||||||
id?: string;
|
id?: string;
|
||||||
properties: OpenAPISchemaPropertyEntry[];
|
properties: OpenAPISchemaPropertyEntry[];
|
||||||
circularRefs?: CircularRefsIds;
|
circularRefs?: CircularRefsIds;
|
||||||
context: OpenAPIClientContext;
|
context: OpenAPIClientContext;
|
||||||
}) {
|
}) {
|
||||||
const { id, properties, circularRefs, context } = props;
|
const {
|
||||||
|
id,
|
||||||
|
properties,
|
||||||
|
circularRefs = new Map<OpenAPIV3.SchemaObject, string>(),
|
||||||
|
context,
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={id} className="openapi-schema-properties">
|
<div id={id} className="openapi-schema-properties">
|
||||||
{properties.map((property, index) => (
|
{properties.map((property, index) => {
|
||||||
<OpenAPISchemaProperty
|
return (
|
||||||
key={index}
|
<OpenAPISchemaProperty
|
||||||
circularRefs={circularRefs}
|
key={index}
|
||||||
property={property}
|
circularRefs={circularRefs}
|
||||||
context={context}
|
property={property}
|
||||||
/>
|
context={context}
|
||||||
))}
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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).
|
* Render a root schema (such as the request body or response body).
|
||||||
*/
|
*/
|
||||||
export function OpenAPIRootSchema(props: {
|
function OpenAPIRootSchema(props: {
|
||||||
schema: OpenAPIV3.SchemaObject;
|
schema: OpenAPIV3.SchemaObject;
|
||||||
context: OpenAPIClientContext;
|
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);
|
const properties = getSchemaProperties(schema);
|
||||||
|
|
||||||
if (properties?.length) {
|
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 (
|
return (
|
||||||
@@ -121,6 +164,19 @@ export function OpenAPIRootSchema(props: {
|
|||||||
className="openapi-schema-root"
|
className="openapi-schema-root"
|
||||||
property={{ schema }}
|
property={{ schema }}
|
||||||
context={context}
|
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 +192,7 @@ function OpenAPISchemaAlternative(props: {
|
|||||||
context: OpenAPIClientContext;
|
context: OpenAPIClientContext;
|
||||||
}) {
|
}) {
|
||||||
const { schema, circularRefs, context } = props;
|
const { schema, circularRefs, context } = props;
|
||||||
|
|
||||||
const description = resolveDescription(schema);
|
const description = resolveDescription(schema);
|
||||||
const properties = getSchemaProperties(schema);
|
const properties = getSchemaProperties(schema);
|
||||||
|
|
||||||
@@ -180,20 +237,59 @@ function OpenAPISchemaCircularRef(props: { id: string; schema: OpenAPIV3.SchemaO
|
|||||||
/**
|
/**
|
||||||
* Render the enum value for a schema.
|
* Render the enum value for a schema.
|
||||||
*/
|
*/
|
||||||
function OpenAPISchemaEnum(props: { enumValues: any[] }) {
|
function OpenAPISchemaEnum(props: {
|
||||||
const { enumValues } = props;
|
schema: OpenAPIV3.SchemaObject & OpenAPICustomOperationProperties;
|
||||||
|
}) {
|
||||||
|
const { schema } = props;
|
||||||
|
|
||||||
|
const enumValues = (() => {
|
||||||
|
// Render x-gitbook-enum first, as it has a different format
|
||||||
|
if (schema['x-gitbook-enum']) {
|
||||||
|
return Object.entries(schema['x-gitbook-enum']).map(([name, { description }]) => {
|
||||||
|
return {
|
||||||
|
value: name,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema['x-enumDescriptions']) {
|
||||||
|
return Object.entries(schema['x-enumDescriptions']).map(([value, description]) => {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema.enum?.map((value) => {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!enumValues?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="openapi-schema-enum">
|
<div className="openapi-schema-enum">
|
||||||
<span>
|
<span>Available options:</span>
|
||||||
Options:{' '}
|
<div className="openapi-schema-enum-list">
|
||||||
{enumValues.map((value, index) => (
|
{enumValues.map((item, index) => (
|
||||||
<span key={index} className="openapi-schema-enum-value">
|
<span key={index} className="openapi-schema-enum-value">
|
||||||
<code>{`${value}`}</code>
|
<OpenAPICopyButton
|
||||||
{index < enumValues.length - 1 ? ', ' : ''}
|
value={item.value}
|
||||||
|
label={item.description}
|
||||||
|
withTooltip={!!item.description}
|
||||||
|
>
|
||||||
|
<code>{`${item.value}`}</code>
|
||||||
|
</OpenAPICopyButton>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,9 +334,7 @@ function OpenAPISchemaPresentation(props: { property: OpenAPISchemaPropertyEntry
|
|||||||
Pattern: <code>{schema.pattern}</code>
|
Pattern: <code>{schema.pattern}</code>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{schema.enum && schema.enum.length > 0 ? (
|
<OpenAPISchemaEnum schema={schema} />
|
||||||
<OpenAPISchemaEnum enumValues={schema.enum} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -365,7 +459,7 @@ function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
|
|||||||
// Otherwise try to infer a nice title
|
// Otherwise try to infer a nice title
|
||||||
let type = 'any';
|
let type = 'any';
|
||||||
|
|
||||||
if (schema.enum) {
|
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
|
||||||
type = `${schema.type} · enum`;
|
type = `${schema.type} · enum`;
|
||||||
// check array AND schema.items as this is sometimes null despite what the type indicates
|
// check array AND schema.items as this is sometimes null despite what the type indicates
|
||||||
} else if (schema.type === 'array' && !!schema.items) {
|
} else if (schema.type === 'array' && !!schema.items) {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{schema?.readOnly ? <span className="openapi-schema-readonly">read-only</span> : null}
|
{schema?.readOnly ? <span className="openapi-schema-readonly">read-only</span> : null}
|
||||||
|
{schema?.writeOnly ? (
|
||||||
|
<span className="openapi-schema-writeonly">write-only</span>
|
||||||
|
) : null}
|
||||||
{required ? (
|
{required ? (
|
||||||
<span className="openapi-schema-required">required</span>
|
<span className="openapi-schema-required">required</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,18 +2,12 @@ import type { OpenAPI } from '@gitbook/openapi-parser';
|
|||||||
|
|
||||||
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
|
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
|
||||||
import { OpenAPIResponses } from './OpenAPIResponses';
|
import { OpenAPIResponses } from './OpenAPIResponses';
|
||||||
import { OpenAPISchemaProperties } from './OpenAPISchema';
|
import { OpenAPISchemaProperties } from './OpenAPISchemaServer';
|
||||||
import { OpenAPISecurities } from './OpenAPISecurities';
|
import { OpenAPISecurities } from './OpenAPISecurities';
|
||||||
import { StaticSection } from './StaticSection';
|
import { StaticSection } from './StaticSection';
|
||||||
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
|
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
|
||||||
import { parameterToProperty } from './utils';
|
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 }) {
|
export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAPIClientContext }) {
|
||||||
const { data, context } = props;
|
const { data, context } = props;
|
||||||
|
|
||||||
@@ -25,13 +19,13 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{securities.length > 0 ? (
|
{securities.length > 0 ? (
|
||||||
<OpenAPISecurities securities={securities} context={context} />
|
<OpenAPISecurities key="securities" securities={securities} context={context} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{parameterGroups.map((group) => {
|
{parameterGroups.map((group) => {
|
||||||
return (
|
return (
|
||||||
<StaticSection
|
<StaticSection
|
||||||
key={group.key}
|
key={`parameter-${group.key}`}
|
||||||
className="openapi-parameters"
|
className="openapi-parameters"
|
||||||
header={group.label}
|
header={group.label}
|
||||||
>
|
>
|
||||||
@@ -44,10 +38,18 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{operation.requestBody ? (
|
{operation.requestBody ? (
|
||||||
<OpenAPIRequestBody requestBody={operation.requestBody} context={context} />
|
<OpenAPIRequestBody
|
||||||
|
key="body"
|
||||||
|
requestBody={operation.requestBody}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{operation.responses ? (
|
{operation.responses ? (
|
||||||
<OpenAPIResponses responses={operation.responses} context={context} />
|
<OpenAPIResponses
|
||||||
|
key="responses"
|
||||||
|
responses={operation.responses}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -137,21 +137,11 @@ export function OpenAPITabsPanels() {
|
|||||||
const key = selectedTab.key.toString();
|
const key = selectedTab.key.toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel key={key} id={key} className="openapi-tabs-panel">
|
<TabPanel id={key} className="openapi-tabs-panel">
|
||||||
{selectedTab.body}
|
<div className="openapi-tabs-body">{selectedTab.body}</div>
|
||||||
{selectedTab.footer ? (
|
{selectedTab.footer ? (
|
||||||
<OpenAPITabsPanelFooter>{selectedTab.footer}</OpenAPITabsPanelFooter>
|
<div className="openapi-tabs-footer">{selectedTab.footer}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</TabPanel>
|
</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', () => {
|
it('should format application/graphql body properly', () => {
|
||||||
const input: CodeSampleInput = {
|
const input: CodeSampleInput = {
|
||||||
method: 'GET',
|
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', () => {
|
it('should format application/graphql body properly', () => {
|
||||||
const input: CodeSampleInput = {
|
const input: CodeSampleInput = {
|
||||||
method: 'GET',
|
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', () => {
|
it('should format application/graphql body properly', () => {
|
||||||
const input: CodeSampleInput = {
|
const input: CodeSampleInput = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -514,7 +565,7 @@ describe('http code sample generator', () => {
|
|||||||
const output = generator?.generate(input);
|
const output = generator?.generate(input);
|
||||||
|
|
||||||
expect(output).toBe(
|
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', () => {
|
it('should format application/graphql body properly', () => {
|
||||||
const input: CodeSampleInput = {
|
const input: CodeSampleInput = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isText,
|
isText,
|
||||||
isXML,
|
isXML,
|
||||||
} from './contentTypeChecks';
|
} from './contentTypeChecks';
|
||||||
|
import { json2xml } from './json2xml';
|
||||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||||
|
|
||||||
export interface CodeSampleInput {
|
export interface CodeSampleInput {
|
||||||
@@ -238,7 +239,10 @@ const BodyGenerators = {
|
|||||||
: String(body);
|
: String(body);
|
||||||
} else if (isText(contentType)) {
|
} else if (isText(contentType)) {
|
||||||
body = `--data '${String(body).replace(/"/g, '')}'`;
|
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
|
// We use --data-binary to avoid cURL converting newlines to \r\n
|
||||||
body = `--data-binary $'${stringifyOpenAPI(body).replace(/"/g, '').replace(/\\n/g, '\n')}'`;
|
body = `--data-binary $'${stringifyOpenAPI(body).replace(/"/g, '').replace(/\\n/g, '\n')}'`;
|
||||||
} else if (isGraphQL(contentType)) {
|
} else if (isGraphQL(contentType)) {
|
||||||
@@ -312,7 +316,9 @@ const BodyGenerators = {
|
|||||||
body = 'formData';
|
body = 'formData';
|
||||||
} else if (isXML(contentType)) {
|
} else if (isXML(contentType)) {
|
||||||
code += 'const xml = `\n';
|
code += 'const xml = `\n';
|
||||||
code += indent(String(body), 4);
|
|
||||||
|
// Convert JSON to XML if needed
|
||||||
|
code += indent(convertBodyToXML(body), 4);
|
||||||
code += '`;\n\n';
|
code += '`;\n\n';
|
||||||
body = 'xml';
|
body = 'xml';
|
||||||
} else if (isText(contentType)) {
|
} else if (isText(contentType)) {
|
||||||
@@ -346,6 +352,11 @@ const BodyGenerators = {
|
|||||||
body = 'files';
|
body = 'files';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isXML(contentType)) {
|
||||||
|
// Convert JSON to XML if needed
|
||||||
|
body = convertBodyToXML(body);
|
||||||
|
}
|
||||||
|
|
||||||
return { body, code, headers };
|
return { body, code, headers };
|
||||||
},
|
},
|
||||||
getHTTPBody: (body: any, headers?: Record<string, string>) => {
|
getHTTPBody: (body: any, headers?: Record<string, string>) => {
|
||||||
@@ -358,23 +369,48 @@ const BodyGenerators = {
|
|||||||
formUrlEncoded: () => {
|
formUrlEncoded: () => {
|
||||||
const encoded = isPlainObject(body)
|
const encoded = isPlainObject(body)
|
||||||
? Object.entries(body)
|
? Object.entries(body)
|
||||||
.map(([key, value]) => `${key}=${String(value)}`)
|
.map(([key, value]) => `${key}=${stringifyOpenAPI(value)}`)
|
||||||
.join('&')
|
.join('&')
|
||||||
: String(body);
|
: stringifyOpenAPI(body);
|
||||||
return `"${encoded}"`;
|
return `"${encoded.replace(/"/g, "'")}"`;
|
||||||
},
|
},
|
||||||
text: () => `"${String(body)}"`,
|
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)}`,
|
default: () => `${stringifyOpenAPI(body, null, 2)}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isPDF(contentType)) return typeHandlers.pdf();
|
if (isPDF(contentType)) return typeHandlers.pdf();
|
||||||
if (isFormUrlEncoded(contentType)) return typeHandlers.formUrlEncoded();
|
if (isFormUrlEncoded(contentType)) return typeHandlers.formUrlEncoded();
|
||||||
if (isText(contentType)) return typeHandlers.text();
|
if (isText(contentType)) return typeHandlers.text();
|
||||||
if (isXML(contentType) || isCSV(contentType)) {
|
if (isXML(contentType)) return typeHandlers.xml();
|
||||||
return typeHandlers.xmlOrCsv();
|
if (isCSV(contentType)) return typeHandlers.csv();
|
||||||
}
|
|
||||||
|
|
||||||
return typeHandlers.default();
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 clsx from 'clsx';
|
||||||
import { OpenAPIDisclosureGroup } from '../OpenAPIDisclosureGroup';
|
import { OpenAPIDisclosureGroup } from '../OpenAPIDisclosureGroup';
|
||||||
import { OpenAPIRootSchema } from '../OpenAPISchema';
|
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
|
||||||
import { Section, SectionBody } from '../StaticSection';
|
import { Section, SectionBody } from '../StaticSection';
|
||||||
import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPISchemasData } from '../types';
|
import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPISchemasData } from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import {
|
import type { Filesystem, OpenAPIV3xDocument } from '@gitbook/openapi-parser';
|
||||||
type Filesystem,
|
import { filterSelectedOpenAPISchemas } from '@gitbook/openapi-parser';
|
||||||
type OpenAPIV3,
|
|
||||||
type OpenAPIV3_1,
|
|
||||||
type OpenAPIV3xDocument,
|
|
||||||
shouldIgnoreEntity,
|
|
||||||
} from '@gitbook/openapi-parser';
|
|
||||||
import { dereferenceFilesystem } from '../dereference';
|
import { dereferenceFilesystem } from '../dereference';
|
||||||
import type { OpenAPISchema, OpenAPISchemasData } from '../types';
|
import type { OpenAPISchemasData } from '../types';
|
||||||
|
|
||||||
//!!TODO: We should return only the schemas that are used in the block. Still a WIP awaiting future work.
|
//!!TODO: We should return only the schemas that are used in the block. Still a WIP awaiting future work.
|
||||||
|
|
||||||
@@ -32,26 +27,3 @@ export async function resolveOpenAPISchemas(
|
|||||||
|
|
||||||
return { schemas };
|
return { schemas };
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
OpenAPICustomOperationProperties,
|
OpenAPICustomOperationProperties,
|
||||||
OpenAPICustomSpecProperties,
|
OpenAPICustomSpecProperties,
|
||||||
|
OpenAPISchema,
|
||||||
OpenAPIV3,
|
OpenAPIV3,
|
||||||
} from '@gitbook/openapi-parser';
|
} from '@gitbook/openapi-parser';
|
||||||
|
|
||||||
@@ -12,7 +13,11 @@ export interface OpenAPIContextProps extends OpenAPIClientContext {
|
|||||||
/**
|
/**
|
||||||
* Render the heading of the operation.
|
* Render the heading of the operation.
|
||||||
*/
|
*/
|
||||||
renderHeading: (props: { deprecated: boolean; title: string }) => React.ReactNode;
|
renderHeading: (props: {
|
||||||
|
deprecated: boolean;
|
||||||
|
title: string;
|
||||||
|
stability?: string;
|
||||||
|
}) => React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Render the document of the operation.
|
* Render the document of the operation.
|
||||||
*/
|
*/
|
||||||
@@ -56,11 +61,6 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
|
|||||||
securities: [string, OpenAPIV3.SecuritySchemeObject][];
|
securities: [string, OpenAPIV3.SecuritySchemeObject][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenAPISchema = {
|
|
||||||
name: string;
|
|
||||||
schema: OpenAPIV3.SchemaObject;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OpenAPISchemasData {
|
export interface OpenAPISchemasData {
|
||||||
/** Components schemas to be used for schemas */
|
/** Components schemas to be used for schemas */
|
||||||
schemas: OpenAPISchema[];
|
schemas: OpenAPISchema[];
|
||||||
|
|||||||
Reference in New Issue
Block a user