Compare commits

...

17 Commits

Author SHA1 Message Date
taranvohra f4f76e9661 Update documentation links 2025-05-16 11:00:30 +05:30
Zeno Kapitein 95a1f652f9 Better print layouts: wrap code blocks & force table column auto-sizing (#3236) 2025-05-15 13:24:10 +02:00
spastorelli cc37e2ae15 Persist visitor params values in public visitor session cookie (#3231) 2025-05-14 14:16:13 +02:00
Claire Chabas 08382ce62f Improve tabs link support (#3211) 2025-05-14 11:10:09 +02:00
Nolann B. 80cb52a237 Handle OpenAPI alternatives from schema.items (#3235) 2025-05-14 08:05:08 +02:00
Nolann B. c6637b09e2 Use default value if string/number/boolean in generateSchemaExample (#3234) 2025-05-13 22:07:41 +02:00
Samy Pessé 778624af00 Only resize images with supported extensions (#3229) 2025-05-13 10:52:58 +02:00
Samy Pessé e15757d01f Use next@15.3.2 instead of canary (#3227) 2025-05-09 15:17:49 +02:00
Zeno Kapitein aed79fd6c3 Remove header logo rounding (#3225) 2025-05-09 14:45:38 +02:00
Greg Bergé 0c973a3292 Always link main logo to the root of the site (#3226) 2025-05-09 10:31:52 +00:00
spastorelli 56c923c0ed Adapt site resolution/lookup to pass visitor unsigned claims in addition to token (#3202)
Co-authored-by: taranvohra <taranvohra@outlook.com>
2025-05-09 15:34:20 +05:30
Samy Pessé 7d7806df30 Passthrough SVG images in image resizing (#3224) 2025-05-08 18:57:50 +02:00
Nolann B. cb5598dc19 Handle invalid OpenAPI Responses (#3223) 2025-05-08 11:54:46 +02:00
Greg Bergé 47f01edcb8 Fix E2E icons (#3222) 2025-05-08 13:04:04 +05:30
Greg Bergé e6ddc0f07f Fix sitemap URLs (#3220) 2025-05-08 08:54:28 +02:00
Zeno Kapitein 373f18f451 Prevent section group popovers from opening on click (#3213) 2025-05-07 13:57:58 +00:00
Taran Vohra 3f292065cd Update site redirect regex for validation (#3216) 2025-05-07 12:42:28 +05:30
55 changed files with 958 additions and 258 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Only resize images with supported extensions.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Decrease rounding of header logo
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Fix URL in sitemap
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Better print layouts: wrap code blocks & force table column auto-sizing
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Handle OpenAPI alternatives from schema.items
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Handle invalid OpenAPI Responses
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Use default value if string number or boolean in generateSchemaExample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Prevent section group popovers from opening on click
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
Update the regex for validating site redirect
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Always link main logo to the root of the site
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": minor
"gitbook": minor
---
Pass SVG images through image resizing without resizing them to serve them from optimal host.
+2 -2
View File
@@ -63,9 +63,9 @@ To start your local version of GitBook, run the command `bun dev`.
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/gitbook.com/docs` after running the development server.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
### Commit your update
+1 -3
View File
@@ -152,7 +152,7 @@ jobs:
| Site | `v1` | `2v` | `2c` |
| --- | --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com) |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v1:
@@ -200,7 +200,6 @@ jobs:
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -263,7 +262,6 @@ jobs:
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 8
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
+3 -3
View File
@@ -1,7 +1,7 @@
<h1 align="center">GitBook</h1>
<p align="center">
<a href="https://docs.gitbook.com/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
<a href="https://gitbook.com/docs">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://gitbook.com/docs/developers">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
</p>
<p align="center">
@@ -66,7 +66,7 @@ bun dev:v2
examples:
- http://localhost:3000/url/docs.gitbook.com
- http://localhost:3000/url/gitbook.com/docs
- http://localhost:3000/url/open-source.gitbook.io/midjourney
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
@@ -118,7 +118,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
## Deployment
> [!WARNING]
> [!WARNING]
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
>
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
+17 -17
View File
@@ -26,7 +26,7 @@
"name": "@gitbook/cache-tags",
"version": "0.3.1",
"dependencies": {
"@gitbook/api": "^0.111.0",
"@gitbook/api": "^0.115.0",
"assert-never": "^1.2.1",
},
"devDependencies": {
@@ -51,7 +51,7 @@
"name": "gitbook",
"version": "0.11.1",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
@@ -143,12 +143,12 @@
"name": "gitbook-v2",
"version": "0.2.5",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "canary",
"next": "^15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
@@ -202,7 +202,7 @@
"name": "@gitbook/react-contentkit",
"version": "0.7.0",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/icons": "workspace:*",
"classnames": "^2.5.1",
},
@@ -260,7 +260,7 @@
},
"overrides": {
"@codemirror/state": "6.4.1",
"@gitbook/api": "0.113.0",
"@gitbook/api": "^0.115.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
},
@@ -625,7 +625,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.113.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-PWMeAkdm4bHSl3b5OmtcmskZ6qRkkDhauCPybo8sGnjS03O14YAUtubAQiNCKX/uwbs+yiQ8KRPyeIwn+g42yw=="],
"@gitbook/api": ["@gitbook/api@0.115.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Lyj+1WVNnE/Zuuqa/1ZdnUQfUiNE6es89RFK6CJ+Tb36TFwls6mbHKXCZsBwSYyoMYTVK39WQ3Nob6Nw6+TWCA=="],
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
@@ -4077,7 +4077,7 @@
"gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"gitbook-v2/next": ["next@15.4.0-canary.7", "", { "dependencies": { "@next/env": "15.4.0-canary.7", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.0-canary.7", "@next/swc-darwin-x64": "15.4.0-canary.7", "@next/swc-linux-arm64-gnu": "15.4.0-canary.7", "@next/swc-linux-arm64-musl": "15.4.0-canary.7", "@next/swc-linux-x64-gnu": "15.4.0-canary.7", "@next/swc-linux-x64-musl": "15.4.0-canary.7", "@next/swc-win32-arm64-msvc": "15.4.0-canary.7", "@next/swc-win32-x64-msvc": "15.4.0-canary.7", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-ZYjT0iu+4osz8XIlr31MuoXaNQKRU75UcwEgNBt93gftoh6tzV2Mebz6sOGeVReYuYUvYlLJJksMBTNcFcPbSA=="],
"gitbook-v2/next": ["next@15.3.2", "", { "dependencies": { "@next/env": "15.3.2", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.2", "@next/swc-darwin-x64": "15.3.2", "@next/swc-linux-arm64-gnu": "15.3.2", "@next/swc-linux-arm64-musl": "15.3.2", "@next/swc-linux-x64-gnu": "15.3.2", "@next/swc-linux-x64-musl": "15.3.2", "@next/swc-win32-arm64-msvc": "15.3.2", "@next/swc-win32-x64-msvc": "15.3.2", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ=="],
"global-dirs/ini": ["ini@1.3.7", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="],
@@ -4969,23 +4969,23 @@
"gaxios/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"gitbook-v2/next/@next/env": ["@next/env@15.4.0-canary.7", "", {}, "sha512-q8S7f2lQti3Y3gcAPzE8Pj8y0EwiWHVyyilMzoLbDPXGVfxlQhXLRiFdy2cDkKN4DyjGZWDeehEtw4huvJAa3Q=="],
"gitbook-v2/next/@next/env": ["@next/env@15.3.2", "", {}, "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.0-canary.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+TMxUu5CAWNe+UFRc47BZAXQxCRqZfVbGyCldddiog4MorvL7kBxSd1qlmrwI73fRRKtXkHIH1TaeItyxzC9rQ=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.0-canary.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-veXp8lg/X/7O+pG9BDQ3OizFz3B40v29jsvEWj+ULY/W8Z6+dCSd5XPP2M8fG/gKKKA0D6L0CnnM2Mj0RRSUJw=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.0-canary.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-KxNGfW7BO0Z5B9rJyl9p7YVjNrxAhu06mH6h1PSdouZG7YMYpdRCconVXeuBI0PEu6g3ywNrOVxZUk1V6G5u0Q=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.0-canary.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-THgXgmP/cC4DsNwvC6uqB90CebB7Ep1KyZajQL3fYKT5V4SWr46yngKLyoyJVeAYWJH908MrWddf7Ya/Zq7cyg=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.0-canary.7", "", { "os": "linux", "cpu": "x64" }, "sha512-kpLB3Jj7fProynQYj2ahFyZlJs0xwm71VzCVrNRu6u7qJGXn6dK5h7+hro8y/y1iqjXWgCLSdxWSHahhWK8XdQ=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.0-canary.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rnGAKvl4cWPVV9D+SybWOGijm0VmKXyqQ+IN0A6WDgdlYZAZP0ZnJv/rq7DSvuOh19AXS8UpQc88SelXV/3j3Q=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.0-canary.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-/PRbn//EuR3UGiquk050gqvjxLliEgGBy1Cx9KkpAT7szaHOBj1mDDQmxMTEhRex4i3YfKGJXWn5mLMCveya6Q=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.0-canary.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7a92XL+DlrbWyycCpQjjQMHOrsA0p+VvS7iA2dyi89Xsq0qtOPzFH0Gb56fsjh6M6BQGFhboOSzjmpjlkMTilQ=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA=="],
"gitbook-v2/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+1 -1
View File
@@ -10,7 +10,7 @@
"packageManager": "bun@1.2.8",
"overrides": {
"@codemirror/state": "6.4.1",
"@gitbook/api": "0.113.0",
"@gitbook/api": "^0.115.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
+1 -1
View File
@@ -10,7 +10,7 @@
},
"version": "0.3.1",
"dependencies": {
"@gitbook/api": "^0.111.0",
"@gitbook/api": "^0.115.0",
"assert-never": "^1.2.1"
},
"devDependencies": {
+8 -8
View File
@@ -3,17 +3,17 @@
"version": "0.2.5",
"private": true,
"dependencies": {
"next": "canary",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"assert-never": "^1.2.1"
"next": "^15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
},
"devDependencies": {
"gitbook": "*",
+82 -35
View File
@@ -1,55 +1,102 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api';
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls';
/**
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
interface LookupPublishedContentByUrlInput {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
apiToken: string | null;
visitorPayload: SiteVisitorPayload;
}
/**
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'resolvePublishedContentByUrl',
name: url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
},
});
}
/**
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*
* @deprecated use resolvePublishedContentByUrl.
*
*/
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'getPublishedContentByURL',
name: url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url,
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
redirectOnError: input.redirectOnError,
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{ signal }
)
)
);
},
});
}
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
async function lookupPublishedContentByUrl(input: {
url: string;
fetchLookupAPIResult: (args: {
url: string;
signal: AbortSignal;
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = await apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'getPublishedContentByURL',
name: alternative.url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
// As this endpoint is cached by our API, we version the request
// to void getting stale data with missing properties.
// this could be improved by ensuring our API cache layer is versioned
// or invalidated when needed
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{
signal,
}
)
)
);
const callResult = await input.fetchLookupAPIResult({
url: alternative.url,
signal,
});
if (callResult.error) {
if (alternative.primary) {
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'bun:test';
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
describe('checkIsSizableImageURL', () => {
it('should return Skip for non-parsable URLs', () => {
expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip);
});
it('should return Skip for non-http(s) URLs', () => {
expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip);
expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip);
});
it('should return Skip for localhost URLs', () => {
expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe(
SizableImageAction.Skip
);
expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip);
});
it('should return Skip for GitBook image URLs', () => {
expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe(
SizableImageAction.Skip
);
});
it('should return Resize for supported image extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.png')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe(
SizableImageAction.Resize
);
});
it('should return Resize for URLs without extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize);
});
it('should return Passthrough for unsupported image extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe(
SizableImageAction.Passthrough
);
});
it('should handle URLs with query parameters correctly', () => {
expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe(
SizableImageAction.Passthrough
);
});
it('should be case-insensitive for extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe(
SizableImageAction.Resize
);
});
});
@@ -1,4 +1,15 @@
import { checkIsHttpURL } from '@/lib/urls';
import { getExtension } from '@/lib/paths';
export enum SizableImageAction {
Resize = 'resize',
Skip = 'skip',
Passthrough = 'passthrough',
}
/**
* https://developers.cloudflare.com/images/transform-images/#supported-input-formats
*/
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* Check if an image URL is resizable.
@@ -6,22 +17,27 @@ import { checkIsHttpURL } from '@/lib/urls';
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
export function checkIsSizableImageURL(input: string): SizableImageAction {
if (!URL.canParse(input)) {
return false;
}
if (input.includes('/~gitbook/image')) {
return false;
return SizableImageAction.Skip;
}
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return SizableImageAction.Skip;
}
if (!checkIsHttpURL(parsed)) {
return false;
if (parsed.hostname === 'localhost') {
return SizableImageAction.Skip;
}
if (parsed.pathname.includes('/~gitbook/image')) {
return SizableImageAction.Skip;
}
return true;
const extension = getExtension(parsed.pathname).toLowerCase();
if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
// If no extension, we consider it resizable.
return SizableImageAction.Resize;
}
return SizableImageAction.Passthrough;
}
@@ -1,7 +1,7 @@
import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookLinker } from '../links';
import { checkIsSizableImageURL } from './checkIsSizableImageURL';
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
import { getImageSize } from './resizer';
import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types';
@@ -24,7 +24,7 @@ export function createImageResizer({
return {
getResizedImageURL: (urlInput) => {
if (!checkIsSizableImageURL(urlInput)) {
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
return null;
}
@@ -64,7 +64,7 @@ export function createImageResizer({
},
getImageSize: async (input, options) => {
if (!checkIsSizableImageURL(input)) {
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null;
}
@@ -1,7 +1,7 @@
import 'server-only';
import assertNever from 'assert-never';
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
import { checkIsSizableImageURL } from '../checkIsSizableImageURL';
import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
import { resizeImageWithCDNCgi } from './cdn-cgi';
import { resizeImageWithCFFetch } from './cf-fetch';
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
@@ -13,7 +13,7 @@ export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null;
}
@@ -48,13 +48,17 @@ export async function resizeImage(
signal?: AbortSignal;
}
): Promise<Response> {
const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip) {
throw new Error(
'Cannot resize this image, this function should have never been called on this url'
);
}
if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
if (action === SizableImageAction.Passthrough) {
return fetch(input, {
signal: options.signal,
});
}
switch (GITBOOK_IMAGE_RESIZE_MODE) {
+5
View File
@@ -93,6 +93,11 @@ export function createLinker(
},
toAbsoluteURL(absolutePath: string): string {
// If the path is already a full URL, we return it as is.
if (URL.canParse(absolutePath)) {
return absolutePath;
}
if (!servedOn.host) {
return absolutePath;
}
+28 -7
View File
@@ -10,15 +10,16 @@ import {
type ResponseCookies,
getPathScopedCookieName,
getResponseCookiesForVisitorAuth,
getVisitorToken,
getVisitorData,
normalizeVisitorAuthURL,
} from '@/lib/visitor-token';
} from '@/lib/visitors';
import { serveResizedImage } from '@/routes/image';
import {
DataFetcherError,
getPublishedContentByURL,
getVisitorAuthBasePath,
normalizeURL,
resolvePublishedContentByUrl,
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
@@ -33,6 +34,15 @@ export const config = {
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
/**
* Temporary list of hosts to test adaptive content using the new resolution API.
*/
const ADAPTIVE_CONTENT_HOSTS = [
'docs.gitbook.com',
'adaptive-docs.gitbook-staging.com',
'enriched-content-playground.gitbook-staging.io',
];
export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
@@ -85,17 +95,22 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
//
// Detect and extract the visitor authentication token from the request
//
// @ts-ignore - request typing
const visitorToken = getVisitorToken({
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({
cookies: request.cookies.getAll(),
url: siteRequestURL,
});
const withAPIToken = async (apiToken: string | null) => {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError(
getPublishedContentByURL({
resolve({
url: siteRequestURL.toString(),
visitorAuthToken: visitorToken?.token ?? null,
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
},
// 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.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
@@ -106,7 +121,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
apiToken,
})
);
const cookies: ResponseCookies = [];
const cookies: ResponseCookies = visitorParamsCookie
? [
// If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation.
visitorParamsCookie,
]
: [];
//
// Handle redirects
+2 -2
View File
@@ -12,7 +12,7 @@ import {
VISITOR_TOKEN_COOKIE,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
} from '@/lib/visitor-token';
} from '@/lib/visitors';
import { getSiteAPIToken } from '../tests/utils';
import {
@@ -269,7 +269,7 @@ const testCases: TestsCase[] = [
},
{
name: 'GitBook',
contentBaseURL: 'https://docs.gitbook.com',
contentBaseURL: 'https://gitbook.com/docs/',
tests: [
{
name: 'Home',
+23 -19
View File
@@ -345,19 +345,30 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<string, 'pending' | 'loaded'> =
(window as any).__ICONS_STATES__ || {};
const urlStates: Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch SVG: ${response.status}`);
}
const svgText = await response.text();
const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22');
return `data:image/svg+xml;charset=utf-8,${encoded}`;
};
const loadUrl = (url: string) => {
// Mark the URL as pending.
urlStates[url] = 'pending';
const img = new Image();
img.onload = () => {
urlStates[url] = 'loaded';
};
img.src = url;
urlStates[url] = { state: 'pending', uri: null };
fetchSvgAsDataUri(url).then((uri) => {
urlStates[url] = { state: 'loaded', uri };
});
};
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
@@ -393,18 +404,11 @@ async function waitForIcons(page: Page) {
// If the URL is already queued for loading, we return the state.
if (urlStates[url]) {
if (urlStates[url] === 'loaded') {
if (urlStates[url].state === 'loaded') {
icon.setAttribute('data-argos-state', 'pending');
const bckMaskImage = icon.style.maskImage;
const bckDisplay = icon.style.display;
icon.style.maskImage = '';
icon.style.display = 'none';
icon.style.maskImage = `url("${urlStates[url].uri}")`;
requestAnimationFrame(() => {
icon.style.maskImage = bckMaskImage;
icon.style.display = bckDisplay;
requestAnimationFrame(() => {
icon.setAttribute('data-argos-state', 'loaded');
});
icon.setAttribute('data-argos-state', 'loaded');
});
return false;
}
+1 -1
View File
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
},
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
@@ -58,7 +58,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
<code
id={id}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line]',
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
)}
>
@@ -6,6 +6,7 @@ import { Card } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import { getDataOrNull } from '@v2/lib/data';
import { Image } from '../utils';
import type { BlockProps } from './Block';
import { Caption } from './Caption';
import { IntegrationBlock } from './Integration';
@@ -52,7 +53,14 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<Card
leadingIcon={
embed.icon ? (
<img src={embed.icon} className={tcls('w-5', 'h-5')} alt="Logo" />
<Image
src={embed.icon}
className={tcls('w-5', 'h-5')}
alt="Logo"
sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer}
/>
) : null
}
href={block.data.url}
@@ -0,0 +1,58 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getBlockTextStyle } from './spacing';
/**
* A hash icon which adds the block or active block item's ID in the URL hash.
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
*/
export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
export function HashLinkButton(props: {
id: string;
block: DocumentBlockTabs | DocumentBlockHeading;
label?: string;
className?: ClassValue;
iconClassName?: ClassValue;
}) {
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
className={tcls(
'relative',
'hash',
'grid',
'grid-area-1-1',
'h-[1em]',
'border-0',
'opacity-0',
'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]',
'md:group-hover/hash:md:opacity-[1]',
'md:group-focus/hash:md:opacity-[1]',
className
)}
>
<a
href={`#${id}`}
aria-label={label}
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'size-3',
'self-center',
'transition-colors',
'text-transparent',
'group-hover/hash:text-tint-subtle',
'contrast-more:group-hover/hash:text-tint-strong',
iconClassName
)}
/>
</a>
</div>
);
}
@@ -1,9 +1,9 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
@@ -23,50 +23,20 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
className={tcls(
textStyle.textSize,
'heading',
'group',
'relative',
'grid',
'scroll-m-12',
hashLinkButtonWrapperStyles,
style
)}
>
<div
className={tcls(
'hash',
'grid',
'grid-area-1-1',
'relative',
'-ml-6',
'w-7',
'border-0',
'opacity-0',
'group-hover:opacity-[0]',
'group-focus:opacity-[0]',
'md:group-hover:md:opacity-[1]',
'md:group-focus:md:opacity-[1]',
textStyle.marginTop
)}
>
<a
href={`#${id}`}
aria-label="Direct link to heading"
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'w-3.5',
'h-[1em]',
'mt-0.5',
'transition-colors',
'text-transparent',
'group-hover:text-tint-subtle',
'contrast-more:group-hover:text-tint-strong',
'lg:w-4'
)}
/>
</a>
</div>
<HashLinkButton
id={id}
block={block}
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
iconClassName={tcls('size-4')}
label="Direct link to heading"
/>
<div
className={tcls(
'grid-area-1-1',
@@ -15,14 +15,14 @@ export function RecordRow(
fixedColumns: string[];
}
) {
const { view, autoSizedColumns, fixedColumns, block } = props;
const { view, autoSizedColumns, fixedColumns, block, context } = props;
return (
<div className={styles.row} role="row">
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
column,
columnWidths: view.columnWidths,
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
});
@@ -13,10 +13,10 @@ import { getColumnAlignment } from './utils';
3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
*/
export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
const { block, view, records, style } = props;
const { block, view, records, style, context } = props;
/* Calculate how many columns are auto-sized vs fixed width */
const columnWidths = view.columnWidths;
const columnWidths = context.mode === 'print' ? undefined : view.columnWidths;
const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
@@ -5,6 +5,8 @@ import React, { useCallback, useMemo } from 'react';
import { useHash, useIsMounted } from '@/components/hooks';
import * as storage from '@/lib/local-storage';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockTabs } from '@gitbook/api';
import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton';
interface TabsState {
activeIds: {
@@ -68,9 +70,10 @@ export function DynamicTabs(
props: TabsInput & {
tabsBody: React.ReactNode[];
style: ClassValue;
block: DocumentBlockTabs;
}
) {
const { id, tabs, tabsBody, style } = props;
const { id, block, tabs, tabsBody, style } = props;
const hash = useHash();
const [tabsState, setTabsState] = useTabsState();
@@ -146,8 +149,8 @@ export function DynamicTabs(
'ring-inset',
'ring-tint-subtle',
'flex',
'overflow-hidden',
'flex-col',
'overflow-hidden',
style
)}
>
@@ -165,16 +168,14 @@ export function DynamicTabs(
)}
>
{tabs.map((tab) => (
<button
<div
key={tab.id}
role="tab"
aria-selected={active.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
onSelectTab(tab);
}}
className={tcls(
hashLinkButtonWrapperStyles,
'flex',
'items-center',
'gap-3.5',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
@@ -184,14 +185,6 @@ export function DynamicTabs(
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'inline-block',
'text-sm',
'px-3.5',
'py-2',
'transition-[color]',
'font-[500]',
'relative',
'after:transition-colors',
'after:border-r',
'after:absolute',
@@ -202,14 +195,16 @@ export function DynamicTabs(
'after:h-[70%]',
'after:w-[1px]',
'px-3.5',
'py-2',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'truncate',
'max-w-full',
'truncate',
active.id === tab.id
? [
@@ -224,8 +219,34 @@ export function DynamicTabs(
: null
)}
>
{tab.title}
</button>
<button
type="button"
role="tab"
aria-selected={active.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
onSelectTab(tab);
}}
className={tcls(
'inline-block',
'text-sm',
'transition-[color]',
'font-[500]',
'relative',
'max-w-full',
'truncate'
)}
>
{tab.title}
</button>
<HashLinkButton
id={getTabButtonId(tab.id)}
block={block}
label="Direct link to tab"
/>
</div>
))}
</div>
{tabs.map((tab, index) => (
@@ -39,6 +39,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
<DynamicTabs
key={tab.id}
id={block.key!}
block={block}
tabs={[tab]}
tabsBody={[tabsBody[index]]}
style={style}
@@ -48,5 +49,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
);
}
return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />;
return (
<DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} />
);
}
@@ -10,6 +10,8 @@ export function getBlockTextStyle(block: DocumentBlock): {
lineHeight: string;
/** Tailwind class for the margin top (mt-*) */
marginTop?: string;
/** Tailwind class for the margin top to apply on the anchor link button */
anchorButtonMarginTop?: string;
} {
switch (block.type) {
case 'paragraph':
@@ -22,18 +24,21 @@ export function getBlockTextStyle(block: DocumentBlock): {
textSize: 'text-3xl font-semibold',
lineHeight: 'leading-tight',
marginTop: 'mt-[1em]',
anchorButtonMarginTop: 'mt-[1.05em]',
};
case 'heading-2':
return {
textSize: 'text-2xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'mt-[0.75em]',
anchorButtonMarginTop: 'mt-[0.9em]',
};
case 'heading-3':
return {
textSize: 'text-xl font-semibold',
lineHeight: 'leading-snug',
marginTop: 'mt-[0.5em]',
anchorButtonMarginTop: 'mt-[0.65em]',
};
case 'divider':
return {
@@ -20,7 +20,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
return (
<Link
href={linker.toAbsoluteURL(linker.toPathInSpace(''))}
href={linker.toAbsoluteURL(linker.toPathInSite(''))}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
>
{customization.header.logo ? (
@@ -48,8 +48,6 @@ export async function HeaderLogo(props: HeaderLogoProps) {
]}
priority="high"
style={tcls(
'rounded',
'straight-corners:rounded-sm',
'overflow-hidden',
'shrink',
'min-w-0',
@@ -18,7 +18,7 @@ export function RocketLoaderDetector(props: { nonce?: string }) {
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 mt-8 mx-8';
alert.innerHTML = \`
<strong>Error in site configuration:</strong>
It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://docs.gitbook.com/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem.
It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://gitbook.com/docs/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem.
\`;
document.body.prepend(alert);
@@ -69,7 +69,11 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
const redirectPathname = withLeadingSlash(rawPathname);
if (/^\/[a-zA-Z0-9-_.\/]+[a-zA-Z0-9-_.]$/.test(redirectPathname)) {
if (
/^\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+(?:\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+)*$/.test(
redirectPathname
)
) {
const redirectSources = new Set<string>([
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
@@ -83,6 +83,12 @@ export function SiteSectionTabs(props: { sections: ClientSiteSections }) {
)
}
asChild
onClick={(e) => {
if (value) {
e.preventDefault();
e.stopPropagation();
}
}}
>
<SectionGroupTab
isActive={isActive}
@@ -180,7 +186,7 @@ const SectionGroupTab = React.forwardRef(function SectionGroupTab(
ref={ref}
{...rest}
className={tcls(
'group relative my-2 flex select-none items-center justify-between rounded straight-corners:rounded-none px-3 py-1 transition-colors',
'group relative my-2 flex select-none items-center justify-between rounded straight-corners:rounded-none px-3 py-1 transition-colors hover:cursor-default',
isActive
? 'text-primary-subtle'
: 'text-tint hover:bg-tint-hover hover:text-tint-strong'
+6 -1
View File
@@ -765,7 +765,12 @@ export const getComputedDocument = cache({
* Mimic the validation done on source server-side to reduce API usage.
*/
function validateSiteRedirectSource(source: string) {
return source.length <= 512 && /^\/[a-zA-Z0-9-_.\\/]+[a-zA-Z0-9-_.]$/.test(source);
return (
source.length <= 512 &&
/^\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+(?:\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+)*$/.test(
source
)
);
}
/**
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'bun:test';
import { getExtension } from './paths';
describe('getExtension', () => {
it('should return the extension of a path', () => {
expect(getExtension('test.txt')).toBe('.txt');
});
it('should return an empty string if there is no extension', () => {
expect(getExtension('test/path/to/file')).toBe('');
});
it('should return the extension of a path with multiple dots', () => {
expect(getExtension('test.with.multiple.dots.txt')).toBe('.txt');
});
});
+12
View File
@@ -57,3 +57,15 @@ export function withTrailingSlash(pathname: string): string {
return pathname;
}
/**
* Get the extension of a path.
*/
export function getExtension(path: string): string {
const re = /\.[0-9a-z]+$/i;
const match = path.match(re);
if (match) {
return match[0];
}
return '';
}
@@ -6,7 +6,8 @@ import {
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
getVisitorToken,
} from './visitor-token';
getVisitorUnsignedClaims,
} from './visitors';
describe('getVisitorAuthToken', () => {
it('should return the token from the query parameters', () => {
@@ -158,3 +159,139 @@ function assertVisitorAuthCookieValue(
throw new Error('Expected a VisitorAuthCookieValue');
}
describe('getVisitorUnsignedClaims', () => {
it('should merge claims from multiple public cookies', () => {
const cookies = [
{
name: 'gitbook-visitor-public-bucket',
value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }),
},
{
name: 'gitbook-visitor-public-launchdarkly',
value: JSON.stringify({
launchdarkly: { flags: { ALPHA: true, API: true } },
}),
},
];
const url = new URL('https://example.com/');
const claims = getVisitorUnsignedClaims({ cookies, url });
expect(claims.all).toStrictEqual({
bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } },
launchdarkly: { flags: { ALPHA: true, API: true } },
});
});
it('should parse visitor.* query params with simple types', () => {
const url = new URL(
'https://example.com/?visitor.isEnterprise=true&visitor.language=fr&visitor.country=fr'
);
const claims = getVisitorUnsignedClaims({ cookies: [], url });
expect(claims.all).toStrictEqual({
isEnterprise: true,
language: 'fr',
country: 'fr',
});
expect(claims.fromVisitorParams).toStrictEqual({
isEnterprise: true,
language: 'fr',
country: 'fr',
});
});
it('should ignore params that do not match visitor.* convention', () => {
const url = new URL('https://example.com/?visitor.isEnterprise=true&otherParam=true');
const claims = getVisitorUnsignedClaims({ cookies: [], url });
expect(claims.all).toStrictEqual({
isEnterprise: true,
// otherParam is not present
});
expect(claims.fromVisitorParams).toStrictEqual({
isEnterprise: true,
});
});
it('should support nested query param keys via dot notation', () => {
const url = new URL(
'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false'
);
const claims = getVisitorUnsignedClaims({ cookies: [], url });
expect(claims.all).toStrictEqual({
isEnterprise: true,
flags: {
ALPHA: true,
API: false,
},
});
expect(claims.fromVisitorParams).toStrictEqual({
isEnterprise: true,
flags: {
ALPHA: true,
API: false,
},
});
});
it('should ignore invalid JSON in cookie values', () => {
const cookies = [
{
name: 'gitbook-visitor-public',
value: '{not: "json"}',
},
];
const url = new URL('https://example.com/');
const claims = getVisitorUnsignedClaims({ cookies, url });
expect(claims.all).toStrictEqual({});
});
it('should merge claims from cookies and visitor.* query params', () => {
const cookies = [
{
name: 'gitbook-visitor-public',
value: JSON.stringify({ role: 'admin', language: 'fr' }),
},
{
name: 'gitbook-visitor-public-bucket',
value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }),
},
];
const url = new URL(
'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false&visitor.bucket.flags.HELLO=false'
);
const claims = getVisitorUnsignedClaims({ cookies, url });
expect(claims.all).toStrictEqual({
role: 'admin',
language: 'fr',
bucket: {
flags: { HELLO: false, SITE_AI: true, SITE_PREVIEW: true },
},
isEnterprise: true,
flags: {
ALPHA: true,
API: false,
},
});
expect(claims.fromVisitorParams).toStrictEqual({
bucket: {
flags: { HELLO: false },
},
isEnterprise: true,
flags: {
ALPHA: true,
API: false,
},
});
});
});
@@ -4,6 +4,7 @@ import hash from 'object-hash';
const VISITOR_AUTH_PARAM = 'jwt_token';
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
const VISITOR_UNSIGNED_CLAIMS_PREFIX = 'gitbook-visitor-public';
/**
* Typing for a cookie, matching the internal type of Next.js.
@@ -30,6 +31,27 @@ type VisitorAuthCookieValue = {
token: string;
};
type ClaimPrimitive =
| string
| number
| boolean
| null
| undefined
| { [key: string]: ClaimPrimitive }
| ClaimPrimitive[];
/**
* The result of a visitor data lookup that can include:
* - a visitor token (JWT)
* - a record of visitor public/unsigned claims (JSON object)
* - a session cookie response to persist any visitor query params across navigations.
*/
export type VisitorDataLookup = {
visitorToken: VisitorTokenLookup;
unsignedClaims: Record<string, ClaimPrimitive>;
visitorParamsCookie: ResponseCookie | undefined;
};
/**
* The result of a visitor token lookup.
*/
@@ -53,6 +75,30 @@ export type VisitorTokenLookup =
/** Not visitor token was found */
| undefined;
/**
* Get the visitor data for the request potentially including:
* - a JWT token that may contain signed claims or can be used for VA authentication.
* - a record of the unsigned claims passed via a cookie or visitor.* params.
* - a session cookie response that is used to persist any visitor.* params that were passed via the site URL.
*/
export function getVisitorData({
cookies,
url,
}: {
cookies: RequestCookies;
url: URL | NextRequest['nextUrl'];
}): VisitorDataLookup {
const visitorToken = getVisitorToken({ cookies, url });
const unsignedClaims = getVisitorUnsignedClaims({ cookies, url });
const visitorParamsCookie = getResponseCookieForVisitorParams(unsignedClaims.fromVisitorParams);
return {
visitorToken,
unsignedClaims: unsignedClaims.all,
visitorParamsCookie,
};
}
/**
* Get the visitor token for the request. This token can either be in the
* query parameters or stored as a cookie.
@@ -82,6 +128,138 @@ export function getVisitorToken({
}
}
/**
* Get the visitor unsigned/public claims for the request. They can either be in `visitor.` query
* parameters or stored in special `gitbook-visitor-public-*` cookies.
*/
export function getVisitorUnsignedClaims(args: {
cookies: RequestCookies;
url: URL | NextRequest['nextUrl'];
}): {
/**
* The unsigned claims coming from both `gitbook-visitor-public` cookies and `visitor.*` query params.
*/
all: Record<string, ClaimPrimitive>;
/**
* The unsigned claims from the `visitor.*` query params.
*/
fromVisitorParams: Record<string, ClaimPrimitive>;
} {
const { cookies, url } = args;
const claims: Record<string, ClaimPrimitive> = {};
const searchParamsClaims: Record<string, ClaimPrimitive> = {};
for (const cookie of cookies) {
if (cookie.name.startsWith(VISITOR_UNSIGNED_CLAIMS_PREFIX)) {
try {
const parsed = JSON.parse(cookie.value);
if (typeof parsed === 'object' && parsed !== null) {
Object.assign(claims, parsed);
}
} catch (_err) {
console.warn(`Invalid JSON in unsigned claim cookie "${cookie.name}"`);
}
}
}
for (const [key, value] of url.searchParams.entries()) {
if (key.startsWith('visitor.')) {
const claimPath = key.substring('visitor.'.length);
const claimValue = parseVisitorQueryParamValue(value);
setVisitorClaimByPath(claims, claimPath, claimValue);
setVisitorClaimByPath(searchParamsClaims, claimPath, claimValue);
}
}
return { all: claims, fromVisitorParams: searchParamsClaims };
}
/**
* Set the value of claims in a claims object at a specific path.
*/
function setVisitorClaimByPath(
claims: Record<string, ClaimPrimitive>,
keyPath: string,
value: ClaimPrimitive
): void {
const keys = keyPath.split('.');
let current = claims;
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
if (index === keys.length - 1) {
current[key] = value;
} else {
if (!(key in current) || !isClaimPrimitiveObject(current[key])) {
current[key] = {};
}
current = current[key];
}
}
}
function isClaimPrimitiveObject(value: unknown): value is Record<string, ClaimPrimitive> {
return typeof value === 'object' && value !== null;
}
/**
* Parse the value expected in a `visitor.` URL query parameter.
*/
function parseVisitorQueryParamValue(value: string): ClaimPrimitive {
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
if (value === 'null') {
return null;
}
if (value === 'undefined') {
return undefined;
}
const num = Number(value);
if (!Number.isNaN(num) && value.trim() !== '') {
return num;
}
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch {}
return value;
}
/**
* Returns to cookie response to use in order to persist visitor params that were passed to the URL.
*/
function getResponseCookieForVisitorParams(
visitorParamsClaims: Record<string, ClaimPrimitive>
): ResponseCookie | undefined {
if (Object.keys(visitorParamsClaims).length === 0) {
return undefined;
}
return {
name: VISITOR_UNSIGNED_CLAIMS_PREFIX,
value: JSON.stringify(visitorParamsClaims),
options: {
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
},
};
}
/**
* Return the lookup result for content served with visitor auth.
*/
+12 -12
View File
@@ -24,9 +24,9 @@ import {
type ResponseCookies,
type VisitorTokenLookup,
getResponseCookiesForVisitorAuth,
getVisitorToken,
getVisitorData,
normalizeVisitorAuthURL,
} from '@/lib/visitor-token';
} from '@/lib/visitors';
import { joinPath, withLeadingSlash } from '@/lib/paths';
import { getProxyModeBasePath } from '@/lib/proxy';
@@ -392,17 +392,17 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<Lookup
* When serving multi spaces based on the current URL.
*/
async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
const visitorAuthToken = getVisitorToken({
const { visitorToken } = getVisitorData({
cookies: request.cookies.getAll(),
url,
});
const lookup = await lookupSiteByAPI(url, visitorAuthToken);
const lookup = await lookupSiteByAPI(url, visitorToken);
return {
...lookup,
...('basePath' in lookup && visitorAuthToken
? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken)
...('basePath' in lookup && visitorToken
? getLookupResultForVisitorAuth(lookup.basePath, visitorToken)
: {}),
visitorToken: visitorAuthToken?.token,
visitorToken: visitorToken?.token,
};
}
@@ -609,12 +609,12 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis
const target = new URL(targetStr);
target.search = url.search;
const visitorAuthToken = getVisitorToken({
const { visitorToken } = getVisitorData({
cookies: request.cookies.getAll(),
url: target,
});
const lookup = await lookupSiteByAPI(target, visitorAuthToken);
const lookup = await lookupSiteByAPI(target, visitorToken);
if ('error' in lookup) {
return lookup;
}
@@ -641,10 +641,10 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis
...lookup,
siteBasePath: joinPath(target.host, lookup.siteBasePath),
basePath: joinPath(target.host, lookup.basePath),
...('basePath' in lookup && visitorAuthToken
? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken)
...('basePath' in lookup && visitorToken
? getLookupResultForVisitorAuth(lookup.basePath, visitorToken)
: {}),
visitorToken: visitorAuthToken?.token,
visitorToken: visitorToken?.token,
};
}
+2 -1
View File
@@ -2,6 +2,7 @@ import {
CURRENT_SIGNATURE_VERSION,
type CloudflareImageOptions,
type SignatureVersion,
SizableImageAction,
checkIsSizableImageURL,
isSignatureVersion,
parseImageAPIURL,
@@ -40,7 +41,7 @@ export async function serveResizedImage(
// Check again if the image can be sized, even though we checked when rendering the Image component
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
// Also prevent infinite redirects.
if (!checkIsSizableImageURL(url)) {
if (checkIsSizableImageURL(url) === SizableImageAction.Skip) {
return new Response('Invalid url parameter', { status: 400 });
}
+2 -2
View File
@@ -12,13 +12,13 @@ interface Test {
// and to be able to see the results, and only catch major regressions.
const tests: Array<Test> = [
{
url: 'https://docs.gitbook.com',
url: 'https://gitbook.com/docs',
strategy: 'desktop',
threshold: 60,
},
{
url: 'https://docs.gitbook.com',
url: 'https://gitbook.com/docs',
strategy: 'mobile',
threshold: 30,
},
+1 -1
View File
@@ -10,7 +10,7 @@
},
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/icons": "workspace:*"
},
"peerDependencies": {
@@ -6,8 +6,8 @@ import { OpenAPIResponseExampleContent } from './OpenAPIResponseExampleContent';
import { type OpenAPIContext, getOpenAPIClientContext } from './context';
import type { OpenAPIOperationData, OpenAPIWebhookData } from './types';
import { getExampleFromReference, getExamples } from './util/example';
import { createStateKey, getStatusCodeDefaultLabel } from './utils';
import { checkIsReference, resolveDescription } from './utils';
import { createStateKey, getStatusCodeDefaultLabel, resolveDescription } from './utils';
import { checkIsReference } from './utils';
/**
* Display an example of the response content.
@@ -41,45 +41,47 @@ export function OpenAPIResponseExample(props: {
return Number(a) - Number(b);
});
const tabs = responses.map(([key, responseObject]) => {
const description = resolveDescription(responseObject);
const label = description ? (
<Markdown source={description} />
) : (
getStatusCodeDefaultLabel(key, context)
);
const tabs = responses
.filter(([_, responseObject]) => responseObject && typeof responseObject === 'object')
.map(([key, responseObject]) => {
const description = resolveDescription(responseObject);
const label = description ? (
<Markdown source={description} />
) : (
getStatusCodeDefaultLabel(key, context)
);
if (checkIsReference(responseObject)) {
return {
key: key,
label,
statusCode: key,
body: (
<OpenAPIExample
example={getExampleFromReference(responseObject, context)}
context={context}
syntax="json"
/>
),
};
}
if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
return {
key: key,
label,
statusCode: key,
body: <OpenAPIEmptyExample context={context} />,
};
}
if (checkIsReference(responseObject)) {
return {
key: key,
label,
statusCode: key,
body: (
<OpenAPIExample
example={getExampleFromReference(responseObject, context)}
context={context}
syntax="json"
/>
),
body: <OpenAPIResponse context={context} content={responseObject.content} />,
};
}
if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
return {
key: key,
label,
statusCode: key,
body: <OpenAPIEmptyExample context={context} />,
};
}
return {
key: key,
label,
statusCode: key,
body: <OpenAPIResponse context={context} content={responseObject.content} />,
};
});
});
if (tabs.length === 0) {
return null;
@@ -20,8 +20,9 @@ export function OpenAPIResponses(props: {
}) {
const { responses, context } = props;
const groups = Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const groups = Object.entries(responses)
.filter(([_, response]) => response && typeof response === 'object')
.map(([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const tabs = (() => {
// If there is no content, but there are headers, we need to show the headers
if (
@@ -83,8 +84,7 @@ export function OpenAPIResponses(props: {
),
tabs,
};
}
);
});
const state = useResponseExamplesState(context.blockKey, groups[0]?.key);
+34 -5
View File
@@ -80,11 +80,10 @@ function OpenAPISchemaProperty(
context={context}
/>
{index < alternatives.length - 1 ? (
<span className="openapi-schema-alternative-separator">
{(schema.anyOf || schema.oneOf) &&
tString(context.translation, 'or')}
{schema.allOf && tString(context.translation, 'and')}
</span>
<OpenAPISchemaAlternativeSeparator
schema={schema}
context={context}
/>
) : null}
</div>
))}
@@ -256,6 +255,28 @@ function OpenAPISchemaAlternative(props: {
);
}
function OpenAPISchemaAlternativeSeparator(props: {
schema: OpenAPIV3.SchemaObject;
context: OpenAPIClientContext;
}) {
const { schema, context } = props;
const anyOf = schema.anyOf || schema.items?.anyOf;
const oneOf = schema.oneOf || schema.items?.oneOf;
const allOf = schema.allOf || schema.items?.allOf;
if (!anyOf && !oneOf && !allOf) {
return null;
}
return (
<span className="openapi-schema-alternative-separator">
{(anyOf || oneOf) && tString(context.translation, 'or')}
{allOf && tString(context.translation, 'and')}
</span>
);
}
/**
* Render a circular reference to a schema.
*/
@@ -457,6 +478,14 @@ export function getSchemaAlternatives(
schema: OpenAPIV3.SchemaObject,
ancestors: Set<OpenAPIV3.SchemaObject> = new Set()
): OpenAPIV3.SchemaObject[] | null {
// Search for alternatives in the items property if it exists
if (
schema.items &&
('oneOf' in schema.items || 'allOf' in schema.items || 'anyOf' in schema.items)
) {
return getSchemaAlternatives(schema.items, ancestors);
}
const alternatives:
| [AlternativeType, (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[]]
| null = (() => {
@@ -204,6 +204,14 @@ const getExampleFromSchema = (
return cache(schema, schema.example);
}
// Use a default value, if theres one and its a string or number
if (
schema.default !== undefined &&
['string', 'number', 'boolean'].includes(typeof schema.default)
) {
return cache(schema, schema.default);
}
// enum: [ 'available', 'pending', 'sold' ]
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
return cache(schema, schema.enum[0]);