mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 14:00:28 +00:00
Compare commits
454 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13514bf6fa | |||
| 05ffd0e321 | |||
| 701eaad92a | |||
| c66ef5dd10 | |||
| f774f9b3a0 | |||
| 29dc7ccb1a | |||
| 80237c34f9 | |||
| 979233c5c0 | |||
| 2f03e6acee | |||
| c492f09a19 | |||
| 2c3af5e73c | |||
| 6597f8508d | |||
| 8beb5d6bcf | |||
| 53f5dbea26 | |||
| 725952a106 | |||
| 3319375e9a | |||
| c5a4619020 | |||
| 989fc69abe | |||
| 0924259217 | |||
| 05affac34f | |||
| 453a459566 | |||
| 722f02ea09 | |||
| 5bcea2fe3b | |||
| 73afd1f5d0 | |||
| 9b8a0d3e54 | |||
| 75bd98d66c | |||
| 9b914d10bd | |||
| 3e11678d8d | |||
| 6e03638130 | |||
| 23f884efb7 | |||
| 2ae76f999b | |||
| 685325cdc7 | |||
| ab132eadb7 | |||
| 14504e43b5 | |||
| 4ed79574e8 | |||
| 31bcd1a386 | |||
| 270e5ef7ad | |||
| 1f4733355e | |||
| 027a859ef4 | |||
| 59f50eaee7 | |||
| 80d2733c25 | |||
| feb6735a4d | |||
| f1d1d2fd81 | |||
| a749c67803 | |||
| c808bb1483 | |||
| e24206ebf0 | |||
| 3410785a3a | |||
| b4a12d606e | |||
| a0545545d7 | |||
| 1f11650ca1 | |||
| da55facf2c | |||
| dc2dbc5710 | |||
| bdd6303bcc | |||
| 66d0fc0683 | |||
| 05e1d8cd96 | |||
| 9f0de74caa | |||
| a820739bd2 | |||
| 304042017c | |||
| 82cd9f2979 | |||
| a3f1fea27b | |||
| bb5c6a42e7 | |||
| 445baaaa61 | |||
| 7419ee7dba | |||
| aa6be381a4 | |||
| 61575838f3 | |||
| 359bb979f8 | |||
| b29a885bae | |||
| 919da7f2c9 | |||
| d222c116e0 | |||
| df840dac18 | |||
| f8d4c7697c | |||
| dddb4ecbb8 | |||
| 470d10cb47 | |||
| dae11e9607 | |||
| 3d1eb53708 | |||
| a76c9a56a7 | |||
| eaad0d4e09 | |||
| 46edde9da6 | |||
| dda0cc635e | |||
| 727bde2d16 | |||
| 9f30af1b33 | |||
| ff05e20537 | |||
| 53de5b13c1 | |||
| a025118f69 | |||
| 71688a817b | |||
| 00c476ea7f | |||
| 5576906914 | |||
| d5aaccd2c4 | |||
| 7059c2ba25 | |||
| aaab157d49 | |||
| 9943276c0c | |||
| c31cf5243a | |||
| 0c03676efb | |||
| 883d10acf8 | |||
| 3e5e458118 | |||
| 0510b6f29e | |||
| 92b7668bf3 | |||
| 3973b1cbf3 | |||
| 160fca1ea1 | |||
| e90c96f7e9 | |||
| d935fb1b2e | |||
| a6529584a5 | |||
| 18953b2d6b | |||
| 21cbd9e3b0 | |||
| 38061bdf65 | |||
| d9029c7e1e | |||
| fe8acc986e | |||
| e721f17f7e | |||
| 2f73db785e | |||
| 9e18ae6587 | |||
| 9eca010f94 | |||
| 6e54a06d9c | |||
| 1f8e416e41 | |||
| cfccc44406 | |||
| 45b30c9c27 | |||
| 67f78368fb | |||
| d9c8d57e8e | |||
| 0d615e3888 | |||
| 68287d3df4 | |||
| dff08ae3e3 | |||
| 8cfa67c1dd | |||
| b41d425993 | |||
| 12c7862250 | |||
| 6f54826296 | |||
| ccf2cffc4b | |||
| f7b801b4c8 | |||
| 95f2aa45eb | |||
| 9524ff5f5a | |||
| 1823101b03 | |||
| 1a8cfd2a8b | |||
| 5b4e710770 | |||
| 1762f85ef9 | |||
| d370a3f0e3 | |||
| d3e573c2e9 | |||
| 94876e3ad1 | |||
| ad190602c7 | |||
| 162b4b78b6 | |||
| e5dc05e994 | |||
| b46cff70c0 | |||
| b6c3870eda | |||
| 1c1f504601 | |||
| 1b8a45678c | |||
| 38de9eb9d7 | |||
| eac1314a7d | |||
| 29d59793dc | |||
| 128ad20280 | |||
| 994ebff8c1 | |||
| 142938401c | |||
| 65cc4afb23 | |||
| e000f23c44 | |||
| 1de9d1aa33 | |||
| 0e601e277b | |||
| e582a47f37 | |||
| 1b251a78b0 | |||
| 24f5249ae9 | |||
| 17c1631013 | |||
| cb100d5e00 | |||
| 1fcc807513 | |||
| 5664e5a30f | |||
| 26e64019d8 | |||
| 56331d2e74 | |||
| 55b095e503 | |||
| 6a073e1dd5 | |||
| 960e25a3f6 | |||
| 1de338c312 | |||
| dbba50cea4 | |||
| 8f046a9cd6 | |||
| 2906e6038a | |||
| 87b8ea8c05 | |||
| 47971dce31 | |||
| 528a05398c | |||
| f584148ab9 | |||
| 0de22f7a96 | |||
| 09c7c30779 | |||
| fde32e26bf | |||
| a6f65916f1 | |||
| 6059efe094 | |||
| 16194c50ba | |||
| af3c6a971c | |||
| ecfdb976a3 | |||
| 08acea651a | |||
| 6b503608cd | |||
| d8763997a5 | |||
| 32aa1f99c1 | |||
| 718a8a54b1 | |||
| 741dd490d4 | |||
| 02d876e6d8 | |||
| 56c52e0bb0 | |||
| 6036becf93 | |||
| 3a7210d514 | |||
| 6088fa582d | |||
| 6691492ef7 | |||
| 7ee9158cd3 | |||
| 5ae1b883ab | |||
| 88397bf419 | |||
| 1138d5935b | |||
| 8276ba080e | |||
| 37d13d80f5 | |||
| 1c97536ab1 | |||
| ca8f028cc0 | |||
| 300f7bfe4b | |||
| 82dc9c40f7 | |||
| 44a20fe5ee | |||
| b950a64406 | |||
| c5f374f66f | |||
| ae99f87db0 | |||
| 5112e3e79b | |||
| c1e27ccacc | |||
| b0bd871997 | |||
| c30bc24fd6 | |||
| 648f0e9e84 | |||
| d2bc5672e0 | |||
| 5c87ec7c2f | |||
| d66c184ed2 | |||
| 12f25d8bfc | |||
| cbe61397a3 | |||
| deb8c54da3 | |||
| 5dab70fab4 | |||
| 665b6bed66 | |||
| f4a90defc3 | |||
| 46f63cbd55 | |||
| c77142a16f | |||
| eb7c22f52a | |||
| e86e51f06f | |||
| db74ea3001 | |||
| 99579ac29c | |||
| 2f6540e008 | |||
| 72c3b80529 | |||
| 8126a83c50 | |||
| 210c432b37 | |||
| f92e90603f | |||
| 14172791e9 | |||
| e4e2f524d4 | |||
| fc7b16f6a7 | |||
| 6ba3ae74d5 | |||
| c71d1598d8 | |||
| 5950657b0e | |||
| 0e1a48cd06 | |||
| 98245e5f20 | |||
| 0b6ddca981 | |||
| ea1468c892 | |||
| e8e64bf510 | |||
| aaf8daf0d7 | |||
| 8af1abc4e1 | |||
| 48ab59feb3 | |||
| f64d5073d4 | |||
| 53b9f10ac8 | |||
| 75606e4df7 | |||
| 0643b7f351 | |||
| 5b3b4e0614 | |||
| 8d039d1348 | |||
| 87eea73081 | |||
| 67a6fb4c87 | |||
| 23584c9862 | |||
| 2f767125c4 | |||
| c73e07d42d | |||
| 6b5d7e6057 | |||
| 076dc48e89 | |||
| bb208ab36a | |||
| 60e35001b6 | |||
| 29a0d7ea02 | |||
| 1f2b7f7acf | |||
| 39e5cb63ef | |||
| 5b5928fb8d | |||
| 08068743a5 | |||
| 35eae1adcf | |||
| e85d357abd | |||
| 31396d8109 | |||
| 664debc0bc | |||
| 56c31c76b3 | |||
| 2d1a71f3d1 | |||
| c67cd73058 | |||
| 2572cb4352 | |||
| c9f6c7ccac | |||
| b6520b0dbc | |||
| 9fe8142117 | |||
| 57cdd25b5f | |||
| 1005ee5d52 | |||
| d9bb9f9a07 | |||
| 75a6fabe61 | |||
| 75e29b69cc | |||
| ffd3937538 | |||
| 575587e4c5 | |||
| 2ce59d7c92 | |||
| 3b3d6e2a73 | |||
| f2a467fdbf | |||
| 867481c66d | |||
| fc8065b7a8 | |||
| 28f1ed9205 | |||
| 568266bc9a | |||
| 7739f341b8 | |||
| 2b501f191c | |||
| aa2ed0fda1 | |||
| 51cba074d7 | |||
| 2c945c663c | |||
| d9a18ecabb | |||
| eab7931afb | |||
| 99b6c92377 | |||
| 4d56f1163b | |||
| 07cf835551 | |||
| 061c0c1a90 | |||
| 1ed18c05ef | |||
| ca134c8a60 | |||
| 7ba67fd852 | |||
| 7675c2c411 | |||
| 7c71363af8 | |||
| d48926eb2a | |||
| d843e5e06f | |||
| a2e564738e | |||
| 54a797a0b1 | |||
| a78c1ecb4d | |||
| 4771c78001 | |||
| b7a5106f36 | |||
| 4bcbdc50ff | |||
| 5d72b35c7f | |||
| ff50ac215c | |||
| 3fe3809416 | |||
| 363d337a72 | |||
| f89b31c940 | |||
| 630c3e84dd | |||
| d43202f137 | |||
| 0964043489 | |||
| e9b31a545d | |||
| 7f28a88374 | |||
| 07ea45bf84 | |||
| a86d35f721 | |||
| d6c28a0ff6 | |||
| a7af3ca7bc | |||
| cda08a9843 | |||
| 7588774237 | |||
| 683373b263 | |||
| 673ec8fb5d | |||
| 63bacaa671 | |||
| e3a3d6a505 | |||
| 175171dda1 | |||
| a4b63b88c8 | |||
| c3675fdfe7 | |||
| 2fa08519b6 | |||
| 065627060b | |||
| 2982b6a808 | |||
| c3f56ecd40 | |||
| fe9e6c1afd | |||
| 73394ae929 | |||
| c754fc9ff4 | |||
| 1ca6208057 | |||
| cdfae4d4df | |||
| c3da5469ca | |||
| 79f2d8ba76 | |||
| 7b1bdb92a6 | |||
| f9a075eed1 | |||
| 32e4193557 | |||
| 133c3e7c0a | |||
| 6ce3cea682 | |||
| 250e77d18b | |||
| e3bfe006e9 | |||
| 76aeecdc68 | |||
| 15d2ee3e76 | |||
| b066db479f | |||
| a69ff2789a | |||
| 4df235d64a | |||
| 11524457e3 | |||
| 8f7364293e | |||
| 56f5fa1fbe | |||
| b075f0f7e9 | |||
| 0ca32afe8d | |||
| 32c2f257a9 | |||
| a7066ccbe1 | |||
| 09248e0efe | |||
| e2ef22fc5f | |||
| 75f825e4f8 | |||
| 9b8d5198e4 | |||
| e09f747727 | |||
| 16e6171750 | |||
| f885e887ea | |||
| 5096f7fbc5 | |||
| f16560c1ee | |||
| d0f4860759 | |||
| 34d36c6de1 | |||
| 042b850b5d | |||
| 73892f4fc2 | |||
| 636b868bf1 | |||
| b144368e3f | |||
| 683b4193d1 | |||
| f4c953665a | |||
| 0f990c772a | |||
| 0bf985af61 | |||
| 42473611d3 | |||
| 2c437f7d94 | |||
| aa8c49eb0c | |||
| b32e40c72a | |||
| 2ff7ed1b52 | |||
| bfbed1a69b | |||
| ef9d012be9 | |||
| f12a2155c0 | |||
| 43f696c4a8 | |||
| 4c19014dcd | |||
| 0e7f9d6778 | |||
| 00a0bf3c1b | |||
| e9149036f6 | |||
| b6a9967959 | |||
| 8bbc3caf3e | |||
| 237b703a6c | |||
| cf3045a718 | |||
| a679e7283f | |||
| 13c75341e2 | |||
| 4cbcc5bb20 | |||
| 51955dae3b | |||
| 51fa3ab2c8 | |||
| af9e66e87e | |||
| 8878dc7f80 | |||
| 4336bc20e0 | |||
| 4a7613a521 | |||
| 689f55340f | |||
| 160ae3d49d | |||
| c079c3c6d9 | |||
| cb782a7557 | |||
| 502d8fc087 | |||
| 6295881138 | |||
| 17f71ba680 | |||
| 5fe7adb87b | |||
| 41684d091b | |||
| 9f6c3347cb | |||
| aa321989c4 | |||
| 0f1565cb4b | |||
| 3996110ebd | |||
| 3422ad4c84 | |||
| 3c07e65b9a | |||
| 287df91661 | |||
| 776bc3135a | |||
| 4638958c9f | |||
| 1f24fe46c4 | |||
| ccbcab8822 | |||
| 094e9cdac2 | |||
| 5c35f36b3a | |||
| d7ec2b57a4 | |||
| 373a1e0312 | |||
| e9fe3975bb | |||
| 32a73b55d4 | |||
| 0426312d6c | |||
| ede2335fb3 | |||
| 709f1a1ccb | |||
| 24b785cbfb | |||
| acc3f2f418 | |||
| d4a653d6a9 | |||
| 7e74d25447 | |||
| 3445db41ce | |||
| d1bd02a6f5 | |||
| 24cd72e088 | |||
| 47f7558c38 | |||
| de747b7b33 | |||
| 8e8b73fbd9 | |||
| e966e33e8f | |||
| bd0ca5babd | |||
| f6bacea1ae |
@@ -4,7 +4,7 @@
|
|||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
"access": "restricted",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": []
|
"ignore": []
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals",
|
|
||||||
"plugins": ["import"],
|
|
||||||
"rules": {
|
|
||||||
"import/order": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"groups": ["builtin", "external", ["internal", "parent", "sibling", "index"]],
|
|
||||||
"newlines-between": "always",
|
|
||||||
"distinctGroup": true,
|
|
||||||
"pathGroups": [
|
|
||||||
{
|
|
||||||
"pattern": "@/**",
|
|
||||||
"group": "external",
|
|
||||||
"position": "after"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"pattern": "@gitbook/**",
|
|
||||||
"group": "external",
|
|
||||||
"position": "after"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"alphabetize": {
|
|
||||||
"order": "asc",
|
|
||||||
"caseInsensitive": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@next/next/no-img-element": ["off"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-7
@@ -1,6 +1,8 @@
|
|||||||
# Welcome to GitBook's contributing guide!
|
# Welcome to GitBook's contributing guide!
|
||||||
|
|
||||||
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team.In this guide, you'll learn the different ways you can contribute.
|
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
|
||||||
|
|
||||||
|
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
|
||||||
|
|
||||||
## Types of Contributions
|
## Types of Contributions
|
||||||
|
|
||||||
@@ -45,13 +47,25 @@ Any contribution you make can be made to the code located in this repository. In
|
|||||||
|
|
||||||
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
|
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
|
||||||
|
|
||||||
##### GitHub Codespaces:
|
|
||||||
|
|
||||||
- [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally.
|
|
||||||
|
|
||||||
#### 2. Create a working branch and start with your changes
|
#### 2. Create a working branch and start with your changes
|
||||||
|
|
||||||
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. After creating the branch, you can start making changes!
|
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
|
||||||
|
|
||||||
|
#### 3. Install dependencies and run the project locally
|
||||||
|
|
||||||
|
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
|
||||||
|
|
||||||
|
You can easily do this by running the command `nvm use`.
|
||||||
|
|
||||||
|
To start your local version of GitBook, run the command `bun dev`.
|
||||||
|
|
||||||
|
#### 4. Preview your changes
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Commit your update
|
### Commit your update
|
||||||
|
|
||||||
@@ -71,4 +85,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
|
|||||||
|
|
||||||
### Your PR is merged
|
### Your PR is merged
|
||||||
|
|
||||||
Congratulations 🎉Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ runs:
|
|||||||
- name: 🏗 Prepare Playwright env
|
- name: 🏗 Prepare Playwright env
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version')
|
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
||||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
# Cache browser binaries, cache key is based on Playwright version and OS
|
||||||
- name: 🧰 Cache Playwright browser binaries
|
- name: 🧰 Cache Playwright browser binaries
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
with:
|
with:
|
||||||
path: '~/.cache/ms-playwright'
|
path: '~/.cache/ms-playwright'
|
||||||
@@ -24,6 +24,7 @@ runs:
|
|||||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
working-directory: packages/gitbook
|
||||||
run: |
|
run: |
|
||||||
bun x playwright install --with-deps chromium
|
bun x playwright install --with-deps chromium
|
||||||
|
|
||||||
@@ -31,5 +32,6 @@ runs:
|
|||||||
- name: 🏗 Install Playwright OS dependencies
|
- name: 🏗 Install Playwright OS dependencies
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
working-directory: packages/gitbook
|
||||||
run: |
|
run: |
|
||||||
bun x playwright install-deps
|
bun x playwright install-deps
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: 'Deploy vercel'
|
||||||
|
description: 'Deploy GitBook to Vercel'
|
||||||
|
inputs:
|
||||||
|
vercel-org:
|
||||||
|
description: 'Vercel organization'
|
||||||
|
required: true
|
||||||
|
vercel-project:
|
||||||
|
description: 'Vercel project'
|
||||||
|
required: true
|
||||||
|
vercel-token:
|
||||||
|
description: 'Vercel token'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Environment to deploy to'
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
deployment-url:
|
||||||
|
description: "Deployment URL"
|
||||||
|
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Sets env vars for environment
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "VERCEL_ENVIRONMENT=${{ inputs.environment }}" >> $GITHUB_ENV
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: bun run vercel build --target=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
id: deploy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }})
|
||||||
|
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
|
||||||
|
- name: Outputs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: 'Setup Bun'
|
||||||
|
description: 'Install Bun and cache dependencies'
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
|
||||||
|
# and impacting bandwidth there.
|
||||||
|
- name: Cache bun
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
- name: Setup bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version-file: 'package.json'
|
||||||
+271
-67
@@ -4,7 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Cloudflare Pages
|
name: Deploy to Cloudflare Pages
|
||||||
@@ -12,29 +13,22 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
deployments: write
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
outputs:
|
outputs:
|
||||||
deployment_url: ${{ steps.cloudflare.outputs.url }}
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Cache Next.js build
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/.next/cache
|
|
||||||
# Generate a new cache whenever packages or source files change.
|
|
||||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
|
||||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
|
|
||||||
- name: Sets env vars for production
|
- name: Sets env vars for production
|
||||||
run: |
|
run: |
|
||||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||||
@@ -45,44 +39,197 @@ jobs:
|
|||||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||||
- name: Build Next.js with next-on-pages
|
- name: Build Next.js with next-on-pages
|
||||||
run: bun run build:cloudflare
|
run: bun run turbo gitbook#build:cloudflare
|
||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
- id: cloudflare
|
SENTRY_RELEASE: ${{ github.sha }}
|
||||||
name: Publish to Cloudflare Pages
|
- id: deploy
|
||||||
uses: cloudflare/pages-action@v1
|
name: Deploy to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
|
|
||||||
directory: ./.vercel/output/static
|
|
||||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '3.82.0'
|
||||||
|
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "ID: ${{ steps.cloudflare.outputs.id }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
echo "URL: ${{ steps.cloudflare.outputs.url }}"
|
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
|
||||||
echo "Environment: ${{ steps.cloudflare.outputs.environment }}"
|
|
||||||
echo "Alias: ${{ steps.cloudflare.outputs.alias }}"
|
|
||||||
- name: Archive build output
|
- name: Archive build output
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: build-output
|
name: build-output
|
||||||
path: .vercel/
|
path: .vercel/
|
||||||
|
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
|
||||||
|
- name: Update Deployment Status to Success
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||||
|
deploy-v2-vercel:
|
||||||
|
name: Deploy v2 to Vercel
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-vercel
|
||||||
|
with:
|
||||||
|
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
|
||||||
|
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
deploy-v2-vercel-staging:
|
||||||
|
name: Deploy v2 to Vercel (staging)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
|
if: startsWith(github.ref, 'refs/heads/main')
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy staging
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-vercel
|
||||||
|
with:
|
||||||
|
environment: staging
|
||||||
|
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
deploy-v2-cloudflare:
|
||||||
|
name: Deploy v2 to Cloudflare Worker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
statuses: write
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Sets env vars for production
|
||||||
|
run: |
|
||||||
|
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||||
|
echo "GITBOOK_URL=https://open-2c.gitbook.com" >> $GITHUB_ENV
|
||||||
|
echo "GITBOOK_ASSETS_PREFIX=https://static-2c.gitbook.com" >> $GITHUB_ENV
|
||||||
|
if: startsWith(github.ref, 'refs/heads/main')
|
||||||
|
- name: Sets env vars for preview
|
||||||
|
run: |
|
||||||
|
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||||
|
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||||
|
- name: Build worker
|
||||||
|
run: bun run turbo build:v2:cloudflare
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||||
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
SENTRY_RELEASE: ${{ github.sha }}
|
||||||
|
- id: deploy
|
||||||
|
name: Deploy to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '3.82.0'
|
||||||
|
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
|
||||||
|
- name: Outputs
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
|
comment-deployments:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Comment Deployments
|
||||||
|
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
||||||
|
needs:
|
||||||
|
- deploy
|
||||||
|
- deploy-v2-vercel
|
||||||
|
- deploy-v2-cloudflare
|
||||||
|
steps:
|
||||||
|
- name: Find GitHub Comment
|
||||||
|
uses: peter-evans/find-comment@v3
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-author: 'github-actions[bot]'
|
||||||
|
body-includes: 'Summary of the deployments'
|
||||||
|
|
||||||
|
- name: Create or update GitHub comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v4
|
||||||
|
with:
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
body: |
|
||||||
|
Summary of the deployments:
|
||||||
|
|
||||||
|
### Version 1 (production)
|
||||||
|
|
||||||
|
| Version | URL | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
|
||||||
|
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
|
||||||
|
|
||||||
|
### Version 2 (experimental)
|
||||||
|
|
||||||
|
| Version | URL | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||||
|
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||||
|
|
||||||
|
### Test content
|
||||||
|
|
||||||
|
| Site | v1 | v2 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.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) |
|
||||||
|
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.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) |
|
||||||
|
edit-mode: replace
|
||||||
visual-testing:
|
visual-testing:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing
|
name: Visual Testing
|
||||||
needs: deploy
|
needs: deploy
|
||||||
|
timeout-minutes: 6
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Setup Playwright
|
- name: Setup Playwright
|
||||||
@@ -90,8 +237,68 @@ jobs:
|
|||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: bun e2e
|
run: bun e2e
|
||||||
env:
|
env:
|
||||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
visual-testing-v2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing v2
|
||||||
|
needs: deploy-v2-vercel
|
||||||
|
timeout-minutes: 6
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Setup Playwright
|
||||||
|
uses: ./.github/actions/setup-playwright
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'v2'
|
||||||
|
visual-testing-customers:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing Customers
|
||||||
|
needs: deploy
|
||||||
|
timeout-minutes: 6
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Setup Playwright
|
||||||
|
uses: ./.github/actions/setup-playwright
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e-customers
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'customers'
|
||||||
|
visual-testing-customers-v2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing Customers v2
|
||||||
|
needs: deploy-v2-vercel
|
||||||
|
timeout-minutes: 6
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Setup Playwright
|
||||||
|
uses: ./.github/actions/setup-playwright
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e-customers
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
pagespeed-testing:
|
pagespeed-testing:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: PageSpeed Testing
|
name: PageSpeed Testing
|
||||||
@@ -99,74 +306,71 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Run pagespeed tests
|
- name: Run pagespeed tests
|
||||||
run: bun ./tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||||
env:
|
env:
|
||||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
|
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
|
||||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Format
|
name: Format
|
||||||
|
timeout-minutes: 6
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- run: bun format:check
|
- run: bun format:check
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Lint
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
env:
|
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
|
||||||
- run: bun lint --no-cache
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Test
|
name: Test
|
||||||
|
timeout-minutes: 6
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- run: bun unit
|
- run: bun unit
|
||||||
typecheck:
|
build-oss:
|
||||||
|
# CI to check that the repository builds correctly on a machine without the credentials
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Typecheck
|
name: Build (Open Source)
|
||||||
|
timeout-minutes: 6
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ''
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
- name: Install dependencies
|
||||||
bun-version: 1.1.18
|
run: bun install
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- run: bun run build
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Typecheck
|
||||||
|
timeout-minutes: 6
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -30,6 +28,30 @@ jobs:
|
|||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
publish: npm run release
|
publish: npm run release
|
||||||
|
version: npm run changeset-version
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||||
|
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
release-preview:
|
||||||
|
# For now it releases the cache-do to both preview and production
|
||||||
|
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
|
||||||
|
name: Release Preview
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Release preview packages
|
||||||
|
run: bun run release:preview
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
+7
-28
@@ -5,16 +5,6 @@ node_modules
|
|||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -24,25 +14,14 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# Turbo
|
||||||
.env*.local
|
.turbo
|
||||||
|
|
||||||
# vercel
|
# Vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# Env files
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# visual tests
|
|
||||||
screenshots/
|
|
||||||
|
|
||||||
# Sentry Config File
|
|
||||||
.sentryclirc
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
|
|
||||||
# Generated public files
|
|
||||||
/public/~gitbook/static/
|
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
.next
|
.next
|
||||||
|
.vercel
|
||||||
|
.open-next
|
||||||
|
|
||||||
# Generated
|
# Generated
|
||||||
packages/emoji-codepoints/index.ts
|
packages/emoji-codepoints/index.ts
|
||||||
|
packages/gitbook/public/~gitbook/static/
|
||||||
|
packages/icons/src/data/*.json
|
||||||
|
|
||||||
|
# Build files
|
||||||
|
dist/
|
||||||
|
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
|
# v2
|
||||||
|
packages/gitbook-v2/public/~gitbook/static/
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["biomejs.biome"]
|
||||||
|
}
|
||||||
Vendored
+6
-1
@@ -7,5 +7,10 @@
|
|||||||
["style \\=([^;]*);", "\"([^\"]*)\""],
|
["style \\=([^;]*);", "\"([^\"]*)\""],
|
||||||
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
|
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
|
||||||
],
|
],
|
||||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"]
|
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||||
|
"prettier.enable": false,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjUiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCA2NSA2NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI3LjM5NjQgMzMuNjc2NEMzMC41MjU1IDM1LjQ4MjQgMzIuMDkgMzYuMzg1NCAzMy44MDgzIDM2LjM4NjlDMzUuNTI2NSAzNi4zODg0IDM3LjA5MjYgMzUuNDg4MiA0MC4yMjQ5IDMzLjY4NzdMNjAuMTkxNCAyMi4yMTA0QzYxLjA5MjcgMjEuNjkyMiA2MS42NDg0IDIwLjczMTggNjEuNjQ4NCAxOS42OTIxQzYxLjY0ODQgMTguNjUyNCA2MS4wOTI3IDE3LjY5MiA2MC4xOTE0IDE3LjE3MzlMNDAuMjE3NyA1LjY5MjQ1QzM3LjA4ODggMy44OTM4NiAzNS41MjQzIDIuOTk0NTcgMzMuODA3OCAyLjk5NTI0QzMyLjA5MTIgMi45OTU5MSAzMC41Mjc1IDMuODk2NDIgMjcuNCA1LjY5NzQ0TDEwLjIyOTMgMTUuNTg1NUMxMC4xMDIgMTUuNjU4OCAxMC4wMzg0IDE1LjY5NTQgOS45NzkwOCAxNS43MzAxQzQuMTEzNzEgMTkuMTYzNyAwLjQ4OTg5MiAyNS40MzIzIDAuNDQxNDM4IDMyLjIyODZDMC40NDA5NDggMzIuMjk3MyAwLjQ0MDk0OCAzMi4zNzA4IDAuNDQwOTQ4IDMyLjUxNzZDMC40NDA5NDggMzIuNjY0MyAwLjQ0MDk0OCAzMi43Mzc2IDAuNDQxNDM3IDMyLjgwNjNDMC40ODk3ODUgMzkuNTk0OSA0LjEwNTUyIDQ1Ljg1NzcgOS45NjA0NCA0OS4yOTRDMTAuMDE5NiA0OS4zMjg3IDEwLjA4MzIgNDkuMzY1NCAxMC4yMTAyIDQ5LjQzODdMMjAuOTY1OSA1NS42NDg3QzI3LjIzMzIgNTkuMjY3MyAzMC4zNjY4IDYxLjA3NjYgMzMuODA4MSA2MS4wNzc3QzM3LjI0OTMgNjEuMDc4OSA0MC4zODQyIDU5LjI3MTcgNDYuNjUzOSA1NS42NTc0TDU4LjAwOCA0OS4xMTIxQzYxLjE0NzQgNDcuMzAyMyA2Mi43MTcxIDQ2LjM5NzQgNjMuNTc5IDQ0LjkwNTZDNjQuNDQwOSA0My40MTM5IDY0LjQ0MDkgNDEuNjAyIDY0LjQ0MDkgMzcuOTc4NFYzMC45NzgxQzY0LjQ0MDkgMjkuOTcyOCA2My44OTY1IDI5LjA0NjQgNjMuMDE4MiAyOC41NTczQzYyLjE2ODQgMjguMDgzOSA2MS4xMzI1IDI4LjA5MSA2MC4yODkxIDI4LjU3NThMMzcuMDA3NCA0MS45NTg4QzM1LjQ0NTQgNDIuODU2NyAzNC42NjQzIDQzLjMwNTYgMzMuODA3MyA0My4zMDU5QzMyLjk1MDIgNDMuMzA2MiAzMi4xNjg5IDQyLjg1NzcgMzAuNjA2MyA0MS45NjA3TDE0Ljg0ODcgMzIuOTE1NUMxNC4wNTk0IDMyLjQ2MjQgMTMuNjY0NyAzMi4yMzU5IDEzLjM0NzcgMzIuMTk1QzEyLjYyNSAzMi4xMDE3IDExLjkzMDEgMzIuNTA2NiAxMS42NTQ4IDMzLjE4MTNDMTEuNTM0MSAzMy40NzcyIDExLjUzNjUgMzMuOTMyMiAxMS41NDE0IDM0Ljg0MjRDMTEuNTQ1IDM1LjUxMjQgMTEuNTQ2OCAzNS44NDc0IDExLjYwOTQgMzYuMTU1NkMxMS43NDk3IDM2Ljg0NTYgMTIuMTEyNyAzNy40NzA1IDEyLjY0MjggMzcuOTM0MUMxMi44Nzk1IDM4LjE0MTEgMTMuMTY5NiAzOC4zMDg1IDEzLjc1IDM4LjY0MzVMMzAuNTk3NCA0OC4zNjcyQzMyLjE2NDEgNDkuMjcxNCAzMi45NDc0IDQ5LjcyMzUgMzMuODA3NSA0OS43MjM3QzM0LjY2NzcgNDkuNzIzOSAzNS40NTEzIDQ5LjI3MjMgMzcuMDE4NCA0OC4zNjg5TDU3LjY2ODQgMzYuNDY1NEM1OC4yMDM3IDM2LjE1NjkgNTguNDcxNCAzNi4wMDI2IDU4LjY3MjEgMzYuMTE4NUM1OC44NzI3IDM2LjIzNDUgNTguODcyNyAzNi41NDM0IDU4Ljg3MjcgMzcuMTYxM1Y0MC4zMzY1QzU4Ljg3MjcgNDEuMjQyNCA1OC44NzI3IDQxLjY5NTQgNTguNjU3MiA0Mi4wNjgzQzU4LjQ0MTggNDIuNDQxMyA1OC4wNDkzIDQyLjY2NzUgNTcuMjY0NCA0My4xMTk5TDQwLjIzMjIgNTIuOTM4QzM3LjA5NjYgNTQuNzQ1NCAzNS41Mjg4IDU1LjY0OTIgMzMuODA3OSA1NS42NDg0QzMyLjA4NjkgNTUuNjQ3NiAzMC41MTk5IDU0Ljc0MjQgMjcuMzg2IDUyLjkzMjFMMTEuNDUwOSA0My43MjdDMTEuNDAwMyA0My42OTc4IDExLjM3NSA0My42ODMyIDExLjM1MTQgNDMuNjY5NEM4LjAxMDIzIDQxLjcxNyA1Ljk0ODU5IDM4LjE0NTEgNS45MjkyNSAzNC4yNzU0QzUuOTI5MTIgMzQuMjQ4IDUuOTI5MTIgMzQuMjE4OCA1LjkyOTEyIDM0LjE2MDRWMzEuMjQ1OEM1LjkyOTEyIDI5LjEwOTUgNy4wNjY4OSAyNy4xMzQ5IDguOTE1MTMgMjYuMDYzNkMxMC41NDgzIDI1LjExNjkgMTIuNTYyOCAyNS4xMTUxIDE0LjE5NzcgMjYuMDU4N0wyNy4zOTY0IDMzLjY3NjRaIiBmaWxsPSIjRjJGN0Y3Ii8+Cjwvc3ZnPgo=&labelColor=5c5c5c&color=3F89A1&label=%20" alt="Documented on GitBook"></a>
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
|
||||||
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
|
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
|
||||||
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
|
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
|
||||||
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
|
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
|
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
|
||||||
|
|
||||||
<p align="center">This repository contains the open-source code used to render GitBook's published content.</p>
|
<p align="center">This repository contains the open source code used to render GitBook's published content.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
|
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
|
||||||
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (Version: >=18.x)
|
- Node.js (Version: >=20.6)
|
||||||
- Bun
|
- Use nvm for easy Node management
|
||||||
|
- Bun (Version: >=1.2.1)
|
||||||
|
- We use a text-based lockfile which isn't supported below 1.2.1
|
||||||
|
|
||||||
### Set up
|
### Set up
|
||||||
|
|
||||||
@@ -46,19 +48,28 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
git clone https://github.com/gitbookIO/gitbook.git
|
git clone https://github.com/gitbookIO/gitbook.git
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install the project's dependencies through Bun.
|
2. Ensure you are using the project's version of `node`. Running `nvm use` will change your local version to the correct one.
|
||||||
|
|
||||||
|
3. Install the project's dependencies through Bun.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start your local development server.
|
4. Start your local development server.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the space in your web browser, using http://localhost:3000/<host>/<path> (example: http://localhost:3000/docs.gitbook.com).
|
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
|
||||||
|
- http://localhost:3000/docs.gitbook.com
|
||||||
|
- http://localhost:3000/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.
|
||||||
|
|
||||||
### Other development commands
|
### Other development commands
|
||||||
|
|
||||||
@@ -69,9 +80,29 @@ Then open the space in your web browser, using http://localhost:3000/<host>/<pat
|
|||||||
|
|
||||||
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
||||||
|
|
||||||
|
## Fonts and Icons
|
||||||
|
|
||||||
|
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
|
||||||
|
|
||||||
|
```
|
||||||
|
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
|
||||||
|
```
|
||||||
|
|
||||||
|
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
|
||||||
|
|
||||||
|
If you are GitBook staff, you'll need our NPM token in your local environment.
|
||||||
|
|
||||||
|
```
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
NPM_TOKEN_READONLY=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
and then reinstall dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
GitBook's rendering engine is fully open-source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||||
|
|
||||||
### Types of contributions
|
### Types of contributions
|
||||||
|
|
||||||
@@ -79,7 +110,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
|
|||||||
|
|
||||||
#### Translations
|
#### Translations
|
||||||
|
|
||||||
The GitBook UI is rendered using a set of translation files found in [`src/intl/translations`](/src/intl/translations/). We welcome all additional translations for the UI.
|
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
|
||||||
|
|
||||||
#### Bugs
|
#### Bugs
|
||||||
|
|
||||||
@@ -90,7 +121,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
|
|||||||
> [!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.
|
> 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](/.github/CONTRIBUTING.md) to get started._
|
> _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._
|
||||||
>
|
>
|
||||||
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
|
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
|
||||||
>
|
>
|
||||||
@@ -106,10 +137,30 @@ On the con side, you become responsible for the reliability of your published si
|
|||||||
|
|
||||||
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
|
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
|
||||||
|
|
||||||
If you plan to distribute the code, you must the source code public to comply with GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
|
If you plan to distribute the code, you must make the source code public to comply with the GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
|
||||||
|
|
||||||
See `LICENSE` for more information.
|
See `LICENSE` for more information.
|
||||||
|
|
||||||
|
## Badges
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
|
||||||
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=F4E28D"></a>
|
||||||
|
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=FDA599"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
```md
|
||||||
|
[](https://gitbook.com/)
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="https://gitbook.com">
|
||||||
|
<img
|
||||||
|
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
GitBook wouldn't be possible without these projects:
|
GitBook wouldn't be possible without these projects:
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"ignore": [
|
||||||
|
"**/node_modules/**/*",
|
||||||
|
"**/dist/**/*",
|
||||||
|
"**/build/**/*",
|
||||||
|
"**/public/**/*",
|
||||||
|
"**/.next/**/*",
|
||||||
|
"**/.open-next/**/*",
|
||||||
|
"**/.turbo/**/*",
|
||||||
|
"**/.vercel/**/*",
|
||||||
|
"**/.cache/**/*",
|
||||||
|
"**/.wrangler/**/*",
|
||||||
|
"packages/openapi-parser/src/fixtures/**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"useEditorconfig": true,
|
||||||
|
"formatWithErrors": false,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 100,
|
||||||
|
"attributePosition": "auto",
|
||||||
|
"bracketSpacing": true
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"performance": {
|
||||||
|
"noDelete": "warn"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off",
|
||||||
|
"noUselessFragments": "warn",
|
||||||
|
"noBannedTypes": "warn"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUndeclaredVariables": "error",
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"useArrayLiterals": "error",
|
||||||
|
"useHookAtTopLevel": "error",
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"noVoidElementsWithChildren": "warn",
|
||||||
|
"useJsxKeyInIterable": "warn",
|
||||||
|
"useExhaustiveDependencies": "warn",
|
||||||
|
"noUnknownFunction": "warn"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "warn",
|
||||||
|
"noParameterAssign": "off",
|
||||||
|
"useThrowOnlyError": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": "warn",
|
||||||
|
"noExplicitAny": "warn",
|
||||||
|
"noImplicitAnyLet": "warn",
|
||||||
|
"noConfusingVoidType": "warn",
|
||||||
|
"noControlCharactersInRegex": "warn",
|
||||||
|
"noPrototypeBuiltins": "warn",
|
||||||
|
"noAssignInExpressions": "warn",
|
||||||
|
"noArrayIndexKey": "warn"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useSemanticElements": "warn",
|
||||||
|
"useKeyWithClickEvents": "warn",
|
||||||
|
"noSvgWithoutTitle": "warn",
|
||||||
|
"useButtonType": "warn",
|
||||||
|
"useIframeTitle": "warn",
|
||||||
|
"useAltText": "warn",
|
||||||
|
"noPositiveTabindex": "warn",
|
||||||
|
"useFocusableInteractive": "warn",
|
||||||
|
"useAriaPropsForRole": "warn",
|
||||||
|
"useValidAnchor": "warn",
|
||||||
|
"noLabelWithoutControl": "warn",
|
||||||
|
"noNoninteractiveTabindex": "warn"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"useSortedClasses": {
|
||||||
|
"level": "error",
|
||||||
|
"fix": "safe",
|
||||||
|
"options": {
|
||||||
|
"attributes": ["class", "className", "style"],
|
||||||
|
"functions": ["clsx", "tw"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"trailingCommas": "es5",
|
||||||
|
"semicolons": "always",
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"attributePosition": "auto",
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"packages/gitbook/**/*",
|
||||||
|
"packages/gitbook-v2/**/*",
|
||||||
|
"packages/react-openapi/**/*",
|
||||||
|
"packages/react-math/**/*",
|
||||||
|
"packages/react-contentkit/**/*",
|
||||||
|
"packages/icons/**/*"
|
||||||
|
],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["React"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["packages/gitbook/**/*"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["React", "GitBookIntegrationEvent"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["*.css"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["theme"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["Bun"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"packages/cache-do/**/*",
|
||||||
|
"packages/gitbook/cf-env.d.ts",
|
||||||
|
"packages/gitbook/src/cloudflare-entrypoint.ts"
|
||||||
|
],
|
||||||
|
"javascript": {
|
||||||
|
"globals": [
|
||||||
|
"DurableObjectLocationHint",
|
||||||
|
"DurableObjectNamespace",
|
||||||
|
"DurableObjectStub",
|
||||||
|
"ContinentCode",
|
||||||
|
"Fetcher",
|
||||||
|
"ExportedHandler"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[install.scopes]
|
||||||
|
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Caching
|
|
||||||
|
|
||||||
## Revalidating the cache
|
|
||||||
|
|
||||||
Invalidate cache can be done at two levels using tags:
|
|
||||||
|
|
||||||
- Data fetching cache
|
|
||||||
- Rendering cache
|
|
||||||
|
|
||||||
To invalidate and refetch the data cache, you can execute a POST request to `/~/gitbook/revalidate`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --location --request POST 'https://gitbook/mycompany.com/~gitbook/revalidate' \
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data-raw '{"tags": ["space.id"]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
To invalidate the rendering cache, the implementation mainly depends on the infrastructure serving the content, GitBook outputs a `Cache-Tag` header on every requests. The value of the header is a comma separated list of tags.
|
|
||||||
|
|
||||||
## Purging the cache
|
|
||||||
|
|
||||||
Purging the cache, without revalidating, is done by passing `"purge": true` in the request body.
|
|
||||||
@@ -1,668 +0,0 @@
|
|||||||
import { argosScreenshot } from '@argos-ci/playwright';
|
|
||||||
import {
|
|
||||||
CustomizationHeaderPreset,
|
|
||||||
CustomizationLocale,
|
|
||||||
CustomizationSettings,
|
|
||||||
} from '@gitbook/api';
|
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import rison from 'rison';
|
|
||||||
|
|
||||||
import { getContentTestURL } from '../tests/utils';
|
|
||||||
|
|
||||||
interface Test {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
run?: (page: Page) => Promise<unknown>;
|
|
||||||
fullPage?: boolean;
|
|
||||||
screenshot?: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestsCase {
|
|
||||||
name: string;
|
|
||||||
baseUrl: string;
|
|
||||||
tests: Array<Test>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allLocales: CustomizationLocale[] = [
|
|
||||||
CustomizationLocale.Fr,
|
|
||||||
CustomizationLocale.Es,
|
|
||||||
CustomizationLocale.Ja,
|
|
||||||
CustomizationLocale.Zh,
|
|
||||||
];
|
|
||||||
|
|
||||||
async function waitForCookiesDialog(page: Page) {
|
|
||||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
|
||||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
|
||||||
const reject = dialog.getByRole('button', { name: 'Reject' });
|
|
||||||
await expect(accept).toBeVisible();
|
|
||||||
await expect(reject).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
const testCases: TestsCase[] = [
|
|
||||||
{
|
|
||||||
name: 'GitBook Site (Single Variant)',
|
|
||||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Home',
|
|
||||||
url: '',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'No variants dropdown',
|
|
||||||
url: '',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search',
|
|
||||||
url: '?q=',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search Results',
|
|
||||||
url: '?q=gitbook',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-results"]');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI Search',
|
|
||||||
url: '?q=What+is+GitBook%3F&ask=true',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-ask-answer"]');
|
|
||||||
},
|
|
||||||
screenshot: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Not found',
|
|
||||||
url: 'content-not-found',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitBook Site (Multi Variants)',
|
|
||||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Variants dropdown',
|
|
||||||
url: '',
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
|
|
||||||
await spaceDrowpdown.waitFor();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Default variant',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'RFC variant',
|
|
||||||
url: 'v/rfcs',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitBook Site (Navigation when switching variant)',
|
|
||||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Keep navigation path/route when switching variant (Public)',
|
|
||||||
url: 'api-multi-versions/reference/api-reference/pets',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = await page.waitForSelector(
|
|
||||||
'[data-testid="space-dropdown-button"]',
|
|
||||||
);
|
|
||||||
await spaceDrowpdown.click();
|
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
|
||||||
await page
|
|
||||||
.getByRole('link', {
|
|
||||||
name: '2.0',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
|
||||||
await page.waitForURL(
|
|
||||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/v/2.0/reference/api-reference/pets?fallback=true',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Keep navigation path/route when switching variant (Share link)',
|
|
||||||
url: 'api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/reference/api-reference/pets',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = await page.waitForSelector(
|
|
||||||
'[data-testid="space-dropdown-button"]',
|
|
||||||
);
|
|
||||||
await spaceDrowpdown.click();
|
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
|
||||||
await page
|
|
||||||
.getByRole('link', {
|
|
||||||
name: '2.0',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
|
||||||
await page.waitForURL(
|
|
||||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/v/2.0/reference/api-reference/pets?fallback=true',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Keep navigation path/route when switching variant (VA)',
|
|
||||||
screenshot: false,
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'c26190fc-74b2-4b54-9fc7-df9941104953';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: async (page) => {
|
|
||||||
const spaceDrowpdown = await page.waitForSelector(
|
|
||||||
'[data-testid="space-dropdown-button"]',
|
|
||||||
);
|
|
||||||
await spaceDrowpdown.click();
|
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
|
||||||
await page
|
|
||||||
.getByRole('link', {
|
|
||||||
name: '2.0',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
|
||||||
await page.waitForURL(
|
|
||||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/v/2.0/reference/api-reference/pets?fallback=true',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GitBook',
|
|
||||||
baseUrl: 'https://docs.gitbook.com',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Home',
|
|
||||||
url: '',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search',
|
|
||||||
url: '?q=',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Search Results',
|
|
||||||
url: '?q=gitbook',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-results"]');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI Search',
|
|
||||||
url: '?q=What+is+GitBook%3F&ask=true',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-test="search-ask-answer"]');
|
|
||||||
},
|
|
||||||
screenshot: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Not found',
|
|
||||||
url: 'content-not-found',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Versioning',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Revision',
|
|
||||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'PDF',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'PDF',
|
|
||||||
url: '~gitbook/pdf?limit=10',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Content tests',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Text',
|
|
||||||
url: 'text-page',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Long text',
|
|
||||||
url: 'text-page/long-text',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Images',
|
|
||||||
url: 'blocks/block-images',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inline Images',
|
|
||||||
url: 'blocks/inline-images',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tabs',
|
|
||||||
url: 'blocks/tabs',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Hints',
|
|
||||||
url: 'blocks/hints',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Integration Blocks',
|
|
||||||
url: 'blocks/integrations',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tables',
|
|
||||||
url: 'blocks/tables',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Expandables',
|
|
||||||
url: 'blocks/expandables',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'API Blocks',
|
|
||||||
url: 'blocks/api-blocks',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Headings',
|
|
||||||
url: 'blocks/headings',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Marks',
|
|
||||||
url: 'blocks/marks',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Emojis',
|
|
||||||
url: 'blocks/emojis',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Links',
|
|
||||||
url: 'blocks/links',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Lists',
|
|
||||||
url: 'blocks/lists',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Code',
|
|
||||||
url: 'blocks/code',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cards',
|
|
||||||
url: 'blocks/cards',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Math',
|
|
||||||
url: 'blocks/math',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Embeds',
|
|
||||||
url: 'blocks/embeds',
|
|
||||||
fullPage: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Annotations',
|
|
||||||
url: 'blocks/annotations',
|
|
||||||
run: async (page) => {
|
|
||||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
|
||||||
await page.click('[data-testid="annotation-button"]');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Page options',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Hidden',
|
|
||||||
url: 'page-options/page-hidden',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'With cover',
|
|
||||||
url: 'page-options/page-with-cover',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'With hero cover',
|
|
||||||
url: 'page-options/page-with-hero-cover',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'With cover and no TOC',
|
|
||||||
url: 'page-options/page-with-cover-and-no-toc',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Customization',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Without header',
|
|
||||||
url: getCustomizationURL({
|
|
||||||
header: {
|
|
||||||
preset: CustomizationHeaderPreset.None,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Share links',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-share-links/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Valid link',
|
|
||||||
url: 'Fc6mMII9FKgnwm7qqynx/',
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Invalid link',
|
|
||||||
url: 'invalid/',
|
|
||||||
run: async (page) => {
|
|
||||||
await expect(
|
|
||||||
page.getByText('Authentication missing to access this content'),
|
|
||||||
).toBeVisible();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Visitor Auth - Space',
|
|
||||||
baseUrl: `https://gitbook.gitbook.io/gbo-va-space/`,
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'First',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `first?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Second',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `second?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Visitor Auth - Collection',
|
|
||||||
baseUrl: `https://gitbook.gitbook.io/gbo-va-collection/`,
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Root',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Primary (Space A)',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `v/spacea?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Space B',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `v/spaceb?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Space C',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `v/spacec?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Visitor Auth - Space (custom domain)',
|
|
||||||
baseUrl: `https://test.gitbook.community/`,
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Root',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'First',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `first?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Custom page',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `custom-page?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inner page',
|
|
||||||
url: (() => {
|
|
||||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
name: 'gitbook-open-tests',
|
|
||||||
},
|
|
||||||
privateKey,
|
|
||||||
{
|
|
||||||
expiresIn: '24h',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return `custom-page/inner-page?jwt_token=${token}`;
|
|
||||||
})(),
|
|
||||||
run: waitForCookiesDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Languages',
|
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
|
||||||
tests: allLocales.map((locale) => ({
|
|
||||||
name: locale,
|
|
||||||
url: getCustomizationURL({
|
|
||||||
internationalization: {
|
|
||||||
locale,
|
|
||||||
inherit: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
run: async (page) => {
|
|
||||||
const dialog = page.getByTestId('cookies-dialog');
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
test.describe(testCase.name, () => {
|
|
||||||
for (const testEntry of testCase.tests) {
|
|
||||||
test(testEntry.name, async ({ page, baseURL }) => {
|
|
||||||
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
|
||||||
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
|
||||||
await page.goto(url);
|
|
||||||
if (testEntry.run) {
|
|
||||||
await testEntry.run(page);
|
|
||||||
}
|
|
||||||
if (testEntry.screenshot !== false) {
|
|
||||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
|
||||||
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
|
|
||||||
argosCSS: `
|
|
||||||
/* Hide Intercom */
|
|
||||||
.intercom-lightweight-app {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fullPage: testEntry.fullPage ?? false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a URL with customization settings.
|
|
||||||
*/
|
|
||||||
function getCustomizationURL(partial: Partial<CustomizationSettings>): string {
|
|
||||||
const encoded = rison.encode_object(partial);
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
searchParams.set('customization', encoded);
|
|
||||||
|
|
||||||
return `?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
+32
-87
@@ -1,96 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@changesets/cli": "^2.27.12",
|
||||||
|
"turbo": "^2.4.4",
|
||||||
|
"vercel": "^39.3.0"
|
||||||
|
},
|
||||||
|
"packageManager": "bun@1.2.4",
|
||||||
|
"overrides": {
|
||||||
|
"@codemirror/state": "6.4.1",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "turbo run dev",
|
||||||
"build": "next build",
|
"dev:v2": "turbo run dev:v2",
|
||||||
"build:cloudflare": "next-on-pages",
|
"build": "turbo run build",
|
||||||
"start": "next start",
|
"build:v2": "turbo run build:v2",
|
||||||
"lint": "next lint",
|
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||||
"format": "prettier ./ --ignore-unknown --write",
|
"typecheck": "turbo run typecheck",
|
||||||
"format:check": "prettier ./ --ignore-unknown --list-different",
|
"format": "biome check --write ./",
|
||||||
"typecheck": "tsc --noEmit",
|
"format:check": "biome check --diagnostic-level=error ./",
|
||||||
"unit": "bun test {src,packages}",
|
"unit": "turbo run unit",
|
||||||
"e2e": "playwright test",
|
"e2e": "turbo run e2e",
|
||||||
|
"e2e-customers": "turbo run e2e-customers",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"release": "changeset publish",
|
"changeset-version": "changeset version && bun run format",
|
||||||
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2"
|
"release": "turbo run release && changeset publish",
|
||||||
|
"release:preview": "turbo run release:preview",
|
||||||
|
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||||
|
"clean": "turbo run clean"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": ["packages/*"],
|
||||||
"packages/*"
|
"patchedDependencies": {
|
||||||
],
|
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||||
"dependencies": {
|
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||||
"@geist-ui/icons": "^1.0.2",
|
|
||||||
"@gitbook/api": "^0.56.0",
|
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
|
||||||
"@sentry/nextjs": "^7.94.1",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
|
||||||
"@upstash/redis": "^1.27.1",
|
|
||||||
"ajv": "^8.12.0",
|
|
||||||
"assert-never": "^1.2.1",
|
|
||||||
"bun-types": "^1.0.7",
|
|
||||||
"classnames": "^2.5.1",
|
|
||||||
"content-security-policy-merger": "^1.0.0",
|
|
||||||
"framer-motion": "^10.16.14",
|
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"jsontoxml": "^1.0.1",
|
|
||||||
"katex": "^0.16.9",
|
|
||||||
"mathjax": "^3.2.2",
|
|
||||||
"memoizee": "^0.4.15",
|
|
||||||
"next": "^14.1.3",
|
|
||||||
"next-themes": "^0.2.1",
|
|
||||||
"nuqs": "^1.17.4",
|
|
||||||
"object-hash": "^3.0.0",
|
|
||||||
"openapi-types": "^12.1.3",
|
|
||||||
"p-map": "^7.0.0",
|
|
||||||
"parse-cache-control": "^1.0.1",
|
|
||||||
"react": "^18",
|
|
||||||
"react-dom": "^18",
|
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
|
||||||
"recoil": "^0.7.7",
|
|
||||||
"rehype-sanitize": "^6.0.0",
|
|
||||||
"rehype-stringify": "^10.0.0",
|
|
||||||
"remark-gfm": "^4.0.0",
|
|
||||||
"remark-parse": "^11.0.0",
|
|
||||||
"remark-rehype": "^11.1.0",
|
|
||||||
"rison": "^0.1.1",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"shiki": "^1.2.0",
|
|
||||||
"tailwind-merge": "^2.2.0",
|
|
||||||
"tailwind-shades": "^1.1.2",
|
|
||||||
"unified": "^11.0.4",
|
|
||||||
"url-join": "^5.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@argos-ci/playwright": "^2.0.0",
|
|
||||||
"@changesets/cli": "^2.27.7",
|
|
||||||
"@cloudflare/next-on-pages": "^1.11.3",
|
|
||||||
"@cloudflare/workers-types": "^4.20231218.0",
|
|
||||||
"@playwright/test": "^1.42.1",
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/jsontoxml": "^1.0.5",
|
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
|
||||||
"@types/katex": "^0.16.5",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/object-hash": "^3.0.6",
|
|
||||||
"@types/parse-cache-control": "^1.0.4",
|
|
||||||
"@types/psi": "^4.1.6",
|
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"@types/rison": "^0.0.9",
|
|
||||||
"autoprefixer": "^10",
|
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-next": "13.5.6",
|
|
||||||
"eslint-plugin-import": "^2.29.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"postcss": "^8",
|
|
||||||
"prettier": "^3.0.3",
|
|
||||||
"psi": "^4.1.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.wrangler
|
||||||
|
worker-configuration.d.ts
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# @gitbook/cache-do
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- b7a5106: Disable cloudflare observability in production
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
|
||||||
|
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 56f5fa1: Enable Workers observability with a sampling of 0.1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# `@gitbook/cache-do`
|
||||||
|
|
||||||
|
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
|
||||||
|
|
||||||
|
### Performances
|
||||||
|
|
||||||
|
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
|
||||||
|
|
||||||
|
### Geo-distribution
|
||||||
|
|
||||||
|
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
|
||||||
|
|
||||||
|
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
|
||||||
|
|
||||||
|
### Concepts
|
||||||
|
|
||||||
|
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
|
||||||
|
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
|
||||||
|
|
||||||
|
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
|
||||||
|
|
||||||
|
**Location**: cache is distributed over 7 unique locations, one for each continent.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/cache-do",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./api": {
|
||||||
|
"types": "./dist/api.d.ts",
|
||||||
|
"development": "./src/api.ts",
|
||||||
|
"default": "./dist/api.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||||
|
"lru_map": "^0.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"wrangler": "^3.109.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "wrangler types --experimental-include-runtime",
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"release": "wrangler deploy",
|
||||||
|
"release:preview": "wrangler deploy && wrangler deploy --env preview"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "bin", "data", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
|
import { decode, encode } from '@msgpack/msgpack';
|
||||||
|
import { LRUMap } from 'lru_map';
|
||||||
|
|
||||||
|
export interface CacheObjectDescriptor {
|
||||||
|
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
|
||||||
|
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value stored in a chunked binary msgpack format.
|
||||||
|
* Stored under the key `prop.${key}.${index}`.
|
||||||
|
*/
|
||||||
|
interface CacheObjectProp<Value = unknown> {
|
||||||
|
value: Value;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
|
||||||
|
*/
|
||||||
|
interface CacheObjectExp {
|
||||||
|
/** Key of the property */
|
||||||
|
k: string;
|
||||||
|
/** Number of chunks */
|
||||||
|
c: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable Object class being deployed as a distributed cache.
|
||||||
|
*/
|
||||||
|
export class CacheObject extends DurableObject {
|
||||||
|
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a descriptor to access the cache object.
|
||||||
|
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
|
||||||
|
* One session is opened per request on the client side and used to perform multiple operations.
|
||||||
|
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
|
||||||
|
*/
|
||||||
|
public open(): CacheObjectDescriptor {
|
||||||
|
return {
|
||||||
|
get: async <Value = unknown>(key: string) => {
|
||||||
|
return this.get<Value>(key);
|
||||||
|
},
|
||||||
|
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
|
||||||
|
await this.set(key, value, expiresAt);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of a property.
|
||||||
|
*/
|
||||||
|
public async get<Value = unknown>(key: string) {
|
||||||
|
return this.logOperation({ operation: 'get', key }, async (setLog) => {
|
||||||
|
// Try the memory state first.
|
||||||
|
const memoryEntry = this.lru.get(key);
|
||||||
|
if (memoryEntry) {
|
||||||
|
setLog({ memory: true });
|
||||||
|
setLog({ memoryMatch: !!memoryEntry.match });
|
||||||
|
if (!memoryEntry.match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = memoryEntry.match.expiresAt < Date.now();
|
||||||
|
setLog({ memoryExpired: isExpired });
|
||||||
|
|
||||||
|
if (!isExpired) {
|
||||||
|
return memoryEntry.match.value as Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getFromStorage<Value>(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of a property from the DO storage.
|
||||||
|
*/
|
||||||
|
public async getFromStorage<Value = unknown>(key: string) {
|
||||||
|
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
|
||||||
|
const entries = await this.ctx.storage.list<Uint8Array>({
|
||||||
|
prefix: getStoragePropKey(key),
|
||||||
|
noCache: true,
|
||||||
|
});
|
||||||
|
if (entries.size) {
|
||||||
|
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
|
||||||
|
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
|
||||||
|
if (entry && entry.value.expiresAt > Date.now()) {
|
||||||
|
// Found
|
||||||
|
this.lru.set(key, { match: entry.value });
|
||||||
|
return entry.value.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
this.lru.set(key, { match: undefined });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache object.
|
||||||
|
*/
|
||||||
|
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||||
|
return this.logOperation({ operation: 'set', key }, async (setLog) => {
|
||||||
|
const prop: CacheObjectProp<Value> = {
|
||||||
|
value,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lru.set(key, { match: prop });
|
||||||
|
await this.ctx.storage.transaction(async (tx) => {
|
||||||
|
const entries = encodeChunks(key, prop);
|
||||||
|
const chunks = Object.keys(entries).length;
|
||||||
|
setLog({ chunks });
|
||||||
|
|
||||||
|
const clockValue: CacheObjectExp = {
|
||||||
|
k: key,
|
||||||
|
c: chunks,
|
||||||
|
};
|
||||||
|
|
||||||
|
await tx.put(getGCClockKey(key, expiresAt), clockValue);
|
||||||
|
await tx.put(entries);
|
||||||
|
|
||||||
|
const currentAlarm = await tx.getAlarm();
|
||||||
|
if (!currentAlarm) {
|
||||||
|
// Set an alarm to garbage collect all entries that have expired in 12h.
|
||||||
|
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge all keys in the cache object.
|
||||||
|
*/
|
||||||
|
public async purge() {
|
||||||
|
return this.logOperation({ operation: 'purge' }, async (setLog) => {
|
||||||
|
const result = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// List all the keys in the cache object.
|
||||||
|
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||||
|
prefix: 'exp.',
|
||||||
|
noCache: true,
|
||||||
|
});
|
||||||
|
setLog({ entries: entries.size });
|
||||||
|
entries.forEach((exp) => {
|
||||||
|
result.add(exp.k);
|
||||||
|
});
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
await this.reset();
|
||||||
|
return Array.from(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alarm to garbage collect all entries that have expired.
|
||||||
|
*/
|
||||||
|
async alarm() {
|
||||||
|
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
|
||||||
|
try {
|
||||||
|
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||||
|
prefix: 'exp.',
|
||||||
|
noCache: true,
|
||||||
|
});
|
||||||
|
setLog({ entries: entries.size });
|
||||||
|
const toDeleteSet = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, exp] of entries) {
|
||||||
|
const timestamp = Number.parseInt(key.split('.')[1]);
|
||||||
|
if (timestamp < Date.now()) {
|
||||||
|
toDeleteSet.add(key);
|
||||||
|
for (let i = 0; i < exp.c; i++) {
|
||||||
|
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the keys by batch of 128.
|
||||||
|
const toDelete = Array.from(toDeleteSet);
|
||||||
|
setLog({ toDelete: toDelete.length });
|
||||||
|
for (let i = 0; i < toDelete.length; i += 128) {
|
||||||
|
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
|
||||||
|
if (toDelete.length) {
|
||||||
|
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
await this.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the cache object.
|
||||||
|
*/
|
||||||
|
async reset() {
|
||||||
|
return this.logOperation({ operation: 'reset' }, async () => {
|
||||||
|
this.lru.clear();
|
||||||
|
await this.ctx.storage.deleteAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time and log an operation.
|
||||||
|
*/
|
||||||
|
async logOperation<T>(
|
||||||
|
log: Record<string, unknown>,
|
||||||
|
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
|
||||||
|
const update: Record<string, unknown> = {};
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return await fn((arg) => {
|
||||||
|
Object.assign(update, arg);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
console.log({ ...log, ...update, objectId, duration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoragePropKey(key: string): string {
|
||||||
|
return `prop.${key}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoragePropChunkKey(key: string, index: number): string {
|
||||||
|
return `${getStoragePropKey(key)}${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGCClockRootKey(timestamp: number): string {
|
||||||
|
return `exp.${timestamp}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGCClockKey(key: string, expiresAt: number): string {
|
||||||
|
return `${getGCClockRootKey(expiresAt)}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
|
||||||
|
const buf = encode(value);
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
const chunks = chunkUint8Array(buf, 128 * 1024);
|
||||||
|
|
||||||
|
for (let index = 0; index < chunks.length; index++) {
|
||||||
|
entries[getStoragePropChunkKey(key, index)] = chunks[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
|
||||||
|
const chunks = Array.from(entries.entries())
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const index = Number.parseInt(key.split('.').pop()!);
|
||||||
|
return [index, value] as const;
|
||||||
|
})
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, value]) => value);
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = mergeUint8Array(chunks);
|
||||||
|
return { value: decode(buf) as T, size: buf.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
for (let i = 0; i < input.length; i += chunkSize) {
|
||||||
|
chunks.push(input.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
|
||||||
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { CacheObject } from './CacheObject';
|
||||||
|
|
||||||
|
export type CacheLocationId = ContinentCode;
|
||||||
|
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location hint for the CacheObject durable object.
|
||||||
|
*/
|
||||||
|
const doLocationHints: {
|
||||||
|
[key in CacheLocationId]: DurableObjectLocationHint;
|
||||||
|
} = {
|
||||||
|
AF: 'afr',
|
||||||
|
AS: 'apac',
|
||||||
|
NA: 'wnam',
|
||||||
|
SA: 'sam',
|
||||||
|
AN: 'oc',
|
||||||
|
EU: 'weur',
|
||||||
|
OC: 'oc',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client to access a cache tag.
|
||||||
|
*/
|
||||||
|
export class CacheObjectStub {
|
||||||
|
private stub: DurableObjectStub<CacheObject>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** Binding to the CacheObject durable object */
|
||||||
|
private doNamespace: DurableObjectNamespace<CacheObject>,
|
||||||
|
/** ID of the location to target */
|
||||||
|
private locationId: CacheLocationId,
|
||||||
|
/** Name of the tag */
|
||||||
|
private tag: string
|
||||||
|
) {
|
||||||
|
const groupId = getCacheObjectIdName(this.locationId, this.tag);
|
||||||
|
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||||
|
// Initialize the object with a locaiton hint,
|
||||||
|
// as we might want to purge all locations before the object is created.
|
||||||
|
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||||
|
locationHint: doLocationHints[this.locationId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a descriptor to the cache object.
|
||||||
|
* It can be used to perform multiple operations in a single RPC session.
|
||||||
|
* Ex:
|
||||||
|
* ```ts
|
||||||
|
* using desc = cache.open();
|
||||||
|
* await desc.set('key', 'value', Date.now() + 1000);
|
||||||
|
* await desc.get('key');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async open() {
|
||||||
|
return await this.stub.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from the cache.
|
||||||
|
*/
|
||||||
|
async get<Value = unknown>(key: string) {
|
||||||
|
return (await this.stub.get(key)) as Value | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache.
|
||||||
|
*/
|
||||||
|
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||||
|
return await this.stub.set(key, value, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge all keys in the cache tag.
|
||||||
|
*/
|
||||||
|
async purge() {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
await Promise.all(
|
||||||
|
allLocations.map(async (locationId) => {
|
||||||
|
const groupId = getCacheObjectIdName(locationId, this.tag);
|
||||||
|
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||||
|
// Initialize the object with a locaiton hint,
|
||||||
|
// as we might want to purge all locations before the object is created.
|
||||||
|
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||||
|
locationHint: doLocationHints[this.locationId],
|
||||||
|
});
|
||||||
|
const locationkeys = await cacheGroup.purge();
|
||||||
|
locationkeys.forEach((key) => keys.add(key));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
|
||||||
|
return `${locationId}:${tag}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './CacheObjectStub';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||||
|
|
||||||
|
export * from './CacheObject';
|
||||||
|
|
||||||
|
export default class Worker extends WorkerEntrypoint {
|
||||||
|
fetch() {
|
||||||
|
return new Response('Hello, world!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"incremental": true,
|
||||||
|
"types": ["./.wrangler/types/runtime.d.ts"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
main = "./src/index.ts"
|
||||||
|
name = "gitbook-open-cache"
|
||||||
|
compatibility_date = "2024-09-02"
|
||||||
|
|
||||||
|
durable_objects.bindings = [
|
||||||
|
{name = "CACHE", class_name = "CacheObject"}
|
||||||
|
]
|
||||||
|
|
||||||
|
migrations = [
|
||||||
|
{tag = "v1", new_classes = ["CacheObject"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[env.preview]
|
||||||
|
name = "gitbook-open-cache-preview"
|
||||||
|
durable_objects.bindings = [
|
||||||
|
{name = "CACHE", class_name = "CacheObject"}
|
||||||
|
]
|
||||||
|
migrations = [
|
||||||
|
{tag = "v1", new_classes = ["CacheObject"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
[env.preview.observability]
|
||||||
|
enabled = true
|
||||||
|
head_sampling_rate = 0.1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @gitbook/cache-tags
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 05ffd0e: Initial version of the package
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/cache-tags`
|
||||||
|
|
||||||
|
Utility to generate cache tags for GitBook Open.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/cache-tags",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "0.96.1",
|
||||||
|
"assert-never": "^1.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { ComputedContentSource } from '@gitbook/api';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a stringified cache tag for a given object.
|
||||||
|
*/
|
||||||
|
export function getCacheTag(
|
||||||
|
spec: /**
|
||||||
|
* All data related to a user
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'user';
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a space
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'space';
|
||||||
|
space: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to an integration.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'integration';
|
||||||
|
integration: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a change request
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'change-request';
|
||||||
|
space: string;
|
||||||
|
changeRequest: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Immutable data related to a revision
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'revision';
|
||||||
|
space: string;
|
||||||
|
revision: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Immutable data related to a document
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'document';
|
||||||
|
space: string;
|
||||||
|
document: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Immutable data related to a computed document
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'computed-document';
|
||||||
|
space: string;
|
||||||
|
integration: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to the URL of a content
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'url';
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a site
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'site';
|
||||||
|
site: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to an OpenAPI spec
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'openapi';
|
||||||
|
organization: string;
|
||||||
|
openAPISpec: string;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
switch (spec.tag) {
|
||||||
|
case 'user':
|
||||||
|
return `user:${spec.user}`;
|
||||||
|
case 'url':
|
||||||
|
return `url:${spec.hostname}`;
|
||||||
|
case 'space':
|
||||||
|
return `space:${spec.space}`;
|
||||||
|
case 'change-request':
|
||||||
|
return `space:${spec.space}:change-request:${spec.changeRequest}`;
|
||||||
|
case 'revision':
|
||||||
|
return `space:${spec.space}:revision:${spec.revision}`;
|
||||||
|
case 'document':
|
||||||
|
return `space:${spec.space}:document:${spec.document}`;
|
||||||
|
case 'computed-document':
|
||||||
|
return `space:${spec.space}:computed-document:${spec.integration}`;
|
||||||
|
case 'site':
|
||||||
|
return `site:${spec.site}`;
|
||||||
|
case 'integration':
|
||||||
|
return `integration:${spec.integration}`;
|
||||||
|
case 'openapi':
|
||||||
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
|
default:
|
||||||
|
assertNever(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags for a computed content source.
|
||||||
|
*/
|
||||||
|
export function getComputedContentSourceCacheTags(
|
||||||
|
inContext: {
|
||||||
|
spaceId: string;
|
||||||
|
organizationId: string;
|
||||||
|
},
|
||||||
|
source: ComputedContentSource
|
||||||
|
) {
|
||||||
|
const tags: string[] = [];
|
||||||
|
|
||||||
|
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
||||||
|
// when the dependencies are updated.
|
||||||
|
const dependencies = Object.values(source.dependencies ?? {});
|
||||||
|
if (dependencies.length > 0) {
|
||||||
|
dependencies.forEach((dependency) => {
|
||||||
|
switch (dependency.ref.kind) {
|
||||||
|
case 'space':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'space',
|
||||||
|
space: dependency.ref.space,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'openapi':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'openapi',
|
||||||
|
organization: inContext.organizationId,
|
||||||
|
openAPISpec: dependency.ref.spec,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Do not throw for unknown dependency types
|
||||||
|
// as it might mean we are lacking behind the API version
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Push a dummy tag, as the v1 is only using the first tag
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'computed-document',
|
||||||
|
space: inContext.spaceId,
|
||||||
|
integration: source.integration,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We invalidate the computed content when a new version of the integration is deployed.
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'integration',
|
||||||
|
integration: source.integration,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 445baaa: Initial release
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/colors`
|
||||||
|
|
||||||
|
A set of default colors and transformation functions used throughout the GitBook Open and app.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/colors",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Default primary color throughout the GitBook ecosystem.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PRIMARY_COLOR = '#346DDB';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The darkest color that exists in GitBook, used as the relative minimum of every generated color scale.
|
||||||
|
*/
|
||||||
|
export const DARK_BASE = '#1D1D1D';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lightest color that exists in GitBook, used as the relative maximum of every generated color scale.
|
||||||
|
*/
|
||||||
|
export const LIGHT_BASE = '#FFFFFF';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used as the basis of all UI elements that are not colored by the primary color. Neutral gray by default, overridden by site customization.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_TINT_COLOR = '#787878';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for informational messages and neutral alerts.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_INFO_COLOR = '#787878';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for showing important information or non-critical warnings.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for destructive actions or raising attention to critical information.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for showing positive actions or achievements.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './colors';
|
||||||
|
export * from './transformations';
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { DARK_BASE, DEFAULT_TINT_COLOR, LIGHT_BASE } from './colors';
|
||||||
|
|
||||||
|
type ColorShades = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RGBColor = [number, number, number];
|
||||||
|
type OKLABColor = { L: number; A: number; B: number };
|
||||||
|
type OKLCHColor = { L: number; C: number; H: number };
|
||||||
|
|
||||||
|
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
|
||||||
|
|
||||||
|
export enum ColorCategory {
|
||||||
|
backgrounds = 'backgrounds',
|
||||||
|
components = 'components',
|
||||||
|
borders = 'borders',
|
||||||
|
accents = 'accents',
|
||||||
|
text = 'text',
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorSubScale = {
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main color scale object.
|
||||||
|
*
|
||||||
|
* Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation.
|
||||||
|
* Each subitem maps a semantic name within that category to a step in the scale.
|
||||||
|
*/
|
||||||
|
export const scale: Record<ColorCategory, ColorSubScale> = {
|
||||||
|
[ColorCategory.backgrounds]: {
|
||||||
|
/** Base background */
|
||||||
|
base: 1,
|
||||||
|
/** Accent background */
|
||||||
|
subtle: 2,
|
||||||
|
},
|
||||||
|
[ColorCategory.components]: {
|
||||||
|
/** Component background */
|
||||||
|
DEFAULT: 3,
|
||||||
|
/** Component hover background */
|
||||||
|
hover: 4,
|
||||||
|
/** Component active background */
|
||||||
|
active: 5,
|
||||||
|
},
|
||||||
|
[ColorCategory.borders]: {
|
||||||
|
/** Subtle borders, separators */
|
||||||
|
subtle: 6,
|
||||||
|
/** Element border, focus rings */
|
||||||
|
DEFAULT: 7,
|
||||||
|
/** Element hover border */
|
||||||
|
hover: 8,
|
||||||
|
},
|
||||||
|
[ColorCategory.accents]: {
|
||||||
|
/** Solid backgrounds */
|
||||||
|
solid: 9,
|
||||||
|
/** Hovered solid backgrounds */
|
||||||
|
'solid-hover': 10,
|
||||||
|
},
|
||||||
|
[ColorCategory.text]: {
|
||||||
|
/** Very low-contrast text
|
||||||
|
* Caution: this contrast does not meet accessiblity guidelines.
|
||||||
|
* Always check if you need to include a mitigating contrast-more style for users who need it. */
|
||||||
|
subtle: 9,
|
||||||
|
/** Low-contrast text */
|
||||||
|
DEFAULT: 11,
|
||||||
|
/** High-contrast text */
|
||||||
|
strong: 12,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mix of foreground and background for every step in a colour scale.
|
||||||
|
* 0: 100% of the background color's luminosity, white in light mode
|
||||||
|
* 1: 100% of the foreground color's luminosity, black in light mode
|
||||||
|
*/
|
||||||
|
export const colorMixMapping = {
|
||||||
|
// bgs |components |borders |solid |text
|
||||||
|
light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1],
|
||||||
|
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex color to an RGB color.
|
||||||
|
*/
|
||||||
|
export function hexToRgb(hex: string): string {
|
||||||
|
const [r, g, b] = hexToRgbArray(hex);
|
||||||
|
// Return the RGB values separated by spaces
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex color to a RGBA color.
|
||||||
|
*/
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const [r, g, b] = hexToRgbArray(hex);
|
||||||
|
// Return the RGBA values separated by spaces
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Tailwind-compatible shades from a single color
|
||||||
|
* @param {string} hex The hex code to generate shades from
|
||||||
|
* @param {boolean} halfShades Generate additional shades, e.g. at 150
|
||||||
|
* @returns {{[key: number]: string}}
|
||||||
|
*/
|
||||||
|
export function shadesOfColor(hex: string, halfShades = false) {
|
||||||
|
const baseColor = hex;
|
||||||
|
|
||||||
|
const shades = [
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
200,
|
||||||
|
300,
|
||||||
|
400,
|
||||||
|
500,
|
||||||
|
600,
|
||||||
|
700,
|
||||||
|
800,
|
||||||
|
900,
|
||||||
|
...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []),
|
||||||
|
].sort();
|
||||||
|
|
||||||
|
const result: ColorShades = {};
|
||||||
|
|
||||||
|
for (const shade of shades) {
|
||||||
|
const key = shade.toString();
|
||||||
|
|
||||||
|
if (shade === 500) {
|
||||||
|
result[key] = hex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shadeIndex = shade;
|
||||||
|
const isDarkShade = shadeIndex > 500;
|
||||||
|
if (isDarkShade) {
|
||||||
|
shadeIndex -= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = shadeIndex / 500;
|
||||||
|
const startColor = isDarkShade ? DARK_BASE : baseColor;
|
||||||
|
const endColor = isDarkShade ? baseColor : LIGHT_BASE;
|
||||||
|
|
||||||
|
result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorScaleOptions = {
|
||||||
|
/** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */
|
||||||
|
darkMode?: boolean;
|
||||||
|
|
||||||
|
/** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
|
||||||
|
background?: string;
|
||||||
|
|
||||||
|
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
|
||||||
|
foreground?: string;
|
||||||
|
|
||||||
|
mix?: {
|
||||||
|
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
/** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */
|
||||||
|
ratio: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour.
|
||||||
|
* @param {string} hex The hex code to generate shades from
|
||||||
|
* @param {object} options
|
||||||
|
*/
|
||||||
|
export function colorScale(
|
||||||
|
hex: string,
|
||||||
|
{
|
||||||
|
darkMode = false,
|
||||||
|
background = darkMode ? DARK_BASE : LIGHT_BASE,
|
||||||
|
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
|
||||||
|
mix,
|
||||||
|
}: ColorScaleOptions = {}
|
||||||
|
) {
|
||||||
|
const baseColor = rgbToOklch(hexToRgbArray(hex));
|
||||||
|
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
|
||||||
|
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
|
||||||
|
const backgroundColor = rgbToOklch(hexToRgbArray(background));
|
||||||
|
|
||||||
|
if (mixColor && mix?.ratio && mix.ratio > 0) {
|
||||||
|
// If defined, we mix in a (tiny) bit of the mix color with the base color.
|
||||||
|
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
|
||||||
|
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
|
||||||
|
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < mapping.length; index++) {
|
||||||
|
const targetL =
|
||||||
|
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||||
|
|
||||||
|
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
|
||||||
|
// Original colour is close enough to target, so let's use the original colour as step 9.
|
||||||
|
result.push(hex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
|
||||||
|
|
||||||
|
const shade = {
|
||||||
|
L: targetL, // Blend lightness
|
||||||
|
C: baseColor.C * chromaRatio,
|
||||||
|
H: baseColor.H, // Maintain the hue from the base color
|
||||||
|
};
|
||||||
|
|
||||||
|
const newHex = rgbArrayToHex(oklchToRgb(shade));
|
||||||
|
|
||||||
|
result.push(newHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex color to an RGB color set.
|
||||||
|
*/
|
||||||
|
export function hexToRgbArray(hex: string): RGBColor {
|
||||||
|
const originalHex = hex;
|
||||||
|
|
||||||
|
let value = hex.replace('#', '');
|
||||||
|
if (hex.length === 3) value = value + value;
|
||||||
|
|
||||||
|
const r = value.substring(0, 2);
|
||||||
|
const g = value.substring(2, 4);
|
||||||
|
const b = value.substring(4, 6);
|
||||||
|
|
||||||
|
const rgb = [r, g, b].map((channel) => {
|
||||||
|
try {
|
||||||
|
const channelInt = Number.parseInt(channel, 16);
|
||||||
|
if (channelInt < 0 || channelInt > 255) throw new Error();
|
||||||
|
return channelInt;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid hex color provided: ${originalHex}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rgb as RGBColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a RGB color set to a hex color.
|
||||||
|
*/
|
||||||
|
export function rgbArrayToHex(rgb: RGBColor): string {
|
||||||
|
return `#${rgb
|
||||||
|
.map((channel) => {
|
||||||
|
const component = channel.toString(16);
|
||||||
|
if (component.length === 1) return `0${component}`;
|
||||||
|
return component;
|
||||||
|
})
|
||||||
|
.join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
||||||
|
const rgb = end.map((channel, index) => {
|
||||||
|
return Math.round(channel + percentage * (start[index] - channel));
|
||||||
|
});
|
||||||
|
|
||||||
|
return rgbArrayToHex(rgb as RGBColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility constants and helper functions
|
||||||
|
export function rgbToLinear(rgb: RGBColor): [number, number, number] {
|
||||||
|
return rgb.map((v) => {
|
||||||
|
const scaled = v / 255;
|
||||||
|
return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
|
||||||
|
}) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linearToRgb(linear: [number, number, number]): RGBColor {
|
||||||
|
return linear.map((v) => {
|
||||||
|
const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
||||||
|
return Math.round(Math.max(0, Math.min(1, scaled)) * 255);
|
||||||
|
}) as RGBColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToOklab(rgb: RGBColor): OKLABColor {
|
||||||
|
const [r, g, b] = rgbToLinear(rgb);
|
||||||
|
|
||||||
|
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
||||||
|
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
||||||
|
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
||||||
|
|
||||||
|
const lRoot = Math.cbrt(l);
|
||||||
|
const mRoot = Math.cbrt(m);
|
||||||
|
const sRoot = Math.cbrt(s);
|
||||||
|
|
||||||
|
return {
|
||||||
|
L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
|
||||||
|
A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
|
||||||
|
B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklabToRgb(oklab: OKLABColor): RGBColor {
|
||||||
|
const { L, A, B } = oklab;
|
||||||
|
|
||||||
|
const lRoot = L + 0.3963377774 * A + 0.2158037573 * B;
|
||||||
|
const mRoot = L - 0.1055613458 * A - 0.0638541728 * B;
|
||||||
|
const sRoot = L - 0.0894841775 * A - 1.291485548 * B;
|
||||||
|
|
||||||
|
const l = lRoot ** 3;
|
||||||
|
const m = mRoot ** 3;
|
||||||
|
const s = sRoot ** 3;
|
||||||
|
|
||||||
|
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||||
|
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||||
|
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
||||||
|
|
||||||
|
return linearToRgb([r, g, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklabToOklch(oklab: OKLABColor): OKLCHColor {
|
||||||
|
const { L, A, B } = oklab;
|
||||||
|
const C = Math.sqrt(A ** 2 + B ** 2);
|
||||||
|
const H = (Math.atan2(B, A) * 180) / Math.PI;
|
||||||
|
return { L, C, H: H < 0 ? H + 360 : H };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklchToOklab(oklch: OKLCHColor): OKLABColor {
|
||||||
|
const { L, C, H } = oklch;
|
||||||
|
const rad = (H * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
L,
|
||||||
|
A: C * Math.cos(rad),
|
||||||
|
B: C * Math.sin(rad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToOklch(rgb: RGBColor): OKLCHColor {
|
||||||
|
return oklabToOklch(rgbToOklab(rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklchToRgb(oklch: OKLCHColor): RGBColor {
|
||||||
|
return oklabToRgb(oklchToOklab(oklch));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToXyz(rgb: RGBColor): [number, number, number] {
|
||||||
|
const [r, g, b] = rgbToLinear(rgb);
|
||||||
|
return [
|
||||||
|
(r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
|
||||||
|
(r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100,
|
||||||
|
(r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xyzToLab65(xyz: [number, number, number]): {
|
||||||
|
L: number;
|
||||||
|
A: number;
|
||||||
|
B: number;
|
||||||
|
} {
|
||||||
|
const [x, y, z] = xyz.map((v, i) => {
|
||||||
|
const scaled = v / D65[i];
|
||||||
|
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
L: 116 * y - 16,
|
||||||
|
A: 500 * (x - y),
|
||||||
|
B: 200 * (y - z),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } {
|
||||||
|
return xyzToLab65(rgbToXyz(rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Delta Phi Star perceptual lightness contrast by Andrew Somers:
|
||||||
|
https://github.com/Myndex/deltaphistar
|
||||||
|
*/
|
||||||
|
export const PHI = 0.5 + Math.sqrt(1.25);
|
||||||
|
|
||||||
|
export function dpsContrast(a: RGBColor, b: RGBColor) {
|
||||||
|
const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI);
|
||||||
|
const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40;
|
||||||
|
return contrast < 7.5 ? 0 : contrast;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
|
||||||
|
const bg = hexToRgbArray(background);
|
||||||
|
|
||||||
|
const best: { color?: RGBColor; contrast: number } = {
|
||||||
|
color: undefined,
|
||||||
|
contrast: 0,
|
||||||
|
};
|
||||||
|
for (const color of foreground) {
|
||||||
|
const c = hexToRgbArray(color);
|
||||||
|
|
||||||
|
const contrast = dpsContrast(c, bg);
|
||||||
|
if (contrast > best.contrast) {
|
||||||
|
best.color = c;
|
||||||
|
best.contrast = contrast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best.color ? rgbArrayToHex(best.color) : foreground[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
index.ts
|
dist/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import emojisRaws from 'emoji-assets/emoji.json';
|
import emojisRaws from 'emoji-assets/emoji.json';
|
||||||
|
|
||||||
interface EmojiData {
|
interface EmojiData {
|
||||||
@@ -16,11 +17,11 @@ Object.entries(emojis).forEach(([key, value]) => {
|
|||||||
if (emoji && key !== emoji) {
|
if (emoji && key !== emoji) {
|
||||||
output[key] = emoji;
|
output[key] = emoji;
|
||||||
} else if (!emoji) {
|
} else if (!emoji) {
|
||||||
console.log('No emoji for', key);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
'index.ts',
|
path.resolve(__dirname, 'dist/index.ts'),
|
||||||
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
|
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"exports": "./index.ts",
|
"exports": "./dist/index.ts",
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"emoji-assets": "^8.0.0"
|
"emoji-assets": "^8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "bun ./build.ts"
|
"generate": "bun ./build.ts",
|
||||||
|
"clean": "rm -rf ./dist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# cloudflare
|
||||||
|
.open-next
|
||||||
|
|
||||||
|
# Symbolic links
|
||||||
|
public
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# gitbook-v2
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 05ffd0e: Improving data cache management for computed content
|
||||||
|
- Updated dependencies [05ffd0e]
|
||||||
|
- @gitbook/cache-tags@0.1.0
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3e11678: fix: lost section groups
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- cfccc44: Setup structure and deployment for new version
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
*/
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
useCache: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
env: {
|
||||||
|
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||||
|
GITBOOK_URL: process.env.GITBOOK_URL,
|
||||||
|
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||||
|
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||||
|
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||||
|
|
||||||
|
// Used to detect if the app is running in V2 mode
|
||||||
|
GITBOOK_V2: 'true',
|
||||||
|
},
|
||||||
|
|
||||||
|
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
|
||||||
|
poweredByHeader: false,
|
||||||
|
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.gitbook.io',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// default open-next.config.ts file created by @opennextjs/cloudflare
|
||||||
|
|
||||||
|
import cache from '@opennextjs/cloudflare/kvCache';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
default: {
|
||||||
|
override: {
|
||||||
|
wrapper: 'cloudflare-node',
|
||||||
|
converter: 'edge',
|
||||||
|
incrementalCache: async () => cache,
|
||||||
|
tagCache: 'dummy',
|
||||||
|
queue: 'dummy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
middleware: {
|
||||||
|
external: true,
|
||||||
|
override: {
|
||||||
|
wrapper: 'cloudflare-edge',
|
||||||
|
converter: 'edge',
|
||||||
|
proxyExternalRequest: 'fetch',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dangerous: {
|
||||||
|
enableCacheInterception: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "gitbook-v2",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.2.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"@gitbook/api": "0.96.1",
|
||||||
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"gitbook": "*",
|
||||||
|
"@opennextjs/cloudflare": "^0.5.3",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"postcss": "^8"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
|
||||||
|
"dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"build:v2": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"build:v2:cloudflare": "opennextjs-cloudflare",
|
||||||
|
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
||||||
|
"unit": "bun test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SitePageSkeleton } from '@/components/SitePage';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SitePageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SitePageNotFound } from '@/components/SitePage';
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
return <SitePageNotFound />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
SitePage,
|
||||||
|
generateSitePageMetadata,
|
||||||
|
generateSitePageViewport,
|
||||||
|
} from '@/components/SitePage';
|
||||||
|
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@v2/app/utils';
|
||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteParams>;
|
||||||
|
searchParams: Promise<{ fallback?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const context = await getDynamicSiteContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
|
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||||
|
const context = await getDynamicSiteContext(await props.params);
|
||||||
|
return generateSitePageViewport(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
|
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
|
||||||
|
const context = await getDynamicSiteContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
|
return generateSitePageMetadata({
|
||||||
|
context,
|
||||||
|
pageParams: { pathname },
|
||||||
|
redirectOnFallback: true,
|
||||||
|
fallback: !!searchParams.fallback,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||||
|
import {
|
||||||
|
SiteLayout,
|
||||||
|
generateSiteLayoutMetadata,
|
||||||
|
generateSiteLayoutViewport,
|
||||||
|
} from '@/components/SiteLayout';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||||
|
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||||
|
|
||||||
|
interface SiteDynamicLayoutProps {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SiteDynamicLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
const forcedTheme = await getThemeFromMiddleware();
|
||||||
|
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomizationRootLayout customization={context.customization}>
|
||||||
|
<SiteLayout
|
||||||
|
context={context}
|
||||||
|
forcedTheme={forcedTheme}
|
||||||
|
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||||
|
visitorAuthToken={visitorAuthToken}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SiteLayout>
|
||||||
|
</CustomizationRootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return generateSiteLayoutViewport(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return generateSiteLayoutMetadata(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveLLMsTxt } from '@/routes/llms';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return serveLLMsTxt(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveRobotsTxt } from '@/routes/robots';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return serveRobotsTxt(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { servePagesSitemap } from '@/routes/sitemap';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return servePagesSitemap(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveRootSitemap } from '@/routes/sitemap';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return serveRootSitemap(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveIcon } from '@/routes/icon';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return serveIcon(context, request);
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import type { PageIdParams } from '@/components/SitePage';
|
||||||
|
import { serveOGImage } from '@/routes/ogimage';
|
||||||
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||||
|
) {
|
||||||
|
const context = await getDynamicSiteContext(await params);
|
||||||
|
return serveOGImage(context, await params);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SitePageNotFound } from '@/components/SitePage';
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
return <SitePageNotFound />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
SitePage,
|
||||||
|
generateSitePageMetadata,
|
||||||
|
generateSitePageViewport,
|
||||||
|
} from '@/components/SitePage';
|
||||||
|
import { getCacheTag } from '@gitbook/cache-tags';
|
||||||
|
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
const params = await props.params;
|
||||||
|
const context = await getStaticSiteContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'site',
|
||||||
|
site: context.site.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||||
|
const context = await getStaticSiteContext(await props.params);
|
||||||
|
return generateSitePageViewport(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const context = await getStaticSiteContext(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
|
return generateSitePageMetadata({
|
||||||
|
context,
|
||||||
|
pageParams: { pathname },
|
||||||
|
redirectOnFallback: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||||
|
import {
|
||||||
|
SiteLayout,
|
||||||
|
generateSiteLayoutMetadata,
|
||||||
|
generateSiteLayoutViewport,
|
||||||
|
} from '@/components/SiteLayout';
|
||||||
|
import { getCacheTag } from '@gitbook/cache-tags';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||||
|
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
|
|
||||||
|
interface SiteStaticLayoutProps {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SiteStaticLayout({
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'site',
|
||||||
|
site: context.site.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomizationRootLayout customization={context.customization}>
|
||||||
|
<SiteLayout
|
||||||
|
context={context}
|
||||||
|
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||||
|
visitorAuthToken={null}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SiteLayout>
|
||||||
|
</CustomizationRootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return generateSiteLayoutViewport(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return generateSiteLayoutMetadata(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveLLMsTxt } from '@/routes/llms';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return serveLLMsTxt(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveRobotsTxt } from '@/routes/robots';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return serveRobotsTxt(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { servePagesSitemap } from '@/routes/sitemap';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return servePagesSitemap(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveRootSitemap } from '@/routes/sitemap';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return serveRootSitemap(context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveIcon } from '@/routes/icon';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return serveIcon(context, request);
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import type { PageIdParams } from '@/components/SitePage';
|
||||||
|
import { serveOGImage } from '@/routes/ogimage';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||||
|
) {
|
||||||
|
const context = await getStaticSiteContext(await params);
|
||||||
|
return serveOGImage(context, await params);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
fetchSiteContextByURL,
|
||||||
|
fetchSiteContextByURLLookup,
|
||||||
|
getBaseContext,
|
||||||
|
} from '@v2/lib/context';
|
||||||
|
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||||
|
|
||||||
|
export type RouteParamMode = 'url-host' | 'url';
|
||||||
|
|
||||||
|
export type RouteLayoutParams = {
|
||||||
|
mode: string;
|
||||||
|
|
||||||
|
/** URL encoded site URL */
|
||||||
|
siteURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteParams = RouteLayoutParams & {
|
||||||
|
pagePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the static context when rendering statically a site.
|
||||||
|
*/
|
||||||
|
export function getStaticSiteContext(params: RouteLayoutParams) {
|
||||||
|
const siteURL = getSiteURLFromParams(params);
|
||||||
|
return fetchSiteContextByURL(
|
||||||
|
getBaseContext({
|
||||||
|
siteURL,
|
||||||
|
urlMode: getModeFromParams(params.mode),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
url: siteURL.toString(),
|
||||||
|
visitorAuthToken: null,
|
||||||
|
redirectOnError: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the site context when rendering dynamically.
|
||||||
|
* The context will depend on the request.
|
||||||
|
*/
|
||||||
|
export async function getDynamicSiteContext(params: RouteLayoutParams) {
|
||||||
|
const siteURL = getSiteURLFromParams(params);
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
|
return fetchSiteContextByURLLookup(
|
||||||
|
getBaseContext({
|
||||||
|
siteURL,
|
||||||
|
urlMode: getModeFromParams(params.mode),
|
||||||
|
}),
|
||||||
|
siteURLData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decoded page path from the params.
|
||||||
|
*/
|
||||||
|
export function getPagePathFromParams(params: RouteParams) {
|
||||||
|
const decoded = decodeURIComponent(params.pagePath);
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSiteURLFromParams(params: RouteLayoutParams) {
|
||||||
|
const decoded = decodeURIComponent(params.siteURL);
|
||||||
|
const url = new URL(`https://${decoded}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModeFromParams(mode: string): RouteParamMode {
|
||||||
|
if (mode === 'url-host') {
|
||||||
|
return 'url-host';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'url';
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { getSiteStructureSections } from '@/lib/sites';
|
||||||
|
import type {
|
||||||
|
ChangeRequest,
|
||||||
|
PublishedSiteContentLookup,
|
||||||
|
RevisionPage,
|
||||||
|
RevisionPageDocument,
|
||||||
|
Site,
|
||||||
|
SiteCustomizationSettings,
|
||||||
|
SiteIntegrationScript,
|
||||||
|
SiteSection,
|
||||||
|
SiteSectionGroup,
|
||||||
|
SiteSpace,
|
||||||
|
SiteStructure,
|
||||||
|
Space,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import { type GitBookDataFetcher, createDataFetcher } from '@v2/lib/data';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { assert } from 'ts-essentials';
|
||||||
|
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from './env';
|
||||||
|
import { type ImageResizer, createImageResizer } from './images';
|
||||||
|
import { type GitBookSpaceLinker, createLinker } from './links';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic context when rendering content.
|
||||||
|
*/
|
||||||
|
export type GitBookBaseContext = {
|
||||||
|
/**
|
||||||
|
* Data fetcher to fetch data from GitBook.
|
||||||
|
*/
|
||||||
|
dataFetcher: GitBookDataFetcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linker to generate links in the current space.
|
||||||
|
*/
|
||||||
|
linker: GitBookSpaceLinker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image resizer to resize images.
|
||||||
|
*/
|
||||||
|
imageResizer?: ImageResizer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any context when rendering content.
|
||||||
|
*/
|
||||||
|
export type GitBookAnyContext = GitBookSpaceContext | GitBookSiteContext | GitBookPageContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context when rendering a space content.
|
||||||
|
*/
|
||||||
|
export type GitBookSpaceContext = GitBookBaseContext & {
|
||||||
|
organizationId: string;
|
||||||
|
|
||||||
|
space: Space;
|
||||||
|
changeRequest: ChangeRequest | null;
|
||||||
|
|
||||||
|
/** ID of the current revision. */
|
||||||
|
revisionId: string;
|
||||||
|
|
||||||
|
/** Pages of the space. */
|
||||||
|
pages: RevisionPage[];
|
||||||
|
|
||||||
|
/** Share key of the space. */
|
||||||
|
shareKey: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteSections = {
|
||||||
|
list: (SiteSectionGroup | SiteSection)[];
|
||||||
|
current: SiteSection;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context when rendering a site.
|
||||||
|
*/
|
||||||
|
export type GitBookSiteContext = GitBookSpaceContext & {
|
||||||
|
site: Site;
|
||||||
|
|
||||||
|
/** Current site space. */
|
||||||
|
siteSpace: SiteSpace;
|
||||||
|
|
||||||
|
/** All site spaces in the current section / or entire site */
|
||||||
|
siteSpaces: SiteSpace[];
|
||||||
|
|
||||||
|
/** Sections of the site. */
|
||||||
|
sections: null | SiteSections;
|
||||||
|
|
||||||
|
/** Customizations of the site. */
|
||||||
|
customization: SiteCustomizationSettings;
|
||||||
|
|
||||||
|
/** Structure of the site. */
|
||||||
|
structure: SiteStructure;
|
||||||
|
|
||||||
|
/** Scripts to load for the site. */
|
||||||
|
scripts: SiteIntegrationScript[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context when rendering a page.
|
||||||
|
*/
|
||||||
|
export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
|
||||||
|
page: RevisionPageDocument;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base context for a request.
|
||||||
|
*/
|
||||||
|
export function getBaseContext(input: {
|
||||||
|
siteURL: URL | string;
|
||||||
|
urlMode: 'url' | 'url-host';
|
||||||
|
apiToken?: string | null;
|
||||||
|
}) {
|
||||||
|
const url = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
|
||||||
|
const urlMode = input.urlMode;
|
||||||
|
|
||||||
|
const dataFetcher = createDataFetcher({
|
||||||
|
apiToken: input.apiToken ?? GITBOOK_API_TOKEN,
|
||||||
|
apiEndpoint: GITBOOK_API_URL,
|
||||||
|
});
|
||||||
|
const gitbookURL = new URL(GITBOOK_URL);
|
||||||
|
|
||||||
|
const linker =
|
||||||
|
urlMode === 'url-host'
|
||||||
|
? createLinker({
|
||||||
|
host: url.host,
|
||||||
|
pathname: url.pathname,
|
||||||
|
})
|
||||||
|
: createLinker({
|
||||||
|
protocol: gitbookURL.protocol,
|
||||||
|
host: gitbookURL.host,
|
||||||
|
pathname: `/url/${url.host}${url.pathname}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (urlMode === 'url') {
|
||||||
|
// Create link in the same format for links to other sites/sections.
|
||||||
|
linker.toLinkForContent = (rawURL: string) => {
|
||||||
|
const urlObject = new URL(rawURL);
|
||||||
|
return `/url/${urlObject.host}${urlObject.pathname}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageResizer = createImageResizer({
|
||||||
|
host: urlMode === 'url-host' ? url.host : gitbookURL.host,
|
||||||
|
|
||||||
|
// To ensure image resizing work for proxied sites,
|
||||||
|
// we serve images from the root of the site.
|
||||||
|
linker: linker,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataFetcher,
|
||||||
|
linker,
|
||||||
|
imageResizer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the context of a site for a given URL and a base context.
|
||||||
|
*/
|
||||||
|
export async function fetchSiteContextByURL(
|
||||||
|
baseContext: GitBookBaseContext,
|
||||||
|
input: {
|
||||||
|
url: string;
|
||||||
|
visitorAuthToken: string | null;
|
||||||
|
redirectOnError: boolean;
|
||||||
|
}
|
||||||
|
): Promise<GitBookSiteContext> {
|
||||||
|
const { dataFetcher } = baseContext;
|
||||||
|
const data = await dataFetcher.getPublishedContentByUrl({
|
||||||
|
url: input.url,
|
||||||
|
visitorAuthToken: input.visitorAuthToken,
|
||||||
|
redirectOnError: input.redirectOnError,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchSiteContextByURLLookup(baseContext, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the context of a site using the resolution of a URL
|
||||||
|
*/
|
||||||
|
export async function fetchSiteContextByURLLookup(
|
||||||
|
baseContext: GitBookBaseContext,
|
||||||
|
data: PublishedSiteContentLookup
|
||||||
|
): Promise<GitBookSiteContext> {
|
||||||
|
const { dataFetcher } = baseContext;
|
||||||
|
if ('redirect' in data) {
|
||||||
|
redirect(data.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetchSiteContextByIds(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
dataFetcher: dataFetcher.withToken({
|
||||||
|
apiToken: data.apiToken,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
organization: data.organization,
|
||||||
|
site: data.site,
|
||||||
|
siteSection: data.siteSection,
|
||||||
|
siteSpace: data.siteSpace,
|
||||||
|
space: data.space,
|
||||||
|
shareKey: data.shareKey,
|
||||||
|
changeRequest: data.changeRequest,
|
||||||
|
revision: data.revision,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a site context by IDs.
|
||||||
|
*/
|
||||||
|
export async function fetchSiteContextByIds(
|
||||||
|
baseContext: GitBookBaseContext,
|
||||||
|
ids: {
|
||||||
|
organization: string;
|
||||||
|
site: string;
|
||||||
|
siteSection: string | undefined;
|
||||||
|
siteSpace: string | undefined;
|
||||||
|
space: string;
|
||||||
|
shareKey: string | undefined;
|
||||||
|
changeRequest: string | undefined;
|
||||||
|
revision: string | undefined;
|
||||||
|
}
|
||||||
|
): Promise<GitBookSiteContext> {
|
||||||
|
const { dataFetcher } = baseContext;
|
||||||
|
|
||||||
|
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
|
||||||
|
await Promise.all([
|
||||||
|
dataFetcher.getPublishedContentSite({
|
||||||
|
organizationId: ids.organization,
|
||||||
|
siteId: ids.site,
|
||||||
|
siteShareKey: ids.shareKey,
|
||||||
|
}),
|
||||||
|
fetchSpaceContextByIds(baseContext, ids),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// override the title with the customization title
|
||||||
|
// TODO: remove this hack once we have a proper way to handle site customizations
|
||||||
|
const site = {
|
||||||
|
...orgSite,
|
||||||
|
...(customizations.site?.title ? { title: customizations.site.title } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = ids.siteSection
|
||||||
|
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const siteSpace = (
|
||||||
|
siteStructure.type === 'siteSpaces' && siteStructure.structure
|
||||||
|
? siteStructure.structure
|
||||||
|
: sections?.current.siteSpaces
|
||||||
|
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||||
|
if (!siteSpace) {
|
||||||
|
throw new Error('Site space not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteSpaces =
|
||||||
|
siteStructure.type === 'siteSpaces'
|
||||||
|
? siteStructure.structure
|
||||||
|
: (sections?.current.siteSpaces ?? []);
|
||||||
|
|
||||||
|
const customization = (() => {
|
||||||
|
if (ids.siteSpace) {
|
||||||
|
const siteSpaceSettings = customizations.siteSpaces[ids.siteSpace];
|
||||||
|
if (siteSpaceSettings) {
|
||||||
|
return siteSpaceSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We got the pointer from an API and customizations from another.
|
||||||
|
// It's possible that the two are unsynced leading to not found customizations for the space.
|
||||||
|
// It's better to fallback on customization of the site that displaying an error.
|
||||||
|
console.warn('Customization not found for site space', ids.siteSpace);
|
||||||
|
}
|
||||||
|
|
||||||
|
return customizations.site;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...spaceContext,
|
||||||
|
organizationId: ids.organization,
|
||||||
|
site,
|
||||||
|
siteSpaces,
|
||||||
|
siteSpace,
|
||||||
|
customization,
|
||||||
|
structure: siteStructure,
|
||||||
|
sections,
|
||||||
|
scripts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a space context by IDs.
|
||||||
|
*/
|
||||||
|
export async function fetchSpaceContextByIds(
|
||||||
|
baseContext: GitBookBaseContext,
|
||||||
|
ids: {
|
||||||
|
space: string;
|
||||||
|
shareKey: string | undefined;
|
||||||
|
changeRequest: string | undefined;
|
||||||
|
revision: string | undefined;
|
||||||
|
}
|
||||||
|
): Promise<GitBookSpaceContext> {
|
||||||
|
const { dataFetcher } = baseContext;
|
||||||
|
|
||||||
|
const [space, changeRequest] = await Promise.all([
|
||||||
|
dataFetcher.getSpace({
|
||||||
|
spaceId: ids.space,
|
||||||
|
shareKey: ids.shareKey,
|
||||||
|
}),
|
||||||
|
ids.changeRequest
|
||||||
|
? dataFetcher.getChangeRequest({
|
||||||
|
spaceId: ids.space,
|
||||||
|
changeRequestId: ids.changeRequest,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
|
||||||
|
|
||||||
|
const pages = await dataFetcher.getRevisionPages({
|
||||||
|
spaceId: ids.space,
|
||||||
|
revisionId,
|
||||||
|
// We only care about the Git metadata when the Git sync is enabled,
|
||||||
|
// otherwise we can optimize performance by not fetching it
|
||||||
|
metadata: !!space.gitSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseContext,
|
||||||
|
organizationId: space.organization,
|
||||||
|
space,
|
||||||
|
pages,
|
||||||
|
changeRequest,
|
||||||
|
revisionId,
|
||||||
|
shareKey: ids.shareKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the context is the root one for a site.
|
||||||
|
* Meaning we are on the default section / space.
|
||||||
|
*/
|
||||||
|
export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
|
||||||
|
const { structure } = context;
|
||||||
|
switch (structure.type) {
|
||||||
|
case 'sections': {
|
||||||
|
return getSiteStructureSections(structure, { ignoreGroups: true }).some(
|
||||||
|
(structure) =>
|
||||||
|
structure.default &&
|
||||||
|
structure.id === context.sections?.current.id &&
|
||||||
|
structure.siteSpaces.some(
|
||||||
|
(siteSpace) => siteSpace.default && siteSpace.id === context.siteSpace.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'siteSpaces': {
|
||||||
|
return structure.structure.some(
|
||||||
|
(siteSpace) => siteSpace.default && siteSpace.id === context.siteSpace.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||||
|
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
||||||
|
const section = parseCurrentSection(structure, siteSectionId);
|
||||||
|
assert(section, 'A section must be defined when there are multiple sections');
|
||||||
|
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCurrentSection(structure: SiteStructure, siteSectionId: string) {
|
||||||
|
const sections = getSiteStructureSections(structure, { ignoreGroups: true });
|
||||||
|
return sections.find((section) => section.id === siteSectionId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
import { type ComputedContentSource, GitBookAPI } from '@gitbook/api';
|
||||||
|
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||||
|
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||||
|
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
|
import type { GitBookDataFetcher } from './types';
|
||||||
|
|
||||||
|
interface DataFetcherInput {
|
||||||
|
/**
|
||||||
|
* API host to use.
|
||||||
|
*/
|
||||||
|
apiEndpoint: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API token.
|
||||||
|
*/
|
||||||
|
apiToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonInput: DataFetcherInput = {
|
||||||
|
apiEndpoint: GITBOOK_API_URL,
|
||||||
|
apiToken: GITBOOK_API_TOKEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a data fetcher using an API token.
|
||||||
|
* The data are being cached by Next.js built-in cache.
|
||||||
|
*/
|
||||||
|
export function createDataFetcher(input: DataFetcherInput = commonInput): GitBookDataFetcher {
|
||||||
|
return {
|
||||||
|
apiEndpoint: input.apiEndpoint,
|
||||||
|
|
||||||
|
async api() {
|
||||||
|
return getAPI(input);
|
||||||
|
},
|
||||||
|
|
||||||
|
withToken({ apiToken }) {
|
||||||
|
return createDataFetcher({
|
||||||
|
...input,
|
||||||
|
apiToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// API that are tied to the token
|
||||||
|
//
|
||||||
|
getPublishedContentSite(params) {
|
||||||
|
return getPublishedContentSite(input, {
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
siteId: params.siteId,
|
||||||
|
siteShareKey: params.siteShareKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getSiteRedirectBySource(params) {
|
||||||
|
return getSiteRedirectBySource(input, {
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
siteId: params.siteId,
|
||||||
|
siteShareKey: params.siteShareKey,
|
||||||
|
source: params.source,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRevision(params) {
|
||||||
|
return getRevision(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
metadata: params.metadata,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRevisionPages(params) {
|
||||||
|
return getRevisionPages(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
metadata: params.metadata,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRevisionFile(params) {
|
||||||
|
return getRevisionFile(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
fileId: params.fileId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRevisionPageByPath(params) {
|
||||||
|
return getRevisionPageByPath(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
path: params.path,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getReusableContent(params) {
|
||||||
|
return getReusableContent(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
reusableContentId: params.reusableContentId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getLatestOpenAPISpecVersionContent(params) {
|
||||||
|
return getLatestOpenAPISpecVersionContent(input, {
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
slug: params.slug,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getSpace(params) {
|
||||||
|
return getSpace(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
shareKey: params.shareKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getChangeRequest(params) {
|
||||||
|
return getChangeRequest(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
changeRequestId: params.changeRequestId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getDocument(params) {
|
||||||
|
return getDocument(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
documentId: params.documentId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getComputedDocument(params) {
|
||||||
|
return getComputedDocument(input, {
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
source: params.source,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getEmbedByUrl(params) {
|
||||||
|
return getEmbedByUrl(input, {
|
||||||
|
url: params.url,
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchSiteContent(params) {
|
||||||
|
return searchSiteContent(input, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// API that are not tied to the token
|
||||||
|
// where the data is the same for all users
|
||||||
|
//
|
||||||
|
getUserById(userId) {
|
||||||
|
return getUserById(commonInput, userId);
|
||||||
|
},
|
||||||
|
getPublishedContentByUrl(params) {
|
||||||
|
return getPublishedContentByUrl(commonInput, {
|
||||||
|
url: params.url,
|
||||||
|
visitorAuthToken: params.visitorAuthToken,
|
||||||
|
redirectOnError: params.redirectOnError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserById(input: DataFetcherInput, userId: string) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).users.getUserById(userId);
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSpace(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
shareKey: string | undefined;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('days');
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'space',
|
||||||
|
space: params.spaceId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
|
||||||
|
shareKey: params.shareKey,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChangeRequest(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
changeRequestId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('minutes');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).spaces.getChangeRequestById(
|
||||||
|
params.spaceId,
|
||||||
|
params.changeRequestId
|
||||||
|
);
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'change-request',
|
||||||
|
space: params.spaceId,
|
||||||
|
changeRequest: res.data.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRevision(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
metadata: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
|
const res = await getAPI(input).spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||||
|
metadata: params.metadata,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRevisionPages(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
metadata: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
|
const res = await getAPI(input).spaces.listPagesInRevisionById(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
{
|
||||||
|
metadata: params.metadata,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.data.pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRevisionFile(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
fileId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).spaces.getFileInRevisionById(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
params.fileId
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRevisionPageByPath(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
|
const encodedPath = encodeURIComponent(params.path);
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).spaces.getPageInRevisionByPath(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
encodedPath
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
|
const res = await getAPI(input).spaces.getDocumentById(params.spaceId, params.documentId);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getComputedDocument(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
organizationId: string;
|
||||||
|
source: ComputedContentSource;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
cacheTag(
|
||||||
|
...getComputedContentSourceCacheTags(
|
||||||
|
{
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
},
|
||||||
|
params.source
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
|
||||||
|
source: params.source,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReusableContent(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
reusableContentId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('max');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).spaces.getReusableContentInRevisionById(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
params.reusableContentId
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLatestOpenAPISpecVersionContent(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
organizationId: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'openapi',
|
||||||
|
organization: params.organizationId,
|
||||||
|
openAPISpec: params.slug,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).orgs.getLatestOpenApiSpecVersionContent(
|
||||||
|
params.organizationId,
|
||||||
|
params.slug
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublishedContentByUrl(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
url: string;
|
||||||
|
visitorAuthToken: string | null;
|
||||||
|
redirectOnError: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
const { url, visitorAuthToken, redirectOnError } = params;
|
||||||
|
|
||||||
|
const hostname = new URL(url).hostname;
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'url',
|
||||||
|
hostname,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await getAPI(input).urls.getPublishedContentByUrl({
|
||||||
|
url,
|
||||||
|
visitorAuthToken: visitorAuthToken ?? undefined,
|
||||||
|
redirectOnError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('site' in res.data) {
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'site',
|
||||||
|
site: res.data.site,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublishedContentSite(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
siteShareKey: string | undefined;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'site',
|
||||||
|
site: params.siteId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await getAPI(input).orgs.getPublishedContentSite(
|
||||||
|
params.organizationId,
|
||||||
|
params.siteId,
|
||||||
|
{
|
||||||
|
shareKey: params.siteShareKey,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteRedirectBySource(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
siteShareKey: string | undefined;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'site',
|
||||||
|
site: params.siteId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAPI(input).orgs.getSiteRedirectBySource(
|
||||||
|
params.organizationId,
|
||||||
|
params.siteId,
|
||||||
|
{
|
||||||
|
shareKey: params.siteShareKey,
|
||||||
|
source: params.source,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
// 422 is returned when the source is invalid
|
||||||
|
// we don't want to throw but just return null
|
||||||
|
if (checkHasErrorCode(error, 422)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkHasErrorCode(error, 404)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmbedByUrl(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: {
|
||||||
|
url: string;
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheLife('weeks');
|
||||||
|
|
||||||
|
const api = getAPI(input);
|
||||||
|
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchSiteContent(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
||||||
|
) {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
const { organizationId, siteId, query, scope } = params;
|
||||||
|
|
||||||
|
cacheLife('days');
|
||||||
|
|
||||||
|
const res = await getAPI(input).orgs.searchSiteContent(organizationId, siteId, {
|
||||||
|
query,
|
||||||
|
...scope,
|
||||||
|
});
|
||||||
|
return res.data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAPI(input: DataFetcherInput) {
|
||||||
|
const { apiEndpoint, apiToken } = input;
|
||||||
|
const api = new GitBookAPI({
|
||||||
|
authToken: apiToken ?? undefined,
|
||||||
|
endpoint: apiEndpoint,
|
||||||
|
userAgent: GITBOOK_USER_AGENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkHasErrorCode(error: unknown, code: number) {
|
||||||
|
return error instanceof Error && 'code' in error && error.code === code;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './api';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
|
export * from './urls';
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type * as api from '@gitbook/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic fetcher for GitBook data.
|
||||||
|
* It is used between v1 and v2.
|
||||||
|
*/
|
||||||
|
export interface GitBookDataFetcher {
|
||||||
|
/**
|
||||||
|
* Endpoint of the API.
|
||||||
|
*/
|
||||||
|
apiEndpoint: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an API client for the current context.
|
||||||
|
*/
|
||||||
|
api(): Promise<api.GitBookAPI>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a data fetcher authenticated with a specific token.
|
||||||
|
*/
|
||||||
|
withToken(input: {
|
||||||
|
apiToken: string;
|
||||||
|
}): GitBookDataFetcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user by its ID.
|
||||||
|
*/
|
||||||
|
getUserById(userId: string): Promise<api.User | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a published content by its URL.
|
||||||
|
*/
|
||||||
|
getPublishedContentByUrl(params: {
|
||||||
|
url: string;
|
||||||
|
visitorAuthToken: string | null;
|
||||||
|
redirectOnError: boolean;
|
||||||
|
}): Promise<api.PublishedSiteContentLookup>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a published content site by its organization ID and site ID.
|
||||||
|
*/
|
||||||
|
getPublishedContentSite(params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
siteShareKey: string | undefined;
|
||||||
|
}): Promise<api.PublishedContentSite>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a space by its ID.
|
||||||
|
*/
|
||||||
|
getSpace(params: { spaceId: string; shareKey: string | undefined }): Promise<api.Space>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a change request by its space ID and change request ID.
|
||||||
|
*/
|
||||||
|
getChangeRequest(params: {
|
||||||
|
spaceId: string;
|
||||||
|
changeRequestId: string;
|
||||||
|
}): Promise<api.ChangeRequest | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the revision by its space ID and revision ID.
|
||||||
|
*/
|
||||||
|
getRevision(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
metadata: boolean;
|
||||||
|
}): Promise<api.Revision>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the revision pages by its space ID and revision ID.
|
||||||
|
*/
|
||||||
|
getRevisionPages(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
metadata: boolean;
|
||||||
|
}): Promise<api.RevisionPage[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a revision file by its space ID, revision ID and file ID.
|
||||||
|
*/
|
||||||
|
getRevisionFile(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
fileId: string;
|
||||||
|
}): Promise<api.RevisionFile | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a revision page by its path.
|
||||||
|
*/
|
||||||
|
getRevisionPageByPath(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
path: string;
|
||||||
|
}): Promise<api.RevisionPageDocument | api.RevisionPageGroup | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a document by its space ID and document ID.
|
||||||
|
*/
|
||||||
|
getDocument(params: { spaceId: string; documentId: string }): Promise<api.JSONDocument>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a computed document by its space ID and computed source.
|
||||||
|
*/
|
||||||
|
getComputedDocument(params: {
|
||||||
|
organizationId: string;
|
||||||
|
spaceId: string;
|
||||||
|
source: api.ComputedContentSource;
|
||||||
|
}): Promise<api.JSONDocument>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a reusable content by its space ID, revision ID and reusable content ID.
|
||||||
|
*/
|
||||||
|
getReusableContent(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
reusableContentId: string;
|
||||||
|
}): Promise<api.RevisionReusableContent | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest OpenAPI spec version content by its organization ID and slug.
|
||||||
|
*/
|
||||||
|
getLatestOpenAPISpecVersionContent(params: {
|
||||||
|
organizationId: string;
|
||||||
|
slug: string;
|
||||||
|
}): Promise<api.OpenAPISpecContent | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a site redirect by its source path.
|
||||||
|
*/
|
||||||
|
getSiteRedirectBySource(params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
siteShareKey: string | undefined;
|
||||||
|
source: string;
|
||||||
|
}): Promise<{ redirect: api.SiteRedirect | null; target: string } | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an embed by its URL.
|
||||||
|
*/
|
||||||
|
getEmbedByUrl(params: { url: string; spaceId: string }): Promise<api.Embed>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search content in a site.
|
||||||
|
*/
|
||||||
|
searchSiteContent(params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
query: string;
|
||||||
|
scope:
|
||||||
|
| { mode: 'all' }
|
||||||
|
| { mode: 'current'; siteSpaceId: string }
|
||||||
|
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||||
|
/** Cache bust to ensure the search results are fresh when the space is updated. */
|
||||||
|
cacheBust?: string;
|
||||||
|
}): Promise<api.SearchSpaceResult[]>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
import { getURLLookupAlternatives, normalizeURL } from './middleware';
|
import { getURLLookupAlternatives, normalizeURL } from './urls';
|
||||||
|
|
||||||
describe('getURLLookupAlternatives', () => {
|
describe('getURLLookupAlternatives', () => {
|
||||||
it('should return all URLs up to the root', () => {
|
it('should return all URLs up to the root', () => {
|
||||||
@@ -22,6 +22,44 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
{
|
{
|
||||||
extraPath: 'c',
|
extraPath: 'c',
|
||||||
url: 'https://docs.mycompany.com/a/b',
|
url: 'https://docs.mycompany.com/a/b',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: '',
|
||||||
|
url: 'https://docs.mycompany.com/a/b/c',
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c/d'))).toEqual({
|
||||||
|
revision: undefined,
|
||||||
|
changeRequest: undefined,
|
||||||
|
basePath: undefined,
|
||||||
|
urls: [
|
||||||
|
{
|
||||||
|
extraPath: 'a/b/c/d',
|
||||||
|
url: 'https://docs.mycompany.com/',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'b/c/d',
|
||||||
|
url: 'https://docs.mycompany.com/a',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'c/d',
|
||||||
|
url: 'https://docs.mycompany.com/a/b',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'd',
|
||||||
|
url: 'https://docs.mycompany.com/a/b/c',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: '',
|
||||||
|
url: 'https://docs.mycompany.com/a/b/c/d',
|
||||||
primary: true,
|
primary: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -30,7 +68,7 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
|
|
||||||
it('should not match before the variant for a variant url', () => {
|
it('should not match before the variant for a variant url', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(new URL('https://test.gitbook.io/v/variant/space')),
|
getURLLookupAlternatives(new URL('https://test.gitbook.io/v/variant/space'))
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: undefined,
|
revision: undefined,
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
@@ -47,7 +85,7 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
|
|
||||||
it('should not match before the variant for a variant in a share link', () => {
|
it('should not match before the variant for a variant in a share link', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(new URL('https://test.gitbook.io/sharelink/v/variant/space')),
|
getURLLookupAlternatives(new URL('https://test.gitbook.io/sharelink/v/variant/space'))
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: undefined,
|
revision: undefined,
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
@@ -65,8 +103,8 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
it('should not match before a revision in a variant', () => {
|
it('should not match before a revision in a variant', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(
|
getURLLookupAlternatives(
|
||||||
new URL('https://test.gitbook.io/v/variant/~/revisions/id/rest'),
|
new URL('https://test.gitbook.io/v/variant/~/revisions/id/rest')
|
||||||
),
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: 'id',
|
revision: 'id',
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
@@ -83,7 +121,7 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
|
|
||||||
it('should not match before a revision ID', () => {
|
it('should not match before a revision ID', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(new URL('https://docs.mycompany.com/~/revisions/id/a/b/c')),
|
getURLLookupAlternatives(new URL('https://docs.mycompany.com/~/revisions/id/a/b/c'))
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: 'id',
|
revision: 'id',
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
@@ -100,7 +138,7 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
|
|
||||||
it('should not match before a change request ID', () => {
|
it('should not match before a change request ID', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(new URL('https://docs.mycompany.com/~/changes/id/hello')),
|
getURLLookupAlternatives(new URL('https://docs.mycompany.com/~/changes/id/hello'))
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: undefined,
|
revision: undefined,
|
||||||
changeRequest: 'id',
|
changeRequest: 'id',
|
||||||
@@ -157,7 +195,7 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
|
|
||||||
it('should match a variant in a share-link', () => {
|
it('should match a variant in a share-link', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(new URL('https://test.gitbook.io/sharelink/v/variant/space')),
|
getURLLookupAlternatives(new URL('https://test.gitbook.io/sharelink/v/variant/space'))
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: undefined,
|
revision: undefined,
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
@@ -175,8 +213,8 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
it('should match a revision in a variant in a share-link', () => {
|
it('should match a revision in a variant in a share-link', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(
|
getURLLookupAlternatives(
|
||||||
new URL('https://test.gitbook.io/sharelink/v/variant/~/revisions/id/a/b/c'),
|
new URL('https://test.gitbook.io/sharelink/v/variant/~/revisions/id/a/b/c')
|
||||||
),
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: 'id',
|
revision: 'id',
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
@@ -194,8 +232,8 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
it('should match a change request in a variant in a share-link', () => {
|
it('should match a change request in a variant in a share-link', () => {
|
||||||
expect(
|
expect(
|
||||||
getURLLookupAlternatives(
|
getURLLookupAlternatives(
|
||||||
new URL('https://test.gitbook.io/sharelink/v/variant/~/changes/id/a/b/c'),
|
new URL('https://test.gitbook.io/sharelink/v/variant/~/changes/id/a/b/c')
|
||||||
),
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
revision: undefined,
|
revision: undefined,
|
||||||
changeRequest: 'id',
|
changeRequest: 'id',
|
||||||
@@ -211,24 +249,34 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should limit depth', () => {
|
it('should limit depth', () => {
|
||||||
expect(getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c/d'))).toEqual({
|
expect(getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c/d/e'))).toEqual({
|
||||||
revision: undefined,
|
revision: undefined,
|
||||||
changeRequest: undefined,
|
changeRequest: undefined,
|
||||||
basePath: undefined,
|
basePath: undefined,
|
||||||
urls: [
|
urls: [
|
||||||
{
|
{
|
||||||
extraPath: 'a/b/c/d',
|
extraPath: 'a/b/c/d/e',
|
||||||
url: 'https://docs.mycompany.com/',
|
url: 'https://docs.mycompany.com/',
|
||||||
primary: false,
|
primary: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extraPath: 'b/c/d',
|
extraPath: 'b/c/d/e',
|
||||||
url: 'https://docs.mycompany.com/a',
|
url: 'https://docs.mycompany.com/a',
|
||||||
primary: false,
|
primary: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
extraPath: 'c/d',
|
extraPath: 'c/d/e',
|
||||||
url: 'https://docs.mycompany.com/a/b',
|
url: 'https://docs.mycompany.com/a/b',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'd/e',
|
||||||
|
url: 'https://docs.mycompany.com/a/b/c',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'e',
|
||||||
|
url: 'https://docs.mycompany.com/a/b/c/d',
|
||||||
primary: true,
|
primary: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -254,7 +302,17 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
{
|
{
|
||||||
extraPath: 'b/c/d',
|
extraPath: 'b/c/d',
|
||||||
url: 'https://docs.mycompany.com/a/~',
|
url: 'https://docs.mycompany.com/a/~',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'c/d',
|
||||||
|
url: 'https://docs.mycompany.com/a/~/b',
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraPath: 'd',
|
||||||
primary: true,
|
primary: true,
|
||||||
|
url: 'https://docs.mycompany.com/a/~/b/c',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -309,13 +367,13 @@ describe('getURLLookupAlternatives', () => {
|
|||||||
describe('normalizeURL', () => {
|
describe('normalizeURL', () => {
|
||||||
it('should remove trailing slashes', () => {
|
it('should remove trailing slashes', () => {
|
||||||
expect(normalizeURL(new URL('https://docs.mycompany.com/hello/'))).toEqual(
|
expect(normalizeURL(new URL('https://docs.mycompany.com/hello/'))).toEqual(
|
||||||
new URL('https://docs.mycompany.com/hello'),
|
new URL('https://docs.mycompany.com/hello')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove duplicate slashes', () => {
|
it('should remove duplicate slashes', () => {
|
||||||
expect(normalizeURL(new URL('https://docs.mycompany.com//hello//there'))).toEqual(
|
expect(normalizeURL(new URL('https://docs.mycompany.com//hello//there'))).toEqual(
|
||||||
new URL('https://docs.mycompany.com/hello/there'),
|
new URL('https://docs.mycompany.com/hello/there')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,121 @@
|
|||||||
|
import { race, tryCatch } from '@/lib/async';
|
||||||
|
import { joinPath } from '@/lib/paths';
|
||||||
|
import { GitBookAPI, type GitBookAPIError, type PublishedSiteContentLookup } from '@gitbook/api';
|
||||||
|
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: {
|
||||||
|
url: string;
|
||||||
|
visitorAuthToken: string | null;
|
||||||
|
redirectOnError: boolean;
|
||||||
|
}): Promise<
|
||||||
|
| { data: PublishedSiteContentLookup; error?: undefined }
|
||||||
|
| { data?: undefined; error: Error | GitBookAPIError }
|
||||||
|
> {
|
||||||
|
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 = new GitBookAPI({
|
||||||
|
authToken: GITBOOK_API_TOKEN ?? undefined,
|
||||||
|
endpoint: GITBOOK_API_URL,
|
||||||
|
userAgent: GITBOOK_USER_AGENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const callResult = await tryCatch(
|
||||||
|
api.urls.getPublishedContentByUrl(
|
||||||
|
{
|
||||||
|
url: alternative.url,
|
||||||
|
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||||
|
redirectOnError: input.redirectOnError,
|
||||||
|
cache: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
'x-gitbook-force-cache': 'true',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (callResult.error) {
|
||||||
|
if (alternative.primary) {
|
||||||
|
// We only return an error for the primary alternative (full URL),
|
||||||
|
// as other parts could result in errors due to the URL being incomplete (share links, etc).
|
||||||
|
return { error: callResult.error };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { data },
|
||||||
|
} = callResult;
|
||||||
|
|
||||||
|
if ('redirect' in data) {
|
||||||
|
if (alternative.primary) {
|
||||||
|
// Append the path to the redirect URL
|
||||||
|
// because we might have matched a shorter path and the redirect is relative to it
|
||||||
|
if (alternative.extraPath) {
|
||||||
|
if (data.target === 'content') {
|
||||||
|
const redirect = new URL(data.redirect);
|
||||||
|
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
|
||||||
|
data.redirect = redirect.toString();
|
||||||
|
} else {
|
||||||
|
const redirect = new URL(data.redirect);
|
||||||
|
if (redirect.searchParams.has('location')) {
|
||||||
|
redirect.searchParams.set(
|
||||||
|
'location',
|
||||||
|
joinPath(
|
||||||
|
redirect.searchParams.get('location') ?? '',
|
||||||
|
alternative.extraPath
|
||||||
|
)
|
||||||
|
);
|
||||||
|
data.redirect = redirect.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We use the following criteria to determine if the lookup result is the right one:
|
||||||
|
* - the primary alternative was resolved (because that's the longest or most inclusive path)
|
||||||
|
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
|
||||||
|
*
|
||||||
|
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
|
||||||
|
*/
|
||||||
|
if (alternative.primary || ('site' in data && data.complete)) {
|
||||||
|
const siteResult: PublishedSiteContentLookup = {
|
||||||
|
...data,
|
||||||
|
changeRequest: data.changeRequest ?? lookup.changeRequest,
|
||||||
|
revision: data.revision ?? lookup.revision,
|
||||||
|
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||||
|
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||||
|
};
|
||||||
|
return { data: siteResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
error: new Error('No content found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
|
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
|
||||||
* The approach is optimized to aim at reusing cached lookup results as much as possible.
|
* The approach is optimized to aim at reusing cached lookup results as much as possible.
|
||||||
@@ -33,7 +151,7 @@ export function getURLLookupAlternatives(input: URL) {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid extraPath ${extraPath} for url ${adding.toString()}, already set to ${
|
`Invalid extraPath ${extraPath} for url ${adding.toString()}, already set to ${
|
||||||
existing.extraPath
|
existing.extraPath
|
||||||
}`,
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -90,8 +208,8 @@ export function getURLLookupAlternatives(input: URL) {
|
|||||||
pushAlternative(noPathURL, url.pathname.slice(1));
|
pushAlternative(noPathURL, url.pathname.slice(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise match with the first two segments of the path
|
// Otherwise match with the first four segments of the path
|
||||||
for (let i = 1; i <= 2; i++) {
|
for (let i = 1; i <= 4; i++) {
|
||||||
if (pathSegments.length >= i) {
|
if (pathSegments.length >= i) {
|
||||||
const shortURL = new URL(url);
|
const shortURL = new URL(url);
|
||||||
shortURL.pathname = pathSegments.slice(0, i).join('/');
|
shortURL.pathname = pathSegments.slice(0, i).join('/');
|
||||||
@@ -119,3 +237,12 @@ export function normalizeURL(url: URL) {
|
|||||||
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
|
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the search params from a URL
|
||||||
|
*/
|
||||||
|
export function stripURLSearch(url: URL): URL {
|
||||||
|
const stripped = new URL(url.toString());
|
||||||
|
stripped.search = '';
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { RevisionPageDocument, Space } from '@gitbook/api';
|
||||||
|
import type { GitBookDataFetcher } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the document for a page.
|
||||||
|
*/
|
||||||
|
export async function getPageDocument(
|
||||||
|
dataFetcher: GitBookDataFetcher,
|
||||||
|
space: Space,
|
||||||
|
page: RevisionPageDocument
|
||||||
|
) {
|
||||||
|
if (page.documentId) {
|
||||||
|
return dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId });
|
||||||
|
}
|
||||||
|
if (page.computed) {
|
||||||
|
return dataFetcher.getComputedDocument({
|
||||||
|
organizationId: space.organization,
|
||||||
|
spaceId: space.id,
|
||||||
|
source: page.computed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Main host on which GitBook is running.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_URL =
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? 'http://localhost:3000'
|
||||||
|
: ((process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ??
|
||||||
|
process.env.GITBOOK_URL ??
|
||||||
|
'');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL at which static assets are served.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_ASSETS_URL =
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? 'http://localhost:3000'
|
||||||
|
: (process.env.GITBOOK_ASSETS_PREFIX ?? GITBOOK_URL);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitBook app URL.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_APP_URL = process.env.NEXT_PUBLIC_GITBOOK_APP_URL ?? 'https://app.gitbook.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default GitBook API URL endpoint.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default GitBook API token.
|
||||||
|
* It can be use to avoid rate-limiting.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User agent to use for API requests.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to disable tracking of events into site insights.
|
||||||
|
* This is used to disable tracking in development mode.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_DISABLE_TRACKING = Boolean(
|
||||||
|
!!process.env.GITBOOK_DISABLE_TRACKING || process.env.NODE_ENV !== 'production'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hostname serving the integrations.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_INTEGRATIONS_HOST =
|
||||||
|
process.env.GITBOOK_INTEGRATIONS_HOST ?? 'integrations.gitbook.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint to use for resizing images.
|
||||||
|
* It should be a Cloudflare domain with image resizing enabled.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ?? null;
|
||||||
|
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
||||||
|
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||||
|
import type { GitBookSpaceLinker } from '../links';
|
||||||
|
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||||
|
import type { ImageResizer } from './types';
|
||||||
|
|
||||||
|
interface CloudflareImageJsonFormat {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
original: {
|
||||||
|
file_size: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
format: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
|
||||||
|
*/
|
||||||
|
export interface CloudflareImageOptions {
|
||||||
|
format?: 'webp' | 'avif' | 'json' | 'jpeg';
|
||||||
|
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
dpr?: number;
|
||||||
|
anim?: boolean;
|
||||||
|
quality?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an image resizer for a rendering context.
|
||||||
|
*/
|
||||||
|
export function createImageResizer({
|
||||||
|
host,
|
||||||
|
linker,
|
||||||
|
}: {
|
||||||
|
/** The linker to use to create URLs. */
|
||||||
|
linker: GitBookSpaceLinker;
|
||||||
|
/** The host name of the current site. */
|
||||||
|
host: string;
|
||||||
|
}): ImageResizer {
|
||||||
|
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
||||||
|
return createNoopImageResizer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getResizedImageURL: (urlInput) => {
|
||||||
|
if (!checkIsSizableImageURL(urlInput)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedSignature: {
|
||||||
|
signature: string;
|
||||||
|
version: SignatureVersion;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
return async (options) => {
|
||||||
|
cachedSignature ??= await generateImageSignature({
|
||||||
|
host,
|
||||||
|
url: urlInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
linker.toAbsoluteURL(linker.toPathInContent('/~gitbook/image'))
|
||||||
|
);
|
||||||
|
url.searchParams.set('url', getImageAPIUrl(urlInput));
|
||||||
|
|
||||||
|
if (options.width) {
|
||||||
|
url.searchParams.set('width', options.width.toString());
|
||||||
|
}
|
||||||
|
if (options.height) {
|
||||||
|
url.searchParams.set('height', options.height.toString());
|
||||||
|
}
|
||||||
|
if (options.dpr) {
|
||||||
|
url.searchParams.set('dpr', options.dpr.toString());
|
||||||
|
}
|
||||||
|
if (options.quality) {
|
||||||
|
url.searchParams.set('quality', options.quality.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
url.searchParams.set('sign', cachedSignature.signature);
|
||||||
|
url.searchParams.set('sv', cachedSignature.version);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageSize: async (input, options) => {
|
||||||
|
if (!checkIsSizableImageURL(input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getImageSize(input, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an image resizer that doesn't do any resizing.
|
||||||
|
*/
|
||||||
|
export function createNoopImageResizer(): ImageResizer {
|
||||||
|
return {
|
||||||
|
getResizedImageURL: () => null,
|
||||||
|
getImageSize: async (_input) => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is an HTTP URL.
|
||||||
|
*/
|
||||||
|
export function checkIsHttpURL(input: string | URL): boolean {
|
||||||
|
if (!URL.canParse(input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parsed = new URL(input);
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an image URL is resizable.
|
||||||
|
* Skip it for non-http(s) URLs (data, etc).
|
||||||
|
* Skip it for SVGs.
|
||||||
|
* Skip it for GitBook images (to avoid recursion).
|
||||||
|
*/
|
||||||
|
export function checkIsSizableImageURL(input: string): boolean {
|
||||||
|
if (!URL.canParse(input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.includes('/~gitbook/image')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(input);
|
||||||
|
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!checkIsHttpURL(parsed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of an image.
|
||||||
|
*/
|
||||||
|
export async function getImageSize(
|
||||||
|
input: string,
|
||||||
|
defaultSize: Partial<CloudflareImageOptions> = {}
|
||||||
|
): Promise<{ width: number; height: number } | null> {
|
||||||
|
if (!checkIsSizableImageURL(input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await resizeImage(input, {
|
||||||
|
// Abort the request after 2 seconds to avoid blocking rendering for too long
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
// Measure size and resize it to the most common size
|
||||||
|
// to optimize caching
|
||||||
|
...defaultSize,
|
||||||
|
format: 'json',
|
||||||
|
anim: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (await response.json()) as CloudflareImageJsonFormat;
|
||||||
|
return {
|
||||||
|
width: json.original.width,
|
||||||
|
height: json.original.height,
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Cloudflare Image Resize operation on an image.
|
||||||
|
*/
|
||||||
|
export async function resizeImage(
|
||||||
|
input: string,
|
||||||
|
options: CloudflareImageOptions & {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
): Promise<Response> {
|
||||||
|
const { signal, ...resizeOptions } = options;
|
||||||
|
|
||||||
|
const parsed = new URL(input);
|
||||||
|
if (parsed.protocol === 'data:') {
|
||||||
|
throw new Error('Cannot resize data: URLs');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.hostname === 'localhost') {
|
||||||
|
throw new Error('Cannot resize localhost URLs');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
|
||||||
|
// we need to use the Cloudflare Image Resize API directly.
|
||||||
|
if (!GITBOOK_IMAGE_RESIZE_URL) {
|
||||||
|
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetch(
|
||||||
|
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
|
||||||
|
resizeOptions
|
||||||
|
)}/${encodeURIComponent(input)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||||
|
Accept:
|
||||||
|
resizeOptions.format === 'json'
|
||||||
|
? 'application/json'
|
||||||
|
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyOptions(options: CloudflareImageOptions): string {
|
||||||
|
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
|
||||||
|
return `${rest}${rest ? ',' : ''}${key}=${value}`;
|
||||||
|
}, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
|
||||||
|
* We protect against it by converting to a special token, and then parsing
|
||||||
|
* the token in the image API.
|
||||||
|
*/
|
||||||
|
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare a URL for the GitBook Open Image API.
|
||||||
|
*/
|
||||||
|
export function getImageAPIUrl(url: string): string {
|
||||||
|
return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImageAPIURL(url: string): string {
|
||||||
|
return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './createImageResizer';
|
||||||
|
export * from './signatures';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import fnv1a from '@sindresorhus/fnv1a';
|
||||||
|
import type { MaybePromise } from 'p-map';
|
||||||
|
import { assert } from 'ts-essentials';
|
||||||
|
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY } from '../env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitBook has supported different version of image signing in the past. To maintain backwards
|
||||||
|
* compatibility, we retain the ability to verify older signatures.
|
||||||
|
*/
|
||||||
|
export type SignatureVersion = '0' | '1' | '2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current version of the signature.
|
||||||
|
*/
|
||||||
|
export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
|
||||||
|
|
||||||
|
type SignFnInput = {
|
||||||
|
url: string;
|
||||||
|
host: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SignFn = (input: SignFnInput) => MaybePromise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signature of an image URL
|
||||||
|
*/
|
||||||
|
export async function verifyImageSignature(
|
||||||
|
input: SignFnInput,
|
||||||
|
{ signature, version }: { signature: string; version: SignatureVersion }
|
||||||
|
): Promise<boolean> {
|
||||||
|
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
||||||
|
const generated = await generator(input);
|
||||||
|
return generated === signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an image signature. Also returns the version of the image signing algorithm that was used.
|
||||||
|
*
|
||||||
|
* This function is sync. If you need to implement an async version of image signing, you'll need to change
|
||||||
|
* ths signature of this fn and where it's used.
|
||||||
|
*/
|
||||||
|
export async function generateImageSignature(input: SignFnInput): Promise<{
|
||||||
|
signature: string;
|
||||||
|
version: SignatureVersion;
|
||||||
|
}> {
|
||||||
|
const result = await generateSignatureV2(input);
|
||||||
|
return { signature: result, version: CURRENT_SIGNATURE_VERSION };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reused buffer for FNV-1a hashing in the v2 algorithm
|
||||||
|
const fnv1aUtf8Buffer = new Uint8Array(512);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a signature for an image.
|
||||||
|
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
|
||||||
|
*/
|
||||||
|
const generateSignatureV2: SignFn = async (input) => {
|
||||||
|
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||||
|
const all = [
|
||||||
|
input.url,
|
||||||
|
input.host, // The hostname is used to avoid serving images from other sites on the same domain
|
||||||
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(':');
|
||||||
|
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reused buffer for FNV-1a hashing in the v1 algorithm
|
||||||
|
const fnv1aUtf8BufferV1 = new Uint8Array(512);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New and faster algorithm to generate a signature for an image.
|
||||||
|
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
|
||||||
|
* to know that it was the algorithm that was used.
|
||||||
|
*/
|
||||||
|
const generateSignatureV1: SignFn = async (input) => {
|
||||||
|
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||||
|
const all = [input.url, GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||||
|
return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
|
||||||
|
* We still need it to validate older signatures that were generated without versioning
|
||||||
|
* but still exist in previously generated and cached content.
|
||||||
|
*/
|
||||||
|
const generateSignatureV0: SignFn = async (input) => {
|
||||||
|
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||||
|
const all = [input.url, GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
|
||||||
|
|
||||||
|
// Convert ArrayBuffer to hex string
|
||||||
|
const hashArray = Array.from(new Uint8Array(hash));
|
||||||
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return hashHex;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mapping of signature versions to signature functions.
|
||||||
|
*/
|
||||||
|
const IMAGE_SIGNATURE_FUNCTIONS: Record<SignatureVersion, SignFn> = {
|
||||||
|
'0': generateSignatureV0,
|
||||||
|
'1': generateSignatureV1,
|
||||||
|
'2': generateSignatureV2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isSignatureVersion(input: string): input is SignatureVersion {
|
||||||
|
return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type GetImageSizeOptions = {
|
||||||
|
dpr?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResizeImageOptions = GetImageSizeOptions & {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
dpr?: number;
|
||||||
|
quality?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImageSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageResizer = {
|
||||||
|
/**
|
||||||
|
* Resize an image.
|
||||||
|
* @param input - The image URL to resize.
|
||||||
|
* @param options - The options to resize the image.
|
||||||
|
*/
|
||||||
|
getResizedImageURL(imageURL: string): null | ((options: ResizeImageOptions) => Promise<string>);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of an image.
|
||||||
|
*/
|
||||||
|
getImageSize(imageURL: string, options: GetImageSizeOptions): Promise<ImageSize | null>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ImageResizer, ResizeImageOptions } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick utility to get a resized image URL.
|
||||||
|
*/
|
||||||
|
export async function getResizedImageURL(
|
||||||
|
resizer: ImageResizer | undefined,
|
||||||
|
url: string,
|
||||||
|
options: ResizeImageOptions
|
||||||
|
) {
|
||||||
|
const getURL = resizer?.getResizedImageURL(url);
|
||||||
|
if (!getURL) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getURL(options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { appendBasePathToLinker, createLinker } from './links';
|
||||||
|
|
||||||
|
const root = createLinker({
|
||||||
|
host: 'docs.company.com',
|
||||||
|
pathname: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
const variantInSection = createLinker({
|
||||||
|
host: 'docs.company.com',
|
||||||
|
pathname: '/section/variant',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toPathInContent', () => {
|
||||||
|
it('should return the correct path', () => {
|
||||||
|
expect(root.toPathInContent('some/path')).toBe('/some/path');
|
||||||
|
expect(variantInSection.toPathInContent('some/path')).toBe('/section/variant/some/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle leading slash', () => {
|
||||||
|
expect(root.toPathInContent('/some/path')).toBe('/some/path');
|
||||||
|
expect(variantInSection.toPathInContent('/some/path')).toBe('/section/variant/some/path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toAbsoluteURL', () => {
|
||||||
|
it('should return the correct path', () => {
|
||||||
|
expect(root.toAbsoluteURL('some/path')).toBe('https://docs.company.com/some/path');
|
||||||
|
expect(variantInSection.toAbsoluteURL('some/path')).toBe(
|
||||||
|
'https://docs.company.com/some/path'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appendBasePathToLinker', () => {
|
||||||
|
const prefixedRoot = appendBasePathToLinker(root, '/section/variant');
|
||||||
|
const prefixedVariantInSection = appendBasePathToLinker(variantInSection, '/base');
|
||||||
|
|
||||||
|
describe('toPathInContent', () => {
|
||||||
|
it('should return the correct path', () => {
|
||||||
|
expect(prefixedRoot.toPathInContent('some/path')).toBe('/section/variant/some/path');
|
||||||
|
expect(prefixedVariantInSection.toPathInContent('some/path')).toBe(
|
||||||
|
'/section/variant/base/some/path'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toAbsoluteURL', () => {
|
||||||
|
it('should return the correct path', () => {
|
||||||
|
expect(prefixedRoot.toAbsoluteURL('some/path')).toBe(
|
||||||
|
'https://docs.company.com/some/path'
|
||||||
|
);
|
||||||
|
expect(prefixedVariantInSection.toAbsoluteURL('some/path')).toBe(
|
||||||
|
'https://docs.company.com/some/path'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { getPagePath } from '@/lib/pages';
|
||||||
|
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic interface to generate links based on a given context.
|
||||||
|
*
|
||||||
|
* URL levels:
|
||||||
|
*
|
||||||
|
* https://docs.company.com/section/variant/page
|
||||||
|
*
|
||||||
|
* toPathInContent('some/path') => /section/variant/some/path
|
||||||
|
* toPathForPage({ pages, page }) => /section/variant/some/path
|
||||||
|
* toAbsoluteURL('some/path') => https://docs.company.com/some/path
|
||||||
|
*/
|
||||||
|
export interface GitBookSpaceLinker {
|
||||||
|
/**
|
||||||
|
* Generate an absolute path for a relative path to the current content.
|
||||||
|
*/
|
||||||
|
toPathInContent(relativePath: string): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an absolute path for a page in the current content.
|
||||||
|
* The result should NOT be passed to `toPathInContent`.
|
||||||
|
*/
|
||||||
|
toPathForPage(input: {
|
||||||
|
pages: RevisionPage[];
|
||||||
|
page: RevisionPageDocument | RevisionPageGroup;
|
||||||
|
anchor?: string;
|
||||||
|
}): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an absolute URL for a given path relative to the host of the current content.
|
||||||
|
*/
|
||||||
|
toAbsoluteURL(absolutePath: string): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a link (URL or path) for a GitBook content URL (url of another site)
|
||||||
|
*/
|
||||||
|
toLinkForContent(url: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a linker to resolve links in a context being served on a specific URL.
|
||||||
|
*/
|
||||||
|
export function createLinker(
|
||||||
|
/** Where the top of the space is served on */
|
||||||
|
servedOn: {
|
||||||
|
protocol?: string;
|
||||||
|
host: string;
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
): GitBookSpaceLinker {
|
||||||
|
if (servedOn.host.includes('/')) {
|
||||||
|
throw new Error('Host cannot include a slash');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linker: GitBookSpaceLinker = {
|
||||||
|
toPathInContent(relativePath: string): string {
|
||||||
|
return joinPaths(servedOn.pathname, relativePath);
|
||||||
|
},
|
||||||
|
|
||||||
|
toAbsoluteURL(absolutePath: string): string {
|
||||||
|
return `${servedOn.protocol ?? 'https:'}//${joinPaths(servedOn.host, absolutePath)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
toPathForPage({ pages, page, anchor }) {
|
||||||
|
return linker.toPathInContent(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||||
|
},
|
||||||
|
|
||||||
|
toLinkForContent(url: string): string {
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return linker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a prefix to a linker.
|
||||||
|
*/
|
||||||
|
export function appendBasePathToLinker(
|
||||||
|
linker: GitBookSpaceLinker,
|
||||||
|
basePath: string
|
||||||
|
): GitBookSpaceLinker {
|
||||||
|
const linkerWithPrefix: GitBookSpaceLinker = {
|
||||||
|
toPathInContent(relativePath: string): string {
|
||||||
|
return linker.toPathInContent(joinPaths(basePath, relativePath));
|
||||||
|
},
|
||||||
|
|
||||||
|
toAbsoluteURL(absolutePath: string): string {
|
||||||
|
return linker.toAbsoluteURL(absolutePath);
|
||||||
|
},
|
||||||
|
|
||||||
|
toPathForPage({ pages, page, anchor }) {
|
||||||
|
return (
|
||||||
|
linkerWithPrefix.toPathInContent(getPagePath(pages, page)) +
|
||||||
|
(anchor ? `#${anchor}` : '')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
toLinkForContent(url: string): string {
|
||||||
|
return linker.toLinkForContent(url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return linkerWithPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPaths(prefix: string, path: string): string {
|
||||||
|
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||||||
|
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
return prefixPath + suffixPath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
export enum MiddlewareHeaders {
|
||||||
|
/**
|
||||||
|
* Type of the route (static or dynamic)
|
||||||
|
*/
|
||||||
|
RouteType = 'x-gitbook-route-type',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the site (without the pathname)
|
||||||
|
*/
|
||||||
|
SiteURL = 'x-gitbook-site-url',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data associated with the URL.
|
||||||
|
*/
|
||||||
|
SiteURLData = 'x-gitbook-site-url-data',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mode of the URL (url or url-host)
|
||||||
|
*/
|
||||||
|
URLMode = 'x-gitbook-url-mode',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme of the page (light or dark)
|
||||||
|
*/
|
||||||
|
Theme = 'x-gitbook-theme',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customization override to apply.
|
||||||
|
*/
|
||||||
|
Customization = 'x-gitbook-customization',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token to use for the API.
|
||||||
|
*/
|
||||||
|
APIToken = 'x-gitbook-token',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visitor token used to access this content
|
||||||
|
*/
|
||||||
|
VisitorAuthToken = 'x-gitbook-visitor-token',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL mode from the middleware headers.
|
||||||
|
* This function should only be called in a server action or a dynamic route.
|
||||||
|
*/
|
||||||
|
export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const mode = headersList.get(MiddlewareHeaders.URLMode);
|
||||||
|
if (!mode) {
|
||||||
|
throw new Error('URL mode is not set by the middleware');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode as 'url' | 'url-host';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the site URL data from the middleware headers.
|
||||||
|
* This function should only be called in a server action or a dynamic route.
|
||||||
|
*/
|
||||||
|
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
||||||
|
|
||||||
|
if (!siteURLData) {
|
||||||
|
throw new Error(
|
||||||
|
'Site URL data is not set by the middleware. This should only be called in a server action or a dynamic route.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(siteURLData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL from the middleware headers.
|
||||||
|
* This function should only be called in a server action or a dynamic route.
|
||||||
|
*/
|
||||||
|
export async function getSiteURLFromMiddleware(): Promise<string> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const siteURL = headersList.get(MiddlewareHeaders.SiteURL);
|
||||||
|
if (!siteURL) {
|
||||||
|
throw new Error('URL mode is not set by the middleware');
|
||||||
|
}
|
||||||
|
|
||||||
|
return siteURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For preview, the theme can be set via query string (?theme=light).
|
||||||
|
* This function should only be called in a dynamic route.
|
||||||
|
*/
|
||||||
|
export async function getThemeFromMiddleware() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const queryStringTheme = headersList.get(MiddlewareHeaders.Theme);
|
||||||
|
if (!queryStringTheme) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryStringTheme === 'light'
|
||||||
|
? CustomizationThemeMode.Light
|
||||||
|
: CustomizationThemeMode.Dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the visitor auth token from the middleware headers.
|
||||||
|
* This function should only be called in a dynamic route.
|
||||||
|
*/
|
||||||
|
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
|
||||||
|
if (!visitorAuthToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return visitorAuthToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type GitBookBaseContext, fetchSiteContextByURLLookup, getBaseContext } from './context';
|
||||||
|
import {
|
||||||
|
getSiteURLDataFromMiddleware,
|
||||||
|
getSiteURLFromMiddleware,
|
||||||
|
getURLModeFromMiddleware,
|
||||||
|
} from './middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base context for a server action.
|
||||||
|
* This function should only be called in a server action.
|
||||||
|
*/
|
||||||
|
export async function getServerActionBaseContext() {
|
||||||
|
const siteURL = await getSiteURLFromMiddleware();
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
const urlMode = await getURLModeFromMiddleware();
|
||||||
|
|
||||||
|
return getBaseContext({
|
||||||
|
siteURL,
|
||||||
|
urlMode,
|
||||||
|
apiToken: siteURLData.apiToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the context for a site in a server action.
|
||||||
|
* The server action is always dynamic and the request is passed through the middleware.
|
||||||
|
*/
|
||||||
|
export async function fetchServerActionSiteContext(baseContext: GitBookBaseContext) {
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
return fetchSiteContextByURLLookup(baseContext, siteURLData);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { GitBookAPIError } from '@gitbook/api';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getContentSecurityPolicy } from '@/lib/csp';
|
||||||
|
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
|
||||||
|
import { serveResizedImage } from '@/routes/image';
|
||||||
|
import { getPublishedContentByURL } from '@v2/lib/data';
|
||||||
|
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* Serve image resizing requests (all requests containing `/~gitbook/image`).
|
||||||
|
* All URLs containing `/~gitbook/image` are rewritten to `/~gitbook/image`
|
||||||
|
* and serve from a single route handler.
|
||||||
|
*
|
||||||
|
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
|
||||||
|
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
|
||||||
|
*/
|
||||||
|
if (request.nextUrl.pathname.endsWith('/~gitbook/image')) {
|
||||||
|
return serveResizedImage(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route all requests to a site
|
||||||
|
const extracted = extractURL(request);
|
||||||
|
if (extracted) {
|
||||||
|
return serveSiteByURL(request, extracted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the rest with the router default logic
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch (error) {
|
||||||
|
return serveErrorResponse(error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve site by URL.
|
||||||
|
*/
|
||||||
|
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
|
||||||
|
const dynamicHeaders = getDynamicHeaders(request);
|
||||||
|
const { url, mode } = urlWithMode;
|
||||||
|
|
||||||
|
const result = await getPublishedContentByURL({
|
||||||
|
url: url.toString(),
|
||||||
|
visitorAuthToken: null,
|
||||||
|
redirectOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = result;
|
||||||
|
|
||||||
|
if ('redirect' in data) {
|
||||||
|
return NextResponse.redirect(data.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeType = dynamicHeaders ? 'dynamic' : 'static';
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||||
|
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||||
|
requestHeaders.set(MiddlewareHeaders.SiteURL, `${url.origin}${data.basePath}`);
|
||||||
|
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
|
||||||
|
if (dynamicHeaders) {
|
||||||
|
for (const [key, value] of Object.entries(dynamicHeaders)) {
|
||||||
|
requestHeaders.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
|
||||||
|
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
||||||
|
requestHeaders.set('origin', request.nextUrl.origin);
|
||||||
|
|
||||||
|
const route = [
|
||||||
|
'sites',
|
||||||
|
routeType,
|
||||||
|
mode,
|
||||||
|
encodeURIComponent(url.host + data.basePath),
|
||||||
|
encodePathInSiteContent(data.pathname),
|
||||||
|
].join('/');
|
||||||
|
|
||||||
|
console.log(`rewriting to ${route}`);
|
||||||
|
|
||||||
|
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
|
||||||
|
const response = NextResponse.rewrite(rewrittenURL, {
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Content Security Policy header
|
||||||
|
response.headers.set('content-security-policy', getContentSecurityPolicy());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve an error response.
|
||||||
|
*/
|
||||||
|
function serveErrorResponse(error: Error) {
|
||||||
|
if (error instanceof GitBookAPIError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 500, headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the GitBook content can be passed in 3 different ways:
|
||||||
|
* - The request URL is in the `X-GitBook-URL` header.
|
||||||
|
* - Hostname is in the `X-GitBook-Host` header and the pathname is the path in the request URL.
|
||||||
|
* - The request URL is matching `/url/:url`
|
||||||
|
*/
|
||||||
|
function extractURL(request: NextRequest): URLWithMode | null {
|
||||||
|
const xGitbookUrl = request.headers.get('x-gitbook-url');
|
||||||
|
if (xGitbookUrl) {
|
||||||
|
return {
|
||||||
|
url: new URL(xGitbookUrl),
|
||||||
|
mode: 'url-host',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const xGitbookHost = request.headers.get('x-gitbook-host');
|
||||||
|
if (xGitbookHost) {
|
||||||
|
return {
|
||||||
|
url: new URL(`https://${xGitbookHost}${request.nextUrl.pathname}`),
|
||||||
|
mode: 'url-host',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = '/url/';
|
||||||
|
if (request.nextUrl.pathname.startsWith(prefix)) {
|
||||||
|
return {
|
||||||
|
url: new URL(`https://${request.nextUrl.pathname.slice(prefix.length)}`),
|
||||||
|
mode: 'url',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate if a request is dynamic or static.
|
||||||
|
*/
|
||||||
|
function getDynamicHeaders(_request: NextRequest): null | Record<string, string> {
|
||||||
|
// TODO:
|
||||||
|
// - check token in query string
|
||||||
|
// - check token in cookies
|
||||||
|
// - check special headers or query string
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode path in a site content.
|
||||||
|
* Special paths are not encoded and passed to be handled by the route handlers.
|
||||||
|
*/
|
||||||
|
function encodePathInSiteContent(rawPathname: string) {
|
||||||
|
const pathname = removeLeadingSlash(removeTrailingSlash(rawPathname));
|
||||||
|
|
||||||
|
if (pathname.match(/^~gitbook\/ogimage\/\S+$/)) {
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (pathname) {
|
||||||
|
case '~gitbook/icon':
|
||||||
|
case '~gitbook/image':
|
||||||
|
case 'llms.txt':
|
||||||
|
case 'sitemap.xml':
|
||||||
|
case 'robots.txt':
|
||||||
|
return pathname;
|
||||||
|
default:
|
||||||
|
return encodeURIComponent(pathname || '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import config from '../gitbook/tailwind.config';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...config,
|
||||||
|
content: [
|
||||||
|
'../gitbook/src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'../gitbook/src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'../gitbook/src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@v2/*": ["./src/*"],
|
||||||
|
"@/*": ["../gitbook/src/*"]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"../gitbook/types/**/*.d.ts",
|
||||||
|
"../gitbook/cf-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"packages/openapi-parser",
|
||||||
|
"packages/react-openapi",
|
||||||
|
"packages/react-math",
|
||||||
|
"packages/gitbook"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
main = ".open-next/worker.js"
|
||||||
|
name = "gitbook-v2"
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
||||||
@@ -25,3 +25,6 @@
|
|||||||
|
|
||||||
### Sentry ###
|
### Sentry ###
|
||||||
# SENTRY_DSN=xxx
|
# SENTRY_DSN=xxx
|
||||||
|
|
||||||
|
### Silent logs
|
||||||
|
# SILENT=true
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# visual tests
|
||||||
|
screenshots/
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.sentryclirc
|
||||||
|
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
|
# Generated public files
|
||||||
|
/public/~gitbook/static/*
|
||||||
|
!/public/~gitbook/static/images
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# gitbook
|
||||||
|
|
||||||
|
## 0.6.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 05ffd0e: Improving data cache management for computed content
|
||||||
|
- 8beb5d6: Add input elements to ContentKit
|
||||||
|
- Updated dependencies [53f5dbe]
|
||||||
|
- Updated dependencies [05ffd0e]
|
||||||
|
- Updated dependencies [8beb5d6]
|
||||||
|
- @gitbook/openapi-parser@2.0.2
|
||||||
|
- @gitbook/cache-tags@0.1.0
|
||||||
|
- @gitbook/react-contentkit@0.6.1
|
||||||
|
- @gitbook/react-openapi@1.0.5
|
||||||
|
|
||||||
|
## 0.6.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 9b914d1: Fix getProxyModeBasePath that was computing incorrect base path in some scenarios
|
||||||
|
- 2ae76f9: Change how a site in proxy mode is resolved
|
||||||
|
- 027a859: Add support for links style customization option
|
||||||
|
- 3e11678: fix: lost section groups
|
||||||
|
- 3319375: Support OpenAPI operation block
|
||||||
|
- Updated dependencies [722f02e]
|
||||||
|
- Updated dependencies [0924259]
|
||||||
|
- @gitbook/react-openapi@1.0.4
|
||||||
|
- @gitbook/openapi-parser@2.0.1
|
||||||
|
|
||||||
|
## 0.6.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- a820739: Remove unused search api method from gitbook/api/lib
|
||||||
|
- a054554: Implement a trusted mode to speed up OpenAPI spec validation
|
||||||
|
- 66d0fc0: Update design for hint block: use semantic colors (info, warning, danger, success) and add alternative styling for hints with headings
|
||||||
|
- 9f0de74: Add support for new OpenAPI ref
|
||||||
|
- da55fac: Render GitBook blocks in OpenAPI operation description
|
||||||
|
- Updated dependencies [c808bb1]
|
||||||
|
- Updated dependencies [dc2dbc5]
|
||||||
|
- Updated dependencies [f1d1d2f]
|
||||||
|
- Updated dependencies [e24206e]
|
||||||
|
- Updated dependencies [a054554]
|
||||||
|
- Updated dependencies [05e1d8c]
|
||||||
|
- Updated dependencies [b4a12d6]
|
||||||
|
- Updated dependencies [9f0de74]
|
||||||
|
- Updated dependencies [da55fac]
|
||||||
|
- @gitbook/openapi-parser@2.0.0
|
||||||
|
- @gitbook/react-openapi@1.0.3
|
||||||
|
|
||||||
|
## 0.6.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 359bb97: Fix opening external links when the GitBook page is embedded in an iframe
|
||||||
|
- 6157583: Improve Markdown parsing
|
||||||
|
- 82cd9f2: Add support for anchor links in OpenAPI blocks
|
||||||
|
- Updated dependencies [445baaa]
|
||||||
|
- Updated dependencies [bb5c6a4]
|
||||||
|
- Updated dependencies [a3f1fea]
|
||||||
|
- Updated dependencies [6157583]
|
||||||
|
- Updated dependencies [7419ee7]
|
||||||
|
- Updated dependencies [82cd9f2]
|
||||||
|
- @gitbook/colors@0.2.0
|
||||||
|
- @gitbook/react-openapi@1.0.2
|
||||||
|
- @gitbook/openapi-parser@1.0.1
|
||||||
|
|
||||||
|
## 0.6.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- dddb4ec: Fix long tab group description
|
||||||
|
- Updated dependencies [f8d4c76]
|
||||||
|
- Updated dependencies [dddb4ec]
|
||||||
|
- Updated dependencies [f8d4c76]
|
||||||
|
- @gitbook/react-openapi@1.0.1
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
|
||||||
|
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
|
||||||
|
- 95f2aa4: Track new events for site insights when ads are being clicked
|
||||||
|
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
|
||||||
|
- 1138d59: Add support for sidebar background styles
|
||||||
|
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
|
||||||
|
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
|
||||||
|
- 7059c2b: Add support for computed content by fetching computed documents for pages.
|
||||||
|
- c71d159: Track events for site insights using the new dedicated API.
|
||||||
|
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
|
||||||
|
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
|
||||||
|
- 7ee9158: Restyle PageAside to use sidebar list styles
|
||||||
|
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
|
||||||
|
- 1417279: Track clicks on links (header, footer, content) for site insights.
|
||||||
|
- 9eca010: Improve the display of recommended questions by streaming them.
|
||||||
|
- 160fca1: new OpenAPI blocks design
|
||||||
|
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
|
||||||
|
- 1823101: Fix internal properties appearing in OpenAPI docs.
|
||||||
|
- 6a073e1: Add antialiasing for text rendering
|
||||||
|
- 8126a83: Improve readability of tables with hover style and vertical dividers
|
||||||
|
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
|
||||||
|
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
|
||||||
|
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
|
||||||
|
- c30bc24: Fix empty sitemap
|
||||||
|
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
|
||||||
|
- 5b4e710: Support llms.txt
|
||||||
|
- b6c3870: Add support for keyboard marks
|
||||||
|
- 6059efe: Fix search no results error showing while there are results
|
||||||
|
- c77142a: Log component stack in Sentry
|
||||||
|
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
|
||||||
|
- 32aa1f9: Handle security issue with cookies on Safari
|
||||||
|
- d935fb1: Don't add extra page scroll when footer is not present
|
||||||
|
- 53de5b1: Fix site section URL resolution in Ask AI sources
|
||||||
|
- 24f5249: Fix vertical section overflow color
|
||||||
|
- 1762f85: Reduce gap between subsequent header buttons
|
||||||
|
- c1e27cc: Fix pass Sentry release properly
|
||||||
|
- 5ae1b88: Fix shrinking page icons
|
||||||
|
- 8f046a9: Start using tint in more places, TOC and PageAside
|
||||||
|
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
|
||||||
|
|
||||||
|
To reduce the load on the API and also avoid errors.
|
||||||
|
|
||||||
|
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
|
||||||
|
- 8cfa67c: Fix default outline list styling
|
||||||
|
- d66c184: Ignore errors from event flushing
|
||||||
|
- 6088fa5: Simplify search results logic to investigate a bug
|
||||||
|
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
|
||||||
|
- 09c7c30: Try to fix error on og image generation
|
||||||
|
- ae99f87: Improve emoji setup, align with GitBook app
|
||||||
|
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
|
||||||
|
- 3a7210d: Fix zoom image view transition on Safari
|
||||||
|
- 718a8a5: Position the variant picker in the ToC
|
||||||
|
- e5dc05e: Update footer styling and allow for more than 4 footer groups
|
||||||
|
- 8276ba0: Make cookies access safer
|
||||||
|
- 1b8a456: Fix Image blocks zoomable behaviour
|
||||||
|
- 56c52e0: Handle Firefox security error on localStorage
|
||||||
|
- 0510b6f: Add section description to SectionGroupTile
|
||||||
|
- 1fcc807: Fix errors from customization not found
|
||||||
|
- 46edde9: Improve the OpenAPI package API
|
||||||
|
- 8af1abc: Improve contrast of search box placeholder
|
||||||
|
- 92b7668: Improve header offset
|
||||||
|
- d9c8d57: Do not dereference before caching OpenAPI spec.
|
||||||
|
- 94876e3: Fix regression issue with page icons for multi-line titles
|
||||||
|
- 47971dc: Fix OG image generation for non-latin characters
|
||||||
|
- 82dc9c4: Simplify the `useHash` algo used.
|
||||||
|
- 128ad20: Ignore cache invalidation error from local backend
|
||||||
|
- ff05e20: Improvements to inline images in headings
|
||||||
|
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
|
||||||
|
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
|
||||||
|
- 48ab59f: Improve colour contrast of list item decoration
|
||||||
|
- d2bc567: Set Sentry release
|
||||||
|
- 37d13d8: Avoid error on fetch by passing a string URL
|
||||||
|
- d3e573c: Generate sitemap for all sections and spaces
|
||||||
|
- f7b801b: Add feedback form to page rating control
|
||||||
|
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
|
||||||
|
- 46f63cb: Fix code format overriding inline link styles
|
||||||
|
- 5950657: Fix emojis display
|
||||||
|
- 528a053: Fix server actions stability leading to no results found sometimes on search
|
||||||
|
- eac1314: Lazy load iframely script to make page more responsive
|
||||||
|
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
|
||||||
|
- 6f54826: New highlight colors
|
||||||
|
- 5c87ec7: Implement a safer way to interact with localStorage.
|
||||||
|
|
||||||
|
If it's disabled on the browser it should not throw error.
|
||||||
|
|
||||||
|
- 02d876e: Fix search UI behaviour
|
||||||
|
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
|
||||||
|
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
|
||||||
|
- aaab157: Visual fix for section group in Safari
|
||||||
|
- cbe6139: Fix dynamic tabs infinite loop
|
||||||
|
- 65cc4af: Fix error when accessing a change request not found
|
||||||
|
- 727bde2: Improve and split OpenAPI parser into its own package
|
||||||
|
- 0b6ddca: Fix variant selector contrast for non-default themes
|
||||||
|
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
|
||||||
|
- fde32e2: Force route handler to be dynamic to avoid errors
|
||||||
|
- a025118: Change card layout depending on cover aspect ratio
|
||||||
|
- 300f7bf: Fix search loading state
|
||||||
|
- 29d5979: Disable C/C++ highlight temporarily
|
||||||
|
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
|
||||||
|
- 1c97536: Fix Sentry instrumentation
|
||||||
|
- b0bd871: Even safer localStorage
|
||||||
|
- b950a64: Avoid errors on legacy browsers
|
||||||
|
- 38061bd: Add section groups to section tabs
|
||||||
|
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
|
||||||
|
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
|
||||||
|
- 6691492: Fix viewing PDF from space
|
||||||
|
- e8e64bf: Fix bullet list display on full size blocks
|
||||||
|
- 16194c5: Vertical orientation for sections list on sites without header
|
||||||
|
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
|
||||||
|
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
|
||||||
|
- 1429384: Fix error when accessing some not found pages.
|
||||||
|
- 21cbd9e: Change link color to primary-subtle
|
||||||
|
- 5dab70f: Fix "Parser" language syntax highlighting
|
||||||
|
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
|
||||||
|
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
|
||||||
|
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
|
||||||
|
- 44a20fe: Improve smoothness of scroll listener
|
||||||
|
- 5664e5a: Fix variant dropdown styling in header
|
||||||
|
- 6b50360: Fix view transition error on Safari
|
||||||
|
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
|
||||||
|
- 5112e3e: Fix Sentry instrumentation server-side
|
||||||
|
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
|
||||||
|
- Updated dependencies [d9029c7]
|
||||||
|
- Updated dependencies [6e54a06]
|
||||||
|
- Updated dependencies [162b4b7]
|
||||||
|
- Updated dependencies [e4e2f52]
|
||||||
|
- Updated dependencies [0c03676]
|
||||||
|
- Updated dependencies [3e5e458]
|
||||||
|
- Updated dependencies [46edde9]
|
||||||
|
- Updated dependencies [d9c8d57]
|
||||||
|
- Updated dependencies [ccf2cff]
|
||||||
|
- Updated dependencies [dda0cc6]
|
||||||
|
- Updated dependencies [eb7c22f]
|
||||||
|
- Updated dependencies [ea1468c]
|
||||||
|
- Updated dependencies [648f0e9]
|
||||||
|
- Updated dependencies [160fca1]
|
||||||
|
- Updated dependencies [f92e906]
|
||||||
|
- Updated dependencies [e721f17]
|
||||||
|
- Updated dependencies [727bde2]
|
||||||
|
- Updated dependencies [dff08ae]
|
||||||
|
- Updated dependencies [fc7b16f]
|
||||||
|
- Updated dependencies [fe8acc9]
|
||||||
|
- Updated dependencies [1823101]
|
||||||
|
- Updated dependencies [a652958]
|
||||||
|
- Updated dependencies [2f73db7]
|
||||||
|
- Updated dependencies [160fca1]
|
||||||
|
- Updated dependencies [12c7862]
|
||||||
|
- Updated dependencies [b41d425]
|
||||||
|
- @gitbook/react-openapi@1.0.0
|
||||||
|
- @gitbook/openapi-parser@1.0.0
|
||||||
|
- @gitbook/react-contentkit@0.6.0
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
|
||||||
|
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
|
||||||
|
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
|
||||||
|
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
|
||||||
|
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
|
||||||
|
- a2e5647: Fix the styling of site section tabs on smaller screens.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 076dc48: Fix expandable block anchore resolution
|
||||||
|
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
|
||||||
|
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
|
||||||
|
- 664debc: Add support for tint color
|
||||||
|
- 4d56f11: Update styling of search+ask modal
|
||||||
|
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
|
||||||
|
- 2f76712: Add breadcrumbs above page title
|
||||||
|
- 07cf835: Add scroll margin to the top when there are sections
|
||||||
|
- 5d72b35: Smoother tab transition for sections
|
||||||
|
- 7c71363: Don't adjust fallback font for mono font.
|
||||||
|
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
|
||||||
|
- 87eea73: Fix margin and image resolution of header logo
|
||||||
|
- aa2ed0f: Restyle hint blocks
|
||||||
|
- ffd3937: Fix security issue with image resizing that could be used for phishing
|
||||||
|
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
|
||||||
|
- c73e07d: Increase token max length to fix code not highlighted
|
||||||
|
- 3b3d6e2: Add icons to sections
|
||||||
|
- 1ed18c0: style: adds missing scalar css variables
|
||||||
|
- Updated dependencies [b7a5106]
|
||||||
|
- Updated dependencies [4771c78]
|
||||||
|
- Updated dependencies [ff50ac2]
|
||||||
|
- Updated dependencies [867481c]
|
||||||
|
- Updated dependencies [7ba67fd]
|
||||||
|
- Updated dependencies [a78c1ec]
|
||||||
|
- @gitbook/cache-do@0.1.1
|
||||||
|
- @gitbook/react-openapi@0.7.1
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e09f747: Revalidate change request cached content when pressing refresh button
|
||||||
|
- 2fa0851: Add navigation tabs for sections
|
||||||
|
- a4b63b8: Support resolution of new site URLs with sections
|
||||||
|
- 5c35f36: Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
|
||||||
|
- e9b31a5: Unify section tab styles with page item styles
|
||||||
|
- f12a215: Add support for Norwegian language
|
||||||
|
- f4c9536: Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||||
|
- 1f24fe4: Add support for page icons
|
||||||
|
- cda08a9: Add support for searching results in a sections site
|
||||||
|
- b32e40c: Persist state of tabs and dynamically sync them based on title
|
||||||
|
- 15d2ee3: Show the caption for file blocks
|
||||||
|
- f885e88: Improve the toolbar for change-requests and revisions to show more actions
|
||||||
|
- 07ea45b: Remove deprecated synced block from GitBook Open
|
||||||
|
- c3675fd: Added support for new Reusable Content block.
|
||||||
|
- 1f24fe4: Add support for icons style customization for sites
|
||||||
|
- 4c19014: Prevent search indexation for pages where it's configured as disabled
|
||||||
|
- 3422ad4: Update rendering of community ads to match new API response, and make it possible to preview ads.
|
||||||
|
- 1152445: Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
|
||||||
|
- 2c437f7: Fix linking to a tab itself
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- aa32198: Avoid multiple <h1> in the page by using a <div> for the title in the header
|
||||||
|
- 51fa3ab: Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||||
|
- a7066cc: Fix scroll position when navigating pages on mobile
|
||||||
|
- c754fc9: Add automatic color contrast in site header, restyle search button
|
||||||
|
- 5fe7adb: RND-3532: drop down menu for hidden links at small screen size
|
||||||
|
- 6295881: Change dark mode shadow for multi-space search toolbar
|
||||||
|
- f89b31c: Upgrade the scalar api client package
|
||||||
|
- 13c7534: Use ellipsis and fix icon color for more links in the header on small screen
|
||||||
|
- f885e88: Improve consistency of change request preview by removing cache-control on response
|
||||||
|
- 16e6171: Improve performances of loading pages with embeds by caching them
|
||||||
|
- 34d36c6: Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
|
||||||
|
- af9e66e: Only display spaces dropdown in compact header when site is multi-variants
|
||||||
|
- e3a3d6a: Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||||
|
- 042b850: Automatically scroll to active item in TOC
|
||||||
|
- d43202f: Optimize bundle size of the server output by reducing bundle size of shiki (skipping themes)
|
||||||
|
- bfbed1a: Ensure "Sponsored via GitBook" can be translated in all languages
|
||||||
|
- fe9e6c1: Update ogimage with new design
|
||||||
|
- 17f71ba: Use url hash to open Expandable and scroll to anchor
|
||||||
|
- 3c07e65: Fix margin for paragraphs in quote blocks
|
||||||
|
- 636b868: Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||||
|
- f16560c: Include offset in calculations of whether scrollable element is in view
|
||||||
|
- 689f553: Fix inconsistent click area in table because of scroll indicator
|
||||||
|
- 6ce3cea: Stop using KV cache backend for now, but also improves it for higher performances
|
||||||
|
- e914903: Synchronize response and response example tabs
|
||||||
|
- 0f990c7: Show definition title when visible in cards
|
||||||
|
- e3a3d6a: Fix flickering when displaying an "Ask" answer with code blocks
|
||||||
|
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||||
|
- 3996110: Optimize images rendered in community ads
|
||||||
|
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||||
|
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||||
|
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||||
|
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||||
|
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||||
|
- cb782a7: Fix "ip" being passed to BSA for community ads
|
||||||
|
- a7af3ca: Improving the look and feel of new section tabs
|
||||||
|
- 0bf985a: Don't show hidden pages in the empty state of a page
|
||||||
|
- d6c28a0: Update header styling of sections, variant selector, and button links
|
||||||
|
|
||||||
|
- Change position of variant selector depending on context (next to logo or in table of contents)
|
||||||
|
- Update section tab styling and animation
|
||||||
|
- Make header buttons smaller with a new `medium` button size
|
||||||
|
|
||||||
|
- Updated dependencies [51fa3ab]
|
||||||
|
- Updated dependencies [9b8d519]
|
||||||
|
- Updated dependencies [cf3045a]
|
||||||
|
- Updated dependencies [f89b31c]
|
||||||
|
- Updated dependencies [d0f4860]
|
||||||
|
- Updated dependencies [ef9d012]
|
||||||
|
- Updated dependencies [094e9cd]
|
||||||
|
- Updated dependencies [636b868]
|
||||||
|
- Updated dependencies [56f5fa1]
|
||||||
|
- Updated dependencies [5c35f36]
|
||||||
|
- Updated dependencies [4247361]
|
||||||
|
- Updated dependencies [aa8c49e]
|
||||||
|
- Updated dependencies [e914903]
|
||||||
|
- Updated dependencies [4cbcc5b]
|
||||||
|
- Updated dependencies [0f1565c]
|
||||||
|
- Updated dependencies [237b703]
|
||||||
|
- Updated dependencies [51955da]
|
||||||
|
- Updated dependencies [a679e72]
|
||||||
|
- Updated dependencies [c079c3c]
|
||||||
|
- Updated dependencies [5c35f36]
|
||||||
|
- Updated dependencies [776bc31]
|
||||||
|
- @gitbook/react-openapi@0.7.0
|
||||||
|
- @gitbook/cache-do@0.1.0
|
||||||
|
- @gitbook/icons@0.1.0
|
||||||
|
- @gitbook/react-contentkit@0.5.1
|
||||||
|
- @gitbook/react-math@0.6.0
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 24b785c: Update shiki for code block syntax highlighting, with support for more languages and fixes for diffs. It also patches the deployment on Cloudflare to support edge functions larger than 4MB.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- acc3f2f: Fix error with the "Try it" of OpenAPI block because of the Scalar proxy failing on Cloudflare with the `cache` option
|
||||||
|
- Updated dependencies [709f1a1]
|
||||||
|
- Updated dependencies [ede2335]
|
||||||
|
- Updated dependencies [0426312]
|
||||||
|
- @gitbook/react-openapi@0.6.0
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3445db4]
|
||||||
|
- @gitbook/react-contentkit@0.5.0
|
||||||
|
- @gitbook/react-openapi@0.5.0
|
||||||
|
- @gitbook/react-math@0.5.0
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [24cd72e]
|
||||||
|
- @gitbook/react-contentkit@0.4.0
|
||||||
|
- @gitbook/react-math@0.4.0
|
||||||
|
- @gitbook/react-openapi@0.4.0
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- de747b7: Refactor the repository to be a proper monorepo and publish JS files on NPM instead of TypeScript files.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [de747b7]
|
||||||
|
- Updated dependencies [de747b7]
|
||||||
|
- @gitbook/react-contentkit@0.3.0
|
||||||
|
- @gitbook/react-openapi@0.3.0
|
||||||
|
- @gitbook/react-math@0.3.0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user