Compare commits

..

41 Commits

Author SHA1 Message Date
Samy Pessé c0b339e406 Version Packages (#3075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-02 13:36:04 +02:00
Nolann B. da485f5144 Fix read-only in generateSchemaExample (#3069) 2025-04-02 13:03:51 +02:00
Nolann B. 139a8050a5 Fix OpenAPI enum display (#3077) 2025-04-02 10:15:19 +02:00
Nolann B. da7b369ffd Fix missing headers in OpenAPIResponses (#3074) 2025-04-01 21:57:36 +02:00
Samy Pessé 432da64919 Version Packages (#3073)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 18:13:52 +02:00
Nolann B. 7d0b422200 Handle grouped OpenAPISchemas (#3071) 2025-04-01 17:24:43 +02:00
Zeno Kapitein fb90eb0acc Limit tinted background on bold theme to sites with filled sidebar (#3072) 2025-04-01 16:43:08 +02:00
Samy Pessé b647538e41 Version Packages (#3033)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 16:35:09 +02:00
Samy Pessé e50797a5c1 Adapt new computed content and add tag for translation (#3070) 2025-04-01 15:10:55 +02:00
Nolann B. 5b1e01c786 Support for x-stability property (#3064) 2025-03-31 13:49:37 +02:00
Nolann B. 813b2af06b Support for x-enumDescriptions and x-gitbook-enum (#3066) 2025-03-31 12:18:57 +02:00
Nolann B. 6f71da88d8 Fix padding in OpenAPI schemas (#3067) 2025-03-31 12:03:53 +02:00
Samy Pessé fa91eb7eaf Fix dark mode PDF (#3065) 2025-03-28 18:00:33 +01:00
Zeno Kapitein c756761591 Add breadcrumbs to search results (#2772) 2025-03-28 13:29:05 +01:00
Samy Pessé 434af90366 Fix image resizing when using proxy by passing signing identifier in site URL data (#3063)
Co-authored-by: Taran Vohra <taranvohra@outlook.com>
2025-03-28 12:28:12 +01:00
Valentino Hudhra 0f41e194cb Fix dismissed annoucement banner ignored when switching sites (#3057) 2025-03-28 10:56:18 +01:00
Nolann B. bd353489e9 Fix missing alternative schemas (#3058) 2025-03-28 06:53:28 +01:00
Samy Pessé 77fd393f15 Fix click on search items when embedded in an iframe and track announcement links (#3061) 2025-03-27 20:13:14 +01:00
Taran Vohra 8701b5e078 Use the site canonical URL when rewriting in the middleware (#3060) 2025-03-27 19:39:39 +01:00
Nolann B. 61db166f92 Add OpenAPI write-only indicator (#3059) 2025-03-27 18:21:38 +01:00
Zeno Kapitein 0d9b9cea0f Fix announcement banner margin (#3056) 2025-03-27 15:59:47 +00:00
Nolann B. 7bb37c7f0a Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser (#3054) 2025-03-27 15:53:41 +01:00
Taran Vohra 5b2bf828e3 Use stable site URL data for route rewrite in the middleware (#3053) 2025-03-27 18:26:06 +05:30
Samy Pessé c85d3f9752 Serve a markdown version of the page when adding a .md extension (#3055)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-27 12:35:18 +00:00
Zeno Kapitein 4d7088bbf0 Stop variant selector from growing too large (#3043) 2025-03-27 10:42:18 +01:00
Samy Pessé dd6ab6044c Fix crash when using user mention or accessing invalid change requests (#3051) 2025-03-27 01:04:00 +01:00
Samy Pessé d236bf029c Fix flash for site with dark default mode (#3050) 2025-03-26 18:12:45 +01:00
Zeno Kapitein d70d566abe Support site announcement banner (#3008)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
2025-03-26 16:37:05 +01:00
Nolann B. a25fdedea9 Replace $ref with $reference in json-decycle (#3049) 2025-03-26 14:16:03 +00:00
Nolann B. 373183a2cd Safe parse OpenAPI JSON schema (#3046) 2025-03-26 13:55:22 +01:00
Samy Pessé e9fa50dfb5 Trim search query before doing search (#3042) 2025-03-26 10:18:18 +00:00
Samy Pessé 57ca4e0e88 Fix crash when integration is not installed on the space (#3040) 2025-03-26 10:19:57 +01:00
Samy Pessé bc90adb73a Allow indexation of images to ensure favicons display on Google (#3041) 2025-03-26 10:17:57 +01:00
Nolann B. 1505ddbd5a Fix multiple request examples selector not showing (#3039) 2025-03-25 20:48:57 +01:00
Greg Bergé cd99ed57fb Fix OpenAPI spec rendering (#3037) 2025-03-25 15:13:18 +00:00
Samy Pessé 27de1cd01d Default to the first ID as being active in scroll detection (#3036) 2025-03-25 15:39:45 +01:00
Nolann B. ae78fc5394 Fix XML in code sample (#3030) 2025-03-25 15:11:45 +01:00
Samy Pessé 40e8e69c6d Disallow crawling by web-robots of search/ask URLs (#3035) 2025-03-25 13:51:09 +00:00
Nolann B. e84a46a558 Fix OpenAPI tabs indicator overflow (#3032) 2025-03-25 10:03:58 +01:00
Samy Pessé 18a72d4466 v2: improve caching (#3031) 2025-03-25 09:40:54 +01:00
Zeno Kapitein d7aa5fa003 Remove loading state for recommended questions (#3029) 2025-03-24 19:19:12 +01:00
112 changed files with 2227 additions and 766 deletions
+18 -11
View File
@@ -49,7 +49,7 @@
}, },
"packages/gitbook": { "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
View File
@@ -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": {
+27 -4
View File
@@ -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.
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
tags.push( tags.push(
getCacheTag({ getCacheTag({
tag: 'integration', tag: 'integration',
integration: source.integration, integration,
}) })
); );
}
return tags; return tags;
} }
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/colors # @gitbook/colors
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0 ## 0.3.0
### Minor Changes ### Minor Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.0", "version": "0.3.1",
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
+1 -1
View File
@@ -220,7 +220,7 @@ export function colorScale(
continue; continue;
} }
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1; const chromaRatio = index < 8 ? index * 0.05 : 1;
const shade = { const shade = {
L: targetL, // Blend lightness L: targetL, // Blend lightness
+6
View File
@@ -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
+3 -2
View File
@@ -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": "*",
@@ -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}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return servePagesSitemap(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveRootSitemap(context);
}
@@ -10,5 +10,5 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> } { params }: { params: Promise<RouteLayoutParams> }
) { ) {
const { context } = await getStaticSiteContext(await params); const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context); return serveLLMsTxt(context, { withMarkdownPages: true });
} }
@@ -0,0 +1,11 @@
import { servePageMarkdown } from '@/routes/markdownPage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageMarkdown(context, pathname);
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive'; import { 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);
} }
+51 -8
View File
@@ -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,
+140 -46
View File
@@ -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,19 +175,20 @@ 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';
return trace('getUserById.uncached', () => {
cacheLife('days'); cacheLife('days');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
@@ -185,9 +196,10 @@ async function getUserById(input: DataFetcherInput, params: { userId: string })
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,6 +208,7 @@ async function getSpace(
) { ) {
'use cache'; 'use cache';
return trace('getSpace.uncached', () => {
cacheLife('days'); cacheLife('days');
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
@@ -211,9 +224,10 @@ async function getSpace(
}); });
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,11 +236,15 @@ async function getChangeRequest(
) { ) {
'use cache'; 'use cache';
return trace('getChangeRequest.uncached', () => {
cacheLife('minutes'); 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(
params.spaceId,
params.changeRequestId
);
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'change-request', tag: 'change-request',
@@ -236,9 +254,10 @@ async function getChangeRequest(
); );
return res.data; return res.data;
}); });
} });
});
async function getRevision( const getRevision = memoize(async function getRevision(
input: DataFetcherInput, input: DataFetcherInput,
params: { params: {
spaceId: string; spaceId: string;
@@ -248,6 +267,7 @@ async function getRevision(
) { ) {
'use cache'; 'use cache';
return trace('getRevision.uncached', () => {
cacheLife('max'); cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
@@ -257,9 +277,10 @@ async function getRevision(
}); });
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';
return trace('getRevisionPages.uncached', () => {
cacheLife('max'); 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(
params.spaceId,
params.revisionId,
{
metadata: params.metadata, 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,6 +317,7 @@ async function getRevisionFile(
) { ) {
'use cache'; 'use cache';
return trace('getRevisionFile.uncached', () => {
cacheLife('max'); cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
@@ -302,9 +330,43 @@ async function getRevisionFile(
); );
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,6 +376,7 @@ async function getRevisionPageByPath(
) { ) {
'use cache'; 'use cache';
return trace('getRevisionPageByPath.uncached', () => {
cacheLife('max'); cacheLife('max');
const encodedPath = encodeURIComponent(params.path); const encodedPath = encodeURIComponent(params.path);
@@ -328,9 +391,10 @@ async function getRevisionPageByPath(
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,6 +403,7 @@ async function getDocument(
) { ) {
'use cache'; 'use cache';
return trace('getDocument.uncached', () => {
cacheLife('max'); cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
@@ -346,9 +411,10 @@ async function getDocument(
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,6 +425,7 @@ async function getComputedDocument(
) { ) {
'use cache'; 'use cache';
return trace('getComputedDocument.uncached', () => {
cacheLife('days'); cacheLife('days');
cacheTag( cacheTag(
@@ -379,9 +446,10 @@ async function getComputedDocument(
}); });
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,6 +459,7 @@ async function getReusableContent(
) { ) {
'use cache'; 'use cache';
return trace('getReusableContent.uncached', () => {
cacheLife('max'); cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
@@ -402,17 +471,20 @@ async function getReusableContent(
); );
return res.data; return res.data;
}); });
} });
});
async function getLatestOpenAPISpecVersionContent( const getLatestOpenAPISpecVersionContent = memoize(
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput, input: DataFetcherInput,
params: { params: {
organizationId: string; organizationId: string;
slug: string; slug: string;
} }
) { ) {
'use cache'; 'use cache';
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'openapi', tag: 'openapi',
@@ -430,9 +502,11 @@ async function getLatestOpenAPISpecVersionContent(
); );
return res.data; return res.data;
}); });
} });
}
);
async function getPublishedContentSite( const getPublishedContentSite = memoize(async function getPublishedContentSite(
input: DataFetcherInput, input: DataFetcherInput,
params: { params: {
organizationId: string; organizationId: string;
@@ -442,6 +516,7 @@ async function getPublishedContentSite(
) { ) {
'use cache'; 'use cache';
return trace('getPublishedContentSite.uncached', () => {
cacheLife('days'); cacheLife('days');
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
@@ -450,16 +525,23 @@ async function getPublishedContentSite(
}) })
); );
return trace('getPublishedContentSite', () => {
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = await apiClient(input); const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite(params.organizationId, params.siteId, { const res = await api.orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey, 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,6 +552,7 @@ async function getSiteRedirectBySource(
) { ) {
'use cache'; 'use cache';
return trace('getSiteRedirectBySource.uncached', () => {
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'site', tag: 'site',
@@ -480,16 +563,21 @@ async function getSiteRedirectBySource(
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(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey, shareKey: params.siteShareKey,
source: params.source, 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,6 +586,7 @@ async function getEmbedByUrl(
) { ) {
'use cache'; 'use cache';
return trace('getEmbedByUrl.uncached', () => {
cacheLife('weeks'); cacheLife('weeks');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
@@ -505,14 +594,16 @@ async function getEmbedByUrl(
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';
return trace('searchSiteContent.uncached', () => {
const { organizationId, siteId, query, scope } = params; const { organizationId, siteId, query, scope } = params;
cacheLife('days'); cacheLife('days');
@@ -525,9 +616,10 @@ async function searchSiteContent(
}); });
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,6 +628,7 @@ async function renderIntegrationUi(
) { ) {
'use cache'; 'use cache';
return trace('renderIntegrationUi.uncached', () => {
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName })); cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
cacheLife('days'); cacheLife('days');
@@ -547,7 +640,8 @@ async function renderIntegrationUi(
); );
return res.data; return res.data;
}); });
} });
});
let loggedServiceBinding = false; let loggedServiceBinding = false;
+9 -1
View File
@@ -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)
+3 -2
View File
@@ -1,5 +1,6 @@
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api'; import { CustomizationThemeMode } from '@gitbook/api';
import { headers } from 'next/headers'; import { 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);
+63 -14
View File
@@ -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' };
+61
View File
@@ -1,5 +1,66 @@
# gitbook # gitbook
## 0.9.2
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- 139a805: Fix OpenAPI enum display
- Updated dependencies [da7b369]
- Updated dependencies [da485f5]
- Updated dependencies [139a805]
- @gitbook/react-openapi@1.1.9
## 0.9.1
### Patch Changes
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
- 7d0b422: Handle grouped OpenAPISchemas
- Updated dependencies [7d0b422]
- Updated dependencies [fb90eb0]
- @gitbook/react-openapi@1.1.8
- @gitbook/colors@0.3.1
## 0.9.0
### Minor Changes
- 77fd393: Track event when visitor is opening a search result.
- d70d566: Support site announcement banner
- 77fd393: Track event when clicking announcement banner link.
### Patch Changes
- e84a46a: Fix OpenAPI tabs indicator overflow
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
- 434af90: Fix image resizing when using the proxy feature in a site.
- c756761: Add breadcrumbs to search results
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 6f71da8: Fix padding in schemas
- fa91eb7: Fix PDF generation when user has dark mode configured.
- 5b1e01c: Support for x-stability property
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
- d236bf0: Fix flash when loading sites with dark mode as default theme
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- e9fa50d: Trim the search query to avoid showing a loading state when typing
- Updated dependencies [bd35348]
- Updated dependencies [ae78fc5]
- Updated dependencies [7bb37c7]
- Updated dependencies [373183a]
- Updated dependencies [1505ddb]
- Updated dependencies [61db166]
- Updated dependencies [5b1e01c]
- Updated dependencies [cd99ed5]
- Updated dependencies [813b2af]
- Updated dependencies [a25fded]
- @gitbook/react-openapi@1.1.7
- @gitbook/openapi-parser@2.1.2
## 0.8.2 ## 0.8.2
### Patch Changes ### Patch Changes
+29
View File
@@ -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/',
+9 -4
View File
@@ -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) {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.8.2", "version": "0.9.2",
"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',
@@ -104,7 +104,7 @@ const HINT_STYLES: {
'[&_.can-override-text]:text-neutral-strong', '[&_.can-override-text]:text-neutral-strong',
], ],
container: container:
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2', 'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
containerWithHeader: 'border-info-solid bg-info-subtle', containerWithHeader: 'border-info-solid bg-info-subtle',
}, },
warning: { warning: {
@@ -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`,
@@ -50,6 +50,7 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
return ( return (
<BaseOpenAPISchemas <BaseOpenAPISchemas
data={data} data={data}
grouped={block.data.grouped}
context={{ context={{
specUrl, specUrl,
icons: { icons: {
@@ -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,11 +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 text-sm leading-relaxed max-w-full text-tint;
} }
.openapi-schema-enum-value { .openapi-schema-enum-value {
@apply text-sm; @apply text-sm mr-1.5;
} }
.openapi-schema-enum-value:first-child { .openapi-schema-enum-value:first-child {
@@ -467,13 +492,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 +542,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 +552,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 +676,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 +714,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]}
@@ -38,7 +38,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
'bg-tint-base/9', 'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9', 'theme-muted:bg-tint-subtle/9',
'theme-bold-tint:bg-tint-subtle/9', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary', 'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint', 'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base', 'contrast-more:bg-tint-base',
@@ -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>
@@ -228,7 +228,7 @@ export function PageAside(props: {
) : null} ) : null}
<div <div
className={tcls( className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-bold-tint:bg-tint-subtle theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex', 'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'page-api-block:xl:max-2xl:bg-transparent' 'page-api-block:xl:max-2xl:bg-transparent'
)} )}
> >
@@ -111,7 +111,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'hover:bg-primary-hover', 'hover:bg-primary-hover',
'theme-muted:hover:bg-primary-active', 'theme-muted:hover:bg-primary-active',
'theme-bold-tint:hover:bg-primary-active', '[html.sidebar-filled.theme-bold.tint_&]:hover:bg-primary-active',
'theme-gradient:hover:bg-primary-active', 'theme-gradient:hover:bg-primary-active',
'tint:font-semibold', 'tint:font-semibold',
@@ -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
@@ -141,9 +153,9 @@ export async function CustomizationRootLayout(props: {
</head> </head>
<body <body
className={tcls( className={tcls(
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'bg-tint-base', 'bg-tint-base',
'theme-muted:bg-tint-subtle', 'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'theme-gradient:bg-gradient-primary', 'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint' 'theme-gradient-tint:bg-gradient-tint'
@@ -175,7 +187,7 @@ export async function CustomizationRootLayout(props: {
* If the tint color is not set or it is a space customization, it will return the default color. * 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,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space; space?: Space;
spaceURL?: string; spaceURL?: string;
}): Promise<[ComputedPageResult, ComputedSectionResult[]]> { siteSection?: SiteSection;
const { item, space, spaceURL } = args; siteSectionGroup?: SiteSectionGroup;
}
const sections = await Promise.all( ): Promise<OrderedComputedResult[]> {
item.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({ const { pageItem, spaceItem, space, spaceURL, siteSection, siteSectionGroup } = args;
type: 'section', const { linker } = context;
id: `${item.id}/${section.id}`,
title: section.title,
href: await getURLWithSections(section.path, spaceURL),
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);
} }
@@ -32,7 +32,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
<nav <nav
aria-label="Sections" aria-label="Sections"
className={tcls( className={tcls(
'-mx-5 before:contents[] relative border-tint-subtle border-b from-transparent sidebar-filled:to-tint-subtle theme-bold-tint:to-tint-subtle theme-muted:to-tint-subtle to-tint-base text-sm text-tint before:pointer-events-none before:absolute before:right-2 before:bottom-0 before:left-0 before:h-12 before:bg-gradient-to-b [html.sidebar-filled.theme-bold.tint_&]:to-tint-base [html.sidebar-filled.theme-muted_&]:to-tint-base', '-mx-5 before:contents[] relative border-tint-subtle border-b from-transparent sidebar-filled:to-tint-subtle theme-muted:to-tint-subtle to-tint-base text-sm text-tint before:pointer-events-none before:absolute before:right-2 before:bottom-0 before:left-0 before:h-12 before:bg-gradient-to-b [html.sidebar-filled.theme-bold.tint_&]:to-tint-base [html.sidebar-filled.theme-bold.tint_&]:to-tint-subtle [html.sidebar-filled.theme-muted_&]:to-tint-base',
className className
)} )}
> >
@@ -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
@@ -40,7 +40,7 @@ export function PageGroupItem(props: {
'bg-tint-base', 'bg-tint-base',
'sidebar-filled:bg-tint-subtle', 'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle', 'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base', '[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary', '[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
@@ -75,7 +75,7 @@ export function TableOfContents(props: {
'sidebar-filled:bg-tint-subtle', 'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle', 'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base', '[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'page-no-toc:!bg-transparent', 'page-no-toc:!bg-transparent',
@@ -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 (
@@ -53,7 +52,7 @@ export function Trademark(props: {
'before:to-tint-base', 'before:to-tint-base',
'sidebar-filled:before:to-tint-subtle', 'sidebar-filled:before:to-tint-subtle',
'theme-muted:before:to-tint-subtle', 'theme-muted:before:to-tint-subtle',
'theme-bold-tint:before:to-tint-subtle', '[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base', '[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
'page-no-toc:before:!to-transparent' 'page-no-toc:before:!to-transparent'
@@ -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;
@@ -79,7 +79,7 @@ function ThemeButton(props: {
active && [ active && [
'bg-primary', 'bg-primary',
'theme-muted:bg-primary-hover', 'theme-muted:bg-primary-hover',
'theme-bold-tint:bg-primary-hover', '[html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover',
'hover:bg-primary', 'hover:bg-primary',
'text-primary-strong', 'text-primary-strong',
'contrast-more:text-primary-strong', 'contrast-more:text-primary-strong',
@@ -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,16 +2,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { Link, type LinkProps } from '../primitives/Link'; import { Link, type LinkProps } from '../primitives/Link';
/** export const linkStyles = [
* Styled version of Link component.
*/
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
return (
<Link
{...rest}
className={tcls(
'underline', 'underline',
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin. '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', 'underline-offset-2',
@@ -29,9 +20,16 @@ export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValu
'transition-all', 'transition-all',
'duration-100', 'duration-100',
style ];
)}
> /**
* Styled version of Link component.
*/
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
return (
<Link {...rest} className={tcls(linkStyles, 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: [
{ {
+2 -4
View File
@@ -1,4 +1,4 @@
import type { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api'; import type { SiteCustomizationSettings } from '@gitbook/api';
import { type TranslationLanguage, languages } from './translations'; 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;
+3 -2
View File
@@ -1,4 +1,5 @@
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api'; import type { SiteAPIToken } from '@gitbook/api';
import type { SiteURLData } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode'; 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));
+3 -2
View File
@@ -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,
+1 -1
View File
@@ -104,7 +104,7 @@ export function resolvePrevNextPages(
} }
/** /**
* Resolve a page to its canonical path. * Resolve a page to its canonical path. The first page of the site will return "".
* The path will NOT start with "/". * The path will NOT start with "/".
*/ */
export function getPagePath( export function getPagePath(
+17
View File
@@ -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.
*/ */
+7 -2
View File
@@ -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
);
} }
} }
} }
+32 -20
View File
@@ -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: {},
}; };
} }
+5 -1
View File
@@ -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);
+1
View File
@@ -564,6 +564,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
apiEndpoint, apiEndpoint,
contextId, contextId,
cookies, cookies,
canonicalUrl: url.toString(),
}; };
} }
+6 -6
View File
@@ -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 }
); );
} }
+43 -14
View File
@@ -1,19 +1,27 @@
import { joinPath } from '@/lib/paths';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/sites';
import type { SiteSection, SiteSpace } from '@gitbook/api'; import type { 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',
},
});
}
+2 -1
View File
@@ -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: /',
+9
View File
@@ -1,3 +1,12 @@
declare module 'memoizee' {
declare function memoizee<F extends (...args: any[]) => any>(
f: F,
options?: { normalizer?: (args: any[]) => string }
): F;
export = memoizee;
}
declare module 'memoizee/weak' { declare 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;
+8
View File
@@ -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
+1 -1
View File
@@ -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",
+1
View File
@@ -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';
+27
View File
@@ -0,0 +1,27 @@
import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
import type { OpenAPISchema } from './types';
/**
* Extract selected schemas from the OpenAPI document.
*/
export function filterSelectedOpenAPISchemas(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
selectedSchemas: string[]
): OpenAPISchema[] {
const componentsSchemas = schema.components?.schemas ?? {};
// Preserve the order of the selected schemas
return selectedSchemas
.map((name) => {
const schema = componentsSchemas[name];
if (schema && !shouldIgnoreEntity(schema)) {
return {
name,
schema,
};
}
return null;
})
.filter((schema): schema is OpenAPISchema => !!schema);
}
+28
View File
@@ -47,8 +47,31 @@ export interface OpenAPICustomOperationProperties {
* Description in Document format. * 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;
};
+33
View File
@@ -1,5 +1,38 @@
# @gitbook/react-openapi # @gitbook/react-openapi
## 1.1.9
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- da485f5: Fix read-only in generateSchemaExample
- 139a805: Fix OpenAPI enum display
## 1.1.8
### Patch Changes
- 7d0b422: Handle grouped OpenAPISchemas
## 1.1.7
### Patch Changes
- bd35348: Fix missing alternative schemas
- ae78fc5: Fix XML in code sample
- 7bb37c7: Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser
- 373183a: Safe parse OpenAPI JSON schema
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 5b1e01c: Support for x-stability property
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- a25fded: Replace $ref with $reference in json-decycle
- Updated dependencies [7bb37c7]
- Updated dependencies [5b1e01c]
- Updated dependencies [813b2af]
- @gitbook/openapi-parser@2.1.2
## 1.1.6 ## 1.1.6
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "1.1.6", "version": "1.1.9",
"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,18 +40,36 @@ 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">
<MediaTypeSelector state={state} renderers={renderers} />
<ExamplesSelector method={method} path={path} renderer={selected} />
</div>
);
}
function MediaTypeSelector(props: {
state: MediaTypeState;
renderers: MediaTypeRenderer[];
}) {
const { renderers, state } = props;
if (renderers.length < 2) {
return null;
}
return (
<select <select
className={clsx('openapi-select')} className={clsx('openapi-select')}
value={state.mediaType} value={state.mediaType}
@@ -56,17 +81,16 @@ export function OpenAPIMediaTypeExamplesSelector(props: {
</option> </option>
))} ))}
</select> </select>
<ExamplesSelector data={data} renderer={selected} />
</div>
); );
} }
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>
);
}
+2 -2
View File
@@ -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,20 +29,22 @@ 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>
) : null} ) : null}
{mediaType.schema && (
<div className="openapi-responsebody"> <div className="openapi-responsebody">
<OpenAPISchemaProperties <OpenAPISchemaProperties
id={`response-${context.blockKey}`} id={`response-${context.blockKey}`}
properties={mediaType.schema ? [{ schema: mediaType.schema }] : []} properties={[{ schema: mediaType.schema }]}
context={context} context={context}
/> />
</div> </div>
)}
</div> </div>
); );
} }
@@ -268,6 +268,7 @@ function getExamplesFromMediaTypeObject(args: {
value: { value: {
[root]: generateSchemaExample(mediaTypeObject.schema, { [root]: generateSchemaExample(mediaTypeObject.schema, {
xml: mediaType === 'application/xml', xml: mediaType === 'application/xml',
mode: 'read',
}), }),
}, },
}, },
@@ -277,7 +278,11 @@ function getExamplesFromMediaTypeObject(args: {
return [ return [
{ {
key: 'default', key: 'default',
example: { value: generateSchemaExample(mediaTypeObject.schema) }, example: {
value: generateSchemaExample(mediaTypeObject.schema, {
mode: 'read',
}),
},
}, },
]; ];
} }
+38 -17
View File
@@ -21,16 +21,48 @@ export function OpenAPIResponses(props: {
icon={context.icons.chevronRight} icon={context.icons.chevronRight}
groups={Object.entries(responses).map( groups={Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => { ([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const content = Object.entries(response.content ?? {}); const tabs = (() => {
// If there is no content, but there are headers, we need to show the headers
if (
(!response.content || !Object.keys(response.content).length) &&
response.headers &&
Object.keys(response.headers).length
) {
return [
{
id: 'default',
body: (
<OpenAPIResponse
response={response}
mediaType={{}}
context={context}
/>
),
},
];
}
return Object.entries(response.content ?? {}).map(
([contentType, mediaType]) => ({
id: contentType,
label: contentType,
body: (
<OpenAPIResponse
response={response}
mediaType={mediaType}
context={context}
/>
),
})
);
})();
const description = response.description; const description = response.description;
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>
@@ -42,18 +74,7 @@ export function OpenAPIResponses(props: {
) : null} ) : null}
</div> </div>
), ),
tabs: content.map(([contentType, mediaType]) => ({ tabs,
id: contentType,
label: contentType,
body: (
<OpenAPIResponse
key={`$response-${statusCode}-${contentType}`}
response={response}
mediaType={mediaType}
context={context}
/>
),
})),
}; };
} }
)} )}

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