diff --git a/.changeset/afraid-gifts-sparkle.md b/.changeset/afraid-gifts-sparkle.md new file mode 100644 index 0000000000..ec33c1efcf --- /dev/null +++ b/.changeset/afraid-gifts-sparkle.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix UX issue about highlighting the search term in search result sections diff --git a/.changeset/big-gorillas-perform.md b/.changeset/big-gorillas-perform.md new file mode 100644 index 0000000000..88ec555a00 --- /dev/null +++ b/.changeset/big-gorillas-perform.md @@ -0,0 +1,9 @@ +--- +"gitbook": patch +--- + +Fix three small visual issues + +- Fix sidebar showing on `no-toc` pages in the gradient theme +- Fix variant selector truncating incorrectly in header when sections are present +- Fix page cover alignment on `lg` screens without TOC diff --git a/.changeset/breezy-falcons-drop.md b/.changeset/breezy-falcons-drop.md new file mode 100644 index 0000000000..834e46bfca --- /dev/null +++ b/.changeset/breezy-falcons-drop.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Respect fullWidth and defaultWidth for images diff --git a/.changeset/clever-jokes-yell.md b/.changeset/clever-jokes-yell.md new file mode 100644 index 0000000000..87b2698636 --- /dev/null +++ b/.changeset/clever-jokes-yell.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Add docs.testgitbook.com to ADAPTIVE_CONTENT_HOSTS list diff --git a/.changeset/cold-buckets-divide.md b/.changeset/cold-buckets-divide.md new file mode 100644 index 0000000000..1e528bad19 --- /dev/null +++ b/.changeset/cold-buckets-divide.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +fix nested a tag causing hydration error diff --git a/.changeset/cool-jars-matter.md b/.changeset/cool-jars-matter.md new file mode 100644 index 0000000000..ff0c934f33 --- /dev/null +++ b/.changeset/cool-jars-matter.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +fix href being empty in TOC diff --git a/.changeset/cool-seas-approve.md b/.changeset/cool-seas-approve.md new file mode 100644 index 0000000000..c97f692e6d --- /dev/null +++ b/.changeset/cool-seas-approve.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix navigation between sections/variants when previewing a site in v2 diff --git a/.changeset/curly-rules-learn.md b/.changeset/curly-rules-learn.md new file mode 100644 index 0000000000..47400e9194 --- /dev/null +++ b/.changeset/curly-rules-learn.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add support for inline icons. diff --git a/.changeset/fair-crews-wink.md b/.changeset/fair-crews-wink.md new file mode 100644 index 0000000000..817854d1d7 --- /dev/null +++ b/.changeset/fair-crews-wink.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add circular corners and depth styling diff --git a/.changeset/famous-melons-compete.md b/.changeset/famous-melons-compete.md new file mode 100644 index 0000000000..17512b6f49 --- /dev/null +++ b/.changeset/famous-melons-compete.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash when integration script fails to render block. diff --git a/.changeset/fast-trees-battle.md b/.changeset/fast-trees-battle.md new file mode 100644 index 0000000000..0e75fbbeda --- /dev/null +++ b/.changeset/fast-trees-battle.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Add authorization header for OAuth2 diff --git a/.changeset/fifty-ducks-press.md b/.changeset/fifty-ducks-press.md new file mode 100644 index 0000000000..a250d31243 --- /dev/null +++ b/.changeset/fifty-ducks-press.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Improve support for OAuth2 security type diff --git a/.changeset/flat-wolves-poke.md b/.changeset/flat-wolves-poke.md new file mode 100644 index 0000000000..2b5c201cd1 --- /dev/null +++ b/.changeset/flat-wolves-poke.md @@ -0,0 +1,6 @@ +--- +"gitbook-v2": patch +"gitbook": patch +--- + +Adds Columns layout block to GBO diff --git a/.changeset/forty-readers-mix.md b/.changeset/forty-readers-mix.md new file mode 100644 index 0000000000..98bede85d1 --- /dev/null +++ b/.changeset/forty-readers-mix.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Support dark-mode specific page cover image diff --git a/.changeset/fresh-shrimps-flow.md b/.changeset/fresh-shrimps-flow.md new file mode 100644 index 0000000000..a2e62c9480 --- /dev/null +++ b/.changeset/fresh-shrimps-flow.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Update Models page styling diff --git a/.changeset/fuzzy-tables-jump.md b/.changeset/fuzzy-tables-jump.md new file mode 100644 index 0000000000..ada33d1c55 --- /dev/null +++ b/.changeset/fuzzy-tables-jump.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Optimize performances by using a smarter per-request cache arround data cached functions diff --git a/.changeset/gorgeous-cycles-cheat.md b/.changeset/gorgeous-cycles-cheat.md new file mode 100644 index 0000000000..d13c27765a --- /dev/null +++ b/.changeset/gorgeous-cycles-cheat.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +add a force-revalidate api route to force bust the cache in case of errors diff --git a/.changeset/green-clouds-cough.md b/.changeset/green-clouds-cough.md new file mode 100644 index 0000000000..0988603814 --- /dev/null +++ b/.changeset/green-clouds-cough.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Fix rendering of ogimage with SVG logos. diff --git a/.changeset/hip-bobcats-cover.md b/.changeset/hip-bobcats-cover.md new file mode 100644 index 0000000000..9bd789f85d --- /dev/null +++ b/.changeset/hip-bobcats-cover.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Best effort at preserving current variant when navigating between sections by matching the pathname against site spaces in the new section. diff --git a/.changeset/khaki-bees-count.md b/.changeset/khaki-bees-count.md new file mode 100644 index 0000000000..a481690e24 --- /dev/null +++ b/.changeset/khaki-bees-count.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash when integration is triggering invalid requests. diff --git a/.changeset/lazy-pants-matter.md b/.changeset/lazy-pants-matter.md new file mode 100644 index 0000000000..6852b473b9 --- /dev/null +++ b/.changeset/lazy-pants-matter.md @@ -0,0 +1,6 @@ +--- +"gitbook-v2": patch +"gitbook": patch +--- + +encode customization header diff --git a/.changeset/long-cameras-protect.md b/.changeset/long-cameras-protect.md new file mode 100644 index 0000000000..358b9be33d --- /dev/null +++ b/.changeset/long-cameras-protect.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Rework full-width layout, add support for full-width page option diff --git a/.changeset/nasty-moles-visit.md b/.changeset/nasty-moles-visit.md new file mode 100644 index 0000000000..031fb81e30 --- /dev/null +++ b/.changeset/nasty-moles-visit.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +fix ISR on preview env diff --git a/.changeset/nervous-students-judge.md b/.changeset/nervous-students-judge.md new file mode 100644 index 0000000000..a24d325b74 --- /dev/null +++ b/.changeset/nervous-students-judge.md @@ -0,0 +1,6 @@ +--- +"gitbook-v2": patch +"gitbook": patch +--- + +Fix concurrent execution in Vercel causing pages to not be attached to the proper tags. diff --git a/.changeset/orange-ears-drop.md b/.changeset/orange-ears-drop.md new file mode 100644 index 0000000000..12dd7fae5d --- /dev/null +++ b/.changeset/orange-ears-drop.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-contentkit": patch +--- + +Add basic error handling when transitioning between states. diff --git a/.changeset/orange-hounds-sparkle.md b/.changeset/orange-hounds-sparkle.md new file mode 100644 index 0000000000..28f2b6aa6e --- /dev/null +++ b/.changeset/orange-hounds-sparkle.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Generate a llms-full.txt version of the docs site diff --git a/.changeset/pink-windows-wonder.md b/.changeset/pink-windows-wonder.md new file mode 100644 index 0000000000..d8e9560dcc --- /dev/null +++ b/.changeset/pink-windows-wonder.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Don't crash ogimage generation on RTL text, as a workaround until we can support it. diff --git a/.changeset/poor-dodos-lick.md b/.changeset/poor-dodos-lick.md new file mode 100644 index 0000000000..ca02d7382b --- /dev/null +++ b/.changeset/poor-dodos-lick.md @@ -0,0 +1,5 @@ +--- +"@gitbook/fonts": minor +--- + +Initial version of the package diff --git a/.changeset/pretty-balloons-fold.md b/.changeset/pretty-balloons-fold.md new file mode 100644 index 0000000000..b501879843 --- /dev/null +++ b/.changeset/pretty-balloons-fold.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix rendering of ogimage when logo or icon are AVIF images. diff --git a/.changeset/purple-cougars-breathe.md b/.changeset/purple-cougars-breathe.md new file mode 100644 index 0000000000..2c6d010d4e --- /dev/null +++ b/.changeset/purple-cougars-breathe.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add margin to adjacent buttons diff --git a/.changeset/rare-pens-whisper.md b/.changeset/rare-pens-whisper.md new file mode 100644 index 0000000000..aed0712ba4 --- /dev/null +++ b/.changeset/rare-pens-whisper.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix ogimage generation failing with some JPEG images. diff --git a/.changeset/real-walls-glow.md b/.changeset/real-walls-glow.md new file mode 100644 index 0000000000..277c39ca9f --- /dev/null +++ b/.changeset/real-walls-glow.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Don't cache unexpected API errors for more than a few minutes. diff --git a/.changeset/rich-buses-hunt.md b/.changeset/rich-buses-hunt.md new file mode 100644 index 0000000000..fbc59d232d --- /dev/null +++ b/.changeset/rich-buses-hunt.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Fix an issue where PDF export URLs were not keeping their query params. diff --git a/.changeset/rotten-donuts-bow.md b/.changeset/rotten-donuts-bow.md new file mode 100644 index 0000000000..d6ebe0650a --- /dev/null +++ b/.changeset/rotten-donuts-bow.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +add a global error boundary diff --git a/.changeset/rotten-seals-rush.md b/.changeset/rotten-seals-rush.md new file mode 100644 index 0000000000..950e25880c --- /dev/null +++ b/.changeset/rotten-seals-rush.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Indent JSON python code sample diff --git a/.changeset/sharp-hats-applaud.md b/.changeset/sharp-hats-applaud.md new file mode 100644 index 0000000000..a3c5e8da9c --- /dev/null +++ b/.changeset/sharp-hats-applaud.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix missing title on button to close the announcement banner. diff --git a/.changeset/sharp-jeans-burn.md b/.changeset/sharp-jeans-burn.md new file mode 100644 index 0000000000..c990f8b7bf --- /dev/null +++ b/.changeset/sharp-jeans-burn.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Make icons for page groups more contrasting diff --git a/.changeset/slimy-cows-press.md b/.changeset/slimy-cows-press.md new file mode 100644 index 0000000000..17ec44ca8a --- /dev/null +++ b/.changeset/slimy-cows-press.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Ignore case while highlighting search results. diff --git a/.changeset/slimy-hornets-share.md b/.changeset/slimy-hornets-share.md new file mode 100644 index 0000000000..bfeef22793 --- /dev/null +++ b/.changeset/slimy-hornets-share.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Consistently show variant selector in section bar if site has sections diff --git a/.changeset/slow-lizards-obey.md b/.changeset/slow-lizards-obey.md new file mode 100644 index 0000000000..ce1ef0e04c --- /dev/null +++ b/.changeset/slow-lizards-obey.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Make TOC height dynamic based on visible header and footer elements diff --git a/.changeset/soft-walls-change.md b/.changeset/soft-walls-change.md new file mode 100644 index 0000000000..2086de0317 --- /dev/null +++ b/.changeset/soft-walls-change.md @@ -0,0 +1,6 @@ +--- +"gitbook": patch +"gitbook-v2": patch +--- + +Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages. diff --git a/.changeset/strong-poets-move.md b/.changeset/strong-poets-move.md new file mode 100644 index 0000000000..9914aa44c9 --- /dev/null +++ b/.changeset/strong-poets-move.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix bold header links hover color diff --git a/.changeset/stupid-plums-perform.md b/.changeset/stupid-plums-perform.md new file mode 100644 index 0000000000..34707627ea --- /dev/null +++ b/.changeset/stupid-plums-perform.md @@ -0,0 +1,6 @@ +--- +"gitbook": patch +"gitbook-v2": patch +--- + +cache fonts and static image used in OGImage in memory diff --git a/.changeset/tame-mangos-battle.md b/.changeset/tame-mangos-battle.md new file mode 100644 index 0000000000..844bf321a5 --- /dev/null +++ b/.changeset/tame-mangos-battle.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix border being added to cards diff --git a/.changeset/thick-chefs-repeat.md b/.changeset/thick-chefs-repeat.md new file mode 100644 index 0000000000..8b08d50e3e --- /dev/null +++ b/.changeset/thick-chefs-repeat.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Handle nested deprecated properties in generateSchemaExample diff --git a/.changeset/thick-cups-shout.md b/.changeset/thick-cups-shout.md new file mode 100644 index 0000000000..3aabb9ad40 --- /dev/null +++ b/.changeset/thick-cups-shout.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash during rendering of ogimage for VA sites with default icon. diff --git a/.changeset/thin-buckets-grow.md b/.changeset/thin-buckets-grow.md new file mode 100644 index 0000000000..84375c735d --- /dev/null +++ b/.changeset/thin-buckets-grow.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Add `urlObject.hash` to `linker.toLinkForContent` to pass through URL fragment identifiers, used in search diff --git a/.changeset/tidy-dots-suffer.md b/.changeset/tidy-dots-suffer.md new file mode 100644 index 0000000000..c622927590 --- /dev/null +++ b/.changeset/tidy-dots-suffer.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +apply customization for dynamic context diff --git a/.changeset/tiny-zoos-scream.md b/.changeset/tiny-zoos-scream.md new file mode 100644 index 0000000000..1c80e7684e --- /dev/null +++ b/.changeset/tiny-zoos-scream.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Reverse order of feedback smileys diff --git a/.changeset/violet-schools-care.md b/.changeset/violet-schools-care.md new file mode 100644 index 0000000000..e77a98b5cf --- /dev/null +++ b/.changeset/violet-schools-care.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Deduplicate path parameters from OpenAPI spec diff --git a/.changeset/warm-roses-sleep.md b/.changeset/warm-roses-sleep.md new file mode 100644 index 0000000000..e14b06cdc2 --- /dev/null +++ b/.changeset/warm-roses-sleep.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix ogimage using incorrect Google Font depending on language. diff --git a/.changeset/wise-gifts-smash.md b/.changeset/wise-gifts-smash.md new file mode 100644 index 0000000000..32a85eecb7 --- /dev/null +++ b/.changeset/wise-gifts-smash.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +remove trailing slash from linker diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a65357a23d..b93b518961 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -63,9 +63,9 @@ To start your local version of GitBook, run the command `bun dev`. When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`. -For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server. +For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/gitbook.com/docs` after running the development server. -You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version. +You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version. ### Commit your update diff --git a/.github/actions/gradual-deploy-cloudflare/action.yaml b/.github/actions/gradual-deploy-cloudflare/action.yaml new file mode 100644 index 0000000000..eff064a754 --- /dev/null +++ b/.github/actions/gradual-deploy-cloudflare/action.yaml @@ -0,0 +1,83 @@ +name: Gradual Deploy to Cloudflare +description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together +inputs: + apiToken: + description: 'Cloudflare API token' + required: true + accountId: + description: 'Cloudflare account ID' + required: true + environment: + description: 'Cloudflare environment to deploy to (staging, production, preview)' + required: true + middlewareVersionId: + description: 'Middleware version ID to deploy' + required: true + serverVersionId: + description: 'Server version ID to deploy' + required: true +outputs: + deployment-url: + description: "Deployment URL" + value: ${{ steps.deploy_middleware.outputs.deployment-url }} +runs: + using: 'composite' + steps: + - id: wrangler_status + name: Check wrangler deployment status + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: deployments status --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + # This step is used to get the version ID that is currently deployed to Cloudflare. + - id: extract_current_version + name: Extract current version + shell: bash + run: | + version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT + + - id: deploy_server + name: Deploy server to Cloudflare at 0% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + # Since we use version overrides headers, we can directly deploy the middleware to 100%. + - id: deploy_middleware + name: Deploy middleware to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc + + - name: Deploy server to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + - name: Outputs + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} + run: | + echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" \ No newline at end of file diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index fbc98fc82f..7e66a8bf33 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -28,7 +28,7 @@ inputs: outputs: deployment-url: description: "Deployment URL" - value: ${{ steps.deploy.outputs.deployment-url }} + value: ${{ steps.upload_middleware.outputs.deployment-url }} runs: using: 'composite' steps: @@ -63,8 +63,8 @@ runs: env: GITBOOK_RUNTIME: cloudflare shell: bash - - id: deploy - name: Deploy to Cloudflare + + - name: Upload the DO worker uses: cloudflare/wrangler-action@v3.14.0 with: apiToken: ${{ inputs.apiToken }} @@ -72,10 +72,67 @@ runs: workingDirectory: ./ wranglerVersion: '4.10.0' environment: ${{ inputs.environment }} - command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc + command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc + + - id: upload_server + name: Upload server to Cloudflare + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + - name: Extract server version worker ID + shell: bash + id: extract_server_version_id + run: | + version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT + + - name: Run updateWrangler scripts + shell: bash + run: | + bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }} + + - id: upload_middleware + name: Upload middleware to Cloudflare + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc + + - name: Extract middleware version worker ID + shell: bash + id: extract_middleware_version_id + run: | + version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT + + - name: Deploy server and middleware to Cloudflare + if: ${{ inputs.deploy == 'true' }} + uses: ./.github/actions/gradual-deploy-cloudflare + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + opServiceAccount: ${{ inputs.opServiceAccount }} + opItem: ${{ inputs.opItem }} + environment: ${{ inputs.environment }} + serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }} + middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }} + deploy: ${{ inputs.deploy }} + + - name: Outputs shell: bash env: - DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }} run: | - echo "URL: ${{ steps.deploy.outputs.deployment-url }}" \ No newline at end of file + echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}" + echo "Output server: ${{ steps.upload_server.outputs.command-output }}" \ No newline at end of file diff --git a/.github/workflows/deploy-preview.yaml b/.github/workflows/deploy-preview.yaml index 156c1cfecc..c445004c0a 100644 --- a/.github/workflows/deploy-preview.yaml +++ b/.github/workflows/deploy-preview.yaml @@ -152,14 +152,14 @@ jobs: | Site | `v1` | `2v` | `2c` | | --- | --- | --- | --- | - | GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com) | + | GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) | | E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | edit-mode: replace visual-testing-v1: runs-on: ubuntu-latest name: Visual Testing v1 needs: deploy-v1-cloudflare - timeout-minutes: 8 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -200,7 +200,6 @@ jobs: name: Visual Testing v2 (Cloudflare) needs: deploy-v2-cloudflare timeout-minutes: 10 - if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v4 @@ -221,7 +220,7 @@ jobs: runs-on: ubuntu-latest name: Visual Testing Customers v1 needs: deploy-v1-cloudflare - timeout-minutes: 8 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -241,7 +240,7 @@ jobs: runs-on: ubuntu-latest name: Visual Testing Customers v2 needs: deploy-v2-vercel - timeout-minutes: 8 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -262,8 +261,7 @@ jobs: runs-on: ubuntu-latest name: Visual Testing Customers v2 (Cloudflare) needs: deploy-v2-cloudflare - timeout-minutes: 8 - if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main' + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json index b9d15df191..8a86e5d5d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ ], "tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"], "prettier.enable": false, + "editor.formatOnSave": true, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", diff --git a/README.md b/README.md index 5a055a7a75..9b13767ffd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ <h1 align="center">GitBook</h1> <p align="center"> - <a href="https://docs.gitbook.com/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a> + <a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a> </p> <p align="center"> @@ -56,17 +56,23 @@ git clone https://github.com/gitbookIO/gitbook.git bun install ``` -4. Start your local development server. +4. Run build. + +``` +bun build:v2 +``` + +5. Start your local development server. ``` bun dev:v2 ``` -5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`. +6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`. examples: -- http://localhost:3000/url/docs.gitbook.com +- http://localhost:3000/url/gitbook.com/docs - http://localhost:3000/url/open-source.gitbook.io/midjourney Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser. @@ -150,11 +156,11 @@ See `LICENSE` for more information. </p> ```md -[](https://gitbook.com/) +[](https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link) ``` ```html -<a href="https://gitbook.com"> +<a href="https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link"> <img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1" /> diff --git a/bun.lock b/bun.lock index 323725884f..8afc0e702e 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "name": "@gitbook/cache-tags", "version": "0.3.1", "dependencies": { - "@gitbook/api": "^0.111.0", + "@gitbook/api": "catalog:", "assert-never": "^1.2.1", }, "devDependencies": { @@ -35,7 +35,7 @@ }, "packages/colors": { "name": "@gitbook/colors", - "version": "0.3.2", + "version": "0.3.3", "devDependencies": { "typescript": "^5.5.3", }, @@ -47,21 +47,34 @@ "emoji-assets": "^8.0.0", }, }, + "packages/fonts": { + "name": "@gitbook/fonts", + "version": "0.0.0", + "dependencies": { + "@gitbook/api": "catalog:", + }, + "devDependencies": { + "google-font-metadata": "^6.0.3", + "typescript": "^5.5.3", + }, + }, "packages/gitbook": { "name": "gitbook", - "version": "0.10.1", + "version": "0.12.0", "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "catalog:", "@gitbook/cache-do": "workspace:*", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", + "@gitbook/fonts": "workspace:*", "@gitbook/icons": "workspace:*", "@gitbook/openapi-parser": "workspace:*", "@gitbook/react-contentkit": "workspace:*", "@gitbook/react-math": "workspace:*", "@gitbook/react-openapi": "workspace:*", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.8", @@ -72,25 +85,32 @@ "assert-never": "^1.2.1", "bun-types": "^1.1.20", "classnames": "^2.5.1", + "direction": "^2.0.1", "event-iterator": "^2.0.0", "framer-motion": "^10.16.14", + "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", "jwt-decode": "^4.0.0", "katex": "^0.16.9", "mathjax": "^3.2.2", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-frontmatter": "^2.0.1", + "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "memoizee": "^0.4.17", + "micromark-extension-frontmatter": "^2.0.0", + "micromark-extension-gfm": "^3.0.0", "next": "14.2.26", "next-themes": "^0.2.1", "nuqs": "^2.2.3", "object-hash": "^3.0.0", "openapi-types": "^12.1.3", - "p-map": "^7.0.0", + "p-map": "^7.0.3", "parse-cache-control": "^1.0.1", "partial-json": "^0.1.7", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-hotkeys-hook": "^4.4.1", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", @@ -103,6 +123,8 @@ "tailwind-merge": "^2.2.0", "tailwind-shades": "^1.1.2", "unified": "^11.0.5", + "unist-util-remove": "^4.0.0", + "unist-util-visit": "^5.0.0", "url-join": "^5.0.0", "usehooks-ts": "^3.1.0", "zod": "^3.24.2", @@ -111,7 +133,7 @@ }, "devDependencies": { "@argos-ci/playwright": "^5.0.3", - "@cloudflare/next-on-pages": "1.13.7", + "@cloudflare/next-on-pages": "1.13.12", "@cloudflare/workers-types": "^4.20241230.0", "@playwright/test": "^1.51.1", "@types/js-cookie": "^3.0.6", @@ -140,14 +162,16 @@ }, "packages/gitbook-v2": { "name": "gitbook-v2", - "version": "0.2.5", + "version": "0.3.0", "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "catalog:", "@gitbook/cache-tags": "workspace:*", + "@opennextjs/cloudflare": "1.2.1", "@sindresorhus/fnv1a": "^3.1.0", "assert-never": "^1.2.1", "jwt-decode": "^4.0.0", - "next": "canary", + "next": "^15.3.2", + "object-identity": "^0.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", "rison": "^0.1.1", @@ -155,7 +179,6 @@ "warn-once": "^0.1.1", }, "devDependencies": { - "@opennextjs/cloudflare": "^1.0.0-beta.3", "@types/rison": "^0.0.9", "gitbook": "*", "postcss": "^8", @@ -184,7 +207,7 @@ }, "packages/openapi-parser": { "name": "@gitbook/openapi-parser", - "version": "2.1.3", + "version": "2.1.4", "dependencies": { "@scalar/openapi-parser": "^0.10.10", "@scalar/openapi-types": "^0.1.9", @@ -201,7 +224,7 @@ "name": "@gitbook/react-contentkit", "version": "0.7.0", "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "catalog:", "@gitbook/icons": "workspace:*", "classnames": "^2.5.1", }, @@ -231,7 +254,7 @@ }, "packages/react-openapi": { "name": "@gitbook/react-openapi", - "version": "1.1.10", + "version": "1.3.0", "dependencies": { "@gitbook/openapi-parser": "workspace:*", "@scalar/api-client-react": "^1.2.19", @@ -259,9 +282,11 @@ }, "overrides": { "@codemirror/state": "6.4.1", - "@gitbook/api": "0.111.0", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + "catalog": { + "@gitbook/api": "^0.121.0", }, "packages": { "@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="], @@ -472,7 +497,7 @@ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], - "@cloudflare/next-on-pages": ["@cloudflare/next-on-pages@1.13.7", "", { "dependencies": { "acorn": "^8.8.0", "ast-types": "^0.14.2", "chalk": "^5.2.0", "chokidar": "^3.5.3", "commander": "^11.1.0", "cookie": "^0.5.0", "esbuild": "^0.15.3", "js-yaml": "^4.1.0", "miniflare": "^3.20231218.1", "package-manager-manager": "^0.2.0", "pcre-to-regexp": "^1.1.0", "semver": "^7.5.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240208.0", "vercel": ">=30.0.0", "wrangler": "^3.28.2" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "next-on-pages": "bin/index.js" } }, "sha512-TSMVy+1fmxzeyykOC9guMEj7G2FgENw1T8V1sIFnah6piaJNBmybdyikPdQSdikP5w6v9eQhBt/TrXDPMDw0dw=="], + "@cloudflare/next-on-pages": ["@cloudflare/next-on-pages@1.13.12", "", { "dependencies": { "acorn": "^8.8.0", "ast-types": "^0.14.2", "chalk": "^5.2.0", "chokidar": "^3.5.3", "commander": "^11.1.0", "cookie": "^0.5.0", "esbuild": "^0.15.3", "js-yaml": "^4.1.0", "miniflare": "^3.20231218.1", "package-manager-manager": "^0.2.0", "pcre-to-regexp": "^1.1.0", "semver": "^7.5.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240208.0", "vercel": ">=30.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "next-on-pages": "bin/index.js" } }, "sha512-rPy7x9c2+0RDDdJ5o0TeRUwXJ1b7N1epnqF6qKSp5Wz1r9KHOyvaZh1ACoOC6Vu5k9su5WZOgy+8fPLIyrldMQ=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250320.0" }, "optionalPeers": ["workerd"] }, "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg=="], @@ -546,10 +571,6 @@ "@emotion/memoize": ["@emotion/memoize@0.7.4", "", {}, "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw=="], - "@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="], - - "@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], @@ -600,6 +621,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "@evan/concurrency": ["@evan/concurrency@0.0.3", "", {}, "sha512-vjhkm2nrXoM39G4aP/U4CC5vFv/ZlMRSjbTII0N65J9R0EpgjdGswnHKS1KSjfGAp/9zKSNaojBgi0SxKnGapw=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], "@floating-ui/core": ["@floating-ui/core@1.6.8", "", { "dependencies": { "@floating-ui/utils": "^0.2.8" } }, "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA=="], @@ -628,7 +651,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], - "@gitbook/api": ["@gitbook/api@0.111.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-E5Pk28kPD4p6XNWdwFM9pgDijdByseIZQqcFK+/hoW5tEZa5Yw/plRKJyN1hmwfPL6SKq6Maf0fbIzTQiVXyQQ=="], + "@gitbook/api": ["@gitbook/api@0.121.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-o4/N24RM0Rg8S/3yPDjPmt6TbQF+1iZmg9q9QKxOxMqpQ2bZmMUqS7dSkeqEbEBMALx/m/x0xQlJbEJGbOwteg=="], "@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"], @@ -640,6 +663,8 @@ "@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.8", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^6.6.0" } }, "sha512-i4PgiuGyUb52Muhc52kK3aMJIMfMkA2RbPW30tre8a6M8T6mWTfYo6gafSgjNvF1vH29zcuB8oBYnF0gO4XcHA=="], + "@gitbook/fonts": ["@gitbook/fonts@workspace:packages/fonts"], + "@gitbook/icons": ["@gitbook/icons@workspace:packages/icons"], "@gitbook/openapi-parser": ["@gitbook/openapi-parser@workspace:packages/openapi-parser"], @@ -794,9 +819,25 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opennextjs/aws": ["@opennextjs/aws@3.5.7", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.19.2", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-YjyHJrkIHI7YwQRCp8GjDOudu86oOc1RiwxvBBpPHrplsS18H4ZmkzGggAKhK6B4myGsJQ/q9kNP2TraoZiNzg=="], + "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], + + "@octokit/core": ["@octokit/core@6.1.5", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg=="], + + "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], + + "@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], + + "@octokit/request": ["@octokit/request@9.2.3", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w=="], - "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.0.0-beta.3", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.5.7", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^3.114.3 || ^4.7.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-qKBXQZhUeQ+iGvfJeF7PO30g59LHnPOlRVZd77zxwn6Uc9C+c0LSwo8N28XRIWyQPkY007rKk9pSIxOrP4MHtQ=="], + "@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], + + "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + + "@opennextjs/aws": ["@opennextjs/aws@3.6.5", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-wni+CWlRCyWfhNfekQBBPPkrDDnaGdZLN9hMybKI0wKOKTO+zhPOqR65Eh3V0pzWAi84Sureb5mdMuLwCxAAcw=="], + + "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.2.1", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.5", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-cOco+nHwlo/PLB1bThF8IIvaS8PgAT9MEI5ZttFO/qt6spgvr2lUaPkpjgSIQmI3sBIEG2cLUykvQ2nbbZEcVw=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -822,11 +863,15 @@ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.12", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VJoMs+BWWE7YhzEQyVwvF9n22Eiyr83HotCVrMQzla/OwRovXCgah7AcaEr4hMNj4gJxSdtIbcHGvmJXOoJVHA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA=="], - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.4", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.7", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.4", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.4", "@radix-ui/react-portal": "1.1.6", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-roving-focus": "1.1.7", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+qYq6LfbiGo97Zz9fioX83HCiIYYFNs8zAsVCMQrIakoNYylIzWuoD/anAD3UzvvR6cnswmfRFJFq/zYYq/k7Q=="], "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wUi01RrTDTOoGtjEPHsxlzPtVzVc3R/AZ5wfh0dyqMAqolhHAHvG5iQjBCTi2AjQqa77FWWbA3kE3RkD+bDMgQ=="], @@ -840,6 +885,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.0", "", { "dependencies": { "@radix-ui/react-slot": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.4", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA=="], @@ -848,6 +895,8 @@ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="], + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], @@ -1094,7 +1143,7 @@ "@scalar/object-utils": ["@scalar/object-utils@1.1.13", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1" } }, "sha512-311eTykIXgOtjCs4VTELj9UMT97jHTWc5qkGNoIzZ5nxjCcvOVe7kDQobIkE8dGT+ybOgHz5qly02Eu7nVHeZQ=="], - "@scalar/openapi-parser": ["@scalar/openapi-parser@0.10.10", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-6MSgvpNKu/anZy96dn8tXQZo1PuDCoeB4m2ZLLDS4vC2zaTnuNBvvQHx+gjwXNKWhTbIVy8bQpYBzlMAYnFNcQ=="], + "@scalar/openapi-parser": ["@scalar/openapi-parser@0.10.14", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-VXr979NMx6wZ+kpFKor2eyCJZOjyMwcBRc6c4Gc92ZMOC7ZNYqjwbw+Ubh2ELJyP5cWAjOFSrNwtylema0pw5w=="], "@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="], @@ -1390,7 +1439,7 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.12.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -1458,6 +1507,8 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "bignumber.js": ["bignumber.js@9.1.2", "", {}, "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug=="], @@ -1470,6 +1521,8 @@ "body-parser": ["body-parser@2.0.2", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "3.1.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.5.2", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "^3.0.0", "type-is": "~1.6.18" } }, "sha512-SNMk0OONlQ01uk8EPeiBvTW7W4ovpL5b1O3t1sjpPgfxOQ6BqQJ6XjxinDPR79Z6HdcD5zBBwr5ssiTlgdNztQ=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="], "boxen": ["boxen@4.2.0", "", { "dependencies": { "ansi-align": "^3.0.0", "camelcase": "^5.3.1", "chalk": "^3.0.0", "cli-boxes": "^2.2.0", "string-width": "^4.1.0", "term-size": "^2.1.0", "type-fest": "^0.8.1", "widest-line": "^3.1.0" } }, "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ=="], @@ -1494,6 +1547,8 @@ "bytes": ["bytes@3.1.0", "", {}, "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacheable": ["cacheable@1.8.9", "", { "dependencies": { "hookified": "^1.7.1", "keyv": "^5.3.1" } }, "sha512-FicwAUyWnrtnd4QqYAoRlNs44/a1jTL7XDKqm5gJ90wz1DQPlC7U2Rd1Tydpv+E7WAr4sQHuw8Q8M3nZMAyecQ=="], "cacheable-request": ["cacheable-request@6.1.0", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^3.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^4.1.0", "responselike": "^1.0.2" } }, "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg=="], @@ -1510,8 +1565,6 @@ "caniuse-lite": ["caniuse-lite@1.0.30001668", "", {}, "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw=="], - "capnp-ts": ["capnp-ts@0.7.0", "", { "dependencies": { "debug": "^4.3.1", "tslib": "^2.2.0" } }, "sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g=="], - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], @@ -1564,8 +1617,6 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - "configstore": ["configstore@5.0.1", "", { "dependencies": { "dot-prop": "^5.2.0", "graceful-fs": "^4.1.2", "make-dir": "^3.0.0", "unique-string": "^2.0.0", "write-file-atomic": "^3.0.0", "xdg-basedir": "^4.0.0" } }, "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA=="], "consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="], @@ -1594,10 +1645,16 @@ "css-functions-list": ["css-functions-list@3.2.3", "", {}, "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA=="], + "css-select": ["css-select@5.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg=="], + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + "css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "cva": ["cva@1.0.0-beta.2", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "typescript": ">= 4.5.5 < 6" }, "optionalPeers": ["typescript"] }, "sha512-dqcOFe247I5pKxfuzqfq3seLL5iMYsTgo40Uw7+pKZAntPgFtR7Tmy59P5IVIq/XgB0NQWoIvYDt9TwHkuK8Cg=="], @@ -1652,8 +1709,18 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], @@ -1686,7 +1753,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "env-cmd": ["env-cmd@10.1.0", "", { "dependencies": { "commander": "^4.0.0", "cross-spawn": "^7.0.0" }, "bin": { "env-cmd": "bin/env-cmd.js" } }, "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA=="], @@ -1756,7 +1823,7 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], @@ -1792,6 +1859,8 @@ "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1808,6 +1877,8 @@ "fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="], + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], @@ -1834,6 +1905,8 @@ "form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], @@ -1892,6 +1965,8 @@ "google-auth-library": ["google-auth-library@5.10.1", "", { "dependencies": { "arrify": "^2.0.0", "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "fast-text-encoding": "^1.0.0", "gaxios": "^2.1.0", "gcp-metadata": "^3.4.0", "gtoken": "^4.1.0", "jws": "^4.0.0", "lru-cache": "^5.0.0" } }, "sha512-rOlaok5vlpV9rSiUu5EpR0vVpc+PhN62oF4RyX/6++DG1VsaulAFEMlDYBLjJDDPI6OcNOCGAKy9UVB/3NIDXg=="], + "google-font-metadata": ["google-font-metadata@6.0.3", "", { "dependencies": { "@evan/concurrency": "^0.0.3", "@octokit/core": "^6.1.2", "cac": "^6.7.14", "consola": "^3.3.3", "deepmerge": "^4.3.1", "json-stringify-pretty-compact": "^4.0.0", "linkedom": "^0.18.6", "pathe": "^1.1.2", "picocolors": "^1.1.1", "playwright": "^1.49.1", "stylis": "^4.3.4", "zod": "^3.24.1" }, "bin": { "gfm": "dist/cli.mjs" } }, "sha512-62+QB+nmBKppHrxwvILZkV/EvKctEAdzkKBIJY26TtwZkUjeEqmAQnE5uHvtMTdB8odqGpQu5LAKPqqUYHI9wA=="], + "google-p12-pem": ["google-p12-pem@2.0.5", "", { "dependencies": { "node-forge": "^0.10.0" }, "bin": { "gp12-pem": "build/src/bin/gp12-pem.js" } }, "sha512-7RLkxwSsMsYh9wQ5Vb2zRtkAHvqPvfoMGag+nugl1noYO7gf0844Yr9TIFA5NEBMAeVt2Z+Imu7CQMp3oNatzQ=="], "googleapis": ["googleapis@47.0.0", "", { "dependencies": { "google-auth-library": "^5.6.1", "googleapis-common": "^3.2.0" } }, "sha512-+Fnjgcc3Na/rk57dwxqW1V0HJXJFjnt3aqFlckULqAqsPkmex/AyJJe6MSlXHC37ZmlXEb9ZtPGUp5ZzRDXpHg=="], @@ -1968,12 +2043,16 @@ "hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html-tags": ["html-tags@3.3.1", "", {}, "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-whitespace-sensitive-tag-names": ["html-whitespace-sensitive-tag-names@3.0.1", "", {}, "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA=="], + "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + "http-cache-semantics": ["http-cache-semantics@4.1.1", "", {}, "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="], "http-errors": ["http-errors@1.4.0", "", { "dependencies": { "inherits": "2.0.1", "statuses": ">= 1.2.1 < 2" } }, "sha512-oLjPqve1tuOl5aRhv8GK5eHpqP1C9fb+Ol+XTLjKfLltE44zdDbEdjPSbU7Ch5rSNsVFqZn97SrMmZLdu1/YMw=="], @@ -1992,6 +2071,8 @@ "ignore": ["ignore@7.0.3", "", {}, "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA=="], + "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-lazy": ["import-lazy@2.1.0", "", {}, "sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A=="], @@ -2084,6 +2165,8 @@ "json-stringify-deterministic": ["json-stringify-deterministic@1.0.12", "", {}, "sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], + "json-xml-parse": ["json-xml-parse@1.3.0", "", {}, "sha512-MVosauc/3W2wL4dd4yaJzH5oXw+HOUfptn0+d4+bFghMiJFop7MaqIwFXJNLiRnNYJNQ6L4o7B+53n5wcvoLFw=="], "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], @@ -2122,6 +2205,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "linkedom": ["linkedom@0.18.11", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" } }, "sha512-K03GU3FUlnhBAP0jPb7tN7YJl7LbjZx30Z8h6wgLXusnKF7+BEZvfEbdkN/lO9LfFzxN3S0ZAriDuJ/13dIsLA=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="], @@ -2180,9 +2265,11 @@ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], - "mdast-util-gfm": ["mdast-util-gfm@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], @@ -2226,6 +2313,8 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA=="], + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], @@ -2308,8 +2397,6 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], - "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -2350,12 +2437,16 @@ "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nuqs": ["nuqs@2.2.3", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "@remix-run/react": ">=2", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router-dom": ">=6" }, "optionalPeers": ["@remix-run/react", "next", "react-router-dom"] }, "sha512-nMCcUW06KSqEXA0xp+LiRqDpIE59BVYbjZLe0HUisJAlswfihHYSsAjYTzV0lcE1thfh8uh+LqUHGdQ8qq8rfA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="], + "object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="], "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], @@ -2396,7 +2487,7 @@ "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "p-map": ["p-map@7.0.2", "", {}, "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q=="], + "p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], @@ -2440,7 +2531,7 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "pcre-to-regexp": ["pcre-to-regexp@1.1.0", "", {}, "sha512-KF9XxmUQJ2DIlMj3TqNqY1AWvyvTuIuq11CuuekxyaYMiFuMKGgQrePYMX5bXKLhLG3sDI4CsGAYHPaT7VV7+g=="], @@ -2454,8 +2545,6 @@ "pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="], - "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "playwright": ["playwright@1.51.1", "", { "dependencies": { "playwright-core": "1.51.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw=="], "playwright-core": ["playwright-core@1.51.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw=="], @@ -2518,13 +2607,13 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-aria": ["react-aria@3.37.0", "", { "dependencies": { "@internationalized/string": "^3.2.5", "@react-aria/breadcrumbs": "^3.5.20", "@react-aria/button": "^3.11.1", "@react-aria/calendar": "^3.7.0", "@react-aria/checkbox": "^3.15.1", "@react-aria/color": "^3.0.3", "@react-aria/combobox": "^3.11.1", "@react-aria/datepicker": "^3.13.0", "@react-aria/dialog": "^3.5.21", "@react-aria/disclosure": "^3.0.1", "@react-aria/dnd": "^3.8.1", "@react-aria/focus": "^3.19.1", "@react-aria/gridlist": "^3.10.1", "@react-aria/i18n": "^3.12.5", "@react-aria/interactions": "^3.23.0", "@react-aria/label": "^3.7.14", "@react-aria/link": "^3.7.8", "@react-aria/listbox": "^3.14.0", "@react-aria/menu": "^3.17.0", "@react-aria/meter": "^3.4.19", "@react-aria/numberfield": "^3.11.10", "@react-aria/overlays": "^3.25.0", "@react-aria/progress": "^3.4.19", "@react-aria/radio": "^3.10.11", "@react-aria/searchfield": "^3.8.0", "@react-aria/select": "^3.15.1", "@react-aria/selection": "^3.22.0", "@react-aria/separator": "^3.4.5", "@react-aria/slider": "^3.7.15", "@react-aria/ssr": "^3.9.7", "@react-aria/switch": "^3.6.11", "@react-aria/table": "^3.16.1", "@react-aria/tabs": "^3.9.9", "@react-aria/tag": "^3.4.9", "@react-aria/textfield": "^3.16.0", "@react-aria/tooltip": "^3.7.11", "@react-aria/utils": "^3.27.0", "@react-aria/visually-hidden": "^3.8.19", "@react-types/shared": "^3.27.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-u3WUEMTcbQFaoHauHO3KhPaBYzEv1o42EdPcLAs05GBw9Q6Axlqwo73UFgMrsc2ElwLAZ4EKpSdWHLo1R5gfiw=="], "react-aria-components": ["react-aria-components@1.6.0", "", { "dependencies": { "@internationalized/date": "^3.7.0", "@internationalized/string": "^3.2.5", "@react-aria/autocomplete": "3.0.0-alpha.37", "@react-aria/collections": "3.0.0-alpha.7", "@react-aria/color": "^3.0.3", "@react-aria/disclosure": "^3.0.1", "@react-aria/dnd": "^3.8.1", "@react-aria/focus": "^3.19.1", "@react-aria/interactions": "^3.23.0", "@react-aria/live-announcer": "^3.4.1", "@react-aria/menu": "^3.17.0", "@react-aria/toolbar": "3.0.0-beta.12", "@react-aria/tree": "3.0.0-beta.3", "@react-aria/utils": "^3.27.0", "@react-aria/virtualizer": "^4.1.1", "@react-stately/autocomplete": "3.0.0-alpha.0", "@react-stately/color": "^3.8.2", "@react-stately/disclosure": "^3.0.1", "@react-stately/layout": "^4.1.1", "@react-stately/menu": "^3.9.1", "@react-stately/selection": "^3.19.0", "@react-stately/table": "^3.13.1", "@react-stately/utils": "^3.10.5", "@react-stately/virtualizer": "^4.2.1", "@react-types/color": "^3.0.2", "@react-types/form": "^3.7.9", "@react-types/grid": "^3.2.11", "@react-types/shared": "^3.27.0", "@react-types/table": "^3.10.4", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.37.0", "react-stately": "^3.35.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-YfG9PUE7XrXtDDAqT4pLTGyYQaiHHTBFdAK/wNgGsypVnQSdzmyYlV3Ty8aHlZJI6hP9RWkbywvosXkU7KcPHg=="], - "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-hotkeys-hook": ["react-hotkeys-hook@4.5.1", "", { "peerDependencies": { "react": ">=16.8.1", "react-dom": ">=16.8.1" } }, "sha512-scAEJOh3Irm0g95NIn6+tQVf/OICCjsQsC9NBHfQws/Vxw4sfq1tDQut5fhTEvPraXhu/sHxRd9lOtxzyYuNAg=="], @@ -2598,12 +2687,6 @@ "rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="], - "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], - - "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], - - "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], - "router": ["router@2.0.0", "", { "dependencies": { "array-flatten": "3.0.0", "is-promise": "4.0.0", "methods": "~1.1.2", "parseurl": "~1.3.3", "path-to-regexp": "^8.0.0", "setprototypeof": "1.2.0", "utils-merge": "1.0.1" } }, "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -2612,7 +2695,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], @@ -2660,8 +2743,6 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], - "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], @@ -2718,6 +2799,8 @@ "stylelint": ["stylelint@16.16.0", "", { "dependencies": { "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "@csstools/media-query-list-parser": "^4.0.2", "@csstools/selector-specificity": "^5.0.0", "@dual-bundle/import-meta-resolve": "^4.1.0", "balanced-match": "^2.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.0", "css-functions-list": "^3.2.3", "css-tree": "^3.1.0", "debug": "^4.3.7", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^10.0.7", "global-modules": "^2.0.0", "globby": "^11.1.0", "globjoin": "^0.1.4", "html-tags": "^3.3.1", "ignore": "^7.0.3", "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", "known-css-properties": "^0.35.0", "mathml-tag-names": "^2.1.3", "meow": "^13.2.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.3", "postcss-resolve-nested-selector": "^0.1.6", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.0", "postcss-value-parser": "^4.2.0", "resolve-from": "^5.0.0", "string-width": "^4.2.3", "supports-hyperlinks": "^3.2.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^5.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-40X5UOb/0CEFnZVEHyN260HlSSUxPES+arrUphOumGWgXERHfwCD0kNBVILgQSij8iliYVwlc0V7M5bcLP9vPg=="], + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2822,6 +2905,8 @@ "ufo": ["ufo@1.5.4", "", {}, "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="], + "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "uid-promise": ["uid-promise@1.0.0", "", {}, "sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig=="], "undici": ["undici@5.28.4", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g=="], @@ -2840,12 +2925,16 @@ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + "unist-util-remove": ["unist-util-remove@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg=="], + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -3558,15 +3647,13 @@ "@changesets/parse/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - "@cloudflare/next-on-pages/chalk": ["chalk@5.3.0", "", {}, "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="], - "@cloudflare/next-on-pages/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "@cloudflare/next-on-pages/esbuild": ["esbuild@0.15.18", "", { "optionalDependencies": { "@esbuild/android-arm": "0.15.18", "@esbuild/linux-loong64": "0.15.18", "esbuild-android-64": "0.15.18", "esbuild-android-arm64": "0.15.18", "esbuild-darwin-64": "0.15.18", "esbuild-darwin-arm64": "0.15.18", "esbuild-freebsd-64": "0.15.18", "esbuild-freebsd-arm64": "0.15.18", "esbuild-linux-32": "0.15.18", "esbuild-linux-64": "0.15.18", "esbuild-linux-arm": "0.15.18", "esbuild-linux-arm64": "0.15.18", "esbuild-linux-mips64le": "0.15.18", "esbuild-linux-ppc64le": "0.15.18", "esbuild-linux-riscv64": "0.15.18", "esbuild-linux-s390x": "0.15.18", "esbuild-netbsd-64": "0.15.18", "esbuild-openbsd-64": "0.15.18", "esbuild-sunos-64": "0.15.18", "esbuild-windows-32": "0.15.18", "esbuild-windows-64": "0.15.18", "esbuild-windows-arm64": "0.15.18" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q=="], - "@cloudflare/next-on-pages/miniflare": ["miniflare@3.20241018.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "^8.8.0", "acorn-walk": "^8.2.0", "capnp-ts": "^0.7.0", "exit-hook": "^2.2.1", "glob-to-regexp": "^0.4.1", "stoppable": "^1.1.0", "undici": "^5.28.4", "workerd": "1.20241018.1", "ws": "^8.17.1", "youch": "^3.2.2", "zod": "^3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-g7i5oGAoJOk8+hJp77A5/wAdu7PEvi5hQc+0wzwzjhUNM2I5DHd2Cc29ACPhAe1kIXvCCVkxs3+REF52qnX0aw=="], + "@cloudflare/next-on-pages/miniflare": ["miniflare@3.20250214.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250214.0", "ws": "8.18.0", "youch": "3.2.3", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-t+lT4p2lbOcKv4PS3sx1F/wcDAlbEYZCO2VooLp4H7JErWWYIi9yjD3UillC3CGOpiBahVg5nrPCoFltZf6UlA=="], - "@cloudflare/next-on-pages/wrangler": ["wrangler@3.112.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250214.2", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.1", "workerd": "1.20250214.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250214.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-PNQWGze3ODlWwG33LPr8kNhbht3eB3L9fogv+fapk2fjaqj0kNweRapkwmvtz46ojcqWzsxmTe4nOC0hIVUfPA=="], + "@cloudflare/next-on-pages/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="], @@ -3662,7 +3749,9 @@ "@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - "@opennextjs/aws/esbuild": ["esbuild@0.19.2", "", { "optionalDependencies": { "@esbuild/android-arm": "0.19.2", "@esbuild/android-arm64": "0.19.2", "@esbuild/android-x64": "0.19.2", "@esbuild/darwin-arm64": "0.19.2", "@esbuild/darwin-x64": "0.19.2", "@esbuild/freebsd-arm64": "0.19.2", "@esbuild/freebsd-x64": "0.19.2", "@esbuild/linux-arm": "0.19.2", "@esbuild/linux-arm64": "0.19.2", "@esbuild/linux-ia32": "0.19.2", "@esbuild/linux-loong64": "0.19.2", "@esbuild/linux-mips64el": "0.19.2", "@esbuild/linux-ppc64": "0.19.2", "@esbuild/linux-riscv64": "0.19.2", "@esbuild/linux-s390x": "0.19.2", "@esbuild/linux-x64": "0.19.2", "@esbuild/netbsd-x64": "0.19.2", "@esbuild/openbsd-x64": "0.19.2", "@esbuild/sunos-x64": "0.19.2", "@esbuild/win32-arm64": "0.19.2", "@esbuild/win32-ia32": "0.19.2", "@esbuild/win32-x64": "0.19.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-G6hPax8UbFakEj3hWO0Vs52LQ8k3lnBhxZWomUJDxfz3rZTLqF5k/FCzuNdLx2RbpBiQQF9H9onlDDH1lZsnjg=="], + "@opennextjs/aws/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "@radix-ui/react-collection/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], @@ -3676,24 +3765,88 @@ "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="], + "@radix-ui/react-dropdown-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.0", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + + "@radix-ui/react-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg=="], + + "@radix-ui/react-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menu/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA=="], + + "@radix-ui/react-menu/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.4", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.4", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA=="], + + "@radix-ui/react-menu/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw=="], + + "@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + + "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.0", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], + + "@radix-ui/react-menu/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-menu/react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], + "@radix-ui/react-navigation-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], "@radix-ui/react-navigation-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="], "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="], "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ=="], + "@radix-ui/react-popover/@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A=="], + "@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.0", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@radix-ui/react-tooltip/@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], "@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], "@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="], + "@radix-ui/react-tooltip/@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-tooltip/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="], "@radix-ui/react-tooltip/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], @@ -3706,6 +3859,8 @@ "@radix-ui/react-tooltip/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q=="], + "@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="], "@react-aria/focus/clsx": ["clsx@2.0.0", "", {}, "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q=="], @@ -3714,14 +3869,10 @@ "@rollup/pluginutils/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], - "@scalar/api-client/@scalar/openapi-parser": ["@scalar/openapi-parser@0.10.14", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-VXr979NMx6wZ+kpFKor2eyCJZOjyMwcBRc6c4Gc92ZMOC7ZNYqjwbw+Ubh2ELJyP5cWAjOFSrNwtylema0pw5w=="], - "@scalar/api-client/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], "@scalar/api-client/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="], - "@scalar/import/@scalar/openapi-parser": ["@scalar/openapi-parser@0.10.14", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-VXr979NMx6wZ+kpFKor2eyCJZOjyMwcBRc6c4Gc92ZMOC7ZNYqjwbw+Ubh2ELJyP5cWAjOFSrNwtylema0pw5w=="], - "@scalar/oas-utils/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], "@scalar/object-utils/flatted": ["flatted@3.3.1", "", {}, "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="], @@ -3922,8 +4073,6 @@ "@vercel/gatsby-plugin-vercel-builder/fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="], - "@vercel/nft/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], - "@vercel/nft/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "@vercel/nft/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -3948,6 +4097,8 @@ "@vercel/static-config/ajv": ["ajv@8.6.3", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw=="], + "@vue/compiler-core/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "@vue/compiler-sfc/postcss": ["postcss@8.4.47", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", "source-map-js": "^1.2.1" } }, "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ=="], "@vueuse/integrations/@vueuse/core": ["@vueuse/core@11.2.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "11.2.0", "@vueuse/shared": "11.2.0", "vue-demi": ">=0.14.10" } }, "sha512-JIUwRcOqOWzcdu1dGlfW04kaJhW3EXnnjJJfLTtddJanymTL7lF1C0+dVVZ/siLfc73mWn+cGP1PE1PKPruRSA=="], @@ -3980,8 +4131,6 @@ "cacheable-request/lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "capnp-ts/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "codemirror/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="], "codemirror/@codemirror/commands": ["@codemirror/commands@6.7.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw=="], @@ -3998,6 +4147,8 @@ "decamelize-keys/map-obj": ["map-obj@1.0.1", "", {}, "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "edge-runtime/async-listen": ["async-listen@3.0.1", "", {}, "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA=="], "edge-runtime/picocolors": ["picocolors@1.0.0", "", {}, "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="], @@ -4032,7 +4183,7 @@ "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "gitbook-v2/next": ["next@15.3.1-canary.8", "", { "dependencies": { "@next/env": "15.3.1-canary.8", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1-canary.8", "@next/swc-darwin-x64": "15.3.1-canary.8", "@next/swc-linux-arm64-gnu": "15.3.1-canary.8", "@next/swc-linux-arm64-musl": "15.3.1-canary.8", "@next/swc-linux-x64-gnu": "15.3.1-canary.8", "@next/swc-linux-x64-musl": "15.3.1-canary.8", "@next/swc-win32-arm64-msvc": "15.3.1-canary.8", "@next/swc-win32-x64-msvc": "15.3.1-canary.8", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Of5a3BTTIl/iUvL2a9Jh7m7G/H8z4Pj5Vs54CLvcdadokxSNgLOpjzbDgFR8J4PawLx6+MOMy19m9Cvr6EPGug=="], + "gitbook-v2/next": ["next@15.3.2", "", { "dependencies": { "@next/env": "15.3.2", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.2", "@next/swc-darwin-x64": "15.3.2", "@next/swc-linux-arm64-gnu": "15.3.2", "@next/swc-linux-arm64-musl": "15.3.2", "@next/swc-linux-x64-gnu": "15.3.2", "@next/swc-linux-x64-musl": "15.3.2", "@next/swc-win32-arm64-msvc": "15.3.2", "@next/swc-win32-x64-msvc": "15.3.2", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ=="], "global-dirs/ini": ["ini@1.3.7", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="], @@ -4046,6 +4197,8 @@ "google-auth-library/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "google-font-metadata/picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "googleapis-common/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], "got/get-stream": ["get-stream@4.1.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w=="], @@ -4070,16 +4223,20 @@ "make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "mdast-util-gfm/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], + "mdast-util-gfm-footnote/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], "mdast-util-gfm-footnote/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], + "mdast-util-gfm-strikethrough/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], + "mdast-util-gfm-strikethrough/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], + "mdast-util-gfm-table/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], + "mdast-util-gfm-table/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], + "mdast-util-gfm-task-list-item/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], + "mdast-util-gfm-task-list-item/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], "meow/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], @@ -4090,8 +4247,6 @@ "micromark/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], - "miniflare/undici": ["undici@5.28.5", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA=="], "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], @@ -4102,8 +4257,6 @@ "minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="], - "mlly/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], - "next/@swc/helpers": ["@swc/helpers@0.5.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -4114,6 +4267,8 @@ "package-json/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "parse5/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "path-match/path-to-regexp": ["path-to-regexp@1.9.0", "", { "dependencies": { "isarray": "0.0.1" } }, "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g=="], "path-scurry/lru-cache": ["lru-cache@11.0.2", "", {}, "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA=="], @@ -4152,15 +4307,13 @@ "read-yaml-file/pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], - "remark-stringify/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], + "remark-gfm/mdast-util-gfm": ["mdast-util-gfm@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw=="], - "rimraf/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "remark-parse/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], - "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], - - "rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + "remark-stringify/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], - "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + "rimraf/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -4200,14 +4353,16 @@ "terminal-link/supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], - "terser/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "ts-node/acorn": ["acorn@8.12.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg=="], + "ts-node/acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], "ts-node/arg": ["arg@4.1.0", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="], + "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "update-notifier/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "url-parse-lax/prepend-http": ["prepend-http@2.0.0", "", {}, "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA=="], @@ -4650,21 +4805,13 @@ "@cloudflare/next-on-pages/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.15.18", "", { "os": "linux", "cpu": "none" }, "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ=="], - "@cloudflare/next-on-pages/miniflare/acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "@cloudflare/next-on-pages/miniflare/undici": ["undici@5.28.5", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA=="], - "@cloudflare/next-on-pages/miniflare/workerd": ["workerd@1.20241018.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20241018.1", "@cloudflare/workerd-darwin-arm64": "1.20241018.1", "@cloudflare/workerd-linux-64": "1.20241018.1", "@cloudflare/workerd-linux-arm64": "1.20241018.1", "@cloudflare/workerd-windows-64": "1.20241018.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-JPW2oAbYOnJj1c5boyDOdjl/Yvur45jhVE8lf+I9oxR6myyAvuH2tdXO62kye68jRluJOMUeyssLes+JRwLmaA=="], + "@cloudflare/next-on-pages/miniflare/workerd": ["workerd@1.20250214.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250214.0", "@cloudflare/workerd-darwin-arm64": "1.20250214.0", "@cloudflare/workerd-linux-64": "1.20250214.0", "@cloudflare/workerd-linux-arm64": "1.20250214.0", "@cloudflare/workerd-windows-64": "1.20250214.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-QWcqXZLiMpV12wiaVnb3nLmfs/g4ZsFQq2mX85z546r3AX4CTIkXl0VP50W3CwqLADej3PGYiRDOTelDOwVG1g=="], - "@cloudflare/next-on-pages/miniflare/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], + "@cloudflare/next-on-pages/miniflare/youch": ["youch@3.2.3", "", { "dependencies": { "cookie": "^0.5.0", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-ZBcWz/uzZaQVdCvfV4uk616Bbpf2ee+F/AvuKDR5EwX/Y4v06xWdtMluqTD7+KlZdM93lLm9gMZYo0sKBS0pgw=="], - "@cloudflare/next-on-pages/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], - - "@cloudflare/next-on-pages/wrangler/esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], - - "@cloudflare/next-on-pages/wrangler/miniflare": ["miniflare@3.20250214.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250214.0", "ws": "8.18.0", "youch": "3.2.3", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-t+lT4p2lbOcKv4PS3sx1F/wcDAlbEYZCO2VooLp4H7JErWWYIi9yjD3UillC3CGOpiBahVg5nrPCoFltZf6UlA=="], - - "@cloudflare/next-on-pages/wrangler/unenv": ["unenv@2.0.0-rc.1", "", { "dependencies": { "defu": "^6.1.4", "mlly": "^1.7.4", "ohash": "^1.1.4", "pathe": "^1.1.2", "ufo": "^1.5.4" } }, "sha512-PU5fb40H8X149s117aB4ytbORcCvlASdtF97tfls4BPIyj4PeVxvpSuy1jAptqYHqB0vb2w2sHvzM0XWcp2OKg=="], - - "@cloudflare/next-on-pages/wrangler/workerd": ["workerd@1.20250214.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250214.0", "@cloudflare/workerd-darwin-arm64": "1.20250214.0", "@cloudflare/workerd-linux-64": "1.20250214.0", "@cloudflare/workerd-linux-arm64": "1.20250214.0", "@cloudflare/workerd-windows-64": "1.20250214.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-QWcqXZLiMpV12wiaVnb3nLmfs/g4ZsFQq2mX85z546r3AX4CTIkXl0VP50W3CwqLADej3PGYiRDOTelDOwVG1g=="], + "@cloudflare/next-on-pages/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "@codemirror/lang-json/@codemirror/language/@codemirror/view": ["@codemirror/view@6.34.1", "", { "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ=="], @@ -4696,54 +4843,96 @@ "@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "@opennextjs/aws/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.2", "", { "os": "android", "cpu": "arm" }, "sha512-tM8yLeYVe7pRyAu9VMi/Q7aunpLwD139EY1S99xbQkT4/q2qa6eA4ige/WJQYdJ8GBL1K33pPFhPfPdJ/WzT8Q=="], + "@opennextjs/aws/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], + + "@opennextjs/aws/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], - "@opennextjs/aws/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.2", "", { "os": "android", "cpu": "arm64" }, "sha512-lsB65vAbe90I/Qe10OjkmrdxSX4UJDjosDgb8sZUKcg3oefEuW2OT2Vozz8ef7wrJbMcmhvCC+hciF8jY/uAkw=="], + "@opennextjs/aws/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], - "@opennextjs/aws/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.19.2", "", { "os": "android", "cpu": "x64" }, "sha512-qK/TpmHt2M/Hg82WXHRc/W/2SGo/l1thtDHZWqFq7oi24AjZ4O/CpPSu6ZuYKFkEgmZlFoa7CooAyYmuvnaG8w=="], + "@opennextjs/aws/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], - "@opennextjs/aws/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Ora8JokrvrzEPEpZO18ZYXkH4asCdc1DLdcVy8TGf5eWtPO1Ie4WroEJzwI52ZGtpODy3+m0a2yEX9l+KUn0tA=="], + "@opennextjs/aws/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], - "@opennextjs/aws/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-tP+B5UuIbbFMj2hQaUr6EALlHOIOmlLM2FK7jeFBobPy2ERdohI4Ka6ZFjZ1ZYsrHE/hZimGuU90jusRE0pwDw=="], + "@opennextjs/aws/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], - "@opennextjs/aws/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YbPY2kc0acfzL1VPVK6EnAlig4f+l8xmq36OZkU0jzBVHcOTyQDhnKQaLzZudNJQyymd9OqQezeaBgkTGdTGeQ=="], + "@opennextjs/aws/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], - "@opennextjs/aws/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nSO5uZT2clM6hosjWHAsS15hLrwCvIWx+b2e3lZ3MwbYSaXwvfO528OF+dLjas1g3bZonciivI8qKR/Hm7IWGw=="], + "@opennextjs/aws/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], - "@opennextjs/aws/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.2", "", { "os": "linux", "cpu": "arm" }, "sha512-Odalh8hICg7SOD7XCj0YLpYCEc+6mkoq63UnExDCiRA2wXEmGlK5JVrW50vZR9Qz4qkvqnHcpH+OFEggO3PgTg=="], + "@opennextjs/aws/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], - "@opennextjs/aws/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-ig2P7GeG//zWlU0AggA3pV1h5gdix0MA3wgB+NsnBXViwiGgY77fuN9Wr5uoCrs2YzaYfogXgsWZbm+HGr09xg=="], + "@opennextjs/aws/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], - "@opennextjs/aws/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-mLfp0ziRPOLSTek0Gd9T5B8AtzKAkoZE70fneiiyPlSnUKKI4lp+mGEnQXcQEHLJAcIYDPSyBvsUbKUG2ri/XQ=="], + "@opennextjs/aws/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], - "@opennextjs/aws/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.2", "", { "os": "linux", "cpu": "none" }, "sha512-hn28+JNDTxxCpnYjdDYVMNTR3SKavyLlCHHkufHV91fkewpIyQchS1d8wSbmXhs1fiYDpNww8KTFlJ1dHsxeSw=="], + "@opennextjs/aws/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], - "@opennextjs/aws/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.2", "", { "os": "linux", "cpu": "none" }, "sha512-KbXaC0Sejt7vD2fEgPoIKb6nxkfYW9OmFUK9XQE4//PvGIxNIfPk1NmlHmMg6f25x57rpmEFrn1OotASYIAaTg=="], + "@opennextjs/aws/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], - "@opennextjs/aws/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-dJ0kE8KTqbiHtA3Fc/zn7lCd7pqVr4JcT0JqOnbj4LLzYnp+7h8Qi4yjfq42ZlHfhOCM42rBh0EwHYLL6LEzcw=="], + "@opennextjs/aws/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], - "@opennextjs/aws/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.2", "", { "os": "linux", "cpu": "none" }, "sha512-7Z/jKNFufZ/bbu4INqqCN6DDlrmOTmdw6D0gH+6Y7auok2r02Ur661qPuXidPOJ+FSgbEeQnnAGgsVynfLuOEw=="], + "@opennextjs/aws/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], - "@opennextjs/aws/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-U+RinR6aXXABFCcAY4gSlv4CL1oOVvSSCdseQmGO66H+XyuQGZIUdhG56SZaDJQcLmrSfRmx5XZOWyCJPRqS7g=="], + "@opennextjs/aws/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], - "@opennextjs/aws/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.2", "", { "os": "linux", "cpu": "x64" }, "sha512-oxzHTEv6VPm3XXNaHPyUTTte+3wGv7qVQtqaZCrgstI16gCuhNOtBXLEBkBREP57YTd68P0VgDgG73jSD8bwXQ=="], + "@opennextjs/aws/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], - "@opennextjs/aws/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.2", "", { "os": "none", "cpu": "x64" }, "sha512-WNa5zZk1XpTTwMDompZmvQLHszDDDN7lYjEHCUmAGB83Bgs20EMs7ICD+oKeT6xt4phV4NDdSi/8OfjPbSbZfQ=="], + "@opennextjs/aws/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], - "@opennextjs/aws/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-S6kI1aT3S++Dedb7vxIuUOb3oAxqxk2Rh5rOXOTYnzN8JzW1VzBd+IqPiSpgitu45042SYD3HCoEyhLKQcDFDw=="], + "@opennextjs/aws/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], - "@opennextjs/aws/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-VXSSMsmb+Z8LbsQGcBMiM+fYObDNRm8p7tkUDMPG/g4fhFX5DEFmjxIEa3N8Zr96SjsJ1woAhF0DUnS3MF3ARw=="], + "@opennextjs/aws/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], - "@opennextjs/aws/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-5NayUlSAyb5PQYFAU9x3bHdsqB88RC3aM9lKDAz4X1mo/EchMIT1Q+pSeBXNgkfNmRecLXA0O8xP+x8V+g/LKg=="], + "@opennextjs/aws/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], - "@opennextjs/aws/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-47gL/ek1v36iN0wL9L4Q2MFdujR0poLZMJwhO2/N3gA89jgHp4MR8DKCmwYtGNksbfJb9JoTtbkoe6sDhg2QTA=="], + "@opennextjs/aws/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], - "@opennextjs/aws/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.2", "", { "os": "win32", "cpu": "x64" }, "sha512-tcuhV7ncXBqbt/Ybf0IyrMcwVOAPDckMK9rXNHtF17UTK18OKLpg08glminN06pt2WCoALhXdLfSPbVvK/6fxw=="], + "@opennextjs/aws/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], + + "@opennextjs/aws/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], + + "@opennextjs/aws/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@radix-ui/react-menu/@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-menu/react-remove-scroll/react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "@radix-ui/react-menu/react-remove-scroll/react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "@radix-ui/react-menu/react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@radix-ui/react-menu/react-remove-scroll/use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "@radix-ui/react-menu/react-remove-scroll/use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="], + "@radix-ui/react-roving-focus/@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="], "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="], @@ -4908,23 +5097,23 @@ "gaxios/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "gitbook-v2/next/@next/env": ["@next/env@15.3.1-canary.8", "", {}, "sha512-ShZTo0hNhbTRrp7k6oUDSck4Xx4hhfSeLBp35jvGaw1QMZzWYr5v/oc0kEt0bfMdl+833flwKV7kFR3BnrULfg=="], + "gitbook-v2/next/@next/env": ["@next/env@15.3.2", "", {}, "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g=="], - "gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.1-canary.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZaDynM+pbrnLLlBAxH/CDGp9KN79OFrLcT1ejlWyo86V3SS9Gyqr4nmTuvTevByTTpr1VHReQel8Zbq0Pttu7Q=="], + "gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g=="], - "gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.1-canary.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-BsMR8WqeCDAX8C9RYAO8TI4ttpuqKk2oYMb1+bCrOYi857SMfB4vWD4PWmMvj7mwFXGqrxly4W6CBQlD66A+fg=="], + "gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w=="], - "gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.1-canary.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-xL+K2SW+/46j/KnKNf1gizM1bxwcEaE56eCEG9RPoYS/lfxHLuHcR9O2MlcPI90g/rIN2HXmHeuKRbXbnVy15g=="], + "gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA=="], - "gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.1-canary.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-bzXlCUXkjIRsMTb6rr7OsWEQmdO2rZgKijnMGBJzEpa9ROq95VJTF+rdyrLmHuc4fPsAGDn/C18V3E1YOi4ipQ=="], + "gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg=="], - "gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.1-canary.8", "", { "os": "linux", "cpu": "x64" }, "sha512-snbPQ9th7eoYYMZpNGhEvX4EbqGjjkiXzTEm2F/oDadlZR2wI6egtfQHiZqeczL7XwG3M66hBJ1/U20wdTDtvA=="], + "gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg=="], - "gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.1-canary.8", "", { "os": "linux", "cpu": "x64" }, "sha512-5hmcaGazc3w6rg/gbQOrmuw0kKShf4Egs0JlUPop/ZiRDfZO5KlyrGY0hUD+2pLG7Yx3B/fTbj86cHKfQZDMBw=="], + "gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w=="], - "gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.1-canary.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-t6uwWC/UbQ8CQGyBVbEmpcJC41yAvz2eZZGec9EoO0ZyQ/fbvjOkqfPnf+WcjWifWNeuiWogjN1UBg7YK9IaVA=="], + "gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ=="], - "gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.1-canary.8", "", { "os": "win32", "cpu": "x64" }, "sha512-93gNqVYwlr9bz6Pm5dQnqjpuOEQyvDre0+H39UqS7h2KuFh3sf2MejhAEr2uFEux4mb7TN0PlohaihO7YxJ3fw=="], + "gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA=="], "gitbook-v2/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -4952,6 +5141,10 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "remark-gfm/mdast-util-gfm/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA=="], + + "remark-gfm/mdast-util-gfm/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="], + "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -5114,81 +5307,15 @@ "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20241018.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-CRySEzjNRoR8frP5AbtJXd1tgVJa5v7bZon9Dh6nljYlhG+piDv8jvOVEUqF3cXXS+M5aXwr4NlozdMvl5g5mg=="], - - "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20241018.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y63yWJNTgETDFkY3Ony71/k/G1HRDFIhEzwbT+OWmg1Qbsqa4TquHPVFkgv+OJhpmD3HV9gTBcn/M2QJ/+pGmg=="], - - "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20241018.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a2AbSAXNMMiREvN+PwjHdJ5zOzI4+qf8+rb6H/y4HcVbPZN5C2fanxv5Bx7NUHLiMD/W0FrGug1aU+RPUVZC9Q=="], - - "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20241018.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-iCJ7bjD/+zhlp3IWnkiry180DwdNvak/sVoS98pIAS41aR3gJVzE5BCz/2yTWFdCoUVZ5yKJrv1HhSKgQRBIEw=="], - - "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20241018.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qwDVh/KrwEPY82h6tZ1O4BXBAKeGy30BeTr9wvTUVeY9eX/KT73GuEG+ttwiashRfqjOa0Gcqjsfpd913ITFyg=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], - - "@cloudflare/next-on-pages/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], - - "@cloudflare/next-on-pages/wrangler/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], - - "@cloudflare/next-on-pages/wrangler/miniflare/undici": ["undici@5.28.5", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA=="], - - "@cloudflare/next-on-pages/wrangler/miniflare/youch": ["youch@3.2.3", "", { "dependencies": { "cookie": "^0.5.0", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-ZBcWz/uzZaQVdCvfV4uk616Bbpf2ee+F/AvuKDR5EwX/Y4v06xWdtMluqTD7+KlZdM93lLm9gMZYo0sKBS0pgw=="], - - "@cloudflare/next-on-pages/wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], - - "@cloudflare/next-on-pages/wrangler/unenv/ohash": ["ohash@1.1.4", "", {}, "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g=="], - - "@cloudflare/next-on-pages/wrangler/unenv/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - - "@cloudflare/next-on-pages/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250214.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cDvvedWDc5zrgDnuXe2qYcz/TwBvzmweO55C7XpPuAWJ9Oqxv81PkdekYxD8mH989aQ/GI5YD0Fe6fDYlM+T3Q=="], + "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250214.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cDvvedWDc5zrgDnuXe2qYcz/TwBvzmweO55C7XpPuAWJ9Oqxv81PkdekYxD8mH989aQ/GI5YD0Fe6fDYlM+T3Q=="], - "@cloudflare/next-on-pages/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250214.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NytCvRveVzu0mRKo+tvZo3d/gCUway3B2ZVqSi/TS6NXDGBYIJo7g6s3BnTLS74kgyzeDOjhu9j/RBJBS809qw=="], + "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250214.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NytCvRveVzu0mRKo+tvZo3d/gCUway3B2ZVqSi/TS6NXDGBYIJo7g6s3BnTLS74kgyzeDOjhu9j/RBJBS809qw=="], - "@cloudflare/next-on-pages/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250214.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pQ7+aHNHj8SiYEs4d/6cNoimE5xGeCMfgU1yfDFtA9YGN9Aj2BITZgOWPec+HW7ZkOy9oWlNrO6EvVjGgB4tbQ=="], + "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250214.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pQ7+aHNHj8SiYEs4d/6cNoimE5xGeCMfgU1yfDFtA9YGN9Aj2BITZgOWPec+HW7ZkOy9oWlNrO6EvVjGgB4tbQ=="], - "@cloudflare/next-on-pages/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250214.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Vhlfah6Yd9ny1npNQjNgElLIjR6OFdEbuR3LCfbLDCwzWEBFhIf7yC+Tpp/a0Hq7kLz3sLdktaP7xl3PJhyOjA=="], + "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250214.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Vhlfah6Yd9ny1npNQjNgElLIjR6OFdEbuR3LCfbLDCwzWEBFhIf7yC+Tpp/a0Hq7kLz3sLdktaP7xl3PJhyOjA=="], - "@cloudflare/next-on-pages/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250214.0", "", { "os": "win32", "cpu": "x64" }, "sha512-GMwMyFbkjBKjYJoKDhGX8nuL4Gqe3IbVnVWf2Q6086CValyIknupk5J6uQWGw2EBU3RGO3x4trDXT5WphQJZDQ=="], + "@cloudflare/next-on-pages/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250214.0", "", { "os": "win32", "cpu": "x64" }, "sha512-GMwMyFbkjBKjYJoKDhGX8nuL4Gqe3IbVnVWf2Q6086CValyIknupk5J6uQWGw2EBU3RGO3x4trDXT5WphQJZDQ=="], "@node-minify/core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/package.json b/package.json index 736eabe411..1cf5326c1d 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,11 @@ "turbo": "^2.5.0", "vercel": "^39.3.0" }, - "packageManager": "bun@1.2.8", + "packageManager": "bun@1.2.15", "overrides": { "@codemirror/state": "6.4.1", - "react": "18.3.1", - "react-dom": "18.3.1", - "@gitbook/api": "0.111.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "private": true, "scripts": { @@ -34,7 +33,12 @@ "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "clean": "turbo run clean" }, - "workspaces": ["packages/*"], + "workspaces": { + "packages": ["packages/*"], + "catalog": { + "@gitbook/api": "^0.121.0" + } + }, "patchedDependencies": { "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch" diff --git a/packages/cache-tags/package.json b/packages/cache-tags/package.json index 3d25aa8f48..a57048f2f6 100644 --- a/packages/cache-tags/package.json +++ b/packages/cache-tags/package.json @@ -10,7 +10,7 @@ }, "version": "0.3.1", "dependencies": { - "@gitbook/api": "^0.111.0", + "@gitbook/api": "catalog:", "assert-never": "^1.2.1" }, "devDependencies": { diff --git a/packages/colors/CHANGELOG.md b/packages/colors/CHANGELOG.md index 0c1606b101..1695a6c97d 100644 --- a/packages/colors/CHANGELOG.md +++ b/packages/colors/CHANGELOG.md @@ -1,5 +1,13 @@ # @gitbook/colors +## 0.3.3 + +### Patch Changes + +- c3f6b8c: Update chroma ratio per step +- 5e975ab: Fix code highlighting for HTTP +- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast + ## 0.3.2 ### Patch Changes diff --git a/packages/colors/package.json b/packages/colors/package.json index 36cd0f47f8..16f54d1ff3 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -8,7 +8,7 @@ "default": "./dist/index.js" } }, - "version": "0.3.2", + "version": "0.3.3", "devDependencies": { "typescript": "^5.5.3" }, diff --git a/packages/colors/src/transformations.ts b/packages/colors/src/transformations.ts index bf7f770c09..8234a460a9 100644 --- a/packages/colors/src/transformations.ts +++ b/packages/colors/src/transformations.ts @@ -214,13 +214,31 @@ export function colorScale( const targetL = foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]); - if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) { + if ( + index === 8 && + !mix && + (darkMode ? targetL - baseColor.L < 0.2 : 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 === 9 ? 1 : index * 0.05; + const chromaRatio = (() => { + switch (index) { + // Step 9 and 10 have max chroma, meaning they are fully saturated. + case 8: + case 9: + return 1; + // Step 11 and 12 have a reduced chroma + case 10: + return 0.4; + case 11: + return 0.1; + default: + return index * 0.05; + } + })(); const shade = { L: targetL, // Blend lightness diff --git a/packages/fonts/.gitignore b/packages/fonts/.gitignore new file mode 100644 index 0000000000..253839862f --- /dev/null +++ b/packages/fonts/.gitignore @@ -0,0 +1,2 @@ +dist/ +src/data/*.json diff --git a/packages/fonts/README.md b/packages/fonts/README.md new file mode 100644 index 0000000000..fef253f27f --- /dev/null +++ b/packages/fonts/README.md @@ -0,0 +1,3 @@ +# `@gitbook/fonts` + +Utilities to lookup default fonts supported by GitBook. diff --git a/packages/fonts/bin/generate.ts b/packages/fonts/bin/generate.ts new file mode 100644 index 0000000000..d4326d615c --- /dev/null +++ b/packages/fonts/bin/generate.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { APIv2 } from 'google-font-metadata'; + +import { CustomizationDefaultFont } from '@gitbook/api'; + +import type { FontDefinitions } from '../src/types'; + +const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = { + [CustomizationDefaultFont.Inter]: 'inter', + [CustomizationDefaultFont.FiraSans]: 'fira-sans-extra-condensed', + [CustomizationDefaultFont.IBMPlexSerif]: 'ibm-plex-serif', + [CustomizationDefaultFont.Lato]: 'lato', + [CustomizationDefaultFont.Merriweather]: 'merriweather', + [CustomizationDefaultFont.NotoSans]: 'noto-sans', + [CustomizationDefaultFont.OpenSans]: 'open-sans', + [CustomizationDefaultFont.Overpass]: 'overpass', + [CustomizationDefaultFont.Poppins]: 'poppins', + [CustomizationDefaultFont.Raleway]: 'raleway', + [CustomizationDefaultFont.Roboto]: 'roboto', + [CustomizationDefaultFont.RobotoSlab]: 'roboto-slab', + [CustomizationDefaultFont.SourceSansPro]: 'source-sans-3', + [CustomizationDefaultFont.Ubuntu]: 'ubuntu', + [CustomizationDefaultFont.ABCFavorit]: 'inter', +}; + +/** + * Scripts to generate the list of all icons. + */ +async function main() { + // @ts-expect-error - we build the object + const output: FontDefinitions = {}; + + for (const font of Object.values(CustomizationDefaultFont)) { + const googleFontName = googleFontsMap[font]; + const fontMetadata = APIv2[googleFontName.toLowerCase()]; + if (!fontMetadata) { + throw new Error(`Font ${googleFontName} not found`); + } + + output[font] = { + font: googleFontName, + unicodeRange: fontMetadata.unicodeRange, + variants: { + '400': {}, + '700': {}, + }, + }; + + Object.keys(output[font].variants).forEach((weight) => { + const variants = fontMetadata.variants[weight]; + const normalVariant = variants.normal; + if (!normalVariant) { + throw new Error(`Font ${googleFontName} has no normal variant`); + } + + output[font].variants[weight] = {}; + Object.entries(normalVariant).forEach(([script, url]) => { + output[font].variants[weight][script] = url.url.woff; + }); + }); + } + + await writeDataFile('fonts', JSON.stringify(output, null, 2)); +} + +/** + * We write both in dist and src as the build process might have happen already + * and tsc doesn't copy the files. + */ +async function writeDataFile(name, content) { + const srcData = path.resolve(__dirname, '../src/data'); + const distData = path.resolve(__dirname, '../dist/data'); + + // Ensure the directories exists + await Promise.all([ + fs.mkdir(srcData, { recursive: true }), + fs.mkdir(distData, { recursive: true }), + ]); + + await Promise.all([ + fs.writeFile(path.resolve(srcData, `${name}.json`), content), + fs.writeFile(path.resolve(distData, `${name}.json`), content), + ]); +} + +main().catch((error) => { + console.error(`Error generating icons list: ${error}`); + process.exit(1); +}); diff --git a/packages/fonts/package.json b/packages/fonts/package.json new file mode 100644 index 0000000000..dbfb2d7ca0 --- /dev/null +++ b/packages/fonts/package.json @@ -0,0 +1,31 @@ +{ + "name": "@gitbook/fonts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "version": "0.0.0", + "dependencies": { + "@gitbook/api": "catalog:" + }, + "devDependencies": { + "google-font-metadata": "^6.0.3", + "typescript": "^5.5.3" + }, + "scripts": { + "generate": "bun ./bin/generate.js", + "build": "tsc --project tsconfig.build.json", + "typecheck": "tsc --noEmit", + "dev": "tsc -w", + "clean": "rm -rf ./dist && rm -rf ./src/data", + "unit": "bun test" + }, + "files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"], + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/fonts/src/__snapshots__/getDefaultFont.test.ts.snap b/packages/fonts/src/__snapshots__/getDefaultFont.test.ts.snap new file mode 100644 index 0000000000..89184be22c --- /dev/null +++ b/packages/fonts/src/__snapshots__/getDefaultFont.test.ts.snap @@ -0,0 +1,57 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`getDefaultFont should return correct object for Latin text 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", +} +`; + +exports[`getDefaultFont should return correct object for Cyrillic text 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", +} +`; + +exports[`getDefaultFont should return correct object for Greek text 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff", +} +`; + +exports[`getDefaultFont should handle mixed script text 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", +} +`; + +exports[`getDefaultFont should handle different font weights: regular 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", +} +`; + +exports[`getDefaultFont should handle different font weights: bold 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff", +} +`; + +exports[`getDefaultFont should handle different fonts: inter 1`] = ` +{ + "font": "Inter", + "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", +} +`; + +exports[`getDefaultFont should handle different fonts: roboto 1`] = ` +{ + "font": "Roboto", + "url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff", +} +`; diff --git a/packages/fonts/src/fonts.ts b/packages/fonts/src/fonts.ts new file mode 100644 index 0000000000..c9544efb19 --- /dev/null +++ b/packages/fonts/src/fonts.ts @@ -0,0 +1,5 @@ +import type { FontDefinitions } from './types'; + +import rawFonts from './data/fonts.json' with { type: 'json' }; + +export const fonts: FontDefinitions = rawFonts; diff --git a/packages/fonts/src/getDefaultFont.test.ts b/packages/fonts/src/getDefaultFont.test.ts new file mode 100644 index 0000000000..58afbeac93 --- /dev/null +++ b/packages/fonts/src/getDefaultFont.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'bun:test'; +import { CustomizationDefaultFont } from '@gitbook/api'; +import { getDefaultFont } from './getDefaultFont'; + +describe('getDefaultFont', () => { + it('should return null for invalid font', () => { + const result = getDefaultFont({ + font: 'invalid-font' as CustomizationDefaultFont, + text: 'Hello', + weight: 400, + }); + expect(result).toBeNull(); + }); + + it('should return null for invalid weight', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Hello', + weight: 999 as any, + }); + expect(result).toBeNull(); + }); + + it('should return null for text not supported by any script', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: '😀', // Emoji not supported by Inter + weight: 400, + }); + expect(result).toBeNull(); + }); + + it('should return correct object for Latin text', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Hello World', + weight: 400, + }); + expect(result).not.toBeNull(); + expect(result?.font).toBe(CustomizationDefaultFont.Inter); + expect(result).toMatchSnapshot(); + }); + + it('should return correct object for Cyrillic text', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Привет мир', + weight: 400, + }); + expect(result).not.toBeNull(); + expect(result?.font).toBe(CustomizationDefaultFont.Inter); + expect(result).toMatchSnapshot(); + }); + + it('should return correct object for Greek text', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Γεια σας', + weight: 400, + }); + expect(result).not.toBeNull(); + expect(result?.font).toBe(CustomizationDefaultFont.Inter); + expect(result).toMatchSnapshot(); + }); + + it('should handle mixed script text', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Hello Привет', + weight: 400, + }); + expect(result).not.toBeNull(); + expect(result?.font).toBe(CustomizationDefaultFont.Inter); + expect(result).toMatchSnapshot(); + }); + + it('should handle different font weights', () => { + const regular = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Hello', + weight: 400, + }); + const bold = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Hello', + weight: 700, + }); + expect(regular).not.toBeNull(); + expect(bold).not.toBeNull(); + expect(regular).toMatchSnapshot('regular'); + expect(bold).toMatchSnapshot('bold'); + }); + + it('should handle empty string', () => { + const result = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: '', + weight: 400, + }); + expect(result).toBeNull(); + }); + + it('should handle different fonts', () => { + const inter = getDefaultFont({ + font: CustomizationDefaultFont.Inter, + text: 'Hello', + weight: 400, + }); + const roboto = getDefaultFont({ + font: CustomizationDefaultFont.Roboto, + text: 'Hello', + weight: 400, + }); + expect(inter).not.toBeNull(); + expect(roboto).not.toBeNull(); + expect(inter).toMatchSnapshot('inter'); + expect(roboto).toMatchSnapshot('roboto'); + }); +}); diff --git a/packages/fonts/src/getDefaultFont.ts b/packages/fonts/src/getDefaultFont.ts new file mode 100644 index 0000000000..ea18e8bba2 --- /dev/null +++ b/packages/fonts/src/getDefaultFont.ts @@ -0,0 +1,112 @@ +import type { CustomizationDefaultFont } from '@gitbook/api'; +import { fonts } from './fonts'; +import type { FontWeight } from './types'; + +/** + * Get the URL to load a font for a text. + */ +export function getDefaultFont(input: { + /** + * GitBook font to use. + */ + font: CustomizationDefaultFont; + + /** + * Text to display with the font. + */ + text: string; + + /** + * Font weight to use. + */ + weight: FontWeight; +}): { font: string; url: string } | null { + if (!input.text.trim()) { + return null; + } + + const fontDefinition = fonts[input.font]; + if (!fontDefinition) { + return null; + } + + const variant = fontDefinition.variants[`${input.weight}`]; + if (!variant) { + return null; + } + + const script = getBestUnicodeRange(input.text, fontDefinition.unicodeRange); + if (!script) { + return null; + } + + return variant[script] + ? { + font: input.font, + url: variant[script], + } + : null; +} + +/** + * Determine which named @font-face unicode-range covers + * the greatest share of the characters in `text`. + * + * @param text The text you want to inspect. + * @param ranges An object whose keys are range names and whose + * values are CSS-style comma-separated unicode-range + * declarations (e.g. "U+0370-03FF,U+1F00-1FFF"). + * @returns The key of the best-matching range, or `null` + * when nothing matches at all. + */ +function getBestUnicodeRange(text: string, ranges: Record<string, string>): string | null { + // ---------- helper: parse "U+XXXX" or "U+XXXX-YYYY" ---------- + const parseOne = (token: string): [number, number] | null => { + token = token.trim().toUpperCase(); + if (!token.startsWith('U+')) return null; + + const body = token.slice(2); // drop "U+" + const [startHex, endHex] = body.split('-'); + const start = Number.parseInt(startHex, 16); + const end = endHex ? Number.parseInt(endHex, 16) : start; + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null; + return [start, end]; + }; + + // ---------- helper: build lookup table ---------- + const parsed: Record<string, [number, number][]> = {}; + for (const [label, list] of Object.entries(ranges)) { + parsed[label] = list + .split(',') + .map(parseOne) + .filter((x): x is [number, number] => x !== null); + } + + // ---------- tally code-point hits ---------- + const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0])); + + for (let i = 0; i < text.length; ) { + const cp = text.codePointAt(i)!; + i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates) + + for (const [label, rangesArr] of Object.entries(parsed)) { + if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) { + hits[label]++; + } + } + } + + // ---------- choose the "best" ---------- + let winner: string | null = null; + let maxCount = 0; + + for (const [label, count] of Object.entries(hits)) { + if (count > maxCount) { + maxCount = count; + winner = label; + } + } + + return maxCount > 0 ? winner : null; +} diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts new file mode 100644 index 0000000000..fbb3584899 --- /dev/null +++ b/packages/fonts/src/index.ts @@ -0,0 +1,2 @@ +export * from './getDefaultFont'; +export * from './types'; diff --git a/packages/fonts/src/types.ts b/packages/fonts/src/types.ts new file mode 100644 index 0000000000..899b32c49f --- /dev/null +++ b/packages/fonts/src/types.ts @@ -0,0 +1,17 @@ +import type { CustomizationDefaultFont } from '@gitbook/api'; + +export type FontWeight = 400 | 700; + +export type FontDefinition = { + font: string; + unicodeRange: { + [script: string]: string; + }; + variants: { + [weight in string]: { + [script: string]: string; + }; + }; +}; + +export type FontDefinitions = { [fontName in CustomizationDefaultFont]: FontDefinition }; diff --git a/packages/fonts/tsconfig.build.json b/packages/fonts/tsconfig.build.json new file mode 100644 index 0000000000..e4828bc1f6 --- /dev/null +++ b/packages/fonts/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "src/**/*.test.ts"] +} diff --git a/packages/fonts/tsconfig.json b/packages/fonts/tsconfig.json new file mode 100644 index 0000000000..2b3fe87c5f --- /dev/null +++ b/packages/fonts/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["es2023"], + "module": "ESNext", + "target": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowJs": true, + "noEmit": false, + "declaration": true, + "outDir": "dist", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "types": [ + "bun-types" // add Bun global + ] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/fonts/turbo.json b/packages/fonts/turbo.json new file mode 100644 index 0000000000..9097cda33c --- /dev/null +++ b/packages/fonts/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "generate": { + "inputs": ["bin/**/*", "package.json"], + "outputs": ["src/data/*.json", "dist/data/*.json"] + } + } +} diff --git a/packages/gitbook-v2/.gitignore b/packages/gitbook-v2/.gitignore index 84254823d2..a23d5b7565 100644 --- a/packages/gitbook-v2/.gitignore +++ b/packages/gitbook-v2/.gitignore @@ -6,6 +6,7 @@ # cloudflare .open-next +.wrangler # Symbolic links public diff --git a/packages/gitbook-v2/CHANGELOG.md b/packages/gitbook-v2/CHANGELOG.md index eb4e718a5b..8a4a4a0288 100644 --- a/packages/gitbook-v2/CHANGELOG.md +++ b/packages/gitbook-v2/CHANGELOG.md @@ -1,5 +1,24 @@ # gitbook-v2 +## 0.3.0 + +### Minor Changes + +- 3119066: Add support for reusable content across spaces. +- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host. + +### Patch Changes + +- 1c8d9fe: keep data cache in OpenNext between deployment +- 778624a: Only resize images with supported extensions. +- e6ddc0f: Fix URL in sitemap +- 5e975ab: Fix code highlighting for HTTP +- e15757d: Fix crash on Cloudflare by using latest stable version of Next.js instead of canary +- 634e0b4: Improve error messages around undefined site sections. +- 97b7c79: Increase logging around caching behaviour causing page crashes. +- 3f29206: Update the regex for validating site redirect +- dd043df: Revert investigation work around URL caches. + ## 0.2.5 ### Patch Changes diff --git a/packages/gitbook-v2/next-env.d.ts b/packages/gitbook-v2/next-env.d.ts index 1b3be0840f..3cd7048ed9 100644 --- a/packages/gitbook-v2/next-env.d.ts +++ b/packages/gitbook-v2/next-env.d.ts @@ -1,5 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> +/// <reference types="next/navigation-types/compat/navigation" /> // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/gitbook-v2/open-next.config.ts b/packages/gitbook-v2/open-next.config.ts index 8f4d389da6..1872309119 100644 --- a/packages/gitbook-v2/open-next.config.ts +++ b/packages/gitbook-v2/open-next.config.ts @@ -1,18 +1,29 @@ -import { defineCloudflareConfig } from '@opennextjs/cloudflare'; -import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'; -import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache'; -import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; -import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache'; +import type { OpenNextConfig } from '@opennextjs/cloudflare'; -export default defineCloudflareConfig({ - incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }), - tagCache: doShardedTagCache({ - baseShardSize: 12, - regionalCache: true, - shardReplication: { - numberOfSoftReplicas: 2, - numberOfHardReplicas: 1, +export default { + default: { + override: { + wrapper: 'cloudflare-node', + converter: 'edge', + proxyExternalRequest: 'fetch', + queue: () => import('./openNext/queue/middleware').then((m) => m.default), + incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), + tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), }, - }), - queue: doQueue, -}); + }, + middleware: { + external: true, + override: { + wrapper: 'cloudflare-edge', + converter: 'edge', + proxyExternalRequest: 'fetch', + queue: () => import('./openNext/queue/middleware').then((m) => m.default), + incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), + tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), + }, + }, + dangerous: { + enableCacheInterception: true, + }, + edgeExternals: ['node:crypto'], +} satisfies OpenNextConfig; diff --git a/packages/gitbook-v2/openNext/customWorkers/default.js b/packages/gitbook-v2/openNext/customWorkers/default.js new file mode 100644 index 0000000000..535c218167 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/default.js @@ -0,0 +1,36 @@ +import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; + +import { DurableObject } from 'cloudflare:workers'; + +// Only needed to run locally, in prod we'll use the one from do.js +export class R2WriteBuffer extends DurableObject { + writePromise; + + async write(cacheKey, value) { + // We are already writing to this key + if (this.writePromise) { + return; + } + + this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value); + this.ctx.waitUntil( + this.writePromise.finally(() => { + this.writePromise = undefined; + }) + ); + } +} + +export default { + async fetch(request, env, ctx) { + return runWithCloudflareRequestContext(request, env, ctx, async () => { + // We can't move the handler import to the top level, otherwise the runtime will not be properly initialized + const { handler } = await import( + '../../.open-next/server-functions/default/handler.mjs' + ); + + // - `Request`s are handled by the Next server + return handler(request, env, ctx); + }); + }, +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc new file mode 100644 index 0000000000..e036db1c44 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc @@ -0,0 +1,163 @@ +{ + "main": "default.js", + "name": "gitbook-open-v2-server", + "compatibility_date": "2025-04-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], + "observability": { + "enabled": true + }, + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "env": { + "dev": { + "vars": { + "STAGE": "dev" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-dev" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["R2WriteBuffer"] + } + ] + }, + "preview": { + "vars": { + "STAGE": "preview", + // Just as a test for the preview environment to check that everything works + "NEXT_PRIVATE_DEBUG_CACHE": "true" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-preview" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-preview" + } + ] + } + }, + "staging": { + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-staging" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-staging" + } + ] + }, + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail" + } + ] + }, + "production": { + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-production" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-production" + } + ] + }, + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail" + } + ] + } + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/do.js b/packages/gitbook-v2/openNext/customWorkers/do.js new file mode 100644 index 0000000000..04f3cf3bec --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/do.js @@ -0,0 +1,38 @@ +// This worker only purposes it to host the different DO that we will need in the other workers. +import { DurableObject } from 'cloudflare:workers'; + +// `use cache` could cause multiple writes to the same key to happen concurrently, there is a limit of 1 write per key/second +// so we need to buffer writes to the R2 bucket to avoid hitting this limit. +export class R2WriteBuffer extends DurableObject { + writePromise; + + async write(cacheKey, value) { + // We are already writing to this key + if (this.writePromise) { + return; + } + + this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value); + this.ctx.waitUntil( + this.writePromise.finally(() => { + this.writePromise = undefined; + }) + ); + } +} + +export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js'; + +export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js'; + +export default { + async fetch() { + // This worker does not handle any requests, it only provides Durable Objects + return new Response('This worker is not meant to handle requests directly', { + status: 400, + headers: { + 'Content-Type': 'text/plain', + }, + }); + }, +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc new file mode 100644 index 0000000000..e239202bef --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc @@ -0,0 +1,145 @@ +{ + "main": "do.js", + "name": "gitbook-open-v2-do", + "compatibility_date": "2025-04-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], + "observability": { + "enabled": true + }, + "env": { + "preview": { + "vars": { + "STAGE": "preview", + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-preview" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache" + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"] + } + ] + }, + "staging": { + "vars": { + "STAGE": "staging", + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-staging" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache" + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"] + } + ] + }, + "production": { + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", + "STAGE": "production" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-production" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache" + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"] + } + ] + } + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/middleware.js b/packages/gitbook-v2/openNext/customWorkers/middleware.js new file mode 100644 index 0000000000..78a84a9760 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/middleware.js @@ -0,0 +1,42 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; +import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; + +import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs'; + +export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js'; + +export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js'; + +export default class extends WorkerEntrypoint { + async fetch(request) { + return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => { + // - `Request`s are handled by the Next server + const reqOrResp = await middlewareHandler(request, this.env, this.ctx); + if (reqOrResp instanceof Response) { + return reqOrResp; + } + + if (this.env.STAGE !== 'preview') { + // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity + reqOrResp.headers.set( + 'Cloudflare-Workers-Version-Overrides', + `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` + ); + return this.env.DEFAULT_WORKER?.fetch(reqOrResp, { + cf: { + cacheEverything: false, + }, + }); + } + // If we are in preview mode, we need to send the request to the preview URL + const modifiedUrl = new URL(reqOrResp.url); + modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME; + const nextRequest = new Request(modifiedUrl, reqOrResp); + return fetch(nextRequest, { + cf: { + cacheEverything: false, + }, + }); + }); + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc new file mode 100644 index 0000000000..09e48afcc0 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc @@ -0,0 +1,216 @@ +{ + "main": "middleware.js", + "name": "gitbook-open-v2", + "compatibility_date": "2025-04-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], + "assets": { + "directory": "../../.open-next/assets", + "binding": "ASSETS" + }, + "observability": { + "enabled": true + }, + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "env": { + "dev": { + "vars": { + "STAGE": "dev", + "NEXT_PRIVATE_DEBUG_CACHE": "true" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-dev" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-dev" + } + ] + }, + "preview": { + "vars": { + "STAGE": "preview", + "PREVIEW_HOSTNAME": "TO_REPLACE", + "WORKER_VERSION_ID": "TO_REPLACE" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-preview" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-preview" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-preview" + } + ] + } + }, + "staging": { + "vars": { + "STAGE": "staging", + "WORKER_VERSION_ID": "TO_REPLACE" + }, + "routes": [ + { + "pattern": "open-2c.gitbook-staging.com/*", + "zone_name": "gitbook-staging.com" + }, + { + "pattern": "static-2c.gitbook-staging.com/*", + "zone_name": "gitbook-staging.com" + } + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-staging" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-staging" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-staging" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"] + } + ] + }, + "production": { + "vars": { + // This is a bit misleading, but it means that we can have 500 concurrent revalidations + // This means that we'll have up to 100 durable objects instance running at the same time + "MAX_REVALIDATE_CONCURRENCY": "100", + // Temporary variable to find the issue once deployed + // TODO: remove this once the issue is fixed + "DEBUG_CLOUDFLARE": "true", + "WORKER_VERSION_ID": "TO_REPLACE", + "STAGE": "production" + }, + "routes": [ + { + "pattern": "open-2c.gitbook.com/*", + "zone_name": "gitbook.com" + }, + { + "pattern": "static-2c.gitbook.com/*", + "zone_name": "gitbook.com" + } + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-production" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-production" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-production" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"] + } + ] + } + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts b/packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts new file mode 100644 index 0000000000..0fdbf6cc70 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts @@ -0,0 +1,26 @@ +// In this script, we use the args from the cli to update the PREVIEW_URL vars in the wrangler config file for the middleware +import fs from 'node:fs'; +import path from 'node:path'; + +const wranglerConfigPath = path.join(__dirname, '../middlewareWrangler.jsonc'); + +const file = fs.readFileSync(wranglerConfigPath, 'utf-8'); + +const args = process.argv.slice(2); +// The versionId is in the format xxx-xxx-xxx-xxx, we need the first part to reconstruct the preview URL +const versionId = args[0]; + +// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev +const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`; + +let updatedFile = file.replace( + /"PREVIEW_HOSTNAME": "TO_REPLACE"/, + `"PREVIEW_HOSTNAME": "${previewHostname}"` +); + +updatedFile = updatedFile.replaceAll( + /"WORKER_VERSION_ID": "TO_REPLACE"/g, + `"WORKER_VERSION_ID": "${versionId}"` +); + +fs.writeFileSync(wranglerConfigPath, updatedFile); diff --git a/packages/gitbook-v2/openNext/incrementalCache.ts b/packages/gitbook-v2/openNext/incrementalCache.ts new file mode 100644 index 0000000000..8e4ca98635 --- /dev/null +++ b/packages/gitbook-v2/openNext/incrementalCache.ts @@ -0,0 +1,217 @@ +import { createHash } from 'node:crypto'; + +import { trace } from '@/lib/tracing'; +import type { + CacheEntryType, + CacheValue, + IncrementalCache, + WithLastModified, +} from '@opennextjs/aws/types/overrides.js'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; + +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; + +export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; +export const DEFAULT_PREFIX = 'incremental-cache'; + +export type KeyOptions = { + cacheType?: CacheEntryType; +}; + +/** + * + * It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace + * the cache operations. It also integrates both R2 and Cache API in a single class. + * Having our own, will allow us to customize it in the future if needed. + */ +class GitbookIncrementalCache implements IncrementalCache { + name = 'GitbookIncrementalCache'; + + protected localCache: Cache | undefined; + + async get<CacheType extends CacheEntryType = 'cache'>( + key: string, + cacheType?: CacheType + ): Promise<WithLastModified<CacheValue<CacheType>> | null> { + const cacheKey = this.getR2Key(key, cacheType); + return trace( + { + operation: 'openNextIncrementalCacheGet', + name: cacheKey, + }, + async (span) => { + span.setAttribute('cacheType', cacheType ?? 'cache'); + const r2 = getCloudflareContext().env[BINDING_NAME]; + const localCache = await this.getCacheInstance(); + if (!r2) throw new Error('No R2 bucket'); + try { + // Check local cache first if available + const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey)); + if (localCacheEntry) { + span.setAttribute('cacheHit', 'local'); + const result = (await localCacheEntry.json()) as WithLastModified< + CacheValue<CacheType> + >; + return this.returnNullOn404(result); + } + + const r2Object = await r2.get(cacheKey); + if (!r2Object) return null; + + span.setAttribute('cacheHit', 'r2'); + return this.returnNullOn404({ + value: await r2Object.json(), + lastModified: r2Object.uploaded.getTime(), + }); + } catch (e) { + console.error('Failed to get from cache', e); + return null; + } + } + ); + } + + //TODO: This is a workaround to handle 404 responses in the cache. + // It should be handled by OpenNext cache interception directly. This should be removed once OpenNext cache interception is fixed. + returnNullOn404<CacheType extends CacheEntryType = 'cache'>( + cacheEntry: WithLastModified<CacheValue<CacheType>> | null + ): WithLastModified<CacheValue<CacheType>> | null { + if (!cacheEntry?.value) return null; + if ('meta' in cacheEntry.value && cacheEntry.value.meta?.status === 404) { + return null; + } + return cacheEntry; + } + + async set<CacheType extends CacheEntryType = 'cache'>( + key: string, + value: CacheValue<CacheType>, + cacheType?: CacheType + ): Promise<void> { + const cacheKey = this.getR2Key(key, cacheType); + return trace( + { + operation: 'openNextIncrementalCacheSet', + name: cacheKey, + }, + async (span) => { + span.setAttribute('cacheType', cacheType ?? 'cache'); + const localCache = await this.getCacheInstance(); + + try { + await this.writeToR2(cacheKey, JSON.stringify(value)); + + //TODO: Check if there is any places where we don't have tags + // Ideally we should always have tags, but in case we don't, we need to decide how to handle it + // For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment + const tags = this.getTagsFromCacheEntry(value) ?? [ + `build_id/${process.env.NEXT_BUILD_ID}`, + ]; + + // We consider R2 as the source of truth, so we update the local cache + // only after a successful R2 write + await localCache.put( + this.getCacheUrlKey(cacheKey), + new Response( + JSON.stringify({ + value, + // Note: `Date.now()` returns the time of the last IO rather than the actual time. + // See https://developers.cloudflare.com/workers/reference/security-model/ + lastModified: Date.now(), + }), + { + headers: { + // Cache-Control default to 30 minutes, will be overridden by `revalidate` + // In theory we should always get the `revalidate` value + 'cache-control': `max-age=${value.revalidate ?? 60 * 30}`, + 'cache-tag': tags.join(','), + }, + } + ) + ); + } catch (e) { + console.error('Failed to set to cache', e); + } + } + ); + } + + async delete(key: string): Promise<void> { + const cacheKey = this.getR2Key(key); + return trace( + { + operation: 'openNextIncrementalCacheDelete', + name: cacheKey, + }, + async () => { + const r2 = getCloudflareContext().env[BINDING_NAME]; + const localCache = await this.getCacheInstance(); + if (!r2) throw new Error('No R2 bucket'); + + try { + await r2.delete(cacheKey); + + // Here again R2 is the source of truth, so we delete from local cache first + await localCache.delete(this.getCacheUrlKey(cacheKey)); + } catch (e) { + console.error('Failed to delete from cache', e); + } + } + ); + } + + async writeToR2(key: string, value: string): Promise<void> { + const env = getCloudflareContext().env as { + WRITE_BUFFER: DurableObjectNamespace< + Rpc.DurableObjectBranded & { + write: (key: string, value: string) => Promise<void>; + } + >; + }; + const id = env.WRITE_BUFFER.idFromName(key); + + // A stub is a client used to invoke methods on the Durable Object + const stub = env.WRITE_BUFFER.get(id); + + await stub.write(key, value); + } + + async getCacheInstance(): Promise<Cache> { + if (this.localCache) return this.localCache; + this.localCache = await caches.open('incremental-cache'); + return this.localCache; + } + + // Utility function to generate keys for R2/Cache API + getR2Key(key: string, cacheType: CacheEntryType = 'cache'): string { + const hash = createHash('sha256').update(key).digest('hex'); + return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace( + /\/+/g, + '/' + ); + } + + getCacheUrlKey(cacheKey: string): string { + return `http://cache.local/${cacheKey}`; + } + + getTagsFromCacheEntry<CacheType extends CacheEntryType>( + entry: CacheValue<CacheType> + ): string[] | undefined { + if ('tags' in entry && entry.tags) { + return entry.tags; + } + + if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) { + const rawTags = entry.meta.headers['x-next-cache-tags']; + if (typeof rawTags === 'string') { + return rawTags.split(','); + } + } + if ('value' in entry) { + return entry.tags; + } + } +} + +export default new GitbookIncrementalCache(); diff --git a/packages/gitbook-v2/openNext/queue/middleware.ts b/packages/gitbook-v2/openNext/queue/middleware.ts new file mode 100644 index 0000000000..5ab486a975 --- /dev/null +++ b/packages/gitbook-v2/openNext/queue/middleware.ts @@ -0,0 +1,14 @@ +import { trace } from '@/lib/tracing'; +import type { Queue } from '@opennextjs/aws/types/overrides.js'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; +import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; + +export default { + name: 'GitbookISRQueue', + send: async (msg) => { + return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => { + const { ctx } = getCloudflareContext(); + ctx.waitUntil(doQueue.send(msg)); + }); + }, +} satisfies Queue; diff --git a/packages/gitbook-v2/openNext/queue/server.ts b/packages/gitbook-v2/openNext/queue/server.ts new file mode 100644 index 0000000000..9a5b3b689b --- /dev/null +++ b/packages/gitbook-v2/openNext/queue/server.ts @@ -0,0 +1,9 @@ +import type { Queue } from '@opennextjs/aws/types/overrides.js'; + +export default { + name: 'GitbookISRQueue', + send: async (msg) => { + // We should never reach this point in the server. If that's the case, we should log it. + console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg); + }, +} satisfies Queue; diff --git a/packages/gitbook-v2/openNext/tagCache/middleware.ts b/packages/gitbook-v2/openNext/tagCache/middleware.ts new file mode 100644 index 0000000000..4f06d7896b --- /dev/null +++ b/packages/gitbook-v2/openNext/tagCache/middleware.ts @@ -0,0 +1,81 @@ +import { trace } from '@/lib/tracing'; +import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js'; +import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache'; +import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; + +const originalTagCache = doShardedTagCache({ + baseShardSize: 12, + regionalCache: true, + // We can probably increase this value even further + regionalCacheTtlSec: 60, + shardReplication: { + numberOfSoftReplicas: 2, + numberOfHardReplicas: 1, + regionalReplication: { + defaultRegion: 'enam', + }, + }, +}); + +export default { + name: 'GitbookTagCache', + mode: 'nextMode', + getLastRevalidated: async (tags: string[]) => { + const tagsToCheck = tags.filter(softTagFilter); + if (tagsToCheck.length === 0) { + // If we reach here, it probably means that there is an issue that we'll need to address. + console.warn( + 'getLastRevalidated - No valid tags to check for last revalidation, original tags:', + tags + ); + return 0; // If no tags to check, return 0 + } + return trace( + { + operation: 'gitbookTagCacheGetLastRevalidated', + name: tagsToCheck.join(', '), + }, + async () => { + return await originalTagCache.getLastRevalidated(tagsToCheck); + } + ); + }, + hasBeenRevalidated: async (tags: string[], lastModified?: number) => { + const tagsToCheck = tags.filter(softTagFilter); + if (tagsToCheck.length === 0) { + // If we reach here, it probably means that there is an issue that we'll need to address. + console.warn( + 'hasBeenRevalidated - No valid tags to check for revalidation, original tags:', + tags + ); + return false; // If no tags to check, return false + } + return trace( + { + operation: 'gitbookTagCacheHasBeenRevalidated', + name: tagsToCheck.join(', '), + }, + async () => { + const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified); + return result; + } + ); + }, + writeTags: async (tags: string[]) => { + return trace( + { + operation: 'gitbookTagCacheWriteTags', + name: tags.join(', '), + }, + async () => { + const tagsToWrite = tags.filter(softTagFilter); + if (tagsToWrite.length === 0) { + console.warn('writeTags - No valid tags to write'); + return; // If no tags to write, exit early + } + // Write only the filtered tags + await originalTagCache.writeTags(tagsToWrite); + } + ); + }, +} satisfies NextModeTagCache; diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 50cf96a0d7..cfa6e71b91 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -1,23 +1,24 @@ { "name": "gitbook-v2", - "version": "0.2.5", + "version": "0.3.0", "private": true, "dependencies": { - "next": "canary", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "@gitbook/api": "*", + "@gitbook/api": "catalog:", "@gitbook/cache-tags": "workspace:*", + "@opennextjs/cloudflare": "1.2.1", "@sindresorhus/fnv1a": "^3.1.0", + "assert-never": "^1.2.1", + "jwt-decode": "^4.0.0", + "next": "^15.3.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "rison": "^0.1.1", "server-only": "^0.0.1", "warn-once": "^0.1.1", - "rison": "^0.1.1", - "jwt-decode": "^4.0.0", - "assert-never": "^1.2.1" + "object-identity": "^0.1.2" }, "devDependencies": { "gitbook": "*", - "@opennextjs/cloudflare": "^1.0.0-beta.3", "@types/rison": "^0.0.9", "tailwindcss": "^3.4.0", "postcss": "^8" @@ -29,7 +30,9 @@ "build:v2": "next build", "start": "next start", "build:v2:cloudflare": "opennextjs-cloudflare build", - "dev:v2:cloudflare": "wrangler dev --port 8771", + "dev:v2:cloudflare": "wrangler dev --port 8771 --env preview", + "dev:v2:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", + "dev:v2:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc", "unit": "bun test", "typecheck": "tsc --noEmit" } diff --git a/packages/gitbook-v2/src/app/global-error.tsx b/packages/gitbook-v2/src/app/global-error.tsx new file mode 100644 index 0000000000..abf0186a40 --- /dev/null +++ b/packages/gitbook-v2/src/app/global-error.tsx @@ -0,0 +1,18 @@ +'use client'; + +import NextError from 'next/error'; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + console.error('Global error:', error); + return ( + <html lang="en"> + <body> + <NextError statusCode={undefined as any} /> + </body> + </html> + ); +} diff --git a/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms-full.txt/route.ts b/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms-full.txt/route.ts new file mode 100644 index 0000000000..7702d6101c --- /dev/null +++ b/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms-full.txt/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from 'next/server'; + +import { serveLLMsFullTxt } from '@/routes/llms-full'; +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 serveLLMsFullTxt(context); +} diff --git a/packages/gitbook-v2/src/app/utils.ts b/packages/gitbook-v2/src/app/utils.ts index da810b3b90..932902df0e 100644 --- a/packages/gitbook-v2/src/app/utils.ts +++ b/packages/gitbook-v2/src/app/utils.ts @@ -1,4 +1,5 @@ import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive'; +import { getDynamicCustomizationSettings } from '@/lib/customization'; import type { SiteAPIToken } from '@gitbook/api'; import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context'; import { jwtDecode } from 'jwt-decode'; @@ -67,6 +68,8 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) { siteURLData ); + context.customization = await getDynamicCustomizationSettings(context.customization); + return { context, visitorAuthClaims: getVisitorAuthClaims(siteURLData), diff --git a/packages/gitbook-v2/src/lib/cache.test.ts b/packages/gitbook-v2/src/lib/cache.test.ts new file mode 100644 index 0000000000..735b1fb06d --- /dev/null +++ b/packages/gitbook-v2/src/lib/cache.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { withStableRef } from './cache'; + +describe('withStableRef', () => { + it('should return primitive values as is', () => { + const toStableRef = withStableRef(); + + expect(toStableRef(42)).toBe(42); + expect(toStableRef('hello')).toBe('hello'); + expect(toStableRef(true)).toBe(true); + expect(toStableRef(null)).toBe(null); + expect(toStableRef(undefined)).toBe(undefined); + }); + + it('should return the same reference for identical objects', () => { + const toStableRef = withStableRef(); + + const obj1 = { a: 1, b: 2 }; + const obj2 = { a: 1, b: 2 }; + + const ref1 = toStableRef(obj1); + const ref2 = toStableRef(obj2); + + expect(ref1).toBe(ref2); + expect(ref1).toBe(obj1); + expect(ref1).not.toBe(obj2); + }); + + it('should return the same reference for identical arrays', () => { + const toStableRef = withStableRef(); + + const arr1 = [1, 2, 3]; + const arr2 = [1, 2, 3]; + + const ref1 = toStableRef(arr1); + const ref2 = toStableRef(arr2); + + expect(ref1).toBe(ref2); + expect(ref1).toBe(arr1); + expect(ref1).not.toBe(arr2); + }); + + it('should return the same reference for identical nested objects', () => { + const toStableRef = withStableRef(); + + const obj1 = { a: { b: 1 }, c: [2, 3] }; + const obj2 = { a: { b: 1 }, c: [2, 3] }; + + const ref1 = toStableRef(obj1); + const ref2 = toStableRef(obj2); + + expect(ref1).toBe(ref2); + expect(ref1).toBe(obj1); + expect(ref1).not.toBe(obj2); + }); + + it('should return different references for different objects', () => { + const toStableRef = withStableRef(); + + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + const ref1 = toStableRef(obj1); + const ref2 = toStableRef(obj2); + + expect(ref1).not.toBe(ref2); + }); + + it('should maintain reference stability across multiple calls', () => { + const toStableRef = withStableRef(); + + const obj = { a: 1 }; + const ref1 = toStableRef(obj); + const ref2 = toStableRef(obj); + const ref3 = toStableRef(obj); + + expect(ref1).toBe(ref2); + expect(ref2).toBe(ref3); + }); +}); diff --git a/packages/gitbook-v2/src/lib/cache.ts b/packages/gitbook-v2/src/lib/cache.ts new file mode 100644 index 0000000000..e05753907a --- /dev/null +++ b/packages/gitbook-v2/src/lib/cache.ts @@ -0,0 +1,59 @@ +import { identify } from 'object-identity'; +import * as React from 'react'; + +/** + * Equivalent to `React.cache` but with support for non-primitive arguments. + * As `React.cache` only uses `Object.is` to compare arguments, it will not work with non-primitive arguments. + */ +export function cache<Args extends any[], Return>(fn: (...args: Args) => Return) { + const cached = React.cache(fn); + + return (...args: Args) => { + const toStableRef = getWithStableRef(); + const stableArgs = args.map((value) => { + return toStableRef(value); + }) as Args; + return cached(...stableArgs); + }; +} + +/** + * To ensure memory is garbage collected between each request, we use a per-request cache to store the ref maps. + */ +const getWithStableRef = React.cache(withStableRef); + +/** + * Create a function that converts a value to a stable reference. + */ +export function withStableRef(): <T>(value: T) => T { + const reverseIndex = new WeakMap<object, string>(); + const refIndex = new Map<string, object>(); + + return <T>(value: T) => { + if (isPrimitive(value)) { + return value; + } + + const objectValue = value as object; + const index = reverseIndex.get(objectValue); + if (index !== undefined) { + return refIndex.get(index) as T; + } + + const hash = identify(objectValue); + reverseIndex.set(objectValue, hash); + + const existing = refIndex.get(hash); + if (existing !== undefined) { + return existing as T; + } + + // first time we've seen this shape + refIndex.set(hash, objectValue); + return value; + }; +} + +function isPrimitive(value: any): boolean { + return value === null || typeof value !== 'object'; +} diff --git a/packages/gitbook-v2/src/lib/context.ts b/packages/gitbook-v2/src/lib/context.ts index d25dd1946e..ba0f299574 100644 --- a/packages/gitbook-v2/src/lib/context.ts +++ b/packages/gitbook-v2/src/lib/context.ts @@ -19,6 +19,7 @@ import { getDataOrNull, throwIfDataError, } from '@v2/lib/data'; +import assertNever from 'assert-never'; import { notFound } from 'next/navigation'; import { assert } from 'ts-essentials'; import { GITBOOK_URL } from './env'; @@ -164,7 +165,7 @@ export function getBaseContext(input: { // 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}${urlObject.search}`; + return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`; }; } @@ -242,19 +243,45 @@ export async function fetchSiteContextByIds( ? 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'); - } + // Parse the current siteSpace and siteSpaces based on the site structure type. + const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => { + if (siteStructure.type === 'siteSpaces') { + const siteSpaces = siteStructure.structure; + const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace); + + if (!siteSpace) { + throw new Error( + `Site space "${ids.siteSpace}" not found in structure type="siteSpaces"` + ); + } + + return { siteSpaces, siteSpace }; + } + + if (siteStructure.type === 'sections') { + assert( + sections, + `cannot find site space "${ids.siteSpace}" because parsed sections are missing siteStructure.type="sections" siteSection="${ids.siteSection}"` + ); - const siteSpaces = - siteStructure.type === 'siteSpaces' - ? siteStructure.structure - : (sections?.current.siteSpaces ?? []); + const currentSection = sections.current; + const siteSpaces = currentSection.siteSpaces; + const siteSpace = currentSection.siteSpaces.find( + (siteSpace) => siteSpace.id === ids.siteSpace + ); + + if (!siteSpace) { + throw new Error( + `Site space "${ids.siteSpace}" not found in structure type="sections" currentSection="${currentSection.id}"` + ); + } + + return { siteSpaces, siteSpace }; + } + + // @ts-expect-error + assertNever(siteStructure, `cannot handle site structure of type ${siteStructure.type}`); + })(); const customization = (() => { if (ids.siteSpace) { @@ -380,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean { 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'); + assert(section, `couldn't find section "${siteSectionId}" in site structure`); return { list: sectionsAndGroups, current: section } satisfies SiteSections; } diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 20e495656e..8e6535331a 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -2,22 +2,14 @@ import { trace } from '@/lib/tracing'; import { type ComputedContentSource, GitBookAPI, - type GitBookAPIServiceBinding, type HttpResponse, type RenderIntegrationUI, } from '@gitbook/api'; import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; -import { - GITBOOK_API_TOKEN, - GITBOOK_API_URL, - GITBOOK_RUNTIME, - GITBOOK_USER_AGENT, -} from '@v2/lib/env'; +import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env'; import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; -import { unstable_cache } from 'next/cache'; -import { getCloudflareContext, getCloudflareRequestGlobal } from './cloudflare'; -import { DataFetcherError, wrapDataFetcherError } from './errors'; -import { withCacheKey, withoutConcurrentExecution } from './memoize'; +import { cache } from '../cache'; +import { DataFetcherError, wrapCacheDataFetcherError } from './errors'; import type { GitBookDataFetcher } from './types'; interface DataFetcherInput { @@ -28,16 +20,13 @@ interface DataFetcherInput { } /** - * Revalidation profile for the cache. - * Based on https://nextjs.org/docs/app/api-reference/functions/cacheLife#default-cache-profiles + * Options to pass to the `fetch` call to disable the Next data-cache when wrapped in `use cache`. */ -enum RevalidationProfile { - minutes = 60, - hours = 60 * 60, - days = 60 * 60 * 24, - weeks = 60 * 60 * 24 * 7, - max = 60 * 60 * 24 * 30, -} +export const noCacheFetchOptions: Partial<RequestInit> = { + next: { + revalidate: 0, + }, +}; /** * Create a data fetcher using an API token. @@ -124,6 +113,15 @@ export function createDataFetcher( }) ); }, + getRevisionPageDocument(params) { + return trace('getRevisionPageDocument', () => + getRevisionPageDocument(input, { + spaceId: params.spaceId, + revisionId: params.revisionId, + pageId: params.pageId, + }) + ); + }, getReusableContent(params) { return trace('getReusableContent', () => getReusableContent(input, { @@ -206,186 +204,54 @@ export function createDataFetcher( }; } -/* - * For the following functions, we: - * - Wrap them with `withCacheKey` to compute a cache key from the function arguments ONCE (to be performant) - * - Pass the cache key to `unstable_cache` to ensure the cache is not tied to closures - * - Call the uncached function in a `withoutConcurrentExecution` wrapper to prevent concurrent executions - * - * Important: - * - Only the function inside the `unstable_cache` is wrapped in `withoutConcurrentExecution` as Next.js needs to call - * the return of `unstable_cache` to identify the tags. - */ - -const getUserById = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async (cacheKey, input: DataFetcherInput, params: { userId: string }) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getUserByIdUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getUserByIdUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.days, - tags: [], - } - ); - - return uncached(); - } - ) -); - -const getUserByIdUseCache = async (input: DataFetcherInput, params: { userId: string }) => { +const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => { 'use cache'; - return getUserByIdUncached(input, params, true); -}; - -const getUserByIdUncached = async ( - input: DataFetcherInput, - params: { userId: string }, - withUseCache = false -) => { - return trace(`getUserById.uncached(${params.userId})`, async () => { - return wrapDataFetcherError(async () => { + return wrapCacheDataFetcherError(async () => { + return trace(`getUserById(${params.userId})`, async () => { const api = apiClient(input); - const res = await api.users.getUserById(params.userId); - if (withUseCache) { - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - } + const res = await api.users.getUserById(params.userId, { + ...noCacheFetchOptions, + }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); return res.data; }); }); -}; - -const getSpace = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; shareKey: string | undefined } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getSpaceUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getSpaceUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.days, - tags: [ - getCacheTag({ - tag: 'space', - space: params.spaceId, - }), - ], - } - ); - - return uncached(); - } - ) -); +}); -const getSpaceUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; shareKey: string | undefined } -) => { - 'use cache'; - return getSpaceUncached(input, params, true); -}; - -const getSpaceUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; shareKey: string | undefined }, - withUseCache = false -) => { - if (withUseCache) { +const getSpace = cache( + async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'space', space: params.spaceId, }) ); - } - return trace(`getSpace.uncached(${params.spaceId}, ${params.shareKey})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getSpaceById(params.spaceId, { - shareKey: params.shareKey, - }); - - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { + const api = apiClient(input); + const res = await api.spaces.getSpaceById( + params.spaceId, + { + shareKey: params.shareKey, + }, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('days'); - } - return res.data; + return res.data; + }); }); - }); -}; - -const getChangeRequest = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; changeRequestId: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getChangeRequestUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getChangeRequestUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.minutes * 5, - tags: [ - getCacheTag({ - tag: 'change-request', - space: params.spaceId, - changeRequest: params.changeRequestId, - }), - ], - } - ); - - return uncached(); - } - ) + } ); -const getChangeRequestUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; changeRequestId: string } -) => { - 'use cache'; - return getChangeRequestUncached(input, params, true); -}; - -const getChangeRequestUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; changeRequestId: string }, - withUseCache = false -) => { - if (withUseCache) { +const getChangeRequest = cache( + async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'change-request', @@ -393,461 +259,243 @@ const getChangeRequestUncached = async ( changeRequest: params.changeRequestId, }) ); - } - return trace( - `getChangeRequest.uncached(${params.spaceId}, ${params.changeRequestId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getChangeRequestById( - params.spaceId, - params.changeRequestId - ); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace( + `getChangeRequest(${params.spaceId}, ${params.changeRequestId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getChangeRequestById( + params.spaceId, + params.changeRequestId, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('minutes'); - } - return res.data; - }); - } - ); -}; - -const getRevision = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getRevisionUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getRevisionUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [], - } - ); - - return uncached(); - } - ) -); - -const getRevisionUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } -) => { - 'use cache'; - return getRevisionUncached(input, params, true); -}; - -const getRevisionUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean }, - withUseCache = false -) => { - return trace(`getRevision.uncached(${params.spaceId}, ${params.revisionId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, { - metadata: params.metadata, - }); - if (withUseCache) { - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - } - return res.data; - }); - }); -}; - -const getRevisionPages = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getRevisionPagesUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getRevisionPagesUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [], - } - ); - - return uncached(); - } - ) -); - -const getRevisionPagesUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } -) => { - 'use cache'; - return getRevisionPagesUncached(input, params, true); -}; - -const getRevisionPagesUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean }, - withUseCache = false -) => { - return trace(`getRevisionPages.uncached(${params.spaceId}, ${params.revisionId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.listPagesInRevisionById( - params.spaceId, - params.revisionId, - { - metadata: params.metadata, + return res.data; } ); - if (withUseCache) { - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - } - return res.data.pages; }); - }); -}; - -const getRevisionFile = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; fileId: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getRevisionFileUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getRevisionFileUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [], - } - ); - - return uncached(); - } - ) + } ); -const getRevisionFileUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; fileId: string } -) => { - 'use cache'; - return getRevisionFileUncached(input, params, true); -}; - -const getRevisionFileUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; fileId: string }, - withUseCache = false -) => { - return trace( - `getRevisionFile.uncached(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, - async () => { - return wrapDataFetcherError(async () => { +const getRevision = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => { const api = apiClient(input); - const res = await api.spaces.getFileInRevisionById( + const res = await api.spaces.getRevisionById( params.spaceId, params.revisionId, - params.fileId, - {} + { + metadata: params.metadata, + }, + { + ...noCacheFetchOptions, + } ); - if (withUseCache) { - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - } + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); return res.data; }); - } - ); -}; - -const getRevisionPageMarkdown = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; pageId: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getRevisionPageMarkdownUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getRevisionPageMarkdownUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [], - } - ); - - return uncached(); - } - ) + }); + } ); -const getRevisionPageMarkdownUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; pageId: string } -) => { - 'use cache'; - return getRevisionPageMarkdownUncached(input, params, true); -}; - -const getRevisionPageMarkdownUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; pageId: string }, - withUseCache = false -) => { - return trace( - `getRevisionPageMarkdown.uncached(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, - async () => { - return wrapDataFetcherError(async () => { +const getRevisionPages = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => { const api = apiClient(input); - const res = await api.spaces.getPageInRevisionById( + const res = await api.spaces.listPagesInRevisionById( params.spaceId, params.revisionId, - params.pageId, { - format: 'markdown', + metadata: params.metadata, + }, + { + ...noCacheFetchOptions, } ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data.pages; + }); + }); + } +); - if (withUseCache) { +const getRevisionFile = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; fileId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getFileInRevisionById( + params.spaceId, + params.revisionId, + params.fileId, + {}, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); + return res.data; } + ); + }); + } +); - if (!('markdown' in res.data)) { - throw new DataFetcherError('Page is not a document', 404); - } - return res.data.markdown; - }); - } - ); -}; +const getRevisionPageMarkdown = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; pageId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getPageInRevisionById( + params.spaceId, + params.revisionId, + params.pageId, + { + format: 'markdown', + }, + { + ...noCacheFetchOptions, + } + ); -const getRevisionPageByPath = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; path: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getRevisionPageByPathUseCache(input, params); - } + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getRevisionPageByPathUncached(input, params); - }, - [cacheKey, 'v2'], - { - revalidate: RevalidationProfile.max, - tags: [], + if (!('markdown' in res.data)) { + throw new DataFetcherError('Page is not a document', 404); + } + return res.data.markdown; } ); - - return uncached(); - } - ) + }); + } ); -const getRevisionPageByPathUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; path: string } -) => { - 'use cache'; - return getRevisionPageByPathUncached(input, params, true); -}; +const getRevisionPageDocument = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; pageId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getPageDocumentInRevisionById( + params.spaceId, + params.revisionId, + params.pageId, + { + evaluated: true, + }, + { + ...noCacheFetchOptions, + } + ); -const getRevisionPageByPathUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; path: string }, - withUseCache = false -) => { - return trace( - `getRevisionPageByPath.uncached(${params.spaceId}, ${params.revisionId}, ${params.path})`, - async () => { - const encodedPath = encodeURIComponent(params.path); - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getPageInRevisionByPath( - params.spaceId, - params.revisionId, - encodedPath, - {} - ); - if (withUseCache) { cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); - } - return res.data; - }); - } - ); -}; - -const getDocument = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; documentId: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getDocumentUseCache(input, params); - } - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getDocumentUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [], + return res.data; } ); - - return uncached(); - } - ) -); - -const getDocumentUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; documentId: string } -) => { - 'use cache'; - return getDocumentUncached(input, params, true); -}; - -const getDocumentUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; documentId: string }, - withUseCache = false -) => { - return trace(`getDocument.uncached(${params.spaceId}, ${params.documentId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {}); - if (withUseCache) { - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - } - return res.data; }); - }); -}; - -const getComputedDocument = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { - spaceId: string; - organizationId: string; - source: ComputedContentSource; - seed: string; - } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getComputedDocumentUseCache(input, params); - } + } +); - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( +const getRevisionPageByPath = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; path: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`, async () => { - return getComputedDocumentUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: getComputedContentSourceCacheTags( + const encodedPath = encodeURIComponent(params.path); + const api = apiClient(input); + const res = await api.spaces.getPageInRevisionByPath( + params.spaceId, + params.revisionId, + encodedPath, + {}, { - spaceId: params.spaceId, - organizationId: params.organizationId, - }, - params.source - ), + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; } ); - - return uncached(); - } - ) + }); + } ); -const getComputedDocumentUseCache = async ( - input: DataFetcherInput, - params: { - spaceId: string; - organizationId: string; - source: ComputedContentSource; - seed: string; +const getDocument = cache( + async (input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => { + const api = apiClient(input); + const res = await api.spaces.getDocumentById( + params.spaceId, + params.documentId, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); + }); } -) => { - 'use cache'; - return getComputedDocumentUncached(input, params, true); -}; +); -const getComputedDocumentUncached = async ( - input: DataFetcherInput, - params: { - spaceId: string; - organizationId: string; - source: ComputedContentSource; - seed: string; - }, - withUseCache = false -) => { - if (withUseCache) { +const getComputedDocument = cache( + async ( + input: DataFetcherInput, + params: { + spaceId: string; + organizationId: string; + source: ComputedContentSource; + seed: string; + } + ) => { + 'use cache'; cacheTag( ...getComputedContentSourceCacheTags( { @@ -857,138 +505,64 @@ const getComputedDocumentUncached = async ( params.source ) ); - } - return trace( - `getComputedDocument.uncached(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getComputedDocument(params.spaceId, { - source: params.source, - seed: params.seed, - }); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace( + `getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getComputedDocument( + params.spaceId, + { + source: params.source, + seed: params.seed, + }, + {}, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); - } - return res.data; - }); - } - ); -}; - -const getReusableContent = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; reusableContentId: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getReusableContentUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getReusableContentUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [], + return res.data; } ); - - return uncached(); - } - ) + }); + } ); -const getReusableContentUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; reusableContentId: string } -) => { - 'use cache'; - return getReusableContentUncached(input, params, true); -}; - -const getReusableContentUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; reusableContentId: string }, - withUseCache = false -) => { - return trace( - `getReusableContent.uncached(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getReusableContentInRevisionById( - params.spaceId, - params.revisionId, - params.reusableContentId - ); - if (withUseCache) { +const getReusableContent = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; reusableContentId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getReusableContentInRevisionById( + params.spaceId, + params.revisionId, + params.reusableContentId, + {}, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); - } - return res.data; - }); - } - ); -}; - -const getLatestOpenAPISpecVersionContent = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { organizationId: string; slug: string } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getLatestOpenAPISpecVersionContentUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getLatestOpenAPISpecVersionContentUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.max, - tags: [ - getCacheTag({ - tag: 'openapi', - organization: params.organizationId, - openAPISpec: params.slug, - }), - ], + return res.data; } ); - - return uncached(); - } - ) + }); + } ); -const getLatestOpenAPISpecVersionContentUseCache = async ( - input: DataFetcherInput, - params: { organizationId: string; slug: string } -) => { - 'use cache'; - return getLatestOpenAPISpecVersionContentUncached(input, params, true); -}; - -const getLatestOpenAPISpecVersionContentUncached = async ( - input: DataFetcherInput, - params: { organizationId: string; slug: string }, - withUseCache = false -) => { - if (withUseCache) { +const getLatestOpenAPISpecVersionContent = cache( + async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'openapi', @@ -996,441 +570,242 @@ const getLatestOpenAPISpecVersionContentUncached = async ( openAPISpec: params.slug, }) ); - } - return trace( - `getLatestOpenAPISpecVersionContent.uncached(${params.organizationId}, ${params.slug})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getLatestOpenApiSpecVersionContent( - params.organizationId, - params.slug - ); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace( + `getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`, + async () => { + const api = apiClient(input); + const res = await api.orgs.getLatestOpenApiSpecVersionContent( + params.organizationId, + params.slug, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); - } - return res.data; - }); - } - ); -}; - -const getPublishedContentSite = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { organizationId: string; siteId: string; siteShareKey: string | undefined } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getPublishedContentSiteUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getPublishedContentSiteUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.days, - tags: [ - getCacheTag({ - tag: 'site', - site: params.siteId, - }), - ], + return res.data; } ); - - return uncached(); - } - ) + }); + } ); -const getPublishedContentSiteUseCache = async ( - input: DataFetcherInput, - params: { organizationId: string; siteId: string; siteShareKey: string | undefined } -) => { - 'use cache'; - return getPublishedContentSiteUncached(input, params, true); -}; - -const getPublishedContentSiteUncached = async ( - input: DataFetcherInput, - params: { organizationId: string; siteId: string; siteShareKey: string | undefined }, - withUseCache = false -) => { - if (withUseCache) { +const getPublishedContentSite = cache( + async ( + input: DataFetcherInput, + params: { organizationId: string; siteId: string; siteShareKey: string | undefined } + ) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'site', site: params.siteId, }) ); - } - return trace( - `getPublishedContentSite.uncached(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getPublishedContentSite( - params.organizationId, - params.siteId, - { - shareKey: params.siteShareKey, - } - ); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace( + `getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, + async () => { + const api = apiClient(input); + const res = await api.orgs.getPublishedContentSite( + params.organizationId, + params.siteId, + { + shareKey: params.siteShareKey, + }, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('days'); - } - return res.data; - }); - } - ); -}; - -const getSiteRedirectBySource = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { - organizationId: string; - siteId: string; - siteShareKey: string | undefined; - source: string; - } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getSiteRedirectBySourceUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getSiteRedirectBySourceUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.days, - tags: [ - getCacheTag({ - tag: 'site', - site: params.siteId, - }), - ], + return res.data; } ); - - return uncached(); - } - ) -); - -const getSiteRedirectBySourceUseCache = async ( - input: DataFetcherInput, - params: { - organizationId: string; - siteId: string; - siteShareKey: string | undefined; - source: string; + }); } -) => { - 'use cache'; - return getSiteRedirectBySourceUncached(input, params, true); -}; +); -const getSiteRedirectBySourceUncached = async ( - input: DataFetcherInput, - params: { - organizationId: string; - siteId: string; - siteShareKey: string | undefined; - source: string; - }, - withUseCache = false -) => { - if (withUseCache) { +const getSiteRedirectBySource = cache( + async ( + input: DataFetcherInput, + params: { + organizationId: string; + siteId: string; + siteShareKey: string | undefined; + source: string; + } + ) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'site', site: params.siteId, }) ); - } - return trace( - `getSiteRedirectBySource.uncached(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getSiteRedirectBySource( - params.organizationId, - params.siteId, - { - shareKey: params.siteShareKey, - source: params.source, - } - ); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace( + `getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, + async () => { + const api = apiClient(input); + const res = await api.orgs.getSiteRedirectBySource( + params.organizationId, + params.siteId, + { + shareKey: params.siteShareKey, + source: params.source, + }, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('days'); - } - return res.data; - }); - } - ); -}; - -const getEmbedByUrl = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async (cacheKey, input: DataFetcherInput, params: { spaceId: string; url: string }) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return getEmbedByUrlUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return getEmbedByUrlUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.weeks, - tags: [], + return res.data; } ); - - return uncached(); - } - ) + }); + } ); -const getEmbedByUrlUseCache = async ( - input: DataFetcherInput, - params: { spaceId: string; url: string } -) => { - 'use cache'; - return getEmbedByUrlUncached(input, params, true); -}; - -const getEmbedByUrlUncached = async ( - input: DataFetcherInput, - params: { spaceId: string; url: string }, - withUseCache = false -) => { - if (withUseCache) { +const getEmbedByUrl = cache( + async (input: DataFetcherInput, params: { spaceId: string; url: string }) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'space', space: params.spaceId, }) ); - } - return trace(`getEmbedByUrl.uncached(${params.spaceId}, ${params.url})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { - url: params.url, - }); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { + const api = apiClient(input); + const res = await api.spaces.getEmbedByUrlInSpace( + params.spaceId, + { + url: params.url, + }, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('weeks'); - } - return res.data; + return res.data; + }); }); - }); -}; - -const searchSiteContent = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: Parameters<GitBookDataFetcher['searchSiteContent']>[0] - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return searchSiteContentUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return searchSiteContentUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.hours, - tags: [], - } - ); - - return uncached(); - } - ) + } ); -const searchSiteContentUseCache = async ( - input: DataFetcherInput, - params: Parameters<GitBookDataFetcher['searchSiteContent']>[0] -) => { - 'use cache'; - return searchSiteContentUncached(input, params, true); -}; - -const searchSiteContentUncached = async ( - input: DataFetcherInput, - params: Parameters<GitBookDataFetcher['searchSiteContent']>[0], - withUseCache = false -) => { - if (withUseCache) { +const searchSiteContent = cache( + async ( + input: DataFetcherInput, + params: Parameters<GitBookDataFetcher['searchSiteContent']>[0] + ) => { + 'use cache'; cacheTag( getCacheTag({ tag: 'site', site: params.siteId, }) ); - } - return trace( - `searchSiteContent.uncached(${params.organizationId}, ${params.siteId}, ${params.query})`, - async () => { - return wrapDataFetcherError(async () => { - const { organizationId, siteId, query, scope } = params; - const api = apiClient(input); - const res = await api.orgs.searchSiteContent(organizationId, siteId, { - query, - ...scope, - }); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace( + `searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`, + async () => { + const { organizationId, siteId, query, scope } = params; + const api = apiClient(input); + const res = await api.orgs.searchSiteContent( + organizationId, + siteId, + { + query, + ...scope, + }, + {}, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('hours'); - } - return res.data.items; - }); - } - ); -}; - -const renderIntegrationUi = withCacheKey( - withoutConcurrentExecution( - getCloudflareRequestGlobal, - async ( - cacheKey, - input: DataFetcherInput, - params: { integrationName: string; request: RenderIntegrationUI } - ) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return renderIntegrationUiUseCache(input, params); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache( - async () => { - return renderIntegrationUiUncached(input, params); - }, - [cacheKey], - { - revalidate: RevalidationProfile.days, - tags: [ - getCacheTag({ - tag: 'integration', - integration: params.integrationName, - }), - ], + return res.data.items; } ); - - return uncached(); - } - ) + }); + } ); -const renderIntegrationUiUseCache = async ( - input: DataFetcherInput, - params: { integrationName: string; request: RenderIntegrationUI } -) => { - 'use cache'; - return renderIntegrationUiUncached(input, params, true); -}; +const renderIntegrationUi = cache( + async ( + input: DataFetcherInput, + params: { integrationName: string; request: RenderIntegrationUI } + ) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'integration', + integration: params.integrationName, + }) + ); -const renderIntegrationUiUncached = ( - input: DataFetcherInput, - params: { integrationName: string; request: RenderIntegrationUI }, - withUseCache = false -) => { - return trace(`renderIntegrationUi.uncached(${params.integrationName})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.integrations.renderIntegrationUiWithPost( - params.integrationName, - params.request - ); - if (withUseCache) { + return wrapCacheDataFetcherError(async () => { + return trace(`renderIntegrationUi(${params.integrationName})`, async () => { + const api = apiClient(input); + const res = await api.integrations.renderIntegrationUiWithPost( + params.integrationName, + params.request, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('days'); - } - return res.data; + return res.data; + }); }); - }); -}; + } +); async function* streamAIResponse( input: DataFetcherInput, params: Parameters<GitBookDataFetcher['streamAIResponse']>[0] ) { const api = apiClient(input); - const res = await api.orgs.streamAiResponseInSite(params.organizationId, params.siteId, { - input: params.input, - output: params.output, - model: params.model, - }); + const res = await api.orgs.streamAiResponseInSite( + params.organizationId, + params.siteId, + { + input: params.input, + output: params.output, + model: params.model, + }, + { + ...noCacheFetchOptions, + } + ); for await (const event of res) { yield event; } } -let loggedServiceBinding = false; - /** * Create a new API client. */ export function apiClient(input: DataFetcherInput = { apiToken: null }) { const { apiToken } = input; - let serviceBinding: GitBookAPIServiceBinding | undefined; - - const cloudflareContext = getCloudflareContext(); - if (cloudflareContext) { - // @ts-expect-error - serviceBinding = cloudflareContext.env.GITBOOK_API as GitBookAPIServiceBinding | undefined; - if (!loggedServiceBinding) { - loggedServiceBinding = true; - if (serviceBinding) { - // biome-ignore lint/suspicious/noConsole: we want to log here - console.log(`using service binding for the API (${GITBOOK_API_URL})`); - } else { - // biome-ignore lint/suspicious/noConsole: we want to log here - console.warn(`no service binding for the API (${GITBOOK_API_URL})`); - } - } - } const api = new GitBookAPI({ authToken: apiToken || GITBOOK_API_TOKEN || undefined, endpoint: GITBOOK_API_URL, userAgent: GITBOOK_USER_AGENT, - serviceBinding, }); return api; diff --git a/packages/gitbook-v2/src/lib/data/cloudflare.ts b/packages/gitbook-v2/src/lib/data/cloudflare.ts index ac3125603d..ca996690b6 100644 --- a/packages/gitbook-v2/src/lib/data/cloudflare.ts +++ b/packages/gitbook-v2/src/lib/data/cloudflare.ts @@ -11,15 +11,3 @@ export function getCloudflareContext() { return getCloudflareContextOpenNext(); } - -/** - * Return an object representing the current request. - */ -export function getCloudflareRequestGlobal() { - const context = getCloudflareContext(); - if (!context) { - return null; - } - - return context.cf; -} diff --git a/packages/gitbook-v2/src/lib/data/errors.ts b/packages/gitbook-v2/src/lib/data/errors.ts index 784eebdbb5..c4d983ec2a 100644 --- a/packages/gitbook-v2/src/lib/data/errors.ts +++ b/packages/gitbook-v2/src/lib/data/errors.ts @@ -1,4 +1,5 @@ import { GitBookAPIError } from '@gitbook/api'; +import { unstable_cacheLife as cacheLife } from 'next/cache'; import type { DataFetcherErrorData, DataFetcherResponse } from './types'; export class DataFetcherError extends Error { @@ -47,11 +48,7 @@ export function getDataOrNull<T>( return response.then((result) => getDataOrNull(result, ignoreErrors)); } - if (response.error) { - if (ignoreErrors.includes(response.error.code)) return null; - throw new DataFetcherError(response.error.message, response.error.code); - } - return response.data; + return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null; } /** @@ -93,6 +90,50 @@ export async function wrapDataFetcherError<T>( } } +/** + * Wrap an async execution to handle errors and return a DataFetcherResponse. + * This should be used inside 'use cache' functions. + */ +export async function wrapCacheDataFetcherError<T>( + fn: () => Promise<T> +): Promise<DataFetcherResponse<T>> { + const result = await wrapDataFetcherError(fn); + if (result.error && result.error.code >= 500) { + // We don't want to cache errors for too long. + // as the API might + cacheLife('minutes'); + } + return result; +} + +/** + * Ignore some data fetcher errors. + */ +export function ignoreDataFetcherErrors<T>( + response: DataFetcherResponse<T>, + ignoreErrors?: number[] +): DataFetcherResponse<T>; +export function ignoreDataFetcherErrors<T>( + response: Promise<DataFetcherResponse<T>>, + ignoreErrors?: number[] +): Promise<DataFetcherResponse<T>>; +export function ignoreDataFetcherErrors<T>( + response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>, + ignoreErrors: number[] = [404] +): DataFetcherResponse<T> | Promise<DataFetcherResponse<T>> { + if (response instanceof Promise) { + return response.then((result) => ignoreDataFetcherErrors(result, ignoreErrors)); + } + + if (response.error) { + if (ignoreErrors.includes(response.error.code)) { + return response; + } + throw new DataFetcherError(response.error.message, response.error.code); + } + return response; +} + /** * Get a data fetcher exposable error from a JS error. */ diff --git a/packages/gitbook-v2/src/lib/data/index.ts b/packages/gitbook-v2/src/lib/data/index.ts index d79049684a..93266d124e 100644 --- a/packages/gitbook-v2/src/lib/data/index.ts +++ b/packages/gitbook-v2/src/lib/data/index.ts @@ -1,8 +1,7 @@ export * from './api'; export * from './types'; -export * from './pages'; export * from './urls'; export * from './errors'; export * from './lookup'; -export * from './proxy'; export * from './visitor'; +export * from './pages'; diff --git a/packages/gitbook-v2/src/lib/data/lookup.ts b/packages/gitbook-v2/src/lib/data/lookup.ts index 498f6e8859..be8c21f1d6 100644 --- a/packages/gitbook-v2/src/lib/data/lookup.ts +++ b/packages/gitbook-v2/src/lib/data/lookup.ts @@ -1,52 +1,46 @@ import { race, tryCatch } from '@/lib/async'; import { joinPath, joinPathWithBaseURL } from '@/lib/paths'; import { trace } from '@/lib/tracing'; -import type { PublishedSiteContentLookup } from '@gitbook/api'; +import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api'; import { apiClient } from './api'; import { getExposableError } from './errors'; import type { DataFetcherResponse } from './types'; import { getURLLookupAlternatives, stripURLSearch } from './urls'; -/** - * Lookup a content by its URL using the GitBook API. - * To optimize caching, we try multiple lookup alternatives and return the first one that matches. - */ -export async function getPublishedContentByURL(input: { +interface LookupPublishedContentByUrlInput { url: string; - visitorAuthToken: string | null; redirectOnError: boolean; apiToken: string | null; -}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> { + visitorPayload: SiteVisitorPayload; +} + +/** + * Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint. + * To optimize caching, we try multiple lookup alternatives and return the first one that matches. + */ +export async function lookupPublishedContentByUrl( + input: LookupPublishedContentByUrlInput +): Promise<DataFetcherResponse<PublishedSiteContentLookup>> { const lookupURL = new URL(input.url); const url = stripURLSearch(lookupURL); const lookup = getURLLookupAlternatives(url); const result = await race(lookup.urls, async (alternative, { signal }) => { - const api = await apiClient({ apiToken: input.apiToken }); - + const api = apiClient({ apiToken: input.apiToken }); const callResult = await trace( { - operation: 'getPublishedContentByURL', + operation: 'resolvePublishedContentByUrl', name: alternative.url, }, () => tryCatch( - api.urls.getPublishedContentByUrl( + api.urls.resolvePublishedContentByUrl( { url: alternative.url, - visitorAuthToken: input.visitorAuthToken ?? undefined, + ...(input.visitorPayload ? { visitor: input.visitorPayload } : {}), redirectOnError: input.redirectOnError, - - // As this endpoint is cached by our API, we version the request - // to void getting stale data with missing properties. - // this could be improved by ensuring our API cache layer is versioned - // or invalidated when needed - // @ts-expect-error - cacheVersion is not a real query param - cacheVersion: 'v2', }, - { - signal, - } + { signal } ) ) ); diff --git a/packages/gitbook-v2/src/lib/data/memoize.test.ts b/packages/gitbook-v2/src/lib/data/memoize.test.ts deleted file mode 100644 index 24c1014087..0000000000 --- a/packages/gitbook-v2/src/lib/data/memoize.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test'; -import { AsyncLocalStorage } from 'node:async_hooks'; -import { withCacheKey, withoutConcurrentExecution } from './memoize'; - -describe('withoutConcurrentExecution', () => { - it('should memoize the function based on the cache key', async () => { - const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b); - const memoized = withoutConcurrentExecution(() => null, fn); - - const p1 = memoized('c1', 1, 2); - const p2 = memoized('c1', 1, 2); - const p3 = memoized('c3', 2, 3); - - expect(await p1).toBe(await p2); - expect(await p1).not.toBe(await p3); - expect(fn.mock.calls.length).toBe(2); - }); - - it('should support caching per request', async () => { - const fn = mock(async () => Math.random()); - - const request1 = { id: 'request1' }; - const request2 = { id: 'request2' }; - - const requestContext = new AsyncLocalStorage<{ id: string }>(); - - const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn); - - // Both in the same request - const promise1 = requestContext.run(request1, () => memoized('c1')); - const promise2 = requestContext.run(request1, () => memoized('c1')); - - // In a different request - const promise3 = requestContext.run(request2, () => memoized('c1')); - - expect(await promise1).toBe(await promise2); - expect(await promise1).not.toBe(await promise3); - expect(fn.mock.calls.length).toBe(2); - }); -}); - -describe('withCacheKey', () => { - it('should wrap the function by passing the cache key', async () => { - const fn = mock( - async (cacheKey: string, arg: { a: number; b: number }, c: number) => - `${cacheKey}, result=${arg.a + arg.b + c}` - ); - const memoized = withCacheKey(fn); - expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7'); - expect(fn.mock.calls.length).toBe(1); - }); -}); diff --git a/packages/gitbook-v2/src/lib/data/memoize.ts b/packages/gitbook-v2/src/lib/data/memoize.ts deleted file mode 100644 index ff1ff5e841..0000000000 --- a/packages/gitbook-v2/src/lib/data/memoize.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Wrap a function by preventing concurrent executions of the same function. - * With a logic to work per-request in Cloudflare Workers. - */ -export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>( - getGlobalContext: () => object | null | undefined, - wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType> -): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> { - const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>(); - - return (key: string, ...args: ArgsType) => { - const globalContext = getGlobalContext() ?? globalThis; - - /** - * Cache storage that is scoped to the current request when executed in Cloudflare Workers, - * to avoid "Cannot perform I/O on behalf of a different request" errors. - */ - const promiseCache = - globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>(); - globalPromiseCache.set(globalContext, promiseCache); - - const concurrent = promiseCache.get(key); - if (concurrent) { - return concurrent; - } - - const promise = (async () => { - try { - const result = await wrapped(key, ...args); - return result; - } finally { - promiseCache.delete(key); - } - })(); - - promiseCache.set(key, promise); - - return promise; - }; -} - -/** - * Wrap a function by passing it a cache key that is computed from the function arguments. - */ -export function withCacheKey<ArgsType extends any[], ReturnType>( - wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> -): (...args: ArgsType) => Promise<ReturnType> { - return (...args: ArgsType) => { - const cacheKey = getCacheKey(args); - return wrapped(cacheKey, ...args); - }; -} - -/** - * Compute a cache key from the function arguments. - */ -function getCacheKey(args: any[]) { - return JSON.stringify(deepSortValue(args)); -} - -function deepSortValue(value: unknown): unknown { - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - value === null || - value === undefined - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map(deepSortValue); - } - - if (value && typeof value === 'object') { - return Object.entries(value) - .map(([key, subValue]) => { - return [key, deepSortValue(subValue)] as const; - }) - .sort((a, b) => { - return a[0].localeCompare(b[0]); - }); - } - - return value; -} diff --git a/packages/gitbook-v2/src/lib/data/pages.ts b/packages/gitbook-v2/src/lib/data/pages.ts index 4324254d9b..319c0a9170 100644 --- a/packages/gitbook-v2/src/lib/data/pages.ts +++ b/packages/gitbook-v2/src/lib/data/pages.ts @@ -1,15 +1,27 @@ -import type { JSONDocument, RevisionPageDocument, Space } from '@gitbook/api'; +import { isV2 } from '@/lib/v2'; +import type { JSONDocument, RevisionPageDocument } from '@gitbook/api'; +import type { GitBookSiteContext, GitBookSpaceContext } from '../context'; import { getDataOrNull } from './errors'; -import type { GitBookDataFetcher } from './types'; /** * Get the document for a page. */ export async function getPageDocument( - dataFetcher: GitBookDataFetcher, - space: Space, + context: GitBookSpaceContext | GitBookSiteContext, page: RevisionPageDocument ): Promise<JSONDocument | null> { + const { dataFetcher, space } = context; + + if (isV2()) { + return getDataOrNull( + dataFetcher.getRevisionPageDocument({ + spaceId: space.id, + revisionId: space.revision, + pageId: page.id, + }) + ); + } + if (page.documentId) { return getDataOrNull( dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId }) diff --git a/packages/gitbook-v2/src/lib/data/types.ts b/packages/gitbook-v2/src/lib/data/types.ts index 178a0ba77d..8d987047e2 100644 --- a/packages/gitbook-v2/src/lib/data/types.ts +++ b/packages/gitbook-v2/src/lib/data/types.ts @@ -106,6 +106,15 @@ export interface GitBookDataFetcher { pageId: string; }): Promise<DataFetcherResponse<string>>; + /** + * Get the document of a page by its path. + */ + getRevisionPageDocument(params: { + spaceId: string; + revisionId: string; + pageId: string; + }): Promise<DataFetcherResponse<api.JSONDocument>>; + /** * Get a document by its space ID and document ID. */ diff --git a/packages/gitbook-v2/src/lib/data/visitor.ts b/packages/gitbook-v2/src/lib/data/visitor.ts index f93f0afe87..e59e32365c 100644 --- a/packages/gitbook-v2/src/lib/data/visitor.ts +++ b/packages/gitbook-v2/src/lib/data/visitor.ts @@ -1,6 +1,6 @@ import { withLeadingSlash, withTrailingSlash } from '@/lib/paths'; import type { PublishedSiteContent } from '@gitbook/api'; -import { getProxyRequestIdentifier, isProxyRequest } from './proxy'; +import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy'; /** * Get the appropriate base path for the visitor authentication cookie. diff --git a/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.test.ts b/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.test.ts new file mode 100644 index 0000000000..679f2c5ce7 --- /dev/null +++ b/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'bun:test'; +import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL'; + +describe('checkIsSizableImageURL', () => { + it('should return Skip for non-parsable URLs', () => { + expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip); + }); + + it('should return Skip for non-http(s) URLs', () => { + expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip); + expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip); + }); + + it('should return Skip for localhost URLs', () => { + expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe( + SizableImageAction.Skip + ); + expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip); + }); + + it('should return Skip for GitBook image URLs', () => { + expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe( + SizableImageAction.Skip + ); + }); + + it('should return Resize for supported image extensions', () => { + expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe( + SizableImageAction.Resize + ); + expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe( + SizableImageAction.Resize + ); + expect(checkIsSizableImageURL('https://example.com/image.png')).toBe( + SizableImageAction.Resize + ); + expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe( + SizableImageAction.Resize + ); + expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe( + SizableImageAction.Resize + ); + }); + + it('should return Resize for URLs without extensions', () => { + expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize); + }); + + it('should return Passthrough for unsupported image extensions', () => { + expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe( + SizableImageAction.Passthrough + ); + expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe( + SizableImageAction.Passthrough + ); + expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe( + SizableImageAction.Passthrough + ); + expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe( + SizableImageAction.Passthrough + ); + }); + + it('should handle URLs with query parameters correctly', () => { + expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe( + SizableImageAction.Resize + ); + expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe( + SizableImageAction.Passthrough + ); + }); + + it('should be case-insensitive for extensions', () => { + expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe( + SizableImageAction.Resize + ); + expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe( + SizableImageAction.Resize + ); + }); +}); diff --git a/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.ts b/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.ts index 45754af505..486ea7a69a 100644 --- a/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.ts +++ b/packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.ts @@ -1,4 +1,15 @@ -import { checkIsHttpURL } from '@/lib/urls'; +import { getExtension } from '@/lib/paths'; + +export enum SizableImageAction { + Resize = 'resize', + Skip = 'skip', + Passthrough = 'passthrough', +} + +/** + * https://developers.cloudflare.com/images/transform-images/#supported-input-formats + */ +const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; /** * Check if an image URL is resizable. @@ -6,22 +17,27 @@ import { checkIsHttpURL } from '@/lib/urls'; * Skip it for SVGs. * Skip it for GitBook images (to avoid recursion). */ -export function checkIsSizableImageURL(input: string): boolean { +export function checkIsSizableImageURL(input: string): SizableImageAction { if (!URL.canParse(input)) { - return false; - } - - if (input.includes('/~gitbook/image')) { - return false; + return SizableImageAction.Skip; } const parsed = new URL(input); - if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) { - return false; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return SizableImageAction.Skip; + } + if (parsed.hostname === 'localhost') { + return SizableImageAction.Skip; } - if (!checkIsHttpURL(parsed)) { - return false; + if (parsed.pathname.includes('/~gitbook/image')) { + return SizableImageAction.Skip; + } + + const extension = getExtension(parsed.pathname).toLowerCase(); + if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) { + // If no extension, we consider it resizable. + return SizableImageAction.Resize; } - return true; + return SizableImageAction.Passthrough; } diff --git a/packages/gitbook-v2/src/lib/images/createImageResizer.ts b/packages/gitbook-v2/src/lib/images/createImageResizer.ts index 703fdadd62..8507a7aefd 100644 --- a/packages/gitbook-v2/src/lib/images/createImageResizer.ts +++ b/packages/gitbook-v2/src/lib/images/createImageResizer.ts @@ -1,7 +1,7 @@ import 'server-only'; import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env'; import type { GitBookLinker } from '../links'; -import { checkIsSizableImageURL } from './checkIsSizableImageURL'; +import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL'; import { getImageSize } from './resizer'; import { type SignatureVersion, generateImageSignature } from './signatures'; import type { ImageResizer } from './types'; @@ -24,7 +24,7 @@ export function createImageResizer({ return { getResizedImageURL: (urlInput) => { - if (!checkIsSizableImageURL(urlInput)) { + if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) { return null; } @@ -64,7 +64,7 @@ export function createImageResizer({ }, getImageSize: async (input, options) => { - if (!checkIsSizableImageURL(input)) { + if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) { return null; } diff --git a/packages/gitbook-v2/src/lib/images/getImageResizingContextId.test.ts b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.test.ts new file mode 100644 index 0000000000..eaba01663b --- /dev/null +++ b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'bun:test'; +import { getImageResizingContextId } from './getImageResizingContextId'; + +describe('getImageResizingContextId', () => { + it('should return proxy identifier for proxy requests', () => { + const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world'); + expect(getImageResizingContextId(proxyRequestURL)).toBe('sites/site_foo'); + }); + + it('should return preview identifier for preview requests', () => { + const previewRequestURL = new URL('https://preview/site_foo/hello/world'); + expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo'); + }); + + it('should return host for regular requests', () => { + const regularRequestURL = new URL('https://example.com/docs/foo/hello/world'); + expect(getImageResizingContextId(regularRequestURL)).toBe('example.com'); + }); +}); diff --git a/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts index 82f9225262..40594e2ae2 100644 --- a/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts +++ b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts @@ -1,4 +1,5 @@ -import { getProxyRequestIdentifier, isProxyRequest } from '../data'; +import { getPreviewRequestIdentifier, isPreviewRequest } from '@v2/lib/preview'; +import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy'; /** * Get the site identifier to use for image resizing for an incoming request. @@ -8,6 +9,9 @@ export function getImageResizingContextId(url: URL): string { if (isProxyRequest(url)) { return getProxyRequestIdentifier(url); } + if (isPreviewRequest(url)) { + return getPreviewRequestIdentifier(url); + } return url.host; } diff --git a/packages/gitbook-v2/src/lib/images/resizer/resizeImage.ts b/packages/gitbook-v2/src/lib/images/resizer/resizeImage.ts index 4ed81dcd2b..8e656f3b70 100644 --- a/packages/gitbook-v2/src/lib/images/resizer/resizeImage.ts +++ b/packages/gitbook-v2/src/lib/images/resizer/resizeImage.ts @@ -1,7 +1,7 @@ import 'server-only'; import assertNever from 'assert-never'; import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env'; -import { checkIsSizableImageURL } from '../checkIsSizableImageURL'; +import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL'; import { resizeImageWithCDNCgi } from './cdn-cgi'; import { resizeImageWithCFFetch } from './cf-fetch'; import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types'; @@ -13,7 +13,7 @@ export async function getImageSize( input: string, defaultSize: Partial<CloudflareImageOptions> = {} ): Promise<{ width: number; height: number } | null> { - if (!checkIsSizableImageURL(input)) { + if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) { return null; } @@ -48,13 +48,17 @@ export async function resizeImage( signal?: AbortSignal; } ): Promise<Response> { - const parsed = new URL(input); - if (parsed.protocol === 'data:') { - throw new Error('Cannot resize data: URLs'); + const action = checkIsSizableImageURL(input); + if (action === SizableImageAction.Skip) { + throw new Error( + 'Cannot resize this image, this function should have never been called on this url' + ); } - if (parsed.hostname === 'localhost') { - throw new Error('Cannot resize localhost URLs'); + if (action === SizableImageAction.Passthrough) { + return fetch(input, { + signal: options.signal, + }); } switch (GITBOOK_IMAGE_RESIZE_MODE) { diff --git a/packages/gitbook-v2/src/lib/images/resizer/types.ts b/packages/gitbook-v2/src/lib/images/resizer/types.ts index 8bbe697f90..9cb370586f 100644 --- a/packages/gitbook-v2/src/lib/images/resizer/types.ts +++ b/packages/gitbook-v2/src/lib/images/resizer/types.ts @@ -13,7 +13,7 @@ export interface CloudflareImageJsonFormat { * https://developers.cloudflare.com/images/image-resizing/resize-with-workers/ */ export interface CloudflareImageOptions { - format?: 'webp' | 'avif' | 'json' | 'jpeg'; + format?: 'webp' | 'avif' | 'json' | 'jpeg' | 'png'; fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad'; width?: number; height?: number; diff --git a/packages/gitbook-v2/src/lib/links.test.ts b/packages/gitbook-v2/src/lib/links.test.ts index 26a2464f50..f73ec5ee86 100644 --- a/packages/gitbook-v2/src/lib/links.test.ts +++ b/packages/gitbook-v2/src/lib/links.test.ts @@ -19,7 +19,7 @@ const siteGitBookIO = createLinker({ siteBasePath: '/sitename/', }); -describe('toPathInContent', () => { +describe('toPathInSpace', () => { it('should return the correct path', () => { expect(root.toPathInSpace('some/path')).toBe('/some/path'); expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path'); @@ -29,6 +29,16 @@ describe('toPathInContent', () => { expect(root.toPathInSpace('/some/path')).toBe('/some/path'); expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path'); }); + + it('should remove the trailing slash', () => { + expect(root.toPathInSpace('some/path/')).toBe('/some/path'); + expect(variantInSection.toPathInSpace('some/path/')).toBe('/section/variant/some/path'); + }); + + it('should not add a trailing slash', () => { + expect(root.toPathInSpace('')).toBe(''); + expect(variantInSection.toPathInSpace('')).toBe('/section/variant'); + }); }); describe('toPathInSite', () => { @@ -36,6 +46,16 @@ describe('toPathInSite', () => { expect(root.toPathInSite('some/path')).toBe('/some/path'); expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path'); }); + + it('should remove the trailing slash', () => { + expect(root.toPathInSite('some/path/')).toBe('/some/path'); + expect(siteGitBookIO.toPathInSite('some/path/')).toBe('/sitename/some/path'); + }); + + it('should not add a trailing slash', () => { + expect(root.toPathInSite('')).toBe(''); + expect(siteGitBookIO.toPathInSite('')).toBe('/sitename'); + }); }); describe('toRelativePathInSite', () => { diff --git a/packages/gitbook-v2/src/lib/links.ts b/packages/gitbook-v2/src/lib/links.ts index 857345703c..a64bda541c 100644 --- a/packages/gitbook-v2/src/lib/links.ts +++ b/packages/gitbook-v2/src/lib/links.ts @@ -93,6 +93,11 @@ export function createLinker( }, toAbsoluteURL(absolutePath: string): string { + // If the path is already a full URL, we return it as is. + if (URL.canParse(absolutePath)) { + return absolutePath; + } + if (!servedOn.host) { return absolutePath; } @@ -123,5 +128,9 @@ export function createLinker( function joinPaths(prefix: string, path: string): string { const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`; const suffixPath = path.startsWith('/') ? path.slice(1) : path; - return prefixPath + suffixPath; + return removeTrailingSlash(prefixPath + suffixPath); +} + +function removeTrailingSlash(path: string): string { + return path.endsWith('/') ? path.slice(0, -1) : path; } diff --git a/packages/gitbook-v2/src/lib/preview.test.ts b/packages/gitbook-v2/src/lib/preview.test.ts new file mode 100644 index 0000000000..bbaf0402bd --- /dev/null +++ b/packages/gitbook-v2/src/lib/preview.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; +import { getPreviewRequestIdentifier, isPreviewRequest } from './preview'; + +describe('isPreviewRequest', () => { + it('should return true for preview requests', () => { + const previewRequestURL = new URL('https://preview/site_foo/hello/world'); + expect(isPreviewRequest(previewRequestURL)).toBe(true); + }); + + it('should return false for non-preview requests', () => { + const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world'); + expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false); + }); +}); + +describe('getPreviewRequestIdentifier', () => { + it('should return the correct identifier for preview requests', () => { + const previewRequestURL = new URL('https://preview/site_foo/hello/world'); + expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo'); + }); +}); diff --git a/packages/gitbook-v2/src/lib/preview.ts b/packages/gitbook-v2/src/lib/preview.ts new file mode 100644 index 0000000000..7094d11970 --- /dev/null +++ b/packages/gitbook-v2/src/lib/preview.ts @@ -0,0 +1,13 @@ +/** + * Check if the request to the site is a preview request. + */ +export function isPreviewRequest(requestURL: URL): boolean { + return requestURL.host === 'preview'; +} + +export function getPreviewRequestIdentifier(requestURL: URL): string { + // For preview requests, we extract the site ID from the pathname + // e.g. https://preview/site_id/... + const pathname = requestURL.pathname.slice(1).split('/'); + return pathname[0]; +} diff --git a/packages/gitbook-v2/src/lib/data/proxy.test.ts b/packages/gitbook-v2/src/lib/proxy.test.ts similarity index 100% rename from packages/gitbook-v2/src/lib/data/proxy.test.ts rename to packages/gitbook-v2/src/lib/proxy.test.ts diff --git a/packages/gitbook-v2/src/lib/data/proxy.ts b/packages/gitbook-v2/src/lib/proxy.ts similarity index 100% rename from packages/gitbook-v2/src/lib/data/proxy.ts rename to packages/gitbook-v2/src/lib/proxy.ts diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index dd983b536a..4bcbf77aea 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -10,14 +10,14 @@ import { type ResponseCookies, getPathScopedCookieName, getResponseCookiesForVisitorAuth, - getVisitorToken, + getVisitorData, normalizeVisitorAuthURL, -} from '@/lib/visitor-token'; +} from '@/lib/visitors'; import { serveResizedImage } from '@/routes/image'; import { DataFetcherError, - getPublishedContentByURL, getVisitorAuthBasePath, + lookupPublishedContentByUrl, normalizeURL, throwIfDataError, } from '@v2/lib/data'; @@ -85,17 +85,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // // Detect and extract the visitor authentication token from the request // - // @ts-ignore - request typing - const visitorToken = getVisitorToken({ + const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({ cookies: request.cookies.getAll(), url: siteRequestURL, }); const withAPIToken = async (apiToken: string | null) => { const siteURLData = await throwIfDataError( - getPublishedContentByURL({ + lookupPublishedContentByUrl({ url: siteRequestURL.toString(), - visitorAuthToken: visitorToken?.token ?? null, + visitorPayload: { + jwtToken: visitorToken?.token ?? undefined, + unsignedClaims, + }, // When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow // redirecting when the token is invalid as we could be dealing with stale token stored in the cookie. // For example when the VA backend signature has changed but the token stored in the cookie is not yet expired. @@ -106,7 +108,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { apiToken, }) ); - const cookies: ResponseCookies = []; + + const cookies: ResponseCookies = visitorParamsCookie + ? [ + // If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation. + visitorParamsCookie, + ] + : []; // // Handle redirects @@ -205,7 +213,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { const customization = siteRequestURL.searchParams.get('customization'); if (customization && validateSerializedCustomization(customization)) { routeType = 'dynamic'; - requestHeaders.set(MiddlewareHeaders.Customization, customization); + // We need to encode the customization headers, otherwise it will fail for some customization values containing non ASCII chars on vercel. + requestHeaders.set(MiddlewareHeaders.Customization, encodeURIComponent(customization)); } const theme = siteRequestURL.searchParams.get('theme'); if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) { @@ -250,6 +259,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { console.log(`rewriting ${request.nextUrl.toString()} to ${route}`); const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString()); + rewrittenURL.search = request.nextUrl.search; // Preserve the original search params + const response = NextResponse.rewrite(rewrittenURL, { request: { headers: requestHeaders, diff --git a/packages/gitbook-v2/src/pages/api/~gitbook/force-revalidate.ts b/packages/gitbook-v2/src/pages/api/~gitbook/force-revalidate.ts new file mode 100644 index 0000000000..45e6c7eca1 --- /dev/null +++ b/packages/gitbook-v2/src/pages/api/~gitbook/force-revalidate.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +interface JsonBody { + // The paths need to be the rewritten one, `res.revalidate` call don't go through the middleware + paths: string[]; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Only allow POST requests + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + const signatureHeader = req.headers['x-gitbook-signature'] as string | undefined; + if (!signatureHeader) { + return res.status(400).json({ error: 'Missing signature header' }); + } + // We cannot use env from `@/v2/lib/env` here as it make it crash because of the import "server-only" in the file. + if (process.env.GITBOOK_SECRET) { + try { + const computedSignature = crypto + .createHmac('sha256', process.env.GITBOOK_SECRET) + .update(JSON.stringify(req.body)) + .digest('hex'); + + if (computedSignature === signatureHeader) { + const results = await Promise.allSettled( + (req.body as JsonBody).paths.map((path) => { + // biome-ignore lint/suspicious/noConsole: we want to log here + console.log(`Revalidating path: ${path}`); + return res.revalidate(path); + }) + ); + return res.status(200).json({ + success: results.every((result) => result.status === 'fulfilled'), + errors: results + .filter((result) => result.status === 'rejected') + .map((result) => (result as PromiseRejectedResult).reason), + }); + } + return res.status(401).json({ error: 'Invalid signature' }); + } catch (error) { + console.error('Error during revalidation:', error); + return res.status(400).json({ error: 'Invalid request or unable to parse JSON' }); + } + } + // If no secret is set, we do not allow revalidation + return res.status(403).json({ error: 'Revalidation is disabled' }); +} diff --git a/packages/gitbook-v2/wrangler.jsonc b/packages/gitbook-v2/wrangler.jsonc index e316e2a242..6d9c2b2324 100644 --- a/packages/gitbook-v2/wrangler.jsonc +++ b/packages/gitbook-v2/wrangler.jsonc @@ -2,7 +2,11 @@ "main": ".open-next/worker.js", "name": "gitbook-open-v2", "compatibility_date": "2025-04-14", - "compatibility_flags": ["nodejs_compat", "allow_importable_env"], + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], "assets": { "directory": ".open-next/assets", "binding": "ASSETS" @@ -10,8 +14,15 @@ "observability": { "enabled": true }, + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", + "IS_PREVIEW": "false" + }, "env": { "preview": { + "vars": { + "IS_PREVIEW": "true" + }, "r2_buckets": [ { "binding": "NEXT_INC_CACHE_R2_BUCKET", @@ -83,6 +94,14 @@ ] }, "production": { + "vars": { + // This is a bit misleading, but it means that we can have 500 concurrent revalidations + // This means that we'll have up to 100 durable objects instance running at the same time + "MAX_REVALIDATE_CONCURRENCY": "100", + // Temporary variable to find the issue once deployed + // TODO: remove this once the issue is fixed + "DEBUG_CLOUDFLARE": "true" + }, "routes": [ { "pattern": "open-2c.gitbook.com/*", diff --git a/packages/gitbook/CHANGELOG.md b/packages/gitbook/CHANGELOG.md index dc285100ff..fb855a6305 100644 --- a/packages/gitbook/CHANGELOG.md +++ b/packages/gitbook/CHANGELOG.md @@ -1,5 +1,51 @@ # gitbook +## 0.12.0 + +### Minor Changes + +- 8339e91: Fix images in reusable content across spaces. +- 326e28e: Design tweaks to code blocks and OpenAPI pages +- 3119066: Add support for reusable content across spaces. +- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host. + +### Patch Changes + +- c4ebb3f: Fix openapi-select hover in responses +- aed79fd: Decrease rounding of header logo +- 42ca7e1: Fix openapi CR preview +- e6ddc0f: Fix URL in sitemap +- 5e975ab: Fix code highlighting for HTTP +- 5d504ff: Fix resolution of links in reusable contents +- 95a1f65: Better print layouts: wrap code blocks & force table column auto-sizing +- 0499966: Fix invalid sitemap.xml generated with relative URLs instead of absolute ones +- 2a805cc: Change OpenAPI schema-optional from `info` to `tint` color +- 580101d: Fix schemas disclosure label causing client error +- 12a455d: Fix OpenAPI layout issues +- 97b7c79: Increase logging around caching behaviour causing page crashes. +- 373f18f: Prevent section group popovers from opening on click +- 3f29206: Update the regex for validating site redirect +- 0c973a3: Always link main logo to the root of the site +- ae5f1ab: Change `Dropdown`s to use Radix's `DropdownMenu` +- 0e201d5: Add border to filled sidebar on gradient theme +- dd043df: Revert investigation work around URL caches. +- 89a5816: Fix OpenAPI disclosure label ("Show properties") misalignment on mobile +- Updated dependencies [c3f6b8c] +- Updated dependencies [d00dc8c] +- Updated dependencies [42ca7e1] +- Updated dependencies [326e28e] +- Updated dependencies [5e975ab] +- Updated dependencies [f7a3470] +- Updated dependencies [580101d] +- Updated dependencies [20ebecb] +- Updated dependencies [80cb52a] +- Updated dependencies [cb5598d] +- Updated dependencies [c6637b0] +- Updated dependencies [a3ec264] + - @gitbook/colors@0.3.3 + - @gitbook/openapi-parser@2.1.4 + - @gitbook/react-openapi@1.3.0 + ## 0.11.1 ### Patch Changes @@ -564,7 +610,7 @@ - 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 +- 5096f7f: Disable KV cache for gitbook.com/docs 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` diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts index f0612ba8e0..aa8e6e4a7c 100644 --- a/packages/gitbook/e2e/customers.spec.ts +++ b/packages/gitbook/e2e/customers.spec.ts @@ -204,11 +204,11 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://sosovalue-white-paper.gitbook.io', tests: [{ name: 'Home', url: '/' }], }, - { - name: 'docs.revrobotics.com', - contentBaseURL: 'https://docs.revrobotics.com', - tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], - }, + // { + // name: 'docs.revrobotics.com', + // contentBaseURL: 'https://docs.revrobotics.com', + // tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], + // }, { name: 'chartschool.stockcharts.com', contentBaseURL: 'https://chartschool.stockcharts.com', diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index e7875cd4e8..3a884ae5ea 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1,9 +1,11 @@ import { CustomizationBackground, CustomizationCorners, + CustomizationDepth, CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationSidebarListStyle, + CustomizationThemeMode, } from '@gitbook/api'; import { expect } from '@playwright/test'; import jwt from 'jsonwebtoken'; @@ -12,7 +14,7 @@ import { VISITOR_TOKEN_COOKIE, getVisitorAuthCookieName, getVisitorAuthCookieValue, -} from '@/lib/visitor-token'; +} from '@/lib/visitors'; import { getSiteAPIToken } from '../tests/utils'; import { @@ -111,24 +113,24 @@ const testCases: TestsCase[] = [ name: 'Customized variant titles are displayed', url: '', run: async (page) => { - const spaceDrowpdown = page + const spaceDropdown = page .locator('[data-testid="space-dropdown-button"]') .locator('visible=true'); - await spaceDrowpdown.click(); + await spaceDropdown.click(); const variantSelectionDropdown = page.locator( - 'css=[data-testid="space-dropdown-button"] + div' + 'css=[data-testid="dropdown-menu"]' ); // the customized space title await expect( - variantSelectionDropdown.getByRole('link', { + variantSelectionDropdown.getByRole('menuitem', { name: 'Multi-Variants', }) ).toBeVisible(); // the NON-customized space title await expect( - variantSelectionDropdown.getByRole('link', { + variantSelectionDropdown.getByRole('menuitem', { name: 'RFCs', }) ).toBeVisible(); @@ -145,14 +147,17 @@ const testCases: TestsCase[] = [ url: 'api-multi-versions/reference/api-reference/pets', screenshot: false, run: async (page) => { - const spaceDrowpdown = await page + const spaceDropdown = await page .locator('[data-testid="space-dropdown-button"]') .locator('visible=true'); - await spaceDrowpdown.click(); + await spaceDropdown.click(); + const variantSelectionDropdown = page.locator( + 'css=[data-testid="dropdown-menu"]' + ); // Click the second variant in the dropdown - await page - .getByRole('link', { + await variantSelectionDropdown + .getByRole('menuitem', { name: '2.0', }) .click(); @@ -168,14 +173,18 @@ const testCases: TestsCase[] = [ url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets', screenshot: false, run: async (page) => { - const spaceDrowpdown = await page + const spaceDropdown = await page .locator('[data-testid="space-dropdown-button"]') .locator('visible=true'); - await spaceDrowpdown.click(); + await spaceDropdown.click(); + + const variantSelectionDropdown = page.locator( + 'css=[data-testid="dropdown-menu"]' + ); // Click the second variant in the dropdown - await page - .getByRole('link', { + await variantSelectionDropdown + .getByRole('menuitem', { name: '2.0', }) .click(); @@ -205,14 +214,18 @@ const testCases: TestsCase[] = [ return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`; }, run: async (page) => { - const spaceDrowpdown = await page + const spaceDropdown = await page .locator('[data-testid="space-dropdown-button"]') .locator('visible=true'); - await spaceDrowpdown.click(); + await spaceDropdown.click(); + + const variantSelectionDropdown = page.locator( + 'css=[data-testid="dropdown-menu"]' + ); // Click the second variant in the dropdown - await page - .getByRole('link', { + await variantSelectionDropdown + .getByRole('menuitem', { name: '2.0', }) .click(); @@ -258,7 +271,7 @@ const testCases: TestsCase[] = [ }, { name: 'GitBook', - contentBaseURL: 'https://docs.gitbook.com', + contentBaseURL: 'https://gitbook.com/docs/', tests: [ { name: 'Home', @@ -419,6 +432,38 @@ const testCases: TestsCase[] = [ }, ], }, + { + name: 'llms.txt', + skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel', + contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/', + tests: [ + { + name: 'llms.txt', + url: 'llms.txt', + screenshot: false, + run: async (_page, response) => { + expect(response?.status()).toBe(200); + expect(response?.headers()['content-type']).toContain('text/markdown'); + }, + }, + ], + }, + { + name: 'llms-full.txt', + skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel', + contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/', + tests: [ + { + name: 'llms-full.txt', + url: 'llms-full.txt', + screenshot: false, + run: async (_page, response) => { + expect(response?.status()).toBe(200); + expect(response?.headers()['content-type']).toContain('text/markdown'); + }, + }, + ], + }, { name: 'Site subdirectory (proxy)', skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel', @@ -558,6 +603,11 @@ const testCases: TestsCase[] = [ url: 'blocks/emojis', run: waitForCookiesDialog, }, + { + name: 'Icons', + url: 'blocks/icons', + run: waitForCookiesDialog, + }, { name: 'Links', url: 'blocks/links', @@ -621,6 +671,7 @@ const testCases: TestsCase[] = [ name: 'Stepper', url: 'blocks/stepper', }, + { name: 'Columns', url: 'blocks/columns' }, ], }, { @@ -637,6 +688,16 @@ const testCases: TestsCase[] = [ url: 'page-options/page-with-cover', run: waitForCookiesDialog, }, + { + name: 'With cover for dark mode', + url: `page-options/page-with-dark-cover${getCustomizationURL({ + themes: { + default: CustomizationThemeMode.Dark, + toggeable: false, + }, + })}`, + run: waitForCookiesDialog, + }, { name: 'With hero cover', url: 'page-options/page-with-hero-cover', @@ -806,6 +867,16 @@ const testCases: TestsCase[] = [ }), run: waitForCookiesDialog, }, + { + name: `With flat and circular corners - Theme mode ${themeMode}`, + url: getCustomizationURL({ + styling: { + depth: CustomizationDepth.Flat, + corners: CustomizationCorners.Circular, + }, + }), + run: waitForCookiesDialog, + }, ]), }, { diff --git a/packages/gitbook/e2e/pdf.spec.ts b/packages/gitbook/e2e/pdf.spec.ts new file mode 100644 index 0000000000..4798a2a047 --- /dev/null +++ b/packages/gitbook/e2e/pdf.spec.ts @@ -0,0 +1,170 @@ +import { argosScreenshot } from '@argos-ci/playwright'; +import { expect, test } from '@playwright/test'; +import { getContentTestURL } from '../tests/utils'; +import { waitForIcons } from './util'; + +test.describe('PDF export', () => { + test('export all pages as PDF (e2e)', async ({ page }) => { + // Set the header to disable the Vercel toolbar + // But only on the main document as it'd cause CORS issues on other resources + await page.route('**/*', async (route, request) => { + if (request.resourceType() === 'document') { + await route.continue({ + headers: { + ...request.headers(), + 'x-vercel-skip-toolbar': '1', + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto( + getContentTestURL( + 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/~gitbook/pdf?limit=10' + ) + ); + + const printBtn = page.getByTestId('print-button'); + await expect(printBtn).toBeVisible(); + + await argosScreenshot(page, 'pdf - all pages', { + viewports: ['macbook-13'], + argosCSS: ` + /* Hide Intercom */ + .intercom-lightweight-app { + display: none !important; + } + `, + threshold: undefined, + fullPage: true, + beforeScreenshot: async ({ runStabilization }) => { + await runStabilization(); + await waitForIcons(page); + }, + }); + }); + + test('export all pages as PDF (GitBook docs)', async ({ page }) => { + // Set the header to disable the Vercel toolbar + // But only on the main document as it'd cause CORS issues on other resources + await page.route('**/*', async (route, request) => { + if (request.resourceType() === 'document') { + await route.continue({ + headers: { + ...request.headers(), + 'x-vercel-skip-toolbar': '1', + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto(getContentTestURL('https://gitbook.com/docs/~gitbook/pdf?limit=10')); + + const printBtn = page.getByTestId('print-button'); + await expect(printBtn).toBeVisible(); + + await argosScreenshot(page, 'pdf - all pages', { + viewports: ['macbook-13'], + argosCSS: ` + /* Hide Intercom */ + .intercom-lightweight-app { + display: none !important; + } + `, + threshold: undefined, + fullPage: true, + beforeScreenshot: async ({ runStabilization }) => { + await runStabilization(); + await waitForIcons(page); + }, + }); + }); + + test('export a single page as PDF (e2e)', async ({ page }) => { + // Set the header to disable the Vercel toolbar + // But only on the main document as it'd cause CORS issues on other resources + await page.route('**/*', async (route, request) => { + if (request.resourceType() === 'document') { + await route.continue({ + headers: { + ...request.headers(), + 'x-vercel-skip-toolbar': '1', + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto( + getContentTestURL( + 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/~gitbook/pdf?page=Bw7LjWwgTjV8nIV4s7rs&only=yes&limit=2' + ) + ); + + const printBtn = page.getByTestId('print-button'); + await expect(printBtn).toBeVisible(); + + await argosScreenshot(page, 'pdf - all pages', { + viewports: ['macbook-13'], + argosCSS: ` + /* Hide Intercom */ + .intercom-lightweight-app { + display: none !important; + } + `, + threshold: undefined, + fullPage: true, + beforeScreenshot: async ({ runStabilization }) => { + await runStabilization(); + await waitForIcons(page); + }, + }); + }); + + test('export a single page as PDF (GitBook docs)', async ({ page }) => { + // Set the header to disable the Vercel toolbar + // But only on the main document as it'd cause CORS issues on other resources + await page.route('**/*', async (route, request) => { + if (request.resourceType() === 'document') { + await route.continue({ + headers: { + ...request.headers(), + 'x-vercel-skip-toolbar': '1', + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto( + getContentTestURL( + 'https://gitbook.com/docs/~gitbook/pdf?page=DfnNkU49mvLe2ythHAyx&only=yes&limit=2' + ) + ); + + const printBtn = page.getByTestId('print-button'); + await expect(printBtn).toBeVisible(); + + await argosScreenshot(page, 'pdf - all pages', { + viewports: ['macbook-13'], + argosCSS: ` + /* Hide Intercom */ + .intercom-lightweight-app { + display: none !important; + } + `, + threshold: undefined, + fullPage: true, + beforeScreenshot: async ({ runStabilization }) => { + await runStabilization(); + await waitForIcons(page); + }, + }); + }); +}); diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index c2edad6186..937b776d70 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -3,6 +3,7 @@ import { CustomizationBackground, CustomizationCorners, CustomizationDefaultFont, + CustomizationDepth, type CustomizationHeaderItem, CustomizationHeaderPreset, CustomizationIconsStyle, @@ -168,6 +169,7 @@ export function runTestCases(testCases: TestsCase[]) { new URL(testEntryPathname, testCase.contentBaseURL).toString() ) : getTestURL(testEntryPathname); + if (testEntry.cookies) { await context.addCookies( testEntry.cookies.map((cookie) => ({ @@ -277,6 +279,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin dangerColor: { light: '#FB2C36', dark: '#FB2C36' }, successColor: { light: '#00C950', dark: '#00C950' }, corners: CustomizationCorners.Rounded, + depth: CustomizationDepth.Subtle, font: CustomizationDefaultFont.Inter, background: CustomizationBackground.Plain, icons: CustomizationIconsStyle.Regular, @@ -343,21 +346,32 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin /** * Wait for all icons present on the page to be loaded. */ -async function waitForIcons(page: Page) { +export async function waitForIcons(page: Page) { await page.waitForFunction(() => { - const urlStates: Record<string, 'pending' | 'loaded'> = - (window as any).__ICONS_STATES__ || {}; + const urlStates: Record< + string, + { state: 'pending'; uri: null } | { state: 'loaded'; uri: string } + > = (window as any).__ICONS_STATES__ || {}; (window as any).__ICONS_STATES__ = urlStates; + const fetchSvgAsDataUri = async (url: string): Promise<string> => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch SVG: ${response.status}`); + } + + const svgText = await response.text(); + const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22'); + + return `data:image/svg+xml;charset=utf-8,${encoded}`; + }; + const loadUrl = (url: string) => { // Mark the URL as pending. - urlStates[url] = 'pending'; - - const img = new Image(); - img.onload = () => { - urlStates[url] = 'loaded'; - }; - img.src = url; + urlStates[url] = { state: 'pending', uri: null }; + fetchSvgAsDataUri(url).then((uri) => { + urlStates[url] = { state: 'loaded', uri }; + }); }; const icons = Array.from(document.querySelectorAll('svg.gb-icon')); @@ -393,18 +407,11 @@ async function waitForIcons(page: Page) { // If the URL is already queued for loading, we return the state. if (urlStates[url]) { - if (urlStates[url] === 'loaded') { + if (urlStates[url].state === 'loaded') { icon.setAttribute('data-argos-state', 'pending'); - const bckMaskImage = icon.style.maskImage; - const bckDisplay = icon.style.display; - icon.style.maskImage = ''; - icon.style.display = 'none'; + icon.style.maskImage = `url("${urlStates[url].uri}")`; requestAnimationFrame(() => { - icon.style.maskImage = bckMaskImage; - icon.style.display = bckDisplay; - requestAnimationFrame(() => { - icon.setAttribute('data-argos-state', 'loaded'); - }); + icon.setAttribute('data-argos-state', 'loaded'); }); return false; } diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index d0abc7512b..a430218cfb 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -1,6 +1,6 @@ { "name": "gitbook", - "version": "0.11.1", + "version": "0.12.0", "private": true, "scripts": { "dev": "env-cmd --silent -f ../../.env.local next dev", @@ -8,25 +8,27 @@ "build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts", "start": "next start", "typecheck": "tsc --noEmit", - "e2e": "playwright test e2e/internal.spec.ts", + "e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts", "e2e-customers": "playwright test e2e/customers.spec.ts", - "unit": "bun test {src,packages}", + "unit": "bun test {src,packages} --preload ./tests/preload-bun.ts", "generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math", "copy:icons": "gitbook-icons ./public/~gitbook/static/icons", "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math" }, "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "catalog:", "@gitbook/cache-do": "workspace:*", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/icons": "workspace:*", + "@gitbook/fonts": "workspace:*", "@gitbook/openapi-parser": "workspace:*", "@gitbook/react-contentkit": "workspace:*", "@gitbook/react-math": "workspace:*", "@gitbook/react-openapi": "workspace:*", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.8", @@ -37,6 +39,7 @@ "assert-never": "^1.2.1", "bun-types": "^1.1.20", "classnames": "^2.5.1", + "event-iterator": "^2.0.0", "framer-motion": "^10.16.14", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", @@ -44,16 +47,24 @@ "katex": "^0.16.9", "mathjax": "^3.2.2", "mdast-util-to-markdown": "^2.1.2", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-frontmatter": "^2.0.1", + "mdast-util-gfm": "^3.1.0", + "micromark-extension-gfm": "^3.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unist-util-remove": "^4.0.0", + "unist-util-visit": "^5.0.0", "memoizee": "^0.4.17", "next": "14.2.26", "next-themes": "^0.2.1", "nuqs": "^2.2.3", "object-hash": "^3.0.0", "openapi-types": "^12.1.3", - "p-map": "^7.0.0", + "p-map": "^7.0.3", "parse-cache-control": "^1.0.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "partial-json": "^0.1.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-hotkeys-hook": "^4.4.1", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", @@ -70,13 +81,13 @@ "usehooks-ts": "^3.1.0", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", - "event-iterator": "^2.0.0", - "partial-json": "^0.1.7", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "image-size": "^2.0.2", + "direction": "^2.0.1" }, "devDependencies": { "@argos-ci/playwright": "^5.0.3", - "@cloudflare/next-on-pages": "1.13.7", + "@cloudflare/next-on-pages": "1.13.12", "@cloudflare/workers-types": "^4.20241230.0", "@playwright/test": "^1.51.1", "@types/js-cookie": "^3.0.6", diff --git a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx index e6d263bf6a..82613af491 100644 --- a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx +++ b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx @@ -19,14 +19,14 @@ export async function AdClassicRendering({ insightsAd: SiteInsightsAd | null; context: GitBookBaseContext; }) { - const smallImgSrc = + const [smallImgSrc, logoSrc] = await Promise.all([ 'smallImage' in ad - ? await getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 }) - : null; - const logoSrc = + ? getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 }) + : null, 'logo' in ad - ? await getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 }) - : null; + ? getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 }) + : null, + ]); return ( <Link rel="sponsored noopener" diff --git a/packages/gitbook/src/components/Ads/renderAd.tsx b/packages/gitbook/src/components/Ads/renderAd.tsx index 146f0d2530..96ff7bd3ba 100644 --- a/packages/gitbook/src/components/Ads/renderAd.tsx +++ b/packages/gitbook/src/components/Ads/renderAd.tsx @@ -43,10 +43,12 @@ interface FetchPlaceholderAdOptions { * and properly access user-agent and IP. */ export async function renderAd(options: FetchAdOptions) { - const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const [context, result] = await Promise.all([ + isV2() ? getServerActionBaseContext() : getV1BaseContext(), + options.source === 'live' ? fetchAd(options) : getPlaceholderAd(), + ]); const mode = options.source === 'live' ? options.mode : 'classic'; - const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd(); if (!result || !result.ad.description || !result.ad.statlink) { return null; } diff --git a/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx index c89bb24804..decd177a13 100644 --- a/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx +++ b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx @@ -1,12 +1,14 @@ 'use client'; +import { tString, useLanguage } from '@/intl/client'; import * as storage from '@/lib/local-storage'; import type { ResolvedContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api'; import { Icon, type IconName } from '@gitbook/icons'; import { CONTAINER_STYLE } from '../layout'; -import { Link, linkStyles } from '../primitives'; +import { Link } from '../primitives'; +import { LinkStyles } from '../primitives/styles'; import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants'; /** @@ -18,6 +20,8 @@ export function AnnouncementBanner(props: { }) { const { announcement, contentRef } = props; + const language = useLanguage(); + const hasLink = announcement.link && contentRef?.href; const closeable = announcement.style !== 'danger'; @@ -25,13 +29,13 @@ export function AnnouncementBanner(props: { const style = BANNER_STYLES[announcement.style]; return ( - <div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2"> + <div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2"> <div className="scroll-nojump"> <div className={tcls('relative', CONTAINER_STYLE)}> <Tag href={contentRef?.href ?? ''} className={tcls( - 'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors', + 'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors', style.container, closeable && 'pr-12', hasLink && style.hover @@ -55,7 +59,7 @@ export function AnnouncementBanner(props: { <div> {announcement.message} {hasLink ? ( - <div className={tcls(linkStyles, style.link, 'ml-1 inline')}> + <div className={tcls(LinkStyles, style.link, 'ml-1 inline')}> {contentRef?.icon ? ( <span className="mr-1 ml-2 *:inline"> {contentRef?.icon} @@ -78,9 +82,10 @@ export function AnnouncementBanner(props: { </Tag> {closeable ? ( <button - className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`} + className={`absolute top-0 right-4 mt-2 mr-2 rounded circular-corners:rounded-lg straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`} type="button" onClick={dismissAnnouncement} + title={tString(language, 'close')} > <Icon icon="close" className="size-4" /> </button> diff --git a/packages/gitbook/src/components/Cookies/CookiesToast.tsx b/packages/gitbook/src/components/Cookies/CookiesToast.tsx index 13dc7605d0..281e6ad911 100644 --- a/packages/gitbook/src/components/Cookies/CookiesToast.tsx +++ b/packages/gitbook/src/components/Cookies/CookiesToast.tsx @@ -47,9 +47,11 @@ export function CookiesToast(props: { privacyPolicy?: string }) { 'bg-tint-base', 'rounded', 'straight-corners:rounded-none', + 'circular-corners:rounded-2xl', 'ring-1', 'ring-tint-subtle', 'shadow-1xs', + 'depth-flat:shadow-none', 'p-4', 'pr-8', 'bottom-4', @@ -72,7 +74,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) { <button type="button" onClick={() => setShow(false)} - aria-label={tString(language, 'cookies_close')} + aria-label={tString(language, 'close')} className={tcls( 'absolute', 'top-3', @@ -83,9 +85,10 @@ export function CookiesToast(props: { privacyPolicy?: string }) { 'justify-center', 'items-center', 'rounded-sm', + 'circular-corners:rounded-full', 'hover:bg-tint-hover' )} - title={tString(language, 'cookies_close')} + title={tString(language, 'close')} > <Icon icon="xmark" className={tcls('size-4')} /> </button> diff --git a/packages/gitbook/src/components/DocumentView/Block.tsx b/packages/gitbook/src/components/DocumentView/Block.tsx index 60998b50b1..c9f4ef493a 100644 --- a/packages/gitbook/src/components/DocumentView/Block.tsx +++ b/packages/gitbook/src/components/DocumentView/Block.tsx @@ -10,8 +10,10 @@ import { } from '@/components/primitives'; import type { ClassValue } from '@/lib/tailwind'; +import { nullIfNever } from '@/lib/typescript'; import { BlockContentRef } from './BlockContentRef'; import { CodeBlock } from './CodeBlock'; +import { Columns } from './Columns'; import { Divider } from './Divider'; import type { DocumentContextProps } from './DocumentView'; import { Drawing } from './Drawing'; @@ -44,13 +46,6 @@ export interface BlockProps<Block extends DocumentBlock> extends DocumentContext style?: ClassValue; } -/** - * Alternative to `assertNever` that returns `null` instead of throwing an error. - */ -function nullIfNever(_value: never): null { - return null; -} - export function Block<T extends DocumentBlock>(props: BlockProps<T>) { const { block, style, isEstimatedOffscreen, context } = props; @@ -68,6 +63,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) { return <List {...props} block={block} />; case 'list-item': return <ListItem {...props} block={block} />; + case 'columns': + return <Columns {...props} block={block} />; case 'code': return <CodeBlock {...props} block={block} />; case 'hint': @@ -112,6 +109,7 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) { case 'image': case 'code-line': case 'tabs-item': + case 'column': throw new Error(`Blocks (${block.type}) should be directly rendered by parent`); default: return nullIfNever(block); @@ -168,6 +166,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue } case 'integration': case 'stepper': case 'reusable-content': + case 'columns': return <SkeletonCard id={id} style={style} />; case 'embed': case 'images': @@ -176,6 +175,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue } case 'image': case 'code-line': case 'tabs-item': + case 'column': throw new Error(`Blocks (${block.type}) should be directly rendered by parent`); default: return nullIfNever(block); diff --git a/packages/gitbook/src/components/DocumentView/Blocks.tsx b/packages/gitbook/src/components/DocumentView/Blocks.tsx index 903acd2955..4bbe605d87 100644 --- a/packages/gitbook/src/components/DocumentView/Blocks.tsx +++ b/packages/gitbook/src/components/DocumentView/Blocks.tsx @@ -70,8 +70,8 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl style={[ 'mx-auto w-full decoration-primary/6', node.data && 'fullWidth' in node.data && node.data.fullWidth - ? 'max-w-screen-xl' - : 'max-w-3xl', + ? 'max-w-screen-2xl' + : 'page-full-width:ml-0 max-w-3xl', blockStyle, ]} isEstimatedOffscreen={isOffscreen} diff --git a/packages/gitbook/src/components/DocumentView/Caption.tsx b/packages/gitbook/src/components/DocumentView/Caption.tsx index f7649e68a8..c7075e6bad 100644 --- a/packages/gitbook/src/components/DocumentView/Caption.tsx +++ b/packages/gitbook/src/components/DocumentView/Caption.tsx @@ -42,7 +42,7 @@ export function Caption( 'after:pointer-events-none', fit ? 'w-fit' : null, withBorder - ? 'rounded straight-corners:rounded-none after:border-tint-subtle after:border after:rounded straight-corners:after:rounded-none dark:after:mix-blend-plus-lighter after:pointer-events-none' + ? 'rounded circular-corners:rounded-2xl straight-corners:rounded-none after:border-tint-subtle after:border after:rounded circular-corners:after:rounded-2xl straight-corners:after:rounded-none dark:after:mix-blend-plus-lighter after:pointer-events-none' : null, ], style, diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.css b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.css index 6a56801796..71d75354eb 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.css +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.css @@ -24,7 +24,7 @@ } .highlight-line-number { - @apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent; + @apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent; @apply before:text-tint before:content-[counter(line)]; .highlight-line.highlighted > & { diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx index f1f8cb8f63..0ad885a444 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx @@ -39,7 +39,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( > <div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]"> {title ? ( - <div className="inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s bg-tint px-3 py-2 text-tint text-xs leading-none tracking-wide"> + <div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"> {title} </div> ) : null} @@ -50,15 +50,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( /> <pre className={tcls( - 'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]', - 'rounded-md straight-corners:rounded-sm', + 'hide-scroll relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base', + 'rounded-md straight-corners:rounded-sm shadow-sm', title && 'rounded-ss-none' )} > <code id={id} className={tcls( - 'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line]', + 'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap', withWrap && 'whitespace-pre-wrap' )} > diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts index eb02b5b928..89d00dafba 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts @@ -12,6 +12,7 @@ import { import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import { type BundledLanguage, bundledLanguages } from 'shiki/langs'; +import { nullIfNever } from '@/lib/typescript'; import { plainHighlight } from './plain-highlight'; export type HighlightLine = { @@ -263,16 +264,28 @@ function getPlainCodeBlockLine( if (node.object === 'text') { content += cleanupLine(node.leaves.map((leaf) => leaf.text).join('')); } else { - const start = index + content.length; - content += getPlainCodeBlockLine(node, index + content.length, inlines); - const end = index + content.length; - - if (inlines) { - inlines.push({ - inline: node, - start, - end, - }); + switch (node.type) { + case 'annotation': { + const start = index + content.length; + content += getPlainCodeBlockLine(node, index + content.length, inlines); + const end = index + content.length; + + if (inlines) { + inlines.push({ + inline: node, + start, + end, + }); + } + break; + } + case 'expression': { + break; + } + default: { + nullIfNever(node); + break; + } } } } diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/theme.css b/packages/gitbook/src/components/DocumentView/CodeBlock/theme.css index 22ce3b60e3..5e4a73a889 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/theme.css +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/theme.css @@ -1,31 +1,67 @@ :root { --shiki-color-text: theme("colors.tint.11"); - --shiki-token-constant: #0a6355; - --shiki-token-string: #8b6d32; - --shiki-token-comment: theme("colors.teal.700/.64"); - --shiki-token-keyword: theme("colors.pomegranate.600"); - --shiki-token-parameter: #0a3069; - --shiki-token-function: #8250df; - --shiki-token-string-expression: #6a4906; - --shiki-token-punctuation: theme("colors.pomegranate.700/.92"); - --shiki-token-link: theme("colors.tint.12"); - --shiki-token-inserted: #22863a; - --shiki-token-deleted: #b31d28; - --shiki-token-changed: #8250df; + --shiki-token-punctuation: theme("colors.tint.11"); + --shiki-token-comment: theme("colors.neutral.9/.7"); + --shiki-token-link: theme("colors.primary.10"); + + --shiki-token-constant: theme("colors.warning.10"); + --shiki-token-string: theme("colors.warning.10"); + --shiki-token-string-expression: theme("colors.success.10"); + --shiki-token-keyword: theme("colors.danger.10"); + --shiki-token-parameter: theme("colors.warning.10"); + --shiki-token-function: theme("colors.primary.10"); + + --shiki-token-inserted: theme("colors.success.10"); + --shiki-token-deleted: theme("colors.danger.10"); + --shiki-token-changed: theme("colors.tint.12"); +} + +@media (prefers-contrast: more) { + :root { + --shiki-color-text: theme("colors.tint.12"); + --shiki-token-punctuation: theme("colors.tint.12"); + --shiki-token-comment: theme("colors.neutral.11"); + --shiki-token-link: theme("colors.primary.11"); + + --shiki-token-constant: theme("colors.warning.11"); + --shiki-token-string: theme("colors.warning.11"); + --shiki-token-string-expression: theme("colors.success.11"); + --shiki-token-keyword: theme("colors.danger.11"); + --shiki-token-parameter: theme("colors.warning.11"); + --shiki-token-function: theme("colors.primary.11"); + + --shiki-token-inserted: theme("colors.success.11"); + --shiki-token-deleted: theme("colors.danger.11"); + --shiki-token-changed: theme("colors.tint.12"); + } } html.dark { - --shiki-color-text: theme("colors.tint.11"); - --shiki-token-constant: #d19a66; - --shiki-token-string: theme("colors.pomegranate.300"); - --shiki-token-comment: theme("colors.teal.300/.64"); - --shiki-token-keyword: theme("colors.pomegranate.400"); - --shiki-token-parameter: theme("colors.yellow.500"); - --shiki-token-function: #56b6c2; - --shiki-token-string-expression: theme("colors.tint.11"); - --shiki-token-punctuation: #acc6ee; - --shiki-token-link: theme("colors.pomegranate.400"); - --shiki-token-inserted: #85e89d; - --shiki-token-deleted: #fdaeb7; - --shiki-token-changed: #56b6c2; + /* Override select colors to have more contrast */ + --shiki-token-comment: theme("colors.neutral.9"); + + --shiki-token-constant: theme("colors.warning.11"); + --shiki-token-string: theme("colors.warning.11"); + --shiki-token-string-expression: theme("colors.success.11"); + --shiki-token-keyword: theme("colors.danger.11"); + --shiki-token-parameter: theme("colors.warning.11"); + --shiki-token-function: theme("colors.primary.11"); +} + +.code-monochrome { + --shiki-token-constant: theme("colors.tint.11"); + --shiki-token-string: theme("colors.tint.12"); + --shiki-token-string-expression: theme("colors.tint.12"); + --shiki-token-keyword: theme("colors.primary.10"); + --shiki-token-parameter: theme("colors.tint.9"); + --shiki-token-function: theme("colors.primary.9"); +} + +html.dark.code-monochrome { + --shiki-token-constant: theme("colors.tint.11"); + --shiki-token-string: theme("colors.tint.12"); + --shiki-token-string-expression: theme("colors.tint.12"); + --shiki-token-keyword: theme("colors.primary.11"); + --shiki-token-parameter: theme("colors.tint.10"); + --shiki-token-function: theme("colors.primary.10"); } diff --git a/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx b/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx new file mode 100644 index 0000000000..181095434e --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Columns/Columns.tsx @@ -0,0 +1,80 @@ +import { type ClassValue, tcls } from '@/lib/tailwind'; +import type { DocumentBlockColumns, Length } from '@gitbook/api'; +import type { BlockProps } from '../Block'; +import { Blocks } from '../Blocks'; + +export function Columns(props: BlockProps<DocumentBlockColumns>) { + const { block, style, ancestorBlocks, document, context } = props; + return ( + <div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}> + {block.nodes.map((columnBlock) => { + const width = columnBlock.data.width; + const { className, style } = transformLengthToCSS(width); + return ( + <Column key={columnBlock.key} className={className} style={style}> + <Blocks + key={columnBlock.key} + nodes={columnBlock.nodes} + document={document} + ancestorBlocks={[...ancestorBlocks, block, columnBlock]} + context={context} + blockStyle="flip-heading-hash" + style="w-full space-y-4 *:max-w-full" + /> + </Column> + ); + })} + </div> + ); +} + +export function Column(props: { + children?: React.ReactNode; + className?: ClassValue; + style?: React.CSSProperties; +}) { + return ( + <div className={tcls('flex-col', props.className)} style={props.style}> + {props.children} + </div> + ); +} + +function transformLengthToCSS(length: Length | undefined) { + if (!length) { + return { className: ['md:w-full'] }; // default to full width if no length is specified + } + + if (typeof length === 'number') { + return { style: undefined }; // not implemented yet with non-percentage lengths + } + + if (length.unit === '%') { + return { + className: [ + 'md:flex-shrink-0', + COLUMN_WIDTHS[Math.round(length.value * 0.01 * (COLUMN_WIDTHS.length - 1))], + ], + }; + } + + return { style: undefined }; // not implemented yet with non-percentage lengths +} + +// Tailwind CSS classes for column widths. +// The index of the array corresponds to the percentage width of the column. +const COLUMN_WIDTHS = [ + 'md:w-0', + 'md:w-1/12', + 'md:w-2/12', + 'md:w-3/12', + 'md:w-4/12', + 'md:w-5/12', + 'md:w-6/12', + 'md:w-7/12', + 'md:w-8/12', + 'md:w-9/12', + 'md:w-10/12', + 'md:w-11/12', + 'md:w-full', +]; diff --git a/packages/gitbook/src/components/DocumentView/Columns/index.ts b/packages/gitbook/src/components/DocumentView/Columns/index.ts new file mode 100644 index 0000000000..a8b4f25b41 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Columns/index.ts @@ -0,0 +1 @@ +export * from './Columns'; diff --git a/packages/gitbook/src/components/DocumentView/Divider.tsx b/packages/gitbook/src/components/DocumentView/Divider.tsx index 8f18593e2f..f787d044b5 100644 --- a/packages/gitbook/src/components/DocumentView/Divider.tsx +++ b/packages/gitbook/src/components/DocumentView/Divider.tsx @@ -7,5 +7,5 @@ import type { BlockProps } from './Block'; export function Divider(props: BlockProps<DocumentBlockDivider>) { const { style } = props; - return <hr className={tcls(style, 'border-tint-subtle')} />; + return <hr className={tcls(style, 'page-full-width:max-w-full border-tint-subtle')} />; } diff --git a/packages/gitbook/src/components/DocumentView/DocumentView.tsx b/packages/gitbook/src/components/DocumentView/DocumentView.tsx index 724a7f2b05..1ff8542eed 100644 --- a/packages/gitbook/src/components/DocumentView/DocumentView.tsx +++ b/packages/gitbook/src/components/DocumentView/DocumentView.tsx @@ -28,6 +28,14 @@ export interface DocumentContext { * @default true */ wrapBlocksInSuspense?: boolean; + + /** + * True if link previews should be rendered. + * This is used to limit the number of link previews rendered in a document. + * If false, no link previews will be rendered. + * @default false + */ + shouldRenderLinkPreviews?: boolean; } export interface DocumentContextProps { diff --git a/packages/gitbook/src/components/DocumentView/Embed.tsx b/packages/gitbook/src/components/DocumentView/Embed.tsx index 796cdaeee3..17849c1958 100644 --- a/packages/gitbook/src/components/DocumentView/Embed.tsx +++ b/packages/gitbook/src/components/DocumentView/Embed.tsx @@ -6,6 +6,7 @@ import { Card } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; import { getDataOrNull } from '@v2/lib/data'; +import { Image } from '../utils'; import type { BlockProps } from './Block'; import { Caption } from './Caption'; import { IntegrationBlock } from './Integration'; @@ -52,7 +53,14 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) { <Card leadingIcon={ embed.icon ? ( - <img src={embed.icon} className={tcls('w-5', 'h-5')} alt="Logo" /> + <Image + src={embed.icon} + className={tcls('w-5', 'h-5')} + alt="Logo" + sources={{ light: { src: embed.icon } }} + sizes={[{ width: 20 }]} + resize={context.contentContext.imageResizer} + /> ) : null } href={block.data.url} diff --git a/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx new file mode 100644 index 0000000000..02f528c459 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/HashLinkButton.tsx @@ -0,0 +1,58 @@ +import { type ClassValue, tcls } from '@/lib/tailwind'; +import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; +import { getBlockTextStyle } from './spacing'; + +/** + * A hash icon which adds the block or active block item's ID in the URL hash. + * The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`. + */ +export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash'); + +export function HashLinkButton(props: { + id: string; + block: DocumentBlockTabs | DocumentBlockHeading; + label?: string; + className?: ClassValue; + iconClassName?: ClassValue; +}) { + const { id, block, className, iconClassName, label = 'Direct link to block' } = props; + const textStyle = getBlockTextStyle(block); + return ( + <div + className={tcls( + 'relative', + 'hash', + 'grid', + 'grid-area-1-1', + 'h-[1em]', + 'border-0', + 'opacity-0', + 'group-hover/hash:opacity-[0]', + 'group-focus/hash:opacity-[0]', + 'md:group-hover/hash:md:opacity-[1]', + 'md:group-focus/hash:md:opacity-[1]', + className + )} + > + <a + href={`#${id}`} + aria-label={label} + className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)} + > + <Icon + icon="hashtag" + className={tcls( + 'size-3', + 'self-center', + 'transition-colors', + 'text-transparent', + 'group-hover/hash:text-tint-subtle', + 'contrast-more:group-hover/hash:text-tint-strong', + iconClassName + )} + /> + </a> + </div> + ); +} diff --git a/packages/gitbook/src/components/DocumentView/Heading.tsx b/packages/gitbook/src/components/DocumentView/Heading.tsx index cb11038321..0de49e623f 100644 --- a/packages/gitbook/src/components/DocumentView/Heading.tsx +++ b/packages/gitbook/src/components/DocumentView/Heading.tsx @@ -1,9 +1,9 @@ import type { DocumentBlockHeading } from '@gitbook/api'; -import { Icon } from '@gitbook/icons'; import { tcls } from '@/lib/tailwind'; import type { BlockProps } from './Block'; +import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton'; import { Inlines } from './Inlines'; import { getBlockTextStyle } from './spacing'; @@ -23,50 +23,20 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) { className={tcls( textStyle.textSize, 'heading', - 'group', - 'relative', 'grid', 'scroll-m-12', + hashLinkButtonWrapperStyles, style )} > - <div - className={tcls( - 'hash', - 'grid', - 'grid-area-1-1', - 'relative', - '-ml-6', - 'w-7', - 'border-0', - 'opacity-0', - 'group-hover:opacity-[0]', - 'group-focus:opacity-[0]', - 'md:group-hover:md:opacity-[1]', - 'md:group-focus:md:opacity-[1]', - textStyle.marginTop - )} - > - <a - href={`#${id}`} - aria-label="Direct link to heading" - className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)} - > - <Icon - icon="hashtag" - className={tcls( - 'w-3.5', - 'h-[1em]', - 'mt-0.5', - 'transition-colors', - 'text-transparent', - 'group-hover:text-tint-subtle', - 'contrast-more:group-hover:text-tint-strong', - 'lg:w-4' - )} - /> - </a> - </div> + <HashLinkButton + id={id} + block={block} + className={tcls('-ml-6', textStyle.anchorButtonMarginTop)} + iconClassName={tcls('size-4')} + label="Direct link to heading" + /> + <div className={tcls( 'grid-area-1-1', diff --git a/packages/gitbook/src/components/DocumentView/Hint.tsx b/packages/gitbook/src/components/DocumentView/Hint.tsx index 59ff1e0ed7..79961ee172 100644 --- a/packages/gitbook/src/components/DocumentView/Hint.tsx +++ b/packages/gitbook/src/components/DocumentView/Hint.tsx @@ -23,6 +23,7 @@ export function Hint(props: BlockProps<DocumentBlockHint>) { 'rounded-md', hasHeading ? 'rounded-l' : null, 'straight-corners:rounded-none', + 'circular-corners:rounded-xl', 'overflow-hidden', hasHeading ? ['border-l-2', hintStyle.containerWithHeader] : hintStyle.container, diff --git a/packages/gitbook/src/components/DocumentView/Images.tsx b/packages/gitbook/src/components/DocumentView/Images.tsx index baaab50bdc..91d869002e 100644 --- a/packages/gitbook/src/components/DocumentView/Images.tsx +++ b/packages/gitbook/src/components/DocumentView/Images.tsx @@ -1,9 +1,4 @@ -import type { - DocumentBlockImage, - DocumentBlockImageDimension, - DocumentBlockImages, - JSONDocument, -} from '@gitbook/api'; +import type { DocumentBlockImage, DocumentBlockImages, JSONDocument, Length } from '@gitbook/api'; import { Image, type ImageResponsiveSize } from '@/components/utils'; import { resolveContentRef } from '@/lib/references'; @@ -29,7 +24,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) { align === 'center' && 'justify-center', align === 'right' && 'justify-end', align === 'left' && 'justify-start', - isMultipleImages && ['grid', 'grid-flow-col', 'max-w-none'] + isMultipleImages && ['grid', 'grid-flow-col'] )} > {block.nodes.map((node: any, _i: number) => ( @@ -119,7 +114,7 @@ async function ImageBlock(props: { * When using relative values, the converted dimension will be relative to the parent element's size. */ function getImageDimension<DefaultValue>( - dimension: DocumentBlockImageDimension | undefined, + dimension: Length | undefined, defaultValue: DefaultValue ): string | DefaultValue { if (typeof dimension === 'number') { diff --git a/packages/gitbook/src/components/DocumentView/Inline.tsx b/packages/gitbook/src/components/DocumentView/Inline.tsx index 9101ffef20..84b56decfc 100644 --- a/packages/gitbook/src/components/DocumentView/Inline.tsx +++ b/packages/gitbook/src/components/DocumentView/Inline.tsx @@ -1,20 +1,11 @@ -import type { - DocumentInline, - DocumentInlineAnnotation, - DocumentInlineButton, - DocumentInlineEmoji, - DocumentInlineImage, - DocumentInlineLink, - DocumentInlineMath, - DocumentInlineMention, - JSONDocument, -} from '@gitbook/api'; -import assertNever from 'assert-never'; +import type { DocumentInline, JSONDocument } from '@gitbook/api'; +import { nullIfNever } from '@/lib/typescript'; import { Annotation } from './Annotation/Annotation'; import type { DocumentContextProps } from './DocumentView'; import { Emoji } from './Emoji'; import { InlineButton } from './InlineButton'; +import { InlineIcon } from './InlineIcon'; import { InlineImage } from './InlineImage'; import { InlineLink } from './InlineLink'; import { InlineMath } from './Math'; @@ -39,16 +30,7 @@ export interface InlineProps<T extends DocumentInline> extends DocumentContextPr children?: React.ReactNode; } -export function Inline< - T extends - | DocumentInlineImage - | DocumentInlineAnnotation - | DocumentInlineEmoji - | DocumentInlineLink - | DocumentInlineMath - | DocumentInlineMention - | DocumentInlineButton, ->(props: InlineProps<T>) { +export function Inline<T extends DocumentInline>(props: InlineProps<T>) { const { inline, ...contextProps } = props; switch (inline.type) { @@ -66,7 +48,13 @@ export function Inline< return <InlineImage {...contextProps} inline={inline} />; case 'button': return <InlineButton {...contextProps} inline={inline} />; + case 'icon': + return <InlineIcon {...contextProps} inline={inline} />; + case 'expression': + // The GitBook API should take care of evaluating expressions. + // We should never need to render them. + return null; default: - assertNever(inline); + return nullIfNever(inline); } } diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx index 9841c02626..a36cd74527 100644 --- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx @@ -17,18 +17,22 @@ export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) } return ( - <Button - href={resolved.href} - label={inline.data.label} - // TODO: use a variant specifically for user-defined buttons. - variant={inline.data.kind} - insights={{ - type: 'link_click', - link: { - target: inline.data.ref, - position: api.SiteInsightsLinkPosition.Content, - }, - }} - /> + // Set the leading to have some vertical space between adjacent buttons + <span className="inline-button leading-[3rem] [&:has(+.inline-button)]:mr-2"> + <Button + href={resolved.href} + label={inline.data.label} + // TODO: use a variant specifically for user-defined buttons. + variant={inline.data.kind} + className="leading-normal" + insights={{ + type: 'link_click', + link: { + target: inline.data.ref, + position: api.SiteInsightsLinkPosition.Content, + }, + }} + /> + </span> ); } diff --git a/packages/gitbook/src/components/DocumentView/InlineIcon.tsx b/packages/gitbook/src/components/DocumentView/InlineIcon.tsx new file mode 100644 index 0000000000..0eca373f69 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/InlineIcon.tsx @@ -0,0 +1,10 @@ +import type { DocumentInlineIcon } from '@gitbook/api'; + +import { Icon, type IconName } from '@gitbook/icons'; +import type { InlineProps } from './Inline'; + +export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) { + const { inline } = props; + + return <Icon icon={inline.data.icon as IconName} className="inline size-[1em]" />; +} diff --git a/packages/gitbook/src/components/DocumentView/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink.tsx deleted file mode 100644 index 37a812bb3c..0000000000 --- a/packages/gitbook/src/components/DocumentView/InlineLink.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'; - -import { resolveContentRef } from '@/lib/references'; -import { Icon } from '@gitbook/icons'; -import { StyledLink } from '../primitives'; -import type { InlineProps } from './Inline'; -import { InlineLinkTooltip } from './InlineLinkTooltip'; -import { Inlines } from './Inlines'; - -export async function InlineLink(props: InlineProps<DocumentInlineLink>) { - const { inline, document, context, ancestorInlines } = props; - - const resolved = context.contentContext - ? await resolveContentRef(inline.data.ref, context.contentContext, { - resolveAnchorText: true, - }) - : null; - - if (!context.contentContext || !resolved) { - return ( - <span title="Broken link" className="underline"> - <Inlines - context={context} - document={document} - nodes={inline.nodes} - ancestorInlines={[...ancestorInlines, inline]} - /> - </span> - ); - } - const isExternal = inline.data.ref.kind === 'url'; - - return ( - <InlineLinkTooltip inline={inline} context={context.contentContext} resolved={resolved}> - <StyledLink - href={resolved.href} - insights={{ - type: 'link_click', - link: { - target: inline.data.ref, - position: SiteInsightsLinkPosition.Content, - }, - }} - > - <Inlines - context={context} - document={document} - nodes={inline.nodes} - ancestorInlines={[...ancestorInlines, inline]} - /> - {isExternal ? ( - <Icon - icon="arrow-up-right" - className="ml-0.5 inline size-3 links-accent:text-tint-subtle" - /> - ) : null} - </StyledLink> - </InlineLinkTooltip> - ); -} diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx new file mode 100644 index 0000000000..8ad287a351 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -0,0 +1,153 @@ +import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'; + +import { getSpaceLanguage, tString } from '@/intl/server'; +import { languages } from '@/intl/translations'; +import { type ResolvedContentRef, resolveContentRef } from '@/lib/references'; +import { Icon } from '@gitbook/icons'; +import type { GitBookAnyContext } from '@v2/lib/context'; +import { StyledLink } from '../../primitives'; +import type { InlineProps } from '../Inline'; +import { Inlines } from '../Inlines'; +import { InlineLinkTooltip } from './InlineLinkTooltip'; + +export async function InlineLink(props: InlineProps<DocumentInlineLink>) { + const { inline, document, context, ancestorInlines } = props; + + const resolved = context.contentContext + ? await resolveContentRef(inline.data.ref, context.contentContext, { + // We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link. + resolveAnchorText: false, + }) + : null; + + if (!context.contentContext || !resolved) { + return ( + <span title="Broken link" className="underline"> + <Inlines + context={context} + document={document} + nodes={inline.nodes} + ancestorInlines={[...ancestorInlines, inline]} + /> + </span> + ); + } + const isExternal = inline.data.ref.kind === 'url'; + const content = ( + <StyledLink + href={resolved.href} + insights={{ + type: 'link_click', + link: { + target: inline.data.ref, + position: SiteInsightsLinkPosition.Content, + }, + }} + > + <Inlines + context={context} + document={document} + nodes={inline.nodes} + ancestorInlines={[...ancestorInlines, inline]} + /> + {isExternal ? ( + <Icon + icon="arrow-up-right" + className="ml-0.5 inline size-3 links-accent:text-tint-subtle" + /> + ) : null} + </StyledLink> + ); + + if (context.shouldRenderLinkPreviews) { + return ( + <InlineLinkTooltipWrapper + inline={inline} + context={context.contentContext} + resolved={resolved} + > + {content} + </InlineLinkTooltipWrapper> + ); + } + + return content; +} + +/** + * An SSR component that renders a link with a tooltip. + * Essentially it pulls the minimum amount of props from the context to render the tooltip. + */ +function InlineLinkTooltipWrapper(props: { + inline: DocumentInlineLink; + context: GitBookAnyContext; + children: React.ReactNode; + resolved: ResolvedContentRef; +}) { + const { inline, context, resolved, children } = props; + + let breadcrumbs = resolved.ancestors ?? []; + const language = + 'customization' in context ? getSpaceLanguage(context.customization) : languages.en; + const isExternal = inline.data.ref.kind === 'url'; + const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined; + if (isExternal) { + breadcrumbs = [ + { + label: tString(language, 'link_tooltip_external_link'), + }, + ]; + } + if (isSamePage) { + breadcrumbs = [ + { + label: tString(language, 'link_tooltip_page_anchor'), + icon: <Icon icon="arrow-down-short-wide" className="size-3" />, + }, + ]; + resolved.subText = undefined; + } + + const aiSummary: { pageId: string; spaceId: string } | undefined = (() => { + if (isExternal) { + return; + } + + if (isSamePage) { + return; + } + + if (!('customization' in context) || !context.customization.ai?.pageLinkSummaries.enabled) { + return; + } + + if (!('page' in context) || !('page' in inline.data.ref)) { + return; + } + + if (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor') { + return { + pageId: resolved.page?.id ?? inline.data.ref.page ?? context.page.id, + spaceId: inline.data.ref.space ?? context.space.id, + }; + } + })(); + + return ( + <InlineLinkTooltip + breadcrumbs={breadcrumbs} + isExternal={isExternal} + isSamePage={isSamePage} + aiSummary={aiSummary} + openInNewTabLabel={tString(language, 'open_in_new_tab')} + target={{ + href: resolved.href, + text: resolved.text, + subText: resolved.subText, + icon: resolved.icon, + }} + > + {children} + </InlineLinkTooltip> + ); +} diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx new file mode 100644 index 0000000000..45be0c7173 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx @@ -0,0 +1,73 @@ +'use client'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +const LoadingValueContext = React.createContext<React.ReactNode>(null); + +// To avoid polluting the RSC payload with the tooltip implementation, +// we lazily load it on the client side. This way, the tooltip is only loaded +// when the user interacts with the link, and it doesn't block the initial render. + +const InlineLinkTooltipImpl = dynamic( + () => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl), + { + // Disable server-side rendering for this component, it's only + // visible on user interaction. + ssr: false, + loading: () => { + // The fallback should be the children (the content of the link), + // but as next/dynamic is aiming for feature parity with React.lazy, + // it doesn't support passing children to the loading component. + // https://github.com/vercel/next.js/issues/7906 + const children = React.useContext(LoadingValueContext); + return <>{children}</>; + }, + } +); + +/** + * Tooltip for inline links. It's lazily loaded to avoid blocking the initial render + * and polluting the RSC payload. + * + * The link text and href have already been rendered on the server for good SEO, + * so we can be as lazy as possible with the tooltip. + */ +export function InlineLinkTooltip(props: { + isSamePage: boolean; + isExternal: boolean; + aiSummary?: { pageId: string; spaceId: string }; + breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>; + target: { + href: string; + text: string; + subText?: string; + icon?: React.ReactNode; + }; + openInNewTabLabel: string; + children: React.ReactNode; +}) { + const { children, ...rest } = props; + const [shouldLoad, setShouldLoad] = React.useState(false); + + // Once the browser is idle, we set shouldLoad to true. + // NOTE: to be slightly more performant, we could load when a link is hovered. + // But I found this was too much of a delay for the tooltip to appear. + // Loading on idle is a good compromise, as it allows the initial render to be fast, + // while still loading the tooltip in the background and not polluting the RSC payload. + React.useEffect(() => { + if ('requestIdleCallback' in window) { + (window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true)); + } else { + // fallback for old browsers + setTimeout(() => setShouldLoad(true), 2000); + } + }, []); + + return shouldLoad ? ( + <LoadingValueContext.Provider value={children}> + <InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl> + </LoadingValueContext.Provider> + ) : ( + children + ); +} diff --git a/packages/gitbook/src/components/DocumentView/InlineLinkTooltip.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx similarity index 64% rename from packages/gitbook/src/components/DocumentView/InlineLinkTooltip.tsx rename to packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx index 1a5cefa1e3..333184a2cd 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLinkTooltip.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx @@ -1,55 +1,27 @@ -import type { DocumentInlineLink } from '@gitbook/api'; - -import type { ResolvedContentRef } from '@/lib/references'; - -import { getSpaceLanguage } from '@/intl/server'; -import { tString } from '@/intl/translate'; -import { languages } from '@/intl/translations'; -import { getNodeText } from '@/lib/document'; +'use client'; import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import * as Tooltip from '@radix-ui/react-tooltip'; -import type { GitBookAnyContext } from '@v2/lib/context'; import { Fragment } from 'react'; -import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary'; -import { Button, StyledLink } from '../primitives'; +import { AIPageLinkSummary } from '../../Adaptive'; +import { Button, StyledLink } from '../../primitives'; -export async function InlineLinkTooltip(props: { - inline: DocumentInlineLink; - context: GitBookAnyContext; +export function InlineLinkTooltipImpl(props: { + isSamePage: boolean; + isExternal: boolean; + aiSummary?: { pageId: string; spaceId: string }; + breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>; + target: { + href: string; + text: string; + subText?: string; + icon?: React.ReactNode; + }; + openInNewTabLabel: string; children: React.ReactNode; - resolved: ResolvedContentRef; }) { - const { inline, context, resolved, children } = props; - - let breadcrumbs = resolved.ancestors; - const language = - 'customization' in context ? getSpaceLanguage(context.customization) : languages.en; - const isExternal = inline.data.ref.kind === 'url'; - const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined; - if (isExternal) { - breadcrumbs = [ - { - label: tString(language, 'link_tooltip_external_link'), - }, - ]; - } - if (isSamePage) { - breadcrumbs = [ - { - label: tString(language, 'link_tooltip_page_anchor'), - icon: <Icon icon="arrow-down-short-wide" className="size-3" />, - }, - ]; - resolved.subText = undefined; - } - - const hasAISummary = - !isExternal && - !isSamePage && - 'customization' in context && - context.customization.ai?.pageLinkSummaries.enabled && - (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor'); + const { isSamePage, isExternal, aiSummary, openInNewTabLabel, target, breadcrumbs, children } = + props; return ( <Tooltip.Provider delayDuration={200}> @@ -100,15 +72,15 @@ export async function InlineLinkTooltip(props: { isExternal && 'text-sm [overflow-wrap:anywhere]' )} > - {resolved.icon ? ( + {target.icon ? ( <div className="mt-1 text-tint-subtle empty:hidden"> - {resolved.icon} + {target.icon} </div> ) : null} - <h5 className="font-semibold">{resolved.text}</h5> + <h5 className="font-semibold">{target.text}</h5> </div> </div> - {!isSamePage && resolved.href ? ( + {!isSamePage && target.href ? ( <Button className={tcls( '-mx-2 -my-2 ml-auto', @@ -117,40 +89,35 @@ export async function InlineLinkTooltip(props: { : null )} variant="blank" - href={resolved.href} + href={target.href} target="_blank" - label={tString(language, 'open_in_new_tab')} + label={openInNewTabLabel} size="small" icon="arrow-up-right-from-square" iconOnly={true} /> ) : null} </div> - {resolved.subText ? ( - <p className="mt-1 text-sm text-tint">{resolved.subText}</p> + {target.subText ? ( + <p className="mt-1 text-sm text-tint">{target.subText}</p> ) : null} </div> - {hasAISummary && 'page' in context && 'page' in inline.data.ref ? ( + {aiSummary ? ( <div className="border-tint-subtle border-t bg-tint p-4"> <AIPageLinkSummary - targetPageId={ - resolved.page?.id ?? - inline.data.ref.page ?? - context.page.id - } - targetSpaceId={inline.data.ref.space ?? context.space.id} - linkTitle={getNodeText(inline)} - linkPreview={`**${resolved.text}**: ${resolved.subText}`} - showTrademark={ - 'customization' in context && - context.customization.trademark.enabled - } + targetPageId={aiSummary.pageId} + targetSpaceId={aiSummary.spaceId} + showTrademark /> </div> ) : null} </div> - <Tooltip.Arrow className={hasAISummary ? 'fill-tint-3' : 'fill-tint-1'} /> + <Tooltip.Arrow + className={ + typeof aiSummary !== 'undefined' ? 'fill-tint-3' : 'fill-tint-1' + } + /> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/index.ts b/packages/gitbook/src/components/DocumentView/InlineLink/index.ts new file mode 100644 index 0000000000..1d7f30120b --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/InlineLink/index.ts @@ -0,0 +1 @@ +export * from './InlineLink'; diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 6ebec6f030..a26aa3bfd2 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -5,8 +5,8 @@ import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env'; import type { BlockProps } from '../Block'; import './contentkit.css'; -import { getDataOrNull } from '@v2/lib/data'; import { contentKitServerContext } from './contentkit'; +import { fetchSafeIntegrationUI } from './render'; import { renderIntegrationUi } from './server-actions'; export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) { @@ -16,8 +16,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio throw new Error('integration block requires a content.spaceId'); } - const { dataFetcher } = context.contentContext; - const initialInput: RenderIntegrationUI = { componentId: block.data.block, props: block.data.props, @@ -30,17 +28,27 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio }, }; - const initialOutput = await getDataOrNull( - dataFetcher.renderIntegrationUi({ - integrationName: block.data.integration, - request: initialInput, - }), + const initialResponse = await fetchSafeIntegrationUI(context.contentContext, { + integrationName: block.data.integration, + request: initialInput, + }); - // The API can respond with a 400 error if the integration is not installed - // and 404 if the integration is not found. - [404, 400] - ); - if (!initialOutput || initialOutput.type === 'complete') { + if (initialResponse.error) { + if (initialResponse.error.code === 404) { + return null; + } + + return ( + <div className={tcls(style)}> + <pre> + Unexpected error with integration {block.data.integration}:{' '} + {initialResponse.error.message} + </pre> + </div> + ); + } + const initialOutput = initialResponse.data; + if (initialOutput.type === 'complete') { return null; } diff --git a/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx b/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx index fb692c6824..155077ca25 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx @@ -20,6 +20,8 @@ export const contentKitServerContext: ContentKitServerContext = { 'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />, eye: (props) => <Icon icon="eye" {...props} />, lock: (props) => <Icon icon="lock" {...props} />, + check: (props) => <Icon icon="check" {...props} />, + 'check-circle': (props) => <Icon icon="check-circle" {...props} />, }, codeBlock: (props) => { return <PlainCodeBlock code={props.code} syntax={props.syntax} />; diff --git a/packages/gitbook/src/components/DocumentView/Integration/render.ts b/packages/gitbook/src/components/DocumentView/Integration/render.ts new file mode 100644 index 0000000000..4e683e0dcd --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/render.ts @@ -0,0 +1,34 @@ +import type { RenderIntegrationUI } from '@gitbook/api'; +import type { GitBookBaseContext } from '@v2/lib/context'; +import { ignoreDataFetcherErrors } from '@v2/lib/data'; + +/** + * Render an integration UI while ignoring some errors. + */ +export async function fetchSafeIntegrationUI( + context: GitBookBaseContext, + { + integrationName, + request, + }: { + integrationName: string; + request: RenderIntegrationUI; + } +) { + const output = await ignoreDataFetcherErrors( + context.dataFetcher.renderIntegrationUi({ + integrationName, + request, + }), + + // The API can respond with certain errors that are expected to happen. + [ + 404, // Integration has been uninstalled + 400, // Integration is rejecting its own request + 422, // Integration is triggering an invalid request, failing at the validation step + 502, // Integration is failing in an unexpected way + ] + ); + + return output; +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx b/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx index 54180e5c89..91d1e0e73c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx @@ -4,9 +4,9 @@ import { getV1BaseContext } from '@/lib/v1'; import { isV2 } from '@/lib/v2'; import type { RenderIntegrationUI } from '@gitbook/api'; import { ContentKitOutput } from '@gitbook/react-contentkit'; -import { throwIfDataError } from '@v2/lib/data'; import { getServerActionBaseContext } from '@v2/lib/server-actions'; import { contentKitServerContext } from './contentkit'; +import { fetchSafeIntegrationUI } from './render'; /** * Server action to render an integration UI request from <ContentKit />. @@ -22,16 +22,19 @@ export async function renderIntegrationUi({ request: RenderIntegrationUI; }) { const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const output = await fetchSafeIntegrationUI(serverAction, { + integrationName: renderContext.integrationName, + request, + }); - const output = await throwIfDataError( - serverAction.dataFetcher.renderIntegrationUi({ - integrationName: renderContext.integrationName, - request, - }) - ); + if (output.error) { + return { + error: output.error.message, + }; + } return { - children: <ContentKitOutput output={output} context={contentKitServerContext} />, - output: output, + children: <ContentKitOutput output={output.data} context={contentKitServerContext} />, + output: output.data, }; } diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css index 8096a9fbef..b7eec226f9 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css @@ -271,7 +271,7 @@ body { } .scalar-activate-button { @apply flex gap-2 items-center; - @apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none place-self-start; + @apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start; @apply ring-1 ring-tint hover:ring-tint-hover; @apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none; @apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12; diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index 39cccc4b38..337f0b2ed2 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -6,7 +6,7 @@ } .openapi-schemas { - @apply flex flex-col mb-14 flex-1; + @apply flex flex-col mb-14 gap-0 flex-1; } .openapi-schemas-title { @@ -145,7 +145,7 @@ } .openapi-column-preview-body { - @apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static; + @apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header:xl:max-2xl:top-32 site-header-sections:top-32 site-header-sections:xl:max-2xl:top-44 print-mode:static; } .openapi-column-preview pre { @@ -169,41 +169,18 @@ @apply flex flex-col; } -.openapi-schema { +.openapi-schema, +.openapi-disclosure { @apply py-2.5 flex flex-col gap-2; } -.openapi-section-body .openapi-schema-properties { - @apply divide-y divide-tint-subtle; -} - -.openapi-disclosure-group-panel > .openapi-schema-properties > *:first-child > .openapi-schema { - @apply pt-0; -} - -.openapi-responsebody > .openapi-schema-properties > .openapi-schema:last-child { - @apply pb-0; -} - -.openapi-responsebody > .openapi-schema-properties > .openapi-schema:only-child { - @apply py-0; -} - .openapi-schema-properties .openapi-schema:last-child { @apply border-b-0; } -.openapi-schema-properties .openapi-schema-opened { - @apply pb-3; -} - -.openapi-schema > .openapi-schema-properties { - @apply mt-3; -} - /* Schema Presentation */ .openapi-schema-presentation { - @apply flex flex-col gap-1.5 font-normal; + @apply flex flex-col gap-1 font-normal; } .openapi-schema-properties:last-child { @@ -221,7 +198,7 @@ } .openapi-schema-propertyname { - @apply select-all font-mono font-normal text-tint-strong; + @apply select-all font-mono font-semibold text-tint-strong; } .openapi-schema-propertyname[data-deprecated="true"] { @@ -233,7 +210,7 @@ } .openapi-schema-optional { - @apply text-info-subtle text-[0.813rem] lowercase; + @apply text-tint-subtle text-[0.813rem] lowercase; } .openapi-schema-readonly { @@ -244,6 +221,10 @@ @apply text-success dark:text-success-subtle/9 text-[0.813rem] lowercase; } +.openapi-schema-types { + @apply flex items-baseline flex-wrap gap-1; +} + .openapi-schema-type { @apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem]; } @@ -321,7 +302,7 @@ .openapi-schema-pattern code, .openapi-schema-enum-value code, .openapi-schema-default code { - @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none; + @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none; } /* Authentication */ @@ -329,6 +310,26 @@ @apply py-2 border-b border-tint-subtle max-w-full flex-1; } +.openapi-securities-oauth-flows { + @apply flex flex-col gap-2 divide-y divide-tint-subtle; +} + +.openapi-securities-oauth-content { + @apply prose *:!prose-sm *:text-tint; +} + +.openapi-securities-oauth-content.openapi-markdown code { + @apply text-xs; +} + +.openapi-securities-oauth-content ul { + @apply !my-0; +} + +.openapi-securities-url { + @apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors; +} + .openapi-securities-body { @apply flex flex-col gap-2; } @@ -373,28 +374,25 @@ } .openapi-response-tab-content { - @apply overflow-hidden max-w-full flex items-baseline; - @apply text-left text-pretty relative leading-[1.125rem] text-tint !font-normal truncate select-text; + @apply flex items-baseline truncate grow shrink max-w-max basis-[60%] mr-auto; + @apply text-left text-pretty relative leading-tight text-tint select-text; } .openapi-response-description.openapi-markdown { - @apply text-left prose-sm text-[0.813rem] text-pretty h-auto relative leading-[1.125rem] text-tint !font-normal truncate select-text prose-strong:font-semibold prose-strong:text-inherit; -} - -.openapi-response-description.openapi-markdown::-webkit-scrollbar { - display: none; + @apply text-left truncate prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit; } -.openapi-response-description p { - @apply truncate max-w-full inline pr-1; +.openapi-disclosure-group-trigger[aria-expanded="true"] .openapi-response-tab-content { + @apply basis-full; } -.openapi-response-content-type { - @apply text-xs text-tint-8 ml-auto shrink-0; +.openapi-disclosure-group-trigger[aria-expanded="true"] + .openapi-response-description.openapi-markdown { + @apply whitespace-normal; } .openapi-response-body { - @apply flex flex-col gap-3; + @apply flex flex-col; } /* Response Body and Headers */ @@ -411,16 +409,7 @@ @apply px-3 py-1; } -.openapi-responsebody-header-content, -.openapi-responseheaders-header-content { - /* unstyled */ -} - /* Code Sample */ -.openapi-codesample { - @apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle; -} - .openapi-codesample-header { @apply flex flex-row items-center; } @@ -429,6 +418,12 @@ @apply flex flex-row items-center gap-2.5; } +.openapi-panel-heading, +.openapi-codesample-header, +.openapi-response-examples-header { + @apply border-b border-tint-subtle; +} + .openapi-response-examples-header .openapi-select > button { @apply max-w-full overflow-hidden shrink pl-0.5 py-0.5; } @@ -503,8 +498,16 @@ } /* Panel */ -.openapi-panel { - @apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle; +.openapi-panel, +.openapi-codesample, +.openapi-response-examples { + @apply border rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; +} + +.openapi-panel pre, +.openapi-codesample pre, +.openapi-response-examples pre { + @apply bg-transparent border-none rounded-none shadow-none; } .openapi-panel-heading { @@ -513,12 +516,11 @@ .openapi-panel-body { @apply relative; - @apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10; } .openapi-panel-footer, .openapi-codesample-footer { - @apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint; + @apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint empty:hidden; } .openapi-panel-footer .openapi-markdown { @@ -526,10 +528,6 @@ } /* Example */ -.openapi-response-examples { - @apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle; -} - .openapi-response-examples-header { @apply flex flex-row items-center p-2.5; } @@ -551,7 +549,6 @@ .openapi-response-examples-panel, .openapi-codesample-panel { @apply flex-1 text-sm relative focus-visible:outline-none; - @apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10; } .openapi-example-empty { @@ -594,7 +591,7 @@ body:has(.openapi-select-popover) { } .openapi-select-popover { - @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none; + @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md circular-corners:rounded-xl straight-corners:rounded-none; @apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1; } @@ -703,27 +700,31 @@ body:has(.openapi-select-popover) { /* Disclosure group */ .openapi-disclosure-group { - @apply border-tint-subtle border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none first:rounded-t-md straight-corners:first:rounded-none first:border-t relative; + @apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none circular-corners:last:rounded-b-xl first:rounded-t-md straight-corners:first:rounded-none circular-corners:first:rounded-t-xl first:border-t relative; +} + +.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) { + @apply bg-tint-subtle; } -.openapi-disclosure-group-header { - @apply flex flex-row items-baseline justify-between gap-3 relative; +.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover):has(.openapi-select:hover) { + @apply !bg-transparent; } .openapi-disclosure-group-trigger { - @apply flex items-baseline transition-all hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1; + @apply flex w-full cursor-pointer items-baseline gap-3 transition-all relative flex-1 p-3 -outline-offset-1; } -.openapi-disclosure-group-trigger:disabled { - @apply cursor-default hover:bg-inherit; +.openapi-disclosure-group-label { + @apply flex flex-wrap items-baseline gap-x-3 gap-y-1 flex-1 truncate; } -.openapi-disclosure-group-trigger:disabled .openapi-disclosure-group-icon { - @apply invisible; +.openapi-disclosure-group-trigger[aria-disabled="true"] { + @apply cursor-default hover:bg-inherit; } -.openapi-disclosure-group-trigger[aria-expanded="true"] .openapi-response-description { - @apply whitespace-normal; +.openapi-disclosure-group-trigger[aria-disabled="true"] .openapi-disclosure-group-icon { + @apply invisible; } .openapi-disclosure-group-icon > svg { @@ -735,29 +736,24 @@ body:has(.openapi-select-popover) { } .openapi-disclosure-group-panel { - @apply p-3 pt-1 transition-all; + @apply px-3 transition-all; } .openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg { @apply rotate-90; } -.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype, -.openapi-disclosure-group-mediatype:has(> .openapi-select[data-open="true"]) { - @apply opacity-11 visible flex; -} - -.openapi-disclosure-group-mediatype { - @apply opacity-0 invisible text-xs transition-opacity duration-300 shrink-0 absolute right-2.5 top-2.5; +.openapi-disclosure-group-mediatype:not(:has(.openapi-select)) { + @apply text-[0.625rem] font-mono shrink-0 grow-0 text-tint-subtle contrast-more:text-tint; } -.openapi-disclosure-group-mediatype > span { - @apply px-1 bg-tint-6 text-tint-12 rounded-full straight-corners:rounded-md; +/* Disclosure */ +.openapi-schemas-disclosure { + @apply border-t border-x last:border-b border-tint-subtle !ring-0 first:!rounded-t-xl last:!rounded-b-xl !rounded-none; } -/* Disclosure */ .openapi-schemas-disclosure > .openapi-disclosure-trigger { - @apply flex items-center font-mono !w-full transition-all text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1; + @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-5 truncate -outline-offset-1; } .openapi-schemas-disclosure > .openapi-disclosure-trigger, @@ -765,49 +761,94 @@ body:has(.openapi-select-popover) { @apply straight-corners:!rounded-none; } +.openapi-disclosure-panel { + @apply ml-1.5 pl-3 border-l border-tint-subtle; +} + +.openapi-schema .openapi-schema-properties .openapi-schema { + @apply animate-fadeIn [animation-fill-mode:both]; +} + .openapi-schemas-disclosure > .openapi-disclosure-trigger[aria-expanded="true"] > svg { @apply rotate-90; } .openapi-disclosure-trigger { - @apply transition-all truncate duration-300 max-w-full hover:text-tint-strong rounded-2xl straight-corners:rounded border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1; + @apply flex flex-row justify-between flex-wrap relative items-start gap-2 text-left -mx-3 px-3 -my-2.5 py-2.5 pr-10; } -.openapi-disclosure-trigger span { - @apply truncate; +.openapi-disclosure { + @apply -mx-3 px-3 py-2.5 transition-all flex flex-col ring-tint-subtle; } -.openapi-disclosure svg { - @apply size-3 shrink-0 transition-transform duration-300; +.openapi-disclosure:not( + .openapi-disclosure-group .openapi-disclosure, + .openapi-schema-alternatives .openapi-disclosure, + .openapi-schemas-disclosure .openapi-schema.openapi-disclosure + ) { + @apply rounded-xl; } -.openapi-disclosure-trigger[aria-expanded="true"] svg { - @apply rotate-45; +.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure { + @apply !rounded-none; } -.openapi-disclosure-trigger[aria-expanded="true"] { - @apply w-full rounded-lg border-b rounded-b-none straight-corners:rounded-b-none; +.openapi-disclosure:has(> .openapi-disclosure-trigger:hover) { + @apply bg-tint-subtle overflow-hidden; } -.openapi-disclosure-trigger[aria-expanded="false"] { - @apply w-auto; +.openapi-disclosure[data-expanded="true"] { + @apply ring-1 shadow-sm; } -.openapi-disclosure-panel[aria-hidden="false"] { - @apply border-b border-x border-tint-subtle rounded-b-lg straight-corners:rounded-b; +.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) { + @apply mt-2; +} +.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) { + @apply mb-2; } -.openapi-disclosure-panel .openapi-schema { - @apply p-2.5; +.openapi-disclosure-trigger-label { + @apply absolute right-3 px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs; } -.openapi-disclosure .openapi-schema-properties .openapi-schema:only-child, -.openapi-disclosure .openapi-schema-properties .openapi-schema:only-child .openapi-schema-name { - @apply !m-0; +.openapi-disclosure-trigger-label span { + @apply hidden; } -.openapi-disclosure .openapi-schema-properties .openapi-schema-enum { - @apply pt-0 mt-0; +.openapi-disclosure-trigger-label svg { + @apply size-3 shrink-0 transition-transform duration-300 text-tint-subtle; +} + +.openapi-disclosure-trigger:hover > .openapi-disclosure-trigger-label, +.openapi-disclosure-trigger[aria-expanded="true"] > .openapi-disclosure-trigger-label { + @apply shadow ring-1 bg-tint-base; +} + +.openapi-disclosure-trigger:hover > .openapi-disclosure-trigger-label span, +.openapi-disclosure-trigger[aria-expanded="true"] > .openapi-disclosure-trigger-label span { + @apply block animate-fadeIn; +} + +@media (hover: none) { + /* Make button label always visible on non-hover devices like phones */ + .openapi-disclosure-trigger-label { + @apply relative ring-1 bg-tint-base right-0; + } + .openapi-disclosure-trigger-label span { + @apply block; + } + .openapi-disclosure-trigger { + @apply pr-3; + } +} + +.openapi-disclosure-trigger[aria-expanded="true"] svg { + @apply rotate-45; +} + +.openapi-disclosure-trigger[aria-expanded="false"] { + @apply w-auto; } .openapi-section-body.openapi-schema.openapi-schema-root { @@ -819,8 +860,18 @@ body:has(.openapi-select-popover) { @apply p-2.5; } +.openapi-schema-alternatives { + @apply ml-1.5 pl-3 border-l border-tint-subtle; +} +.openapi-schema-alternative { + @apply relative; +} +.openapi-schema-alternative-separator { + @apply p-0.5 tracking-wide leading-none uppercase text-[0.625rem] text-tint-subtle whitespace-nowrap absolute -left-3 -bottom-2.5 -translate-x-1/2 z-10 bg-tint-base border-y border-tint-subtle -rotate-6; +} + .openapi-tooltip { - @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px]; + @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg font-medium px-1.5 py-0.5 shadow-sm text-[13px]; } .openapi-tooltip svg { @@ -865,11 +916,11 @@ body:has(.openapi-select-popover) { } @keyframes popover-leave { - 0% { + from { opacity: 1; transform: translateY(0) scale(1); } - 100% { + to { opacity: 0; transform: translateY(4px) scale(0.95); } diff --git a/packages/gitbook/src/components/DocumentView/ReusableContent.tsx b/packages/gitbook/src/components/DocumentView/ReusableContent.tsx index e95ffc7545..ccb66babd4 100644 --- a/packages/gitbook/src/components/DocumentView/ReusableContent.tsx +++ b/packages/gitbook/src/components/DocumentView/ReusableContent.tsx @@ -2,6 +2,7 @@ import type { DocumentBlockReusableContent } from '@gitbook/api'; import { resolveContentRef } from '@/lib/references'; +import type { GitBookSpaceContext } from '@v2/lib/context'; import { getDataOrNull } from '@v2/lib/data'; import type { BlockProps } from './Block'; import { UnwrappedBlocks } from './Blocks'; @@ -13,15 +14,28 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon throw new Error('Expected a content context to render a reusable content block'); } - const resolved = await resolveContentRef(block.data.ref, context.contentContext); - if (!resolved?.reusableContent?.document) { + const dataFetcher = block.meta?.token + ? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token }) + : context.contentContext.dataFetcher; + + const resolved = await resolveContentRef(block.data.ref, { + ...context.contentContext, + dataFetcher, + }); + + if (!resolved?.reusableContent) { + return null; + } + + const reusableContent = resolved.reusableContent.revisionReusableContent; + if (!reusableContent.document) { return null; } const document = await getDataOrNull( - context.contentContext.dataFetcher.getDocument({ - spaceId: context.contentContext.space.id, - documentId: resolved.reusableContent.document, + dataFetcher.getDocument({ + spaceId: resolved.reusableContent.space.id, + documentId: reusableContent.document, }) ); @@ -29,12 +43,34 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon return null; } + // Create a new context for reusable content block, including + // the data fetcher with the token from the block meta and the correct + // space and revision pointers. + const reusableContentContext: GitBookSpaceContext = + context.contentContext.space.id === resolved.reusableContent.space.id + ? context.contentContext + : { + ...context.contentContext, + dataFetcher, + space: resolved.reusableContent.space, + revisionId: resolved.reusableContent.revision, + // When the reusable content is in a different space, we don't resolve relative links to pages + // as this space might not be part of the current site. + // In the future, we might expand the logic to look up the space from the list of all spaces in the site + // and adapt the relative links to point to the correct variant. + pages: [], + shareKey: undefined, + }; + return ( <UnwrappedBlocks nodes={document.nodes} document={document} ancestorBlocks={[...ancestorBlocks, block]} - context={context} + context={{ + ...context, + contentContext: reusableContentContext, + }} /> ); } diff --git a/packages/gitbook/src/components/DocumentView/StepperStep.tsx b/packages/gitbook/src/components/DocumentView/StepperStep.tsx index 46e70ed603..5c7456ad60 100644 --- a/packages/gitbook/src/components/DocumentView/StepperStep.tsx +++ b/packages/gitbook/src/components/DocumentView/StepperStep.tsx @@ -34,13 +34,13 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) { <div className="relative select-none"> <div className={tcls( - 'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-subtle tabular-nums', - 'font-medium text-primary' + 'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-solid theme-muted:bg-primary-subtle tabular-nums', + 'font-medium text-contrast-primary-solid theme-muted:text-primary' )} > {index + 1} </div> - <div className="can-override-bg absolute top-9 bottom-2 left-[0.875rem] w-px bg-primary-subtle" /> + <div className="can-override-bg absolute top-9 bottom-2 left-[0.875rem] w-px bg-primary-7 theme-muted:bg-primary-subtle" /> </div> <Blocks {...contextProps} diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx index 2a246d13c6..49aa7a1bc5 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx @@ -4,13 +4,14 @@ import { SiteInsightsLinkPosition, } from '@gitbook/api'; -import { Link } from '@/components/primitives'; +import { LinkBox, LinkOverlay } from '@/components/primitives'; import { Image } from '@/components/utils'; import { resolveContentRef } from '@/lib/references'; -import { type ClassValue, tcls } from '@/lib/tailwind'; +import { tcls } from '@/lib/tailwind'; import { RecordColumnValue } from './RecordColumnValue'; import type { TableRecordKV, TableViewProps } from './Table'; +import { RecordCardStyles } from './styles'; import { getRecordValue } from './utils'; export async function RecordCard( @@ -23,18 +24,18 @@ export async function RecordCard( const coverFile = view.coverDefinition ? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0] : null; - const cover = - coverFile && context.contentContext - ? await resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext) - : null; - const targetRef = view.targetDefinition ? (record[1].values[view.targetDefinition] as ContentRef) : null; - const target = + + const [cover, target] = await Promise.all([ + coverFile && context.contentContext + ? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext) + : null, targetRef && context.contentContext - ? await resolveContentRef(targetRef, context.contentContext) - : null; + ? resolveContentRef(targetRef, context.contentContext) + : null, + ]); const coverIsSquareOrPortrait = cover?.file?.dimensions && @@ -44,15 +45,15 @@ export async function RecordCard( <div className={tcls( 'grid-area-1-1', - 'z-0', 'relative', 'grid', 'bg-tint-base', 'w-[calc(100%+2px)]', 'h-[calc(100%+2px)]', 'inset-[-1px]', - 'rounded-[7px]', + 'rounded', 'straight-corners:rounded-none', + 'circular-corners:rounded-xl', 'overflow-hidden', '[&_.heading>div:first-child]:hidden', '[&_.heading>div]:text-[.8em]', @@ -143,45 +144,26 @@ export async function RecordCard( </div> ); - const style = [ - 'group', - 'grid', - 'shadow-1xs', - 'shadow-tint-9/1', - 'rounded-md', - 'straight-corners:rounded-none', - 'dark:shadow-transparent', - 'z-0', - - 'before:pointer-events-none', - 'before:grid-area-1-1', - 'before:transition-shadow', - 'before:w-full', - 'before:h-full', - 'before:rounded-[inherit]', - 'before:ring-1', - 'before:ring-tint-12/2', - 'before:z-10', - 'before:relative', - ] as ClassValue; - if (target && targetRef) { return ( - <Link - href={target.href} - className={tcls(style, 'hover:before:ring-tint-12/5')} - insights={{ - type: 'link_click', - link: { - target: targetRef, - position: SiteInsightsLinkPosition.Content, - }, - }} - > + // We don't use `Link` directly here because we could end up in a situation where + // a link is rendered inside a link, which is not allowed in HTML. + // It causes an hydration error in React. + <LinkBox href={target.href} classNames={['RecordCardStyles']}> + <LinkOverlay + href={target.href} + insights={{ + type: 'link_click', + link: { + target: targetRef, + position: SiteInsightsLinkPosition.Content, + }, + }} + /> {body} - </Link> + </LinkBox> ); } - return <div className={tcls(style)}>{body}</div>; + return <div className={tcls(RecordCardStyles)}>{body}</div>; } diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index d5ddcdc491..50dc0d8270 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -115,7 +115,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>( return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>; } - const alignment = getColumnAlignment(definition); + const horizontalAlignment = getColumnAlignment(definition); + const childrenHorizontalAlignment = `[&_*]:${horizontalAlignment}`; return ( <Blocks @@ -130,8 +131,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>( 'lg:space-y-3', 'leading-normal', verticalAlignment, - alignment === 'right' ? 'text-right' : null, - alignment === 'center' ? 'text-center' : null, + horizontalAlignment, + childrenHorizontalAlignment, ]} context={context} blockStyle={['w-full', 'max-w-[unset]']} diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx index e39f386cc5..b09760ceb2 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx @@ -15,14 +15,14 @@ export function RecordRow( fixedColumns: string[]; } ) { - const { view, autoSizedColumns, fixedColumns, block } = props; + const { view, autoSizedColumns, fixedColumns, block, context } = props; return ( <div className={styles.row} role="row"> {view.columns.map((column) => { const columnWidth = getColumnWidth({ column, - columnWidths: view.columnWidths, + columnWidths: context.mode === 'print' ? undefined : view.columnWidths, autoSizedColumns, fixedColumns, }); diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx index 65bae8e0b8..606b2305b5 100644 --- a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx @@ -13,10 +13,10 @@ import { getColumnAlignment } from './utils'; 3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px */ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) { - const { block, view, records, style } = props; + const { block, view, records, style, context } = props; /* Calculate how many columns are auto-sized vs fixed width */ - const columnWidths = view.columnWidths; + const columnWidths = context.mode === 'print' ? undefined : view.columnWidths; const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]); const fixedColumns = view.columns.filter((column) => columnWidths?.[column]); @@ -38,36 +38,33 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) { className={tcls( tableWidth, styles.rowGroup, - 'straight-corners:rounded-none' + 'straight-corners:rounded-none', + 'circular-corners:rounded-xl' )} > <div role="row" className={tcls('flex', 'w-full')}> - {view.columns.map((column) => { - const alignment = getColumnAlignment(block.data.definition[column]); - return ( - <div - key={column} - role="columnheader" - className={tcls( - styles.columnHeader, - alignment === 'right' ? 'text-right' : null, - alignment === 'center' ? 'text-center' : null - )} - style={{ - width: getColumnWidth({ - column, - columnWidths, - autoSizedColumns, - fixedColumns, - }), - minWidth: columnWidths?.[column] || '100px', - }} - title={block.data.definition[column].title} - > - {block.data.definition[column].title} - </div> - ); - })} + {view.columns.map((column) => ( + <div + key={column} + role="columnheader" + className={tcls( + styles.columnHeader, + getColumnAlignment(block.data.definition[column]) + )} + style={{ + width: getColumnWidth({ + column, + columnWidths, + autoSizedColumns, + fixedColumns, + }), + minWidth: columnWidths?.[column] || '100px', + }} + title={block.data.definition[column].title} + > + {block.data.definition[column].title} + </div> + ))} </div> </div> )} diff --git a/packages/gitbook/src/components/DocumentView/Table/styles.ts b/packages/gitbook/src/components/DocumentView/Table/styles.ts new file mode 100644 index 0000000000..3e33b06837 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/styles.ts @@ -0,0 +1,26 @@ +import type { ClassValue } from '@/lib/tailwind'; + +export const RecordCardStyles = [ + 'group', + 'grid', + 'shadow-1xs', + 'shadow-tint-9/1', + 'depth-flat:shadow-none', + 'rounded', + 'straight-corners:rounded-none', + 'circular-corners:rounded-xl', + 'dark:shadow-transparent', + + 'before:pointer-events-none', + 'before:grid-area-1-1', + 'before:transition-shadow', + 'before:w-full', + 'before:h-full', + 'before:rounded-[inherit]', + 'before:ring-1', + 'before:ring-tint-12/2', + 'before:z-10', + 'before:relative', + + 'hover:before:ring-tint-12/5', +] as ClassValue; diff --git a/packages/gitbook/src/components/DocumentView/Table/table.module.css b/packages/gitbook/src/components/DocumentView/Table/table.module.css index 4b602a607c..53065b9d67 100644 --- a/packages/gitbook/src/components/DocumentView/Table/table.module.css +++ b/packages/gitbook/src/components/DocumentView/Table/table.module.css @@ -23,7 +23,7 @@ } .columnHeader { - @apply text-sm font-medium py-2 px-4 text-tint-strong; + @apply text-sm font-medium py-2 px-3 text-tint-strong; } .row { diff --git a/packages/gitbook/src/components/DocumentView/Table/utils.ts b/packages/gitbook/src/components/DocumentView/Table/utils.ts index 01bd82bcd6..1fc95b357e 100644 --- a/packages/gitbook/src/components/DocumentView/Table/utils.ts +++ b/packages/gitbook/src/components/DocumentView/Table/utils.ts @@ -1,4 +1,5 @@ import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api'; +import assertNever from 'assert-never'; /** * Get the value for a column in a record. @@ -14,11 +15,24 @@ export function getRecordValue<T extends number | string | boolean | string[] | /** * Get the text alignment for a column. */ -export function getColumnAlignment(column: DocumentTableDefinition): 'left' | 'right' | 'center' { +export function getColumnAlignment(column: DocumentTableDefinition) { + const defaultAlignment = 'text-left'; + if (column.type === 'text') { - return column.textAlignment ?? 'left'; + switch (column.textAlignment) { + case undefined: + case 'left': + return defaultAlignment; + case 'center': + return 'text-center'; + case 'right': + return 'text-right'; + default: + assertNever(column.textAlignment); + } } - return 'left'; + + return defaultAlignment; } /** diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 38afbcf086..e4465b3032 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -5,6 +5,8 @@ import React, { useCallback, useMemo } from 'react'; import { useHash, useIsMounted } from '@/components/hooks'; import * as storage from '@/lib/local-storage'; import { type ClassValue, tcls } from '@/lib/tailwind'; +import type { DocumentBlockTabs } from '@gitbook/api'; +import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton'; interface TabsState { activeIds: { @@ -68,9 +70,10 @@ export function DynamicTabs( props: TabsInput & { tabsBody: React.ReactNode[]; style: ClassValue; + block: DocumentBlockTabs; } ) { - const { id, tabs, tabsBody, style } = props; + const { id, block, tabs, tabsBody, style } = props; const hash = useHash(); const [tabsState, setTabsState] = useTabsState(); @@ -146,8 +149,8 @@ export function DynamicTabs( 'ring-inset', 'ring-tint-subtle', 'flex', - 'overflow-hidden', 'flex-col', + 'overflow-hidden', style )} > @@ -165,16 +168,14 @@ export function DynamicTabs( )} > {tabs.map((tab) => ( - <button + <div key={tab.id} - role="tab" - aria-selected={active.id === tab.id} - aria-controls={getTabPanelId(tab.id)} - id={getTabButtonId(tab.id)} - onClick={() => { - onSelectTab(tab); - }} className={tcls( + hashLinkButtonWrapperStyles, + 'flex', + 'items-center', + 'gap-3.5', + //prev from active-tab '[&:has(+_.active-tab)]:rounded-br-md', @@ -184,14 +185,6 @@ export function DynamicTabs( //next from active-tab '[.active-tab_+_:after]:rounded-br-md', - 'inline-block', - 'text-sm', - 'px-3.5', - 'py-2', - 'transition-[color]', - 'font-[500]', - 'relative', - 'after:transition-colors', 'after:border-r', 'after:absolute', @@ -202,14 +195,16 @@ export function DynamicTabs( 'after:h-[70%]', 'after:w-[1px]', + 'px-3.5', + 'py-2', + 'last:after:border-transparent', 'text-tint', 'bg-tint-12/1', 'hover:text-tint-strong', - - 'truncate', 'max-w-full', + 'truncate', active.id === tab.id ? [ @@ -224,8 +219,34 @@ export function DynamicTabs( : null )} > - {tab.title} - </button> + <button + type="button" + role="tab" + aria-selected={active.id === tab.id} + aria-controls={getTabPanelId(tab.id)} + id={getTabButtonId(tab.id)} + onClick={() => { + onSelectTab(tab); + }} + className={tcls( + 'inline-block', + 'text-sm', + 'transition-[color]', + 'font-[500]', + 'relative', + 'max-w-full', + 'truncate' + )} + > + {tab.title} + </button> + + <HashLinkButton + id={getTabButtonId(tab.id)} + block={block} + label="Direct link to tab" + /> + </div> ))} </div> {tabs.map((tab, index) => ( diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index 4d5a0d555a..d1ab244852 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -39,6 +39,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) { <DynamicTabs key={tab.id} id={block.key!} + block={block} tabs={[tab]} tabsBody={[tabsBody[index]]} style={style} @@ -48,5 +49,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) { ); } - return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />; + return ( + <DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} /> + ); } diff --git a/packages/gitbook/src/components/DocumentView/spacing.ts b/packages/gitbook/src/components/DocumentView/spacing.ts index a11db3546c..1c21a8b17d 100644 --- a/packages/gitbook/src/components/DocumentView/spacing.ts +++ b/packages/gitbook/src/components/DocumentView/spacing.ts @@ -10,6 +10,8 @@ export function getBlockTextStyle(block: DocumentBlock): { lineHeight: string; /** Tailwind class for the margin top (mt-*) */ marginTop?: string; + /** Tailwind class for the margin top to apply on the anchor link button */ + anchorButtonMarginTop?: string; } { switch (block.type) { case 'paragraph': @@ -22,18 +24,21 @@ export function getBlockTextStyle(block: DocumentBlock): { textSize: 'text-3xl font-semibold', lineHeight: 'leading-tight', marginTop: 'mt-[1em]', + anchorButtonMarginTop: 'mt-[1.05em]', }; case 'heading-2': return { textSize: 'text-2xl font-semibold', lineHeight: 'leading-snug', marginTop: 'mt-[0.75em]', + anchorButtonMarginTop: 'mt-[0.9em]', }; case 'heading-3': return { textSize: 'text-xl font-semibold', lineHeight: 'leading-snug', marginTop: 'mt-[0.5em]', + anchorButtonMarginTop: 'mt-[0.65em]', }; case 'divider': return { diff --git a/packages/gitbook/src/components/Footer/Footer.tsx b/packages/gitbook/src/components/Footer/Footer.tsx index 776c0a4af4..454ac0f421 100644 --- a/packages/gitbook/src/components/Footer/Footer.tsx +++ b/packages/gitbook/src/components/Footer/Footer.tsx @@ -25,6 +25,7 @@ export function Footer(props: { context: GitBookSiteContext }) { return ( <footer + id="site-footer" className={tcls( 'border-tint-subtle border-t', // If the footer only contains a mode toggle, we only show it on smaller screens @@ -35,12 +36,16 @@ export function Footer(props: { context: GitBookSiteContext }) { <div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}> <div className={tcls( - 'mx-auto grid max-w-3xl justify-between gap-12 lg:max-w-none', + 'lg:!max-w-none mx-auto grid max-w-3xl site-full-width:max-w-screen-2xl justify-between gap-12', 'grid-cols-[auto_auto]', 'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]', 'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]', + 'site-full-width:lg:grid-cols-[18rem_minmax(auto,_80rem)_auto]', + 'site-full-width:xl:grid-cols-[18rem_minmax(auto,_80rem)_14rem]', 'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]', - 'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]' + 'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]', + '[body:has(.site-full-width,.page-no-toc)_&]:lg:grid-cols-[minmax(auto,_90rem)_auto]', + '[body:has(.site-full-width,.page-no-toc)_&]:xl:grid-cols-[14rem_minmax(auto,_90rem)_14rem]' )} > { @@ -101,7 +106,7 @@ export function Footer(props: { context: GitBookSiteContext }) { 'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2' )} > - <div className="mx-auto flex max-w-3xl flex-col gap-10 sm:flex-row sm:gap-6"> + <div className="mx-auto flex max-w-3xl site-full-width:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6"> {partition(customization.footer.groups, FOOTER_COLUMNS).map( (column, columnIndex) => ( <div diff --git a/packages/gitbook/src/components/Header/Dropdown.tsx b/packages/gitbook/src/components/Header/Dropdown.tsx deleted file mode 100644 index 21b626ac2c..0000000000 --- a/packages/gitbook/src/components/Header/Dropdown.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Icon } from '@gitbook/icons'; -import { type DetailedHTMLProps, type HTMLAttributes, useId } from 'react'; - -import { type ClassValue, tcls } from '@/lib/tailwind'; - -import { Link, type LinkInsightsProps } from '../primitives'; - -export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit< - Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>, - 'ref' ->; - -/** - * Button with a dropdown. - */ -export function Dropdown<E extends HTMLElement>(props: { - /** Content of the button */ - button: (buttonProps: DropdownButtonProps<E>) => React.ReactNode; - /** Content of the dropdown */ - children: React.ReactNode; - /** Custom styles */ - className?: ClassValue; -}) { - const { button, children, className } = props; - const dropdownId = useId(); - - return ( - <div className={tcls('group/dropdown', 'relative flex min-w-0 shrink')}> - {button({ - id: dropdownId, - tabIndex: 0, - 'aria-expanded': true, - 'aria-haspopup': true, - })} - <div - tabIndex={-1} - role="menu" - aria-orientation="vertical" - aria-labelledby={dropdownId} - className={tcls( - 'w-52', - 'max-h-80', - 'flex', - 'absolute', - 'top-full', - 'left-0', - 'origin-top-left', - 'invisible', - 'transition-opacity', - 'duration-1000', - 'group-hover/dropdown:visible', - 'group-focus-within/dropdown:visible', - className - )} - > - <div className="fixed z-50 w-52"> - <div - className={tcls( - 'mt-2', - 'w-full', - 'max-h-80', - 'bg-tint-base', - 'rounded-lg', - 'straight-corners:rounded-sm', - 'p-2', - 'shadow-1xs', - 'overflow-auto', - 'ring-1', - 'ring-tint-subtle', - 'focus:outline-none' - )} - > - {children} - </div> - </div> - </div> - </div> - ); -} - -/** - * Animated chevron to display in the dropdown button. - */ -export function DropdownChevron() { - return ( - <Icon - icon="chevron-down" - className={tcls( - 'shrink-0', - 'opacity-6', - 'size-3', - 'ms-1', - 'transition-all', - 'group-hover/dropdown:opacity-11', - 'group-focus-within/dropdown:rotate-180' - )} - /> - ); -} - -/** - * Group of menu items in a dropdown. - */ -export function DropdownMenu(props: { children: React.ReactNode }) { - const { children } = props; - - return <div className={tcls('flex', 'flex-col', 'gap-1')}>{children}</div>; -} - -/** - * Menu item in a dropdown. - */ -export function DropdownMenuItem( - props: { - href: string | null; - active?: boolean; - className?: ClassValue; - children: React.ReactNode; - } & LinkInsightsProps -) { - const { children, active = false, href, className, insights } = props; - - if (href) { - return ( - <Link - href={href} - insights={insights} - className={tcls( - 'rounded straight-corners:rounded-sm px-3 py-1 text-sm', - active ? 'bg-primary text-primary-strong' : null, - 'hover:bg-tint-hover', - className - )} - > - {children} - </Link> - ); - } - - return ( - <div className={tcls('px-3 py-1 font-medium text-tint text-xs', className)}>{children}</div> - ); -} diff --git a/packages/gitbook/src/components/Header/DropdownMenu.tsx b/packages/gitbook/src/components/Header/DropdownMenu.tsx new file mode 100644 index 0000000000..8f51e4685c --- /dev/null +++ b/packages/gitbook/src/components/Header/DropdownMenu.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { Icon } from '@gitbook/icons'; +import type { DetailedHTMLProps, HTMLAttributes } from 'react'; +import { useState } from 'react'; + +import { type ClassValue, tcls } from '@/lib/tailwind'; + +import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; + +import { Link, type LinkInsightsProps } from '../primitives'; + +export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit< + Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>, + 'ref' +>; + +/** + * Button with a dropdown. + */ +export function DropdownMenu(props: { + /** Content of the button */ + button: React.ReactNode; + /** Content of the dropdown */ + children: React.ReactNode; + /** Custom styles */ + className?: ClassValue; + /** Open the dropdown on hover */ + openOnHover?: boolean; +}) { + const { button, children, className, openOnHover = false } = props; + const [hovered, setHovered] = useState(false); + const [clicked, setClicked] = useState(false); + + return ( + <RadixDropdownMenu.Root + modal={false} + open={openOnHover ? clicked || hovered : clicked} + onOpenChange={setClicked} + > + <RadixDropdownMenu.Trigger + asChild + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => (openOnHover ? setClicked(!clicked) : null)} + className="group/dropdown" + > + {button} + </RadixDropdownMenu.Trigger> + + <RadixDropdownMenu.Portal> + <RadixDropdownMenu.Content + data-testid="dropdown-menu" + hideWhenDetached + collisionPadding={8} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + align="start" + className="z-40 animate-present pt-2" + > + <div + className={tcls( + 'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80', + className + )} + > + {children} + </div> + </RadixDropdownMenu.Content> + </RadixDropdownMenu.Portal> + </RadixDropdownMenu.Root> + ); +} + +/** + * Animated chevron to display in the dropdown button. + */ +export function DropdownChevron() { + return ( + <Icon + icon="chevron-down" + className={tcls( + 'shrink-0', + 'opacity-6', + 'size-3', + 'ms-1', + 'transition-all', + 'group-hover/dropdown:opacity-11', + 'group-data-[state=open]/dropdown:opacity-11', + 'group-data-[state=open]/dropdown:rotate-180' + )} + /> + ); +} + +/** + * Button with a chevron for use in dropdowns. + */ +export function DropdownButton(props: { + children: React.ReactNode; + className?: ClassValue; +}) { + const { children, className } = props; + + return ( + <div className={tcls('group/dropdown', 'flex', 'items-center', className)}> + {children} + <DropdownChevron /> + </div> + ); +} + +/** + * Menu item in a dropdown. + */ +export function DropdownMenuItem( + props: { + href: string | null; + active?: boolean; + className?: ClassValue; + children: React.ReactNode; + } & LinkInsightsProps +) { + const { children, active = false, href, className, insights } = props; + + const itemClassName = tcls( + 'rounded straight-corners:rounded-sm px-3 py-1 text-sm', + active + ? 'bg-primary text-primary-strong data-[highlighted]:bg-primary-hover' + : 'data-[highlighted]:bg-tint-hover', + 'focus:outline-none', + className + ); + + if (href) { + return ( + <RadixDropdownMenu.Item asChild> + <Link href={href} insights={insights} className={itemClassName}> + {children} + </Link> + </RadixDropdownMenu.Item> + ); + } + + return ( + <RadixDropdownMenu.Item + className={tcls('px-3 py-1 font-medium text-tint text-xs', className)} + > + {children} + </RadixDropdownMenu.Item> + ); +} + +export function DropdownSubMenu(props: { children: React.ReactNode; label: React.ReactNode }) { + const { children, label } = props; + + return ( + <RadixDropdownMenu.Sub> + <RadixDropdownMenu.SubTrigger className="flex cursor-pointer items-center justify-between rounded straight-corners:rounded-sm px-3 py-1 text-sm focus:outline-none data-[highlighted]:bg-tint-hover"> + {label} + <Icon icon="chevron-right" className="size-3 shrink-0 opacity-6" /> + </RadixDropdownMenu.SubTrigger> + <RadixDropdownMenu.Portal> + <RadixDropdownMenu.SubContent + hideWhenDetached + collisionPadding={8} + className="z-40 animate-present" + > + <div className="flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80"> + {children} + </div> + </RadixDropdownMenu.SubContent> + </RadixDropdownMenu.Portal> + </RadixDropdownMenu.Sub> + ); +} diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 35c2583c41..f3bd145d56 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -104,11 +104,9 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo 'lg:basis-40', 'md:max-w-[40%]', 'lg:max-w-lg', - 'lg:ml-[max(calc((100%-18rem-48rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - margin (3rem) + 'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) 'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem) 'page-no-toc:lg:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]', - 'page-full-width:lg:ml-[max(calc((100%-18rem-103rem-3rem)/2),1.5rem)]', - 'page-full-width:2xl:ml-[max(calc((100%-18rem-96rem-14rem+3rem)/2),1.5rem)]', 'md:mr-auto', 'order-last', 'md:order-[unset]', @@ -195,7 +193,6 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo <div className={tcls( CONTAINER_STYLE, - 'page-default-width:max-w-[unset]', 'grow', 'flex', 'items-end', @@ -205,7 +202,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo {siteSpaces.length > 1 && ( <div id="variants" - className="my-2 mr-5 page-no-toc:flex hidden grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64" + className="my-2 mr-5 grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64" > <SpacesDropdown context={context} diff --git a/packages/gitbook/src/components/Header/HeaderLink.tsx b/packages/gitbook/src/components/Header/HeaderLink.tsx index 184537c15d..6b13fef714 100644 --- a/packages/gitbook/src/components/Header/HeaderLink.tsx +++ b/packages/gitbook/src/components/Header/HeaderLink.tsx @@ -13,12 +13,11 @@ import { tcls } from '@/lib/tailwind'; import { Button, Link } from '../primitives'; import { - Dropdown, type DropdownButtonProps, DropdownChevron, DropdownMenu, DropdownMenuItem, -} from './Dropdown'; +} from './DropdownMenu'; export async function HeaderLink(props: { context: GitBookSiteContext; @@ -33,21 +32,13 @@ export async function HeaderLink(props: { if (link.links && link.links.length > 0) { return ( - <Dropdown + <DropdownMenu className={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`} - button={(buttonProps) => { - if (!target || !link.to) { - return ( - <HeaderItemDropdown - {...buttonProps} - headerPreset={headerPreset} - title={link.title} - /> - ); - } - return ( + button={ + !target || !link.to ? ( + <HeaderItemDropdown headerPreset={headerPreset} title={link.title} /> + ) : ( <HeaderLinkNavItem - {...buttonProps} linkTarget={link.to} linkStyle={linkStyle} headerPreset={headerPreset} @@ -55,15 +46,14 @@ export async function HeaderLink(props: { isDropdown href={target?.href} /> - ); - }} + ) + } + openOnHover={true} > - <DropdownMenu> - {link.links.map((subLink, index) => ( - <SubHeaderLink key={index} {...props} link={subLink} /> - ))} - </DropdownMenu> - </Dropdown> + {link.links.map((subLink, index) => ( + <SubHeaderLink key={index} {...props} link={subLink} /> + ))} + </DropdownMenu> ); } @@ -157,17 +147,19 @@ function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPrese 'text-tint', 'links-default:hover:text-primary', + 'links-default:data-[state=open]:text-primary', 'links-default:tint:hover:text-tint-strong', - + 'links-default:tint:data-[state=open]:text-tint-strong', 'underline-offset-2', 'links-accent:hover:underline', + 'links-accent:data-[state=open]:underline', 'links-accent:underline-offset-4', 'links-accent:decoration-primary-subtle', 'links-accent:decoration-[3px]', 'links-accent:py-0.5', // Prevent underline from being cut off at the bottom 'theme-bold:text-header-link', - 'theme-bold:hover:text-header-link' + 'theme-bold:hover:!text-header-link/7' ); } diff --git a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx index 5d27fc9a8d..7954b80ec4 100644 --- a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx +++ b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx @@ -10,7 +10,7 @@ import type React from 'react'; import { resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; -import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown'; +import { DropdownChevron, DropdownMenu, DropdownMenuItem, DropdownSubMenu } from './DropdownMenu'; import styles from './headerLinks.module.css'; /** @@ -23,7 +23,7 @@ export function HeaderLinkMore(props: { }) { const { label, links, context } = props; - const renderButton = () => ( + const renderButton = ( <button type="button" className={tcls( @@ -45,19 +45,18 @@ export function HeaderLinkMore(props: { return ( <div className={`${styles.linkEllipsis} z-20 items-center`}> - <Dropdown + <DropdownMenu button={renderButton} + openOnHover={true} className={tcls( 'max-md:right-0 max-md:left-auto', context.customization.styling.search === 'prominent' && 'right-0 left-auto' )} > - <DropdownMenu> - {links.map((link, index) => ( - <MoreMenuLink key={index} link={link} context={context} /> - ))} - </DropdownMenu> - </Dropdown> + {links.map((link, index) => ( + <MoreMenuLink key={index} link={link} context={context} /> + ))} + </DropdownMenu> </div> ); } @@ -70,32 +69,28 @@ async function MoreMenuLink(props: { const target = link.to ? await resolveContentRef(link.to, context) : null; - return ( - <> - {'links' in link && link.links.length > 0 && ( - <hr className="-mx-2 my-1 border-tint border-t first:hidden" /> - )} - <DropdownMenuItem - href={target?.href ?? null} - insights={ - link.to - ? { - type: 'link_click', - link: { - target: link.to, - position: SiteInsightsLinkPosition.Header, - }, - } - : undefined - } - > - {link.title} - </DropdownMenuItem> - {'links' in link - ? link.links.map((subLink, index) => ( - <MoreMenuLink key={index} {...props} link={subLink} /> - )) - : null} - </> + return 'links' in link && link.links.length > 0 ? ( + <DropdownSubMenu label={link.title}> + {link.links.map((subLink, index) => { + return <MoreMenuLink key={index} {...props} link={subLink} />; + })} + </DropdownSubMenu> + ) : ( + <DropdownMenuItem + href={target?.href ?? null} + insights={ + link.to + ? { + type: 'link_click', + link: { + target: link.to, + position: SiteInsightsLinkPosition.Header, + }, + } + : undefined + } + > + {link.title} + </DropdownMenuItem> ); } diff --git a/packages/gitbook/src/components/Header/HeaderLogo.tsx b/packages/gitbook/src/components/Header/HeaderLogo.tsx index 08a1d2d10a..72d0b4798b 100644 --- a/packages/gitbook/src/components/Header/HeaderLogo.tsx +++ b/packages/gitbook/src/components/Header/HeaderLogo.tsx @@ -20,7 +20,7 @@ export async function HeaderLogo(props: HeaderLogoProps) { return ( <Link - href={linker.toAbsoluteURL(linker.toPathInSpace(''))} + href={linker.toAbsoluteURL(linker.toPathInSite(''))} className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')} > {customization.header.logo ? ( @@ -48,8 +48,6 @@ export async function HeaderLogo(props: HeaderLogoProps) { ]} priority="high" style={tcls( - 'rounded', - 'straight-corners:rounded-sm', 'overflow-hidden', 'shrink', 'min-w-0', diff --git a/packages/gitbook/src/components/Header/SpacesDropdown.tsx b/packages/gitbook/src/components/Header/SpacesDropdown.tsx index 39e81e59f4..87ad44b5d7 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdown.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdown.tsx @@ -1,10 +1,10 @@ import type { SiteSpace } from '@gitbook/api'; +import { getSiteSpaceURL } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; - import type { GitBookSiteContext } from '@v2/lib/context'; -import { Dropdown, DropdownChevron, DropdownMenu } from './Dropdown'; -import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem'; +import { DropdownChevron, DropdownMenu } from './DropdownMenu'; +import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem'; export function SpacesDropdown(props: { context: GitBookSiteContext; @@ -13,17 +13,15 @@ export function SpacesDropdown(props: { className?: string; }) { const { context, siteSpace, siteSpaces, className } = props; - const { linker } = context; return ( - <Dropdown + <DropdownMenu className={tcls( 'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context 'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered )} - button={(buttonProps) => ( + button={ <div - {...buttonProps} data-testid="space-dropdown-button" className={tcls( 'flex', @@ -40,49 +38,41 @@ export function SpacesDropdown(props: { 'straight-corners:rounded-none', 'bg-tint-base', - 'group-hover/dropdown:bg-tint-base', - 'group-focus-within/dropdown:bg-tint-base', 'text-sm', 'text-tint', - 'group-hover/dropdown:text-tint-strong', - 'group-focus-within/dropdown:text-tint-strong', + 'hover:text-tint-strong', + 'data-[state=open]:text-tint-strong', 'ring-1', 'ring-tint-subtle', - 'group-hover/dropdown:ring-tint-hover', - 'group-focus-within/dropdown:ring-tint-hover', + 'hover:ring-tint-hover', + 'data-[state=open]:ring-tint-hover', 'contrast-more:bg-tint-base', 'contrast-more:ring-1', - 'contrast-more:group-hover/dropdown:ring-2', + 'contrast-more:hover:ring-2', + 'contrast-more:data-[state=open]:ring-2', 'contrast-more:ring-tint', - 'contrast-more:group-hover/dropdown:ring-tint-hover', - 'contrast-more:group-focus-within/dropdown:ring-tint-hover', + 'contrast-more:hover:ring-tint-hover', + 'contrast-more:data-[state=open]:ring-tint-hover', className )} > - <span className={tcls('line-clamp-1', 'grow')}>{siteSpace.title}</span> + <span className={tcls('truncate', 'grow')}>{siteSpace.title}</span> <DropdownChevron /> </div> - )} + } > - <DropdownMenu> - {siteSpaces.map((otherSiteSpace, index) => ( - <SpacesDropdownMenuItem - key={`${otherSiteSpace.id}-${index}`} - variantSpace={{ - id: otherSiteSpace.id, - title: otherSiteSpace.title, - url: otherSiteSpace.urls.published - ? linker.toLinkForContent(otherSiteSpace.urls.published) - : otherSiteSpace.space.urls.app, - }} - active={otherSiteSpace.id === siteSpace.id} - /> - ))} - </DropdownMenu> - </Dropdown> + <SpacesDropdownMenuItems + slimSpaces={siteSpaces.map((space) => ({ + id: space.id, + title: space.title, + url: getSiteSpaceURL(context, space), + }))} + curPath={siteSpace.path} + /> + </DropdownMenu> ); } diff --git a/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx b/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx index 8c3676ff89..2044d0fc6f 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx @@ -4,14 +4,30 @@ import type { Space } from '@gitbook/api'; import { joinPath } from '@/lib/paths'; import { useCurrentPagePath } from '../hooks'; -import { DropdownMenuItem } from './Dropdown'; +import { DropdownMenuItem } from './DropdownMenu'; -function useVariantSpaceHref(variantSpaceUrl: string) { +interface VariantSpace { + id: Space['id']; + title: Space['title']; + url: string; +} + +// When switching to a different variant space, we reconstruct the URL by swapping the space path. +function useVariantSpaceHref(variantSpaceUrl: string, currentSpacePath: string, active = false) { const currentPathname = useCurrentPagePath(); + // We need to ensure that the variant space URL is not the same as the current space path. + // If it is, we return only the variant space URL to redirect to the root of the variant space. + // This is necessary in case the currentPathname is the same as the variantSpaceUrl, + // otherwise we would redirect to the same space if the variant space that we are switching to is the default one. + if (!active && currentPathname.startsWith(`${currentSpacePath}/`)) { + return variantSpaceUrl; + } + if (URL.canParse(variantSpaceUrl)) { const targetUrl = new URL(variantSpaceUrl); targetUrl.pathname = joinPath(targetUrl.pathname, currentPathname); + targetUrl.searchParams.set('fallback', 'true'); return targetUrl.toString(); @@ -22,11 +38,12 @@ function useVariantSpaceHref(variantSpaceUrl: string) { } export function SpacesDropdownMenuItem(props: { - variantSpace: { id: Space['id']; title: Space['title']; url: string }; + variantSpace: VariantSpace; active: boolean; + currentSpacePath: string; }) { - const { variantSpace, active } = props; - const variantHref = useVariantSpaceHref(variantSpace.url); + const { variantSpace, active, currentSpacePath } = props; + const variantHref = useVariantSpaceHref(variantSpace.url, currentSpacePath, active); return ( <DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}> @@ -34,3 +51,23 @@ export function SpacesDropdownMenuItem(props: { </DropdownMenuItem> ); } + +export function SpacesDropdownMenuItems(props: { + slimSpaces: VariantSpace[]; + curPath: string; +}) { + const { slimSpaces, curPath } = props; + + return ( + <> + {slimSpaces.map((space) => ( + <SpacesDropdownMenuItem + key={space.id} + variantSpace={space} + active={false} + currentSpacePath={curPath} + /> + ))} + </> + ); +} diff --git a/packages/gitbook/src/components/PDF/PDFPage.tsx b/packages/gitbook/src/components/PDF/PDFPage.tsx index 96cb784ccd..d2bc5c91a6 100644 --- a/packages/gitbook/src/components/PDF/PDFPage.tsx +++ b/packages/gitbook/src/components/PDF/PDFPage.tsx @@ -9,7 +9,6 @@ import { } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context'; -import { getPageDocument } from '@v2/lib/data'; import type { GitBookLinker } from '@v2/lib/links'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; @@ -29,6 +28,7 @@ import { PageControlButtons } from './PageControlButtons'; import { PrintButton } from './PrintButton'; import './pdf.css'; import { sanitizeGitBookAppURL } from '@/lib/app'; +import { getPageDocument } from '@v2/lib/data'; const DEFAULT_LIMIT = 100; @@ -53,7 +53,7 @@ export async function PDFPage(props: { }) { const baseContext = props.context; const searchParams = new URLSearchParams(props.searchParams); - const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams)); + const pdfParams = getPDFSearchParams(searchParams); const customization = 'customization' in baseContext ? baseContext.customization : defaultCustomization(); @@ -224,8 +224,7 @@ async function PDFPageDocument(props: { context: GitBookSpaceContext; }) { const { page, context } = props; - const { space } = context; - const document = await getPageDocument(context.dataFetcher, space, page); + const document = await getPageDocument(context, page); return ( <PrintPage id={getPagePDFContainerId(page)}> @@ -246,6 +245,7 @@ async function PDFPageDocument(props: { page, }, getId: (id) => getPagePDFContainerId(page, id), + shouldRenderLinkPreviews: false, // We don't want to render link previews in the PDF. }} // We consider all pages as offscreen in PDF mode // to ensure we can efficiently render as many pages as possible diff --git a/packages/gitbook/src/components/PageAside/AsideSectionHighlight.tsx b/packages/gitbook/src/components/PageAside/AsideSectionHighlight.tsx index 41775ca782..4b18d3d909 100644 --- a/packages/gitbook/src/components/PageAside/AsideSectionHighlight.tsx +++ b/packages/gitbook/src/components/PageAside/AsideSectionHighlight.tsx @@ -30,6 +30,7 @@ export function AsideSectionHighlight({ 'rounded-md', 'straight-corners:rounded-none', + 'circular-corners:rounded-2xl', 'sidebar-list-line:rounded-l-none', 'sidebar-list-pill:bg-primary', diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index a51410cbc0..d00af1bf2d 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -48,11 +48,9 @@ export function PageAside(props: { 'group/aside', 'hidden', 'xl:flex', - // 'page-no-toc:lg:flex', 'flex-col', 'basis-56', - // 'page-no-toc:basis-40', - // 'page-no-toc:xl:basis-56', + 'xl:ml-12', 'grow-0', 'shrink-0', 'break-anywhere', // To prevent long words in headings from breaking the layout @@ -87,7 +85,7 @@ export function PageAside(props: { 'page-api-block:xl:max-2xl:dark:hover:shadow-tint-1/1', 'page-api-block:xl:max-2xl:rounded-md', 'page-api-block:xl:max-2xl:h-auto', - 'page-api-block:xl:max-2xl:my-8', + 'page-api-block:xl:max-2xl:my-4', 'page-api-block:p-2' )} > diff --git a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx index d9b02ebdf8..519c1ea15b 100644 --- a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx +++ b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx @@ -82,6 +82,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) { 'rounded-md', 'straight-corners:rounded-none', + 'circular-corners:rounded-2xl', 'sidebar-list-line:rounded-l-none', 'hover:bg-tint-hover', diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 6d548dec95..9b9e744743 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { getSpaceLanguage } from '@/intl/server'; import { t } from '@/intl/translate'; -import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document'; +import { hasFullWidthBlock, hasMoreThan, isNodeEmpty } from '@/lib/document'; import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; import { DocumentView, DocumentViewSkeleton } from '../DocumentView'; @@ -17,6 +17,8 @@ import { PageFooterNavigation } from './PageFooterNavigation'; import { PageHeader } from './PageHeader'; import { PreservePageLayout } from './PreservePageLayout'; +const LINK_PREVIEW_MAX_COUNT = 100; + export function PageBody(props: { context: GitBookSiteContext; page: RevisionPageDocument; @@ -27,7 +29,18 @@ export function PageBody(props: { const { page, context, ancestors, document, withPageFeedback } = props; const { customization } = context; - const asFullWidth = document ? hasFullWidthBlock(document) : false; + const contentFullWidth = document ? hasFullWidthBlock(document) : false; + + // Render link previews only if there are less than LINK_PREVIEW_MAX_COUNT links in the document. + const shouldRenderLinkPreviews = document + ? !hasMoreThan( + document, + (inline) => inline.object === 'inline' && inline.type === 'link', + LINK_PREVIEW_MAX_COUNT + ) + : false; + const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q'; + const asFullWidth = pageFullWidth || contentFullWidth; const language = getSpaceLanguage(customization); const updatedAt = page.updatedAt ?? page.createdAt; @@ -36,15 +49,11 @@ export function PageBody(props: { <main className={tcls( 'relative min-w-0 flex-1', - 'py-8 lg:px-12', + 'mx-auto max-w-screen-2xl py-8', // Allow words to break if they are too long. 'break-anywhere', - // When in api page mode without the aside, we align with the border of the main content - 'page-api-block:xl:max-2xl:pr-0', - // Max size to ensure one column in api is aligned with rest of content (2 x 3xl) + (gap-3 + 2) * px-12 - 'page-api-block:mx-auto page-api-block:max-w-screen-2xl', - // page.layout.tableOfContents ? null : 'xl:ml-56', - asFullWidth ? 'page-full-width' : 'page-default-width', + pageFullWidth ? 'page-full-width 2xl:px-8' : 'page-default-width', + asFullWidth ? 'site-full-width' : 'site-default-width', page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc' )} > @@ -70,6 +79,7 @@ export function PageBody(props: { context={{ mode: 'default', contentContext: context, + shouldRenderLinkPreviews, }} /> </React.Suspense> @@ -81,7 +91,7 @@ export function PageBody(props: { <PageFooterNavigation context={context} page={page} /> ) : null} - <div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong"> + <div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong"> {updatedAt ? ( <p className="mr-auto text-sm"> {t(language, 'page_last_modified', <DateRelative value={updatedAt} />)} diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index d434d5eba9..3b37dfd389 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -21,7 +21,10 @@ export async function PageCover(props: { context: GitBookSiteContext; }) { const { as, page, cover, context } = props; - const resolved = cover.ref ? await resolveContentRef(cover.ref, context) : null; + const [resolved, resolvedDark] = await Promise.all([ + cover.ref ? resolveContentRef(cover.ref, context) : null, + cover.refDark ? resolveContentRef(cover.refDark, context) : null, + ]); return ( <div @@ -33,14 +36,20 @@ export async function PageCover(props: { ? [ 'sm:-mx-6', 'md:-mx-8', - '-lg:mr-8', - 'lg:ml-0', + 'lg:-mr-8', + 'lg:-ml-12', !page.layout.tableOfContents && context.customization.header.preset !== 'none' - ? 'lg:-ml-64' + ? 'xl:-ml-[19rem]' : null, ] - : ['sm:mx-auto', 'max-w-3xl', 'sm:rounded-md', 'mb-8'] + : [ + 'sm:mx-auto', + 'max-w-3xl ', + 'page-full-width:max-w-screen-2xl', + 'sm:rounded-md', + 'mb-8', + ] )} > <Image @@ -58,6 +67,12 @@ export async function PageCover(props: { height: defaultPageCover.height, }, }, + dark: resolvedDark + ? { + src: resolvedDark.href, + size: resolvedDark.file?.dimensions, + } + : null, }} resize={ // When using the default cover, we don't want to resize as it's a SVG diff --git a/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx b/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx index 620cb5970d..a6be37d412 100644 --- a/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx +++ b/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx @@ -32,8 +32,8 @@ export async function PageFooterNavigation(props: { 'mt-6', 'gap-2', 'max-w-3xl', + 'page-full-width:max-w-screen-2xl', 'mx-auto', - 'page-api-block:ml-0', 'text-tint' )} > @@ -107,6 +107,7 @@ function NavigationCard( 'border', 'border-tint-subtle', 'rounded', + 'circular-corners:rounded-2xl', 'straight-corners:rounded-none', 'hover:border-primary', 'text-pretty', diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index b8b62f945d..7c1ad9cbf7 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -23,7 +23,14 @@ export async function PageHeader(props: { return ( <header - className={tcls('max-w-3xl', 'mx-auto', 'mb-6', 'space-y-3', 'page-api-block:ml-0')} + className={tcls( + 'max-w-3xl', + 'page-full-width:max-w-screen-2xl', + 'mx-auto', + 'mb-6', + 'space-y-3', + 'page-api-block:ml-0' + )} > {ancestors.length > 0 && ( <nav> diff --git a/packages/gitbook/src/components/PageBody/PreservePageLayout.tsx b/packages/gitbook/src/components/PageBody/PreservePageLayout.tsx index 7094711fbb..06a801034c 100644 --- a/packages/gitbook/src/components/PageBody/PreservePageLayout.tsx +++ b/packages/gitbook/src/components/PageBody/PreservePageLayout.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; /** * This component preserves the layout of the page while loading a new one. - * This approach is needed as page layout (full width block) is done using CSS (`body:has(.page-full-width)`), + * This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`), * which becomes false while transitioning between the 2 page states: * - * 1. Page 1 with full width block: `body:has(.page-full-width)` is true - * 2. Loading skeleton while transitioning to page 2: `body:has(.page-full-width)` is false - * 3. Page 2 with full width block: `body:has(.page-full-width)` is true + * 1. Page 1 with full width block: `body:has(.site-full-width)` is true + * 2. Loading skeleton while transitioning to page 2: `body:has(.site-full-width)` is false + * 3. Page 2 with full width block: `body:has(.site-full-width)` is true * * This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2). */ @@ -24,9 +24,9 @@ export function PreservePageLayout(props: { asFullWidth: boolean }) { } if (asFullWidth) { - header.classList.add('page-full-width'); + header.classList.add('site-full-width'); } else { - header.classList.remove('page-full-width'); + header.classList.remove('site-full-width'); } }, [asFullWidth]); diff --git a/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx b/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx index 551a8b83f5..2c15f52831 100644 --- a/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx +++ b/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx @@ -64,10 +64,10 @@ export function PageFeedbackForm(props: { <div className="rounded-full border border-tint-subtle bg-tint-base contrast-more:border-tint-12"> <div className="flex"> <RatingButton - rating={PageFeedbackRating.Bad} - label={tString(languages, 'was_this_helpful_negative')} - onClick={() => onSubmitRating(PageFeedbackRating.Bad)} - active={rating === PageFeedbackRating.Bad} + rating={PageFeedbackRating.Good} + label={tString(languages, 'was_this_helpful_positive')} + onClick={() => onSubmitRating(PageFeedbackRating.Good)} + active={rating === PageFeedbackRating.Good} disabled={rating !== undefined} /> <RatingButton @@ -78,10 +78,10 @@ export function PageFeedbackForm(props: { disabled={rating !== undefined} /> <RatingButton - rating={PageFeedbackRating.Good} - label={tString(languages, 'was_this_helpful_positive')} - onClick={() => onSubmitRating(PageFeedbackRating.Good)} - active={rating === PageFeedbackRating.Good} + rating={PageFeedbackRating.Bad} + label={tString(languages, 'was_this_helpful_negative')} + onClick={() => onSubmitRating(PageFeedbackRating.Bad)} + active={rating === PageFeedbackRating.Bad} disabled={rating !== undefined} /> </div> diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index c7d2f35b6e..ffaad1fff8 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -1,5 +1,4 @@ import { - CustomizationCorners, CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationSidebarBackgroundStyle, @@ -75,16 +74,15 @@ export async function CustomizationRootLayout(props: { lang={customization.internationalization.locale} className={tcls( customization.header.preset === CustomizationHeaderPreset.None - ? 'site-header-none' + ? null : 'scroll-pt-[76px]', // Take the sticky header in consideration for the scrolling - customization.styling.corners === CustomizationCorners.Straight - ? ' straight-corners' - : '', + customization.styling.corners && `${customization.styling.corners}-corners`, 'theme' in customization.styling && `theme-${customization.styling.theme}`, tintColor ? ' tint' : 'no-tint', sidebarStyles.background && `sidebar-${sidebarStyles.background}`, sidebarStyles.list && `sidebar-list-${sidebarStyles.list}`, 'links' in customization.styling && `links-${customization.styling.links}`, + 'depth' in customization.styling && `depth-${customization.styling.depth}`, fontNotoColorEmoji.variable, ibmPlexMono.variable, fontData.type === 'default' ? fontData.variable : 'font-custom', diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index 71dae7cc6b..a46758c0ae 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -143,9 +143,12 @@ margin-right: 0; width: calc(100% - var(--scrollbar-width)); } - body:has(.page-full-width) .scroll-nojump { - margin-left: 0; - width: 100%; + } + + .elevate-link { + & a[href]:not(.link-overlay) { + position: relative; + z-index: 20; } } } @@ -161,6 +164,6 @@ html.dark { color-scheme: dark light; } -html.announcement-hidden .announcement-banner { +html.announcement-hidden #announcement-banner { @apply hidden; } diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index 8a7dce493d..f7c4b1fc8e 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -176,6 +176,7 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) { 'py-2', 'rounded', 'straight-corners:rounded-none', + 'circular-corners:rounded-full', 'text-tint', 'hover:bg-tint-hover', 'focus-within:bg-tint-hover' diff --git a/packages/gitbook/src/components/Search/SearchButton.tsx b/packages/gitbook/src/components/Search/SearchButton.tsx index ab8516d059..7bcc4f7481 100644 --- a/packages/gitbook/src/components/Search/SearchButton.tsx +++ b/packages/gitbook/src/components/Search/SearchButton.tsx @@ -45,21 +45,25 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV 'w-full', 'py-2', 'px-3', + 'circular-corners:px-4', 'gap-2', 'bg-tint-base', 'ring-1', 'ring-tint-12/2', + 'depth-flat:ring-tint-subtle', 'shadow-sm', 'shadow-tint-12/3', 'dark:shadow-none', + 'depth-flat:shadow-none', 'text-tint', 'rounded-lg', 'straight-corners:rounded-sm', + 'circular-corners:rounded-full', 'contrast-more:ring-tint-12', 'contrast-more:text-tint-strong', @@ -68,10 +72,12 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV 'hover:bg-tint-subtle', 'hover:shadow-md', 'hover:scale-102', + 'depth-flat:hover:scale-100', 'hover:ring-tint-hover', 'hover:text-tint-strong', 'focus:shadow-md', 'focus:scale-102', + 'depth-flat:focus:scale-100', 'focus:ring-tint-hover', 'focus:text-tint-strong', diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index bdc747241a..c8046ca333 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -225,9 +225,11 @@ function SearchModalBody( 'w-full', 'rounded-lg', 'straight-corners:rounded-sm', + 'circular-corners:rounded-2xl', 'ring-1', - 'ring-tint-hover', + 'ring-tint', 'shadow-2xl', + 'depth-flat:shadow-none', 'overflow-hidden', 'dark:ring-inset', 'dark:ring-tint' diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 6b4d0e1c0e..aa97e621a4 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -108,6 +108,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt 'p-2', 'rounded', 'straight-corners:rounded-none', + 'circular-corners:rounded-full', active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6'] )} > diff --git a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx index 83f585c82b..787632a805 100644 --- a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx @@ -72,6 +72,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion 'rounded', 'self-center', 'straight-corners:rounded-none', + 'circular-corners:rounded-full', active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6'] )} > diff --git a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index ee2daa1ffb..8ccda684d2 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -66,17 +66,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe <HighlightQuery query={query} text={item.title} /> </p> ) : null} - {item.body ? ( - <p className={tcls('text-sm', 'line-clamp-3', 'relative')}> - <HighlightQuery query={query} text={item.body} /> - </p> - ) : null} + {item.body ? highlightQueryInBody(item.body, query) : null} </div> <div className={tcls( 'p-2', 'rounded', 'straight-corners:rounded-none', + 'circular-corners:rounded-full', 'bg-primary-solid', 'text-contrast-primary-solid', 'hidden', @@ -89,3 +86,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe </Link> ); }); + +function highlightQueryInBody(body: string, query: string) { + const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()); + + // Ensure the query to be highlighted is visible in the body. + return ( + <p className={tcls('text-sm', 'line-clamp-3', 'relative')}> + <HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} /> + </p> + ); +} diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index b45bc0aba1..9bd8257c16 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -345,6 +345,7 @@ async function transformAnswer( mode: 'default', contentContext: undefined, wrapBlocksInSuspense: false, + shouldRenderLinkPreviews: false, // We don't want to render link previews in the AI answer. }} style={['space-y-5']} /> diff --git a/packages/gitbook/src/components/SiteLayout/RocketLoaderDetector.tsx b/packages/gitbook/src/components/SiteLayout/RocketLoaderDetector.tsx index 096024089e..cc689bbef1 100644 --- a/packages/gitbook/src/components/SiteLayout/RocketLoaderDetector.tsx +++ b/packages/gitbook/src/components/SiteLayout/RocketLoaderDetector.tsx @@ -18,7 +18,7 @@ export function RocketLoaderDetector(props: { nonce?: string }) { alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 mt-8 mx-8'; alert.innerHTML = \` <strong>Error in site configuration:</strong> - It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://docs.gitbook.com/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem. + It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://gitbook.com/docs/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem. \`; document.body.prepend(alert); diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index dda7095266..83211354f1 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -102,30 +102,37 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; const faviconSize = 48; - const icons = [ - { - url: customIcon?.light - ? await getResizedImageURL(imageResizer, customIcon.light, { - width: faviconSize, - height: faviconSize, - }) - : linker.toAbsoluteURL( - linker.toPathInSpace('~gitbook/icon?size=small&theme=light') - ), - type: 'image/png', - media: '(prefers-color-scheme: light)', - }, - { - url: customIcon?.dark - ? await getResizedImageURL(imageResizer, customIcon.dark, { - width: faviconSize, - height: faviconSize, - }) - : linker.toAbsoluteURL(linker.toPathInSpace('~gitbook/icon?size=small&theme=dark')), - type: 'image/png', - media: '(prefers-color-scheme: dark)', - }, - ]; + const icons = await Promise.all( + [ + { + url: customIcon?.light + ? getResizedImageURL(imageResizer, customIcon.light, { + width: faviconSize, + height: faviconSize, + }) + : linker.toAbsoluteURL( + linker.toPathInSpace('~gitbook/icon?size=small&theme=light') + ), + type: 'image/png', + media: '(prefers-color-scheme: light)', + }, + { + url: customIcon?.dark + ? getResizedImageURL(imageResizer, customIcon.dark, { + width: faviconSize, + height: faviconSize, + }) + : linker.toAbsoluteURL( + linker.toPathInSpace('~gitbook/icon?size=small&theme=dark') + ), + type: 'image/png', + media: '(prefers-color-scheme: dark)', + }, + ].map(async (icon) => ({ + ...icon, + url: await icon.url, + })) + ); return { title: site.title, diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index fe6934a529..9e7c08515f 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -62,7 +62,7 @@ export async function SitePage(props: SitePageProps) { const withSections = Boolean(sections && sections.list.length > 0); const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader }; - const document = await getPageDocument(context.dataFetcher, context.space, page); + const document = await getPageDocument(context, page); return ( <PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}> diff --git a/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx b/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx index 37d97167c9..7b840afbb0 100644 --- a/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx +++ b/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx @@ -19,7 +19,7 @@ export function SitePageSkeleton() { 'lg:items-start' )} > - <div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'page-full-width:mx-0')}> + <div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'site-full-width:mx-0')}> <SkeletonHeading style={tcls('mb-8')} /> <SkeletonParagraph style={tcls('mb-4')} /> </div> diff --git a/packages/gitbook/src/components/SitePage/fetch.ts b/packages/gitbook/src/components/SitePage/fetch.ts index 6ca4e55293..732c36072c 100644 --- a/packages/gitbook/src/components/SitePage/fetch.ts +++ b/packages/gitbook/src/components/SitePage/fetch.ts @@ -69,7 +69,11 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams | // If a page still can't be found, we try with the API, in case we have a redirect at site level. const redirectPathname = withLeadingSlash(rawPathname); - if (/^\/[a-zA-Z0-9-_.\/]+[a-zA-Z0-9-_.]$/.test(redirectPathname)) { + if ( + /^\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+(?:\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+)*$/.test( + redirectPathname + ) + ) { const redirectSources = new Set<string>([ // Test the pathname relative to the root // For example hello/world -> section/variant/hello/world diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index a8b83cfb5a..dc78a3c3c2 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx @@ -75,16 +75,30 @@ export function SiteSectionListItem(props: { const isMounted = useIsMounted(); React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item - const linkRef = React.createRef<HTMLAnchorElement>(); - useScrollToActiveTOCItem({ linkRef, isActive }); + const anchorRef = React.createRef<HTMLAnchorElement>(); + useScrollToActiveTOCItem({ anchorRef, isActive }); return ( <Link + ref={anchorRef} href={section.url} - ref={linkRef} aria-current={isActive && 'page'} className={tcls( - 'group/section-link flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none px-3 py-2 transition-all hover:bg-tint-hover hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint', + 'group/section-link', + 'flex', + 'flex-row', + 'items-center', + 'gap-3', + 'rounded-md', + 'straight-corners:rounded-none', + 'circular-corners:rounded-xl', + 'px-3', + 'py-2', + 'transition-all', + 'hover:bg-tint-hover', + 'hover:text-tint-strong', + 'contrast-more:hover:ring-1', + 'contrast-more:hover:ring-tint', isActive ? 'font-semibold text-primary-subtle hover:bg-primary-hover hover:text-primary contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:hover:ring-1 contrast-more:hover:ring-primary-hover' : null, @@ -121,18 +135,15 @@ export function SiteSectionGroupItem(props: { const hasDescendants = group.sections.length > 0; const isActiveGroup = group.sections.some((section) => section.id === currentSection.id); - const [isVisible, setIsVisible] = React.useState(isActiveGroup); + const shouldOpen = hasDescendants && isActiveGroup; + const [isOpen, setIsOpen] = React.useState(shouldOpen); - // Update the visibility of the children, if we are navigating to a descendant. + // Update the visibility of the children if the group becomes active. React.useEffect(() => { - if (!hasDescendants) { - return; + if (shouldOpen) { + setIsOpen(shouldOpen); } - - setIsVisible((prev) => prev || isActiveGroup); - }, [isActiveGroup, hasDescendants]); - - const { show, hide, scope } = useToggleAnimation({ hasDescendants, isVisible }); + }, [shouldOpen]); return ( <> @@ -141,7 +152,7 @@ export function SiteSectionGroupItem(props: { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - setIsVisible((prev) => !prev); + setIsOpen((prev) => !prev); }} className={`group/section-link flex w-full flex-row items-center gap-3 rounded-md straight-corners:rounded-none px-3 py-2 text-left transition-all hover:bg-tint-hover hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint ${ isActiveGroup @@ -184,7 +195,7 @@ export function SiteSectionGroupItem(props: { 'after:h-7', 'hover:bg-tint-active', 'hover:text-current', - isActiveGroup ? ['hover:bg-tint-hover'] : [] + isActiveGroup && 'hover:bg-tint-hover' )} > <Icon @@ -201,17 +212,13 @@ export function SiteSectionGroupItem(props: { 'group-hover:opacity-11', 'contrast-more:opacity-11', - isVisible ? ['rotate-90'] : ['rotate-0'] + isOpen ? 'rotate-90' : 'rotate-0' )} /> </span> </button> {hasDescendants ? ( - <motion.div - ref={scope} - className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')} - initial={isVisible ? show : hide} - > + <Descendants isVisible={isOpen}> {group.sections.map((section) => ( <SiteSectionListItem section={section} @@ -220,8 +227,25 @@ export function SiteSectionGroupItem(props: { className="pl-5" /> ))} - </motion.div> + </Descendants> ) : null} </> ); } + +function Descendants(props: { + isVisible: boolean; + children: React.ReactNode; +}) { + const { isVisible, children } = props; + const { show, hide, scope } = useToggleAnimation(isVisible); + return ( + <motion.div + ref={scope} + className={isVisible ? undefined : '[&_ul>li]:opacity-1'} + initial={isVisible ? show : hide} + > + {children} + </motion.div> + ); +} diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 2833f9f31a..8d6df8eafa 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -83,6 +83,12 @@ export function SiteSectionTabs(props: { sections: ClientSiteSections }) { ) } asChild + onClick={(e) => { + if (value) { + e.preventDefault(); + e.stopPropagation(); + } + }} > <SectionGroupTab isActive={isActive} @@ -127,7 +133,7 @@ export function SiteSectionTabs(props: { sections: ClientSiteSections }) { }} > <NavigationMenu.Viewport - className="relative mt-3 h-[var(--radix-navigation-menu-viewport-height)] w-[calc(100vw_-_2rem)] origin-[top_center] overflow-hidden rounded-lg straight-corners:rounded-sm bg-tint-base shadow-lg shadow-tint-10/6 ring-1 ring-tint-subtle duration-250 data-[state=closed]:duration-150 motion-safe:transition-[width,_height,_transform] data-[state=closed]:motion-safe:animate-scaleOut data-[state=open]:motion-safe:animate-scaleIn md:mx-0 md:w-[var(--radix-navigation-menu-viewport-width)] dark:shadow-tint-1/6" + className="relative mt-3 h-[var(--radix-navigation-menu-viewport-height)] w-[calc(100vw_-_2rem)] origin-[top_center] overflow-hidden rounded-lg straight-corners:rounded-sm bg-tint-base depth-flat:shadow-none shadow-lg shadow-tint-10/6 ring-1 ring-tint-subtle duration-250 data-[state=closed]:duration-150 motion-safe:transition-[width,_height,_transform] data-[state=closed]:motion-safe:animate-scaleOut data-[state=open]:motion-safe:animate-scaleIn md:mx-0 md:w-[var(--radix-navigation-menu-viewport-width)] dark:shadow-tint-1/6" style={{ translate: undefined /* don't move this to a Tailwind class as Radix renders viewport incorrectly for a few frames */, @@ -151,7 +157,7 @@ const SectionTab = React.forwardRef(function SectionTab( ref={ref} {...rest} className={tcls( - 'group relative my-2 flex select-none items-center justify-between rounded straight-corners:rounded-none px-3 py-1', + 'group relative my-2 flex select-none items-center justify-between rounded circular-corners:rounded-full straight-corners:rounded-none px-3 py-1', isActive ? 'text-primary-subtle' : 'text-tint hover:bg-tint-hover hover:text-tint-strong' @@ -180,7 +186,7 @@ const SectionGroupTab = React.forwardRef(function SectionGroupTab( ref={ref} {...rest} className={tcls( - 'group relative my-2 flex select-none items-center justify-between rounded straight-corners:rounded-none px-3 py-1 transition-colors', + 'group relative my-2 flex select-none items-center justify-between rounded circular-corners:rounded-full straight-corners:rounded-none px-3 py-1 transition-colors hover:cursor-default', isActive ? 'text-primary-subtle' : 'text-tint hover:bg-tint-hover hover:text-tint-strong' diff --git a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts index 79a007ecc3..98c22bbb66 100644 --- a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts +++ b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts @@ -1,5 +1,7 @@ -import type { SiteSection, SiteSectionGroup } from '@gitbook/api'; +import { getSectionURL, getSiteSpaceURL } from '@/lib/sites'; +import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api'; import type { GitBookSiteContext, SiteSections } from '@v2/lib/context'; +import assertNever from 'assert-never'; export type ClientSiteSections = { list: (ClientSiteSection | ClientSiteSectionGroup)[]; @@ -26,16 +28,32 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections: const clientSections: (ClientSiteSection | ClientSiteSectionGroup)[] = []; for (const item of list) { - if (item.object === 'site-section-group') { - clientSections.push({ - id: item.id, - title: item.title, - icon: item.icon, - object: item.object, - sections: item.sections.map((section) => encodeSection(context, section)), - }); - } else { - clientSections.push(encodeSection(context, item)); + switch (item.object) { + case 'site-section-group': { + const sections = item.sections + .filter((section) => shouldIncludeSection(context, section)) + .map((section) => encodeSection(context, section)); + + // Skip empty groups + if (sections.length === 0) { + continue; + } + + clientSections.push({ + id: item.id, + title: item.title, + icon: item.icon, + object: item.object, + sections, + }); + continue; + } + case 'site-section': { + clientSections.push(encodeSection(context, item)); + continue; + } + default: + assertNever(item, 'Unknown site section object type'); } } @@ -46,13 +64,69 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections: } function encodeSection(context: GitBookSiteContext, section: SiteSection) { - const { linker } = context; return { id: section.id, title: section.title, description: section.description, icon: section.icon, object: section.object, - url: section.urls.published ? linker.toLinkForContent(section.urls.published) : '', + url: findBestTargetURL(context, section), }; } + +/** + * Test if a section should be included in the list of sections. + */ +function shouldIncludeSection(context: GitBookSiteContext, section: SiteSection) { + if (context.site.id !== 'site_JOVzv') { + return true; + } + + // Testing for a new mode of navigation where the multi-variants section are hidden + // if they do not include an equivalent of the current site space. + + // TODO: replace with a proper flag on the section + const withNavigateOnlyIfEquivalent = section.id === 'sitesc_4jvEm'; + + if (!withNavigateOnlyIfEquivalent) { + return true; + } + + const { siteSpace: currentSiteSpace } = context; + if (section.siteSpaces.length === 1) { + return true; + } + return section.siteSpaces.some((siteSpace) => + areSiteSpacesEquivalent(siteSpace, currentSiteSpace) + ); +} + +/** + * Find the best default site space to navigate to for a givent section: + * 1. If we are on the default, continue on the default. + * 2. If a site space has the same path as the current one, return it. + * 3. Otherwise, return the default one. + */ +function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) { + const { siteSpace: currentSiteSpace } = context; + + if (section.siteSpaces.length === 1 || currentSiteSpace.default) { + return getSectionURL(context, section); + } + + const bestMatch = section.siteSpaces.find((siteSpace) => + areSiteSpacesEquivalent(siteSpace, currentSiteSpace) + ); + if (bestMatch) { + return getSiteSpaceURL(context, bestMatch); + } + + return getSectionURL(context, section); +} + +/** + * Test if 2 site spaces are equivalent. + */ +function areSiteSpacesEquivalent(siteSpace1: SiteSpace, siteSpace2: SiteSpace) { + return siteSpace1.path === siteSpace2.path; +} diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 6b9aefd929..f69de405e3 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -73,6 +73,7 @@ export function SpaceLayout(props: { 'flex-col', 'lg:flex-row', CONTAINER_STYLE, + 'site-full-width:max-w-full', // Ensure the footer is display below the viewport even if the content is not enough withFooter && 'min-h-[calc(100vh-64px)]', @@ -124,7 +125,7 @@ export function SpaceLayout(props: { sections={encodeClientSiteSections(context, sections)} /> )} - {isMultiVariants && ( + {isMultiVariants && !sections && ( <SpacesDropdown context={context} siteSpace={siteSpace} diff --git a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx index ad7fc513e3..4875633ac9 100644 --- a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx @@ -17,10 +17,14 @@ export async function PageDocumentItem(props: { context: GitBookSiteContext; }) { const { rootPages, page, context } = props; - const href = context.linker.toPathForPage({ pages: rootPages, page }); + let href = context.linker.toPathForPage({ pages: rootPages, page }); + // toPathForPage can returns an empty path, this will cause all links to point to the current page. + if (href === '') { + href = '/'; + } return ( - <li className={tcls('flex', 'flex-col')}> + <li className="flex flex-col"> <ToggleableLinkItem href={href} pathname={getPagePath(rootPages, page)} @@ -52,7 +56,7 @@ export async function PageDocumentItem(props: { } > {page.emoji || page.icon ? ( - <span className={tcls('flex', 'gap-3', 'items-center')}> + <span className="flex items-center gap-3"> <TOCPageIcon page={page} /> {page.title} </span> diff --git a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx index 884f6eedac..24ea366a2e 100644 --- a/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx @@ -15,27 +15,13 @@ export function PageGroupItem(props: { const { rootPages, page, context } = props; return ( - <li className={tcls('flex', 'flex-col', 'group/page-group-item')}> + <li className="group/page-group-item flex flex-col"> <div className={tcls( - 'flex', - 'items-center', - - 'gap-3', - 'px-3', - 'z-[1]', - 'sticky', - '-top-5', - 'pt-6', - 'group-first/page-group-item:-mt-5', + '-top-5 group-first/page-group-item:-mt-5 sticky z-[1] flex items-center gap-3 px-3 pt-6', + 'font-semibold text-xs uppercase tracking-wide', 'pb-3', // Add extra padding to make the header fade a bit nicer '-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px. - - 'text-xs', - 'tracking-wide', - 'font-semibold', - 'uppercase', - '[mask-image:linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation. 'bg-tint-base', 'sidebar-filled:bg-tint-subtle', diff --git a/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx index 43c31730c5..c00d1c6216 100644 --- a/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx @@ -17,24 +17,7 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Git <li className={tcls('flex', 'flex-col')}> <Link href={resolved?.href ?? '#'} - className={tcls( - 'flex', - 'justify-start', - 'items-center', - 'gap-3', - 'p-1.5', - 'pl-3', - 'text-sm', - 'transition-colors', - 'duration-100', - 'text-tint-strong/7', - 'rounded-md', - 'straight-corners:rounded-none', - 'before:content-none', - 'font-normal', - 'hover:bg-tint', - 'hover:text-tint-strong' - )} + classNames={['PageLinkItemStyles']} insights={{ type: 'link_click', link: { @@ -55,9 +38,9 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Git 'shrink-0', 'text-current', 'transition-colors', - '[&>path]:transition-[opacity]', - '[&>path]:[opacity:0.40]', - 'group-hover:[&>path]:[opacity:1]' + '[&>path]:transition-opacity', + '[&>path]:opacity-[0.4]', + 'group-hover:[&>path]:opacity-11' )} /> </Link> diff --git a/packages/gitbook/src/components/TableOfContents/PagesList.tsx b/packages/gitbook/src/components/TableOfContents/PagesList.tsx index ff92f269b7..ccd7579ec3 100644 --- a/packages/gitbook/src/components/TableOfContents/PagesList.tsx +++ b/packages/gitbook/src/components/TableOfContents/PagesList.tsx @@ -1,8 +1,9 @@ -import { type RevisionPage, RevisionPageType } from '@gitbook/api'; +import type { RevisionPage } from '@gitbook/api'; import type { GitBookSiteContext } from '@v2/lib/context'; import { type ClassValue, tcls } from '@/lib/tailwind'; +import assertNever from 'assert-never'; import { PageDocumentItem } from './PageDocumentItem'; import { PageGroupItem } from './PageGroupItem'; import { PageLinkItem } from './PageLinkItem'; @@ -16,41 +17,45 @@ export function PagesList(props: { const { rootPages, pages, context, style } = props; return ( - <ul className={tcls('flex', 'flex-col', 'gap-y-0.5', style)}> + <ul className={tcls('flex flex-col gap-y-0.5', style)}> {pages.map((page) => { - if (page.type === RevisionPageType.Computed) { + if (page.type === 'computed') { throw new Error( 'Unexpected computed page, it should have been computed in the API' ); } - if (page.type === RevisionPageType.Link) { - return <PageLinkItem key={page.id} page={page} context={context} />; - } - if (page.hidden) { return null; } - if (page.type === RevisionPageType.Group) { - return ( - <PageGroupItem - key={page.id} - rootPages={rootPages} - page={page} - context={context} - /> - ); + switch (page.type) { + case 'document': + return ( + <PageDocumentItem + key={page.id} + rootPages={rootPages} + page={page} + context={context} + /> + ); + + case 'link': + return <PageLinkItem key={page.id} page={page} context={context} />; + + case 'group': + return ( + <PageGroupItem + key={page.id} + rootPages={rootPages} + page={page} + context={context} + /> + ); + + default: + assertNever(page); } - - return ( - <PageDocumentItem - key={page.id} - rootPages={rootPages} - page={page} - context={context} - /> - ); })} </ul> ); diff --git a/packages/gitbook/src/components/TableOfContents/TOCPageIcon.tsx b/packages/gitbook/src/components/TableOfContents/TOCPageIcon.tsx index aef502c446..266f3c1e2b 100644 --- a/packages/gitbook/src/components/TableOfContents/TOCPageIcon.tsx +++ b/packages/gitbook/src/components/TableOfContents/TOCPageIcon.tsx @@ -13,7 +13,7 @@ export function TOCPageIcon({ page }: { page: RevisionPage }) { page={page} style={tcls( 'text-base', - 'text-tint-strong/6', + '[.toclink_&]:text-tint-strong/6', 'group-aria-current-page/toclink:text-primary-subtle', 'contrast-more:group-aria-current-page/toclink:text-primary', diff --git a/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx b/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx index 9e9332ea1c..492dc9022d 100644 --- a/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx +++ b/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx @@ -1,40 +1,64 @@ 'use client'; -import React from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + type ComponentPropsWithoutRef, +} from 'react'; +import { assert } from 'ts-essentials'; -import { type ClassValue, tcls } from '@/lib/tailwind'; +interface TOCScrollContainerContextType { + onContainerMount: (listener: (element: HTMLDivElement) => void) => () => void; +} -const TOCScrollContainerRefContext = React.createContext<React.RefObject<HTMLDivElement> | null>( - null -); +const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null); -function useTOCScrollContainerRefContext() { - const ctx = React.useContext(TOCScrollContainerRefContext); - if (!ctx) { - throw new Error('Context `TOCScrollContainerRefContext` must be used within Provider'); - } +function useTOCScrollContainerContext() { + const ctx = React.useContext(TOCScrollContainerContext); + assert(ctx); return ctx; } -export function TOCScrollContainer(props: { - children: React.ReactNode; - className?: ClassValue; - style?: React.CSSProperties; -}) { - const { children, className, style } = props; - const scrollContainerRef = React.createRef<HTMLDivElement>(); +/** + * Table of contents scroll container. + */ +export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) { + const ref = useRef<HTMLDivElement>(null); + const listeners = useRef<((element: HTMLDivElement) => void)[]>([]); + const onContainerMount: TOCScrollContainerContextType['onContainerMount'] = useCallback( + (listener) => { + if (ref.current) { + listener(ref.current); + return () => {}; + } + listeners.current.push(listener); + return () => { + listeners.current = listeners.current.filter((l) => l !== listener); + }; + }, + [] + ); + const value: TOCScrollContainerContextType = useMemo( + () => ({ onContainerMount }), + [onContainerMount] + ); + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + listeners.current.forEach((listener) => listener(element)); + return () => { + listeners.current = []; + }; + }, []); return ( - <TOCScrollContainerRefContext.Provider value={scrollContainerRef}> - <div - ref={scrollContainerRef} - data-testid="toc-scroll-container" - className={tcls(className)} - style={style} - > - {children} - </div> - </TOCScrollContainerRefContext.Provider> + <TOCScrollContainerContext.Provider value={value}> + <div ref={ref} data-testid="toc-scroll-container" {...props} /> + </TOCScrollContainerContext.Provider> ); } @@ -42,39 +66,31 @@ export function TOCScrollContainer(props: { const TOC_ITEM_OFFSET = 100; /** - * Scrolls the table of contents container to the page item when it becomes active + * Scrolls the table of contents container to the page item when it's initially active. */ export function useScrollToActiveTOCItem(props: { + anchorRef: React.RefObject<HTMLAnchorElement>; isActive: boolean; - linkRef: React.RefObject<HTMLAnchorElement>; }) { - const { isActive, linkRef } = props; - const scrollContainerRef = useTOCScrollContainerRefContext(); - const isScrolled = React.useRef(false); - React.useLayoutEffect(() => { - if (!isActive) { - isScrolled.current = false; - return; - } - if (isScrolled.current) { - return; - } - const tocItem = linkRef.current; - const tocContainer = scrollContainerRef.current; - if (!tocItem || !tocContainer || !isOutOfView(tocItem, tocContainer)) { - return; + const { isActive, anchorRef } = props; + const isInitialActiveRef = useRef(isActive); + const { onContainerMount } = useTOCScrollContainerContext(); + useEffect(() => { + const anchor = anchorRef.current; + if (isInitialActiveRef.current && anchor) { + return onContainerMount((container) => { + if (isOutOfView(anchor, container)) { + container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET }); + } + }); } - tocContainer?.scrollTo({ - top: tocItem.offsetTop - TOC_ITEM_OFFSET, - }); - isScrolled.current = true; - }, [isActive, linkRef, scrollContainerRef]); + }, [onContainerMount, anchorRef]); } -function isOutOfView(tocItem: HTMLElement, tocContainer: HTMLElement) { - const tocItemTop = tocItem.offsetTop; - const containerTop = tocContainer.scrollTop; - const containerBottom = containerTop + tocContainer.clientHeight; +function isOutOfView(element: HTMLElement, container: HTMLElement) { + const tocItemTop = element.offsetTop; + const containerTop = container.scrollTop; + const containerBottom = containerTop + container.clientHeight; return ( tocItemTop < containerTop + TOC_ITEM_OFFSET || tocItemTop > containerBottom - TOC_ITEM_OFFSET diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 9f9c0d85da..7ca7a2c0c0 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind'; import { PagesList } from './PagesList'; import { TOCScrollContainer } from './TOCScroller'; +import { TableOfContentsScript } from './TableOfContentsScript'; import { Trademark } from './Trademark'; export function TableOfContents(props: { @@ -17,111 +18,110 @@ export function TableOfContents(props: { const { space, customization, pages } = context; return ( - <aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar. - data-testid="table-of-contents" - className={tcls( - 'group', - 'text-sm', - - 'grow-0', - 'shrink-0', - 'basis-full', - 'lg:basis-72', - 'page-no-toc:lg:basis-56', - - 'relative', - 'z-[1]', - 'lg:sticky', - // Without header - 'lg:top-0', - 'lg:h-screen', - - // With header - 'site-header:lg:top-16', - 'site-header:lg:h-[calc(100vh_-_4rem)]', - - // With header and sections - 'site-header-sections:lg:top-[6.75rem]', - 'site-header-sections:lg:h-[calc(100vh_-_6.75rem)]', - - 'pt-6', - 'pb-4', - 'sidebar-filled:lg:pr-6', - 'page-no-toc:lg:pr-0', + <> + <aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar. + data-testid="table-of-contents" + id="table-of-contents" + className={tcls( + 'group', + 'text-sm', - 'hidden', - 'navigation-open:!flex', - 'lg:flex', - 'page-no-toc:lg:hidden', - 'page-no-toc:xl:flex', - 'site-header-none:page-no-toc:lg:flex', - 'flex-col', - 'gap-4', + 'grow-0', + 'shrink-0', + 'basis-full', + 'lg:basis-72', + 'page-no-toc:lg:basis-56', - 'navigation-open:border-b', - 'border-tint-subtle' - )} - > - {header && header} - <div // The actual sidebar, either shown with a filled bg or transparent. - className={tcls( - 'lg:-ms-5', - 'overflow-hidden', 'relative', - - 'flex', + 'z-[1]', + 'lg:sticky', + 'lg:mr-12', + + // Server-side static positioning + 'lg:top-0', + 'lg:h-screen', + 'announcement:lg:h-[calc(100vh-4.25rem)]', + + 'site-header:lg:top-16', + 'site-header:lg:h-[calc(100vh-4rem)]', + 'announcement:site-header:lg:h-[calc(100vh-4rem-4.25rem)]', + + 'site-header-sections:lg:top-[6.75rem]', + 'site-header-sections:lg:h-[calc(100vh-6.75rem)]', + 'announcement:site-header-sections:lg:h-[calc(100vh-6.75rem-4.25rem)]', + + // Client-side dynamic positioning (CSS vars applied by script) + '[html[style*="--toc-top-offset"]_&]:lg:!top-[var(--toc-top-offset)]', + '[html[style*="--toc-height"]_&]:lg:!h-[var(--toc-height)]', + + 'pt-6', + 'pb-4', + 'sidebar-filled:lg:pr-6', + 'page-no-toc:lg:pr-0', + + 'hidden', + 'navigation-open:!flex', + 'lg:flex', + 'page-no-toc:lg:hidden', + 'page-no-toc:xl:flex', + 'site-header-none:page-no-toc:lg:flex', 'flex-col', - 'flex-grow', - - 'sidebar-filled:bg-tint-subtle', - 'theme-muted:bg-tint-subtle', - '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', - '[html.sidebar-filled.theme-muted_&]:bg-tint-base', - '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', - 'page-no-toc:!bg-transparent', + 'gap-4', - 'sidebar-filled:rounded-xl', - 'straight-corners:rounded-none' + 'navigation-open:border-b', + 'border-tint-subtle' )} > - {innerHeader && <div className={tcls('px-5 *:my-4')}>{innerHeader}</div>} - <TOCScrollContainer // The scrollview inside the sidebar + {header && header} + <div // The actual sidebar, either shown with a filled bg or transparent. className={tcls( - 'flex', - 'flex-grow', - 'flex-col', - - 'p-2', - customization.trademark.enabled && 'lg:pb-20', - - 'overflow-y-auto', - 'lg:gutter-stable', - '[&::-webkit-scrollbar]:bg-transparent', - '[&::-webkit-scrollbar-thumb]:bg-transparent', - 'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle', - 'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7', - 'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8' + 'lg:-ms-5', + 'relative flex flex-grow flex-col overflow-hidden border-tint-subtle', + + 'sidebar-filled:bg-tint-subtle', + 'theme-muted:bg-tint-subtle', + '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', + '[html.sidebar-filled.theme-muted_&]:bg-tint-base', + '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', + '[html.sidebar-filled.theme-gradient_&]:border', + 'page-no-toc:!bg-transparent', + 'page-no-toc:!border-none', + + 'sidebar-filled:rounded-xl', + 'straight-corners:rounded-none', + '[html.sidebar-filled.circular-corners_&]:page-has-toc:rounded-3xl' )} > - <PagesList - rootPages={pages} - pages={pages} - context={context} - style={tcls( - 'page-no-toc:hidden', - 'sidebar-list-line:border-l', - 'border-tint-subtle' + {innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>} + <TOCScrollContainer // The scrollview inside the sidebar + className={tcls( + 'flex flex-grow flex-col p-2', + customization.trademark.enabled && 'lg:pb-20', + 'lg:gutter-stable overflow-y-auto', + '[&::-webkit-scrollbar]:bg-transparent', + '[&::-webkit-scrollbar-thumb]:bg-transparent', + 'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle', + 'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7', + 'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8' )} - /> - {customization.trademark.enabled ? ( - <Trademark - space={space} - customization={customization} - placement={SiteInsightsTrademarkPlacement.Sidebar} + > + <PagesList + rootPages={pages} + pages={pages} + context={context} + style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l" /> - ) : null} - </TOCScrollContainer> - </div> - </aside> + {customization.trademark.enabled ? ( + <Trademark + space={space} + customization={customization} + placement={SiteInsightsTrademarkPlacement.Sidebar} + /> + ) : null} + </TOCScrollContainer> + </div> + </aside> + <TableOfContentsScript /> + </> ); } diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContentsScript.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContentsScript.tsx new file mode 100644 index 0000000000..a4e6bea18c --- /dev/null +++ b/packages/gitbook/src/components/TableOfContents/TableOfContentsScript.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect } from 'react'; + +/** + * Adjusts TableOfContents height based on visible elements + */ +export function TableOfContentsScript() { + useEffect(() => { + const root = document.documentElement; + + // Calculate and set TOC dimensions + const updateTocLayout = () => { + // Get key elements + const header = document.getElementById('site-header'); + const banner = document.getElementById('announcement-banner'); + const footer = document.getElementById('site-footer'); + + // Set sticky top position based on header + const headerHeight = header?.offsetHeight ?? 0; + root.style.setProperty('--toc-top-offset', `${headerHeight}px`); + + // Start with full viewport height minus header + let height = window.innerHeight - headerHeight; + + // Subtract visible banner (if any) + if (banner && window.getComputedStyle(banner).display !== 'none') { + const bannerRect = banner.getBoundingClientRect(); + if (bannerRect.height > 0 && bannerRect.bottom > 0) { + height -= Math.min(bannerRect.height, bannerRect.bottom); + } + } + + // Subtract visible footer (if any) + if (footer) { + const footerRect = footer.getBoundingClientRect(); + if (footerRect.top < window.innerHeight) { + height -= Math.min(footerRect.height, window.innerHeight - footerRect.top); + } + } + + // Update height + root.style.setProperty('--toc-height', `${height}px`); + }; + + // Initial update + updateTocLayout(); + + // Let the browser handle scroll throttling naturally + window.addEventListener('scroll', updateTocLayout, { passive: true }); + window.addEventListener('resize', updateTocLayout, { passive: true }); + + // Use MutationObserver for DOM changes + const observer = new MutationObserver(() => { + requestAnimationFrame(updateTocLayout); + }); + + // Only observe what matters + observer.observe(document.documentElement, { + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'], + }); + + return () => { + observer.disconnect(); + window.removeEventListener('scroll', updateTocLayout); + window.removeEventListener('resize', updateTocLayout); + }; + }, []); + + return null; +} diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index aa4700482b..681df69080 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -2,12 +2,12 @@ import { Icon } from '@gitbook/icons'; import { motion } from 'framer-motion'; -import React from 'react'; +import React, { useRef } from 'react'; import { tcls } from '@/lib/tailwind'; -import { useCurrentPagePath, useToggleAnimation } from '../hooks'; -import { Link, type LinkInsightsProps } from '../primitives'; +import { useCurrentPagePath } from '../hooks'; +import { Link, type LinkInsightsProps, type LinkProps } from '../primitives'; import { useScrollToActiveTOCItem } from './TOCScroller'; /** @@ -24,128 +24,158 @@ export function ToggleableLinkItem( const { href, children, descendants, pathname, insights } = props; const currentPagePath = useCurrentPagePath(); - const isActive = currentPagePath === pathname; - const hasDescendants = !!descendants; - const hasActiveDescendant = - hasDescendants && (isActive || currentPagePath.startsWith(`${pathname}/`)); - const [isVisible, setIsVisible] = React.useState(hasActiveDescendant); + if (!descendants) { + return ( + <LinkItem href={href} insights={insights} isActive={isActive}> + {children} + </LinkItem> + ); + } - // Update the visibility of the children, if we are navigating to a descendant. - React.useEffect(() => { - if (!hasDescendants) { - return; - } + return ( + <DescendantsRenderer + descendants={descendants} + defaultIsOpen={isActive || currentPagePath.startsWith(`${pathname}/`)} + > + {({ descendants, toggler }) => ( + <> + <LinkItem href={href} insights={insights} isActive={isActive}> + {children} + {toggler} + </LinkItem> + {descendants} + </> + )} + </DescendantsRenderer> + ); +} - setIsVisible((prev) => prev || hasActiveDescendant); - }, [hasActiveDescendant, hasDescendants]); +function LinkItem( + props: Pick<LinkProps, 'href' | 'insights' | 'children'> & { + isActive: boolean; + } +) { + const { isActive, href, insights, children } = props; + const anchorRef = useRef<HTMLAnchorElement>(null); + useScrollToActiveTOCItem({ anchorRef, isActive }); + return ( + <Link + ref={anchorRef} + href={href} + insights={insights} + aria-current={isActive ? 'page' : undefined} + classNames={[ + 'ToggleableLinkItemStyles', + ...(isActive ? ['ToggleableLinkItemActiveStyles' as const] : []), + ]} + > + {children} + </Link> + ); +} - const { show, hide, scope } = useToggleAnimation({ hasDescendants, isVisible }); +function DescendantsRenderer(props: { + defaultIsOpen: boolean; + descendants: React.ReactNode; + children: (renderProps: { + descendants: React.ReactNode; + toggler: React.ReactNode; + }) => React.ReactNode; +}) { + const { defaultIsOpen, children, descendants } = props; + const [isOpen, setIsOpen] = React.useState(defaultIsOpen); - const linkRef = React.createRef<HTMLAnchorElement>(); - useScrollToActiveTOCItem({ linkRef, isActive }); + // Update the visibility of the children if one of the descendants becomes active. + React.useEffect(() => { + if (defaultIsOpen) { + setIsOpen(defaultIsOpen); + } + }, [defaultIsOpen]); + + return children({ + toggler: ( + <Toggler + isLinkActive={isOpen} + isOpen={isOpen} + onToggle={() => { + setIsOpen((prev) => !prev); + }} + /> + ), + descendants: <Descendants isVisible={isOpen}>{descendants}</Descendants>, + }); +} +function Toggler(props: { + isLinkActive: boolean; + isOpen: boolean; + onToggle: () => void; +}) { + const { isLinkActive, isOpen, onToggle } = props; return ( - <> - <Link - ref={linkRef} - href={href} - insights={insights} - {...(isActive ? { 'aria-current': 'page' } : {})} + <span + className={tcls( + 'group', + 'relative', + 'rounded-full', + 'straight-corners:rounded-sm', + 'w-5', + 'h-5', + 'after:grid-area-1-1', + 'after:absolute', + 'after:-top-1', + 'after:grid', + 'after:-left-1', + 'after:w-7', + 'after:h-7', + 'hover:bg-tint-active', + 'hover:text-current', + isLinkActive && 'hover:bg-tint-hover' + )} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onToggle(); + }} + > + <Icon + icon="chevron-right" className={tcls( - 'group/toclink relative transition-colors', - 'flex flex-row justify-between', - 'rounded-md straight-corners:rounded-none p-1.5 pl-3', - 'text-balance font-normal text-sm text-tint-strong/7 hover:bg-tint-hover hover:text-tint-strong contrast-more:text-tint-strong', - 'hover:contrast-more:text-tint-strong hover:contrast-more:ring-1 hover:contrast-more:ring-tint-12', - 'before:contents[] before:-left-px before:absolute before:inset-y-0', - 'sidebar-list-line:rounded-l-none sidebar-list-line:before:w-px sidebar-list-default:[&+div_a]:rounded-l-none [&+div_a]:pl-5 sidebar-list-default:[&+div_a]:before:w-px', - - isActive && [ - 'font-semibold', - 'sidebar-list-line:before:w-0.5', - - 'before:bg-primary-solid', - 'text-primary-subtle', - 'contrast-more:text-primary', - - 'sidebar-list-pill:bg-primary', - '[html.sidebar-list-pill.theme-muted_&]:bg-primary-hover', - '[html.sidebar-list-pill.theme-bold.tint_&]:bg-primary-hover', - '[html.sidebar-filled.sidebar-list-pill.theme-muted_&]:bg-primary', - '[html.sidebar-filled.sidebar-list-pill.theme-bold.tint_&]:bg-primary', - - 'hover:bg-primary-hover', - 'hover:text-primary', - 'hover:before:bg-primary-solid-hover', - 'sidebar-list-pill:hover:bg-primary-hover', - - 'contrast-more:text-primary', - 'contrast-more:hover:text-primary-strong', - 'contrast-more:bg-primary', - 'contrast-more:ring-1', - 'contrast-more:ring-primary', - 'contrast-more:hover:ring-primary-hover', - ] + 'm-1 grid size-3 flex-shrink-0 text-current opacity-6 transition', + 'group-hover:opacity-11 contrast-more:opacity-11', + isOpen ? 'rotate-90' : 'rotate-0' )} - > - {children} - {hasDescendants ? ( - <span - className={tcls( - 'group', - 'relative', - 'rounded-full', - 'straight-corners:rounded-sm', - 'w-5', - 'h-5', - 'after:grid-area-1-1', - 'after:absolute', - 'after:-top-1', - 'after:grid', - 'after:-left-1', - 'after:w-7', - 'after:h-7', - 'hover:bg-tint-active', - 'hover:text-current', - isActive ? ['hover:bg-tint-hover'] : [] - )} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - setIsVisible((prev) => !prev); - }} - > - <Icon - icon="chevron-right" - className={tcls( - 'grid', - 'flex-shrink-0', - 'size-3', - 'm-1', - 'transition-[opacity]', - 'text-current', - 'transition-transform', - 'opacity-6', - 'group-hover:opacity-11', - 'contrast-more:opacity-11', + /> + </span> + ); +} - isVisible ? ['rotate-90'] : ['rotate-0'] - )} - /> - </span> - ) : null} - </Link> - {hasDescendants ? ( - <motion.div - ref={scope} - className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')} - initial={isVisible ? show : hide} - > - {descendants} - </motion.div> - ) : null} - </> +const show = { + opacity: 1, + height: 'auto', +}; +const hide = { + opacity: 0, + height: 0, + transitionEnd: { + display: 'none', + }, +}; + +function Descendants(props: { + isVisible: boolean; + children: React.ReactNode; +}) { + const { isVisible, children } = props; + return ( + <motion.div + className="overflow-hidden" + animate={isVisible ? show : hide} + initial={isVisible ? show : hide} + > + {children} + </motion.div> ); } diff --git a/packages/gitbook/src/components/TableOfContents/Trademark.tsx b/packages/gitbook/src/components/TableOfContents/Trademark.tsx index 19f5aed660..aa2d37c0ac 100644 --- a/packages/gitbook/src/components/TableOfContents/Trademark.tsx +++ b/packages/gitbook/src/components/TableOfContents/Trademark.tsx @@ -102,6 +102,7 @@ export function TrademarkLink(props: { 'rounded-lg', 'straight-corners:rounded-none', + 'circular-corners:rounded-2xl', 'hover:bg-tint', 'hover:text-tint-strong', diff --git a/packages/gitbook/src/components/TableOfContents/index.ts b/packages/gitbook/src/components/TableOfContents/index.ts index 715a3a239e..6eeff92697 100644 --- a/packages/gitbook/src/components/TableOfContents/index.ts +++ b/packages/gitbook/src/components/TableOfContents/index.ts @@ -1 +1,4 @@ -export * from './TableOfContents'; +export { TableOfContents } from './TableOfContents'; +export { PagesList } from './PagesList'; +export { TOCScrollContainer } from './TOCScroller'; +export { Trademark } from './Trademark'; diff --git a/packages/gitbook/src/components/TableOfContents/styles.ts b/packages/gitbook/src/components/TableOfContents/styles.ts new file mode 100644 index 0000000000..3ceb636941 --- /dev/null +++ b/packages/gitbook/src/components/TableOfContents/styles.ts @@ -0,0 +1,56 @@ +export const PageLinkItemStyles = [ + 'flex', + 'justify-start', + 'items-center', + 'gap-3', + 'p-1.5', + 'pl-3', + 'text-sm', + 'transition-colors', + 'duration-100', + 'text-tint-strong/7', + 'rounded-md', + 'straight-corners:rounded-none', + 'circular-corners:rounded-xl', + 'before:content-none', + 'font-normal', + 'hover:bg-tint', + 'hover:text-tint-strong', +]; + +export const ToggleableLinkItemStyles = [ + 'group/toclink toclink relative transition-colors', + 'flex flex-row justify-between', + 'circular-corners:rounded-2xl rounded-md straight-corners:rounded-none p-1.5 pl-3', + 'text-balance font-normal text-sm text-tint-strong/7 hover:bg-tint-hover hover:text-tint-strong contrast-more:text-tint-strong', + 'hover:contrast-more:text-tint-strong hover:contrast-more:ring-1 hover:contrast-more:ring-tint-12', + 'before:contents[] before:-left-px before:absolute before:inset-y-0', + 'sidebar-list-line:rounded-l-none sidebar-list-line:before:w-px sidebar-list-default:[&+div_a]:rounded-l-none [&+div_a]:pl-5 sidebar-list-default:[&+div_a]:before:w-px', +]; + +export const ToggleableLinkItemActiveStyles = [ + 'font-semibold', + 'sidebar-list-line:before:w-0.5', + + 'before:bg-primary-solid', + 'text-primary-subtle', + 'contrast-more:text-primary', + + 'sidebar-list-pill:bg-primary', + '[html.sidebar-list-pill.theme-muted_&]:bg-primary-hover', + '[html.sidebar-list-pill.theme-bold.tint_&]:bg-primary-hover', + '[html.sidebar-filled.sidebar-list-pill.theme-muted_&]:bg-primary', + '[html.sidebar-filled.sidebar-list-pill.theme-bold.tint_&]:bg-primary', + + 'hover:bg-primary-hover', + 'hover:text-primary', + 'hover:before:bg-primary-solid-hover', + 'sidebar-list-pill:hover:bg-primary-hover', + + 'contrast-more:text-primary', + 'contrast-more:hover:text-primary-strong', + 'contrast-more:bg-primary', + 'contrast-more:ring-1', + 'contrast-more:ring-primary', + 'contrast-more:hover:ring-primary-hover', +]; diff --git a/packages/gitbook/src/components/ThemeToggler/ThemeToggler.tsx b/packages/gitbook/src/components/ThemeToggler/ThemeToggler.tsx index af943dcbce..93f15e47c4 100644 --- a/packages/gitbook/src/components/ThemeToggler/ThemeToggler.tsx +++ b/packages/gitbook/src/components/ThemeToggler/ThemeToggler.tsx @@ -69,6 +69,7 @@ function ThemeButton(props: { 'p-2', 'rounded', 'straight-corners:rounded-none', + 'circular-corners:rounded-full', 'transition-all', 'text-tint', 'contrast-more:text-tint-strong', diff --git a/packages/gitbook/src/components/hooks/useCurrentPagePath.ts b/packages/gitbook/src/components/hooks/useCurrentPagePath.ts index f1f719ddd7..23943e00b0 100644 --- a/packages/gitbook/src/components/hooks/useCurrentPagePath.ts +++ b/packages/gitbook/src/components/hooks/useCurrentPagePath.ts @@ -3,6 +3,7 @@ import { useParams, useSelectedLayoutSegment } from 'next/navigation'; import { removeLeadingSlash } from '@/lib/paths'; +import { useMemo } from 'react'; /** * Return the page of the current page being rendered. @@ -14,9 +15,11 @@ export function useCurrentPagePath() { // For V1, we use the selected layout segment. const rawActiveSegment = useSelectedLayoutSegment() ?? ''; - if (typeof params.pagePath === 'string') { - return removeLeadingSlash(decodeURIComponent(params.pagePath)); - } + return useMemo(() => { + if (typeof params.pagePath === 'string') { + return removeLeadingSlash(decodeURIComponent(params.pagePath)); + } - return decodeURIComponent(rawActiveSegment); + return decodeURIComponent(rawActiveSegment); + }, [params.pagePath, rawActiveSegment]); } diff --git a/packages/gitbook/src/components/hooks/useToggleAnimation.ts b/packages/gitbook/src/components/hooks/useToggleAnimation.ts index c028155142..3c9c02fad7 100644 --- a/packages/gitbook/src/components/hooks/useToggleAnimation.ts +++ b/packages/gitbook/src/components/hooks/useToggleAnimation.ts @@ -1,7 +1,7 @@ -import { stagger, useAnimate } from 'framer-motion'; -import React from 'react'; +'use client'; -import { useIsMounted } from '.'; +import { stagger, useAnimate } from 'framer-motion'; +import React, { useLayoutEffect } from 'react'; const show = { opacity: 1, @@ -19,44 +19,35 @@ const hide = { const staggerMenuItems = stagger(0.02, { ease: (p) => p ** 2 }); -export function useToggleAnimation({ - hasDescendants, - isVisible, -}: { - hasDescendants: boolean; - isVisible: boolean; -}) { - const isMounted = useIsMounted(); +export function useToggleAnimation(isVisible: boolean) { const [scope, animate] = useAnimate(); + const previousIsVisibleRef = React.useRef<boolean | undefined>(undefined); + useLayoutEffect(() => { + previousIsVisibleRef.current = isVisible; + }); + const previousIsVisible = previousIsVisibleRef.current; // Animate the visibility of the children // only after the initial state. React.useEffect(() => { - if (!isMounted || !hasDescendants) { + if (previousIsVisible === undefined || previousIsVisible === isVisible) { return; } + try { - animate(scope.current, isVisible ? show : hide, { - duration: 0.1, - }); + animate(scope.current, isVisible ? show : hide, { duration: 0.1 }); const selector = '& > ul > li'; - if (isVisible) - animate( - selector, - { opacity: 1 }, - { - delay: staggerMenuItems, - } - ); - else { + if (isVisible) { + animate(selector, { opacity: 1 }, { delay: staggerMenuItems }); + } else { animate(selector, { opacity: 0 }); } } catch (error) { // The selector can crash in some browsers, we ignore it as the animation is not critical. console.error(error); } - }, [isVisible, isMounted, hasDescendants, animate, scope]); + }, [previousIsVisible, isVisible, animate, scope]); return { show, hide, scope }; } diff --git a/packages/gitbook/src/components/layout.ts b/packages/gitbook/src/components/layout.ts index 250d3366ab..6a9b39a982 100644 --- a/packages/gitbook/src/components/layout.ts +++ b/packages/gitbook/src/components/layout.ts @@ -14,7 +14,7 @@ export const CONTAINER_STYLE: ClassValue = [ 'md:px-8', 'max-w-screen-2xl', 'mx-auto', - 'page-full-width:max-w-full', + // 'site-full-width:max-w-full', ]; /** diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 01d8880077..4ccc10b6b7 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -6,6 +6,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import { Link, type LinkInsightsProps } from './Link'; +import { useClassnames } from './StyleProvider'; type ButtonProps = { href?: string; @@ -18,7 +19,7 @@ type ButtonProps = { } & LinkInsightsProps & HTMLAttributes<HTMLElement>; -const variantClasses = { +export const variantClasses = { primary: [ 'bg-primary-solid', 'text-contrast-primary-solid', @@ -40,8 +41,10 @@ const variantClasses = { ], secondary: [ 'bg-tint', + 'depth-flat:bg-transparent', 'text-tint', 'hover:bg-tint-hover', + 'depth-flat:hover:bg-tint-hover', 'hover:text-primary', 'contrast-more:bg-tint-subtle', ], @@ -60,53 +63,22 @@ export function Button({ ...rest }: ButtonProps & { target?: HTMLAttributeAnchorTarget }) { const sizes = { - default: ['text-base', 'px-4', 'py-2'], - medium: ['text-sm', 'px-3', 'py-1.5'], + default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'], + medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'], small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'], }; const sizeClasses = sizes[size] || sizes.default; - const domClassName = tcls( - 'button', - 'inline-flex', - 'items-center', - 'gap-2', - 'rounded-md', - 'straight-corners:rounded-none', - // 'place-self-start', - - 'ring-1', - 'ring-tint', - 'hover:ring-tint-hover', - - 'shadow-sm', - 'shadow-tint', - 'dark:shadow-tint-1', - 'hover:shadow-md', - 'active:shadow-none', - - 'contrast-more:ring-tint-12', - 'contrast-more:hover:ring-2', - 'contrast-more:hover:ring-tint-12', - - 'hover:scale-105', - 'active:scale-100', - 'transition-all', - - 'grow-0', - 'shrink-0', - 'truncate', - variantClasses[variant], - sizeClasses, - className - ); + const domClassName = tcls(variantClasses[variant], sizeClasses, className); + const buttonOnlyClassNames = useClassnames(['ButtonStyles']); if (href) { return ( <Link href={href} className={domClassName} + classNames={['ButtonStyles']} insights={insights} aria-label={label} target={target} @@ -119,7 +91,12 @@ export function Button({ } return ( - <button type="button" className={domClassName} aria-label={label} {...rest}> + <button + type="button" + className={tcls(domClassName, buttonOnlyClassNames)} + aria-label={label} + {...rest} + > {icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null} {iconOnly ? null : label} </button> diff --git a/packages/gitbook/src/components/primitives/Card.tsx b/packages/gitbook/src/components/primitives/Card.tsx index bb40142f31..1195c78476 100644 --- a/packages/gitbook/src/components/primitives/Card.tsx +++ b/packages/gitbook/src/components/primitives/Card.tsx @@ -17,27 +17,7 @@ export async function Card( const { title, leadingIcon, href, preTitle, postTitle, style, insights } = props; return ( - <Link - href={href} - className={tcls( - 'group', - 'flex', - 'flex-row', - 'justify-between', - 'items-center', - 'gap-4', - 'ring-1', - 'ring-tint-subtle', - 'rounded', - 'straight-corners:rounded-none', - 'px-5', - 'py-3', - 'transition-shadow', - 'hover:ring-primary-hover', - style - )} - insights={insights} - > + <Link href={href} className={tcls(style)} classNames={['CardStyles']} insights={insights}> {leadingIcon} <span className={tcls('flex', 'flex-col', 'flex-1')}> {preTitle ? ( diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index 5cb026d3a5..e15a01ca11 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -3,7 +3,9 @@ import NextLink, { type LinkProps as NextLinkProps } from 'next/link'; import React from 'react'; +import { tcls } from '@/lib/tailwind'; import { type TrackEventInput, useTrackEvent } from '../Insights'; +import { type DesignTokenName, useClassnames } from './StyleProvider'; // Props from Next, which includes NextLinkProps and all the things anchor elements support. type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps> & @@ -24,6 +26,8 @@ export type LinkProps = Omit<BaseLinkProps, 'href'> & LinkInsightsProps & { /** Enforce href is passed as a string (not a URL). */ href: string; + /** This is a temporary solution designed to reduce the number of tailwind class passed to the client */ + classNames?: DesignTokenName[]; }; /** @@ -34,8 +38,9 @@ export const Link = React.forwardRef(function Link( props: LinkProps, ref: React.Ref<HTMLAnchorElement> ) { - const { href, prefetch, children, insights, ...domProps } = props; + const { href, prefetch, children, insights, classNames, className, ...domProps } = props; const trackEvent = useTrackEvent(); + const forwardedClassNames = useClassnames(classNames || []); // Use a real anchor tag for external links,s and a Next.js Link for internal links. // If we use a NextLink for external links, Nextjs won't rerender the top-level layouts. @@ -62,19 +67,74 @@ export const Link = React.forwardRef(function Link( // as this will be rendered on the server and it could result in a mismatch. if (isExternalLink(href)) { return ( - <a ref={ref} {...domProps} href={href} onClick={onClick}> + <a + ref={ref} + className={tcls(...forwardedClassNames, className)} + {...domProps} + href={href} + onClick={onClick} + > {children} </a> ); } return ( - <NextLink ref={ref} href={href} prefetch={prefetch} {...domProps} onClick={onClick}> + <NextLink + ref={ref} + href={href} + prefetch={prefetch} + className={tcls(...forwardedClassNames, className)} + {...domProps} + onClick={onClick} + > {children} </NextLink> ); }); +/** + * A box used to contain a link overlay. + * It is used to create a clickable area that can contain other elements. + */ +export const LinkBox = React.forwardRef(function LinkBox( + props: React.BaseHTMLAttributes<HTMLDivElement> & { classNames?: DesignTokenName[] }, + ref: React.Ref<HTMLDivElement> +) { + const { children, className, classNames, ...domProps } = props; + const forwardedClassNames = useClassnames(classNames || []); + return ( + <div + ref={ref} + {...domProps} + className={tcls('elevate-link relative', className, forwardedClassNames)} + > + {children} + </div> + ); +}); + +/** + * A link overlay that can be used to create a clickable area on top of other elements. + * It is used to create a link that covers the entire area of the element without encapsulating it in a link tag. + * This is useful to avoid nesting links inside links. + */ +export const LinkOverlay = React.forwardRef(function LinkOverlay( + props: LinkProps, + ref: React.Ref<HTMLAnchorElement> +) { + const { children, className, ...domProps } = props; + return ( + <Link + ref={ref} + {...domProps} + className={tcls('link-overlay absolute inset-0 z-10', className)} + > + {children} + </Link> + ); +}); + /** * Check if a link is external, compared to an origin. */ diff --git a/packages/gitbook/src/components/primitives/StyleProvider.tsx b/packages/gitbook/src/components/primitives/StyleProvider.tsx new file mode 100644 index 0000000000..a1e1b1fe03 --- /dev/null +++ b/packages/gitbook/src/components/primitives/StyleProvider.tsx @@ -0,0 +1,32 @@ +'use client'; +import type { ClassValue } from '@/lib/tailwind'; + +import { RecordCardStyles } from '../DocumentView/Table/styles'; +import { + PageLinkItemStyles, + ToggleableLinkItemActiveStyles, + ToggleableLinkItemStyles, +} from '../TableOfContents/styles'; +import { ButtonStyles, CardStyles, LinkStyles } from './styles'; + +const styles = { + LinkStyles, + CardStyles, + ButtonStyles, + RecordCardStyles, + PageLinkItemStyles, + ToggleableLinkItemStyles, + ToggleableLinkItemActiveStyles, +}; + +export type DesignTokenName = keyof typeof styles; + +/** + * Get the class names for the given design token names. + * TODO: remove this once we figure out a better solution. Likely with TW4. + * @param names The design token names to get class names for. + * @returns The class names for the given design token names. + */ +export function useClassnames(names: DesignTokenName[]): ClassValue[] { + return names.flatMap((name) => styles[name] || []); +} diff --git a/packages/gitbook/src/components/primitives/StyledLink.tsx b/packages/gitbook/src/components/primitives/StyledLink.tsx index 3fb346f5f6..f3b391b67e 100644 --- a/packages/gitbook/src/components/primitives/StyledLink.tsx +++ b/packages/gitbook/src/components/primitives/StyledLink.tsx @@ -1,35 +1,18 @@ -import { type ClassValue, tcls } from '@/lib/tailwind'; +import type { ClassValue } from '@/lib/tailwind'; import { Link, type LinkProps } from '../primitives/Link'; - -export const linkStyles = [ - 'underline', - 'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin. - 'underline-offset-2', - 'links-accent:underline-offset-4', - - 'links-default:decoration-primary/6', - 'links-default:text-primary-subtle', - 'links-default:hover:text-primary-strong', - 'links-default:contrast-more:text-primary', - 'links-default:contrast-more:hover:text-primary-strong', - - 'links-accent:decoration-primary-subtle', - 'links-accent:hover:decoration-[3px]', - 'links-accent:hover:[text-decoration-skip-ink:none]', - - 'transition-all', - 'duration-100', -]; +import type { DesignTokenName } from './StyleProvider'; /** * Styled version of Link component. */ export function StyledLink(props: Omit<LinkProps, 'style'> & { className?: ClassValue }) { - const { className, ...rest } = props; + const { classNames, ...rest } = props; + + const classNamesToForward: DesignTokenName[] = [...(classNames || []), 'LinkStyles']; return ( - <Link {...rest} className={tcls(linkStyles, className)}> + <Link {...rest} classNames={classNamesToForward}> {props.children} </Link> ); diff --git a/packages/gitbook/src/components/primitives/styles.ts b/packages/gitbook/src/components/primitives/styles.ts new file mode 100644 index 0000000000..9d465aac4a --- /dev/null +++ b/packages/gitbook/src/components/primitives/styles.ts @@ -0,0 +1,74 @@ +import type { ClassValue } from '@/lib/tailwind'; + +export const ButtonStyles = [ + 'button', + 'inline-flex', + 'items-center', + 'gap-2', + 'rounded-md', + 'straight-corners:rounded-none', + 'circular-corners:rounded-full', + // 'place-self-start', + + 'ring-1', + 'ring-tint', + 'hover:ring-tint-hover', + + 'shadow-sm', + 'shadow-tint', + 'dark:shadow-tint-1', + 'hover:shadow-md', + 'active:shadow-none', + 'depth-flat:shadow-none', + + 'contrast-more:ring-tint-12', + 'contrast-more:hover:ring-2', + 'contrast-more:hover:ring-tint-12', + + 'hover:scale-104', + 'depth-flat:hover:scale-100', + 'active:scale-100', + 'transition-all', + + 'grow-0', + 'shrink-0', + 'truncate', +] as ClassValue[]; + +export const CardStyles = [ + 'group', + 'flex', + 'flex-row', + 'justify-between', + 'items-center', + 'gap-4', + 'ring-1', + 'ring-tint-subtle', + 'rounded', + 'straight-corners:rounded-none', + 'circular-corners:rounded-2xl', + 'px-5', + 'py-3', + 'transition-shadow', + 'hover:ring-primary-hover', +] as ClassValue[]; + +export const LinkStyles = [ + 'underline', + 'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin. + 'underline-offset-2', + 'links-accent:underline-offset-4', + + 'links-default:decoration-primary/6', + 'links-default:text-primary-subtle', + 'links-default:hover:text-primary-strong', + 'links-default:contrast-more:text-primary', + 'links-default:contrast-more:hover:text-primary-strong', + + 'links-accent:decoration-primary-subtle', + 'links-accent:hover:decoration-[3px]', + 'links-accent:hover:[text-decoration-skip-ink:none]', + + 'transition-all', + 'duration-100', +] as ClassValue[]; diff --git a/packages/gitbook/src/fonts/custom.test.ts b/packages/gitbook/src/fonts/custom.test.ts index ce2616187e..b430b76241 100644 --- a/packages/gitbook/src/fonts/custom.test.ts +++ b/packages/gitbook/src/fonts/custom.test.ts @@ -28,6 +28,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { ], }, ], + permissions: { + edit: false, + }, }, multiWeight: { @@ -81,6 +84,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { ], }, ], + permissions: { + edit: false, + }, }, multiSource: { @@ -99,6 +105,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { ], }, ], + permissions: { + edit: false, + }, }, missingFormat: { @@ -117,6 +126,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { ], }, ], + permissions: { + edit: false, + }, }, empty: { @@ -124,6 +136,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { custom: true, fontFamily: 'Empty Font', fontFaces: [], + permissions: { + edit: false, + }, }, specialChars: { @@ -136,6 +151,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { sources: [{ url: 'https://example.com/fonts/special.woff2', format: 'woff2' }], }, ], + permissions: { + edit: false, + }, }, complex: { @@ -158,6 +176,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { ], }, ], + permissions: { + edit: false, + }, }, variousURLs: { @@ -174,6 +195,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = { ], }, ], + permissions: { + edit: false, + }, }, }; diff --git a/packages/gitbook/src/fonts/custom.ts b/packages/gitbook/src/fonts/custom.ts index cb31f771ed..84abdf8b27 100644 --- a/packages/gitbook/src/fonts/custom.ts +++ b/packages/gitbook/src/fonts/custom.ts @@ -1,9 +1,9 @@ -import type { CustomizationFontDefinition } from '@gitbook/api'; +import type { CustomizationFontDefinitionInput } from '@gitbook/api'; /** * Define the custom font faces and set the --font-custom to the custom font name */ -export function generateFontFacesCSS(customFont: CustomizationFontDefinition): string { +export function generateFontFacesCSS(customFont: CustomizationFontDefinitionInput): string { const { fontFaces } = customFont; // Generate font face declarations for all weights @@ -45,7 +45,7 @@ export function generateFontFacesCSS(customFont: CustomizationFontDefinition): s /** * Get a list of font sources to preload (only 400 and 700 weights) */ -export function getFontSourcesToPreload(customFont: CustomizationFontDefinition) { +export function getFontSourcesToPreload(customFont: CustomizationFontDefinitionInput) { return customFont.fontFaces.filter( (face): face is typeof face & { weight: 400 | 700 } => face.weight === 400 || face.weight === 700 diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index b42da6c55f..9ed91a152c 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -40,7 +40,7 @@ export const de = { cookies_prompt_privacy: 'Datenschutzrichtlinie', cookies_accept: 'Akzeptieren', cookies_reject: 'Ablehnen', - cookies_close: 'Schließen', + close: 'Schließen', edit_on_git: 'Bearbeiten auf ${1}', notfound_title: 'Seite nicht gefunden', notfound: 'Die gesuchte Seite existiert nicht.', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index df9cc50993..6fa837ebb8 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -40,7 +40,7 @@ export const en = { cookies_prompt_privacy: 'privacy policy', cookies_accept: 'Accept', cookies_reject: 'Reject', - cookies_close: 'Close', + close: 'Close', edit_on_git: 'Edit on ${1}', notfound_title: 'Page not found', notfound: "The page you are looking for doesn't exist.", diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 2d0d465652..d2ca1bb9d5 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -42,7 +42,7 @@ export const es: TranslationLanguage = { cookies_prompt_privacy: 'política de privacidad', cookies_accept: 'Aceptar', cookies_reject: 'Rechazar', - cookies_close: 'Cerrar', + close: 'Cerrar', edit_on_git: 'Editar en ${1}', notfound_title: 'Página no encontrada', notfound: 'La página que buscas no existe.', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 71d340332b..922f7554d0 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -42,7 +42,7 @@ export const fr: TranslationLanguage = { cookies_prompt_privacy: 'politique de confidentialité', cookies_accept: 'Accepter', cookies_reject: 'Rejeter', - cookies_close: 'Fermer', + close: 'Fermer', edit_on_git: 'Modifier sur ${1}', notfound_title: 'Page non trouvée', notfound: "La page que vous cherchez n'existe pas.", diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index f3f5480689..6eaa789ed8 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -42,7 +42,7 @@ export const ja: TranslationLanguage = { cookies_prompt_privacy: 'プライバシーポリシー', cookies_accept: '同意する', cookies_reject: '拒否する', - cookies_close: '閉じる', + close: '閉じる', edit_on_git: '${1}で編集', notfound_title: 'ページが見つかりません', notfound: 'お探しのページは存在しません。', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index 6bfb45e9a6..d61bfb71d9 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -42,7 +42,7 @@ export const nl: TranslationLanguage = { cookies_prompt_privacy: 'privacyverklaring', cookies_accept: 'Accepteren', cookies_reject: 'Weigeren', - cookies_close: 'Sluiten', + close: 'Sluiten', edit_on_git: 'Bewerk op ${1}', notfound_title: 'Pagina niet gevonden', notfound: 'De pagina die je zoekt, bestaat niet.', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 6be6b413e3..ddd94e5466 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -42,7 +42,7 @@ export const no: TranslationLanguage = { cookies_prompt_privacy: 'personvernerklæringen', cookies_accept: 'Godta', cookies_reject: 'Avslå', - cookies_close: 'Lukk', + close: 'Lukk', edit_on_git: 'Rediger på ${1}', notfound_title: 'Siden ble ikke funnet', notfound: 'Siden du leter etter eksisterer ikke.', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 35a40cd45f..c3e2244785 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -40,7 +40,7 @@ export const pt_br = { cookies_prompt_privacy: 'política de privacidade', cookies_accept: 'Aceitar', cookies_reject: 'Rejeitar', - cookies_close: 'Fechar', + close: 'Fechar', edit_on_git: 'Editar no ${1}', notfound_title: 'Página não encontrada', notfound: 'A página que você está procurando não existe.', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index 08efdddde6..096b0d8cb3 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -40,7 +40,7 @@ export const zh: TranslationLanguage = { cookies_prompt_privacy: '隐私政策', cookies_accept: '接受', cookies_reject: '拒绝', - cookies_close: '关闭', + close: '关闭', edit_on_git: '在${1}上编辑', notfound_title: '页面未找到', notfound: '您要找的页面不存在。', diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 2bb2039bc3..cb612664b0 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -267,6 +267,7 @@ export const getPublishedContentByUrl = cache({ const parsed = parseCacheResponse(response); + // biome-ignore lint/suspicious/noConsole: log the ttl of the token console.log( `Parsed ttl: ${parsed.ttl} at ${Date.now()}, for ${'apiToken' in response.data ? response.data.apiToken : '<no-token>'}` ); @@ -492,6 +493,39 @@ export const getRevisionPageByPath = cache({ }, }); +/** + * Get a document from a page by its ID + */ +export const getRevisionPageDocument = cache({ + name: 'api.getRevisionPageDocument.v1', + tag: (spaceId, revisionId) => + getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + tagImmutable: true, + getKeySuffix: getAPIContextId, + get: async ( + spaceId: string, + revisionId: string, + pageId: string, + options: CacheFunctionOptions + ) => { + const apiCtx = await api(); + const response = await apiCtx.client.spaces.getPageDocumentInRevisionById( + spaceId, + revisionId, + pageId, + { + evaluated: true, + }, + { + ...noCacheFetchOptions, + signal: options.signal, + } + ); + + return cacheResponse(response, cacheTtl_7days); + }, +}); + /** * Resolve a file by its ID. * It should not be used directly, use `getRevisionFile` instead. @@ -764,7 +798,12 @@ export const getComputedDocument = cache({ * Mimic the validation done on source server-side to reduce API usage. */ function validateSiteRedirectSource(source: string) { - return source.length <= 512 && /^\/[a-zA-Z0-9-_.\\/]+[a-zA-Z0-9-_.]$/.test(source); + return ( + source.length <= 512 && + /^\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+(?:\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+)*$/.test( + source + ) + ); } /** diff --git a/packages/gitbook/src/lib/customization.ts b/packages/gitbook/src/lib/customization.ts index 38440234b1..d23a0a88cb 100644 --- a/packages/gitbook/src/lib/customization.ts +++ b/packages/gitbook/src/lib/customization.ts @@ -12,9 +12,12 @@ export async function getDynamicCustomizationSettings( ): Promise<SiteCustomizationSettings> { const headersList = await headers(); const extend = headersList.get(MiddlewareHeaders.Customization); + if (extend) { try { - const parsedSettings = rison.decode_object<SiteCustomizationSettings>(extend); + // We need to decode it first as it is URL encoded, then decode the Rison object + const unencoded = decodeURIComponent(extend); + const parsedSettings = rison.decode_object<SiteCustomizationSettings>(unencoded); return parsedSettings; } catch (_error) {} diff --git a/packages/gitbook/src/lib/document-sections.ts b/packages/gitbook/src/lib/document-sections.ts index 38619a5881..9e59122365 100644 --- a/packages/gitbook/src/lib/document-sections.ts +++ b/packages/gitbook/src/lib/document-sections.ts @@ -1,4 +1,4 @@ -import type { JSONDocument } from '@gitbook/api'; +import type { DocumentBlock, JSONDocument } from '@gitbook/api'; import type { GitBookAnyContext } from '@v2/lib/context'; import { getNodeText } from './document'; @@ -19,58 +19,102 @@ export interface DocumentSection { export async function getDocumentSections( context: GitBookAnyContext, document: JSONDocument +): Promise<DocumentSection[]> { + return getSectionsFromNodes(document.nodes, context); +} + +/** + * Extract a list of sections from a list of nodes. + */ +async function getSectionsFromNodes( + nodes: DocumentBlock[], + context: GitBookAnyContext ): Promise<DocumentSection[]> { const sections: DocumentSection[] = []; let depth = 0; - for (const block of document.nodes) { - if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) { - if (block.type === 'heading-1') { + for (const block of nodes) { + switch (block.type) { + case 'heading-1': { + const id = block.meta?.id; + if (!id) { + continue; + } depth = 1; - } - const title = getNodeText(block); - const id = block.meta.id; - - sections.push({ - id, - title, - depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1, - }); - } - - if ((block.type === 'swagger' || block.type === 'openapi-operation') && block.meta?.id) { - const { data: operation } = await resolveOpenAPIOperationBlock({ - block, - context, - }); - if (operation) { + const title = getNodeText(block); sections.push({ - id: block.meta.id, - tag: operation.method.toUpperCase(), - title: operation.operation.summary || operation.path, + id, + title, depth: 1, - deprecated: operation.operation.deprecated, }); + continue; } - } - - if ( - block.type === 'openapi-schemas' && - !block.data.grouped && - block.meta?.id && - block.data.schemas.length === 1 - ) { - const { data } = await resolveOpenAPISchemasBlock({ - block, - context, - }); - const schema = data?.schemas[0]; - if (schema) { + case 'heading-2': { + const id = block.meta?.id; + if (!id) { + continue; + } + const title = getNodeText(block); sections.push({ - id: block.meta.id, - title: `The ${schema.name} object`, - depth: 1, + id, + title, + depth: depth > 0 ? 2 : 1, + }); + continue; + } + case 'stepper': { + const stepNodes = await Promise.all( + block.nodes.map(async (step) => getSectionsFromNodes(step.nodes, context)) + ); + for (const stepSections of stepNodes) { + sections.push(...stepSections); + } + continue; + } + case 'swagger': + case 'openapi-operation': { + const id = block.meta?.id; + if (!id) { + continue; + } + const { data: operation } = await resolveOpenAPIOperationBlock({ + block, + context, + }); + if (operation) { + sections.push({ + id, + tag: operation.method.toUpperCase(), + title: operation.operation.summary || operation.path, + depth: 1, + deprecated: operation.operation.deprecated, + }); + } + continue; + } + case 'openapi-schemas': { + const id = block.meta?.id; + if (!id) { + continue; + } + if (block.data.grouped || block.data.schemas.length !== 1) { + // Skip grouped schemas, they are not sections + continue; + } + + const { data } = await resolveOpenAPISchemasBlock({ + block, + context, }); + const schema = data?.schemas[0]; + if (schema) { + sections.push({ + id, + title: `The ${schema.name} object`, + depth: 1, + }); + } + continue; } } } diff --git a/packages/gitbook/src/lib/document.ts b/packages/gitbook/src/lib/document.ts index 33618f76c1..8d9fabc1e5 100644 --- a/packages/gitbook/src/lib/document.ts +++ b/packages/gitbook/src/lib/document.ts @@ -30,6 +30,41 @@ export function hasFullWidthBlock(document: JSONDocument): boolean { return false; } +/** + * Returns true if the document has more than `limit` blocks and/or inlines that match the `check` predicate. + */ +export function hasMoreThan( + document: JSONDocument | DocumentBlock, + check: (block: DocumentBlock | DocumentInline) => boolean, + limit = 1 +): boolean { + let count = 0; + + function traverse(node: JSONDocument | DocumentBlock | DocumentFragment): boolean { + for (const child of 'nodes' in node ? node.nodes : []) { + if (child.object === 'text') continue; + + if (check(child)) { + count++; + if (count > limit) return true; + } + + if (child.object === 'block' && 'nodes' in child) { + if (traverse(child)) return true; + } + + if (child.object === 'block' && 'fragments' in child) { + for (const fragment of child.fragments) { + if (traverse(fragment)) return true; + } + } + } + return false; + } + + return traverse(document); +} + /** * Get the text of a block/inline. */ diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts index d1f8c03cef..889ea742ae 100644 --- a/packages/gitbook/src/lib/openapi/fetch.ts +++ b/packages/gitbook/src/lib/openapi/fetch.ts @@ -1,5 +1,4 @@ import { parseOpenAPI } from '@gitbook/openapi-parser'; -import { unstable_cache } from 'next/cache'; import { type CacheFunctionOptions, cache, noCacheFetchOptions } from '@/lib/cache'; import type { @@ -8,9 +7,6 @@ import type { OpenAPIWebhookBlock, ResolveOpenAPIBlockArgs, } from '@/lib/openapi/types'; -import { getCloudflareRequestGlobal } from '@v2/lib/data/cloudflare'; -import { withCacheKey, withoutConcurrentExecution } from '@v2/lib/data/memoize'; -import { GITBOOK_RUNTIME } from '@v2/lib/env'; import { assert } from 'ts-essentials'; import { resolveContentRef } from '../references'; import { isV2 } from '../v2'; @@ -50,7 +46,7 @@ export async function fetchOpenAPIFilesystem( function fetchFilesystem(url: string) { if (isV2()) { - return fetchFilesystemV2(url); + return fetchFilesystemUseCache(url); } return fetchFilesystemV1(url); @@ -70,22 +66,6 @@ const fetchFilesystemV1 = cache({ }, }); -const fetchFilesystemV2 = withCacheKey( - withoutConcurrentExecution(getCloudflareRequestGlobal, async (cacheKey, url: string) => { - if (GITBOOK_RUNTIME !== 'cloudflare') { - return fetchFilesystemUseCache(url); - } - - // FIXME: OpenNext doesn't support 'use cache' yet - const uncached = unstable_cache(async () => fetchFilesystemUncached(url), [cacheKey], { - revalidate: 60 * 60 * 24, - }); - - const response = await uncached(); - return response; - }) -); - const fetchFilesystemUseCache = async (url: string) => { 'use cache'; return fetchFilesystemUncached(url); diff --git a/packages/gitbook/src/lib/paths.test.ts b/packages/gitbook/src/lib/paths.test.ts new file mode 100644 index 0000000000..1d418f4988 --- /dev/null +++ b/packages/gitbook/src/lib/paths.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test'; +import { getExtension } from './paths'; + +describe('getExtension', () => { + it('should return the extension of a path', () => { + expect(getExtension('test.txt')).toBe('.txt'); + }); + + it('should return an empty string if there is no extension', () => { + expect(getExtension('test/path/to/file')).toBe(''); + }); + + it('should return the extension of a path with multiple dots', () => { + expect(getExtension('test.with.multiple.dots.txt')).toBe('.txt'); + }); +}); diff --git a/packages/gitbook/src/lib/paths.ts b/packages/gitbook/src/lib/paths.ts index eff69a569b..ebdf35cd3f 100644 --- a/packages/gitbook/src/lib/paths.ts +++ b/packages/gitbook/src/lib/paths.ts @@ -57,3 +57,15 @@ export function withTrailingSlash(pathname: string): string { return pathname; } + +/** + * Get the extension of a path. + */ +export function getExtension(path: string): string { + const re = /\.[0-9a-z]+$/i; + const match = path.match(re); + if (match) { + return match[0]; + } + return ''; +} diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index ab941e43c5..7362decbe6 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -41,8 +41,12 @@ export interface ResolvedContentRef { file?: RevisionFile; /** Page document resolved from the content ref */ page?: RevisionPageDocument; - /** Resolved reusable content, if the ref points to reusable content on a revision. */ - reusableContent?: RevisionReusableContent; + /** Resolved reusable content, if the ref points to reusable content on a revision. Also contains the space and revision used for resolution. */ + reusableContent?: { + revisionReusableContent: RevisionReusableContent; + space: Space; + revision: string; + }; /** Resolve OpenAPI spec filesystem. */ openAPIFilesystem?: Filesystem; } @@ -143,7 +147,7 @@ export async function resolveContentRef( // Compute the text to display for the link if (anchor) { - text = `#${anchor}`; + text = page.title; ancestors.push({ label: page.title, icon: <PageIcon page={page} style={iconStyle} />, @@ -151,7 +155,7 @@ export async function resolveContentRef( }); if (resolveAnchorText) { - const document = await getPageDocument(dataFetcher, space, page); + const document = await getPageDocument(context, page); if (document) { const block = getBlockById(document, anchor); if (block) { @@ -231,21 +235,52 @@ export async function resolveContentRef( } case 'reusable-content': { + // Figure out which space and revision the reusable content is in. + const container: { space: Space; revision: string } | null = await (async () => { + // without a space on the content ref, or if the space is the same as the current one, we can use the current revision. + if (!contentRef.space || contentRef.space === context.space.id) { + return { space: context.space, revision: revisionId }; + } + + const space = await getDataOrNull( + dataFetcher.getSpace({ + spaceId: contentRef.space, + shareKey: undefined, + }) + ); + + if (!space) { + return null; + } + + return { space, revision: space.revision }; + })(); + + if (!container) { + return null; + } + const reusableContent = await getDataOrNull( dataFetcher.getReusableContent({ - spaceId: space.id, - revisionId, + spaceId: container.space.id, + revisionId: container.revision, reusableContentId: contentRef.reusableContent, }) ); + if (!reusableContent) { return null; } + return { - href: getGitBookAppHref(`/s/${space.id}`), + href: getGitBookAppHref(`/s/${container.space}/~/reusable/${reusableContent.id}`), text: reusableContent.title, active: false, - reusableContent, + reusableContent: { + revisionReusableContent: reusableContent, + space: container.space, + revision: container.revision, + }, }; } diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index 88d63e75d3..1827683e48 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -1,4 +1,6 @@ import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api'; +import type { GitBookSiteContext } from '@v2/lib/context'; +import { joinPath } from './paths'; /** * Get all sections from a site structure. @@ -64,6 +66,34 @@ export function findSiteSpaceById(siteStructure: SiteStructure, spaceId: string) return null; } +/** + * Get the URL to navigate to for a section. + * When the site is not published yet, `urls.published` is not available. + * To ensure navigation works in preview, we compute a relative URL from the siteSection path. + */ +export function getSectionURL(context: GitBookSiteContext, section: SiteSection) { + const { linker } = context; + return section.urls.published + ? linker.toLinkForContent(section.urls.published) + : linker.toPathInSite(section.path); +} + +/** + * Get the URL to navigate to for a site space. + * When the site is not published yet, `urls.published` is not available. + * To ensure navigation works in preview, we compute a relative URL from the siteSpace path. + */ +export function getSiteSpaceURL(context: GitBookSiteContext, siteSpace: SiteSpace) { + const { linker, sections } = context; + if (siteSpace.urls.published) { + return linker.toLinkForContent(siteSpace.urls.published); + } + + return linker.toPathInSite( + sections?.current ? joinPath(sections.current.path, siteSpace.path) : siteSpace.path + ); +} + function findSiteSpaceByIdInSections(sections: SiteSection[], spaceId: string): SiteSpace | null { for (const section of sections) { const siteSpace = diff --git a/packages/gitbook/src/lib/tracing.ts b/packages/gitbook/src/lib/tracing.ts index 38db339240..71b5c072e8 100644 --- a/packages/gitbook/src/lib/tracing.ts +++ b/packages/gitbook/src/lib/tracing.ts @@ -28,19 +28,19 @@ export async function trace<T>( }; const start = now(); - let failed = false; + let traceError: null | Error = null; try { return await fn(span); } catch (error) { span.setAttribute('error', true); - failed = true; + traceError = error as Error; throw error; } finally { if (process.env.SILENT !== 'true' && process.env.NODE_ENV !== 'development') { const end = now(); // biome-ignore lint/suspicious/noConsole: we want to log performance data console.log( - `trace ${completeName} ${failed ? 'failed' : 'succeeded'} in ${end - start}ms`, + `trace ${completeName} ${traceError ? `failed with ${traceError.message}` : 'succeeded'} in ${end - start}ms`, attributes ); } diff --git a/packages/gitbook/src/lib/typescript.ts b/packages/gitbook/src/lib/typescript.ts index 43940d4a2a..f13574a5a4 100644 --- a/packages/gitbook/src/lib/typescript.ts +++ b/packages/gitbook/src/lib/typescript.ts @@ -4,3 +4,10 @@ export function filterOutNullable<T>(value: T): value is NonNullable<T> { return !!value; } + +/** + * Alternative to `assertNever` that returns `null` instead of throwing an error. + */ +export function nullIfNever(_value: never): null { + return null; +} diff --git a/packages/gitbook/src/lib/urls.ts b/packages/gitbook/src/lib/urls.ts index 0bfa89e769..05dba4f3d0 100644 --- a/packages/gitbook/src/lib/urls.ts +++ b/packages/gitbook/src/lib/urls.ts @@ -8,3 +8,17 @@ export function checkIsHttpURL(input: string | URL): boolean { const parsed = new URL(input); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } + +/** + * True for absolute URLs (`scheme:*`) or hash-only anchors. + */ +export function checkIsExternalURL(input: string): boolean { + return URL.canParse(input); +} + +/** + * True for a hash-only anchor. + */ +export function checkIsAnchor(input: string): boolean { + return input.startsWith('#'); +} diff --git a/packages/gitbook/src/lib/utils.ts b/packages/gitbook/src/lib/utils.ts index 0db784cf2d..d7a98610d0 100644 --- a/packages/gitbook/src/lib/utils.ts +++ b/packages/gitbook/src/lib/utils.ts @@ -17,6 +17,7 @@ export function defaultCustomization(): api.SiteCustomizationSettings { background: api.CustomizationBackground.Plain, icons: api.CustomizationIconsStyle.Regular, links: api.CustomizationLinksStyle.Default, + depth: api.CustomizationDepth.Subtle, sidebar: { background: api.CustomizationSidebarBackgroundStyle.Default, list: api.CustomizationSidebarListStyle.Default, diff --git a/packages/gitbook/src/lib/v1.ts b/packages/gitbook/src/lib/v1.ts index ea5219816b..db6118a84c 100644 --- a/packages/gitbook/src/lib/v1.ts +++ b/packages/gitbook/src/lib/v1.ts @@ -8,6 +8,7 @@ import type { GitBookDataFetcher } from '@v2/lib/data/types'; import { createImageResizer } from '@v2/lib/images'; import { createLinker } from '@v2/lib/links'; +import { GitBookAPI } from '@gitbook/api'; import { DataFetcherError, wrapDataFetcherError } from '@v2/lib/data'; import { headers } from 'next/headers'; import { @@ -24,12 +25,14 @@ import { getRevision, getRevisionFile, getRevisionPageByPath, + getRevisionPageDocument, getRevisionPages, getSiteRedirectBySource, getSpace, getUserById, renderIntegrationUi, searchSiteContent, + withAPI as withAPIV1, } from './api'; import { getDynamicCustomizationSettings } from './customization'; import { withLeadingSlash, withTrailingSlash } from './paths'; @@ -58,7 +61,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> { return url; }; - const dataFetcher = await getDataFetcherV1(); + const dataFetcher = getDataFetcherV1(); const imageResizer = createImageResizer({ imagesContextId: host, @@ -82,77 +85,121 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> { * Try not to use this as much as possible, and instead take the data fetcher from the props. * This data fetcher should only be used at the top of the tree. */ -async function getDataFetcherV1(): Promise<GitBookDataFetcher> { +function getDataFetcherV1(apiTokenOverride?: string): GitBookDataFetcher { + let apiClient: GitBookAPI | undefined; + + /** + * Run a function with the correct API client. If an API token is provided, we + * create a new API client with the token. Otherwise, we use the default API client. + */ + async function withAPI<T>(fn: () => Promise<T>): Promise<T> { + // No token override - we can use the default API client. + if (!apiTokenOverride) { + return fn(); + } + + const client = await api(); + + if (!apiClient) { + // New client uses same endpoint and user agent as the default client. + apiClient = new GitBookAPI({ + endpoint: client.client.endpoint, + authToken: apiTokenOverride, + userAgent: client.client.userAgent, + }); + } + + return withAPIV1( + { + client: apiClient, + contextId: client.contextId, + }, + fn + ); + } + const dataFetcher: GitBookDataFetcher = { async api() { - const result = await api(); - return result.client; + return withAPI(async () => { + const result = await api(); + return result.client; + }); }, - withToken() { - // In v1, the token is global and controlled by the middleware. - // We don't need to do anything special here. - return dataFetcher; + withToken({ apiToken }) { + return getDataFetcherV1(apiToken); }, getUserById(userId) { - return wrapDataFetcherError(async () => { - const user = await getUserById(userId); - if (!user) { - throw new DataFetcherError('User not found', 404); - } - - return user; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const user = await getUserById(userId); + if (!user) { + throw new DataFetcherError('User not found', 404); + } + + return user; + }) + ); }, getPublishedContentSite(params) { - return wrapDataFetcherError(async () => { - return getPublishedContentSite(params); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getPublishedContentSite(params); + }) + ); }, getSpace(params) { - return wrapDataFetcherError(async () => { - return getSpace(params.spaceId, params.shareKey); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getSpace(params.spaceId, params.shareKey); + }) + ); }, getChangeRequest(params) { - return wrapDataFetcherError(async () => { - const changeRequest = await getChangeRequest( - params.spaceId, - params.changeRequestId - ); - if (!changeRequest) { - throw new DataFetcherError('Change request not found', 404); - } - - return changeRequest; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const changeRequest = await getChangeRequest( + params.spaceId, + params.changeRequestId + ); + if (!changeRequest) { + throw new DataFetcherError('Change request not found', 404); + } + + return changeRequest; + }) + ); }, getRevision(params) { - return wrapDataFetcherError(async () => { - return getRevision(params.spaceId, params.revisionId, { - metadata: params.metadata, - }); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getRevision(params.spaceId, params.revisionId, { + metadata: params.metadata, + }); + }) + ); }, getRevisionFile(params) { - return wrapDataFetcherError(async () => { - const revisionFile = await getRevisionFile( - params.spaceId, - params.revisionId, - params.fileId - ); - if (!revisionFile) { - throw new DataFetcherError('Revision file not found', 404); - } - - return revisionFile; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const revisionFile = await getRevisionFile( + params.spaceId, + params.revisionId, + params.fileId + ); + if (!revisionFile) { + throw new DataFetcherError('Revision file not found', 404); + } + + return revisionFile; + }) + ); }, getRevisionPageMarkdown() { @@ -160,117 +207,152 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> { }, getDocument(params) { - return wrapDataFetcherError(async () => { - const document = await getDocument(params.spaceId, params.documentId); - if (!document) { - throw new DataFetcherError('Document not found', 404); - } - - return document; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const document = await getDocument(params.spaceId, params.documentId); + if (!document) { + throw new DataFetcherError('Document not found', 404); + } + + return document; + }) + ); }, getComputedDocument(params) { - return wrapDataFetcherError(() => { - return getComputedDocument( - params.organizationId, - params.spaceId, - params.source, - params.seed - ); - }); + return withAPI(() => + wrapDataFetcherError(() => { + return getComputedDocument( + params.organizationId, + params.spaceId, + params.source, + params.seed + ); + }) + ); }, getRevisionPages(params) { - return wrapDataFetcherError(async () => { - return getRevisionPages(params.spaceId, params.revisionId, { - metadata: params.metadata, - }); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getRevisionPages(params.spaceId, params.revisionId, { + metadata: params.metadata, + }); + }) + ); + }, + + getRevisionPageDocument(params) { + return withAPI(() => + wrapDataFetcherError(async () => { + return getRevisionPageDocument( + params.spaceId, + params.revisionId, + params.pageId + ); + }) + ); }, getRevisionPageByPath(params) { - return wrapDataFetcherError(async () => { - const revisionPage = await getRevisionPageByPath( - params.spaceId, - params.revisionId, - params.path - ); - - if (!revisionPage) { - throw new DataFetcherError('Revision page not found', 404); - } - - return revisionPage; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const revisionPage = await getRevisionPageByPath( + params.spaceId, + params.revisionId, + params.path + ); + + if (!revisionPage) { + throw new DataFetcherError('Revision page not found', 404); + } + + return revisionPage; + }) + ); }, getReusableContent(params) { - return wrapDataFetcherError(async () => { - const reusableContent = await getReusableContent( - params.spaceId, - params.revisionId, - params.reusableContentId - ); - - if (!reusableContent) { - throw new DataFetcherError('Reusable content not found', 404); - } - - return reusableContent; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const reusableContent = await getReusableContent( + params.spaceId, + params.revisionId, + params.reusableContentId + ); + + if (!reusableContent) { + throw new DataFetcherError('Reusable content not found', 404); + } + + return reusableContent; + }) + ); }, getLatestOpenAPISpecVersionContent(params) { - return wrapDataFetcherError(async () => { - const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent( - params.organizationId, - params.slug - ); - - if (!openAPISpecVersionContent) { - throw new DataFetcherError('OpenAPI spec version content not found', 404); - } - - return openAPISpecVersionContent; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent( + params.organizationId, + params.slug + ); + + if (!openAPISpecVersionContent) { + throw new DataFetcherError('OpenAPI spec version content not found', 404); + } + + return openAPISpecVersionContent; + }) + ); }, getSiteRedirectBySource(params) { - return wrapDataFetcherError(async () => { - const siteRedirect = await getSiteRedirectBySource(params); - if (!siteRedirect) { - throw new DataFetcherError('Site redirect not found', 404); - } - - return siteRedirect; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const siteRedirect = await getSiteRedirectBySource(params); + if (!siteRedirect) { + throw new DataFetcherError('Site redirect not found', 404); + } + + return siteRedirect; + }) + ); }, getEmbedByUrl(params) { - return wrapDataFetcherError(() => { - return getEmbedByUrlInSpace(params.spaceId, params.url); - }); + return withAPI(() => + wrapDataFetcherError(() => { + return getEmbedByUrlInSpace(params.spaceId, params.url); + }) + ); }, searchSiteContent(params) { - return wrapDataFetcherError(async () => { - const { organizationId, siteId, query, cacheBust, scope } = params; - const result = await searchSiteContent( - organizationId, - siteId, - query, - scope, - cacheBust - ); - return result.items; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const { organizationId, siteId, query, cacheBust, scope } = params; + const result = await searchSiteContent( + organizationId, + siteId, + query, + scope, + cacheBust + ); + return result.items; + }) + ); }, renderIntegrationUi(params) { - return wrapDataFetcherError(async () => { - const result = await renderIntegrationUi(params.integrationName, params.request); - return result; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const result = await renderIntegrationUi( + params.integrationName, + params.request + ); + return result; + }) + ); }, streamAIResponse() { diff --git a/packages/gitbook/src/lib/visitor-token.test.ts b/packages/gitbook/src/lib/visitors.test.ts similarity index 56% rename from packages/gitbook/src/lib/visitor-token.test.ts rename to packages/gitbook/src/lib/visitors.test.ts index a1f07d2504..4337371fa6 100644 --- a/packages/gitbook/src/lib/visitor-token.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -6,7 +6,8 @@ import { getVisitorAuthCookieName, getVisitorAuthCookieValue, getVisitorToken, -} from './visitor-token'; + getVisitorUnsignedClaims, +} from './visitors'; describe('getVisitorAuthToken', () => { it('should return the token from the query parameters', () => { @@ -158,3 +159,139 @@ function assertVisitorAuthCookieValue( throw new Error('Expected a VisitorAuthCookieValue'); } + +describe('getVisitorUnsignedClaims', () => { + it('should merge claims from multiple public cookies', () => { + const cookies = [ + { + name: 'gitbook-visitor-public-bucket', + value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }), + }, + { + name: 'gitbook-visitor-public-launchdarkly', + value: JSON.stringify({ + launchdarkly: { flags: { ALPHA: true, API: true } }, + }), + }, + ]; + + const url = new URL('https://example.com/'); + const claims = getVisitorUnsignedClaims({ cookies, url }); + + expect(claims.all).toStrictEqual({ + bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } }, + launchdarkly: { flags: { ALPHA: true, API: true } }, + }); + }); + + it('should parse visitor.* query params with simple types', () => { + const url = new URL( + 'https://example.com/?visitor.isEnterprise=true&visitor.language=fr&visitor.country=fr' + ); + + const claims = getVisitorUnsignedClaims({ cookies: [], url }); + + expect(claims.all).toStrictEqual({ + isEnterprise: true, + language: 'fr', + country: 'fr', + }); + expect(claims.fromVisitorParams).toStrictEqual({ + isEnterprise: true, + language: 'fr', + country: 'fr', + }); + }); + + it('should ignore params that do not match visitor.* convention', () => { + const url = new URL('https://example.com/?visitor.isEnterprise=true&otherParam=true'); + + const claims = getVisitorUnsignedClaims({ cookies: [], url }); + + expect(claims.all).toStrictEqual({ + isEnterprise: true, + // otherParam is not present + }); + expect(claims.fromVisitorParams).toStrictEqual({ + isEnterprise: true, + }); + }); + + it('should support nested query param keys via dot notation', () => { + const url = new URL( + 'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false' + ); + + const claims = getVisitorUnsignedClaims({ cookies: [], url }); + + expect(claims.all).toStrictEqual({ + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + expect(claims.fromVisitorParams).toStrictEqual({ + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + }); + + it('should ignore invalid JSON in cookie values', () => { + const cookies = [ + { + name: 'gitbook-visitor-public', + value: '{not: "json"}', + }, + ]; + const url = new URL('https://example.com/'); + const claims = getVisitorUnsignedClaims({ cookies, url }); + + expect(claims.all).toStrictEqual({}); + }); + + it('should merge claims from cookies and visitor.* query params', () => { + const cookies = [ + { + name: 'gitbook-visitor-public', + value: JSON.stringify({ role: 'admin', language: 'fr' }), + }, + { + name: 'gitbook-visitor-public-bucket', + value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }), + }, + ]; + const url = new URL( + 'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false&visitor.bucket.flags.HELLO=false' + ); + + const claims = getVisitorUnsignedClaims({ cookies, url }); + + expect(claims.all).toStrictEqual({ + role: 'admin', + language: 'fr', + bucket: { + flags: { HELLO: false, SITE_AI: true, SITE_PREVIEW: true }, + }, + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + + expect(claims.fromVisitorParams).toStrictEqual({ + bucket: { + flags: { HELLO: false }, + }, + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + }); +}); diff --git a/packages/gitbook/src/lib/visitor-token.ts b/packages/gitbook/src/lib/visitors.ts similarity index 61% rename from packages/gitbook/src/lib/visitor-token.ts rename to packages/gitbook/src/lib/visitors.ts index 64f0c97931..7ae970666b 100644 --- a/packages/gitbook/src/lib/visitor-token.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -4,6 +4,7 @@ import hash from 'object-hash'; const VISITOR_AUTH_PARAM = 'jwt_token'; export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token'; +const VISITOR_UNSIGNED_CLAIMS_PREFIX = 'gitbook-visitor-public'; /** * Typing for a cookie, matching the internal type of Next.js. @@ -30,6 +31,27 @@ type VisitorAuthCookieValue = { token: string; }; +type ClaimPrimitive = + | string + | number + | boolean + | null + | undefined + | { [key: string]: ClaimPrimitive } + | ClaimPrimitive[]; + +/** + * The result of a visitor data lookup that can include: + * - a visitor token (JWT) + * - a record of visitor public/unsigned claims (JSON object) + * - a session cookie response to persist any visitor query params across navigations. + */ +export type VisitorDataLookup = { + visitorToken: VisitorTokenLookup; + unsignedClaims: Record<string, ClaimPrimitive>; + visitorParamsCookie: ResponseCookie | undefined; +}; + /** * The result of a visitor token lookup. */ @@ -53,6 +75,30 @@ export type VisitorTokenLookup = /** Not visitor token was found */ | undefined; +/** + * Get the visitor data for the request potentially including: + * - a JWT token that may contain signed claims or can be used for VA authentication. + * - a record of the unsigned claims passed via a cookie or visitor.* params. + * - a session cookie response that is used to persist any visitor.* params that were passed via the site URL. + */ +export function getVisitorData({ + cookies, + url, +}: { + cookies: RequestCookies; + url: URL | NextRequest['nextUrl']; +}): VisitorDataLookup { + const visitorToken = getVisitorToken({ cookies, url }); + const unsignedClaims = getVisitorUnsignedClaims({ cookies, url }); + const visitorParamsCookie = getResponseCookieForVisitorParams(unsignedClaims.fromVisitorParams); + + return { + visitorToken, + unsignedClaims: unsignedClaims.all, + visitorParamsCookie, + }; +} + /** * Get the visitor token for the request. This token can either be in the * query parameters or stored as a cookie. @@ -82,6 +128,138 @@ export function getVisitorToken({ } } +/** + * Get the visitor unsigned/public claims for the request. They can either be in `visitor.` query + * parameters or stored in special `gitbook-visitor-public-*` cookies. + */ +export function getVisitorUnsignedClaims(args: { + cookies: RequestCookies; + url: URL | NextRequest['nextUrl']; +}): { + /** + * The unsigned claims coming from both `gitbook-visitor-public` cookies and `visitor.*` query params. + */ + all: Record<string, ClaimPrimitive>; + /** + * The unsigned claims from the `visitor.*` query params. + */ + fromVisitorParams: Record<string, ClaimPrimitive>; +} { + const { cookies, url } = args; + const claims: Record<string, ClaimPrimitive> = {}; + const searchParamsClaims: Record<string, ClaimPrimitive> = {}; + + for (const cookie of cookies) { + if (cookie.name.startsWith(VISITOR_UNSIGNED_CLAIMS_PREFIX)) { + try { + const parsed = JSON.parse(cookie.value); + if (typeof parsed === 'object' && parsed !== null) { + Object.assign(claims, parsed); + } + } catch (_err) { + console.warn(`Invalid JSON in unsigned claim cookie "${cookie.name}"`); + } + } + } + + for (const [key, value] of url.searchParams.entries()) { + if (key.startsWith('visitor.')) { + const claimPath = key.substring('visitor.'.length); + const claimValue = parseVisitorQueryParamValue(value); + + setVisitorClaimByPath(claims, claimPath, claimValue); + setVisitorClaimByPath(searchParamsClaims, claimPath, claimValue); + } + } + + return { all: claims, fromVisitorParams: searchParamsClaims }; +} + +/** + * Set the value of claims in a claims object at a specific path. + */ +function setVisitorClaimByPath( + claims: Record<string, ClaimPrimitive>, + keyPath: string, + value: ClaimPrimitive +): void { + const keys = keyPath.split('.'); + let current = claims; + + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + + if (index === keys.length - 1) { + current[key] = value; + } else { + if (!(key in current) || !isClaimPrimitiveObject(current[key])) { + current[key] = {}; + } + + current = current[key]; + } + } +} + +function isClaimPrimitiveObject(value: unknown): value is Record<string, ClaimPrimitive> { + return typeof value === 'object' && value !== null; +} + +/** + * Parse the value expected in a `visitor.` URL query parameter. + */ +function parseVisitorQueryParamValue(value: string): ClaimPrimitive { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + if (value === 'null') { + return null; + } + + if (value === 'undefined') { + return undefined; + } + + const num = Number(value); + if (!Number.isNaN(num) && value.trim() !== '') { + return num; + } + + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null) { + return parsed; + } + } catch {} + + return value; +} + +/** + * Returns to cookie response to use in order to persist visitor params that were passed to the URL. + */ +function getResponseCookieForVisitorParams( + visitorParamsClaims: Record<string, ClaimPrimitive> +): ResponseCookie | undefined { + if (Object.keys(visitorParamsClaims).length === 0) { + return undefined; + } + + return { + name: VISITOR_UNSIGNED_CLAIMS_PREFIX, + value: JSON.stringify(visitorParamsClaims), + options: { + sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined, + secure: process.env.NODE_ENV === 'production', + }, + }; +} + /** * Return the lookup result for content served with visitor auth. */ diff --git a/packages/gitbook/src/lib/waitUntil.ts b/packages/gitbook/src/lib/waitUntil.ts index 6e2940f17b..dbaae41a0d 100644 --- a/packages/gitbook/src/lib/waitUntil.ts +++ b/packages/gitbook/src/lib/waitUntil.ts @@ -1,4 +1,7 @@ import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; +import { getCloudflareContext as getCloudflareContextV2 } from '@v2/lib/data/cloudflare'; +import { GITBOOK_RUNTIME } from '@v2/lib/env'; +import { isV2 } from './v2'; let pendings: Array<Promise<unknown>> = []; @@ -47,12 +50,25 @@ export async function waitUntil(promise: Promise<unknown>) { return; } - const cloudflareContext = await getGlobalContext(); - if ('waitUntil' in cloudflareContext) { - cloudflareContext.waitUntil(promise); - } else { - await promise; + if (GITBOOK_RUNTIME === 'cloudflare') { + if (isV2()) { + const context = getCloudflareContextV2(); + if (context) { + context.ctx.waitUntil(promise); + return; + } + } else { + const cloudflareContext = await getGlobalContext(); + if ('waitUntil' in cloudflareContext) { + cloudflareContext.waitUntil(promise); + return; + } + } } + + await promise.catch((error) => { + console.error('Ignored error in waitUntil', error); + }); } /** diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 30f7b44e6a..9edb751243 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -24,9 +24,9 @@ import { type ResponseCookies, type VisitorTokenLookup, getResponseCookiesForVisitorAuth, - getVisitorToken, + getVisitorData, normalizeVisitorAuthURL, -} from '@/lib/visitor-token'; +} from '@/lib/visitors'; import { joinPath, withLeadingSlash } from '@/lib/paths'; import { getProxyModeBasePath } from '@/lib/proxy'; @@ -201,7 +201,9 @@ export async function middleware(request: NextRequest) { const customization = url.searchParams.get('customization'); if (customization && validateSerializedCustomization(customization)) { - headers.set(MiddlewareHeaders.Customization, customization); + // We need to encode the customization to ensure it is properly encoded in vercel. + // We do it here as well so that we have a single method to decode it later. + headers.set(MiddlewareHeaders.Customization, encodeURIComponent(customization)); } const theme = url.searchParams.get('theme'); @@ -392,17 +394,17 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<Lookup * When serving multi spaces based on the current URL. */ async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> { - const visitorAuthToken = getVisitorToken({ + const { visitorToken } = getVisitorData({ cookies: request.cookies.getAll(), url, }); - const lookup = await lookupSiteByAPI(url, visitorAuthToken); + const lookup = await lookupSiteByAPI(url, visitorToken); return { ...lookup, - ...('basePath' in lookup && visitorAuthToken - ? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken) + ...('basePath' in lookup && visitorToken + ? getLookupResultForVisitorAuth(lookup.basePath, visitorToken) : {}), - visitorToken: visitorAuthToken?.token, + visitorToken: visitorToken?.token, }; } @@ -609,12 +611,12 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis const target = new URL(targetStr); target.search = url.search; - const visitorAuthToken = getVisitorToken({ + const { visitorToken } = getVisitorData({ cookies: request.cookies.getAll(), url: target, }); - const lookup = await lookupSiteByAPI(target, visitorAuthToken); + const lookup = await lookupSiteByAPI(target, visitorToken); if ('error' in lookup) { return lookup; } @@ -641,10 +643,10 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis ...lookup, siteBasePath: joinPath(target.host, lookup.siteBasePath), basePath: joinPath(target.host, lookup.basePath), - ...('basePath' in lookup && visitorAuthToken - ? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken) + ...('basePath' in lookup && visitorToken + ? getLookupResultForVisitorAuth(lookup.basePath, visitorToken) : {}), - visitorToken: visitorAuthToken?.token, + visitorToken: visitorToken?.token, }; } diff --git a/packages/gitbook/src/routes/icon.tsx b/packages/gitbook/src/routes/icon.tsx index 03a6f35224..ab3429d631 100644 --- a/packages/gitbook/src/routes/icon.tsx +++ b/packages/gitbook/src/routes/icon.tsx @@ -24,6 +24,11 @@ const SIZES = { }, }; +type RenderIconOptions = { + size: keyof typeof SIZES; + theme: 'light' | 'dark'; +}; + /** * Generate an icon for a site content. */ @@ -31,7 +36,7 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) { const options = getOptions(req.url); const size = SIZES[options.size]; - const { site, customization } = context; + const { customization } = context; const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; // If the site has a custom icon, redirect to it @@ -45,17 +50,45 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) { ); } + return new ImageResponse(<SiteDefaultIcon context={context} options={options} />, { + width: size.width, + height: size.height, + headers: { + 'cache-tag': [ + getCacheTag({ + tag: 'site', + site: context.site.id, + }), + ].join(','), + }, + }); +} + +/** + * Render the icon as a React node. + */ +export function SiteDefaultIcon(props: { + context: GitBookSiteContext; + options: RenderIconOptions; + style?: React.CSSProperties; + tw?: string; +}) { + const { context, options, style, tw } = props; + const size = SIZES[options.size]; + + const { site, customization } = context; const contentTitle = site.title; - return new ImageResponse( + return ( <div - tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle)} + tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle, tw)} style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', + ...style, }} > <h2 @@ -70,19 +103,7 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) { ? getEmojiForCode(customization.favicon.emoji) : contentTitle.slice(0, 1).toUpperCase()} </h2> - </div>, - { - width: size.width, - height: size.height, - headers: { - 'cache-tag': [ - getCacheTag({ - tag: 'site', - site: context.site.id, - }), - ].join(','), - }, - } + </div> ); } diff --git a/packages/gitbook/src/routes/image.ts b/packages/gitbook/src/routes/image.ts index 45d590f311..3bc946ed90 100644 --- a/packages/gitbook/src/routes/image.ts +++ b/packages/gitbook/src/routes/image.ts @@ -2,6 +2,7 @@ import { CURRENT_SIGNATURE_VERSION, type CloudflareImageOptions, type SignatureVersion, + SizableImageAction, checkIsSizableImageURL, isSignatureVersion, parseImageAPIURL, @@ -40,7 +41,7 @@ export async function serveResizedImage( // Check again if the image can be sized, even though we checked when rendering the Image component // Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain // Also prevent infinite redirects. - if (!checkIsSizableImageURL(url)) { + if (checkIsSizableImageURL(url) === SizableImageAction.Skip) { return new Response('Invalid url parameter', { status: 400 }); } diff --git a/packages/gitbook/src/routes/llms-full.ts b/packages/gitbook/src/routes/llms-full.ts new file mode 100644 index 0000000000..90efb73f63 --- /dev/null +++ b/packages/gitbook/src/routes/llms-full.ts @@ -0,0 +1,209 @@ +import path from 'node:path'; +import { joinPath } from '@/lib/paths'; +import { getIndexablePages } from '@/lib/sitemap'; +import { getSiteStructureSections } from '@/lib/sites'; +import { checkIsAnchor, checkIsExternalURL } from '@/lib/urls'; +import type { RevisionPageDocument, SiteSection, SiteSpace } from '@gitbook/api'; +import { type GitBookSiteContext, checkIsRootSiteContext } from '@v2/lib/context'; +import { throwIfDataError } from '@v2/lib/data'; +import assertNever from 'assert-never'; +import type { Link, Paragraph, Root } from 'mdast'; +import { fromMarkdown } from 'mdast-util-from-markdown'; +import { frontmatterFromMarkdown } from 'mdast-util-frontmatter'; +import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'; +import { toMarkdown } from 'mdast-util-to-markdown'; +import { frontmatter } from 'micromark-extension-frontmatter'; +import { gfm } from 'micromark-extension-gfm'; +import { pMapIterable } from 'p-map'; +import { remove } from 'unist-util-remove'; +import { visit } from 'unist-util-visit'; + +// We limit the concurrency to 100 to avoid reaching limit with concurrent requests +// or file descriptor limits. +const MAX_CONCURRENCY = 100; + +/** + * Generate a llms-full.txt file for the site. + * As the result can be large, we stream it as we generate it. + */ +export async function serveLLMsFullTxt(context: GitBookSiteContext) { + if (!checkIsRootSiteContext(context)) { + return new Response('llms.txt is only served from the root of the site', { status: 404 }); + } + + return new Response( + new ReadableStream<Uint8Array>({ + async pull(controller) { + await streamMarkdownFromSiteStructure(context, controller); + controller.close(); + }, + }), + { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + }, + } + ); +} + +/** + * Stream markdown from site structure. + */ +async function streamMarkdownFromSiteStructure( + context: GitBookSiteContext, + stream: ReadableStreamDefaultController<Uint8Array> +): Promise<void> { + switch (context.structure.type) { + case 'sections': + return streamMarkdownFromSections( + context, + stream, + getSiteStructureSections(context.structure, { ignoreGroups: true }) + ); + case 'siteSpaces': + return streamMarkdownFromSiteSpaces(context, stream, context.structure.structure, ''); + default: + assertNever(context.structure); + } +} + +/** + * Stream markdown from site sections. + */ +async function streamMarkdownFromSections( + context: GitBookSiteContext, + stream: ReadableStreamDefaultController<Uint8Array>, + siteSections: SiteSection[] +): Promise<void> { + for (const siteSection of siteSections) { + await streamMarkdownFromSiteSpaces( + context, + stream, + siteSection.siteSpaces, + siteSection.path + ); + } +} + +/** + * Stream markdown from site spaces. + */ +async function streamMarkdownFromSiteSpaces( + context: GitBookSiteContext, + stream: ReadableStreamDefaultController<Uint8Array>, + siteSpaces: SiteSpace[], + basePath: string +): Promise<void> { + const { dataFetcher } = context; + + for (const siteSpace of siteSpaces) { + const siteSpaceUrl = siteSpace.urls.published; + if (!siteSpaceUrl) { + continue; + } + const rootPages = await throwIfDataError( + dataFetcher.getRevisionPages({ + spaceId: siteSpace.space.id, + revisionId: siteSpace.space.revision, + metadata: false, + }) + ); + const pages = getIndexablePages(rootPages); + + for await (const markdown of pMapIterable( + pages, + async ({ page }) => { + if (page.type !== 'document') { + return ''; + } + + return getMarkdownForPage( + context, + siteSpace, + page, + joinPath(basePath, siteSpace.path) + ); + }, + { + concurrency: MAX_CONCURRENCY, + } + )) { + stream.enqueue(new TextEncoder().encode(markdown)); + } + } +} + +/** + * Get markdown from a page. + */ +async function getMarkdownForPage( + context: GitBookSiteContext, + siteSpace: SiteSpace, + page: RevisionPageDocument, + basePath: string +): Promise<string> { + const { dataFetcher } = context; + + const pageMarkdown = await throwIfDataError( + dataFetcher.getRevisionPageMarkdown({ + spaceId: siteSpace.space.id, + revisionId: siteSpace.space.revision, + pageId: page.id, + }) + ); + + const tree = fromMarkdown(pageMarkdown, { + extensions: [frontmatter(['yaml']), gfm()], + mdastExtensions: [frontmatterFromMarkdown(['yaml']), gfmFromMarkdown()], + }); + + // Remove frontmatter + remove(tree, 'yaml'); + + if (page.description) { + // The first node is the page title as a H1, we insert the description as a paragraph + // after it. + const descriptionNode: Paragraph = { + type: 'paragraph', + children: [{ type: 'text', value: page.description }], + }; + tree.children.splice(1, 0, descriptionNode); + } + + // Rewrite relative links to absolute links + transformLinks(context, tree, { currentPagePath: page.path, basePath }); + + const markdown = toMarkdown(tree, { extensions: [gfmToMarkdown()] }); + return `${markdown}\n\n`; +} + +/** + * Re-writes the URL of every relative <a> link so it is expressed from the site-root. + */ +export function transformLinks( + context: GitBookSiteContext, + tree: Root, + options: { currentPagePath: string; basePath: string } +): Root { + const { linker } = context; + const { currentPagePath, basePath } = options; + const currentDir = path.posix.dirname(currentPagePath); + + visit(tree, 'link', (node: Link) => { + const original = node.url; + + // Skip anchors, mailto:, http(s):, protocol-like, or already-rooted paths + if (checkIsExternalURL(original) || checkIsAnchor(original) || original.startsWith('/')) { + return; + } + + // Resolve against the current page’s directory and strip any leading “/” + const pathInSite = path.posix + .normalize(path.posix.join(basePath, currentDir, original)) + .replace(/^\/+/, ''); + + node.url = linker.toPathInSite(pathInSite); + }); + + return tree; +} diff --git a/packages/gitbook/src/routes/llms.ts b/packages/gitbook/src/routes/llms.ts index 24f9119505..a7e953cbad 100644 --- a/packages/gitbook/src/routes/llms.ts +++ b/packages/gitbook/src/routes/llms.ts @@ -46,7 +46,7 @@ export async function serveLLMsTxt( }), { headers: { - 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Type': 'text/markdown; charset=utf-8', }, } ); diff --git a/packages/gitbook/src/routes/ogimage.tsx b/packages/gitbook/src/routes/ogimage.tsx index 41842ab241..4d8a83b2c3 100644 --- a/packages/gitbook/src/routes/ogimage.tsx +++ b/packages/gitbook/src/routes/ogimage.tsx @@ -1,41 +1,33 @@ import { CustomizationDefaultFont, CustomizationHeaderPreset } from '@gitbook/api'; import { colorContrast } from '@gitbook/colors'; +import { type FontWeight, getDefaultFont } from '@gitbook/fonts'; +import { direction } from 'direction'; +import { imageSize } from 'image-size'; import { redirect } from 'next/navigation'; import { ImageResponse } from 'next/og'; import { type PageParams, fetchPageData } from '@/components/SitePage'; import { getFontSourcesToPreload } from '@/fonts/custom'; import { getAssetURL } from '@/lib/assets'; +import { getExtension } from '@/lib/paths'; import { filterOutNullable } from '@/lib/typescript'; import { getCacheTag } from '@gitbook/cache-tags'; import type { GitBookSiteContext } from '@v2/lib/context'; -import { getCloudflareContext } from '@v2/lib/data/cloudflare'; -import { getResizedImageURL } from '@v2/lib/images'; - -const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = { - [CustomizationDefaultFont.Inter]: 'Inter', - [CustomizationDefaultFont.FiraSans]: 'Fira Sans Extra Condensed', - [CustomizationDefaultFont.IBMPlexSerif]: 'IBM Plex Serif', - [CustomizationDefaultFont.Lato]: 'Lato', - [CustomizationDefaultFont.Merriweather]: 'Merriweather', - [CustomizationDefaultFont.NotoSans]: 'Noto Sans', - [CustomizationDefaultFont.OpenSans]: 'Open Sans', - [CustomizationDefaultFont.Overpass]: 'Overpass', - [CustomizationDefaultFont.Poppins]: 'Poppins', - [CustomizationDefaultFont.Raleway]: 'Raleway', - [CustomizationDefaultFont.Roboto]: 'Roboto', - [CustomizationDefaultFont.RobotoSlab]: 'Roboto Slab', - [CustomizationDefaultFont.SourceSansPro]: 'Source Sans 3', - [CustomizationDefaultFont.Ubuntu]: 'Ubuntu', - [CustomizationDefaultFont.ABCFavorit]: 'Inter', -}; +import { + type ResizeImageOptions, + SizableImageAction, + checkIsSizableImageURL, + getResizedImageURL, + resizeImage, +} from '@v2/lib/images'; +import { SiteDefaultIcon } from './icon'; /** * Render the OpenGraph image for a site content. */ export async function serveOGImage(baseContext: GitBookSiteContext, params: PageParams) { const { context, pageTarget } = await fetchPageData(baseContext, params); - const { customization, site, linker, imageResizer } = context; + const { customization, site, imageResizer } = context; const page = pageTarget?.page; // If user configured a custom social preview, we redirect to it. @@ -49,7 +41,6 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page } // Compute all text to load only the necessary fonts - const contentTitle = customization.header.logo ? '' : site.title; const pageTitle = page ? page.title.length > 64 ? `${page.title.slice(0, 64)}...` @@ -63,18 +54,18 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page : ''; // Load the fonts - const { fontFamily, fonts } = await (async () => { + const fontLoader = async () => { // google fonts if (typeof customization.styling.font === 'string') { - const fontFamily = googleFontsMap[customization.styling.font] ?? 'Inter'; + const fontFamily = customization.styling.font ?? CustomizationDefaultFont.Inter; const regularText = pageDescription; - const boldText = `${contentTitle}${pageTitle}`; + const boldText = `${site.title} ${pageTitle}`; const fonts = ( await Promise.all([ - loadGoogleFont({ fontFamily, text: regularText, weight: 400 }), - loadGoogleFont({ fontFamily, text: boldText, weight: 700 }), + loadGoogleFont({ font: fontFamily, text: regularText, weight: 400 }), + loadGoogleFont({ font: fontFamily, text: boldText, weight: 700 }), ]) ).filter(filterOutNullable); @@ -103,7 +94,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page ).filter(filterOutNullable); return { fontFamily: 'CustomFont', fonts }; - })(); + }; const theme = customization.themes.default; const useLightTheme = theme === 'light'; @@ -157,32 +148,55 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page break; } - const favicon = await (async () => { - if ('icon' in customization.favicon) - return ( - <img - src={customization.favicon.icon[theme]} - width={40} - height={40} - tw="mr-4" - alt="Icon" - /> - ); - if ('emoji' in customization.favicon) - return ( - <span tw="text-4xl mr-4"> - {String.fromCodePoint(Number.parseInt(`0x${customization.favicon.emoji}`))} - </span> - ); - const src = await readSelfImage( - linker.toAbsoluteURL( - linker.toPathInSpace( - `~gitbook/icon?size=medium&theme=${customization.themes.default}` - ) - ) + const faviconLoader = async () => { + if (customization.header.logo) { + // Don't load the favicon if we have a logo + // as it'll not be used. + return null; + } + + const faviconSize = { + width: 48, + height: 48, + }; + + if ('icon' in customization.favicon) { + const faviconImage = await fetchImage(customization.favicon.icon[theme], faviconSize); + if (faviconImage) { + return <img {...faviconImage} {...faviconSize} alt="Icon" />; + } + } + + return ( + <SiteDefaultIcon + context={context} + options={{ + size: 'small', + theme, + }} + style={faviconSize} + /> ); - return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />; - })(); + }; + + const logoLoader = async () => { + if (!customization.header.logo) { + return null; + } + + return await fetchImage( + useLightTheme ? customization.header.logo.light : customization.header.logo.dark, + { + height: 60, + } + ); + }; + + const [favicon, logo, { fontFamily, fonts }] = await Promise.all([ + faviconLoader(), + logoLoader(), + fontLoader(), + ]); return new ImageResponse( <div @@ -203,25 +217,19 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page {/* Grid */} <img tw="absolute inset-0 w-[100vw] h-[100vh]" - src={await readStaticImage(gridAsset)} + src={(await fetchStaticImage(gridAsset)).src} alt="Grid" /> {/* Logo */} - {customization.header.logo ? ( - <img - alt="Logo" - height={60} - src={ - useLightTheme - ? customization.header.logo.light - : customization.header.logo.dark - } - /> + {logo ? ( + <div tw="flex flex-row"> + <img {...logo} alt="Logo" tw="h-[60px]" /> + </div> ) : ( - <div tw="flex"> + <div tw="flex flex-row items-center"> {favicon} - <h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3> + <h3 tw="text-4xl ml-4 my-0 font-bold">{transformText(site.title)}</h3> </div> )} @@ -230,10 +238,12 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page <h1 tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`} > - {pageTitle} + {transformText(pageTitle)} </h1> {pageDescription ? ( - <h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2> + <h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal"> + {transformText(pageDescription)} + </h2> ) : null} </div> </div>, @@ -257,37 +267,31 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page ); } -async function loadGoogleFont(input: { fontFamily: string; text: string; weight: 400 | 700 }) { - const { fontFamily, text, weight } = input; - - if (!text.trim()) { - return null; - } - - const url = new URL('https://fonts.googleapis.com/css2'); - url.searchParams.set('family', `${fontFamily}:wght@${weight}`); - url.searchParams.set('text', text); - - const result = await fetch(url.href); - if (!result.ok) { - return null; - } - - const css = await result.text(); - const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/); - const resourceUrl = resource ? resource[1] : null; - - if (resourceUrl) { - const response = await fetch(resourceUrl); - if (response.ok) { - const data = await response.arrayBuffer(); - return { - name: fontFamily, - data, - style: 'normal' as const, - weight, - }; - } +async function loadGoogleFont(input: { + font: CustomizationDefaultFont; + text: string; + weight: FontWeight; +}) { + const lookup = getDefaultFont({ + font: input.font, + text: input.text, + weight: input.weight, + }); + + // If we found a font file, load it + if (lookup) { + return getWithCache(`google-font-files:${lookup.url}`, async () => { + const response = await fetch(lookup.url); + if (response.ok) { + const data = await response.arrayBuffer(); + return { + name: lookup.font, + data, + style: 'normal' as const, + weight: input.weight, + }; + } + }); } // If for some reason we can't load the font, we'll just use the default one @@ -311,58 +315,100 @@ async function loadCustomFont(input: { url: string; weight: 400 | 700 }) { }; } +// biome-ignore lint/suspicious/noExplicitAny: <explanation> +const staticCache = new Map<string, any>(); + /** - * Fetch a resource from the function itself. - * To avoid error with worker to worker requests in the same zone, we use the `WORKER_SELF_REFERENCE` binding. + * Get or initialize a value in the static cache. */ -async function fetchSelf(url: string) { - const cloudflare = getCloudflareContext(); - if (cloudflare?.env.WORKER_SELF_REFERENCE) { - return await cloudflare.env.WORKER_SELF_REFERENCE.fetch( - // `getAssetURL` can return a relative URL, so we need to make it absolute - // the URL doesn't matter, as we're using the worker-self-reference binding - new URL(url, 'https://worker-self-reference/') - ); +async function getWithCache<T>(key: string, fn: () => Promise<T>) { + const cached = staticCache.get(key) as T; + if (cached) { + return Promise.resolve(cached); } - return await fetch(url); + const result = await fn(); + staticCache.set(key, result); + return result; } /** - * Read an image from a response as a base64 encoded string. + * Read a static image and cache it in memory. */ -async function readImage(response: Response) { - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.startsWith('image/')) { - throw new Error(`Invalid content type: ${contentType}`); - } +async function fetchStaticImage(url: string) { + return getWithCache(`static-image:${url}`, async () => { + const image = await fetchImage(url); + if (!image) { + throw new Error('Failed to fetch static image'); + } - const arrayBuffer = await response.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString('base64'); - return `data:${contentType};base64,${base64}`; + return image; + }); } -const staticImagesCache = new Map<string, string>(); +/** + * @vercel/og supports the following image formats: + * Extracted from https://github.com/vercel/next.js/blob/canary/packages/next/src/compiled/%40vercel/og/index.node.js + */ +const UNSUPPORTED_IMAGE_EXTENSIONS = ['.avif', '.webp']; +const SUPPORTED_IMAGE_TYPES = [ + 'image/png', + 'image/apng', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', +]; /** - * Read a static image and cache it in memory. + * Fetch an image from a URL and return a base64 encoded string. + * We do this as @vercel/og is otherwise failing on SVG images referenced by a URL. */ -async function readStaticImage(url: string) { - const cached = staticImagesCache.get(url); - if (cached) { - return cached; +async function fetchImage(url: string, options?: ResizeImageOptions) { + // Skip early some images to avoid fetching them + const parsedURL = new URL(url); + if (UNSUPPORTED_IMAGE_EXTENSIONS.includes(getExtension(parsedURL.pathname).toLowerCase())) { + return null; + } + + // We use the image resizer to normalize the image format to PNG. + // as @vercel/og can sometimes fail on some JPEG images. + const response = + checkIsSizableImageURL(url) !== SizableImageAction.Resize + ? await fetch(url) + : await resizeImage(url, { + ...options, + format: 'png', + }); + + // Filter out unsupported image types + const contentType = response.headers.get('content-type'); + if (!contentType || !SUPPORTED_IMAGE_TYPES.some((type) => contentType.includes(type))) { + return null; } - const image = await readSelfImage(url); - staticImagesCache.set(url, image); - return image; + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const base64 = buffer.toString('base64'); + const src = `data:${contentType};base64,${base64}`; + + try { + const { width, height } = imageSize(buffer); + return { src, width, height }; + } catch { + return null; + } } /** - * Read an image from GitBook itself. + * @vercel/og doesn't support RTL text, so we need to transform with a HACK for now. + * We can remove it once support has been added. + * https://github.com/vercel/satori/issues/74 */ -async function readSelfImage(url: string) { - const response = await fetchSelf(url); - const image = await readImage(response); - return image; +function transformText(text: string) { + const dir = direction(text); + if (dir !== 'rtl') { + return text; + } + + return ''; } diff --git a/packages/gitbook/src/routes/robots.ts b/packages/gitbook/src/routes/robots.ts index 297fc46771..1e1c0a2873 100644 --- a/packages/gitbook/src/routes/robots.ts +++ b/packages/gitbook/src/routes/robots.ts @@ -8,20 +8,23 @@ export async function serveRobotsTxt(context: GitBookSiteContext) { const { linker } = context; const isRoot = checkIsRootSiteContext(context); - const lines = [ - 'User-agent: *', - // Disallow dynamic routes / search queries - 'Disallow: /*?', - ...((await isSiteIndexable(context)) - ? [ - 'Allow: /', - `Sitemap: ${linker.toAbsoluteURL(linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml'))}`, - ] - : ['Disallow: /']), - ]; - const content = lines.join('\n'); + const isIndexable = await isSiteIndexable(context); - return new Response(content, { + const lines = isIndexable + ? [ + 'User-agent: *', + // Allow image resizing and icon generation routes for favicons and search results + 'Allow: /~gitbook/image?*', + 'Allow: /~gitbook/icon?*', + // Disallow other dynamic routes / search queries + 'Disallow: /*?', + 'Allow: /', + `Sitemap: ${linker.toAbsoluteURL(linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml'))}`, + ] + : ['User-agent: *', 'Disallow: /']; + + const robotsTxt = lines.join('\n'); + return new Response(robotsTxt, { headers: { 'Content-Type': 'text/plain', }, diff --git a/packages/gitbook/src/routes/sitemap.ts b/packages/gitbook/src/routes/sitemap.ts index 4a64e12387..a5f5e452fb 100644 --- a/packages/gitbook/src/routes/sitemap.ts +++ b/packages/gitbook/src/routes/sitemap.ts @@ -141,7 +141,7 @@ function getUrlsFromSiteSpaces(context: GitBookSiteContext, siteSpaces: SiteSpac } const url = new URL(siteSpace.urls.published); url.pathname = joinPath(url.pathname, 'sitemap-pages.xml'); - return context.linker.toLinkForContent(url.toString()); + return context.linker.toAbsoluteURL(context.linker.toLinkForContent(url.toString())); }, []); return urls.filter(filterOutNullable); } diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 7a05bd15fc..192ad39eb8 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -443,9 +443,17 @@ const config: Config = { scale: { '98': '0.98', '102': '1.02', + '104': '1.04', }, }, opacity: opacity(), + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + }, }, plugins: [ plugin(({ addVariant }) => { @@ -457,12 +465,16 @@ const config: Config = { /** * Variant when a header is displayed. */ - addVariant('site-header-none', 'html.site-header-none &'); + addVariant('site-header-none', 'body:not(:has(#site-header:not(.mobile-only))) &'); addVariant('site-header', 'body:has(#site-header:not(.mobile-only)) &'); addVariant('site-header-sections', [ 'body:has(#site-header:not(.mobile-only) #sections) &', 'body:has(.page-no-toc):has(#site-header:not(.mobile-only) #variants) &', ]); + addVariant( + 'announcement', + 'html:not(.announcement-hidden):has(#announcement-banner) &' + ); const customisationVariants = { // Sidebar styles @@ -478,7 +490,10 @@ const config: Config = { theme: ['theme-clean', 'theme-muted', 'theme-bold', 'theme-gradient'], // Corner styles - corner: ['straight-corners'], + corner: ['straight-corners', 'rounded-corners', 'circular-corners'], + + // Depth styles + depth: ['depth-flat', 'depth-subtle'], // Link styles links: ['links-default', 'links-accent'], @@ -506,8 +521,9 @@ const config: Config = { /** * Variant when the page contains a block that will be rendered in full-width mode. */ + addVariant('site-full-width', 'body:has(.site-full-width) &'); + addVariant('site-default-width', 'body:has(.site-default-width) &'); addVariant('page-full-width', 'body:has(.page-full-width) &'); - addVariant('page-default-width', 'body:has(.page-default-width) &'); /** * Variant when the page is configured to hide the table of content. diff --git a/packages/gitbook/tests/pagespeed-testing.ts b/packages/gitbook/tests/pagespeed-testing.ts index 94f48d4201..07f1f01b3d 100644 --- a/packages/gitbook/tests/pagespeed-testing.ts +++ b/packages/gitbook/tests/pagespeed-testing.ts @@ -12,13 +12,13 @@ interface Test { // and to be able to see the results, and only catch major regressions. const tests: Array<Test> = [ { - url: 'https://docs.gitbook.com', + url: 'https://gitbook.com/docs', strategy: 'desktop', threshold: 60, }, { - url: 'https://docs.gitbook.com', + url: 'https://gitbook.com/docs', strategy: 'mobile', threshold: 30, }, diff --git a/packages/gitbook/tests/preload-bun.ts b/packages/gitbook/tests/preload-bun.ts new file mode 100644 index 0000000000..3fc846ae26 --- /dev/null +++ b/packages/gitbook/tests/preload-bun.ts @@ -0,0 +1,8 @@ +import { mock } from 'bun:test'; + +/** + * Mock the `server-only` module to avoid errors when running tests as it doesn't work well in Bun + */ +mock.module('server-only', () => { + return {}; +}); diff --git a/packages/icons/package.json b/packages/icons/package.json index 472b7a7b3b..0e0b802123 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -32,7 +32,7 @@ }, "scripts": { "generate": "bun ./bin/gen-list.js", - "build": "tsc", + "build": "tsc --project tsconfig.build.json", "typecheck": "tsc --noEmit", "dev": "tsc -w", "clean": "rm -rf ./dist && rm -rf ./src/data", @@ -41,7 +41,7 @@ "bin": { "gitbook-icons": "./bin/gitbook-icons.js" }, - "files": ["dist", "src", "bin", "data", "README.md", "CHANGELOG.md"], + "files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"], "engines": { "node": ">=20.0.0" } diff --git a/packages/icons/tsconfig.build.json b/packages/icons/tsconfig.build.json new file mode 100644 index 0000000000..e4828bc1f6 --- /dev/null +++ b/packages/icons/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "src/**/*.test.ts"] +} diff --git a/packages/openapi-parser/CHANGELOG.md b/packages/openapi-parser/CHANGELOG.md index 36f5cbc224..7f6ac7ccd2 100644 --- a/packages/openapi-parser/CHANGELOG.md +++ b/packages/openapi-parser/CHANGELOG.md @@ -1,5 +1,11 @@ # @gitbook/openapi-parser +## 2.1.4 + +### Patch Changes + +- d00dc8c: Pass scalar's errors through OpenAPIParseError + ## 2.1.3 ### Patch Changes diff --git a/packages/openapi-parser/package.json b/packages/openapi-parser/package.json index f0829fca1b..e65ebb4dda 100644 --- a/packages/openapi-parser/package.json +++ b/packages/openapi-parser/package.json @@ -9,7 +9,7 @@ "default": "./dist/index.js" } }, - "version": "2.1.3", + "version": "2.1.4", "sideEffects": false, "dependencies": { "@scalar/openapi-parser": "^0.10.10", diff --git a/packages/openapi-parser/src/error.ts b/packages/openapi-parser/src/error.ts index e0d25d3e29..a7c5398bcb 100644 --- a/packages/openapi-parser/src/error.ts +++ b/packages/openapi-parser/src/error.ts @@ -1,3 +1,5 @@ +import type { ErrorObject } from '@scalar/openapi-parser'; + type OpenAPIParseErrorCode = | 'invalid' | 'parse-v2-in-v3' @@ -12,17 +14,19 @@ export class OpenAPIParseError extends Error { public override name = 'OpenAPIParseError'; public code: OpenAPIParseErrorCode; public rootURL: string | null; - + public errors: ErrorObject[] | undefined; constructor( message: string, options: { code: OpenAPIParseErrorCode; rootURL?: string | null; cause?: Error; + errors?: ErrorObject[] | undefined; } ) { super(message, { cause: options.cause }); this.code = options.code; this.rootURL = options.rootURL ?? null; + this.errors = options.errors; } } diff --git a/packages/openapi-parser/src/v3.ts b/packages/openapi-parser/src/v3.ts index 14a890769a..3b54819cff 100644 --- a/packages/openapi-parser/src/v3.ts +++ b/packages/openapi-parser/src/v3.ts @@ -40,6 +40,7 @@ async function untrustedValidate(input: ValidateOpenAPIV3Input) { throw new OpenAPIParseError('Invalid OpenAPI document', { code: 'invalid', rootURL, + errors: result.errors, }); } diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index d2c8bd8ee7..af2f568544 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "classnames": "^2.5.1", - "@gitbook/api": "*", + "@gitbook/api": "catalog:", "@gitbook/icons": "workspace:*" }, "peerDependencies": { diff --git a/packages/react-contentkit/src/ContentKit.tsx b/packages/react-contentkit/src/ContentKit.tsx index 9d53c70de2..4f413f367a 100644 --- a/packages/react-contentkit/src/ContentKit.tsx +++ b/packages/react-contentkit/src/ContentKit.tsx @@ -38,10 +38,18 @@ export function ContentKit<RenderContext>(props: { render: (input: { renderContext: RenderContext; request: RequestRenderIntegrationUI; - }) => Promise<{ - children: React.ReactNode; - output: ContentKitRenderOutput; - }>; + }) => Promise< + | { + error?: undefined; + children: React.ReactNode; + output: ContentKitRenderOutput; + } + | { + error: string; + children?: undefined; + output?: undefined; + } + >; /** Callback when an action is triggered */ onAction?: (action: ContentKitAction) => void; /** Callback when the flow is completed */ @@ -98,17 +106,18 @@ export function ContentKit<RenderContext>(props: { request: newInput, }); const output = result.output; + if (output) { + if (output.type === 'complete') { + return onComplete?.(output.returnValue); + } - if (output.type === 'complete') { - return onComplete?.(output.returnValue); + setCurrent((prev) => ({ + input: newInput, + children: result.children, + output: output, + state: prev.state, + })); } - - setCurrent((prev) => ({ - input: newInput, - children: result.children, - output: output, - state: prev.state, - })); }, [setCurrent, current, render, onComplete] ); @@ -147,8 +156,10 @@ export function ContentKit<RenderContext>(props: { renderContext, request: modalInput, }); - - if (result.output.type === 'element' || !result.output.type) { + if ( + result.output && + (result.output.type === 'element' || !result.output.type) + ) { setSubView({ mode: 'modal', initialInput: modalInput, diff --git a/packages/react-openapi/CHANGELOG.md b/packages/react-openapi/CHANGELOG.md index f74c6f044b..6f36c7dc61 100644 --- a/packages/react-openapi/CHANGELOG.md +++ b/packages/react-openapi/CHANGELOG.md @@ -1,5 +1,24 @@ # @gitbook/react-openapi +## 1.3.0 + +### Minor Changes + +- 326e28e: Design tweaks to code blocks and OpenAPI pages + +### Patch Changes + +- 42ca7e1: Fix openapi CR preview +- 5e975ab: Fix code highlighting for HTTP +- 580101d: Fix schemas disclosure label causing client error +- 20ebecb: Missing top-level required OpenAPI alternatives +- 80cb52a: Handle OpenAPI alternatives from schema.items +- cb5598d: Handle invalid OpenAPI Responses +- c6637b0: Use default value if string number or boolean in generateSchemaExample +- a3ec264: Fix Python code sample "null vs None" +- Updated dependencies [d00dc8c] + - @gitbook/openapi-parser@2.1.4 + ## 1.2.1 ### Patch Changes diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index cd1099c242..d4f170270a 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -8,7 +8,7 @@ "default": "./dist/index.js" } }, - "version": "1.2.1", + "version": "1.3.0", "sideEffects": false, "dependencies": { "@gitbook/openapi-parser": "workspace:*", diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 8b67bbedc4..8bffb6d9ad 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -312,6 +312,11 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { [name]: 'YOUR_API_KEY', }; } + case 'oauth2': { + return { + Authorization: 'Bearer YOUR_OAUTH2_TOKEN', + }; + } default: { return {}; } diff --git a/packages/react-openapi/src/OpenAPIDisclosure.tsx b/packages/react-openapi/src/OpenAPIDisclosure.tsx index d84b4523ba..56e663b44b 100644 --- a/packages/react-openapi/src/OpenAPIDisclosure.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosure.tsx @@ -9,11 +9,12 @@ import { Button, Disclosure, DisclosurePanel } from 'react-aria-components'; */ export function OpenAPIDisclosure(props: { icon: React.ReactNode; + header: React.ReactNode; children: React.ReactNode; label: string | ((isExpanded: boolean) => string); className?: string; }): React.JSX.Element { - const { icon, children, label, className } = props; + const { icon, header, label, children, className } = props; const [isExpanded, setIsExpanded] = useState(false); return ( @@ -31,8 +32,11 @@ export function OpenAPIDisclosure(props: { : 'none', })} > - {icon} - <span>{typeof label === 'function' ? label(isExpanded) : label}</span> + {header} + <div className="openapi-disclosure-trigger-label"> + <span>{typeof label === 'function' ? label(isExpanded) : label}</span> + {icon} + </div> </Button> <DisclosurePanel className="openapi-disclosure-panel"> {isExpanded ? children : null} diff --git a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx index 7f2a428d5c..ecef441b76 100644 --- a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx @@ -76,7 +76,7 @@ function DisclosureItem(props: { }); const panelRef = useRef<HTMLDivElement | null>(null); - const triggerRef = useRef<HTMLButtonElement | null>(null); + const triggerRef = useRef<HTMLDivElement | null>(null); const isDisabled = groupState?.isDisabled || !group.tabs?.length || false; const { buttonProps: triggerProps, panelProps } = useDisclosure( { @@ -96,55 +96,56 @@ function DisclosureItem(props: { return ( <div className="openapi-disclosure-group" aria-expanded={state.isExpanded}> - <div className="openapi-disclosure-group-header"> - <button - slot="trigger" - ref={triggerRef} - {...mergeProps(buttonProps, focusProps)} - disabled={isDisabled} - style={{ - outline: isFocusVisible - ? '2px solid rgb(var(--primary-color-500)/0.4)' - : 'none', - }} - className="openapi-disclosure-group-trigger" - > - <div className="openapi-disclosure-group-icon"> - {icon || ( - <svg viewBox="0 0 24 24" className="openapi-disclosure-group-icon"> - <path d="m8.25 4.5 7.5 7.5-7.5 7.5" /> - </svg> - )} - </div> + <div + slot="trigger" + ref={triggerRef} + {...mergeProps(buttonProps, focusProps)} + aria-disabled={isDisabled} + style={{ + outline: isFocusVisible + ? '2px solid rgb(var(--primary-color-500)/0.4)' + : 'none', + }} + className="openapi-disclosure-group-trigger" + > + <div className="openapi-disclosure-group-icon"> + {icon || ( + <svg viewBox="0 0 24 24" className="openapi-disclosure-group-icon"> + <path d="m8.25 4.5 7.5 7.5-7.5 7.5" /> + </svg> + )} + </div> + <div className="openapi-disclosure-group-label"> {group.label} - </button> - {group.tabs ? ( - <div - className="openapi-disclosure-group-mediatype" - onClick={(e) => e.stopPropagation()} - > - {group.tabs?.length > 1 ? ( - <OpenAPISelect - icon={selectIcon} - stateKey={selectStateKey} - onSelectionChange={() => { - state.expand(); - }} - items={group.tabs} - placement="bottom end" - > - {group.tabs.map((tab) => ( - <OpenAPISelectItem key={tab.key} id={tab.key} value={tab}> - {tab.label} - </OpenAPISelectItem> - ))} - </OpenAPISelect> - ) : group.tabs[0]?.label ? ( - <span>{group.tabs[0].label}</span> - ) : null} - </div> - ) : null} + + {group.tabs ? ( + <div + className="openapi-disclosure-group-mediatype" + onClick={(e) => e.stopPropagation()} + > + {group.tabs?.length > 1 ? ( + <OpenAPISelect + icon={selectIcon} + stateKey={selectStateKey} + onSelectionChange={() => { + state.expand(); + }} + items={group.tabs} + placement="bottom end" + > + {group.tabs.map((tab) => ( + <OpenAPISelectItem key={tab.key} id={tab.key} value={tab}> + {tab.label} + </OpenAPISelectItem> + ))} + </OpenAPISelect> + ) : group.tabs[0]?.label ? ( + <span>{group.tabs[0].label}</span> + ) : null} + </div> + ) : null} + </div> </div> {state.isExpanded && selectedTab && ( diff --git a/packages/react-openapi/src/OpenAPIResponse.tsx b/packages/react-openapi/src/OpenAPIResponse.tsx index de744b7589..3c005ec29b 100644 --- a/packages/react-openapi/src/OpenAPIResponse.tsx +++ b/packages/react-openapi/src/OpenAPIResponse.tsx @@ -1,7 +1,9 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import { OpenAPIDisclosure } from './OpenAPIDisclosure'; +import { OpenAPISchemaPresentation } from './OpenAPISchema'; import { OpenAPISchemaProperties } from './OpenAPISchemaServer'; import type { OpenAPIClientContext } from './context'; +import { tString } from './translate'; import { parameterToProperty, resolveDescription } from './utils'; /** @@ -27,7 +29,31 @@ export function OpenAPIResponse(props: { return ( <div className="openapi-response-body"> {headers.length > 0 ? ( - <OpenAPIDisclosure icon={context.icons.plus} label="Headers"> + <OpenAPIDisclosure + header={ + <OpenAPISchemaPresentation + context={context} + property={{ + propertyName: tString(context.translation, 'headers'), + schema: { + type: 'object', + }, + required: null, + }} + /> + } + icon={context.icons.plus} + label={(isExpanded) => + tString( + context.translation, + isExpanded ? 'hide' : 'show', + tString( + context.translation, + headers.length === 1 ? 'header' : 'headers' + ) + ) + } + > <OpenAPISchemaProperties properties={headers.map(([name, header]) => parameterToProperty({ name, ...header }) @@ -40,7 +66,13 @@ export function OpenAPIResponse(props: { <div className="openapi-responsebody"> <OpenAPISchemaProperties id={`response-${context.blockKey}`} - properties={[{ schema: mediaType.schema }]} + properties={[ + { + schema: mediaType.schema, + propertyName: tString(context.translation, 'response'), + required: null, + }, + ]} context={context} /> </div> diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx index 6d1cfdac25..b4b865f505 100644 --- a/packages/react-openapi/src/OpenAPIResponseExample.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx @@ -6,8 +6,8 @@ import { OpenAPIResponseExampleContent } from './OpenAPIResponseExampleContent'; import { type OpenAPIContext, getOpenAPIClientContext } from './context'; import type { OpenAPIOperationData, OpenAPIWebhookData } from './types'; import { getExampleFromReference, getExamples } from './util/example'; -import { createStateKey, getStatusCodeDefaultLabel } from './utils'; -import { checkIsReference, resolveDescription } from './utils'; +import { createStateKey, getStatusCodeDefaultLabel, resolveDescription } from './utils'; +import { checkIsReference } from './utils'; /** * Display an example of the response content. @@ -41,45 +41,47 @@ export function OpenAPIResponseExample(props: { return Number(a) - Number(b); }); - const tabs = responses.map(([key, responseObject]) => { - const description = resolveDescription(responseObject); - const label = description ? ( - <Markdown source={description} /> - ) : ( - getStatusCodeDefaultLabel(key, context) - ); + const tabs = responses + .filter(([_, responseObject]) => responseObject && typeof responseObject === 'object') + .map(([key, responseObject]) => { + const description = resolveDescription(responseObject); + const label = description ? ( + <Markdown source={description} /> + ) : ( + getStatusCodeDefaultLabel(key, context) + ); - if (checkIsReference(responseObject)) { - return { - key: key, - label, - statusCode: key, - body: ( - <OpenAPIExample - example={getExampleFromReference(responseObject, context)} - context={context} - syntax="json" - /> - ), - }; - } + if (checkIsReference(responseObject)) { + return { + key: key, + label, + statusCode: key, + body: ( + <OpenAPIExample + example={getExampleFromReference(responseObject, context)} + context={context} + syntax="json" + /> + ), + }; + } + + if (!responseObject.content || Object.keys(responseObject.content).length === 0) { + return { + key: key, + label, + statusCode: key, + body: <OpenAPIEmptyExample context={context} />, + }; + } - if (!responseObject.content || Object.keys(responseObject.content).length === 0) { return { key: key, label, statusCode: key, - body: <OpenAPIEmptyExample context={context} />, + body: <OpenAPIResponse context={context} content={responseObject.content} />, }; - } - - return { - key: key, - label, - statusCode: key, - body: <OpenAPIResponse context={context} content={responseObject.content} />, - }; - }); + }); if (tabs.length === 0) { return null; diff --git a/packages/react-openapi/src/OpenAPIResponses.tsx b/packages/react-openapi/src/OpenAPIResponses.tsx index efee2b3ce8..c4565aa788 100644 --- a/packages/react-openapi/src/OpenAPIResponses.tsx +++ b/packages/react-openapi/src/OpenAPIResponses.tsx @@ -20,8 +20,9 @@ export function OpenAPIResponses(props: { }) { const { responses, context } = props; - const groups = Object.entries(responses).map( - ([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => { + const groups = Object.entries(responses) + .filter(([_, response]) => response && typeof response === 'object') + .map(([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => { const tabs = (() => { // If there is no content, but there are headers, we need to show the headers if ( @@ -83,8 +84,7 @@ export function OpenAPIResponses(props: { ), tabs, }; - } - ); + }); const state = useResponseExamplesState(context.blockKey, groups[0]?.key); diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index d268b17cf5..e9f8c6707b 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -4,6 +4,7 @@ import type { OpenAPICustomOperationProperties, OpenAPIV3 } from '@gitbook/openapi-parser'; import { useId } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import clsx from 'clsx'; import { Markdown } from './Markdown'; @@ -12,6 +13,7 @@ import { OpenAPIDisclosure } from './OpenAPIDisclosure'; import { OpenAPISchemaName } from './OpenAPISchemaName'; import type { OpenAPIClientContext } from './context'; import { retrocycle } from './decycle'; +import { getDisclosureLabel } from './getDisclosureLabel'; import { stringifyOpenAPI } from './stringifyOpenAPI'; import { tString } from './translate'; import { checkIsReference, resolveDescription, resolveFirstExample } from './utils'; @@ -19,73 +21,97 @@ import { checkIsReference, resolveDescription, resolveFirstExample } from './uti type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>; export interface OpenAPISchemaPropertyEntry { - propertyName?: string | undefined; - required?: boolean | undefined; + propertyName?: string; + required?: boolean | null; schema: OpenAPIV3.SchemaObject; } /** * Render a property of an OpenAPI schema. */ -function OpenAPISchemaProperty(props: { - property: OpenAPISchemaPropertyEntry; - context: OpenAPIClientContext; - circularRefs: CircularRefsIds; - className?: string; -}) { - const { circularRefs: parentCircularRefs, context, className, property } = props; +function OpenAPISchemaProperty( + props: { + property: OpenAPISchemaPropertyEntry; + context: OpenAPIClientContext; + circularRefs: CircularRefsIds; + className?: string; + } & Omit<ComponentPropsWithoutRef<'div'>, 'property' | 'context' | 'circularRefs' | 'className'> +) { + const { circularRefs: parentCircularRefs, context, className, property, ...rest } = props; const { schema } = property; const id = useId(); - return ( - <div id={id} className={clsx('openapi-schema', className)}> - <OpenAPISchemaPresentation context={context} property={property} /> - {(() => { - const circularRefId = parentCircularRefs.get(schema); - // Avoid recursing infinitely, and instead render a link to the parent schema - if (circularRefId) { - return <OpenAPISchemaCircularRef id={circularRefId} schema={schema} />; - } + const circularRefId = parentCircularRefs.get(schema); + // Avoid recursing infinitely, and instead render a link to the parent schema + if (circularRefId) { + return <OpenAPISchemaCircularRef id={circularRefId} schema={schema} />; + } + + const circularRefs = new Map(parentCircularRefs); + circularRefs.set(schema, id); + + const properties = getSchemaProperties(schema); + + const ancestors = new Set(circularRefs.keys()); + const alternatives = getSchemaAlternatives(schema, ancestors); + + const header = <OpenAPISchemaPresentation context={context} property={property} />; + const content = (() => { + if (properties?.length) { + return ( + <OpenAPISchemaProperties + properties={properties} + circularRefs={circularRefs} + context={context} + /> + ); + } - const circularRefs = new Map(parentCircularRefs); - circularRefs.set(schema, id); - - const properties = getSchemaProperties(schema); - if (properties?.length) { - return ( - <OpenAPIDisclosure - icon={context.icons.plus} - label={(isExpanded) => - getDisclosureLabel({ schema, isExpanded, context }) - } - > - <OpenAPISchemaProperties - properties={properties} + if (alternatives) { + return ( + <div className="openapi-schema-alternatives"> + {alternatives.map((alternativeSchema, index) => ( + <div key={index} className="openapi-schema-alternative"> + <OpenAPISchemaAlternative + schema={alternativeSchema} circularRefs={circularRefs} context={context} /> - </OpenAPIDisclosure> - ); - } + {index < alternatives.length - 1 ? ( + <OpenAPISchemaAlternativeSeparator + schema={schema} + context={context} + /> + ) : null} + </div> + ))} + </div> + ); + } - const ancestors = new Set(circularRefs.keys()); - const alternatives = getSchemaAlternatives(schema, ancestors); - - if (alternatives) { - return alternatives.map((schema, index) => ( - <OpenAPISchemaAlternative - key={index} - schema={schema} - circularRefs={circularRefs} - context={context} - /> - )); - } + return null; + })(); - return null; - })()} + if (properties?.length) { + return ( + <OpenAPIDisclosure + icon={context.icons.plus} + className={clsx('openapi-schema', className)} + header={header} + label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })} + {...rest} + > + {content} + </OpenAPIDisclosure> + ); + } + + return ( + <div id={id} {...rest} className={clsx('openapi-schema', className)}> + {header} + {content} </div> ); } @@ -115,6 +141,7 @@ function OpenAPISchemaProperties(props: { circularRefs={circularRefs} property={property} context={context} + style={{ animationDelay: `${index * 0.02}s` }} /> ); })} @@ -205,34 +232,48 @@ function OpenAPISchemaAlternative(props: { context: OpenAPIClientContext; }) { const { schema, circularRefs, context } = props; - - const description = resolveDescription(schema); const properties = getSchemaProperties(schema); + return properties?.length ? ( + <OpenAPIDisclosure + icon={context.icons.plus} + header={<OpenAPISchemaPresentation property={{ schema }} context={context} />} + label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })} + > + <OpenAPISchemaProperties + properties={properties} + circularRefs={circularRefs} + context={context} + /> + </OpenAPIDisclosure> + ) : ( + <OpenAPISchemaProperty + property={{ schema }} + circularRefs={circularRefs} + context={context} + /> + ); +} + +function OpenAPISchemaAlternativeSeparator(props: { + schema: OpenAPIV3.SchemaObject; + context: OpenAPIClientContext; +}) { + const { schema, context } = props; + + const anyOf = schema.anyOf || schema.items?.anyOf; + const oneOf = schema.oneOf || schema.items?.oneOf; + const allOf = schema.allOf || schema.items?.allOf; + + if (!anyOf && !oneOf && !allOf) { + return null; + } + return ( - <> - {description ? ( - <Markdown source={description} className="openapi-schema-description" /> - ) : null} - <OpenAPIDisclosure - icon={context.icons.plus} - label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })} - > - {properties?.length ? ( - <OpenAPISchemaProperties - properties={properties} - circularRefs={circularRefs} - context={context} - /> - ) : ( - <OpenAPISchemaProperty - property={{ schema }} - circularRefs={circularRefs} - context={context} - /> - )} - </OpenAPIDisclosure> - </> + <span className="openapi-schema-alternative-separator"> + {(anyOf || oneOf) && tString(context.translation, 'or')} + {allOf && tString(context.translation, 'and')} + </span> ); } @@ -293,7 +334,7 @@ function OpenAPISchemaEnum(props: { return ( <span className="openapi-schema-enum"> - Available options:{' '} + {tString(context.translation, 'possible_values')}:{' '} {enumValues.map((item, index) => ( <span key={index} className="openapi-schema-enum-value"> <OpenAPICopyButton @@ -313,7 +354,7 @@ function OpenAPISchemaEnum(props: { /** * Render the top row of a schema. e.g: name, type, and required status. */ -function OpenAPISchemaPresentation(props: { +export function OpenAPISchemaPresentation(props: { property: OpenAPISchemaPropertyEntry; context: OpenAPIClientContext; }) { @@ -437,6 +478,14 @@ export function getSchemaAlternatives( schema: OpenAPIV3.SchemaObject, ancestors: Set<OpenAPIV3.SchemaObject> = new Set() ): OpenAPIV3.SchemaObject[] | null { + // Search for alternatives in the items property if it exists + if ( + schema.items && + ('oneOf' in schema.items || 'allOf' in schema.items || 'anyOf' in schema.items) + ) { + return getSchemaAlternatives(schema.items, ancestors); + } + const alternatives: | [AlternativeType, (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[]] | null = (() => { @@ -552,6 +601,9 @@ function flattenAlternatives( schemasOrRefs: (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[], ancestors: Set<OpenAPIV3.SchemaObject> ): OpenAPIV3.SchemaObject[] { + // Get the parent schema's required fields from the most recent ancestor + const latestAncestor = Array.from(ancestors).pop(); + return schemasOrRefs.reduce<OpenAPIV3.SchemaObject[]>((acc, schemaOrRef) => { if (checkIsReference(schemaOrRef)) { return acc; @@ -560,16 +612,47 @@ function flattenAlternatives( if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) { const schemas = getSchemaAlternatives(schemaOrRef, ancestors); if (schemas) { - acc.push(...schemas); + acc.push( + ...schemas.map((schema) => ({ + ...schema, + required: mergeRequiredFields(schema, latestAncestor), + })) + ); } return acc; } - acc.push(schemaOrRef); + // For direct schemas, handle required fields + const schema = { + ...schemaOrRef, + required: mergeRequiredFields(schemaOrRef, latestAncestor), + }; + + acc.push(schema); return acc; }, []); } +/** + * Merge the required fields of a schema with the required fields of its latest ancestor. + */ +function mergeRequiredFields( + schemaOrRef: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + latestAncestor: OpenAPIV3.SchemaObject | undefined +) { + if (!schemaOrRef.required && !latestAncestor?.required) { + return undefined; + } + + if (checkIsReference(schemaOrRef)) { + return latestAncestor?.required; + } + + return Array.from( + new Set([...(latestAncestor?.required || []), ...(schemaOrRef.required || [])]) + ); +} + function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string { // Otherwise try to infer a nice title let type = 'any'; @@ -587,6 +670,11 @@ function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string { if (schema.format) { type += ` · ${schema.format}`; } + + // Only add the title if it's an object (no need for the title of a string, number, etc.) + if (type === 'object' && schema.title) { + type += ` · ${schema.title.replaceAll(' ', '')}`; + } } if ('anyOf' in schema) { @@ -601,27 +689,3 @@ function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string { return type; } - -function getDisclosureLabel(props: { - schema: OpenAPIV3.SchemaObject; - isExpanded: boolean; - context: OpenAPIClientContext; -}) { - const { schema, isExpanded, context } = props; - let label: string; - if (schema.type === 'array' && !!schema.items) { - if (schema.items.oneOf) { - label = tString(context.translation, 'available_items').toLowerCase(); - } - // Fallback to "child attributes" for enums and objects - else if (schema.items.enum || schema.items.type === 'object') { - label = tString(context.translation, 'child_attributes').toLowerCase(); - } else { - label = schema.items.title ?? schema.title ?? getSchemaTitle(schema.items); - } - } else { - label = schema.title || tString(context.translation, 'child_attributes').toLowerCase(); - } - - return `${isExpanded ? tString(context.translation, 'hide') : tString(context.translation, 'show')} ${label}`; -} diff --git a/packages/react-openapi/src/OpenAPISchemaName.tsx b/packages/react-openapi/src/OpenAPISchemaName.tsx index 6e230419cd..8994b4d71f 100644 --- a/packages/react-openapi/src/OpenAPISchemaName.tsx +++ b/packages/react-openapi/src/OpenAPISchemaName.tsx @@ -6,7 +6,7 @@ import { t, tString } from './translate'; interface OpenAPISchemaNameProps { schema?: OpenAPIV3.SchemaObject; propertyName?: string | React.JSX.Element; - required?: boolean; + required?: boolean | null; type?: string; context: OpenAPIClientContext; } @@ -27,12 +27,14 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps) { {propertyName} </span> ) : null} - <span> - {type ? <span className="openapi-schema-type">{type}</span> : null} - {additionalItems ? ( - <span className="openapi-schema-type">{additionalItems}</span> - ) : null} - </span> + {type || additionalItems ? ( + <span> + {type ? <span className="openapi-schema-type">{type}</span> : null} + {additionalItems ? ( + <span className="openapi-schema-type">{additionalItems}</span> + ) : null} + </span> + ) : null} {schema?.readOnly ? ( <span className="openapi-schema-readonly"> {t(context.translation, 'read_only')} @@ -43,7 +45,7 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps) { {t(context.translation, 'write_only')} </span> ) : null} - {required ? ( + {required === null ? null : required ? ( <span className="openapi-schema-required"> {t(context.translation, 'required')} </span> diff --git a/packages/react-openapi/src/OpenAPISecurities.tsx b/packages/react-openapi/src/OpenAPISecurities.tsx index 3f86236506..8b007ff3c1 100644 --- a/packages/react-openapi/src/OpenAPISecurities.tsx +++ b/packages/react-openapi/src/OpenAPISecurities.tsx @@ -1,5 +1,7 @@ +import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import { InteractiveSection } from './InteractiveSection'; import { Markdown } from './Markdown'; +import { OpenAPICopyButton } from './OpenAPICopyButton'; import { OpenAPISchemaName } from './OpenAPISchemaName'; import type { OpenAPIClientContext } from './context'; import { t } from './translate'; @@ -105,13 +107,7 @@ function getLabelForType(security: OpenAPISecurityWithRequired, context: OpenAPI /> ); case 'oauth2': - return ( - <OpenAPISchemaName - context={context} - propertyName="OAuth2" - required={security.required} - /> - ); + return <OpenAPISchemaOAuth2Flows context={context} security={security} />; case 'openIdConnect': return ( <OpenAPISchemaName @@ -125,3 +121,111 @@ function getLabelForType(security: OpenAPISecurityWithRequired, context: OpenAPI return security.type; } } + +function OpenAPISchemaOAuth2Flows(props: { + context: OpenAPIClientContext; + security: OpenAPIV3.OAuth2SecurityScheme & { required?: boolean }; +}) { + const { context, security } = props; + + const flows = Object.entries(security.flows ?? {}); + + return ( + <div className="openapi-securities-oauth-flows"> + {flows.map(([name, flow], index) => ( + <OpenAPISchemaOAuth2Item + key={index} + flow={flow} + name={name} + context={context} + security={security} + /> + ))} + </div> + ); +} + +function OpenAPISchemaOAuth2Item(props: { + flow: NonNullable<OpenAPIV3.OAuth2SecurityScheme['flows']>[keyof NonNullable< + OpenAPIV3.OAuth2SecurityScheme['flows'] + >]; + name: string; + context: OpenAPIClientContext; + security: OpenAPIV3.OAuth2SecurityScheme & { required?: boolean }; +}) { + const { flow, context, security, name } = props; + + if (!flow) { + return null; + } + + const scopes = Object.entries(flow?.scopes ?? {}); + + return ( + <div> + <OpenAPISchemaName + context={context} + propertyName="OAuth2" + type={name} + required={security.required} + /> + <div className="openapi-securities-oauth-content openapi-markdown"> + {security.description ? <Markdown source={security.description} /> : null} + {'authorizationUrl' in flow && flow.authorizationUrl ? ( + <span> + Authorization URL:{' '} + <OpenAPICopyButton + value={flow.authorizationUrl} + context={context} + className="openapi-securities-url" + withTooltip + > + {flow.authorizationUrl} + </OpenAPICopyButton> + </span> + ) : null} + {'tokenUrl' in flow && flow.tokenUrl ? ( + <span> + Token URL:{' '} + <OpenAPICopyButton + value={flow.tokenUrl} + context={context} + className="openapi-securities-url" + withTooltip + > + {flow.tokenUrl} + </OpenAPICopyButton> + </span> + ) : null} + {'refreshUrl' in flow && flow.refreshUrl ? ( + <span> + Refresh URL:{' '} + <OpenAPICopyButton + value={flow.refreshUrl} + context={context} + className="openapi-securities-url" + withTooltip + > + {flow.refreshUrl} + </OpenAPICopyButton> + </span> + ) : null} + {scopes.length ? ( + <div> + {t(context.translation, 'available_scopes')}:{' '} + <ul> + {scopes.map(([key, value]) => ( + <li key={key}> + <OpenAPICopyButton value={key} context={context} withTooltip> + <code>{key}</code> + </OpenAPICopyButton> + : {value} + </li> + ))} + </ul> + </div> + ) : null} + </div> + </div> + ); +} diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx index 49c41cda40..1e6e562690 100644 --- a/packages/react-openapi/src/OpenAPISpec.tsx +++ b/packages/react-openapi/src/OpenAPISpec.tsx @@ -18,7 +18,7 @@ export function OpenAPISpec(props: { const { operation } = data; - const parameters = operation.parameters ?? []; + const parameters = deduplicateParameters(operation.parameters ?? []); const parameterGroups = groupParameters(parameters, context); const securities = 'securities' in data ? data.securities : []; @@ -113,3 +113,23 @@ function getParameterGroupName(paramIn: string, context: OpenAPIClientContext): return paramIn; } } + +/** Deduplicate parameters by name and in. + * Some specs have both parameters define at path and operation level. + * We only want to display one of them. + */ +function deduplicateParameters(parameters: OpenAPI.Parameters): OpenAPI.Parameters { + const seen = new Set(); + + return parameters.filter((param) => { + const key = `${param.name}:${param.in}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + + return true; + }); +} diff --git a/packages/react-openapi/src/StaticSection.tsx b/packages/react-openapi/src/StaticSection.tsx index ffb3a2bba8..b79b20e617 100644 --- a/packages/react-openapi/src/StaticSection.tsx +++ b/packages/react-openapi/src/StaticSection.tsx @@ -11,7 +11,7 @@ export function SectionHeader(props: ComponentPropsWithoutRef<'div'>) { {...props} className={clsx( 'openapi-section-header', - props.className && `${props.className}-header` + props.className ? `${props.className}-header` : undefined )} /> ); diff --git a/packages/react-openapi/src/code-samples.test.ts b/packages/react-openapi/src/code-samples.test.ts index 363baf5525..375cee84be 100644 --- a/packages/react-openapi/src/code-samples.test.ts +++ b/packages/react-openapi/src/code-samples.test.ts @@ -400,7 +400,7 @@ describe('python code sample generator', () => { const output = generator?.generate(input); expect(output).toBe( - 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={"key":"value"}\n)\n\ndata = response.json()' + 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={\n "key": "value"\n }\n)\n\ndata = response.json()' ); }); @@ -415,13 +415,14 @@ describe('python code sample generator', () => { key: 'value', truethy: true, falsey: false, + nullish: null, }, }; const output = generator?.generate(input); expect(output).toBe( - 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({"key":"value","truethy":True,"falsey":False})\n)\n\ndata = response.json()' + 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({\n "key": "value",\n "truethy": True,\n "falsey": False,\n "nullish": None\n })\n)\n\ndata = response.json()' ); }); diff --git a/packages/react-openapi/src/code-samples.ts b/packages/react-openapi/src/code-samples.ts index fe44da11d8..8d855bbb67 100644 --- a/packages/react-openapi/src/code-samples.ts +++ b/packages/react-openapi/src/code-samples.ts @@ -30,7 +30,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [ { id: 'http', label: 'HTTP', - syntax: 'bash', + syntax: 'http', generate: ({ method, url, headers = {}, body }: CodeSampleInput) => { const { host, path } = parseHostAndPath(url); @@ -356,18 +356,25 @@ const BodyGenerators = { // Convert JSON to XML if needed body = JSON.stringify(convertBodyToXML(body)); } else { - body = stringifyOpenAPI(body, (_key, value) => { - switch (value) { - case true: - return '$$__TRUE__$$'; - case false: - return '$$__FALSE__$$'; - default: - return value; - } - }) + body = stringifyOpenAPI( + body, + (_key, value) => { + switch (value) { + case true: + return '$$__TRUE__$$'; + case false: + return '$$__FALSE__$$'; + case null: + return '$$__NULL__$$'; + default: + return value; + } + }, + 2 + ) .replaceAll('"$$__TRUE__$$"', 'True') - .replaceAll('"$$__FALSE__$$"', 'False'); + .replaceAll('"$$__FALSE__$$"', 'False') + .replaceAll('"$$__NULL__$$"', 'None'); } return { body, code, headers }; diff --git a/packages/react-openapi/src/generateSchemaExample.test.ts b/packages/react-openapi/src/generateSchemaExample.test.ts index 3181881b62..682c5f2f38 100644 --- a/packages/react-openapi/src/generateSchemaExample.test.ts +++ b/packages/react-openapi/src/generateSchemaExample.test.ts @@ -1017,4 +1017,24 @@ describe('generateSchemaExample', () => { }, }); }); + + it('handles deprecated properties', () => { + expect( + generateSchemaExample({ + type: 'object', + deprecated: true, + }) + ).toBeUndefined(); + }); + + it('handle nested deprecated properties', () => { + expect( + generateSchemaExample({ + type: 'array', + items: { + deprecated: true, + }, + }) + ).toBeUndefined(); + }); }); diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts index 37695b9fe6..595f723d91 100644 --- a/packages/react-openapi/src/generateSchemaExample.ts +++ b/packages/react-openapi/src/generateSchemaExample.ts @@ -167,7 +167,7 @@ const getExampleFromSchema = ( const makeUpRandomData = !!options?.emptyString; // If the property is deprecated we don't show it in examples. - if (schema.deprecated) { + if (schema.deprecated || (schema.type === 'array' && schema.items?.deprecated)) { return undefined; } @@ -204,6 +204,14 @@ const getExampleFromSchema = ( return cache(schema, schema.example); } + // Use a default value, if there’s one and it’s a string or number + if ( + schema.default !== undefined && + ['string', 'number', 'boolean'].includes(typeof schema.default) + ) { + return cache(schema, schema.default); + } + // enum: [ 'available', 'pending', 'sold' ] if (Array.isArray(schema.enum) && schema.enum.length > 0) { return cache(schema, schema.enum[0]); diff --git a/packages/react-openapi/src/getDisclosureLabel.ts b/packages/react-openapi/src/getDisclosureLabel.ts new file mode 100644 index 0000000000..814113b910 --- /dev/null +++ b/packages/react-openapi/src/getDisclosureLabel.ts @@ -0,0 +1,25 @@ +'use client'; + +import type { OpenAPIV3 } from '@gitbook/openapi-parser'; +import type { OpenAPIClientContext } from './context'; +import { tString } from './translate'; + +export function getDisclosureLabel(props: { + schema: OpenAPIV3.SchemaObject; + isExpanded: boolean; + context: OpenAPIClientContext; +}) { + const { schema, isExpanded, context } = props; + let label: string; + if (schema.type === 'array' && !!schema.items) { + if (schema.items.oneOf) { + label = tString(context.translation, 'available_items').toLowerCase(); + } else { + label = tString(context.translation, 'properties').toLowerCase(); + } + } else { + label = tString(context.translation, 'properties').toLowerCase(); + } + + return tString(context.translation, isExpanded ? 'hide' : 'show', label); +} diff --git a/packages/react-openapi/src/schemas/OpenAPISchemaItem.tsx b/packages/react-openapi/src/schemas/OpenAPISchemaItem.tsx new file mode 100644 index 0000000000..86b7d49e8f --- /dev/null +++ b/packages/react-openapi/src/schemas/OpenAPISchemaItem.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { SectionBody } from '../StaticSection'; + +import type { OpenAPIV3 } from '@gitbook/openapi-parser'; +import { OpenAPIDisclosure } from '../OpenAPIDisclosure'; +import { OpenAPIRootSchema } from '../OpenAPISchemaServer'; +import { Section } from '../StaticSection'; +import type { OpenAPIClientContext } from '../context'; +import { getDisclosureLabel } from '../getDisclosureLabel'; + +export function OpenAPISchemaItem(props: { + name: string; + schema: OpenAPIV3.SchemaObject; + context: OpenAPIClientContext; +}) { + const { schema, context, name } = props; + + return ( + <OpenAPIDisclosure + className="openapi-schemas-disclosure" + key={name} + icon={context.icons.plus} + header={name} + label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })} + > + <Section className="openapi-section-schemas"> + <SectionBody> + <OpenAPIRootSchema schema={schema} context={context} /> + </SectionBody> + </Section> + </OpenAPIDisclosure> + ); +} diff --git a/packages/react-openapi/src/schemas/OpenAPISchemas.tsx b/packages/react-openapi/src/schemas/OpenAPISchemas.tsx index 2283245fcb..c0700a530a 100644 --- a/packages/react-openapi/src/schemas/OpenAPISchemas.tsx +++ b/packages/react-openapi/src/schemas/OpenAPISchemas.tsx @@ -1,9 +1,8 @@ import type { OpenAPISchema } from '@gitbook/openapi-parser'; import clsx from 'clsx'; -import { OpenAPIDisclosure } from '../OpenAPIDisclosure'; import { OpenAPIExample } from '../OpenAPIExample'; import { OpenAPIRootSchema } from '../OpenAPISchemaServer'; -import { Section, SectionBody, StaticSection } from '../StaticSection'; +import { StaticSection } from '../StaticSection'; import { type OpenAPIContextInput, getOpenAPIClientContext, @@ -11,6 +10,7 @@ import { } from '../context'; import { t } from '../translate'; import { getExampleFromSchema } from '../util/example'; +import { OpenAPISchemaItem } from './OpenAPISchemaItem'; /** * OpenAPI Schemas component. @@ -85,18 +85,12 @@ export function OpenAPISchemas(props: { <div className={clsx('openapi-schemas', className)}> {schemas.map(({ name, schema }) => { return ( - <OpenAPIDisclosure - className="openapi-schemas-disclosure" + <OpenAPISchemaItem key={name} - icon={context.icons.chevronRight} - label={name} - > - <Section className="openapi-section-schemas"> - <SectionBody> - <OpenAPIRootSchema schema={schema} context={clientContext} /> - </SectionBody> - </Section> - </OpenAPIDisclosure> + name={name} + context={clientContext} + schema={schema} + /> ); })} </div> diff --git a/packages/react-openapi/src/translations/de.ts b/packages/react-openapi/src/translations/de.ts index 8fc792e8ea..6229acd4fa 100644 --- a/packages/react-openapi/src/translations/de.ts +++ b/packages/react-openapi/src/translations/de.ts @@ -18,9 +18,11 @@ export const de = { nullable: 'Nullfähig', body: 'Rumpf', payload: 'Nutzlast', - headers: 'Kopfzeilen', + headers: 'Header', + header: 'Header', authorizations: 'Autorisierungen', responses: 'Antworten', + response: 'Antwort', path_parameters: 'Pfadparameter', query_parameters: 'Abfrageparameter', header_parameters: 'Header-Parameter', @@ -30,8 +32,12 @@ export const de = { success: 'Erfolg', redirect: 'Umleitung', error: 'Fehler', - show: 'Anzeigen', - hide: 'Verstecken', + show: 'Zeige ${1}', + hide: 'Verstecke ${1}', available_items: 'Verfügbare Elemente', - child_attributes: 'Unterattribute', + available_scopes: 'Verfügbare scopes', + properties: 'Eigenschaften', + or: 'oder', + and: 'und', + possible_values: 'Mögliche Werte', }; diff --git a/packages/react-openapi/src/translations/en.ts b/packages/react-openapi/src/translations/en.ts index 61f45e9e3f..3681626898 100644 --- a/packages/react-openapi/src/translations/en.ts +++ b/packages/react-openapi/src/translations/en.ts @@ -19,8 +19,10 @@ export const en = { body: 'Body', payload: 'Payload', headers: 'Headers', + header: 'Header', authorizations: 'Authorizations', responses: 'Responses', + response: 'Response', path_parameters: 'Path parameters', query_parameters: 'Query parameters', header_parameters: 'Header parameters', @@ -30,8 +32,12 @@ export const en = { success: 'Success', redirect: 'Redirect', error: 'Error', - show: 'Show', - hide: 'Hide', + show: 'Show ${1}', + hide: 'Hide ${1}', available_items: 'Available items', - child_attributes: 'Child attributes', + available_scopes: 'Available scopes', + possible_values: 'Possible values', + properties: 'Properties', + or: 'or', + and: 'and', }; diff --git a/packages/react-openapi/src/translations/es.ts b/packages/react-openapi/src/translations/es.ts index 5faa36c674..f9e8c58f48 100644 --- a/packages/react-openapi/src/translations/es.ts +++ b/packages/react-openapi/src/translations/es.ts @@ -18,9 +18,11 @@ export const es = { nullable: 'Nulo', body: 'Cuerpo', payload: 'Caga útil', - headers: 'Encabezados', + headers: 'Headers', + header: 'Header', authorizations: 'Autorizaciones', responses: 'Respuestas', + response: 'Respuesta', path_parameters: 'Parámetros de ruta', query_parameters: 'Parámetros de consulta', header_parameters: 'Parámetros de encabezado', @@ -30,8 +32,12 @@ export const es = { success: 'Éxito', redirect: 'Redirección', error: 'Error', - show: 'Mostrar', - hide: 'Ocultar', + show: 'Mostrar ${1}', + hide: 'Ocultar ${1}', available_items: 'Elementos disponibles', - child_attributes: 'Atributos secundarios', + available_scopes: 'Scopes disponibles', + properties: 'Propiedades', + or: 'o', + and: 'y', + possible_values: 'Valores posibles', }; diff --git a/packages/react-openapi/src/translations/fr.ts b/packages/react-openapi/src/translations/fr.ts index ccaf05b3d9..fde7a9222c 100644 --- a/packages/react-openapi/src/translations/fr.ts +++ b/packages/react-openapi/src/translations/fr.ts @@ -18,20 +18,26 @@ export const fr = { nullable: 'Nullable', body: 'Corps', payload: 'Charge utile', - headers: 'En-têtes', + headers: 'Headers', + header: 'Header', authorizations: 'Autorisations', responses: 'Réponses', + response: 'Réponse', path_parameters: 'Paramètres de chemin', query_parameters: 'Paramètres de requête', - header_parameters: 'Paramètres d’en-tête', + header_parameters: "Paramètres d'en-tête", attributes: 'Attributs', test_it: 'Tester', information: 'Information', success: 'Succès', redirect: 'Redirection', error: 'Erreur', - show: 'Afficher', - hide: 'Masquer', + show: 'Afficher ${1}', + hide: 'Masquer ${1}', available_items: 'Éléments disponibles', - child_attributes: 'Attributs enfants', + available_scopes: 'Scopes disponibles', + properties: 'Propriétés', + or: 'ou', + and: 'et', + possible_values: 'Valeurs possibles', }; diff --git a/packages/react-openapi/src/translations/ja.ts b/packages/react-openapi/src/translations/ja.ts index 55b5b2a0a0..04d43f67ae 100644 --- a/packages/react-openapi/src/translations/ja.ts +++ b/packages/react-openapi/src/translations/ja.ts @@ -19,8 +19,10 @@ export const ja = { body: '本文', payload: 'ペイロード', headers: 'ヘッダー', + header: 'ヘッダー', authorizations: '認可', responses: 'レスポンス', + response: 'レスポンス', path_parameters: 'パスパラメータ', query_parameters: 'クエリパラメータ', header_parameters: 'ヘッダーパラメータ', @@ -30,8 +32,12 @@ export const ja = { success: '成功', redirect: 'リダイレクト', error: 'エラー', - show: '表示', - hide: '非表示', + show: '${1}を表示', + hide: '${1}を非表示', available_items: '利用可能なアイテム', - child_attributes: '子属性', + available_scopes: '利用可能なスコープ', + properties: 'プロパティ', + or: 'または', + and: 'かつ', + possible_values: '可能な値', }; diff --git a/packages/react-openapi/src/translations/nl.ts b/packages/react-openapi/src/translations/nl.ts index 5186580c78..2c57d7af4f 100644 --- a/packages/react-openapi/src/translations/nl.ts +++ b/packages/react-openapi/src/translations/nl.ts @@ -19,8 +19,10 @@ export const nl = { body: 'Body', payload: 'Payload', headers: 'Headers', + header: 'Header', authorizations: 'Autorisaties', responses: 'Reacties', + response: 'Reactie', path_parameters: 'Padparameters', query_parameters: 'Queryparameters', header_parameters: 'Headerparameters', @@ -30,8 +32,12 @@ export const nl = { success: 'Succes', redirect: 'Omleiding', error: 'Fout', - show: 'Toon', - hide: 'Verbergen', + show: 'Toon ${1}', + hide: 'Verberg ${1}', available_items: 'Beschikbare items', - child_attributes: 'Kindattributen', + available_scopes: 'Beschikbare scopes', + properties: 'Eigenschappen', + or: 'of', + and: 'en', + possible_values: 'Mogelijke waarden', }; diff --git a/packages/react-openapi/src/translations/no.ts b/packages/react-openapi/src/translations/no.ts index a4efc3cfe7..9ef1b80048 100644 --- a/packages/react-openapi/src/translations/no.ts +++ b/packages/react-openapi/src/translations/no.ts @@ -18,9 +18,11 @@ export const no = { nullable: 'Kan være null', body: 'Brødtekst', payload: 'Nyttelast', - headers: 'Overskrifter', + headers: 'Headers', + header: 'Header', authorizations: 'Autorisasjoner', responses: 'Responser', + response: 'Respons', path_parameters: 'Sti-parametere', query_parameters: 'Forespørselsparametere', header_parameters: 'Header-parametere', @@ -30,8 +32,12 @@ export const no = { success: 'Suksess', redirect: 'Viderekobling', error: 'Feil', - show: 'Vis', - hide: 'Skjul', + show: 'Vis ${1}', + hide: 'Skjul ${1}', available_items: 'Tilgjengelige elementer', - child_attributes: 'Barneattributter', + available_scopes: 'Tilgjengelige scopes', + properties: 'Egenskaper', + or: 'eller', + and: 'og', + possible_values: 'Mulige verdier', }; diff --git a/packages/react-openapi/src/translations/pt-br.ts b/packages/react-openapi/src/translations/pt-br.ts index 7c1f86a7c0..2e9e7cb2d9 100644 --- a/packages/react-openapi/src/translations/pt-br.ts +++ b/packages/react-openapi/src/translations/pt-br.ts @@ -18,9 +18,11 @@ export const pt_br = { nullable: 'Nulo', body: 'Corpo', payload: 'Carga útil', - headers: 'Cabeçalhos', + headers: 'Headers', + header: 'Header', authorizations: 'Autorizações', responses: 'Respostas', + response: 'Resposta', path_parameters: 'Parâmetros de rota', query_parameters: 'Parâmetros de consulta', header_parameters: 'Parâmetros de cabeçalho', @@ -30,8 +32,12 @@ export const pt_br = { success: 'Sucesso', redirect: 'Redirecionamento', error: 'Erro', - show: 'Mostrar', - hide: 'Ocultar', + show: 'Mostrar ${1}', + hide: 'Ocultar ${1}', available_items: 'Itens disponíveis', - child_attributes: 'Atributos filhos', + available_scopes: 'Scopes disponíveis', + properties: 'Propriedades', + or: 'ou', + and: 'e', + possible_values: 'Valores possíveis', }; diff --git a/packages/react-openapi/src/translations/zh.ts b/packages/react-openapi/src/translations/zh.ts index 414043fd32..f0e81f21bc 100644 --- a/packages/react-openapi/src/translations/zh.ts +++ b/packages/react-openapi/src/translations/zh.ts @@ -18,9 +18,11 @@ export const zh = { nullable: '可为 null', body: '请求体', payload: '有效载荷', - headers: '头部信息', + headers: '头字段', + header: '头部', authorizations: '授权', responses: '响应', + response: '响应', path_parameters: '路径参数', query_parameters: '查询参数', header_parameters: '头参数', @@ -30,8 +32,12 @@ export const zh = { success: '成功', redirect: '重定向', error: '错误', - show: '显示', - hide: '隐藏', + show: '显示${1}', + hide: '隐藏${1}', available_items: '可用项', - child_attributes: '子属性', + available_scopes: '可用范围', + properties: '属性', + or: '或', + and: '和', + possible_values: '可能的值', };