mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 02:23:30 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f76e9661 | |||
| 95a1f652f9 | |||
| cc37e2ae15 | |||
| 08382ce62f | |||
| 80cb52a237 | |||
| c6637b09e2 | |||
| 778624af00 | |||
| e15757d01f | |||
| aed79fd6c3 | |||
| 0c973a3292 | |||
| 56c923c0ed | |||
| 7d7806df30 | |||
| cb5598dc19 | |||
| 47f01edcb8 | |||
| e6ddc0f07f | |||
| 373f18f451 | |||
| 3f292065cd |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Only resize images with supported extensions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Decrease rounding of header logo
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix URL in sitemap
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Better print layouts: wrap code blocks & force table column auto-sizing
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Handle OpenAPI alternatives from schema.items
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Handle invalid OpenAPI Responses
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Use default value if string number or boolean in generateSchemaExample
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Prevent section group popovers from opening on click
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Update the regex for validating site redirect
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Always link main logo to the root of the site
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook-v2": minor
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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._
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
+138
-1
@@ -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.
|
||||
*/
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 there’s one and it’s 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]);
|
||||
|
||||
Reference in New Issue
Block a user