mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-12 23:07:41 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5997a66638 | |||
| 65210b7d1d | |||
| 265ca4bd9d | |||
| 3e86382cdc | |||
| 19660edf91 | |||
| e3d22380fc | |||
| 9b5b8c85ec | |||
| 0c3a51eca4 | |||
| 7d1cd9f9df | |||
| 6029a31c07 | |||
| 4fb7e14436 | |||
| 31e88d16eb | |||
| 7ddd023d72 | |||
| b53e73c0e7 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix horizontal page scroll caused by card carousels bleeding past the viewport.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove duplicated declarations from the global stylesheet.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"@gitbook/icons": patch
|
||||
---
|
||||
|
||||
Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Flush insights events when the page is hidden, preload the search index at low priority and prioritize the dark page cover on dark-default sites.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the page outline not scrolling to keep the active section visible on long pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the search button accessible name, hoverable link previews, IME-safe Enter on Safari, cookie banner semantics, active outline item announcement and textarea shrinking.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Respect reduced motion for enter and exit animations, keep the header separator and input focus visible in forced colors mode, and size mobile surfaces with dynamic viewport units.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Send only the site origin as referrer to third parties, and only let the embedding page open the embed messaging channel.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Wait for server results before Enter opens a result in site search, and route search prewarm requests on statically served sites.
|
||||
@@ -87,3 +87,5 @@ The site must be [published publicly](https://gitbook.com/docs/published-documen
|
||||
## License
|
||||
|
||||
The renderer is distributed under the [GNU GPLv3 License](https://github.com/GitbookIO/gitbook/blob/main/LICENSE). If you distribute the code, you must keep the source code public. To use it in a private repository, you need a [commercial license](https://www.gitbook.com/pricing).
|
||||
|
||||
The [`@gitbook/embed`](https://github.com/GitbookIO/gitbook/blob/main/packages/embed) package is distributed under the [Apache License 2.0](https://github.com/GitbookIO/gitbook/blob/main/packages/embed/LICENSE). Contributions to it are made under the same license.
|
||||
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# A newer run for the same PR supersedes the previous one. Each push to main gets a group of its
|
||||
# own, so main runs are never queued behind or cancelled by another run.
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request_target' && format('preview-pr-{0}', github.event.pull_request.number) || format('preview-run-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
jobs:
|
||||
approval:
|
||||
# Ensure that external contributors' PRs are manually approved before deployment
|
||||
@@ -18,8 +23,9 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: approval
|
||||
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
environment:
|
||||
name: 2v-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
@@ -44,8 +50,9 @@ jobs:
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (preview)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: approval
|
||||
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
environment:
|
||||
name: 2c-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
@@ -82,7 +89,7 @@ jobs:
|
||||
comment-deployments:
|
||||
runs-on: ubuntu-latest
|
||||
name: Comment Deployments (preview)
|
||||
if: always() && github.event_name != 'push'
|
||||
if: ${{ !cancelled() && github.event_name != 'push' }}
|
||||
needs:
|
||||
- deploy-v2-vercel
|
||||
- deploy-v2-cloudflare
|
||||
@@ -95,6 +102,25 @@ jobs:
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: 'Summary of the deployments'
|
||||
|
||||
# A deploy that didn't succeed has no URL (e.g. a re-run hitting Vercel's "deploymentId
|
||||
# already exists"), so keep the URL the comment already shows instead of blanking it.
|
||||
- name: Resolve deployment URLs
|
||||
id: urls
|
||||
env:
|
||||
PREVIOUS_COMMENT: ${{ steps.fc.outputs.comment-body }}
|
||||
VERCEL_RESULT: ${{ needs.deploy-v2-vercel.result }}
|
||||
VERCEL_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
CLOUDFLARE_RESULT: ${{ needs.deploy-v2-cloudflare.result }}
|
||||
CLOUDFLARE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
run: |
|
||||
previous_url() {
|
||||
sed -n "s/^| $1 | \[\([^]]*\)\].*/\1/p" <<<"$PREVIOUS_COMMENT" | head -n 1
|
||||
}
|
||||
if [ "$VERCEL_RESULT" != success ]; then VERCEL_URL=$(previous_url Vercel); fi
|
||||
if [ "$CLOUDFLARE_RESULT" != success ]; then CLOUDFLARE_URL=$(previous_url Cloudflare); fi
|
||||
echo "vercel=$VERCEL_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "cloudflare=$CLOUDFLARE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update GitHub comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
@@ -105,21 +131,21 @@ jobs:
|
||||
|
||||
| Version | URL | Status |
|
||||
| --- | --- | --- |
|
||||
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||
| Vercel | [${{ steps.urls.outputs.vercel }}](${{ steps.urls.outputs.vercel }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||
| Cloudflare | [${{ steps.urls.outputs.cloudflare }}](${{ steps.urls.outputs.cloudflare }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||
|
||||
### Test content
|
||||
|
||||
| Site | `2v` | `2c` |
|
||||
| --- | --- | --- |
|
||||
| GitBook | [${{ 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-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) |
|
||||
| GitBook | [${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs](${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs](${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs) |
|
||||
| E2E | [${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||
edit-mode: replace
|
||||
visual-testing-v2-vercel:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -143,7 +169,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -167,7 +193,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -191,7 +217,7 @@ jobs:
|
||||
# Style recalc depends on the client bundle, not the host, so this runs here only and
|
||||
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
|
||||
- name: Run style invalidation tests
|
||||
if: always()
|
||||
if: ${{ !cancelled() }}
|
||||
working-directory: packages/gitbook
|
||||
run: bun run e2e-style-perf
|
||||
env:
|
||||
@@ -200,7 +226,7 @@ jobs:
|
||||
|
||||
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
|
||||
- name: Build style invalidation report
|
||||
if: always() && github.event_name != 'push'
|
||||
if: ${{ !cancelled() && github.event_name != 'push' }}
|
||||
id: style-perf
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
@@ -219,7 +245,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find style invalidation comment
|
||||
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
|
||||
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc-style-perf
|
||||
with:
|
||||
@@ -228,7 +254,7 @@ jobs:
|
||||
body-includes: 'Style invalidation on a large API reference'
|
||||
|
||||
- name: Create or update style invalidation comment
|
||||
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
|
||||
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
|
||||
@@ -239,7 +265,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -263,7 +289,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Browserless Testing v2 (Vercel)
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
with:
|
||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||
fetch-depth: 0
|
||||
# Otherwise git pushes with GITHUB_TOKEN instead of the PAT, and the release PR never triggers Preview
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -124,6 +124,8 @@ If you plan to distribute the code, you must make the source code public to comp
|
||||
|
||||
See `LICENSE` for more information.
|
||||
|
||||
The [`@gitbook/embed`](packages/embed) package is an exception: it is distributed under the [Apache License 2.0](packages/embed/LICENSE), so it can be used in proprietary applications.
|
||||
|
||||
## Badges
|
||||
|
||||
<p align="left">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.31.1",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.90.0",
|
||||
"oxlint": "^1.93.0",
|
||||
"turbo": "^2.11.10",
|
||||
"vercel": "50.37.3",
|
||||
},
|
||||
@@ -49,10 +49,9 @@
|
||||
},
|
||||
"packages/embed": {
|
||||
"name": "@gitbook/embed",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -110,7 +109,7 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.28.1",
|
||||
"version": "0.28.2",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -1363,17 +1362,17 @@
|
||||
|
||||
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.11.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-By4idRap5+nLnMuRzLB4qkOPRlvNdyY1ne4VJYDyUoUB11H94k7uif9mbe3JT8JSNXs1D68uMcBj6hJ/jMQRvA=="],
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.11.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-HMMeUIEAsmyIbSYn1ySvF/4gGMa0joiTMq4dQDuloIAnOpbtFT3JEiVsYnASdTWDXvplbRbZlqjU1EBeSR9G1A=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pyEYlVcuexOPa92p0Wlwl9YT1Is5D60lxN3ItWm3g30YLvFaUq86TF4BBWg5ZAc0uMTnrOzCvuCsOBTdzUWnRQ=="],
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+8iySNgqYWY6PZYUQzYu6zn0C04z2OEq1vQ6oMGsJ9sxN6wOpg1gzi1AJ5VKDHviWvMUjhqng51hTEQZOuiqYQ=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.11.10", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-1tYeR/IDZ5HEdsb0jNq2L2xam5nMQ+g80tlV2BZmv4OFw+pMcUM0mMmUcfc5wXPPNHVNPuDMjh9Y8G8HgXPLZA=="],
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.11.11", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-uImTKFYCFKsoFxjaXeJXlMPv4uU3OIIDjyzoIwYVqOtu9LnCZ/5/fzu/WP6+G9YfwGAPRplTHkKFfcI5t97RGw=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.10", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-HuekQxvVGhUd46g1Im/DKdOtglHr4/gguZAvzEpPUgtxNkztSa8lWhQEGQ+EmPQ0n7w19qjThFU+OfEEd0uvlw=="],
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.11", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-Y7xffSVfyyYRGzNy6PLdrFL4xXN6iTuR3W0SyJvH/yd3d4defA6dansZwJpPDVnIpD8a2Qn256DwY/Qin4Ip4g=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.11.10", "", { "os": "win32", "cpu": "x64" }, "sha512-UJOP3BkbreY3/JlMgJR6ZiimxYIBn+c+B4tnBIxD0E9c3Estmk+XoVNVBNBottZLNRXe3/ScgOU4+/k8tIWysg=="],
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.11.11", "", { "os": "win32", "cpu": "x64" }, "sha512-e2WqNtkPGo3QDwNNwwBo89Dx3PtSJ9Wp9QuRNzn80rrRnSHB+1mfBMZTpdHjA8/G/sG9rl76TdWdpgVsimjRdg=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-xOkEjF3ws/b/YNlc+bvJHMYBn+vL+QbEISZFVi0qS+ImmhA1X3/gmNmBrjjIG3dg6aeN+n6gQitzSkXQ286qJQ=="],
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-LURivfmgv34Osm8NR1gZGx4vdlux9KAgRM+FoooXNG0838ff0+akWZ76iPSke0NS2rsf7Z4Gw+YSaygzNTZuRA=="],
|
||||
|
||||
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
|
||||
|
||||
@@ -3131,7 +3130,7 @@
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turbo": ["turbo@2.11.10", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.10", "@turbo/darwin-arm64": "2.11.10", "@turbo/linux-64": "2.11.10", "@turbo/linux-arm64": "2.11.10", "@turbo/windows-64": "2.11.10", "@turbo/windows-arm64": "2.11.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-3GJYwig8ygEI89oaOZdduJX8/aw6O9eRPYHoIt6T3FZl9hkKjiyIspufJoVgqXihPoJu8M5CSV0DlZvzfohBCw=="],
|
||||
"turbo": ["turbo@2.11.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.11", "@turbo/darwin-arm64": "2.11.11", "@turbo/linux-64": "2.11.11", "@turbo/linux-arm64": "2.11.11", "@turbo/windows-64": "2.11.11", "@turbo/windows-arm64": "2.11.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AI4UJbUE/2cVZdoXJ4PODc8qvrjhQQX9Fa93eKGOI6Ims/w7+kUIb9T1jhlgSnZcf+gtUruci/ohSwqtNNC5Yw=="],
|
||||
|
||||
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"@changesets/cli": "^2.31.1",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.93.0",
|
||||
"turbo": "^2.11.10",
|
||||
"turbo": "^2.11.11",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 31e88d1: Relicense under Apache 2.0 and drop the dependency on `@gitbook/icons`.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -542,3 +542,7 @@ button: {
|
||||
icon: 'assistant'
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -42,7 +42,8 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Name of a Font Awesome icon (e.g. `bolt`), or a GitBook custom icon.
|
||||
*/
|
||||
// Kept as a plain string so the package doesn't depend on @gitbook/icons and its licence.
|
||||
type IconName = string;
|
||||
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing a tool.
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
z-index: 9998;
|
||||
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
|
||||
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- the `vh` declaration above is the fallback on host pages whose browser lacks `dvh` */
|
||||
height: calc(min(var(--gitbook-widget-window-height), calc(100dvh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||
background-color: var(--gitbook-widget-background-translucent);
|
||||
backdrop-filter: blur(48px);
|
||||
border: 1px solid var(--gitbook-widget-border-color);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# gitbook
|
||||
|
||||
## 0.28.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b53e73c: Link suggested follow-up questions in markdown AI answers to the `.md` ask route so agents get markdown back.
|
||||
- 4a40547: Fix the page outline not scrolling to keep the active section visible on long pages.
|
||||
- 7ddd023: Fix search prewarming on public sites.
|
||||
- Updated dependencies [31e88d1]
|
||||
- @gitbook/embed@0.7.0
|
||||
|
||||
## 0.28.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.28.1",
|
||||
"version": "0.28.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
|
||||
export async function POST() {
|
||||
const [context, siteURLData] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
getSiteURLDataFromMiddleware(),
|
||||
]);
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
|
||||
await apiClient.orgs.prewarmSiteSearch(siteURLData.organization, siteURLData.site);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -57,7 +57,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
<div
|
||||
role="dialog"
|
||||
data-testid="cookies-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={tString(language, 'cookies_title')}
|
||||
aria-describedby={describedById}
|
||||
className={tcls(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getCodeTextFromId } from './utils';
|
||||
import { useCopyToClipboard } from '@/components/hooks';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
@@ -15,41 +14,30 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
||||
const { codeId, style } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [copied]);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const onClick = () => {
|
||||
const codeText = getCodeTextFromId(codeId);
|
||||
if (codeText === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(codeText);
|
||||
|
||||
setCopied(true);
|
||||
copy(codeText);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
className={tcls(style, 'translate-y-0!', 'print:hidden')}
|
||||
>
|
||||
{t(language, copied ? 'code_copied' : 'code_copy')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
className={tcls(style, 'translate-y-0!', 'print:hidden')}
|
||||
>
|
||||
{t(language, copied ? 'code_copied' : 'code_copy')}
|
||||
</Button>
|
||||
{/* A label swap on the focused button isn't announced, the live region is. */}
|
||||
<span role="status" className="sr-only">
|
||||
{copied ? t(language, 'code_copied') : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
.contentkit-modal {
|
||||
@apply flex flex-col gap-6 relative z-50 bg-tint
|
||||
rounded shadow-lg opacity-0 transition-opacity duration-300 border border-tint
|
||||
w-full max-w-[768px] max-h-[90vh] overflow-y-auto;
|
||||
w-full max-w-[768px] max-h-[90dvh] overflow-y-auto;
|
||||
}
|
||||
.contentkit-modal.contentkit-modal-opened {
|
||||
@apply opacity-[1];
|
||||
|
||||
@@ -16,6 +16,8 @@ import { matchesText } from '../Table/searchMatch';
|
||||
import { MIN_RECORDS_FOR_SEARCH } from '../Table/shouldShowSearch';
|
||||
import { Input } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
// Imported here, behind the `next/dynamic` boundary, so only pages with OpenAPI blocks load it.
|
||||
import './style.css';
|
||||
|
||||
// Only what survives the RSC boundary. Rebuilding the renderers on the client is what lets this
|
||||
// whole subtree sit behind a `next/dynamic` chunk instead of the route's eager entry.
|
||||
|
||||
@@ -17,7 +17,6 @@ import { PlainCodeBlock } from '../CodeBlock';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { Heading } from '../Heading';
|
||||
import type { OpenAPIBlockClientContextProps } from './OpenAPIBlockClient';
|
||||
import './style.css';
|
||||
import { SCALAR_RUNTIME_PATH } from './scalar-runtime-path';
|
||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Global, not in style.css: badges also show in the sidebar and outline of pages without OpenAPI blocks. */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-small {}
|
||||
|
||||
.openapi-method-medium {
|
||||
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
|
||||
}
|
||||
|
||||
.toclink .openapi-method {
|
||||
@apply text-[0.625rem] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
.openapi-statuscode-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
|
||||
.openapi-method-post,
|
||||
.openapi-statuscode-redirection {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||
}
|
||||
|
||||
.openapi-method-put,
|
||||
.openapi-statuscode-informational {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.openapi-method-patch {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.openapi-method-delete,
|
||||
.openapi-statuscode-error {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
@@ -143,53 +143,6 @@ button.openapi-mcp {
|
||||
@apply !mb-0;
|
||||
}
|
||||
|
||||
/* Method Tags */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-small {}
|
||||
|
||||
.openapi-method-medium {
|
||||
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
|
||||
}
|
||||
|
||||
.toclink .openapi-method {
|
||||
@apply text-[0.625rem] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
.openapi-statuscode-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
|
||||
.openapi-method-post,
|
||||
.openapi-statuscode-redirection {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||
}
|
||||
|
||||
.openapi-method-put,
|
||||
.openapi-statuscode-informational {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.openapi-method-patch {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.openapi-method-delete,
|
||||
.openapi-statuscode-error {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
|
||||
/* URL */
|
||||
.openapi-url {
|
||||
@apply font-mono text-sm text-tint;
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction';
|
||||
import { useCopyToClipboard } from '@/components/hooks';
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -27,25 +28,10 @@ export function PromptActions(props: { prompt: string; openInAIProviders: boolea
|
||||
const { prompt, openInAIProviders } = props;
|
||||
const language = useLanguage();
|
||||
const selectedAction = usePromptAction();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, COPIED_MESSAGE_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [copied]);
|
||||
const { copied, copy } = useCopyToClipboard(COPIED_MESSAGE_DURATION);
|
||||
|
||||
const copyPrompt = () => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
copy(prompt);
|
||||
};
|
||||
|
||||
// The pick follows the visitor from site to site, so fall back to copying wherever the agent
|
||||
|
||||
@@ -43,13 +43,14 @@ describe('ViewCards', () => {
|
||||
it('renders the carousel with a symmetric peek and conditional edge masks', () => {
|
||||
const markup = renderWithContext(<ViewCards {...makeProps(false)} />);
|
||||
|
||||
expect(markup).toContain('-mx-12');
|
||||
expect(markup).toContain('px-12');
|
||||
expect(markup).toContain('scroll-px-12');
|
||||
// Gutter-sized peek, the full 3rem from `2xl`.
|
||||
expect(markup).toContain('-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12');
|
||||
expect(markup).toContain('scroll-px-4');
|
||||
expect(markup).toContain('2xl:scroll-px-12');
|
||||
expect(markup).toContain('left-0');
|
||||
expect(markup).toContain('ml-8');
|
||||
expect(markup).toContain('2xl:ml-8');
|
||||
expect(markup).toContain('right-0');
|
||||
expect(markup).toContain('mr-8');
|
||||
expect(markup).toContain('2xl:mr-8');
|
||||
expect(markup).not.toContain('before:bg-linear-to-r');
|
||||
expect(markup).not.toContain('after:bg-linear-to-l');
|
||||
expect(markup).toContain('snap-mandatory');
|
||||
|
||||
@@ -49,6 +49,16 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
);
|
||||
}
|
||||
|
||||
// The peek fits the page gutter (`CONTAINER_STYLE`) so the fade stays on screen; from `2xl` the
|
||||
// cards fill the row exactly and need the full 3rem to show the next one.
|
||||
const PEEK = {
|
||||
margin: '-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12',
|
||||
padding:
|
||||
'px-4 scroll-px-4 sm:px-6 sm:scroll-px-6 md:px-8 md:scroll-px-8 2xl:px-12 2xl:scroll-px-12',
|
||||
leadingButton: 'ml-0 sm:ml-2 md:ml-4 2xl:ml-8',
|
||||
trailingButton: 'mr-0 sm:mr-2 md:mr-4 2xl:mr-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* The carousel layout: cards lay out in a single horizontally-scrolling row that
|
||||
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
|
||||
@@ -70,7 +80,7 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls('-mx-12', 'hover:z-11')}
|
||||
className={tcls(PEEK.margin, 'hover:z-11')}
|
||||
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
|
||||
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
|
||||
contentClassName={tcls(
|
||||
@@ -79,19 +89,18 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
'-mt-px',
|
||||
'pb-6',
|
||||
'-mb-6',
|
||||
'px-12',
|
||||
'scroll-px-12',
|
||||
PEEK.padding,
|
||||
'snap-x',
|
||||
'snap-mandatory'
|
||||
)}
|
||||
scrollByVisibleItems
|
||||
leading={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'ml-8' },
|
||||
button: { size: 'small', className: PEEK.leadingButton },
|
||||
}}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'mr-8' },
|
||||
button: { size: 'small', className: PEEK.trailingButton },
|
||||
}}
|
||||
>
|
||||
{records.map((record) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getNextTabIndex } from './DynamicTabs';
|
||||
|
||||
describe('getNextTabIndex', () => {
|
||||
it('moves with the arrow keys and wraps around', () => {
|
||||
expect(getNextTabIndex('ArrowRight', 0, 3)).toBe(1);
|
||||
expect(getNextTabIndex('ArrowRight', 2, 3)).toBe(0);
|
||||
expect(getNextTabIndex('ArrowLeft', 0, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('jumps to the first and last tab', () => {
|
||||
expect(getNextTabIndex('Home', 2, 3)).toBe(0);
|
||||
expect(getNextTabIndex('End', 0, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('mirrors the arrows in right-to-left', () => {
|
||||
expect(getNextTabIndex('ArrowLeft', 0, 3, true)).toBe(1);
|
||||
expect(getNextTabIndex('ArrowRight', 0, 3, true)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores other keys and an unknown tab', () => {
|
||||
expect(getNextTabIndex('Enter', 0, 3)).toBeNull();
|
||||
expect(getNextTabIndex('ArrowRight', -1, 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -151,10 +151,15 @@ const TabItemList = memo(function TabItemList(props: {
|
||||
}).filter((x) => x !== undefined),
|
||||
[overflowing, tabs]
|
||||
);
|
||||
// Roving tabindex: one tab stop for the strip, the active tab when it isn't in the overflow menu.
|
||||
const visibleTabs = tabs.filter((tab) => !overflowing.has(getTabButtonId(tab.id)));
|
||||
const focusableTabId =
|
||||
visibleTabs.find((tab) => tab.id === activeTabId)?.id ?? visibleTabs[0]?.id ?? null;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="tablist"
|
||||
onKeyDown={moveFocusBetweenTabs}
|
||||
className={tcls(
|
||||
'group/tabs',
|
||||
'overflow-hidden',
|
||||
@@ -182,6 +187,7 @@ const TabItemList = memo(function TabItemList(props: {
|
||||
key={tab.id}
|
||||
ref={itemRef}
|
||||
isActive={tab.id === activeTabId}
|
||||
isFocusable={tab.id === focusableTabId}
|
||||
isMeasuring={isMeasuring}
|
||||
tab={tab}
|
||||
onSelect={onSelect}
|
||||
@@ -241,16 +247,18 @@ function TabsDropdownMenu(props: {
|
||||
const TabItem = memo(function TabItem(props: {
|
||||
ref: React.Ref<HTMLButtonElement>;
|
||||
isActive: boolean;
|
||||
isFocusable: boolean;
|
||||
isMeasuring: boolean;
|
||||
tab: TabsItem;
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
const { ref, tab, isActive, isMeasuring, onSelect } = props;
|
||||
const { ref, tab, isActive, isFocusable, isMeasuring, onSelect } = props;
|
||||
return (
|
||||
<TabButton
|
||||
ref={ref}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={isFocusable ? 0 : -1}
|
||||
aria-controls={tab.id}
|
||||
id={getTabButtonId(tab.id)}
|
||||
title={tab.title}
|
||||
@@ -343,6 +351,55 @@ function TabButton(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow keys, Home and End move focus between the tabs of a tablist; Enter or Space then selects.
|
||||
*/
|
||||
function moveFocusBetweenTabs(event: React.KeyboardEvent<HTMLElement>) {
|
||||
const current = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (current?.getAttribute('role') !== 'tab') {
|
||||
return;
|
||||
}
|
||||
const tabs = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])')
|
||||
);
|
||||
const index = tabs.indexOf(current);
|
||||
const isRTL = getComputedStyle(event.currentTarget).direction === 'rtl';
|
||||
const next = getNextTabIndex(event.key, index, tabs.length, isRTL);
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
tabs[next]?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the tab to focus for a key press, or null when the key doesn't move focus.
|
||||
*/
|
||||
export function getNextTabIndex(
|
||||
key: string,
|
||||
index: number,
|
||||
count: number,
|
||||
isRTL = false
|
||||
): number | null {
|
||||
if (index < 0 || count === 0) {
|
||||
return null;
|
||||
}
|
||||
const forward = isRTL ? 'ArrowLeft' : 'ArrowRight';
|
||||
const backward = isRTL ? 'ArrowRight' : 'ArrowLeft';
|
||||
switch (key) {
|
||||
case forward:
|
||||
return (index + 1) % count;
|
||||
case backward:
|
||||
return (index - 1 + count) % count;
|
||||
case 'Home':
|
||||
return 0;
|
||||
case 'End':
|
||||
return count - 1;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for a tab button.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
|
||||
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
@@ -107,7 +108,8 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
|
||||
const { settings } = message;
|
||||
embeddableConfiguration.setState(settings);
|
||||
integrationsAssistantTools.setState({
|
||||
tools: settings.tools,
|
||||
// The embed types icons as plain strings to avoid depending on @gitbook/icons.
|
||||
tools: settings.tools as GitBookIntegrationTool[],
|
||||
});
|
||||
|
||||
const defaultPage =
|
||||
|
||||
@@ -47,8 +47,8 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
askQuery,
|
||||
close,
|
||||
cursor,
|
||||
phase,
|
||||
enterWaiting,
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
query,
|
||||
@@ -65,16 +65,14 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
{ restoreLastQueryOnMount: true }
|
||||
);
|
||||
|
||||
const fetching = phase === 'loading';
|
||||
|
||||
return (
|
||||
<LinkContext value={linkContext}>
|
||||
<SearchFrame
|
||||
asEmbeddable={hasDocsTab}
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
phase={phase}
|
||||
enterWaiting={enterWaiting}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
fillHeight={true}
|
||||
query={query}
|
||||
results={results}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { isForeignHandshake } from './channel';
|
||||
|
||||
describe('isForeignHandshake', () => {
|
||||
const parent = {} as MessageEventSource;
|
||||
const other = {} as MessageEventSource;
|
||||
|
||||
it('accepts a handshake from the expected window', () => {
|
||||
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: parent }, parent)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a handshake from another window', () => {
|
||||
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: other }, parent)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores messages that are not handshakes', () => {
|
||||
expect(isForeignHandshake({ data: { type: 'other' }, source: other }, parent)).toBe(false);
|
||||
expect(isForeignHandshake({ data: null, source: other }, parent)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,33 @@ export function log(...data: any[]) {
|
||||
console.log(...data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a message is a bidc handshake sent by a window other than the expected one.
|
||||
*/
|
||||
export function isForeignHandshake(
|
||||
event: Pick<MessageEvent, 'data' | 'source'>,
|
||||
expectedSource: MessageEventSource
|
||||
): boolean {
|
||||
return event.data?.type === 'bidc-connect' && event.source !== expectedSource;
|
||||
}
|
||||
|
||||
export const getChannel = memoize(() => {
|
||||
if (typeof window === 'undefined' || window.parent === window) {
|
||||
return null;
|
||||
}
|
||||
log('[gitbook] create channel with parent window');
|
||||
|
||||
// bidc accepts a handshake from any window; registered first and in capture so it runs before
|
||||
// bidc's own listener and only the embedding page can open the channel.
|
||||
window.addEventListener(
|
||||
'message',
|
||||
(event) => {
|
||||
if (isForeignHandshake(event, window.parent)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
return createChannel();
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ export async function Header(props: {
|
||||
'flex-none',
|
||||
'shadow-[0px_1px_0px]',
|
||||
'shadow-tint-12/2',
|
||||
// Box shadows are dropped in forced colors mode, the separator needs a real border.
|
||||
'forced-colors:border-b',
|
||||
|
||||
'bg-tint-base/9',
|
||||
'theme-muted:bg-tint-subtle/9',
|
||||
|
||||
@@ -9,7 +9,7 @@ interface HeaderLinksProps {
|
||||
|
||||
export async function HeaderLinks({ children, style }: HeaderLinksProps) {
|
||||
return (
|
||||
<div
|
||||
<nav
|
||||
className={tcls(
|
||||
'header-links',
|
||||
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
|
||||
@@ -17,6 +17,6 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,13 +193,18 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* When the page is unloaded, flush all events.
|
||||
*/
|
||||
// `beforeunload` doesn't fire reliably on mobile, hiding the page is the last dependable signal.
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushEventsSync);
|
||||
const flushWhenHidden = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushEventsSync();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', flushWhenHidden);
|
||||
window.addEventListener('pagehide', flushEventsSync);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushEventsSync);
|
||||
document.removeEventListener('visibilitychange', flushWhenHidden);
|
||||
window.removeEventListener('pagehide', flushEventsSync);
|
||||
};
|
||||
}, [flushEventsSync]);
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
aria-current={activeId === section.id ? 'true' : undefined}
|
||||
data-cover-aware-text
|
||||
className={tcls(
|
||||
'relative',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PageCover } from './PageCover';
|
||||
import { PageFooterNavigation } from './PageFooterNavigation';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { PreservePageLayout } from './PreservePageLayout';
|
||||
import { MAIN_CONTENT_ID } from '@/components/layout';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
@@ -103,6 +104,7 @@ export async function PageBody(props: {
|
||||
/>
|
||||
) : null}
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
// Comments on the whole page target anywhere in it that isn't a block.
|
||||
{...(withCommentAnchors ? { 'data-comment-page': '' } : {})}
|
||||
className={tcls(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { StaticImageData } from 'next/image';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import type { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationDefaultThemeMode,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageDocumentCover,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { CONTENT_STYLE } from '../layout';
|
||||
import { getCoverHeight } from './coverHeight';
|
||||
@@ -144,6 +148,11 @@ export async function PageCover(props: {
|
||||
y={cover.yPos}
|
||||
height={height}
|
||||
mask={mask}
|
||||
priorityTheme={
|
||||
context.customization.themes.default === CustomizationDefaultThemeMode.Dark
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -25,10 +25,14 @@ interface PageCoverImageProps {
|
||||
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
|
||||
height: number | undefined;
|
||||
mask?: 'none' | 'radial';
|
||||
/** Theme most visitors see first, its cover is the LCP candidate. */
|
||||
priorityTheme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export function PageCoverImage(props: PageCoverImageProps) {
|
||||
const { imgs, y, height, mask } = props;
|
||||
const { imgs, y, height, mask, priorityTheme = 'light' } = props;
|
||||
// The light cover is never demoted to low: a visitor can still have switched to the light theme.
|
||||
const darkFirst = priorityTheme === 'dark' && Boolean(imgs.dark);
|
||||
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
|
||||
height,
|
||||
aspectRatio: PAGE_COVER_SIZE,
|
||||
@@ -48,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
src={imgs.light.src}
|
||||
srcSet={imgs.light.srcSet}
|
||||
sizes={imgs.light.sizes}
|
||||
fetchPriority="high"
|
||||
fetchPriority={darkFirst ? 'auto' : 'high'}
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
style={{
|
||||
@@ -69,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
src={imgs.dark.src}
|
||||
srcSet={imgs.dark.srcSet}
|
||||
sizes={imgs.dark.sizes}
|
||||
fetchPriority="low"
|
||||
fetchPriority={darkFirst ? 'high' : 'low'}
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
style={{
|
||||
|
||||
@@ -56,7 +56,6 @@ function preloadFont(fontData: FontData) {
|
||||
ReactDOM.preload(url, {
|
||||
as: 'font',
|
||||
crossOrigin: 'anonymous',
|
||||
fetchPriority: 'high',
|
||||
type: format ? `font/${format}` : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@config '../../../tailwind.config.ts';
|
||||
|
||||
@import "./prose.css";
|
||||
@import "../DocumentView/OpenAPI/method-badge.css";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
@@ -33,7 +34,6 @@
|
||||
@utility hide-scrollbar {
|
||||
/* Hide scrollbar by default */
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
/* Show scrollbar on hover */
|
||||
@@ -52,21 +52,14 @@
|
||||
rgba(0, 0, 0, 1) 96px,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 96px,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@utility linear-mask-util {
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
}
|
||||
|
||||
@utility grid-area-1-1 {
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
@utility gutter-stable {
|
||||
@@ -102,41 +95,6 @@
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
|
||||
translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
|
||||
translate(0, -50%);
|
||||
@@ -152,21 +110,14 @@
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-gradient-primary {
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
}
|
||||
|
||||
@utility bg-gradient-tint {
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
}
|
||||
|
||||
@utility site-background {
|
||||
@@ -186,24 +137,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@apply leading-relaxed;
|
||||
@@ -680,3 +613,275 @@ html:has(.zoom-modal) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Enter and exit animations, referenced by the `animation` theme in tailwind.config.ts. */
|
||||
@keyframes present {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem) scale(90%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: rotateX(-10deg) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes scaleOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: rotateX(-10deg) scale(0.95);
|
||||
}
|
||||
}
|
||||
@keyframes blurIn {
|
||||
0% {
|
||||
filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes blurOut {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
@keyframes blurInDisplay {
|
||||
0% {
|
||||
filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
display: none;
|
||||
}
|
||||
100% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes blurOutDisplay {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@keyframes enterFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@keyframes enterFromRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(50%);
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@keyframes exitToLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@keyframes exitToRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(50%);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Reduced motion keeps the fades and drops the movement. Redefining the keyframes rather than
|
||||
* disabling the animations keeps their duration, so code waiting on `animationend` is unaffected.
|
||||
* Each variant keeps the identity transform and filter of the full animation's end state: with
|
||||
* `fill-mode: both` they persist, and dropping them would change stacking and text rasterization.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes present {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes scaleOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes blurIn {
|
||||
from {
|
||||
filter: blur(0px);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes blurOut {
|
||||
from {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
filter: blur(0px);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes blurInDisplay {
|
||||
from {
|
||||
filter: blur(0px);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes blurOutDisplay {
|
||||
from {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
filter: blur(0px);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@keyframes enterFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@keyframes enterFromRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
display: none;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@keyframes exitToLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@keyframes exitToRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
display: flex;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A loader must keep showing activity, so it slows down instead of stopping. */
|
||||
.animate-spin {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { Emoji } from '../primitives/Emoji/Emoji';
|
||||
import { Link } from '../primitives/Link';
|
||||
import type { LocalPageResult } from './useLocalSearchResults';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Renders local search results above the main server results.
|
||||
*
|
||||
* Layout:
|
||||
* - While server results are still fetching → 2-column wrapping row (cards fill width)
|
||||
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
|
||||
*
|
||||
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
|
||||
*/
|
||||
export function LocalSearchResults({
|
||||
results,
|
||||
fetching,
|
||||
}: {
|
||||
results: LocalPageResult[];
|
||||
fetching: boolean;
|
||||
}) {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'py-2',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-2',
|
||||
fetching
|
||||
? 'flex-wrap'
|
||||
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
|
||||
)}
|
||||
>
|
||||
{results.map((result) => (
|
||||
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalSearchResultCard({
|
||||
result,
|
||||
fetching,
|
||||
}: {
|
||||
result: LocalPageResult;
|
||||
fetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={result.pathname}
|
||||
className={tcls(
|
||||
'group',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-1',
|
||||
'p-3',
|
||||
'rounded-corners:rounded-lg',
|
||||
'circular-corners:rounded-2xl',
|
||||
'text-tint',
|
||||
'bg-tint-subtle',
|
||||
'hover:bg-tint',
|
||||
'hover:text-tint-strong',
|
||||
'transition-colors',
|
||||
'cursor-pointer',
|
||||
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
|
||||
{result.emoji ? (
|
||||
<span className="size-4 shrink-0 text-tint-subtle">
|
||||
<Emoji code={result.emoji} style="text-base leading-none" />
|
||||
</span>
|
||||
) : result.icon ? (
|
||||
<span className="size-4 shrink-0 text-tint-subtle">
|
||||
<Icon icon={result.icon as IconName} className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
|
||||
{result.title}
|
||||
</p>
|
||||
<span className="ml-auto shrink-0 text-tint-subtle">
|
||||
<Icon icon="chevron-right" className="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
{result.description ? (
|
||||
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||
import { SearchScopeControl } from './SearchScopeControl';
|
||||
import { useSearchController } from './useSearchController';
|
||||
import { useSearchPopupFocusTrap } from './useSearchPopupFocusTrap';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
|
||||
@@ -50,8 +50,8 @@ export function SearchContainer({
|
||||
askInAssistant,
|
||||
close,
|
||||
cursor,
|
||||
phase,
|
||||
enterWaiting,
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
open,
|
||||
@@ -67,7 +67,6 @@ export function SearchContainer({
|
||||
withSearchAI,
|
||||
scopeControl,
|
||||
} = useSearchController(searchProps);
|
||||
const fetching = phase === 'loading';
|
||||
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
|
||||
|
||||
useHotkeys(
|
||||
@@ -169,8 +168,8 @@ export function SearchContainer({
|
||||
<SearchFrame
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
phase={phase}
|
||||
enterWaiting={enterWaiting}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
input={usesSideSheet ? frameInput : undefined}
|
||||
query={query}
|
||||
results={results}
|
||||
@@ -204,6 +203,7 @@ export function SearchContainer({
|
||||
active={isSearchOpen}
|
||||
onClick={open}
|
||||
className={className}
|
||||
aria-label={tString(language, withSearchAI ? 'search_or_ask' : 'search')}
|
||||
aria-expanded={isSearchOpen}
|
||||
aria-controls={resultsId}
|
||||
/>
|
||||
|
||||
@@ -16,15 +16,14 @@ import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { SearchAskBar } from './SearchAskBar';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import type { ResultType, SearchPhase } from './useSearchResults';
|
||||
import type { ResultType } from './useSearchResults';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
export function SearchFrame(props: {
|
||||
asEmbeddable?: boolean;
|
||||
askQuery: string;
|
||||
cursor: number | null;
|
||||
phase: SearchPhase;
|
||||
/** Enter was pressed and will act once results have loaded. */
|
||||
enterWaiting: boolean;
|
||||
error: boolean;
|
||||
fetching: boolean;
|
||||
fillHeight?: boolean;
|
||||
input?: React.ReactNode;
|
||||
query: string;
|
||||
@@ -44,8 +43,8 @@ export function SearchFrame(props: {
|
||||
className,
|
||||
cursor,
|
||||
dataTestId,
|
||||
phase,
|
||||
enterWaiting,
|
||||
error,
|
||||
fetching,
|
||||
fillHeight = false,
|
||||
input,
|
||||
query,
|
||||
@@ -57,7 +56,6 @@ export function SearchFrame(props: {
|
||||
showAsk,
|
||||
sidebar,
|
||||
} = props;
|
||||
const fetching = phase === 'loading';
|
||||
|
||||
const searchAsk = useSearchAskState();
|
||||
const { assistants } = useAI();
|
||||
@@ -79,7 +77,7 @@ export function SearchFrame(props: {
|
||||
fetching ? 'block animate-fade-in' : 'hidden animate-fade-out-slow'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: fetching && !enterWaiting ? '2s' : undefined,
|
||||
animationDelay: fetching ? '2s' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -113,9 +111,10 @@ export function SearchFrame(props: {
|
||||
ref={resultsRef}
|
||||
query={query}
|
||||
id={resultsId}
|
||||
phase={phase}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
onResultSelect={onResultSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -133,7 +133,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
}
|
||||
}
|
||||
trailing={
|
||||
!showAsk && value && !fetching && resultsCount > 0 ? (
|
||||
!showAsk && value && resultsCount > 0 ? (
|
||||
<div className="animate-blur-in mr-2 text-sm text-tint-subtle">
|
||||
{t(language, 'search_results_count', resultsCount.toString())}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
|
||||
import { SearchRecordResultItem } from './SearchRecordResultItem';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import type { ResultType, SearchPhase } from './useSearchResults';
|
||||
import type { ResultType } from './useSearchResults';
|
||||
import { useAI } from '@/components/AI';
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
@@ -47,15 +47,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
id: string;
|
||||
query: string;
|
||||
results: ResultType[];
|
||||
phase: SearchPhase;
|
||||
fetching: boolean;
|
||||
cursor: number | null;
|
||||
error: boolean;
|
||||
onResultSelect?: () => void;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, id, query, results, phase, cursor, onResultSelect } = props;
|
||||
const fetching = phase === 'loading';
|
||||
const error = phase === 'failed';
|
||||
const { children, id, query, results, fetching, cursor, error, onResultSelect } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const questionWords = new Set([
|
||||
'who',
|
||||
'what',
|
||||
'where',
|
||||
'when',
|
||||
'why',
|
||||
'how',
|
||||
'explain',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'which',
|
||||
'whom',
|
||||
'whose',
|
||||
'can',
|
||||
'have',
|
||||
'give',
|
||||
'tell',
|
||||
'show',
|
||||
'find',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return true if an input query looks like a question.
|
||||
*/
|
||||
export function isQuestion(query: string): boolean {
|
||||
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const words = query.toLowerCase().trim().split(/\s+/);
|
||||
|
||||
if (words.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const word of words) {
|
||||
if (questionWords.has(word)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
} from './last-query';
|
||||
import { addRecentSearchQuery } from './recent-queries';
|
||||
import { findSearchSection, type SearchBaseProps } from './search-props';
|
||||
import type { SearchResultsRef } from './SearchResults';
|
||||
import { useSearchState, useSetSearchState } from './useSearch';
|
||||
import { useSearchKeyboardNavigation } from './useSearchKeyboardNavigation';
|
||||
import { useSearchResults } from './useSearchResults';
|
||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||
import { isComposingKeyEvent } from '@/lib/keyboard';
|
||||
import { getLocalizedTitle } from '@/lib/sites';
|
||||
|
||||
function useInitialAskBootstrap(props: {
|
||||
@@ -71,6 +73,51 @@ function useFilteredSiteSpaceIds(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function useSearchKeyboardNavigation(props: {
|
||||
query: string;
|
||||
results: ReturnType<typeof useSearchResults>['results'];
|
||||
resultsRef: React.RefObject<SearchResultsRef | null>;
|
||||
abort: () => void;
|
||||
askCount: number;
|
||||
onAskSelect: (index: number) => void;
|
||||
}) {
|
||||
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
|
||||
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
|
||||
query,
|
||||
resultCount: results.length,
|
||||
totalCount: results.length + askCount,
|
||||
});
|
||||
|
||||
const onInputKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveCursorBy(-1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveCursorBy(1);
|
||||
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
|
||||
event.preventDefault();
|
||||
if (cursor !== null && cursor >= results.length) {
|
||||
onAskSelect(cursor - results.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultsRef.current?.select()) {
|
||||
// Stop any in-flight search request only when activating a result.
|
||||
abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
onInputKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearchController(
|
||||
props: SearchBaseProps,
|
||||
options: { restoreLastQueryOnMount?: boolean } = {}
|
||||
@@ -93,6 +140,7 @@ export function useSearchController(
|
||||
const setSearchState = useSetSearchState();
|
||||
const router = useRouter();
|
||||
const trackEvent = useTrackEvent();
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
const isLoaded = useBodyLoaded();
|
||||
const integrationsLoaded = useIntegrationsLoaded();
|
||||
|
||||
@@ -201,7 +249,7 @@ export function useSearchController(
|
||||
});
|
||||
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
|
||||
|
||||
const { results, phase, abort } = useSearchResults({
|
||||
const { results, fetching, error, abort } = useSearchResults({
|
||||
asEmbeddable,
|
||||
disabled: !(normalizedQuery || withAI),
|
||||
open: Boolean(state?.open),
|
||||
@@ -255,11 +303,11 @@ export function useSearchController(
|
||||
|
||||
const askCount = normalizedQuery && !showAsk ? assistants.length : 0;
|
||||
|
||||
const { cursor, onInputKeyDown, enterWaiting, resultsRef } = useSearchKeyboardNavigation({
|
||||
searchKey: [searchValue, state?.scope, state?.section].join('\n'),
|
||||
open: Boolean(state?.open),
|
||||
phase,
|
||||
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
|
||||
query: normalizedQuery,
|
||||
results,
|
||||
resultsRef,
|
||||
abort,
|
||||
askCount,
|
||||
onAskSelect: askInAssistant,
|
||||
});
|
||||
@@ -270,8 +318,8 @@ export function useSearchController(
|
||||
askCount,
|
||||
askInAssistant,
|
||||
cursor,
|
||||
phase,
|
||||
enterWaiting,
|
||||
error,
|
||||
fetching,
|
||||
abort,
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { SearchResultsRef } from './SearchResults';
|
||||
import type { ResultType, SearchPhase } from './useSearchResults';
|
||||
|
||||
/** The row highlighted when none was picked with the arrow keys. */
|
||||
function getDefaultCursor(phase: SearchPhase, rowCount: number): number | null {
|
||||
// Results listed while loading are provisional, so none of them is the default.
|
||||
return phase === 'loading' || rowCount === 0 ? null : 0;
|
||||
}
|
||||
|
||||
type EnterAction =
|
||||
| { type: 'wait' }
|
||||
| { type: 'open' }
|
||||
| { type: 'ask'; assistantIndex: number }
|
||||
| { type: 'none' };
|
||||
|
||||
/** What Enter does, given the highlighted row. */
|
||||
function getEnterAction(input: {
|
||||
phase: SearchPhase;
|
||||
cursor: number | null;
|
||||
resultCount: number;
|
||||
listMounted: boolean;
|
||||
}): EnterAction {
|
||||
const { phase, cursor, resultCount, listMounted } = input;
|
||||
|
||||
if (cursor === null) {
|
||||
return phase === 'loading' ? { type: 'wait' } : { type: 'none' };
|
||||
}
|
||||
if (cursor >= resultCount) {
|
||||
return { type: 'ask', assistantIndex: cursor - resultCount };
|
||||
}
|
||||
// The results list is loaded on demand and can mount after the results arrive.
|
||||
return listMounted ? { type: 'open' } : { type: 'wait' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard navigation of the search rows (results, then one "ask" row per assistant):
|
||||
* which row is highlighted and what Enter does.
|
||||
*/
|
||||
export function useSearchKeyboardNavigation(props: {
|
||||
/** Identifies the search; changes with the input value, the scope and the section. */
|
||||
searchKey: string;
|
||||
open: boolean;
|
||||
phase: SearchPhase;
|
||||
results: ResultType[];
|
||||
askCount: number;
|
||||
onAskSelect: (assistantIndex: number) => void;
|
||||
}) {
|
||||
const { searchKey, open, phase, results, askCount, onAskSelect } = props;
|
||||
const rowCount = results.length + askCount;
|
||||
|
||||
const resultsListRef = React.useRef<SearchResultsRef | null>(null);
|
||||
const [listMounted, setListMounted] = React.useState(false);
|
||||
const setResultsList = React.useCallback((list: SearchResultsRef | null) => {
|
||||
resultsListRef.current = list;
|
||||
setListMounted(list !== null);
|
||||
}, []);
|
||||
|
||||
// A pick only holds for the list it was made on.
|
||||
const [picked, setPicked] = React.useState<{ results: ResultType[]; index: number } | null>(
|
||||
null
|
||||
);
|
||||
let cursor = getDefaultCursor(phase, rowCount);
|
||||
if (picked?.results === results && rowCount > 0) {
|
||||
cursor = Math.min(picked.index, rowCount - 1);
|
||||
}
|
||||
|
||||
const moveCursorBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
if (rowCount === 0) {
|
||||
return;
|
||||
}
|
||||
const index = cursor === null ? 0 : Math.max(Math.min(cursor + delta, rowCount - 1), 0);
|
||||
setPicked({ results, index });
|
||||
},
|
||||
[cursor, results, rowCount]
|
||||
);
|
||||
|
||||
// The search Enter was pressed on while it could not act yet.
|
||||
const [waitingFor, setWaitingFor] = React.useState<string | null>(null);
|
||||
|
||||
const pressEnter = React.useCallback(() => {
|
||||
const action = getEnterAction({
|
||||
phase,
|
||||
cursor,
|
||||
resultCount: results.length,
|
||||
listMounted,
|
||||
});
|
||||
setWaitingFor(action.type === 'wait' ? searchKey : null);
|
||||
|
||||
if (action.type === 'open') {
|
||||
resultsListRef.current?.select();
|
||||
} else if (action.type === 'ask') {
|
||||
onAskSelect(action.assistantIndex);
|
||||
}
|
||||
}, [phase, cursor, results.length, listMounted, searchKey, onAskSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (waitingFor === null) {
|
||||
return;
|
||||
}
|
||||
// Dropped when the search changed, the box closed, or the visitor opened a result
|
||||
// or asked an assistant before the server answered.
|
||||
if (waitingFor !== searchKey || !open || phase === 'stopped') {
|
||||
setWaitingFor(null);
|
||||
return;
|
||||
}
|
||||
pressEnter();
|
||||
}, [waitingFor, searchKey, open, phase, pressEnter]);
|
||||
|
||||
const onInputKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setWaitingFor(null);
|
||||
moveCursorBy(event.key === 'ArrowUp' ? -1 : 1);
|
||||
} else if (event.key === 'Escape') {
|
||||
setWaitingFor(null);
|
||||
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
pressEnter();
|
||||
}
|
||||
},
|
||||
[moveCursorBy, pressEnter]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
onInputKeyDown,
|
||||
enterWaiting: waitingFor !== null,
|
||||
resultsRef: setResultsList,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import {
|
||||
@@ -25,35 +26,6 @@ export type ResultType =
|
||||
|
||||
export type { LocalPageResult, MergedPageResult };
|
||||
|
||||
/**
|
||||
* Where a search stands. Results listed while `loading` are instant title matches only,
|
||||
* so they are provisional; in every other phase the list is final.
|
||||
*/
|
||||
export type SearchPhase = 'idle' | 'loading' | 'loaded' | 'failed' | 'stopped';
|
||||
|
||||
type RemoteSearch =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading'; query: string }
|
||||
| { status: 'loaded'; query: string; results: OrderedComputedResult[] }
|
||||
| { status: 'failed'; query: string }
|
||||
// The visitor opened a result or asked an assistant before the server answered.
|
||||
| { status: 'stopped'; query: string };
|
||||
|
||||
const NO_REMOTE_RESULTS: OrderedComputedResult[] = [];
|
||||
|
||||
const SEARCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function getSearchPhase(query: string, remote: RemoteSearch): SearchPhase {
|
||||
if (!query) {
|
||||
return 'idle';
|
||||
}
|
||||
// A new query renders once before its request starts.
|
||||
if (remote.status === 'idle' || remote.query !== query) {
|
||||
return 'loading';
|
||||
}
|
||||
return remote.status;
|
||||
}
|
||||
|
||||
// Small helper extracted for unit testing of scope → local filter mapping
|
||||
// computeFilterSiteSpaceIds is imported from './filter' for testability
|
||||
|
||||
@@ -124,7 +96,11 @@ export function useSearchResults(props: {
|
||||
filterSiteSpaceIds,
|
||||
});
|
||||
|
||||
const [remote, setRemote] = React.useState<RemoteSearch>({ status: 'idle' });
|
||||
const [remoteState, setRemoteState] = React.useState<{
|
||||
results: OrderedComputedResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
}>({ results: [], fetching: false, error: false });
|
||||
|
||||
// Track the current in-flight fetch so it can be aborted imperatively
|
||||
// when the user navigates away before the request completes.
|
||||
@@ -139,15 +115,36 @@ export function useSearchResults(props: {
|
||||
return;
|
||||
}
|
||||
if (!query) {
|
||||
setRemote({ status: 'idle' });
|
||||
|
||||
const hasQuestions =
|
||||
cachedRecommendedQuestions.has(siteSpaceId) ||
|
||||
(suggestions !== undefined && suggestions.length > 0);
|
||||
if (!withAI || hasQuestions) {
|
||||
if (!withAI) {
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedRecommendedQuestions.has(siteSpaceId)) {
|
||||
const results = cachedRecommendedQuestions.get(siteSpaceId);
|
||||
assert(
|
||||
results,
|
||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||
);
|
||||
// Recommended questions are stored as ResultType[] already
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// We currently have a bug where the same question can be returned multiple times.
|
||||
@@ -155,6 +152,18 @@ export function useSearchResults(props: {
|
||||
const questions = new Set<string>();
|
||||
const recommendedQuestions: RecommendedQuestionResult[] = [];
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
suggestions.forEach((question) => {
|
||||
questions.add(question);
|
||||
});
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -176,8 +185,12 @@ export function useSearchResults(props: {
|
||||
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
||||
|
||||
if (!cancelled) {
|
||||
// A new object re-renders, so the list picks up the cached questions.
|
||||
setRemote({ status: 'idle' });
|
||||
// Recommended questions are handled via a separate path below
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
@@ -187,7 +200,11 @@ export function useSearchResults(props: {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
setRemote({ status: 'loading', query });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(async () => {
|
||||
@@ -196,10 +213,8 @@ export function useSearchResults(props: {
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
|
||||
|
||||
// A request that never answers would leave the search loading for good.
|
||||
const giveUp = setTimeout(() => abortController.abort(), SEARCH_TIMEOUT_MS);
|
||||
try {
|
||||
const results = await fetchSearch(
|
||||
const resultsPromise = fetchSearch(
|
||||
computeRemoteSearchScope(
|
||||
scope,
|
||||
siteSpaceId,
|
||||
@@ -207,25 +222,44 @@ export function useSearchResults(props: {
|
||||
selectedSectionSiteSpaceIds
|
||||
)
|
||||
);
|
||||
|
||||
const onResults = (results: OrderedComputedResult[]) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
// Can happen when the route cannot be found and returns the page's html.
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState((prev) => ({ ...prev, results }));
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
};
|
||||
const onError = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
};
|
||||
|
||||
await resultsPromise.then(onResults, onError);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
// Can happen when the route cannot be found and returns the page's html.
|
||||
setRemote({ status: 'failed', query });
|
||||
return;
|
||||
}
|
||||
|
||||
setRemote({ status: 'loaded', query, results });
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
setRemoteState((prev) => ({ ...prev, fetching: false }));
|
||||
} catch {
|
||||
// If there is an error, we need to catch it to avoid infinite loading state.
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemote({ status: 'failed', query });
|
||||
} finally {
|
||||
clearTimeout(giveUp);
|
||||
setRemoteState({
|
||||
results: [],
|
||||
fetching: false,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
@@ -258,15 +292,9 @@ export function useSearchResults(props: {
|
||||
const abort = React.useCallback(() => {
|
||||
abortRef.current?.();
|
||||
abortRef.current = null;
|
||||
setRemote((prev) =>
|
||||
prev.status === 'loading' ? { status: 'stopped', query: prev.query } : prev
|
||||
);
|
||||
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
|
||||
}, []);
|
||||
|
||||
const phase = getSearchPhase(query, remote);
|
||||
const remoteResults =
|
||||
phase === 'loaded' && remote.status === 'loaded' ? remote.results : NO_REMOTE_RESULTS;
|
||||
|
||||
// Merge local and remote results.
|
||||
// Re-runs immediately whenever either result set changes.
|
||||
const results = React.useMemo<ResultType[]>(() => {
|
||||
@@ -287,14 +315,13 @@ export function useSearchResults(props: {
|
||||
|
||||
return fuseSearchResults({
|
||||
localResults,
|
||||
remoteResults,
|
||||
remoteResults: remoteState.results,
|
||||
query,
|
||||
allowedSiteSpaceIds: filterSiteSpaceIds,
|
||||
});
|
||||
}, [
|
||||
localResults,
|
||||
remote,
|
||||
remoteResults,
|
||||
remoteState.results,
|
||||
query,
|
||||
filterSiteSpaceIds,
|
||||
withAI,
|
||||
@@ -303,7 +330,12 @@ export function useSearchResults(props: {
|
||||
recentQueries,
|
||||
]);
|
||||
|
||||
return { results, phase, abort };
|
||||
return {
|
||||
results,
|
||||
fetching: remoteState.fetching,
|
||||
error: remoteState.error,
|
||||
abort,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
|
||||
export function useSearchResultsCursor(props: {
|
||||
query: string;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const { query, resultCount, totalCount } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (totalCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursor((prev) => {
|
||||
if (resultCount > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return prev ?? 0;
|
||||
});
|
||||
}, [resultCount, totalCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCursor((prev) => {
|
||||
if (prev === null) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(prev, totalCount - 1);
|
||||
});
|
||||
}, [totalCount]);
|
||||
|
||||
const moveBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
setCursor((prev) => {
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prev === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(Math.min(prev + delta, totalCount - 1), 0);
|
||||
});
|
||||
},
|
||||
[totalCount]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
setCursor,
|
||||
moveBy,
|
||||
};
|
||||
}
|
||||
@@ -74,10 +74,12 @@ export async function SiteLayout(props: {
|
||||
// Start the search-index download from the HTML itself. `crossOrigin` must match the
|
||||
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
|
||||
// index downloads twice — the omission was exactly that bug before.
|
||||
// Low priority: it's only needed once search opens and must not compete with the LCP.
|
||||
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
|
||||
as: 'fetch',
|
||||
type: 'application/json',
|
||||
crossOrigin: 'anonymous',
|
||||
fetchPriority: 'low',
|
||||
});
|
||||
|
||||
scripts.forEach(({ script }) => {
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ConsentLayout(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<main className="site-background flex min-h-screen flex-col" {...rest}>
|
||||
<main className="site-background flex min-h-dvh flex-col" {...rest}>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex w-full flex-1 items-center justify-center',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useSpaceBasePath,
|
||||
} from '../SpaceLayout/SpaceLayoutContext';
|
||||
import { TrackPageViewEvent } from '@/components/Insights';
|
||||
import { MAIN_CONTENT_ID } from '@/components/layout';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -102,6 +103,7 @@ export function SitePageNotFound() {
|
||||
<CurrentPageProvider page={null}>
|
||||
<div className="lg:has-sidebar:-ml-84 w-full">
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
className={tcls(
|
||||
// `page-no-toc` hides the TOC (like PageBody), and `layout-wide` takes the
|
||||
// now-empty sidebar out of flow so the content stays centered on all layouts.
|
||||
|
||||
@@ -22,7 +22,7 @@ const POPUP_OFFSET = 4;
|
||||
const OPEN_DELAY_MS = 200;
|
||||
const MOTION = 'duration-300 ease-[cubic-bezier(0.83,0,0.17,1)]';
|
||||
// Bounds the scrolling content too: the popup settles at `height: auto`, so `h-full` doesn't.
|
||||
const MAX_POPUP_HEIGHT = 'max-h-[calc(100vh-8rem)]';
|
||||
const MAX_POPUP_HEIGHT = 'max-h-[calc(100dvh-8rem)]';
|
||||
const MAX_ITEMS_PER_COLUMN = 10; // number of items per column
|
||||
const GROUP_MASONRY_THRESHOLD = 3; // if a section group has more than this many child groups, it will be shown in a masonry grid
|
||||
const COLUMN_WIDTH = '18rem';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CurrentContentProvider } from '../hooks';
|
||||
import { InsightsProvider, VisitorProvider } from '../Insights';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||
import { SkipToContent } from '../primitives/SkipToContent';
|
||||
import { SearchContainer, getSearchBaseProps } from '../Search';
|
||||
import {
|
||||
SiteSectionList,
|
||||
@@ -131,6 +132,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
|
||||
return (
|
||||
<SpaceLayoutServerContext {...props}>
|
||||
<SkipToContent />
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
|
||||
<NavigationLoader />
|
||||
@@ -142,8 +144,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Chat panel shifts content left when open */}
|
||||
<div className="lg:chat-open:mr-(--ai-chat-width) motion-safe:transition-all motion-safe:duration-300">
|
||||
{/* Chat panel shifts content left when open. Clipped so blocks bleeding into the gutters (card carousels) can't widen the page. */}
|
||||
<div className="overflow-x-clip lg:chat-open:mr-(--ai-chat-width) motion-safe:transition-all motion-safe:duration-300">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
@@ -160,8 +162,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withTopHeader
|
||||
? [
|
||||
'site-header:min-h-[calc(100vh-65px)]',
|
||||
'site-header-sections:min-h-[calc(100vh-109px)]',
|
||||
'site-header:min-h-[calc(100dvh-65px)]',
|
||||
'site-header-sections:min-h-[calc(100dvh-109px)]',
|
||||
]
|
||||
: 'lg:min-h-screen'
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function ToggleableLinkItem(
|
||||
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
|
||||
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
|
||||
const hasBeenToggled = useRef(false);
|
||||
const linkId = React.useId();
|
||||
|
||||
// Update the visibility of the children if one of the descendants becomes active.
|
||||
React.useEffect(() => {
|
||||
@@ -60,10 +61,16 @@ export function ToggleableLinkItem(
|
||||
}
|
||||
|
||||
return (
|
||||
<DescendantsRenderer descendants={descendants} isOpen={isOpen} setIsOpen={handleToggle}>
|
||||
<DescendantsRenderer
|
||||
descendants={descendants}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={handleToggle}
|
||||
labelledBy={linkId}
|
||||
>
|
||||
{({ descendants, toggler }) => (
|
||||
<>
|
||||
<LinkItem
|
||||
id={linkId}
|
||||
href={href}
|
||||
insights={insights}
|
||||
isActive={isActive}
|
||||
@@ -88,12 +95,12 @@ export function ToggleableLinkItem(
|
||||
}
|
||||
|
||||
function LinkItem(
|
||||
props: Pick<LinkProps, 'href' | 'insights' | 'children'> & {
|
||||
props: Pick<LinkProps, 'href' | 'insights' | 'children' | 'id'> & {
|
||||
isActive: boolean;
|
||||
onActiveClick?: () => void;
|
||||
}
|
||||
) {
|
||||
const { isActive, href, insights, children, onActiveClick } = props;
|
||||
const { id, isActive, href, insights, children, onActiveClick } = props;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isActive && onActiveClick) {
|
||||
@@ -104,6 +111,7 @@ function LinkItem(
|
||||
|
||||
return (
|
||||
<Link
|
||||
id={id}
|
||||
data-active={isActive}
|
||||
href={href}
|
||||
insights={insights}
|
||||
@@ -123,18 +131,21 @@ function DescendantsRenderer(props: {
|
||||
descendants: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Id of the link the toggle belongs to, used as its accessible name. */
|
||||
labelledBy: string;
|
||||
children: (renderProps: {
|
||||
descendants: React.ReactNode;
|
||||
toggler: React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
}) {
|
||||
const { descendants, isOpen, setIsOpen } = props;
|
||||
const { descendants, isOpen, setIsOpen, labelledBy } = props;
|
||||
|
||||
return props.children({
|
||||
toggler: (
|
||||
<Toggler
|
||||
isLinkActive={isOpen}
|
||||
isOpen={isOpen}
|
||||
labelledBy={labelledBy}
|
||||
onToggle={() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}}
|
||||
@@ -144,8 +155,13 @@ function DescendantsRenderer(props: {
|
||||
});
|
||||
}
|
||||
|
||||
function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () => void }) {
|
||||
const { isOpen, onToggle } = props;
|
||||
function Toggler(props: {
|
||||
isLinkActive: boolean;
|
||||
isOpen: boolean;
|
||||
labelledBy: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { isOpen, labelledBy, onToggle } = props;
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
@@ -164,9 +180,12 @@ function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () =
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
variant="blank"
|
||||
aria-hidden="true" // The button has no label or focus so hiding it from screen readers.
|
||||
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
|
||||
tabIndex={-1} // Prevent focus on the button since it's already inside a clickable link that performs the same toggle action.
|
||||
// Named after its page and focusable: the link only toggles on the current page, so
|
||||
// this is the only keyboard way to expand another page's children.
|
||||
aria-labelledby={labelledBy}
|
||||
aria-expanded={isOpen}
|
||||
// Cancels the Button's expanded highlight: an open chevron must look like a closed one.
|
||||
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base aria-expanded:bg-transparent aria-expanded:text-current aria-expanded:hover:bg-tint-hover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './useNow';
|
||||
export * from './useListOverflow';
|
||||
export * from './useCurrentPageMetadata';
|
||||
export * from './useBackToSpace';
|
||||
export * from './useCopyToClipboard';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard and expose a `copied` flag that resets after `duration`.
|
||||
* The flag is only set once the write succeeded.
|
||||
*/
|
||||
export function useCopyToClipboard(duration = 1000) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => setCopied(false), duration);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copied, duration]);
|
||||
|
||||
const copy = React.useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// Clipboard access can be denied (permissions, insecure context): don't claim success.
|
||||
setCopied(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { copied, copy };
|
||||
}
|
||||
@@ -50,3 +50,6 @@ export const SIDE_COLUMN_WITHOUT_HEADER_AND_COVER: ClassValue = [
|
||||
'top-[240px]',
|
||||
'h-[calc(100vh-240px)]',
|
||||
];
|
||||
|
||||
/** Id of the `<main>` landmark, target of the skip link. */
|
||||
export const MAIN_CONTENT_ID = 'gitbook-main-content';
|
||||
|
||||
@@ -187,7 +187,7 @@ export const Button = React.forwardRef<
|
||||
className={domClassName}
|
||||
classNames={['ButtonStyles']}
|
||||
insights={insights}
|
||||
aria-label={label?.toString()}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
aria-pressed={active}
|
||||
target={target}
|
||||
data-active={active}
|
||||
@@ -200,7 +200,7 @@ export const Button = React.forwardRef<
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
className={tcls(buttonOnlyClassNames, domClassName)}
|
||||
aria-label={label?.toString()}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
data-active={active}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function HoverCard(
|
||||
>
|
||||
<PreviewCard.Popup
|
||||
{...popupProps}
|
||||
className="animate-scale-in data-closed:animate-scale-out"
|
||||
className="pointer-events-auto animate-scale-in data-closed:animate-scale-out"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useControlledState } from '../hooks/useControlledState';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
import { KeyboardShortcut, type KeyboardShortcutProps } from './KeyboardShortcut';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { isComposingKeyEvent } from '@/lib/keyboard';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
type CustomInputProps = {
|
||||
@@ -166,8 +167,16 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
|
||||
|
||||
if (multiline && resize && ref.current) {
|
||||
// TODO: replace with `field-sizing: content` when more broadly supported. https://caniuse.com/?search=field-sizing
|
||||
// Reset the height to auto, then set it to the scroll height. If we don't reset, the height will only ever grow.
|
||||
setHeight(ref.current.scrollHeight);
|
||||
// scrollHeight never goes below the current height, so shrinking needs a measure with the
|
||||
// height reset. Only done on deletion: collapsing the field can shift the page scroll.
|
||||
const previousHeight = ref.current.style.height;
|
||||
const isShrinking = newValue.length < value.length;
|
||||
if (isShrinking) {
|
||||
ref.current.style.height = 'auto';
|
||||
}
|
||||
const nextHeight = ref.current.scrollHeight;
|
||||
ref.current.style.height = previousHeight;
|
||||
setHeight(nextHeight);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,12 +202,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
|
||||
// If the user wants to handle the keydown by itself, we let him do it.
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
hasValue
|
||||
) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposingKeyEvent(event) && hasValue) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
@@ -250,7 +254,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
|
||||
? 'cursor-not-allowed border-tint-subtle bg-tint-subtle text-tint/8'
|
||||
: [
|
||||
'depth-subtle:focus-within:-translate-y-px depth-subtle:hover:not-has-[button:hover]:-translate-y-px depth-subtle:shadow-xs',
|
||||
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
|
||||
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 forced-colors:focus-within:outline-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
|
||||
],
|
||||
multiline ? 'flex-col' : 'flex-row',
|
||||
sizes[sizing].container,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { MAIN_CONTENT_ID } from '../layout';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
|
||||
/**
|
||||
* Link shown on keyboard focus to jump over the header and navigation.
|
||||
*/
|
||||
export function SkipToContent() {
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<a
|
||||
href={`#${MAIN_CONTENT_ID}`}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-tint-base focus:px-4 focus:py-2 focus:text-sm focus:text-tint-strong focus:shadow-lg focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{tString(language, 'skip_to_content')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const ar: TranslationLanguage = {
|
||||
switch_to_light_theme: 'التبديل إلى السمة الفاتحة',
|
||||
switch_to_system_theme: 'التبديل إلى سمة النظام',
|
||||
search: 'بحث',
|
||||
skip_to_content: 'تخطي إلى المحتوى',
|
||||
clear: 'مسح',
|
||||
table_filtered_by_selection: 'يطابق الاختيار: ${1}',
|
||||
table_clear_selection: 'مسح الاختيار',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const bg: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Превключване към светла тема',
|
||||
switch_to_system_theme: 'Превключване към системната тема',
|
||||
search: 'Търсене',
|
||||
skip_to_content: 'Към съдържанието',
|
||||
clear: 'Изчистване',
|
||||
table_filtered_by_selection: 'Съответства на избора: ${1}',
|
||||
table_clear_selection: 'Изчисти избора',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const cs: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Přepnout na světlý motiv',
|
||||
switch_to_system_theme: 'Přepnout na systémový motiv',
|
||||
search: 'Hledat',
|
||||
skip_to_content: 'Přejít k obsahu',
|
||||
clear: 'Vymazat',
|
||||
table_filtered_by_selection: 'Odpovídá výběru: ${1}',
|
||||
table_clear_selection: 'Zrušit výběr',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const da: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Skift til lyst tema',
|
||||
switch_to_system_theme: 'Skift til systemtema',
|
||||
search: 'Søg',
|
||||
skip_to_content: 'Spring til indhold',
|
||||
clear: 'Ryd',
|
||||
table_filtered_by_selection: 'Matcher valg: ${1}',
|
||||
table_clear_selection: 'Ryd valg',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const de: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
search: 'Suche',
|
||||
skip_to_content: 'Zum Inhalt springen',
|
||||
clear: 'Löschen',
|
||||
table_filtered_by_selection: 'Entspricht Auswahl: ${1}',
|
||||
table_clear_selection: 'Auswahl zurücksetzen',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const el: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Αλλαγή σε φωτεινό θέμα',
|
||||
switch_to_system_theme: 'Αλλαγή στο θέμα συστήματος',
|
||||
search: 'Αναζήτηση',
|
||||
skip_to_content: 'Μετάβαση στο περιεχόμενο',
|
||||
clear: 'Εκκαθάριση',
|
||||
table_filtered_by_selection: 'Ταιριάζει με την επιλογή: ${1}',
|
||||
table_clear_selection: 'Εκκαθάριση επιλογής',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const en = {
|
||||
switch_to_light_theme: 'Switch to light theme',
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
search: 'Search',
|
||||
skip_to_content: 'Skip to content',
|
||||
clear: 'Clear',
|
||||
table_filtered_by_selection: 'Matches selection: ${1}',
|
||||
table_clear_selection: 'Clear selection',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const es: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Cambiar a tema claro',
|
||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||
search: 'Buscar',
|
||||
skip_to_content: 'Saltar al contenido',
|
||||
clear: 'Limpiar',
|
||||
table_filtered_by_selection: 'Coincide con la selección: ${1}',
|
||||
table_clear_selection: 'Borrar selección',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const et: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Lülitu heledale teemale',
|
||||
switch_to_system_theme: 'Lülitu süsteemi teemale',
|
||||
search: 'Otsi',
|
||||
skip_to_content: 'Liigu sisu juurde',
|
||||
clear: 'Tühjenda',
|
||||
table_filtered_by_selection: 'Vastab valikule: ${1}',
|
||||
table_clear_selection: 'Tühjenda valik',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const fi: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Vaihda vaaleaan teemaan',
|
||||
switch_to_system_theme: 'Vaihda järjestelmän teemaan',
|
||||
search: 'Haku',
|
||||
skip_to_content: 'Siirry sisältöön',
|
||||
clear: 'Tyhjennä',
|
||||
table_filtered_by_selection: 'Vastaa valintaa: ${1}',
|
||||
table_clear_selection: 'Tyhjennä valinta',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const fr: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Activer le thème clair',
|
||||
switch_to_system_theme: 'Utiliser le thème du système',
|
||||
search: 'Rechercher',
|
||||
skip_to_content: 'Aller au contenu',
|
||||
clear: 'Effacer',
|
||||
table_filtered_by_selection: 'Correspond à la sélection : ${1}',
|
||||
table_clear_selection: 'Effacer la sélection',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const he: TranslationLanguage = {
|
||||
switch_to_light_theme: 'מעבר לערכת נושא בהירה',
|
||||
switch_to_system_theme: 'מעבר לערכת הנושא של המערכת',
|
||||
search: 'חיפוש',
|
||||
skip_to_content: 'דילוג לתוכן',
|
||||
clear: 'ניקוי',
|
||||
table_filtered_by_selection: 'תואם לבחירה: ${1}',
|
||||
table_clear_selection: 'נקה בחירה',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const hi: TranslationLanguage = {
|
||||
switch_to_light_theme: 'लाइट थीम पर जाएं',
|
||||
switch_to_system_theme: 'सिस्टम थीम पर जाएं',
|
||||
search: 'खोजें',
|
||||
skip_to_content: 'सामग्री पर जाएं',
|
||||
clear: 'साफ करें',
|
||||
table_filtered_by_selection: 'चयन से मेल खाता है: ${1}',
|
||||
table_clear_selection: 'चयन साफ़ करें',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const hr: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Prebaci na svijetlu temu',
|
||||
switch_to_system_theme: 'Prebaci na temu sustava',
|
||||
search: 'Pretraži',
|
||||
skip_to_content: 'Preskoči na sadržaj',
|
||||
clear: 'Očisti',
|
||||
table_filtered_by_selection: 'Odgovara odabiru: ${1}',
|
||||
table_clear_selection: 'Očisti odabir',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const hu: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Váltás világos témára',
|
||||
switch_to_system_theme: 'Váltás rendszer témára',
|
||||
search: 'Keresés',
|
||||
skip_to_content: 'Ugrás a tartalomra',
|
||||
clear: 'Törlés',
|
||||
table_filtered_by_selection: 'Megfelel a kijelölésnek: ${1}',
|
||||
table_clear_selection: 'Kijelölés törlése',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const id: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Beralih ke tema terang',
|
||||
switch_to_system_theme: 'Beralih ke tema sistem',
|
||||
search: 'Cari',
|
||||
skip_to_content: 'Lewati ke konten',
|
||||
clear: 'Bersihkan',
|
||||
table_filtered_by_selection: 'Sesuai pilihan: ${1}',
|
||||
table_clear_selection: 'Hapus pilihan',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const it: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Passa al tema chiaro',
|
||||
switch_to_system_theme: 'Passa al tema di sistema',
|
||||
search: 'Cerca',
|
||||
skip_to_content: 'Vai al contenuto',
|
||||
clear: 'Cancella',
|
||||
table_filtered_by_selection: 'Corrisponde alla selezione: ${1}',
|
||||
table_clear_selection: 'Cancella selezione',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ja: TranslationLanguage = {
|
||||
switch_to_light_theme: 'ライトテーマに切り替え',
|
||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||
search: '検索',
|
||||
skip_to_content: 'コンテンツへスキップ',
|
||||
clear: 'クリア',
|
||||
table_filtered_by_selection: '選択に一致: ${1}',
|
||||
table_clear_selection: '選択をクリア',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ko: TranslationLanguage = {
|
||||
switch_to_light_theme: '라이트 테마로 전환',
|
||||
switch_to_system_theme: '시스템 테마로 전환',
|
||||
search: '검색',
|
||||
skip_to_content: '본문으로 건너뛰기',
|
||||
clear: '지우기',
|
||||
table_filtered_by_selection: '선택과 일치: ${1}',
|
||||
table_clear_selection: '선택 지우기',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const lt: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Perjungti į šviesią temą',
|
||||
switch_to_system_theme: 'Perjungti į sistemos temą',
|
||||
search: 'Ieškoti',
|
||||
skip_to_content: 'Pereiti prie turinio',
|
||||
clear: 'Išvalyti',
|
||||
table_filtered_by_selection: 'Atitinka pasirinkimą: ${1}',
|
||||
table_clear_selection: 'Išvalyti pasirinkimą',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const lv: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Pārslēgt uz gaišo motīvu',
|
||||
switch_to_system_theme: 'Pārslēgt uz sistēmas motīvu',
|
||||
search: 'Meklēt',
|
||||
skip_to_content: 'Pāriet uz saturu',
|
||||
clear: 'Notīrīt',
|
||||
table_filtered_by_selection: 'Atbilst izvēlei: ${1}',
|
||||
table_clear_selection: 'Notīrīt izvēli',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ms: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Tukar kepada tema cerah',
|
||||
switch_to_system_theme: 'Tukar kepada tema sistem',
|
||||
search: 'Cari',
|
||||
skip_to_content: 'Langkau ke kandungan',
|
||||
clear: 'Kosongkan',
|
||||
table_filtered_by_selection: 'Sepadan dengan pilihan: ${1}',
|
||||
table_clear_selection: 'Kosongkan pilihan',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const nl: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Schakel over naar lichte modus',
|
||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||
search: 'Zoeken',
|
||||
skip_to_content: 'Naar inhoud',
|
||||
clear: 'Wissen',
|
||||
table_filtered_by_selection: 'Komt overeen met selectie: ${1}',
|
||||
table_clear_selection: 'Selectie wissen',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const no: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Bytt til lyst tema',
|
||||
switch_to_system_theme: 'Bytt til systemtema',
|
||||
search: 'Søk',
|
||||
skip_to_content: 'Hopp til innhold',
|
||||
clear: 'Tøm',
|
||||
table_filtered_by_selection: 'Samsvarer med valg: ${1}',
|
||||
table_clear_selection: 'Fjern valg',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const pl: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Przełącz na jasny motyw',
|
||||
switch_to_system_theme: 'Przełącz na motyw systemowy',
|
||||
search: 'Szukaj',
|
||||
skip_to_content: 'Przejdź do treści',
|
||||
clear: 'Wyczyść',
|
||||
table_filtered_by_selection: 'Zgodne z wyborem: ${1}',
|
||||
table_clear_selection: 'Wyczyść wybór',
|
||||
|
||||
@@ -10,6 +10,7 @@ export const pt_br: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Mudar para modo claro',
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
search: 'Buscar',
|
||||
skip_to_content: 'Pular para o conteúdo',
|
||||
clear: 'Limpar',
|
||||
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
|
||||
table_clear_selection: 'Limpar seleção',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user