mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-26 12:18:01 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afbc57c3e0 | |||
| e4638f1833 | |||
| d7f2addb03 | |||
| 7f8ab7dcc7 | |||
| 8dd9444596 | |||
| bdc2497771 | |||
| 581abe0bb7 | |||
| 4503be2f19 | |||
| bdfc98b97e | |||
| 0f0cfde6ba | |||
| 357af3f6d2 | |||
| b95add7b27 | |||
| 99984ebbb3 | |||
| a52daf8b15 | |||
| f80429e4e8 | |||
| 6b4c453497 |
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: write-pr-description
|
||||
description: Write a concise, product-focused PR description from the changes and available discussion. Use when asked for a PR summary or description.
|
||||
---
|
||||
|
||||
# Write a PR description
|
||||
|
||||
Read the full branch diff and commit history against the PR's base branch, not just the latest commit. Use the PR discussion and linked issues for context when available. The PR's changes are the source of truth for what ships if an issue describes something different.
|
||||
|
||||
Write for contributors and reviewers who may not know the area. Lead with what changes, prefer user or product impact when supported, and include implementation details only when they help review. Keep the proposed changes short, using bullets only when useful.
|
||||
|
||||
Include relevant validation and screenshots when available. Do not claim checks passed unless they ran. Use only information appropriate for a public repository; do not copy private issue details or customer information into the description.
|
||||
|
||||
## Template and changelog
|
||||
|
||||
Read `.github/pull_request_template.md` immediately before writing. Preserve its required section names and order. Include `Proposed changes` and `Changelog`; omit `Demo` when there is nothing to show. Add optional `Context` before `Changelog` only when it meets the rule below.
|
||||
|
||||
Write a short changelog entry describing what shipped. Use `[Feature]` for user-facing additions or improvements, `[Fix]` for user-facing bug fixes, and `[Chore]` for internal maintenance. For features and fixes, say what users can do or what no longer goes wrong in plain words. Keep chores useful to teammates without inventing user impact. Remove unused examples and placeholders. Usually one entry is enough; use more only for distinct changes. The PR changelog does not replace any changeset required by `AGENTS.md`.
|
||||
|
||||
## Optional context
|
||||
|
||||
Write `Proposed changes` and `Changelog` first. Add `Context` only when it tells someone unfamiliar with the area something material that the changelog does not. `Problem` names a concrete failure or limitation supported by the PR or discussion. `Outcome` names what this PR delivers at its actual scope. Omit the section if either field would repeat the changelog, describe only implementation, or guess at an impact.
|
||||
|
||||
```markdown
|
||||
## Context
|
||||
|
||||
**Problem:** <concrete failure or limitation>
|
||||
|
||||
**Outcome:** <result delivered by this PR>
|
||||
```
|
||||
|
||||
Keep each field to 1–2 short, non-technical sentences. Prefer user or product impact when supported. For infrastructure changes, explain the failure or limitation they caused. Do not invent an actor, customer segment, ownership, or impact, or force the change into a user story. Do not turn a preparatory change into a claim that the whole workflow is fixed. Keep the roles distinct: `Context` explains why this matters, `Changelog` says what shipped, and `Proposed changes` can explain implementation.
|
||||
|
||||
Examples:
|
||||
|
||||
- A dependency bump with no demonstrated user effect needs no `Context`.
|
||||
- If creating the first change request leaves the user on an empty screen, say that it now opens after creation. Leave the animation-listener details in `Proposed changes`.
|
||||
- If a PR indexes links for later checks, describe the indexing result. Do not claim every link has already been checked.
|
||||
|
||||
## Writing pass
|
||||
|
||||
- Use plain words and direct verbs: "use" instead of "leverage", "help" instead of "facilitate". Prefer active voice when the actor is known.
|
||||
- Name the concrete behavior instead of saying "improves the experience" or "makes it more robust". Support performance claims with measurements.
|
||||
- Cut filler, promotional language, and generic conclusions. If a sentence could fit almost any PR, make it specific or delete it.
|
||||
- Keep one idea per sentence and use consistent names. Remove redundant hedging without hiding real uncertainty.
|
||||
- Avoid "not just X, but Y", forced groups of three, em dashes, decorative emojis, and unnecessary bold text.
|
||||
|
||||
Return the description in a Markdown code block unless asked to update the PR. When asked to update it, apply the description with `gh pr edit` using a body file.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Only advertise RSS feeds on pages with Updates blocks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Serve Markdown responses as plain text to OpenAI's ChatGPT Search crawler.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Announce pagination at the top of every part of a multi-part llms-full.txt, with absolute links to the previous and next parts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Missing pages with accented or non-Latin characters in the URL now return 404 instead of an endless redirect.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Pass page context to webframes opened from integration modals.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Apply release notes tag filters from the URL via CSS before first paint, so the filtered entries, the "page contents" section list, and the tag chip highlighting all show correctly from the start on cached pages, with no flash of unfiltered content. Also stop the "page contents" scroll highlight from defaulting to an entry hidden by the filter.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Don't track a page view for 404s on internal `~gitbook` routes, so they no longer appear as broken URLs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Narrow a table or card grid to the reader's content selection: choosing an option in a tab or a select button now filters any select column that offers it. The table says what it has been narrowed to, and offers a Clear that drops the selection.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track Insights link clicks on the Download button of file blocks.
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/write-pr-description
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/write-pr-description
|
||||
@@ -70,6 +70,7 @@ runs:
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_DISABLE_INSIGHTS: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES: ${{ inputs.environment == 'staging' && 'true' || '' }}
|
||||
shell: bash
|
||||
|
||||
- name: Upload the DO worker
|
||||
|
||||
@@ -78,6 +78,11 @@ runs:
|
||||
echo "GITBOOK_DISABLE_INSIGHTS=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
|
||||
cat .vercel/.env.${{ inputs.environment }}.local
|
||||
- name: Inject staging build env vars
|
||||
if: ${{ inputs.environment == 'staging' }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "GITBOOK_DISABLE_LOOKUP_ALTERNATIVES=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
- name: Build Project Artifacts
|
||||
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
## Proposed changes
|
||||
|
||||
<!-- Briefly describe what changed. Include relevant validation. -->
|
||||
|
||||
## Demo
|
||||
|
||||
<!-- Add screenshots or a recording when useful. Otherwise, remove this section. -->
|
||||
|
||||
<!-- Optional: add Context here only when it explains a concrete problem and result that the changelog does not. Remove this comment before submitting. -->
|
||||
|
||||
## Changelog
|
||||
|
||||
<!-- Keep the relevant entries and remove unused placeholders. Feature = user-facing addition or improvement; Fix = user-facing bug fix; Chore = internal maintenance. -->
|
||||
|
||||
- [Feature] ...
|
||||
- [Fix] ...
|
||||
- [Chore] ...
|
||||
@@ -160,7 +160,7 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.3.3",
|
||||
"next": "^16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -342,10 +342,10 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"next@16.3.3": "patches/next@16.3.3.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@opennextjs/aws": "4.1.5",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"axios": "1.8.4",
|
||||
@@ -779,57 +779,57 @@
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
|
||||
|
||||
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="],
|
||||
|
||||
@@ -891,23 +891,23 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="],
|
||||
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
|
||||
|
||||
@@ -943,7 +943,7 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.1.3", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-nMDA3APULL1hFREeWqPdxRk3djdgF1/Y38gnqlYBVs4iUib9sxFguP1uGSAyYbR7eVxGholjXoWBYx7jDBP/Ag=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
|
||||
|
||||
@@ -2649,7 +2649,7 @@
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="],
|
||||
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
@@ -2969,7 +2969,7 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
@@ -3327,6 +3327,8 @@
|
||||
|
||||
"@argos-ci/core/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"@argos-ci/core/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="],
|
||||
@@ -3417,11 +3419,11 @@
|
||||
|
||||
"@hyperjump/json-schema/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
|
||||
|
||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
|
||||
|
||||
"@internationalized/date/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
@@ -3955,6 +3957,60 @@
|
||||
|
||||
"@argos-ci/core/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
|
||||
|
||||
"@argos-ci/core/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
|
||||
@@ -4001,9 +4057,9 @@
|
||||
|
||||
"@headlessui/react/@floating-ui/react/@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
@@ -4377,6 +4433,10 @@
|
||||
|
||||
"yargs/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
@@ -4461,6 +4521,10 @@
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"@headlessui/react/@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4"
|
||||
"axios": "1.8.4",
|
||||
"@opennextjs/aws": "4.1.5"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1958,9 +1958,6 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Visitor Auth - Space (oversized token)',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||||
// Our Cloudflare stack still folds multiple Set-Cookie headers into one,
|
||||
// breaking chunked cookies (variant of opennextjs-cloudflare#501).
|
||||
skip: process.env.ARGOS_BUILD_NAME === 'v2-cloudflare',
|
||||
tests: [
|
||||
{
|
||||
name: 'Oversized token is chunked into cookies and survives navigation',
|
||||
@@ -2011,7 +2008,9 @@ const testCases: TestsCase[] = [
|
||||
.replace(/\/$/, '');
|
||||
secondURL.pathname = `${basePathname}/second`;
|
||||
secondURL.search = '';
|
||||
await page.goto(secondURL.toString());
|
||||
// Same reason as the harness navigation: third-party subresources on
|
||||
// this site can hang and never fire `load`.
|
||||
await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'second' })
|
||||
).toBeVisible();
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.3.3",
|
||||
"next": "^16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { serveSiteAiCatalog } from '@/lib/aiCatalog';
|
||||
|
||||
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveSiteAiCatalog(context, request);
|
||||
}
|
||||
|
||||
export { handler as GET, handler as OPTIONS };
|
||||
+10
-405
@@ -1,41 +1,12 @@
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
AgentFeedbackSource,
|
||||
CustomizationPageActionType,
|
||||
SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import {
|
||||
AGENT_FEEDBACK_GOAL_MAX_LENGTH,
|
||||
AGENT_FEEDBACK_MAX_LENGTH,
|
||||
agentFeedbackDescriptions,
|
||||
parseAgentFeedbackPageURL,
|
||||
} from '@/lib/agentFeedback';
|
||||
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
|
||||
/**
|
||||
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
|
||||
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
|
||||
*/
|
||||
function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
|
||||
waitUntil(
|
||||
trackServerInsightsEvents(args).catch((error) => {
|
||||
console.error('Failed to track MCP insights event:', error);
|
||||
})
|
||||
);
|
||||
}
|
||||
import { isSiteMcpEnabled } from '@/lib/mcp/endpoints';
|
||||
import { buildMcpServerInfo } from '@/lib/mcp/serverCard';
|
||||
import { createSiteMcpTools, registerSiteMcpTools, trackMcpEvent } from '@/lib/mcp/tools';
|
||||
|
||||
export async function handleMcpRequest(
|
||||
rawRequest: NextRequest,
|
||||
@@ -43,11 +14,8 @@ export async function handleMcpRequest(
|
||||
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
const { pageActions } = context.customization;
|
||||
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
|
||||
if (!isMcpEnabled) {
|
||||
if (!isSiteMcpEnabled(context)) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
@@ -72,375 +40,12 @@ export async function handleMcpRequest(
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
{
|
||||
title: 'Search documentation',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: results.flatMap((result) => {
|
||||
if (result.type === 'record') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${result.title}`,
|
||||
`Link: ${result.url}`,
|
||||
result.description ? `Content: ${result.description}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === result.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
// The search API returns sections ordered highest-score-first, so
|
||||
// the first section with a body is the best-scoring preview.
|
||||
const body = (pageResult.sections ?? []).find(
|
||||
(section) => section.body
|
||||
)?.body;
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const siteUrl = context.siteSpace.urls.published;
|
||||
server.tool(
|
||||
'getPage',
|
||||
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
|
||||
{
|
||||
url: z
|
||||
.string()
|
||||
.describe('The URL of the page to fetch')
|
||||
.transform((value, ctx) => {
|
||||
if (URL.canParse(value)) {
|
||||
return value;
|
||||
}
|
||||
if (URL.canParse(`https://${value}`)) {
|
||||
return `https://${value}`;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Get page content',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
const match = findSiteSpaceByUrl(context.structure, url);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: match.siteSpace.space.id,
|
||||
revisionId: match.siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
|
||||
const resolved = resolveSiteSpacePagePath(
|
||||
match.siteSpace,
|
||||
revision.pages,
|
||||
match.pagePath
|
||||
);
|
||||
if (!resolved) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const markdown = await getMarkdownForPageInSpace(
|
||||
context,
|
||||
match.siteSpace,
|
||||
resolved.page
|
||||
);
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'page_view',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
page: resolved.page.id,
|
||||
space: match.siteSpace.space.id,
|
||||
revision: match.siteSpace.space.revision,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return { content: [{ type: 'text', text: markdown }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Only expose the answer tool when the site has AI enabled, since it relies on
|
||||
// the same AI search backend that powers the site's "ask a question" experience.
|
||||
if (isAIEnabled(context.customization.ai.mode)) {
|
||||
server.tool(
|
||||
'askQuestion',
|
||||
`Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`,
|
||||
{
|
||||
question: z
|
||||
.string()
|
||||
.describe(
|
||||
`The natural-language question to answer about ${site.title}.`
|
||||
),
|
||||
goal: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Ask a question',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ question, goal }) => {
|
||||
try {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Please provide a question to answer.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedGoal = goal?.trim() || undefined;
|
||||
|
||||
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
|
||||
goal: trimmedGoal,
|
||||
});
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'ask_question',
|
||||
query: trimmedQuestion,
|
||||
...(trimmedGoal ? { goal: trimmedGoal } : {}),
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
if (!answer || !answer.answer || !('markdown' in answer.answer)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "We couldn't answer this question.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const answerMarkdown = toPageMarkdown(
|
||||
await fromPageMarkdown(context, {
|
||||
markdown: answer.answer.markdown,
|
||||
pagePath: '',
|
||||
})
|
||||
);
|
||||
const sourcesMarkdown = await renderAskSourcesMarkdown(
|
||||
context,
|
||||
answer.sources ?? []
|
||||
);
|
||||
|
||||
let text = answerMarkdown.trim();
|
||||
if (sourcesMarkdown) {
|
||||
text += `\n\n# Sources\n\n${sourcesMarkdown}`;
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
server.tool(
|
||||
'sendFeedback',
|
||||
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
|
||||
{
|
||||
content: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AGENT_FEEDBACK_MAX_LENGTH)
|
||||
.describe(agentFeedbackDescriptions.finding),
|
||||
pageUrl: z
|
||||
.string()
|
||||
.describe(agentFeedbackDescriptions.pageURL(siteUrl))
|
||||
.transform((value, ctx) => {
|
||||
const url = parseAgentFeedbackPageURL(value, siteUrl);
|
||||
if (!url) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return url;
|
||||
}),
|
||||
goal: z
|
||||
.string()
|
||||
.max(AGENT_FEEDBACK_GOAL_MAX_LENGTH)
|
||||
.optional()
|
||||
.describe(agentFeedbackDescriptions.goal),
|
||||
},
|
||||
{
|
||||
title: 'Send feedback',
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ content, pageUrl, goal }) => {
|
||||
try {
|
||||
const result = await submitAgentFeedback(context, {
|
||||
feedback: content,
|
||||
goal,
|
||||
page: pageUrl,
|
||||
source: AgentFeedbackSource.Mcp,
|
||||
});
|
||||
|
||||
if (!result.submitted) {
|
||||
return {
|
||||
content: [{ type: 'text', text: result.error }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'agent_feedback',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
page: result.page.pageId,
|
||||
space: result.page.spaceId,
|
||||
revision: result.page.revisionId,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
|
||||
};
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
registerSiteMcpTools(server, createSiteMcpTools(context, { request }));
|
||||
},
|
||||
{
|
||||
// The same identity the server card republishes, so the card and the handshake agree.
|
||||
serverInfo: buildMcpServerInfo(context),
|
||||
},
|
||||
{},
|
||||
{
|
||||
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
|
||||
maxDuration: 60,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { serveSiteMcpServerCard } from '@/lib/mcp/serverCard';
|
||||
|
||||
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveSiteMcpServerCard(context, request);
|
||||
}
|
||||
|
||||
export { handler as GET, handler as OPTIONS };
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_DISABLE_INSIGHTS,
|
||||
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
@@ -37,6 +38,7 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
GITBOOK_DISABLE_INSIGHTS,
|
||||
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
|
||||
|
||||
// Secret envs
|
||||
GITBOOK_SECRET: !!GITBOOK_SECRET,
|
||||
|
||||
@@ -19,21 +19,25 @@ const adaptiveVisitorReaderCache = new Map<
|
||||
function createResourceReader<T>(promise: Promise<T>) {
|
||||
let result: T | null | undefined;
|
||||
|
||||
const suspender = (async () => {
|
||||
const settled = (async () => {
|
||||
try {
|
||||
result = await promise;
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
return {
|
||||
read() {
|
||||
if (result === undefined) {
|
||||
throw suspender;
|
||||
throw settled;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
load() {
|
||||
return settled;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +69,12 @@ export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
|
||||
|
||||
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
|
||||
|
||||
export type AdaptiveVisitorAsyncContextValue = () => Promise<AdaptiveVisitorClaims | null>;
|
||||
|
||||
const AdaptiveVisitorAsyncContext = createContext<AdaptiveVisitorAsyncContextValue>(
|
||||
async () => null
|
||||
);
|
||||
|
||||
/**
|
||||
* Provide context to adapt site based on visitor claims.
|
||||
*/
|
||||
@@ -83,11 +93,22 @@ export function AdaptiveVisitorContextProvider(
|
||||
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
|
||||
}, [visitorClaimsURL, contextId]);
|
||||
|
||||
const loadAdaptiveVisitorClaims = React.useCallback(async () => {
|
||||
if (!contextId) {
|
||||
return null;
|
||||
}
|
||||
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).load();
|
||||
}, [visitorClaimsURL, contextId]);
|
||||
|
||||
return (
|
||||
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
|
||||
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
|
||||
{children}
|
||||
</OpenAPIPrefillContextProvider>
|
||||
<AdaptiveVisitorAsyncContext.Provider value={loadAdaptiveVisitorClaims}>
|
||||
<OpenAPIPrefillContextProvider
|
||||
getPrefillInputContextData={getAdaptiveVisitorClaims}
|
||||
>
|
||||
{children}
|
||||
</OpenAPIPrefillContextProvider>
|
||||
</AdaptiveVisitorAsyncContext.Provider>
|
||||
</AdaptiveVisitorContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -98,3 +119,10 @@ export function AdaptiveVisitorContextProvider(
|
||||
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
|
||||
return useContext(AdaptiveVisitorContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns an async getter for adaptive visitor claims data, for use outside of render.
|
||||
*/
|
||||
export function useAdaptiveVisitorAsync(): AdaptiveVisitorAsyncContextValue {
|
||||
return useContext(AdaptiveVisitorAsyncContext);
|
||||
}
|
||||
|
||||
+9
-10
@@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
|
||||
import type { WebframePageContext } from './adaptive';
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useAdaptiveVisitorAsync } from '@/components/Adaptive';
|
||||
import { NavigationStatusContext } from '@/components/hooks';
|
||||
import { type GitBookLinker, createLinker } from '@/lib/links';
|
||||
|
||||
@@ -40,7 +40,12 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
|
||||
const router = useRouter();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const loadAdaptiveVisitorClaims = useAdaptiveVisitorAsync();
|
||||
|
||||
const getVisitorContext = React.useCallback(async () => {
|
||||
const visitorClaims = await loadAdaptiveVisitorClaims();
|
||||
return { visitor: visitorClaims?.visitor ?? null };
|
||||
}, [loadAdaptiveVisitorClaims]);
|
||||
|
||||
// Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest
|
||||
// of the app, instead of duplicating the join logic here.
|
||||
@@ -55,15 +60,9 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
},
|
||||
[onNavigationClick, router]
|
||||
);
|
||||
// Read during render (Suspense) only when the integration is allowed visitor claims, so that
|
||||
// webframes that don't use visitor claims don't suspend on the visitor-claims fetch.
|
||||
const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null;
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: canAccessVisitorClaims
|
||||
? () => ({ visitor: visitorClaims?.visitor ?? null })
|
||||
: undefined,
|
||||
getVisitorContext: canAccessVisitorClaims ? getVisitorContext : undefined,
|
||||
getPageContext: page ? () => ({ page }) : undefined,
|
||||
navigate: ({ path, anchor, query }) => {
|
||||
// Resolve the requested path relative to the site root so a webframe can navigate
|
||||
@@ -78,7 +77,7 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
navigateTo(linker.toPathInSite(path) + search + hash);
|
||||
},
|
||||
}),
|
||||
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
|
||||
[canAccessVisitorClaims, getVisitorContext, page, linker, navigateTo]
|
||||
);
|
||||
|
||||
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
import { ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
|
||||
import { getWebframePageContext } from './adaptive';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import './contentkit.css';
|
||||
import {
|
||||
@@ -74,16 +74,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
return null;
|
||||
}
|
||||
|
||||
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||
|
||||
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
|
||||
const page = getWebframePageContext(context.contentContext);
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
|
||||
// exposes the current page, plus visitor claims when the integration is allowed them.
|
||||
const useClientContext = containsWebframe;
|
||||
|
||||
const contentKitProps = {
|
||||
renderContext: {
|
||||
integrationName: block.data.integration,
|
||||
@@ -105,20 +100,14 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
{useClientContext ? (
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
page={page}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitWithClientContext>
|
||||
) : (
|
||||
<ContentKit {...contentKitProps}>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKit>
|
||||
)}
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
page={page}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitWithClientContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
|
||||
|
||||
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
|
||||
import { getWebframePageContext } from './adaptive';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
|
||||
const webframe: ContentKitWebFrame = {
|
||||
type: 'webframe',
|
||||
source: { url: 'https://integrations.gitbook.com/frame' },
|
||||
};
|
||||
|
||||
function elementOutput(element: unknown): ContentKitRenderOutput {
|
||||
return {
|
||||
type: 'element',
|
||||
element,
|
||||
state: {},
|
||||
props: {},
|
||||
} as ContentKitRenderOutput;
|
||||
}
|
||||
|
||||
describe('integrationBlockContainsWebframe', () => {
|
||||
it('returns false for a completed output', () => {
|
||||
expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there is no webframe in the tree', () => {
|
||||
const output = elementOutput({
|
||||
type: 'block',
|
||||
children: [{ type: 'text', text: 'hello' }],
|
||||
} as never);
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a webframe is nested in the tree', () => {
|
||||
const output = elementOutput({
|
||||
type: 'block',
|
||||
children: [{ type: 'vstack', children: [webframe] }],
|
||||
} as never);
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebframePageContext', () => {
|
||||
it('returns null when the context has no page', () => {
|
||||
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import type {
|
||||
ContentKitDescendantElement,
|
||||
ContentKitRenderOutput,
|
||||
ContentKitRootElement,
|
||||
ContentKitStepper,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
|
||||
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
|
||||
|
||||
/**
|
||||
* Current page exposed to a webframe through the client-only webframe state.
|
||||
*/
|
||||
@@ -19,18 +10,6 @@ export type WebframePageContext = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an integration block's output contains a webframe that can consume client-only context
|
||||
* (navigation, visitor claims and/or the current page).
|
||||
*/
|
||||
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
|
||||
if (output.type === 'complete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return doesContentKitElementContainWebframe(output.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current page to expose to a webframe, or `null` when it is unknown
|
||||
* (e.g. a non-page context, or reusable content resolved from another source).
|
||||
@@ -55,46 +34,3 @@ export function getWebframePageContext(
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a ContentKit element tree contains a webframe element.
|
||||
*/
|
||||
function doesContentKitElementContainWebframe(element: ContentKitElement): boolean {
|
||||
switch (element.type) {
|
||||
case 'webframe':
|
||||
return true;
|
||||
case 'block':
|
||||
case 'box':
|
||||
case 'hstack':
|
||||
case 'vstack':
|
||||
case 'step':
|
||||
case 'modal':
|
||||
case 'configuration':
|
||||
case 'stepper':
|
||||
case 'card':
|
||||
return doesContentKitElementArrayContainWebframe(element.children);
|
||||
case 'codeblock':
|
||||
return (
|
||||
doesContentKitElementArrayContainWebframe(element.header) ||
|
||||
doesContentKitElementArrayContainWebframe(element.footer)
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function doesContentKitElementArrayContainWebframe(elements: unknown): boolean {
|
||||
if (!Array.isArray(elements)) {
|
||||
return doesContentKitElementContainWebframeValue(elements);
|
||||
}
|
||||
|
||||
return elements.some(doesContentKitElementContainWebframeValue);
|
||||
}
|
||||
|
||||
function doesContentKitElementContainWebframeValue(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null || !('type' in value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return doesContentKitElementContainWebframe(value as ContentKitElement);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
} from './search';
|
||||
import { shouldShowTableSearch } from './shouldShowSearch';
|
||||
import { StickyViewGrid } from './StickyViewGrid';
|
||||
import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
|
||||
import {
|
||||
TableSearchEmpty,
|
||||
TableSearchInput,
|
||||
TableSearchProvider,
|
||||
TableSelectionFilter,
|
||||
} from './TableSearch';
|
||||
import { ViewCards } from './ViewCards';
|
||||
import { ViewGrid, ViewGridHeader } from './ViewGrid';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -48,9 +53,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
searchOverride: block.data.search,
|
||||
isPrint: context.mode === 'print',
|
||||
});
|
||||
const searchRecords = showSearch
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
const selectColumns = getTableSelectColumns(block);
|
||||
// Also needed when the search bar is hidden: a reader's content selection can narrow a select
|
||||
// column from a tab or picker elsewhere on the page, and without records there is nothing to
|
||||
// match against. A table with no select column can never be narrowed that way, so it skips.
|
||||
const searchRecords =
|
||||
showSearch || selectColumns.length > 0
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
const cellMergeLayout = createTableCellMergeLayout(
|
||||
block,
|
||||
records.map(([recordId]) => recordId)
|
||||
@@ -59,6 +69,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
return (
|
||||
<TableSearchProvider
|
||||
records={searchRecords}
|
||||
selectColumns={selectColumns}
|
||||
recordGroups={
|
||||
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
|
||||
}
|
||||
@@ -66,10 +77,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
<div className={tcls(style, 'flex flex-col gap-3')}>
|
||||
{showSearch ? (
|
||||
<TableSearchInput
|
||||
selectColumns={getTableSelectColumns(block)}
|
||||
selectColumns={selectColumns}
|
||||
checkboxColumns={getTableCheckboxColumns(block)}
|
||||
/>
|
||||
) : null}
|
||||
{/* Sits under the filter control it relates to, and tight against the search bar,
|
||||
so it reads as part of the filter controls rather than a caption on the table.
|
||||
Standalone — cards, short grids — there is no control to sit under. */}
|
||||
<TableSelectionFilter className={showSearch ? '-mt-1.5 justify-end' : undefined} />
|
||||
<TableView
|
||||
{...props}
|
||||
isOffscreen={isOffscreen}
|
||||
|
||||
@@ -10,8 +10,16 @@ import {
|
||||
type TableSearchRecordData,
|
||||
getVisibleTableRecordIds,
|
||||
} from './searchMatch';
|
||||
import {
|
||||
type SlugFilterEntry,
|
||||
getAppliedSlugFilter,
|
||||
reconcileSelectedOptions,
|
||||
resolveSlugFilter,
|
||||
slugFilterKey,
|
||||
} from './slugFilter';
|
||||
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { selectStore } from '@/lib/select';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,13 @@ type TableSearchContextValue = {
|
||||
visibleIds: ReadonlySet<string> | null;
|
||||
/** True when there are records but the active filters match none of them. */
|
||||
isEmpty: boolean;
|
||||
/**
|
||||
* Select columns the reader's content selection narrowed and the reader has left alone.
|
||||
* A column they have since filtered themselves drops out, since the table no longer shows it.
|
||||
*/
|
||||
slugFilter: SlugFilterEntry[];
|
||||
/** Drop the selection driving {@link slugFilter}, site-wide. The only thing that clears one. */
|
||||
clearSlugFilter: () => void;
|
||||
};
|
||||
|
||||
const TableSearchContext = React.createContext<TableSearchContextValue | null>(null);
|
||||
@@ -47,16 +62,32 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
|
||||
export function TableSearchProvider(props: {
|
||||
records?: TableSearchRecordData[];
|
||||
recordGroups?: readonly (readonly string[])[];
|
||||
/** Select columns of this table, so the reader's content selection can narrow them. */
|
||||
selectColumns?: TableSelectColumn[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { records = [], recordGroups = [] } = props;
|
||||
const { records = [], recordGroups = [], selectColumns = [] } = props;
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
|
||||
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
// The selection that drives this lives outside the table — a tab, a select button or a picker
|
||||
// elsewhere on the page — so this synchronises with it rather than deriving from it.
|
||||
const slugFilter = useSlugFilter(selectColumns);
|
||||
|
||||
// Columns the selection narrowed last time round. Kept so a new selection — or clearing it —
|
||||
// undoes the previous one, rather than leaving a filter the reader can no longer account for.
|
||||
const narrowedColumns = React.useRef<string[]>([]);
|
||||
|
||||
const toggleOption = React.useCallback((column: string, value: string) => {
|
||||
// The reader is taking this column over, so the selection no longer owns it: a later clear
|
||||
// must leave their choice of options alone. Their change stays local — the filter is this
|
||||
// table's, for this visit, while the selection is site-wide and persists, so only the
|
||||
// clear beside the notice touches it.
|
||||
narrowedColumns.current = narrowedColumns.current.filter((narrowed) => narrowed !== column);
|
||||
|
||||
setSelectedOptions((previous) => {
|
||||
const values = new Set(previous[column]);
|
||||
if (values.has(value)) {
|
||||
@@ -87,6 +118,33 @@ export function TableSearchProvider(props: {
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const previouslyNarrowed = narrowedColumns.current;
|
||||
narrowedColumns.current = slugFilter.map((entry) => entry.column);
|
||||
|
||||
setSelectedOptions((previous) =>
|
||||
reconcileSelectedOptions(previous, previouslyNarrowed, slugFilter)
|
||||
);
|
||||
}, [slugFilter]);
|
||||
|
||||
// What the notice may speak for: the columns the selection narrowed and the reader has left
|
||||
// alone. A column they have since filtered themselves still has an active slug, but the table
|
||||
// is no longer showing it, so the notice must not claim it.
|
||||
const appliedSlugFilter = React.useMemo(
|
||||
() => getAppliedSlugFilter(slugFilter, selectedOptions),
|
||||
[slugFilter, selectedOptions]
|
||||
);
|
||||
|
||||
// Clearing goes through the store rather than local state: the selection is what persists, so
|
||||
// only dropping it there stops the filter coming back on the next load. It is site-wide, so a
|
||||
// tab elsewhere on the page reverts to its default too. This is the only thing that clears a
|
||||
// selection — changing the filter never does.
|
||||
const clearSlugFilter = React.useCallback(() => {
|
||||
for (const entry of appliedSlugFilter) {
|
||||
selectStore.deactivate(entry.slug);
|
||||
}
|
||||
}, [appliedSlugFilter]);
|
||||
|
||||
// Match every record once, here, rather than in each row — rows just look themselves up by id.
|
||||
const visibleIds = React.useMemo(
|
||||
() =>
|
||||
@@ -112,8 +170,20 @@ export function TableSearchProvider(props: {
|
||||
toggleCheckbox,
|
||||
visibleIds,
|
||||
isEmpty,
|
||||
slugFilter: appliedSlugFilter,
|
||||
clearSlugFilter,
|
||||
}),
|
||||
[query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
|
||||
[
|
||||
query,
|
||||
selectedOptions,
|
||||
toggleOption,
|
||||
checkedColumns,
|
||||
toggleCheckbox,
|
||||
visibleIds,
|
||||
isEmpty,
|
||||
appliedSlugFilter,
|
||||
clearSlugFilter,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -121,6 +191,40 @@ export function TableSearchProvider(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's selection, reduced to the columns of *this* table.
|
||||
*
|
||||
* Subscribes once and returns a string rather than an object: `useSyncExternalStore` compares
|
||||
* snapshots by identity, so a fresh object each call would loop. It also means a selection that
|
||||
* changes nothing for this table re-renders nothing — the reason `useSelect` stopped exposing the
|
||||
* recency list in the first place.
|
||||
*/
|
||||
function useSlugFilter(selectColumns: TableSelectColumn[]): SlugFilterEntry[] {
|
||||
const columnsKey = selectColumns
|
||||
.map(
|
||||
(column) =>
|
||||
`${column.id}:${column.options.map((option) => `${option.value}=${option.label}`).join('|')}`
|
||||
)
|
||||
.join(';');
|
||||
|
||||
const getKey = React.useCallback(
|
||||
() => slugFilterKey(resolveSlugFilter(selectColumns, selectStore.getState().slugs)),
|
||||
// `selectColumns` is a fresh array each render; its contents are what matter.
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
[columnsKey]
|
||||
);
|
||||
|
||||
const filterKey = React.useSyncExternalStore(selectStore.subscribe, getKey, getKey);
|
||||
|
||||
// The key is only an identity: rebuild the filter itself when it moves, rather than parsing the
|
||||
// key back apart, since an option value can be any string an import gave it.
|
||||
return React.useMemo(
|
||||
() => resolveSlugFilter(selectColumns, selectStore.getState().slugs),
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
[filterKey]
|
||||
);
|
||||
}
|
||||
|
||||
function useTableSearch(): TableSearchContextValue {
|
||||
const context = React.useContext(TableSearchContext);
|
||||
if (!context) {
|
||||
@@ -193,6 +297,49 @@ export function TableSearchEmpty(props: { className?: ClassValue }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the selection narrowing this table, and lets the reader drop it.
|
||||
*
|
||||
* Deliberately worded around the *selection* rather than the filter: the column dropdown beside it
|
||||
* shows the same column as active, but clearing there only resets local state and the filter returns
|
||||
* on the next load. This is the control that actually undoes it.
|
||||
*
|
||||
* Rendered independently of the search bar. `shouldShowTableSearch` leaves the filter controls off
|
||||
* cards, off grids below the row threshold, and off any table whose author turned search off — and
|
||||
* in every one of those a narrowed table would otherwise just read as missing rows.
|
||||
*/
|
||||
export function TableSelectionFilter(props: { className?: ClassValue }) {
|
||||
const language = useLanguage();
|
||||
const { slugFilter, clearSlugFilter } = useTableSearch();
|
||||
|
||||
if (slugFilter.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls('flex flex-wrap items-center gap-2 text-sm text-tint', props.className)}
|
||||
>
|
||||
<Icon icon="filter" className="size-3 shrink-0" />
|
||||
<span>
|
||||
{tString(
|
||||
language,
|
||||
'table_filtered_by_selection',
|
||||
slugFilter.map((entry) => entry.label).join(', ')
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="xsmall"
|
||||
icon="xmark"
|
||||
iconOnly
|
||||
label={tString(language, 'table_clear_selection')}
|
||||
onClick={clearSlugFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank multi-select dropdown button for a single select column. Becomes `active` while any
|
||||
* of its options are selected.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { TableSelectColumn } from './search';
|
||||
import {
|
||||
getAppliedSlugFilter,
|
||||
getOptionSlug,
|
||||
reconcileSelectedOptions,
|
||||
resolveSlugFilter,
|
||||
slugFilterKey,
|
||||
} from './slugFilter';
|
||||
|
||||
/**
|
||||
* A select column as the editor writes one: each option carries an opaque generated `value` and the
|
||||
* author's wording as its `label`. Tests must not conflate the two — matching on `value` looks
|
||||
* right against fixtures that reuse the label and matches nothing against real content.
|
||||
*/
|
||||
function column(id: string, labels: string[]): TableSelectColumn {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
options: labels.map((label, index) => ({
|
||||
value: `${id}-key-${index}`,
|
||||
label,
|
||||
color: 'blue',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The opaque value of the option an author labelled `label`. */
|
||||
function valueOf(column: TableSelectColumn, label: string): string {
|
||||
const option = column.options.find((option) => option.label === label);
|
||||
if (!option) {
|
||||
throw new Error(`no option labelled ${label}`);
|
||||
}
|
||||
return option.value;
|
||||
}
|
||||
|
||||
describe('resolveSlugFilter', () => {
|
||||
const platform = column('platform', ['macOS', 'Windows', 'Linux']);
|
||||
const status = column('status', ['Done', 'To do']);
|
||||
|
||||
it('matches the option label, and resolves to its opaque value', () => {
|
||||
expect(resolveSlugFilter([platform], ['macos'])).toEqual([
|
||||
{
|
||||
column: 'platform',
|
||||
value: valueOf(platform, 'macOS'),
|
||||
label: 'macOS',
|
||||
slug: 'macos',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the label and slug needed to show and clear the filter', () => {
|
||||
// The reader is told what narrowed the table, and the slug is what clearing deactivates.
|
||||
const [entry] = resolveSlugFilter([platform], ['windows']);
|
||||
expect(entry?.label).toBe('Windows');
|
||||
expect(entry?.slug).toBe('windows');
|
||||
});
|
||||
|
||||
it('never matches the opaque value itself', () => {
|
||||
expect(resolveSlugFilter([platform], [valueOf(platform, 'macOS')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('slugifies the label the way every other select surface does', () => {
|
||||
const languages = column('language', ['Node.js', 'C++', 'Windows 10']);
|
||||
expect(resolveSlugFilter([languages], ['node.js'])[0]?.label).toBe('Node.js');
|
||||
expect(resolveSlugFilter([languages], ['c++'])[0]?.label).toBe('C++');
|
||||
expect(resolveSlugFilter([languages], ['windows-10'])[0]?.label).toBe('Windows 10');
|
||||
});
|
||||
|
||||
it('falls back to the value when an option has no label', () => {
|
||||
const legacy: TableSelectColumn = {
|
||||
id: 'legacy',
|
||||
label: 'legacy',
|
||||
options: [{ value: 'macos', label: '', color: 'blue' }],
|
||||
};
|
||||
expect(resolveSlugFilter([legacy], ['macos'])).toEqual([
|
||||
{ column: 'legacy', value: 'macos', label: 'macos', slug: 'macos' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a column the selection says nothing about', () => {
|
||||
const entries = resolveSlugFilter([platform, status], ['macos']);
|
||||
expect(entries.map((entry) => entry.column)).toEqual(['platform']);
|
||||
});
|
||||
|
||||
it('narrows several columns at once when the selection covers both', () => {
|
||||
const entries = resolveSlugFilter([platform, status], ['macos', 'done']);
|
||||
expect(entries.map((entry) => entry.value)).toEqual([
|
||||
valueOf(platform, 'macOS'),
|
||||
valueOf(status, 'Done'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the most recently activated when a column offers several active options', () => {
|
||||
// Most-recent-first, so `windows` wins over `macos` — the rule tabs resolve with.
|
||||
expect(resolveSlugFilter([platform], ['windows', 'macos'])[0]?.value).toBe(
|
||||
valueOf(platform, 'Windows')
|
||||
);
|
||||
expect(resolveSlugFilter([platform], ['macos', 'windows'])[0]?.value).toBe(
|
||||
valueOf(platform, 'macOS')
|
||||
);
|
||||
});
|
||||
|
||||
it('filters nothing when the selection matches no column', () => {
|
||||
expect(resolveSlugFilter([platform, status], ['python'])).toEqual([]);
|
||||
expect(resolveSlugFilter([platform], [])).toEqual([]);
|
||||
expect(resolveSlugFilter([], ['macos'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptionSlug', () => {
|
||||
// Shared by the matcher and by the write-back that moves the selection when a reader changes a
|
||||
// governed column, so the two can never disagree about what an option answers to.
|
||||
it('slugifies the label, and falls back to the value', () => {
|
||||
expect(getOptionSlug({ value: 'key-0', label: 'macOS' })).toBe('macos');
|
||||
expect(getOptionSlug({ value: 'key-1', label: 'Windows 10' })).toBe('windows-10');
|
||||
expect(getOptionSlug({ value: 'macos', label: '' })).toBe('macos');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileSelectedOptions', () => {
|
||||
const entry = (column: string, value: string) => ({
|
||||
column,
|
||||
value,
|
||||
label: value,
|
||||
slug: value,
|
||||
});
|
||||
|
||||
it('drops the column when the selection is cleared', () => {
|
||||
const previous = { platform: new Set(['macos-value']) };
|
||||
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves filters the reader set themselves', () => {
|
||||
const previous = {
|
||||
platform: new Set(['macos-value']),
|
||||
status: new Set(['done-value']),
|
||||
};
|
||||
expect(reconcileSelectedOptions(previous, ['platform'], [])).toEqual({
|
||||
status: new Set(['done-value']),
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the previous selection rather than adding to it', () => {
|
||||
const previous = { platform: new Set(['macos-value']) };
|
||||
expect(
|
||||
reconcileSelectedOptions(previous, ['platform'], [entry('platform', 'windows-value')])
|
||||
).toEqual({ platform: new Set(['windows-value']) });
|
||||
});
|
||||
|
||||
it('gives the same answer however many times it is applied', () => {
|
||||
// React may invoke a state updater more than once with the same input. An earlier version
|
||||
// tracked the narrowed columns inside the updater, so the second pass saw them already
|
||||
// cleared, took the early return and handed back the *unchanged* state — silently undoing
|
||||
// a clear while leaving an apply working, which is exactly how it presented.
|
||||
const previous = { platform: new Set(['macos-value']) };
|
||||
const once = reconcileSelectedOptions(previous, ['platform'], []);
|
||||
const twice = reconcileSelectedOptions(previous, ['platform'], []);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it('is a no-op when there is nothing to narrow and nothing to undo', () => {
|
||||
const previous = { status: new Set(['done-value']) };
|
||||
expect(reconcileSelectedOptions(previous, [], [])).toBe(previous);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppliedSlugFilter', () => {
|
||||
const macos = { column: 'platform', value: 'macos-value', label: 'macOS', slug: 'macos' };
|
||||
|
||||
it('speaks for a column the reader has left alone', () => {
|
||||
const selected = { platform: new Set(['macos-value']) };
|
||||
expect(getAppliedSlugFilter([macos], selected)).toEqual([macos]);
|
||||
});
|
||||
|
||||
it('drops a column the reader has filtered to something else', () => {
|
||||
// Changing the filter deliberately leaves the selection active, so the slug is still there;
|
||||
// the notice just must not claim a match the table is no longer showing.
|
||||
const selected = { platform: new Set(['windows-value']) };
|
||||
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a column the reader has widened to several options', () => {
|
||||
const selected = { platform: new Set(['macos-value', 'windows-value']) };
|
||||
expect(getAppliedSlugFilter([macos], selected)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a column the reader has cleared', () => {
|
||||
expect(getAppliedSlugFilter([macos], {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugFilterKey', () => {
|
||||
const entry = (column: string, value: string) => ({
|
||||
column,
|
||||
value,
|
||||
label: value,
|
||||
slug: value,
|
||||
});
|
||||
|
||||
it('is stable whatever order the columns resolve in', () => {
|
||||
expect(slugFilterKey([entry('platform', 'macos'), entry('status', 'done')])).toBe(
|
||||
slugFilterKey([entry('status', 'done'), entry('platform', 'macos')])
|
||||
);
|
||||
});
|
||||
|
||||
it('changes when the selection moves', () => {
|
||||
expect(slugFilterKey([entry('platform', 'macos')])).not.toBe(
|
||||
slugFilterKey([entry('platform', 'linux')])
|
||||
);
|
||||
});
|
||||
|
||||
it('is empty when nothing is filtered', () => {
|
||||
expect(slugFilterKey([])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { TableSelectColumn } from './search';
|
||||
import type { SelectedOptions } from './searchMatch';
|
||||
import { slugifySelectValue } from '@/lib/select';
|
||||
|
||||
/** One select column narrowed by the reader's content selection. */
|
||||
export interface SlugFilterEntry {
|
||||
/** Id of the select column being narrowed. */
|
||||
column: string;
|
||||
/** The option's opaque value, which the record matcher compares against. */
|
||||
value: string;
|
||||
/** The option's author-typed label, shown to the reader. */
|
||||
label: string;
|
||||
/** The active slug that narrowed the column, so the reader can clear it again. */
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `select` slug an option answers to.
|
||||
*
|
||||
* A table option's `value` is an opaque generated key, so it is the author-typed `label` that names
|
||||
* it — the same wording a tab title or select button would carry, put through the same slugifier so
|
||||
* a "macOS" column option and a "macOS" tab resolve to the one slug. Options with no label fall back
|
||||
* to the raw value, mirroring how a cell renders one.
|
||||
*/
|
||||
export function getOptionSlug(option: { value: string; label: string }): string {
|
||||
return slugifySelectValue(option.label || option.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which option of each select column the reader's current selection points at.
|
||||
*
|
||||
* A table's select column declares its own option set, so a slug only filters a column that
|
||||
* actually offers it: a reader who picked `macos` narrows a Platform column to macOS and leaves a
|
||||
* Status column alone. Where several of a column's options are active at once, the most recently
|
||||
* activated wins — the same rule tabs resolve with, so a table and the tabs beside it agree.
|
||||
*/
|
||||
export function resolveSlugFilter(
|
||||
columns: TableSelectColumn[],
|
||||
slugs: string[]
|
||||
): SlugFilterEntry[] {
|
||||
const entries: SlugFilterEntry[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
let best: SlugFilterEntry | undefined;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const option of column.options) {
|
||||
const slug = getOptionSlug(option);
|
||||
if (!slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rank = slugs.indexOf(slug);
|
||||
if (rank >= 0 && rank < bestRank) {
|
||||
bestRank = rank;
|
||||
best = {
|
||||
column: column.id,
|
||||
value: option.value,
|
||||
label: option.label || option.value,
|
||||
slug,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
entries.push(best);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Stable identity for a filter, so it is only rebuilt when the selection actually moves. */
|
||||
export function slugFilterKey(entries: SlugFilterEntry[]): string {
|
||||
return entries
|
||||
.map((entry) => `${entry.column}=${entry.value}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the columns the selection narrows into the reader's own filters.
|
||||
*
|
||||
* Kept pure, and given the previously narrowed columns rather than reading them from a ref, so it
|
||||
* can be applied more than once without changing the answer — React may invoke a state updater
|
||||
* twice, and an earlier version tracked those columns inside the updater itself, which silently
|
||||
* undid a clear on the second pass.
|
||||
*
|
||||
* Only columns the selection narrowed last time are dropped; anything the reader filtered by hand
|
||||
* is left exactly as it was.
|
||||
*/
|
||||
export function reconcileSelectedOptions(
|
||||
previous: SelectedOptions,
|
||||
previouslyNarrowed: readonly string[],
|
||||
slugFilter: readonly SlugFilterEntry[]
|
||||
): SelectedOptions {
|
||||
if (previouslyNarrowed.length === 0 && slugFilter.length === 0) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = { ...previous };
|
||||
for (const column of previouslyNarrowed) {
|
||||
delete next[column];
|
||||
}
|
||||
for (const entry of slugFilter) {
|
||||
next[entry.column] = new Set([entry.value]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the filter to the columns the table is *still* showing.
|
||||
*
|
||||
* A reader can change any of these columns by hand, and that deliberately leaves the selection
|
||||
* alone — the filter is this table's, for this visit, while the selection is site-wide and
|
||||
* persists. So the notice has to stop speaking for a column that no longer matches, rather than
|
||||
* describing a view the reader has since changed.
|
||||
*/
|
||||
export function getAppliedSlugFilter(
|
||||
slugFilter: readonly SlugFilterEntry[],
|
||||
selectedOptions: SelectedOptions
|
||||
): SlugFilterEntry[] {
|
||||
return slugFilter.filter((entry) => {
|
||||
const values = selectedOptions[entry.column];
|
||||
return values?.size === 1 && values.has(entry.value);
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,18 @@ import { Icon } from '@gitbook/icons';
|
||||
import { TagIcon } from '../Tag';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
const UPDATES_FILTER_SEARCH_PARAM = 'tag';
|
||||
import {
|
||||
UPDATES_FILTER_KEY_ATTR,
|
||||
UPDATES_FILTER_SEARCH_PARAM,
|
||||
UPDATES_TAG_ATTR,
|
||||
UPDATES_TAG_CHIP_SELECTED_ATTR,
|
||||
UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR,
|
||||
UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR,
|
||||
UPDATES_TAG_CLEAR_ATTR,
|
||||
UPDATES_TAG_FILTER_ATTR,
|
||||
normalizeUpdatesFilterTags,
|
||||
updatesFilterStyleKey,
|
||||
} from '@/lib/updates';
|
||||
|
||||
type UpdatesFilterContextValue = {
|
||||
selectedTags: string[];
|
||||
@@ -36,21 +46,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
|
||||
const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]);
|
||||
|
||||
const sanitizeTags = React.useCallback(
|
||||
(tags: string[]) => {
|
||||
const next: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!availableTags.has(tag) || seen.has(tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
next.push(tag);
|
||||
seen.add(tag);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
(tags: string[]) => normalizeUpdatesFilterTags(tags, availableTags),
|
||||
[availableTags]
|
||||
);
|
||||
|
||||
@@ -63,8 +59,20 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
|
||||
() => sanitizeTags(rawSelectedTags),
|
||||
[sanitizeTags, rawSelectedTags]
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags);
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const selectedTagsRef = React.useRef(selectedTags);
|
||||
const styleKey = React.useMemo(() => updatesFilterStyleKey(tagSlugs), [tagSlugs]);
|
||||
|
||||
// Clean up on unmount so a nav to a page with no filterable updates doesn't leave a stale
|
||||
// stylesheet hiding everything (see UPDATES_FILTER_KEY_ATTR).
|
||||
React.useLayoutEffect(() => {
|
||||
document.documentElement.setAttribute(UPDATES_FILTER_KEY_ATTR, styleKey);
|
||||
|
||||
return () => {
|
||||
document.documentElement.removeAttribute(UPDATES_FILTER_KEY_ATTR);
|
||||
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
|
||||
};
|
||||
}, [styleKey]);
|
||||
|
||||
const replaceTags = React.useCallback(
|
||||
(nextTags: string[]) => {
|
||||
@@ -88,7 +96,10 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
|
||||
}
|
||||
}, [rawSelectedTags, replaceTags, urlSelectedTags]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Layout effect so the filter attribute is applied before paint, avoiding a flash in the chip UI.
|
||||
React.useLayoutEffect(() => {
|
||||
applyTagFilterAttribute(urlSelectedTags);
|
||||
|
||||
if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) {
|
||||
return;
|
||||
}
|
||||
@@ -104,6 +115,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
|
||||
|
||||
selectedTagsRef.current = nextTags;
|
||||
setSelectedTags(nextTags);
|
||||
applyTagFilterAttribute(nextTags);
|
||||
replaceTags(nextTags);
|
||||
},
|
||||
[replaceTags, sanitizeTags]
|
||||
@@ -152,8 +164,7 @@ export function UpdatesTagFilters(props: {
|
||||
clearLabel: string;
|
||||
}) {
|
||||
const { tags, tagsLabel, clearLabel } = props;
|
||||
const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter();
|
||||
const isFiltering = selectedTags.length > 0;
|
||||
const { toggleTag, clearTags } = useUpdatesFilter();
|
||||
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
@@ -166,64 +177,105 @@ export function UpdatesTagFilters(props: {
|
||||
<Icon icon="tags" className="size-3" />
|
||||
{tagsLabel}
|
||||
</div>
|
||||
{/* Visible/clickable only while a filter is active — see generateUpdatesFilterCSS. */}
|
||||
<Button
|
||||
variant="blank"
|
||||
size="xsmall"
|
||||
icon="xmark"
|
||||
label={clearLabel}
|
||||
onClick={isFiltering ? clearTags : undefined}
|
||||
aria-hidden={!isFiltering}
|
||||
tabIndex={isFiltering ? undefined : -1}
|
||||
className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')}
|
||||
onClick={clearTags}
|
||||
{...{ [UPDATES_TAG_CLEAR_ATTR]: '' }}
|
||||
className="pointer-events-none invisible text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 px-3">
|
||||
{tags.map((tag) => {
|
||||
const selected = selectedTagSet.has(tag.slug);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag.slug}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleTag(tag.slug)}
|
||||
className={tcls(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors',
|
||||
'circular-corners:rounded-2xl straight-corners:rounded-xs',
|
||||
'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary',
|
||||
selected
|
||||
? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
|
||||
: 'bg-tint-5 text-tint-strong hover:bg-tint-hover',
|
||||
isFiltering && !selected && 'opacity-8 hover:opacity-11'
|
||||
)}
|
||||
>
|
||||
<TagIcon tag={tag} />
|
||||
<span className="truncate">{tag.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{tags.map((tag) => (
|
||||
<TagChip key={tag.slug} tag={tag} onToggle={toggleTag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CHIP_CLASS =
|
||||
'inline-flex max-w-full rounded-full circular-corners:rounded-2xl straight-corners:rounded-xs not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary';
|
||||
|
||||
const CHIP_VARIANT_CLASS =
|
||||
'max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors circular-corners:rounded-2xl straight-corners:rounded-xs';
|
||||
|
||||
function TagChip(props: { tag: RevisionTag; onToggle: (tag: string) => void }) {
|
||||
const { tag, onToggle } = props;
|
||||
const { selectedTagSet } = useUpdatesFilter();
|
||||
const onClick = () => onToggle(tag.slug);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selectedTagSet.has(tag.slug)}
|
||||
onClick={onClick}
|
||||
className={CHIP_CLASS}
|
||||
>
|
||||
<span
|
||||
{...{ [UPDATES_TAG_CHIP_SELECTED_ATTR]: tag.slug }}
|
||||
className={tcls(
|
||||
CHIP_VARIANT_CLASS,
|
||||
'hidden bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
|
||||
)}
|
||||
>
|
||||
<TagIcon tag={tag} />
|
||||
<span className="truncate">{tag.label}</span>
|
||||
</span>
|
||||
<span
|
||||
{...{ [UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR]: tag.slug }}
|
||||
className={tcls(
|
||||
CHIP_VARIANT_CLASS,
|
||||
'hidden bg-tint-5 text-tint-strong opacity-8 hover:bg-tint-hover hover:opacity-11'
|
||||
)}
|
||||
>
|
||||
<TagIcon tag={tag} />
|
||||
<span className="truncate">{tag.label}</span>
|
||||
</span>
|
||||
<span
|
||||
{...{ [UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR]: tag.slug }}
|
||||
className={tcls(
|
||||
CHIP_VARIANT_CLASS,
|
||||
'flex bg-tint-5 text-tint-strong hover:bg-tint-hover'
|
||||
)}
|
||||
>
|
||||
<TagIcon tag={tag} />
|
||||
<span className="truncate">{tag.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function areTagsEqual(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((tag, index) => tag === right[index]);
|
||||
}
|
||||
|
||||
/** Mirrors the active filter onto `<html>`, matching what the pre-paint script does on first load. */
|
||||
function applyTagFilterAttribute(tags: string[]) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
document.documentElement.setAttribute(UPDATES_TAG_FILTER_ATTR, tags.join(' '));
|
||||
} else {
|
||||
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
|
||||
}
|
||||
}
|
||||
|
||||
/** Visibility is driven purely by CSS against `data-update-tags` (see generateUpdatesFilterCSS). */
|
||||
export function FilteredUpdate(props: {
|
||||
tagSlugs: string[];
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { tagSlugs, className, children } = props;
|
||||
const { selectedTagSet } = useUpdatesFilter();
|
||||
|
||||
const isVisible =
|
||||
selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug));
|
||||
|
||||
return (
|
||||
<div className={className} hidden={!isVisible}>
|
||||
<div className={className} {...{ [UPDATES_TAG_ATTR]: tagSlugs.join(' ') }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { serializeUpdatesFilterScriptArgs } from './UpdatesFilterScript';
|
||||
|
||||
describe('serializeUpdatesFilterScriptArgs', () => {
|
||||
it('cannot terminate the surrounding script element', () => {
|
||||
const serialized = serializeUpdatesFilterScriptArgs([
|
||||
['</script><script>globalThis.injected = true</script>'],
|
||||
]);
|
||||
|
||||
expect(serialized).not.toContain('</script>');
|
||||
expect(serialized).toContain('\\u003c/script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
UPDATES_FILTER_KEY_ATTR,
|
||||
UPDATES_FILTER_SEARCH_PARAM,
|
||||
UPDATES_TAG_FILTER_ATTR,
|
||||
UPDATES_TAG_FILTER_CAP,
|
||||
updatesFilterStyleKey,
|
||||
} from '@/lib/updates';
|
||||
|
||||
/**
|
||||
* Mirrors the `?tag=` search params onto `<html>` as `data-updates-tag-filter`, before first paint,
|
||||
* so the generated CSS can filter with no flash. Also stamps the filter style key.
|
||||
*
|
||||
* NOTE: stringified and injected as an inline script — must be self-contained, touching only
|
||||
* `document.documentElement` and `window.location`.
|
||||
*/
|
||||
export function applyUpdatesFilterScript(
|
||||
searchParam: string,
|
||||
attribute: string,
|
||||
cap: number,
|
||||
availableTags: string[],
|
||||
keyAttribute: string,
|
||||
styleKey: string
|
||||
) {
|
||||
const el = document.documentElement;
|
||||
el.setAttribute(keyAttribute, styleKey);
|
||||
|
||||
try {
|
||||
const validTags = new Set(availableTags);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const seen = new Set<string>();
|
||||
const slugs: string[] = [];
|
||||
|
||||
for (const value of params.getAll(searchParam)) {
|
||||
const slug = value.trim();
|
||||
if (!slug || seen.has(slug) || slugs.length >= cap || !validTags.has(slug)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(slug);
|
||||
slugs.push(slug);
|
||||
}
|
||||
|
||||
if (slugs.length > 0) {
|
||||
el.setAttribute(attribute, slugs.join(' '));
|
||||
} else {
|
||||
el.removeAttribute(attribute);
|
||||
}
|
||||
} catch {
|
||||
// Malformed URL — fall through, so the generated CSS's default (show everything) applies.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script that applies the URL's `?tag=` filter to `<html>` before first paint. Rendered only
|
||||
* on pages with filterable updates (see SitePage), not globally.
|
||||
*/
|
||||
export function UpdatesFilterScript(props: { tagSlugs: string[] }) {
|
||||
const { tagSlugs } = props;
|
||||
const inserted = useRef(false);
|
||||
const scriptArgs = serializeUpdatesFilterScriptArgs([
|
||||
UPDATES_FILTER_SEARCH_PARAM,
|
||||
UPDATES_TAG_FILTER_ATTR,
|
||||
UPDATES_TAG_FILTER_CAP,
|
||||
tagSlugs,
|
||||
UPDATES_FILTER_KEY_ATTR,
|
||||
updatesFilterStyleKey(tagSlugs),
|
||||
]);
|
||||
|
||||
// Only needed for the initial document — client navs are handled by UpdatesFilterProvider.
|
||||
useServerInsertedHTML(() => {
|
||||
if (inserted.current) {
|
||||
return null;
|
||||
}
|
||||
inserted.current = true;
|
||||
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(${applyUpdatesFilterScript.toString()})(${scriptArgs})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Serialize arguments without allowing revision data to terminate the surrounding script tag. */
|
||||
export function serializeUpdatesFilterScriptArgs(args: unknown[]): string {
|
||||
return JSON.stringify(args).replaceAll('<', '\\u003c').slice(1, -1);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
@@ -7,6 +8,7 @@ import type { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import { useCurrentPage } from '../hooks';
|
||||
import { useIsVisible } from '../VisibilityContext';
|
||||
import { useTrackEvent } from './InsightsProvider';
|
||||
import { isGitBookInternalPath } from '@/lib/paths';
|
||||
|
||||
/**
|
||||
* Track a page view event.
|
||||
@@ -15,11 +17,14 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
|
||||
const { displayContext } = props;
|
||||
const page = useCurrentPage();
|
||||
const trackEvent = useTrackEvent();
|
||||
const pathname = usePathname();
|
||||
// Internal routes that 404 (e.g. `~gitbook/pdf` under an unpublished variant) aren't broken URLs.
|
||||
const isInternalNotFound = !page && isGitBookInternalPath(pathname ?? '');
|
||||
// Always true outside of the embed, whose frame can be loaded while hidden.
|
||||
const isVisible = useIsVisible();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isVisible) {
|
||||
if (!isVisible || isInternalNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,7 +37,7 @@ export function TrackPageViewEvent(props: { displayContext: SiteInsightsDisplayC
|
||||
displayContext,
|
||||
}
|
||||
);
|
||||
}, [page, trackEvent, displayContext, isVisible]);
|
||||
}, [page, trackEvent, displayContext, isVisible, isInternalNotFound]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useScrollActiveId } from '@/components/hooks';
|
||||
import { useBodyLoaded } from '@/components/primitives';
|
||||
import type { DocumentSection } from '@/lib/document-sections';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { UPDATES_TAG_SECTION_ATTR } from '@/lib/updates';
|
||||
|
||||
/**
|
||||
* The threshold at which we consider a section as intersecting the viewport.
|
||||
@@ -22,20 +23,18 @@ const ACTIVE_ITEM_OFFSET = 100;
|
||||
|
||||
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
|
||||
const { selectedTagSet } = useUpdatesFilter();
|
||||
const visibleSections = React.useMemo(() => {
|
||||
if (selectedTagSet.size === 0) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return sections.filter((section) => {
|
||||
if (section.tags === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug));
|
||||
});
|
||||
}, [sections, selectedTagSet]);
|
||||
const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]);
|
||||
const ids = React.useMemo(
|
||||
() =>
|
||||
sections
|
||||
.filter(
|
||||
(section) =>
|
||||
selectedTagSet.size === 0 ||
|
||||
section.tags === undefined ||
|
||||
section.tags.some((tagSlug) => selectedTagSet.has(tagSlug))
|
||||
)
|
||||
.map(({ id }) => id),
|
||||
[sections, selectedTagSet]
|
||||
);
|
||||
|
||||
const enabled = useBodyLoaded();
|
||||
|
||||
@@ -62,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
className="relative flex flex-col border-tint-subtle pb-5 sidebar-list-line:border-l"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
{visibleSections.map((section) => (
|
||||
{sections.map((section) => (
|
||||
<li
|
||||
key={section.id}
|
||||
className={tcls(
|
||||
@@ -76,6 +75,9 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
|
||||
)}
|
||||
ref={activeId === section.id ? activeItemRef : null}
|
||||
{...(section.tags !== undefined
|
||||
? { [UPDATES_TAG_SECTION_ATTR]: section.tags.join(' ') }
|
||||
: {})}
|
||||
>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { getLLMsTxtURL, getPageMarkdownURL } from '@/lib/llms-directive';
|
||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getPageRSSURL } from '@/routes/rss';
|
||||
|
||||
const LINK_PREVIEW_MAX_COUNT = 500;
|
||||
|
||||
@@ -86,6 +87,14 @@ export async function PageBody(props: {
|
||||
|
||||
return (
|
||||
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
|
||||
{contentHasUpdates ? (
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
href={getPageRSSURL(context, page)}
|
||||
title="RSS Feed"
|
||||
/>
|
||||
) : null}
|
||||
<main
|
||||
className={tcls(
|
||||
'relative min-w-0 flex-1',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type RevisionPageDocument,
|
||||
type SiteSection,
|
||||
type SiteSectionGroup,
|
||||
SiteVisibility,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
@@ -25,6 +24,7 @@ import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
|
||||
import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown';
|
||||
import { PageTags } from './PageTags';
|
||||
import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context';
|
||||
import { hasAdaptiveMcpEndpoint } from '@/lib/mcp/endpoints';
|
||||
import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages';
|
||||
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -559,12 +559,7 @@ function isPageActionEnabled(
|
||||
* Return the MCP URL to be used in the page actions dropdown.
|
||||
*/
|
||||
function getPageActionsMCPURL(context: GitBookSiteContext) {
|
||||
const useAuthenticatedEndpoint = Boolean(
|
||||
context.site.visibility !== SiteVisibility.VisitorAuth &&
|
||||
context.site.adaptiveContent?.enabled &&
|
||||
context.site.urls.login &&
|
||||
context.isLoggedInVisitor
|
||||
);
|
||||
const useAuthenticatedEndpoint = hasAdaptiveMcpEndpoint(context) && context.isLoggedInVisitor;
|
||||
const endpoint = useAuthenticatedEndpoint ? '~gitbook/mcp/auth' : '~gitbook/mcp';
|
||||
|
||||
return context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint));
|
||||
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
import { IconsProvider } from '@gitbook/icons';
|
||||
|
||||
import { PageContextProvider } from '../PageContext';
|
||||
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
|
||||
import {
|
||||
type PagePathParams,
|
||||
fetchPageData,
|
||||
getLowercasePathnameRedirect,
|
||||
getPathnameParam,
|
||||
} from './fetch';
|
||||
import { PageClientLayout } from './PageClientLayout';
|
||||
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
|
||||
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
@@ -33,8 +39,11 @@ import {
|
||||
resolveSiteSpaceCustomHomePage,
|
||||
} from '@/lib/sites';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getDocumentFilterableTags } from '@/lib/updates';
|
||||
import { getPageRSSURL } from '@/routes/rss';
|
||||
import {
|
||||
generateUpdatesFilterCSS,
|
||||
getDocumentFilterableTags,
|
||||
updatesFilterStyleHref,
|
||||
} from '@/lib/updates';
|
||||
|
||||
export type SitePageProps = {
|
||||
context: GitBookSiteContext;
|
||||
@@ -81,6 +90,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
} = await getSitePageData(props);
|
||||
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||
const filterableTags = document ? getDocumentFilterableTags(document, context.revision) : [];
|
||||
const filterableTagSlugs = filterableTags.map((tag) => tag.slug);
|
||||
const content = (
|
||||
<>
|
||||
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
|
||||
@@ -134,8 +144,10 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
return (
|
||||
<IconsProvider iconSources={iconSources}>
|
||||
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
|
||||
{filterableTags.length > 0 ? (
|
||||
<UpdatesFilterProvider tagSlugs={filterableTags.map((tag) => tag.slug)}>
|
||||
{filterableTagSlugs.length > 0 ? (
|
||||
<UpdatesFilterProvider tagSlugs={filterableTagSlugs}>
|
||||
<UpdatesFilterScript tagSlugs={filterableTagSlugs} />
|
||||
<UpdatesFilterStyle tagSlugs={filterableTagSlugs} />
|
||||
{content}
|
||||
</UpdatesFilterProvider>
|
||||
) : (
|
||||
@@ -146,6 +158,22 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stylesheet that resolves which `updates` entries the active `?tag=` filter shows, purely in CSS
|
||||
* (see generateUpdatesFilterCSS). Byte-identical for every visitor, so it has no cache impact.
|
||||
*/
|
||||
function UpdatesFilterStyle({ tagSlugs }: { tagSlugs: string[] }) {
|
||||
const css = generateUpdatesFilterCSS(tagSlugs);
|
||||
if (!css) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<style href={updatesFilterStyleHref(tagSlugs)} precedence="high">
|
||||
{css}
|
||||
</style>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateSitePageViewport(context: GitBookSiteContext): Promise<Viewport> {
|
||||
const { customization } = context;
|
||||
|
||||
@@ -220,9 +248,6 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
||||
languages: alternates?.languages,
|
||||
types: {
|
||||
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
|
||||
// We always reference the RSS feed even if the page doesn't have updates blocks,
|
||||
// It might result in 404, but we can't know here if the page has updates blocks.
|
||||
'application/rss+xml': [{ url: getPageRSSURL(context, page), title: 'RSS Feed' }],
|
||||
// Currently it will output with an empty "type" like <link rel="alternate" href="..." type />
|
||||
// Team at Vercel is aware of this and will ensure it will be omitted when the value is empty in future versions of Next.js
|
||||
// https://gitbook.slack.com/archives/C04K6MV5W1K/p1763034072958419?thread_ts=1762937203.511629&cid=C04K6MV5W1K
|
||||
@@ -257,8 +282,8 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
|
||||
const rawPathname = getPathnameParam(props.pageParams);
|
||||
if (!pageTarget) {
|
||||
const pathname = rawPathname.toLowerCase();
|
||||
if (pathname !== rawPathname) {
|
||||
const pathname = getLowercasePathnameRedirect(rawPathname);
|
||||
if (pathname !== null) {
|
||||
// If the pathname was not normalized, redirect to the normalized version
|
||||
// before trying to resolve the page again
|
||||
redirect(context.linker.toPathInSpace(pathname));
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
mock.module('server-only', () => ({}));
|
||||
|
||||
const { fetchPageData } = await import('./fetch');
|
||||
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
|
||||
const { normalizeURL } = await import('@/lib/data/urls');
|
||||
|
||||
const page = {
|
||||
id: 'page-1',
|
||||
@@ -92,3 +93,56 @@ describe('fetchPageData', () => {
|
||||
expect(result.pageTarget?.page.git).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLowercasePathnameRedirect', () => {
|
||||
it('redirects ASCII paths with uppercase letters', () => {
|
||||
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
|
||||
});
|
||||
|
||||
it('does not redirect lowercase paths', () => {
|
||||
expect(getLowercasePathnameRedirect('foo/cafe')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not redirect when only percent-encoded hex digits are uppercase', () => {
|
||||
expect(getLowercasePathnameRedirect('foo/caf%C3%A9')).toBeNull();
|
||||
expect(getLowercasePathnameRedirect('foo/caf%c3%a9')).toBeNull();
|
||||
});
|
||||
|
||||
it('lowercases encoded non-ASCII letters', () => {
|
||||
// É -> é
|
||||
expect(getLowercasePathnameRedirect('foo/%C3%89')).toBe('foo/%C3%A9');
|
||||
});
|
||||
|
||||
it('does not redirect paths that fail to decode', () => {
|
||||
expect(getLowercasePathnameRedirect('Foo/%E0%A4%A')).toBeNull();
|
||||
});
|
||||
|
||||
it('redirects to a pathname the middleware leaves unchanged', () => {
|
||||
const normalize = (pathname: string) =>
|
||||
normalizeURL(new URL(`https://example.com/${pathname}`)).pathname.slice(1);
|
||||
|
||||
const paths = [
|
||||
'Foo/Bar',
|
||||
'Foo/caf%C3%A9',
|
||||
'foo/%C3%89',
|
||||
'Video/porte%C3%91o-x.html',
|
||||
'%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82', // Привет
|
||||
'%CE%95%CE%BB%CE%BB%CE%AC%CE%B4%CE%B1', // Ελλάδα
|
||||
'%C3%96sterreich/Stra%C3%9FE', // Österreich/StraßE
|
||||
'%C4%B0stanbul', // İstanbul, lowercases to two code points
|
||||
'Foo:Bar',
|
||||
'Foo@Bar+Baz',
|
||||
'Brack[et]',
|
||||
'Q%3FX',
|
||||
'Hash%23Y',
|
||||
'Sp%20Ace',
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
const target = getLowercasePathnameRedirect(path);
|
||||
expect(target).not.toBeNull();
|
||||
expect(normalize(target!)).toBe(target!);
|
||||
expect(getLowercasePathnameRedirect(target!)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,3 +184,41 @@ export function getPathnameParam(params: PagePathParams): string {
|
||||
|
||||
return pathname.map((part) => decodeURIComponent(part)).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lowercased pathname to redirect a missing page to, or `null` if there is none.
|
||||
* The pathname is percent-encoded, so lowercase its decoded form and re-encode it canonically:
|
||||
* any other encoding would make the middleware redirect again, or loop.
|
||||
*/
|
||||
export function getLowercasePathnameRedirect(rawPathname: string): string | null {
|
||||
let changed = false;
|
||||
const segments: string[] = [];
|
||||
|
||||
for (const segment of rawPathname.split('/')) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowercased = decoded.toLowerCase();
|
||||
changed ||= lowercased !== decoded;
|
||||
segments.push(lowercased);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return encodeURLPathname(segments.join('/')).slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode a decoded pathname the way the URL parser does, the canonical form `normalizeURL` produces.
|
||||
*/
|
||||
function encodeURLPathname(pathname: string): string {
|
||||
const url = new URL('https://gitbook.invalid');
|
||||
url.pathname = pathname;
|
||||
return url.pathname;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
{hasDescendants ? (
|
||||
<span
|
||||
className={tcls(
|
||||
'toc-group-chevron ml-auto flex shrink-0 transition-opacity duration-150',
|
||||
'toc-group-chevron ml-auto mr-1 flex shrink-0 transition-opacity duration-150',
|
||||
isOpen
|
||||
? 'pointer-events-none opacity-0 delay-75'
|
||||
: 'opacity-6 delay-0'
|
||||
|
||||
@@ -165,7 +165,7 @@ function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () =
|
||||
iconOnly
|
||||
variant="blank"
|
||||
aria-hidden="true" // The button has no label or focus so hiding it from screen readers.
|
||||
className="-my-0.5 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
|
||||
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.
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,11 +15,12 @@ export function useScrollActiveId(
|
||||
enabled: boolean;
|
||||
} = { enabled: true }
|
||||
): string | undefined {
|
||||
const [activeId, setActiveId] = React.useState<string | undefined>(ids[0]);
|
||||
const [activeId, setActiveId] = React.useState<string | undefined>(undefined);
|
||||
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
const defaultActiveId = ids[0];
|
||||
const defaultActiveId =
|
||||
ids.find((id) => document.getElementById(id)?.offsetParent !== null) ?? ids[0];
|
||||
sectionsIntersectingMap.current.clear();
|
||||
setActiveId((activeId) =>
|
||||
activeId !== undefined && ids.includes(activeId) ? activeId : defaultActiveId
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
/**
|
||||
@@ -7,13 +8,17 @@ import { Button, type ButtonProps } from './Button';
|
||||
export function DownloadButton(
|
||||
props: Omit<ButtonProps, 'onClick' | 'href'> & { downloadUrl: string; filename: string }
|
||||
) {
|
||||
const { downloadUrl, filename, ...buttonProps } = props;
|
||||
const { downloadUrl, filename, insights, ...buttonProps } = props;
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (insights) {
|
||||
trackEvent(insights);
|
||||
}
|
||||
void forceDownload(downloadUrl, filename);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ar: TranslationLanguage = {
|
||||
switch_to_system_theme: 'التبديل إلى سمة النظام',
|
||||
search: 'بحث',
|
||||
clear: 'مسح',
|
||||
table_filtered_by_selection: 'يطابق الاختيار: ${1}',
|
||||
table_clear_selection: 'مسح الاختيار',
|
||||
tags: 'الوسوم',
|
||||
search_back: 'العودة إلى نتائج البحث',
|
||||
search_or_ask: 'اسأل أو ابحث',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const bg: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Превключване към системната тема',
|
||||
search: 'Търсене',
|
||||
clear: 'Изчистване',
|
||||
table_filtered_by_selection: 'Съответства на избора: ${1}',
|
||||
table_clear_selection: 'Изчисти избора',
|
||||
tags: 'Етикети',
|
||||
search_back: 'Назад към резултатите от търсенето',
|
||||
search_or_ask: 'Попитайте или търсете',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const cs: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Přepnout na systémový motiv',
|
||||
search: 'Hledat',
|
||||
clear: 'Vymazat',
|
||||
table_filtered_by_selection: 'Odpovídá výběru: ${1}',
|
||||
table_clear_selection: 'Zrušit výběr',
|
||||
tags: 'Štítky',
|
||||
search_back: 'Zpět na výsledky hledání',
|
||||
search_or_ask: 'Zeptat se nebo hledat',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const da: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Skift til systemtema',
|
||||
search: 'Søg',
|
||||
clear: 'Ryd',
|
||||
table_filtered_by_selection: 'Matcher valg: ${1}',
|
||||
table_clear_selection: 'Ryd valg',
|
||||
tags: 'Tags',
|
||||
search_back: 'Tilbage til søgeresultater',
|
||||
search_or_ask: 'Spørg eller søg',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const de: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
search: 'Suche',
|
||||
clear: 'Löschen',
|
||||
table_filtered_by_selection: 'Entspricht Auswahl: ${1}',
|
||||
table_clear_selection: 'Auswahl zurücksetzen',
|
||||
tags: 'Tags',
|
||||
search_back: 'Zurück zu den Suchergebnissen',
|
||||
search_or_ask: 'Fragen oder Suchen',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const el: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Αλλαγή στο θέμα συστήματος',
|
||||
search: 'Αναζήτηση',
|
||||
clear: 'Εκκαθάριση',
|
||||
table_filtered_by_selection: 'Ταιριάζει με την επιλογή: ${1}',
|
||||
table_clear_selection: 'Εκκαθάριση επιλογής',
|
||||
tags: 'Ετικέτες',
|
||||
search_back: 'Επιστροφή στα αποτελέσματα αναζήτησης',
|
||||
search_or_ask: 'Ρωτήστε ή αναζητήστε',
|
||||
|
||||
@@ -9,6 +9,8 @@ export const en = {
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
search: 'Search',
|
||||
clear: 'Clear',
|
||||
table_filtered_by_selection: 'Matches selection: ${1}',
|
||||
table_clear_selection: 'Clear selection',
|
||||
tags: 'Tags',
|
||||
search_back: 'Back to search results',
|
||||
search_or_ask: 'Ask or search',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const es: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||
search: 'Buscar',
|
||||
clear: 'Limpiar',
|
||||
table_filtered_by_selection: 'Coincide con la selección: ${1}',
|
||||
table_clear_selection: 'Borrar selección',
|
||||
tags: 'Etiquetas',
|
||||
search_back: 'Volver a los resultados de búsqueda',
|
||||
search_or_ask: 'Preguntar o Buscar',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const et: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Lülitu süsteemi teemale',
|
||||
search: 'Otsi',
|
||||
clear: 'Tühjenda',
|
||||
table_filtered_by_selection: 'Vastab valikule: ${1}',
|
||||
table_clear_selection: 'Tühjenda valik',
|
||||
tags: 'Sildid',
|
||||
search_back: 'Tagasi otsingutulemuste juurde',
|
||||
search_or_ask: 'Küsi või otsi',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const fi: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Vaihda järjestelmän teemaan',
|
||||
search: 'Haku',
|
||||
clear: 'Tyhjennä',
|
||||
table_filtered_by_selection: 'Vastaa valintaa: ${1}',
|
||||
table_clear_selection: 'Tyhjennä valinta',
|
||||
tags: 'Tunnisteet',
|
||||
search_back: 'Takaisin hakutuloksiin',
|
||||
search_or_ask: 'Kysy tai hae',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const fr: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Utiliser le thème du système',
|
||||
search: 'Rechercher',
|
||||
clear: 'Effacer',
|
||||
table_filtered_by_selection: 'Correspond à la sélection : ${1}',
|
||||
table_clear_selection: 'Effacer la sélection',
|
||||
tags: 'Étiquettes',
|
||||
search_back: 'Retour aux résultats de recherche',
|
||||
search_or_ask: 'Rechercher',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const he: TranslationLanguage = {
|
||||
switch_to_system_theme: 'מעבר לערכת הנושא של המערכת',
|
||||
search: 'חיפוש',
|
||||
clear: 'ניקוי',
|
||||
table_filtered_by_selection: 'תואם לבחירה: ${1}',
|
||||
table_clear_selection: 'נקה בחירה',
|
||||
tags: 'תגיות',
|
||||
search_back: 'חזרה לתוצאות החיפוש',
|
||||
search_or_ask: 'שאלה או חיפוש',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const hi: TranslationLanguage = {
|
||||
switch_to_system_theme: 'सिस्टम थीम पर जाएं',
|
||||
search: 'खोजें',
|
||||
clear: 'साफ करें',
|
||||
table_filtered_by_selection: 'चयन से मेल खाता है: ${1}',
|
||||
table_clear_selection: 'चयन साफ़ करें',
|
||||
tags: 'टैग',
|
||||
search_back: 'खोज परिणामों पर वापस जाएं',
|
||||
search_or_ask: 'पूछें या खोजें',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const hr: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Prebaci na temu sustava',
|
||||
search: 'Pretraži',
|
||||
clear: 'Očisti',
|
||||
table_filtered_by_selection: 'Odgovara odabiru: ${1}',
|
||||
table_clear_selection: 'Očisti odabir',
|
||||
tags: 'Oznake',
|
||||
search_back: 'Natrag na rezultate pretraživanja',
|
||||
search_or_ask: 'Pitaj ili pretraži',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const hu: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Váltás rendszer témára',
|
||||
search: 'Keresés',
|
||||
clear: 'Törlés',
|
||||
table_filtered_by_selection: 'Megfelel a kijelölésnek: ${1}',
|
||||
table_clear_selection: 'Kijelölés törlése',
|
||||
tags: 'Címkék',
|
||||
search_back: 'Vissza a keresési eredményekhez',
|
||||
search_or_ask: 'Kérdezés vagy keresés',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const id: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Beralih ke tema sistem',
|
||||
search: 'Cari',
|
||||
clear: 'Bersihkan',
|
||||
table_filtered_by_selection: 'Sesuai pilihan: ${1}',
|
||||
table_clear_selection: 'Hapus pilihan',
|
||||
tags: 'Tag',
|
||||
search_back: 'Kembali ke hasil pencarian',
|
||||
search_or_ask: 'Tanya atau cari',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const it: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Passa al tema di sistema',
|
||||
search: 'Cerca',
|
||||
clear: 'Cancella',
|
||||
table_filtered_by_selection: 'Corrisponde alla selezione: ${1}',
|
||||
table_clear_selection: 'Cancella selezione',
|
||||
tags: 'Tag',
|
||||
search_back: 'Torna ai risultati di ricerca',
|
||||
search_or_ask: 'Chiedi o cerca',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ja: TranslationLanguage = {
|
||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||
search: '検索',
|
||||
clear: 'クリア',
|
||||
table_filtered_by_selection: '選択に一致: ${1}',
|
||||
table_clear_selection: '選択をクリア',
|
||||
tags: 'タグ',
|
||||
search_back: '検索結果に戻る',
|
||||
search_or_ask: '質問または検索',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ko: TranslationLanguage = {
|
||||
switch_to_system_theme: '시스템 테마로 전환',
|
||||
search: '검색',
|
||||
clear: '지우기',
|
||||
table_filtered_by_selection: '선택과 일치: ${1}',
|
||||
table_clear_selection: '선택 지우기',
|
||||
tags: '태그',
|
||||
search_back: '검색 결과로 돌아가기',
|
||||
search_or_ask: '질문 또는 검색',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const lt: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Perjungti į sistemos temą',
|
||||
search: 'Ieškoti',
|
||||
clear: 'Išvalyti',
|
||||
table_filtered_by_selection: 'Atitinka pasirinkimą: ${1}',
|
||||
table_clear_selection: 'Išvalyti pasirinkimą',
|
||||
tags: 'Žymos',
|
||||
search_back: 'Grįžti į paieškos rezultatus',
|
||||
search_or_ask: 'Klausti arba ieškoti',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const lv: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Pārslēgt uz sistēmas motīvu',
|
||||
search: 'Meklēt',
|
||||
clear: 'Notīrīt',
|
||||
table_filtered_by_selection: 'Atbilst izvēlei: ${1}',
|
||||
table_clear_selection: 'Notīrīt izvēli',
|
||||
tags: 'Birkas',
|
||||
search_back: 'Atpakaļ uz meklēšanas rezultātiem',
|
||||
search_or_ask: 'Jautāt vai meklēt',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ms: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Tukar kepada tema sistem',
|
||||
search: 'Cari',
|
||||
clear: 'Kosongkan',
|
||||
table_filtered_by_selection: 'Sepadan dengan pilihan: ${1}',
|
||||
table_clear_selection: 'Kosongkan pilihan',
|
||||
tags: 'Tag',
|
||||
search_back: 'Kembali ke hasil carian',
|
||||
search_or_ask: 'Tanya atau cari',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const nl: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||
search: 'Zoeken',
|
||||
clear: 'Wissen',
|
||||
table_filtered_by_selection: 'Komt overeen met selectie: ${1}',
|
||||
table_clear_selection: 'Selectie wissen',
|
||||
tags: 'Tags',
|
||||
search_back: 'Terug naar zoekresultaten',
|
||||
search_or_ask: 'Zoek of vraag',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const no: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Bytt til systemtema',
|
||||
search: 'Søk',
|
||||
clear: 'Tøm',
|
||||
table_filtered_by_selection: 'Samsvarer med valg: ${1}',
|
||||
table_clear_selection: 'Fjern valg',
|
||||
tags: 'Tagger',
|
||||
search_back: 'Tilbake til søkeresultater',
|
||||
search_or_ask: 'Spør eller søk',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const pl: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Przełącz na motyw systemowy',
|
||||
search: 'Szukaj',
|
||||
clear: 'Wyczyść',
|
||||
table_filtered_by_selection: 'Zgodne z wyborem: ${1}',
|
||||
table_clear_selection: 'Wyczyść wybór',
|
||||
tags: 'Tagi',
|
||||
search_back: 'Wróć do wyników wyszukiwania',
|
||||
search_or_ask: 'Zapytaj lub wyszukaj',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const pt_br: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
search: 'Buscar',
|
||||
clear: 'Limpar',
|
||||
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
|
||||
table_clear_selection: 'Limpar seleção',
|
||||
tags: 'Tags',
|
||||
search_back: 'Voltar aos resultados da busca',
|
||||
search_or_ask: 'Perguntar ou buscar',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const pt: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Mudar para o tema do sistema',
|
||||
search: 'Pesquisar',
|
||||
clear: 'Limpar',
|
||||
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
|
||||
table_clear_selection: 'Limpar seleção',
|
||||
tags: 'Etiquetas',
|
||||
search_back: 'Voltar aos resultados da pesquisa',
|
||||
search_or_ask: 'Perguntar ou pesquisar',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ro: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Comută la tema sistemului',
|
||||
search: 'Caută',
|
||||
clear: 'Șterge',
|
||||
table_filtered_by_selection: 'Se potrivește cu selecția: ${1}',
|
||||
table_clear_selection: 'Șterge selecția',
|
||||
tags: 'Etichete',
|
||||
search_back: 'Înapoi la rezultatele căutării',
|
||||
search_or_ask: 'Întreabă sau caută',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ru: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Переключиться на системную тему',
|
||||
search: 'Поиск',
|
||||
clear: 'Очистить',
|
||||
table_filtered_by_selection: 'Соответствует выбору: ${1}',
|
||||
table_clear_selection: 'Очистить выбор',
|
||||
tags: 'Теги',
|
||||
search_back: 'Вернуться к результатам поиска',
|
||||
search_or_ask: 'Найти или спросить',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const sk: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Prepnúť na systémový motív',
|
||||
search: 'Hľadať',
|
||||
clear: 'Vymazať',
|
||||
table_filtered_by_selection: 'Zodpovedá výberu: ${1}',
|
||||
table_clear_selection: 'Zrušiť výber',
|
||||
tags: 'Značky',
|
||||
search_back: 'Späť na výsledky vyhľadávania',
|
||||
search_or_ask: 'Opýtať sa alebo hľadať',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const sl: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Preklopi na sistemsko temo',
|
||||
search: 'Išči',
|
||||
clear: 'Počisti',
|
||||
table_filtered_by_selection: 'Ustreza izbiri: ${1}',
|
||||
table_clear_selection: 'Počisti izbiro',
|
||||
tags: 'Oznake',
|
||||
search_back: 'Nazaj na rezultate iskanja',
|
||||
search_or_ask: 'Vprašaj ali išči',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const sv: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Byt till systemtema',
|
||||
search: 'Sök',
|
||||
clear: 'Rensa',
|
||||
table_filtered_by_selection: 'Matchar val: ${1}',
|
||||
table_clear_selection: 'Rensa val',
|
||||
tags: 'Taggar',
|
||||
search_back: 'Tillbaka till sökresultat',
|
||||
search_or_ask: 'Fråga eller sök',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const th: TranslationLanguage = {
|
||||
switch_to_system_theme: 'เปลี่ยนเป็นธีมของระบบ',
|
||||
search: 'ค้นหา',
|
||||
clear: 'ล้าง',
|
||||
table_filtered_by_selection: 'ตรงกับการเลือก: ${1}',
|
||||
table_clear_selection: 'ล้างการเลือก',
|
||||
tags: 'แท็ก',
|
||||
search_back: 'กลับไปยังผลการค้นหา',
|
||||
search_or_ask: 'ถามหรือค้นหา',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const tr: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Sistem temasına geç',
|
||||
search: 'Ara',
|
||||
clear: 'Temizle',
|
||||
table_filtered_by_selection: 'Seçimle eşleşiyor: ${1}',
|
||||
table_clear_selection: 'Seçimi temizle',
|
||||
tags: 'Etiketler',
|
||||
search_back: 'Arama sonuçlarına geri dön',
|
||||
search_or_ask: 'Sor veya ara',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const uk: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Перемкнути на системну тему',
|
||||
search: 'Пошук',
|
||||
clear: 'Очистити',
|
||||
table_filtered_by_selection: 'Відповідає вибору: ${1}',
|
||||
table_clear_selection: 'Очистити вибір',
|
||||
tags: 'Теги',
|
||||
search_back: 'Назад до результатів пошуку',
|
||||
search_or_ask: 'Запитати або шукати',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const vi: TranslationLanguage = {
|
||||
switch_to_system_theme: 'Chuyển sang giao diện hệ thống',
|
||||
search: 'Tìm kiếm',
|
||||
clear: 'Xóa',
|
||||
table_filtered_by_selection: 'Khớp với lựa chọn: ${1}',
|
||||
table_clear_selection: 'Xóa lựa chọn',
|
||||
tags: 'Thẻ',
|
||||
search_back: 'Quay lại kết quả tìm kiếm',
|
||||
search_or_ask: 'Hỏi hoặc tìm kiếm',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const yue: TranslationLanguage = {
|
||||
switch_to_system_theme: '切換到系統主題',
|
||||
search: '搜尋',
|
||||
clear: '清除',
|
||||
table_filtered_by_selection: '符合選擇:${1}',
|
||||
table_clear_selection: '清除選擇',
|
||||
tags: '標籤',
|
||||
search_back: '返回搜尋結果',
|
||||
search_or_ask: '發問或搜尋',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const zh_tw: TranslationLanguage = {
|
||||
switch_to_system_theme: '切換至系統主題',
|
||||
search: '搜尋',
|
||||
clear: '清除',
|
||||
table_filtered_by_selection: '符合選擇:${1}',
|
||||
table_clear_selection: '清除選擇',
|
||||
tags: '標籤',
|
||||
search_back: '返回搜尋結果',
|
||||
search_or_ask: '詢問或搜尋',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const zh: TranslationLanguage = {
|
||||
switch_to_system_theme: '切换到系统主题',
|
||||
search: '搜索',
|
||||
clear: '清除',
|
||||
table_filtered_by_selection: '符合选择:${1}',
|
||||
table_clear_selection: '清除选择',
|
||||
tags: '标签',
|
||||
search_back: '返回搜索结果',
|
||||
search_or_ask: '询问或搜索',
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { CustomizationPageActionType, SiteVisibility } from '@gitbook/api';
|
||||
|
||||
import {
|
||||
AI_CATALOG_MEDIA_TYPE,
|
||||
AI_CATALOG_SPEC_VERSION,
|
||||
type AiCatalog,
|
||||
buildSiteAiCatalog,
|
||||
serveSiteAiCatalog,
|
||||
} from '.';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
|
||||
/**
|
||||
* `urn:air:{publisher}:{namespace}:{name}`, per the AI Catalog specification.
|
||||
*/
|
||||
const AIR_URN_PATTERN = /^urn:air:[^:]+:[^:]+:[^:]+$/;
|
||||
|
||||
function makeContext(
|
||||
options: {
|
||||
host?: string;
|
||||
siteBasePath?: string;
|
||||
siteId?: string;
|
||||
visibility?: SiteVisibility;
|
||||
pageActions?: CustomizationPageActionType[];
|
||||
} = {}
|
||||
): GitBookSiteContext {
|
||||
const {
|
||||
host = 'docs.acme.org',
|
||||
siteBasePath = '/',
|
||||
siteId = 'site_123',
|
||||
visibility = SiteVisibility.Public,
|
||||
pageActions = [CustomizationPageActionType.Mcp],
|
||||
} = options;
|
||||
|
||||
return {
|
||||
site: { id: siteId, title: 'Acme', visibility },
|
||||
customization: { pageActions: { items: pageActions } },
|
||||
linker: createLinker({ host, siteBasePath, spaceBasePath: siteBasePath }),
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
describe('buildSiteAiCatalog', () => {
|
||||
it('advertises the site MCP server card', () => {
|
||||
const catalog = buildSiteAiCatalog(makeContext());
|
||||
|
||||
expect(catalog).toEqual({
|
||||
specVersion: AI_CATALOG_SPEC_VERSION,
|
||||
entries: [
|
||||
{
|
||||
identifier: 'urn:air:docs.acme.org:mcp:site_123',
|
||||
type: 'application/mcp-server-card+json',
|
||||
url: 'https://docs.acme.org/~gitbook/mcp/server-card',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds an identifier the specification can parse', () => {
|
||||
const catalog = buildSiteAiCatalog(makeContext({ siteId: 'site_A1b2-c3' }));
|
||||
|
||||
expect(catalog?.entries[0]?.identifier).toMatch(AIR_URN_PATTERN);
|
||||
});
|
||||
|
||||
it('omits fields the referenced card is authoritative for', () => {
|
||||
const entry = buildSiteAiCatalog(makeContext())?.entries[0];
|
||||
|
||||
expect(entry).not.toHaveProperty('displayName');
|
||||
expect(entry).not.toHaveProperty('description');
|
||||
expect(entry).not.toHaveProperty('version');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'a custom domain',
|
||||
options: { host: 'docs.acme.org', siteBasePath: '/' },
|
||||
identifier: 'urn:air:docs.acme.org:mcp:site_123',
|
||||
url: 'https://docs.acme.org/~gitbook/mcp/server-card',
|
||||
},
|
||||
{
|
||||
scenario: 'a gitbook.io subdomain',
|
||||
options: { host: 'acme.gitbook.io', siteBasePath: '/' },
|
||||
identifier: 'urn:air:acme.gitbook.io:mcp:site_123',
|
||||
url: 'https://acme.gitbook.io/~gitbook/mcp/server-card',
|
||||
},
|
||||
{
|
||||
scenario: 'a subpath site',
|
||||
options: { host: 'gitbook.com', siteBasePath: '/docs/' },
|
||||
identifier: 'urn:air:gitbook.com:mcp:site_123',
|
||||
url: 'https://gitbook.com/docs/~gitbook/mcp/server-card',
|
||||
},
|
||||
])('anchors the entry to $scenario', ({ options, identifier, url }) => {
|
||||
const entry = buildSiteAiCatalog(makeContext(options))?.entries[0];
|
||||
|
||||
expect(entry?.identifier).toBe(identifier);
|
||||
expect(entry?.url).toBe(url);
|
||||
});
|
||||
|
||||
it('drops the port from the publisher, which a URN would read as a separator', () => {
|
||||
const context = makeContext();
|
||||
const linker = createLinker({
|
||||
host: 'localhost:3000',
|
||||
siteBasePath: '/',
|
||||
spaceBasePath: '/',
|
||||
});
|
||||
const entry = buildSiteAiCatalog({ ...context, linker })?.entries[0];
|
||||
|
||||
expect(entry?.identifier).toBe('urn:air:localhost:mcp:site_123');
|
||||
expect(entry?.identifier).toMatch(AIR_URN_PATTERN);
|
||||
});
|
||||
|
||||
it('advertises nothing when the site publishes no MCP server', () => {
|
||||
expect(buildSiteAiCatalog(makeContext({ pageActions: [] }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serveSiteAiCatalog', () => {
|
||||
const catalogRequest = (init?: RequestInit) =>
|
||||
new Request('https://docs.acme.org/.well-known/ai-catalog.json', init);
|
||||
|
||||
it('serves the catalog with its media type and CORS headers', async () => {
|
||||
const res = await serveSiteAiCatalog(makeContext(), catalogRequest());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe(`${AI_CATALOG_MEDIA_TYPE}; charset=utf-8`);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(res.headers.get('cache-control')).toBe('public, max-age=3600');
|
||||
|
||||
const catalog = (await res.json()) as AiCatalog;
|
||||
expect(catalog.specVersion).toBe(AI_CATALOG_SPEC_VERSION);
|
||||
expect(catalog.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('answers a preflight with a 204', async () => {
|
||||
const res = await serveSiteAiCatalog(makeContext(), catalogRequest({ method: 'OPTIONS' }));
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('returns 304 when the client already has the current catalog', async () => {
|
||||
const context = makeContext();
|
||||
const first = await serveSiteAiCatalog(context, catalogRequest());
|
||||
const etag = first.headers.get('etag');
|
||||
|
||||
const revalidated = await serveSiteAiCatalog(
|
||||
context,
|
||||
catalogRequest({ headers: { 'If-None-Match': `W/${etag}` } })
|
||||
);
|
||||
|
||||
expect(revalidated.status).toBe(304);
|
||||
});
|
||||
|
||||
it.each([SiteVisibility.ShareLink, SiteVisibility.VisitorAuth])(
|
||||
'never caches the catalog of a %s site',
|
||||
async (visibility) => {
|
||||
const res = await serveSiteAiCatalog(makeContext({ visibility }), catalogRequest());
|
||||
|
||||
expect(res.headers.get('cache-control')).toBe('no-store');
|
||||
}
|
||||
);
|
||||
|
||||
it('does not serve a catalog when there is nothing to advertise', async () => {
|
||||
const res = await serveSiteAiCatalog(makeContext({ pageActions: [] }), catalogRequest());
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { serveDiscoveryDocument } from '@/lib/discoveryDocument';
|
||||
import { isSiteMcpEnabled } from '@/lib/mcp/endpoints';
|
||||
import { MCP_SERVER_CARD_PATH } from '@/lib/mcp/paths';
|
||||
import { SERVER_CARD_MEDIA_TYPE } from '@/lib/mcp/serverCard';
|
||||
|
||||
export const AI_CATALOG_MEDIA_TYPE = 'application/ai-catalog+json';
|
||||
export const AI_CATALOG_SPEC_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* The AI Catalog a site publishes at `/.well-known/ai-catalog.json`, per the
|
||||
* [AI Catalog specification](https://github.com/Agent-Card/ai-catalog).
|
||||
*/
|
||||
export interface AiCatalog {
|
||||
/**
|
||||
* Required. The specification version, in "Major.Minor" form.
|
||||
*/
|
||||
specVersion: string;
|
||||
/**
|
||||
* Required, and may be empty.
|
||||
*/
|
||||
entries: AiCatalogEntry[];
|
||||
}
|
||||
|
||||
export interface AiCatalogEntry {
|
||||
/**
|
||||
* Required. Domain-anchored `urn:air:{publisher}:{namespace}:{name}`.
|
||||
*/
|
||||
identifier: string;
|
||||
/**
|
||||
* Required. The media type of the artifact the entry points at.
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Where the artifact can be retrieved. Exactly one of `url` or `data` is used.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the catalog of AI artifacts the site advertises, or `null` when it advertises none.
|
||||
*/
|
||||
export function buildSiteAiCatalog(context: GitBookSiteContext): AiCatalog | null {
|
||||
const { linker, site } = context;
|
||||
|
||||
const entries: AiCatalogEntry[] = [];
|
||||
|
||||
if (isSiteMcpEnabled(context)) {
|
||||
entries.push({
|
||||
identifier: buildCatalogIdentifier(context, 'mcp', site.id),
|
||||
type: SERVER_CARD_MEDIA_TYPE,
|
||||
url: linker.toAbsoluteURL(linker.toPathInSite(MCP_SERVER_CARD_PATH)),
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { specVersion: AI_CATALOG_SPEC_VERSION, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the site's catalog, or a 404 when the site advertises nothing to discover.
|
||||
*/
|
||||
export async function serveSiteAiCatalog(
|
||||
context: GitBookSiteContext,
|
||||
request: Request
|
||||
): Promise<Response> {
|
||||
const catalog = buildSiteAiCatalog(context);
|
||||
if (!catalog) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return serveDiscoveryDocument(context, request, {
|
||||
document: catalog,
|
||||
mediaType: AI_CATALOG_MEDIA_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
function buildCatalogIdentifier(
|
||||
context: GitBookSiteContext,
|
||||
namespace: string,
|
||||
name: string
|
||||
): string {
|
||||
const publisher = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite('')))
|
||||
.hostname;
|
||||
|
||||
return `urn:air:${publisher}:${namespace}:${name}`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Internal route the catalog is served from, which the well-known path rewrites to.
|
||||
*/
|
||||
export const AI_CATALOG_PATH = '~gitbook/ai-catalog';
|
||||
|
||||
/**
|
||||
* Where domain-level discovery looks. Unlike a server card, a catalog *is* site-wide metadata, so
|
||||
* `.well-known` is the spec's own home for it rather than a concession to scanners.
|
||||
*/
|
||||
export const AI_CATALOG_WELL_KNOWN_PATH = '.well-known/ai-catalog.json';
|
||||
@@ -6,6 +6,7 @@ export function isChatGPTRequest(request: Pick<Request, 'headers'>): boolean {
|
||||
return (
|
||||
normalizedUserAgent.includes('chatgpt-user') ||
|
||||
normalizedUserAgent.includes('chatgpt agent') ||
|
||||
normalizedUserAgent.includes('oai-searchbot') ||
|
||||
normalizedSignatureAgent.includes('chatgpt.com')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getURLLookupAlternatives, getURLLookupPathname, normalizeURL } from './urls';
|
||||
import {
|
||||
getURLLookupAlternatives,
|
||||
getURLLookupPathname,
|
||||
normalizeURL,
|
||||
shouldBypassLookupAlternatives,
|
||||
} from './urls';
|
||||
|
||||
describe('getURLLookupPathname', () => {
|
||||
const previewRoot = 'https://sites.gitbook.com/preview/site_example/section';
|
||||
@@ -832,3 +837,54 @@ describe('normalizeURL with encoded paths', () => {
|
||||
expect(result.searchParams.get('filter')).toBe(risonValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getURLLookupAlternatives with bypass', () => {
|
||||
it('only looks up the full URL', () => {
|
||||
expect(
|
||||
getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c'), { bypass: true })
|
||||
).toEqual({
|
||||
revision: undefined,
|
||||
changeRequest: undefined,
|
||||
basePath: undefined,
|
||||
urls: [{ url: 'https://docs.mycompany.com/a/b/c', extraPath: '', primary: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it('only looks up the full URL for a variant', () => {
|
||||
expect(
|
||||
getURLLookupAlternatives(new URL('https://test.gitbook.io/v/variant/space'), {
|
||||
bypass: true,
|
||||
}).urls
|
||||
).toEqual([
|
||||
{ url: 'https://test.gitbook.io/v/variant/space', extraPath: '', primary: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['revisions', 'changes'])('keeps the alternatives for %s', (kind) => {
|
||||
const url = new URL(`https://docs.mycompany.com/a/~/${kind}/id/page`);
|
||||
expect(getURLLookupAlternatives(url, { bypass: true })).toEqual(
|
||||
getURLLookupAlternatives(url, { bypass: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldBypassLookupAlternatives', () => {
|
||||
const bypassURLs = ['https://docs.mycompany.com/section', 'https://other.mycompany.com/'];
|
||||
|
||||
it.each([
|
||||
'https://docs.mycompany.com/section',
|
||||
'https://docs.mycompany.com/section/page',
|
||||
'https://other.mycompany.com/',
|
||||
'https://other.mycompany.com/page',
|
||||
])('matches %s', (url) => {
|
||||
expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://docs.mycompany.com/sectionpage',
|
||||
'https://docs.mycompany.com/',
|
||||
'https://unknown.mycompany.com/section',
|
||||
])('does not match %s', (url) => {
|
||||
expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GITBOOK_DISABLE_LOOKUP_ALTERNATIVES } from '../env';
|
||||
import { joinPath, removeTrailingSlash } from '../paths';
|
||||
import { isProxyRootRequest } from '../proxy';
|
||||
import { DataFetcherError, getExposableError } from './errors';
|
||||
@@ -42,6 +43,34 @@ function getContentPathSegments(pathSegments: string[]): string[] {
|
||||
return pathSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site URL prefixes resolved with the full URL only, for sites where a shorter alternative
|
||||
* would resolve to the wrong content.
|
||||
*/
|
||||
const LOOKUP_ALTERNATIVES_BYPASS_URLS: string[] = ['https://proxy.gitbook.site/sites/site_p4Xo4'];
|
||||
|
||||
/**
|
||||
* Whether the lookup of this (normalized) URL should skip the shorter alternatives.
|
||||
*/
|
||||
export function shouldBypassLookupAlternatives(
|
||||
url: URL,
|
||||
bypassURLs: string[] = LOOKUP_ALTERNATIVES_BYPASS_URLS
|
||||
): boolean {
|
||||
if (GITBOOK_DISABLE_LOOKUP_ALTERNATIVES) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return bypassURLs.some((bypassURL) => {
|
||||
const prefix = normalizeURL(new URL(bypassURL));
|
||||
const prefixPath = removeTrailingSlash(prefix.pathname);
|
||||
return (
|
||||
prefix.origin === url.origin &&
|
||||
(removeTrailingSlash(url.pathname) === prefixPath ||
|
||||
url.pathname.startsWith(`${prefixPath}/`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
|
||||
* The approach is optimized to aim at reusing cached lookup results as much as possible.
|
||||
@@ -62,8 +91,9 @@ function getContentPathSegments(pathSegments: string[]): string[] {
|
||||
* - Public content has a custom hostname in the organization with a variant: docs.company.com/<space>/v/<variant>/<path>
|
||||
* - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com/<space>/<link>/v/<variant>/<path>
|
||||
*/
|
||||
export function getURLLookupAlternatives(input: URL) {
|
||||
export function getURLLookupAlternatives(input: URL, options: { bypass?: boolean } = {}) {
|
||||
const url = normalizeURL(input);
|
||||
const bypass = options.bypass ?? shouldBypassLookupAlternatives(url);
|
||||
|
||||
let basePath: string | undefined = undefined;
|
||||
let changeRequest: string | undefined = undefined;
|
||||
@@ -123,6 +153,11 @@ export function getURLLookupAlternatives(input: URL) {
|
||||
pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/'));
|
||||
}
|
||||
|
||||
// Revisions and changes above still need their alternatives to extract the base path.
|
||||
else if (bypass) {
|
||||
pushAlternative(url, '');
|
||||
}
|
||||
|
||||
// URL looks like a collection url (with /v/ in the path)
|
||||
// We only start matching after the /v/ segment and we ignore everything before it
|
||||
// to avoid potentially matching as a page not found under the default space in the collection
|
||||
@@ -198,7 +233,7 @@ export function normalizeRequestURL(url: URL): Response | null {
|
||||
|
||||
/**
|
||||
* Normalize a URL to remove duplicate slashes and trailing slashes
|
||||
* and transform the pathname to lowercase.
|
||||
* and decode the pathname to its canonical encoding.
|
||||
*/
|
||||
export function normalizeURL(url: URL) {
|
||||
const result = new URL(url);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { SiteVisibility } from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
/**
|
||||
* CORS headers the server-card extension requires on a discovery endpoint.
|
||||
*/
|
||||
const CORS_HEADERS = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET',
|
||||
'access-control-allow-headers': 'Content-Type, If-None-Match',
|
||||
'access-control-expose-headers': 'ETag',
|
||||
};
|
||||
|
||||
const CACHE_CONTROL = 'public, max-age=3600';
|
||||
|
||||
/**
|
||||
* Serve a discovery document with the caching and CORS headers the extension asks a host for, or
|
||||
* `304` when the client's copy is still current.
|
||||
*/
|
||||
export async function serveDiscoveryDocument(
|
||||
context: GitBookSiteContext,
|
||||
request: Request,
|
||||
options: {
|
||||
document: unknown;
|
||||
mediaType: string;
|
||||
}
|
||||
): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
const body = JSON.stringify(options.document);
|
||||
const etag = `"${await hashDocument(body)}"`;
|
||||
|
||||
const isPubliclyReachable =
|
||||
context.site.visibility === SiteVisibility.Public ||
|
||||
context.site.visibility === SiteVisibility.Unlisted;
|
||||
|
||||
const headers = {
|
||||
...CORS_HEADERS,
|
||||
'cache-control': isPubliclyReachable ? CACHE_CONTROL : 'no-store',
|
||||
etag,
|
||||
};
|
||||
|
||||
if (matchesEtag(request.headers.get('if-none-match'), etag)) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
headers: { ...headers, 'content-type': `${options.mediaType}; charset=utf-8` },
|
||||
});
|
||||
}
|
||||
|
||||
async function hashDocument(body: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function matchesEtag(ifNoneMatch: string | null, etag: string): boolean {
|
||||
if (!ifNoneMatch) {
|
||||
return false;
|
||||
}
|
||||
if (ifNoneMatch.trim() === '*') {
|
||||
return true;
|
||||
}
|
||||
return ifNoneMatch
|
||||
.split(',')
|
||||
.some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
|
||||
}
|
||||
+6
@@ -84,6 +84,12 @@ export const GITBOOK_DISABLE_TRACKING = Boolean(
|
||||
*/
|
||||
export const GITBOOK_DISABLE_INSIGHTS = process.env.GITBOOK_DISABLE_INSIGHTS === 'true';
|
||||
|
||||
/**
|
||||
* Whether to resolve site content with the full URL only, skipping the shorter lookup alternatives.
|
||||
*/
|
||||
export const GITBOOK_DISABLE_LOOKUP_ALTERNATIVES =
|
||||
process.env.GITBOOK_DISABLE_LOOKUP_ALTERNATIVES === 'true';
|
||||
|
||||
/**
|
||||
* Hostname serving the integrations.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,14 @@ describe('ChatGPT Markdown compatibility', () => {
|
||||
it('detects ChatGPT requests without matching other agents', () => {
|
||||
expect(isChatGPTRequest(requestWith({ 'user-agent': 'ChatGPT-User/1.0' }))).toBe(true);
|
||||
expect(isChatGPTRequest(requestWith({ 'user-agent': 'ChatGPT Agent' }))).toBe(true);
|
||||
expect(
|
||||
isChatGPTRequest(
|
||||
requestWith({
|
||||
'user-agent':
|
||||
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot',
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isChatGPTRequest(
|
||||
requestWith({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CustomizationPageActionType, SiteVisibility } from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
/**
|
||||
* Whether the site publishes an MCP server at all. Gates the endpoint and everything describing it.
|
||||
*/
|
||||
export function isSiteMcpEnabled(context: GitBookSiteContext): boolean {
|
||||
return context.customization.pageActions.items.includes(CustomizationPageActionType.Mcp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `~gitbook/mcp` endpoint requires auth for the site (i.e VA site).
|
||||
*/
|
||||
export function mcpEndpointRequiresAuth(context: GitBookSiteContext): boolean {
|
||||
return context.site.visibility === SiteVisibility.VisitorAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site exposes `~gitbook/mcp/auth`, the endpoint a visitor authenticates against to
|
||||
* read adaptive content.
|
||||
*/
|
||||
export function hasAdaptiveMcpEndpoint(context: GitBookSiteContext): boolean {
|
||||
return Boolean(
|
||||
!mcpEndpointRequiresAuth(context) &&
|
||||
context.site.adaptiveContent?.enabled &&
|
||||
context.site.urls.login
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Path the card is served on, appended to the Streamable HTTP endpoint as the extension reserves
|
||||
* (`GET <streamable-http-url>/server-card`).
|
||||
*/
|
||||
export const MCP_SERVER_CARD_PATH = '~gitbook/mcp/server-card';
|
||||
|
||||
/**
|
||||
* Path crawlers probe today, kept alongside the reserved one. The extension argues against
|
||||
* `.well-known` for a single server's card (it is application-level, not site-wide metadata), but
|
||||
* the scanners in the wild look here, so the card answers on both.
|
||||
*/
|
||||
export const MCP_SERVER_CARD_WELL_KNOWN_PATH = '.well-known/mcp/server-card.json';
|
||||
@@ -0,0 +1,418 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { CustomizationAIMode, CustomizationPageActionType, SiteVisibility } from '@gitbook/api';
|
||||
|
||||
import { hasAdaptiveMcpEndpoint, mcpEndpointRequiresAuth } from './endpoints';
|
||||
import {
|
||||
MCP_SERVER_VERSION,
|
||||
SERVER_CARD_MEDIA_TYPE,
|
||||
SERVER_CARD_SCHEMA_URL,
|
||||
type SiteMcpServerCard,
|
||||
buildMcpServerInfo,
|
||||
buildSiteMcpServerCard,
|
||||
serveSiteMcpServerCard,
|
||||
} from './serverCard';
|
||||
import { createSiteMcpTools, registerSiteMcpTools } from './tools';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
|
||||
const SERVER_CARD_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
|
||||
const MAX_TEXT_LENGTH = 100;
|
||||
|
||||
function makeContext(
|
||||
options: {
|
||||
host?: string;
|
||||
siteBasePath?: string;
|
||||
spaceBasePath?: string;
|
||||
siteId?: string;
|
||||
title?: string;
|
||||
visibility?: SiteVisibility;
|
||||
adaptiveContent?: boolean;
|
||||
aiMode?: CustomizationAIMode;
|
||||
pageActions?: CustomizationPageActionType[];
|
||||
} = {}
|
||||
): GitBookSiteContext {
|
||||
const {
|
||||
host = 'docs.acme.org',
|
||||
siteBasePath = '/',
|
||||
spaceBasePath = siteBasePath,
|
||||
siteId = 'site_123',
|
||||
title = 'Acme',
|
||||
visibility = SiteVisibility.Public,
|
||||
adaptiveContent = true,
|
||||
aiMode = CustomizationAIMode.Search,
|
||||
pageActions = [CustomizationPageActionType.Mcp],
|
||||
} = options;
|
||||
|
||||
return {
|
||||
organizationId: 'org_123',
|
||||
site: {
|
||||
id: siteId,
|
||||
title,
|
||||
visibility,
|
||||
adaptiveContent: { enabled: adaptiveContent },
|
||||
urls: { login: `https://${host}/~gitbook/auth/login` },
|
||||
},
|
||||
siteSpace: { urls: { published: `https://${host}${siteBasePath}` } },
|
||||
customization: {
|
||||
ai: { mode: aiMode },
|
||||
pageActions: { items: pageActions },
|
||||
},
|
||||
linker: createLinker({ host, siteBasePath, spaceBasePath }),
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
const request = new Request('https://docs.acme.org/~gitbook/mcp/server-card');
|
||||
|
||||
function buildCard(options?: Parameters<typeof makeContext>[0]): SiteMcpServerCard {
|
||||
const context = makeContext(options);
|
||||
return buildSiteMcpServerCard(context, createSiteMcpTools(context, { request }));
|
||||
}
|
||||
|
||||
describe('buildSiteMcpServerCard', () => {
|
||||
describe('schema constraints', () => {
|
||||
it('declares every required member', () => {
|
||||
const card = buildCard();
|
||||
|
||||
expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL);
|
||||
expect(card.name).toBeString();
|
||||
expect(card.version).toBeString();
|
||||
expect(card.description).toBeString();
|
||||
});
|
||||
|
||||
it('names the server with a reverse-DNS namespace and exactly one slash', () => {
|
||||
const card = buildCard({ siteId: 'site_A1b2-c3' });
|
||||
|
||||
expect(card.name).toBe('com.gitbook.sites.mcp/site_A1b2-c3');
|
||||
expect(card.name).toMatch(SERVER_CARD_NAME_PATTERN);
|
||||
expect(card.name.split('/')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the description and title within 100 characters', () => {
|
||||
const card = buildCard({ title: 'Acme' });
|
||||
|
||||
expect(card.title).toBe('Acme MCP Server');
|
||||
expect(card.description).toBe('Search and read the Acme documentation over MCP.');
|
||||
expect(card.description.length).toBeLessThanOrEqual(MAX_TEXT_LENGTH);
|
||||
expect(card.title.length).toBeLessThanOrEqual(MAX_TEXT_LENGTH);
|
||||
});
|
||||
|
||||
it('does not repeat an article a customer title already carries', () => {
|
||||
const card = buildCard({ title: 'The Acme Handbook' });
|
||||
|
||||
expect(card.title).toBe('The Acme Handbook MCP Server');
|
||||
expect(card.description).toBe(
|
||||
'Search and read The Acme Handbook documentation over MCP.'
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates an arbitrarily long customer title without losing what the server is', () => {
|
||||
const card = buildCard({ title: 'A'.repeat(500) });
|
||||
|
||||
expect(card.title.length).toBe(MAX_TEXT_LENGTH);
|
||||
expect(card.title.endsWith('… MCP Server')).toBe(true);
|
||||
expect(card.description.length).toBe(MAX_TEXT_LENGTH);
|
||||
expect(card.description.startsWith('Search and read the ')).toBe(true);
|
||||
expect(card.description.endsWith('… documentation over MCP.')).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes an exact version rather than a range', () => {
|
||||
const card = buildCard();
|
||||
|
||||
expect(card.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(card.version).toBe(MCP_SERVER_VERSION);
|
||||
});
|
||||
|
||||
it('declares a transport type the extension allows on every remote', () => {
|
||||
const card = buildCard();
|
||||
|
||||
for (const remote of card.remotes) {
|
||||
expect(['streamable-http', 'sse']).toContain(remote.type);
|
||||
expect(URL.canParse(remote.url)).toBe(true);
|
||||
expect(remote.supportedProtocolVersions.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('server identity', () => {
|
||||
it('reports the same identity the transport reports at initialize', () => {
|
||||
const context = makeContext();
|
||||
const card = buildSiteMcpServerCard(context, createSiteMcpTools(context, { request }));
|
||||
|
||||
expect(card.serverInfo).toEqual(buildMcpServerInfo(context));
|
||||
expect(card.serverInfo.title).toBe(card.title);
|
||||
expect(card.serverInfo.version).toBe(card.version);
|
||||
});
|
||||
|
||||
it('names the running server after its endpoint', () => {
|
||||
const card = buildCard();
|
||||
|
||||
expect(card.serverInfo.name).toBe('docs.acme.org/~gitbook/mcp');
|
||||
expect(card.endpoint).toBe(`https://${card.serverInfo.name}`);
|
||||
});
|
||||
|
||||
it('keeps the card name stable and schema-valid, unlike the endpoint', () => {
|
||||
const card = buildCard();
|
||||
|
||||
expect(card.name).toBe('com.gitbook.sites.mcp/site_123');
|
||||
expect(card.name).toMatch(SERVER_CARD_NAME_PATTERN);
|
||||
// The endpoint cannot be the card name: two slashes and a `~`.
|
||||
expect(card.serverInfo.name).not.toMatch(SERVER_CARD_NAME_PATTERN);
|
||||
});
|
||||
|
||||
it('names the server after the site rather than the software', () => {
|
||||
const acme = buildMcpServerInfo(makeContext({ host: 'docs.acme.com', title: 'Acme' }));
|
||||
const other = buildMcpServerInfo(
|
||||
makeContext({ host: 'gitbook.com', siteBasePath: '/other/', title: 'Other' })
|
||||
);
|
||||
|
||||
expect(acme.name).toBe('docs.acme.com/~gitbook/mcp');
|
||||
expect(other.name).toBe('gitbook.com/other/~gitbook/mcp');
|
||||
expect(acme.title).toBe('Acme MCP Server');
|
||||
expect(other.title).toBe('Other MCP Server');
|
||||
});
|
||||
|
||||
it('declares the capabilities the SDK reports at initialize', () => {
|
||||
const card = buildCard();
|
||||
|
||||
// Verified against a live server: `McpServer` declares listChanged for its tools.
|
||||
expect(card.capabilities).toEqual({ tools: { listChanged: true } });
|
||||
});
|
||||
|
||||
it('points `endpoint` at the same URL as the public remote', () => {
|
||||
const card = buildCard();
|
||||
|
||||
expect(card.endpoint).toBe(card.remotes[0]?.url ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remotes', () => {
|
||||
it('publishes one card with both endpoints when the site serves adaptive content', () => {
|
||||
const card = buildCard({ adaptiveContent: true });
|
||||
|
||||
expect(card.remotes.map((remote) => remote.url)).toEqual([
|
||||
'https://docs.acme.org/~gitbook/mcp',
|
||||
'https://docs.acme.org/~gitbook/mcp/auth',
|
||||
]);
|
||||
expect(card.remotes[0]?.headers).toBeUndefined();
|
||||
expect(card.remotes[1]?.headers).toEqual([
|
||||
{
|
||||
name: 'Authorization',
|
||||
description: expect.stringContaining('Bearer token'),
|
||||
isRequired: true,
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the adaptive endpoint when the site has no adaptive content', () => {
|
||||
const card = buildCard({ adaptiveContent: false });
|
||||
|
||||
expect(card.remotes.map((remote) => remote.url)).toEqual([
|
||||
'https://docs.acme.org/~gitbook/mcp',
|
||||
]);
|
||||
expect(card.remotes[0]?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([SiteVisibility.Public, SiteVisibility.Unlisted, SiteVisibility.ShareLink])(
|
||||
'leaves the public endpoint unauthenticated on a %s site',
|
||||
(visibility) => {
|
||||
const card = buildCard({ visibility, adaptiveContent: false });
|
||||
|
||||
expect(card.remotes[0]?.headers).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('requires a token on the public endpoint of a visitor-auth site, and offers no adaptive endpoint', () => {
|
||||
const card = buildCard({ visibility: SiteVisibility.VisitorAuth });
|
||||
|
||||
expect(card.remotes.map((remote) => remote.url)).toEqual([
|
||||
'https://docs.acme.org/~gitbook/mcp',
|
||||
]);
|
||||
expect(card.remotes[0]?.headers).toEqual([
|
||||
{
|
||||
name: 'Authorization',
|
||||
description: expect.stringContaining('restricted to authenticated visitors'),
|
||||
isRequired: true,
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('urls', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'a custom domain',
|
||||
options: { host: 'docs.acme.org', siteBasePath: '/' },
|
||||
websiteUrl: 'https://docs.acme.org',
|
||||
endpoint: 'https://docs.acme.org/~gitbook/mcp',
|
||||
icon: 'https://docs.acme.org/~gitbook/icon?size=medium',
|
||||
},
|
||||
{
|
||||
scenario: 'a gitbook.io subdomain',
|
||||
options: { host: 'acme.gitbook.io', siteBasePath: '/' },
|
||||
websiteUrl: 'https://acme.gitbook.io',
|
||||
endpoint: 'https://acme.gitbook.io/~gitbook/mcp',
|
||||
icon: 'https://acme.gitbook.io/~gitbook/icon?size=medium',
|
||||
},
|
||||
{
|
||||
scenario: 'a subpath site',
|
||||
options: {
|
||||
host: 'gitbook.com',
|
||||
siteBasePath: '/docs/',
|
||||
spaceBasePath: '/docs/v1/',
|
||||
},
|
||||
websiteUrl: 'https://gitbook.com/docs',
|
||||
endpoint: 'https://gitbook.com/docs/~gitbook/mcp',
|
||||
icon: 'https://gitbook.com/docs/~gitbook/icon?size=medium',
|
||||
},
|
||||
])('builds absolute URLs for $scenario', ({ options, websiteUrl, endpoint, icon }) => {
|
||||
const card = buildCard(options);
|
||||
|
||||
expect(card.websiteUrl).toBe(websiteUrl);
|
||||
expect(card.endpoint).toBe(endpoint);
|
||||
expect(card.remotes[1]?.url).toBe(`${endpoint}/auth`);
|
||||
expect(card.icons[0]?.src).toBe(icon);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endpoint predicates', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'a public site with adaptive content',
|
||||
options: { visibility: SiteVisibility.Public, adaptiveContent: true },
|
||||
requiresAuth: false,
|
||||
hasAdaptive: true,
|
||||
},
|
||||
{
|
||||
scenario: 'a public site without adaptive content',
|
||||
options: { visibility: SiteVisibility.Public, adaptiveContent: false },
|
||||
requiresAuth: false,
|
||||
hasAdaptive: false,
|
||||
},
|
||||
{
|
||||
scenario: 'a visitor-auth site',
|
||||
options: { visibility: SiteVisibility.VisitorAuth, adaptiveContent: true },
|
||||
requiresAuth: true,
|
||||
hasAdaptive: false,
|
||||
},
|
||||
])('resolves the endpoints of $scenario', ({ options, requiresAuth, hasAdaptive }) => {
|
||||
const context = makeContext(options);
|
||||
|
||||
expect(mcpEndpointRequiresAuth(context)).toBe(requiresAuth);
|
||||
expect(hasAdaptiveMcpEndpoint(context)).toBe(hasAdaptive);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools', () => {
|
||||
it('advertises exactly the tools registered on the server', () => {
|
||||
const context = makeContext();
|
||||
const request = new Request('https://docs.acme.org/~gitbook/mcp');
|
||||
const tools = createSiteMcpTools(context, { request });
|
||||
|
||||
const registered: string[] = [];
|
||||
registerSiteMcpTools(
|
||||
{ tool: (name: string) => registered.push(name) } as unknown as McpServer,
|
||||
tools
|
||||
);
|
||||
|
||||
const card = buildSiteMcpServerCard(context, tools);
|
||||
expect(card.tools.map((tool) => tool.name)).toEqual(registered);
|
||||
});
|
||||
|
||||
it('carries a description and annotations, but not input schemas', () => {
|
||||
const card = buildCard();
|
||||
const search = card.tools.find((tool) => tool.name === 'searchDocumentation');
|
||||
|
||||
expect(search?.description).toContain('Acme');
|
||||
expect(search?.annotations).toMatchObject({
|
||||
title: 'Search documentation',
|
||||
readOnlyHint: true,
|
||||
});
|
||||
expect(search).not.toHaveProperty('inputSchema');
|
||||
});
|
||||
|
||||
it('omits askQuestion when the site has no AI mode', () => {
|
||||
const withAI = buildCard({ aiMode: CustomizationAIMode.Assistant });
|
||||
const withoutAI = buildCard({ aiMode: CustomizationAIMode.None });
|
||||
|
||||
expect(withAI.tools.map((tool) => tool.name)).toEqual([
|
||||
'searchDocumentation',
|
||||
'getPage',
|
||||
'askQuestion',
|
||||
'sendFeedback',
|
||||
]);
|
||||
expect(withoutAI.tools.map((tool) => tool.name)).toEqual([
|
||||
'searchDocumentation',
|
||||
'getPage',
|
||||
'sendFeedback',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serveSiteMcpServerCard', () => {
|
||||
const cardRequest = (init?: RequestInit) =>
|
||||
new Request('https://docs.acme.org/~gitbook/mcp/server-card', init);
|
||||
|
||||
it('serves the card with the media type and CORS headers the extension requires', async () => {
|
||||
const res = await serveSiteMcpServerCard(makeContext(), cardRequest());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe(`${SERVER_CARD_MEDIA_TYPE}; charset=utf-8`);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(res.headers.get('access-control-allow-methods')).toBe('GET');
|
||||
expect(res.headers.get('access-control-allow-headers')).toBe('Content-Type, If-None-Match');
|
||||
expect(res.headers.get('access-control-expose-headers')).toBe('ETag');
|
||||
|
||||
const card = (await res.json()) as SiteMcpServerCard;
|
||||
expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL);
|
||||
});
|
||||
|
||||
it('answers a preflight with a 204', async () => {
|
||||
const res = await serveSiteMcpServerCard(makeContext(), cardRequest({ method: 'OPTIONS' }));
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
||||
});
|
||||
|
||||
it('returns 304 when the client already has the current card', async () => {
|
||||
const context = makeContext();
|
||||
const first = await serveSiteMcpServerCard(context, cardRequest());
|
||||
const etag = first.headers.get('etag');
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const revalidated = await serveSiteMcpServerCard(
|
||||
context,
|
||||
cardRequest({ headers: { 'If-None-Match': `W/${etag}` } })
|
||||
);
|
||||
|
||||
expect(revalidated.status).toBe(304);
|
||||
expect(revalidated.headers.get('etag')).toBe(etag);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ visibility: SiteVisibility.Public, cacheControl: 'public, max-age=3600' },
|
||||
{ visibility: SiteVisibility.Unlisted, cacheControl: 'public, max-age=3600' },
|
||||
{ visibility: SiteVisibility.ShareLink, cacheControl: 'no-store' },
|
||||
{ visibility: SiteVisibility.VisitorAuth, cacheControl: 'no-store' },
|
||||
])('caches a $visibility site with $cacheControl', async ({ visibility, cacheControl }) => {
|
||||
const res = await serveSiteMcpServerCard(makeContext({ visibility }), cardRequest());
|
||||
|
||||
expect(res.headers.get('cache-control')).toBe(cacheControl);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: 'no page action is enabled', pageActions: [] },
|
||||
{
|
||||
scenario: 'another page action is enabled',
|
||||
pageActions: [CustomizationPageActionType.Markdown],
|
||||
},
|
||||
])('does not serve a card when $scenario', async ({ pageActions }) => {
|
||||
const res = await serveSiteMcpServerCard(makeContext({ pageActions }), cardRequest());
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { Implementation, ServerCapabilities, Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import packageJSON from '../../../package.json';
|
||||
import { hasAdaptiveMcpEndpoint, isSiteMcpEnabled, mcpEndpointRequiresAuth } from './endpoints';
|
||||
import { type SiteMcpTool, createSiteMcpTools } from './tools';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { serveDiscoveryDocument } from '@/lib/discoveryDocument';
|
||||
|
||||
export const SERVER_CARD_MEDIA_TYPE = 'application/mcp-server-card+json';
|
||||
export const SERVER_CARD_SCHEMA_URL =
|
||||
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json';
|
||||
|
||||
/**
|
||||
* Namespace for every published site's card.
|
||||
*/
|
||||
const SERVER_CARD_NAMESPACE = 'com.gitbook.sites.mcp';
|
||||
|
||||
/**
|
||||
* Version reported on the card and at `initialize` alike, so the two cannot drift.
|
||||
*/
|
||||
export const MCP_SERVER_VERSION = packageJSON.version;
|
||||
|
||||
const MAX_TEXT_LENGTH = 100;
|
||||
|
||||
const TITLE_SUFFIX = ' MCP Server';
|
||||
const DESCRIPTION_PREFIX = 'Search and read ';
|
||||
const DESCRIPTION_ARTICLE = 'the ';
|
||||
const DESCRIPTION_SUFFIX = ' documentation over MCP.';
|
||||
|
||||
/**
|
||||
* A site's MCP Server Card, as defined by
|
||||
* [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127).
|
||||
*
|
||||
* Its schema is still experimental and published neither on npm nor at the `$schema` URL it pins,
|
||||
* so the fields we serve are declared here and their constraints are checked in `serverCard.test.ts`.
|
||||
*/
|
||||
export interface SiteMcpServerCard {
|
||||
/**
|
||||
* Required, and exactly {@link SERVER_CARD_SCHEMA_URL}.
|
||||
*/
|
||||
$schema: string;
|
||||
/**
|
||||
* Required. Reverse-DNS, with exactly one slash separating namespace from server name.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Required. The version the server reports at `initialize`.
|
||||
*/
|
||||
version: string;
|
||||
/**
|
||||
* Required, and at most 100 characters.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Display name, at most 100 characters.
|
||||
*/
|
||||
title: string;
|
||||
websiteUrl: string;
|
||||
icons: {
|
||||
src: string;
|
||||
mimeType?: string;
|
||||
sizes?: string[];
|
||||
}[];
|
||||
remotes: SiteMcpServerCardRemote[];
|
||||
serverInfo: SiteMcpServerInfo;
|
||||
/**
|
||||
* The Streamable HTTP endpoint, the same URL as `remotes[0].url`.
|
||||
*/
|
||||
endpoint: string;
|
||||
capabilities: ServerCapabilities;
|
||||
tools: SiteMcpServerCardTool[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity the server reports at `initialize`, republished on the card so a card.
|
||||
*/
|
||||
export type SiteMcpServerInfo = Implementation & {
|
||||
/**
|
||||
* Required here, though the SDK leaves it optional: a client with no card to read has nothing
|
||||
* else to display, since `name` is the Streamable HTTP endpoint without its scheme.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface SiteMcpServerCardRemote {
|
||||
type: 'streamable-http' | 'sse';
|
||||
url: string;
|
||||
supportedProtocolVersions: string[];
|
||||
headers?: {
|
||||
name: string;
|
||||
description: string;
|
||||
isRequired: boolean;
|
||||
isSecret: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool the server registers, as a client sees it from `tools/list` minus the input schema:
|
||||
* `tools/list` stays authoritative for that.
|
||||
*/
|
||||
export type SiteMcpServerCardTool = Pick<Tool, 'name' | 'description' | 'annotations'>;
|
||||
|
||||
/**
|
||||
* Build the card for a site's MCP server: the metadata an agent can read before it connects.
|
||||
*/
|
||||
export function buildSiteMcpServerCard(
|
||||
context: GitBookSiteContext,
|
||||
tools: SiteMcpTool[]
|
||||
): SiteMcpServerCard {
|
||||
const { linker, site } = context;
|
||||
|
||||
const endpoint = getSiteMcpEndpoint(context);
|
||||
const serverInfo = buildMcpServerInfo(context);
|
||||
|
||||
return {
|
||||
$schema: SERVER_CARD_SCHEMA_URL,
|
||||
// Not the endpoint: the schema allows exactly one slash and no `~`, and a registry keys on
|
||||
// this, so it stays put when a customer moves the site to another domain.
|
||||
name: `${SERVER_CARD_NAMESPACE}/${site.id}`,
|
||||
version: serverInfo.version,
|
||||
title: serverInfo.title,
|
||||
description: buildDescription(site.title),
|
||||
websiteUrl: linker.toAbsoluteURL(linker.toPathInSite('')),
|
||||
icons: [
|
||||
{
|
||||
src: linker.toAbsoluteURL(linker.toPathInSite('~gitbook/icon?size=medium')),
|
||||
mimeType: 'image/png',
|
||||
sizes: ['180x180'],
|
||||
},
|
||||
],
|
||||
remotes: buildRemotes(context, endpoint),
|
||||
serverInfo,
|
||||
endpoint,
|
||||
// What the SDK declares at `initialize` for a server with registered tools. A card that
|
||||
// said otherwise would contradict the live connection.
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
annotations: tool.annotations,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the identity the server reports at `initialize`, which the card republishes verbatim.
|
||||
*/
|
||||
export function buildMcpServerInfo(context: GitBookSiteContext): SiteMcpServerInfo {
|
||||
const endpoint = new URL(getSiteMcpEndpoint(context));
|
||||
|
||||
return {
|
||||
name: `${endpoint.host}${endpoint.pathname}`,
|
||||
title: buildTitle(context.site.title),
|
||||
version: MCP_SERVER_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSiteMcpEndpoint(context: GitBookSiteContext): string {
|
||||
return context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a site's MCP server card.
|
||||
*
|
||||
* A card names the site and lists its tools, so it is gated exactly like the MCP endpoint it
|
||||
* describes.
|
||||
*/
|
||||
export async function serveSiteMcpServerCard(
|
||||
context: GitBookSiteContext,
|
||||
request: Request
|
||||
): Promise<Response> {
|
||||
if (!isSiteMcpEnabled(context)) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return serveDiscoveryDocument(context, request, {
|
||||
document: buildSiteMcpServerCard(context, createSiteMcpTools(context, { request })),
|
||||
mediaType: SERVER_CARD_MEDIA_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the info of the MCP endpoints a client can connect to.
|
||||
*/
|
||||
function buildRemotes(context: GitBookSiteContext, endpoint: string): SiteMcpServerCardRemote[] {
|
||||
const { linker } = context;
|
||||
|
||||
const remotes: SiteMcpServerCardRemote[] = [
|
||||
{
|
||||
type: 'streamable-http',
|
||||
url: endpoint,
|
||||
supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
|
||||
...(mcpEndpointRequiresAuth(context)
|
||||
? {
|
||||
headers: authorizationHeader(
|
||||
'Bearer token obtained from the OAuth 2.0 flow advertised at /.well-known/oauth-protected-resource/~gitbook/mcp. This site is restricted to authenticated visitors.'
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
|
||||
if (hasAdaptiveMcpEndpoint(context)) {
|
||||
remotes.push({
|
||||
type: 'streamable-http',
|
||||
url: linker.toAbsoluteURL(linker.toPathInSite('~gitbook/mcp/auth')),
|
||||
supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
|
||||
headers: authorizationHeader(
|
||||
'Bearer token obtained from the OAuth 2.0 flow advertised at /.well-known/oauth-protected-resource/~gitbook/mcp/auth. Serves the content adapted to the authenticated visitor; connect to the public endpoint to read the site as an anonymous one.'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return remotes;
|
||||
}
|
||||
|
||||
function authorizationHeader(description: string): SiteMcpServerCardRemote['headers'] {
|
||||
return [{ name: 'Authorization', description, isRequired: true, isSecret: true }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the server after the site it serves, within the 100 characters the schema allows a `title`.
|
||||
*/
|
||||
function buildTitle(siteTitle: string): string {
|
||||
return `${truncate(siteTitle, MAX_TEXT_LENGTH - TITLE_SUFFIX.length)}${TITLE_SUFFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit the site's title into the 100 characters the schema allows a `description`.
|
||||
*/
|
||||
function buildDescription(siteTitle: string): string {
|
||||
// Drop the article for a title that carries its own, which would read "read the The Acme Docs".
|
||||
const article = /^the\s/i.test(siteTitle.trim()) ? '' : DESCRIPTION_ARTICLE;
|
||||
const budget =
|
||||
MAX_TEXT_LENGTH - DESCRIPTION_PREFIX.length - article.length - DESCRIPTION_SUFFIX.length;
|
||||
return `${DESCRIPTION_PREFIX}${article}${truncate(siteTitle, budget)}${DESCRIPTION_SUFFIX}`;
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
const normalized = value.trim();
|
||||
return normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ZodRawShape } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AgentFeedbackSource, SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import {
|
||||
AGENT_FEEDBACK_GOAL_MAX_LENGTH,
|
||||
AGENT_FEEDBACK_MAX_LENGTH,
|
||||
agentFeedbackDescriptions,
|
||||
parseAgentFeedbackPageURL,
|
||||
} from '@/lib/agentFeedback';
|
||||
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
|
||||
/**
|
||||
* One tool the site's MCP server exposes.
|
||||
*/
|
||||
export interface SiteMcpTool<Args extends ZodRawShape = ZodRawShape> {
|
||||
name: string;
|
||||
description: string;
|
||||
/**
|
||||
* Zod shape of the tool arguments, passed as-is to `server.tool`.
|
||||
*/
|
||||
inputSchema: Args;
|
||||
annotations: ToolAnnotations;
|
||||
handler: ToolCallback<Args>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tools the site's MCP server exposes, for the site the request is served from.
|
||||
*/
|
||||
export function createSiteMcpTools(
|
||||
context: GitBookSiteContext,
|
||||
options: {
|
||||
/**
|
||||
* The MCP request being served, attached to the insights events the tools emit.
|
||||
*/
|
||||
request: Request;
|
||||
}
|
||||
): SiteMcpTool[] {
|
||||
const { request } = options;
|
||||
const { dataFetcher, linker, site } = context;
|
||||
const siteUrl = context.siteSpace.urls.published;
|
||||
|
||||
const tools: SiteMcpTool[] = [
|
||||
defineTool({
|
||||
name: 'searchDocumentation',
|
||||
description: `Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
inputSchema: {
|
||||
query: z.string(),
|
||||
},
|
||||
annotations: {
|
||||
title: 'Search documentation',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
handler: async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: results.flatMap((result) => {
|
||||
if (result.type === 'record') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${result.title}`,
|
||||
`Link: ${result.url}`,
|
||||
result.description ? `Content: ${result.description}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === result.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
// The search API returns sections ordered highest-score-first, so
|
||||
// the first section with a body is the best-scoring preview.
|
||||
const body = (pageResult.sections ?? []).find(
|
||||
(section) => section.body
|
||||
)?.body;
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
name: 'getPage',
|
||||
description: `Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
|
||||
inputSchema: {
|
||||
url: z
|
||||
.string()
|
||||
.describe('The URL of the page to fetch')
|
||||
.transform((value, ctx) => {
|
||||
if (URL.canParse(value)) {
|
||||
return value;
|
||||
}
|
||||
if (URL.canParse(`https://${value}`)) {
|
||||
return `https://${value}`;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}),
|
||||
},
|
||||
annotations: {
|
||||
title: 'Get page content',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
handler: async ({ url }) => {
|
||||
try {
|
||||
const match = findSiteSpaceByUrl(context.structure, url);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: match.siteSpace.space.id,
|
||||
revisionId: match.siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
|
||||
const resolved = resolveSiteSpacePagePath(
|
||||
match.siteSpace,
|
||||
revision.pages,
|
||||
match.pagePath
|
||||
);
|
||||
if (!resolved) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const markdown = await getMarkdownForPageInSpace(
|
||||
context,
|
||||
match.siteSpace,
|
||||
resolved.page
|
||||
);
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'page_view',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
page: resolved.page.id,
|
||||
space: match.siteSpace.space.id,
|
||||
revision: match.siteSpace.space.revision,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return { content: [{ type: 'text', text: markdown }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Only expose the answer tool when the site has AI enabled, since it relies on
|
||||
// the same AI search backend that powers the site's "ask a question" experience.
|
||||
if (isAIEnabled(context.customization.ai.mode)) {
|
||||
tools.push(
|
||||
defineTool({
|
||||
name: 'askQuestion',
|
||||
description: `Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`,
|
||||
inputSchema: {
|
||||
question: z
|
||||
.string()
|
||||
.describe(`The natural-language question to answer about ${site.title}.`),
|
||||
goal: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.'
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: 'Ask a question',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
handler: async ({ question, goal }) => {
|
||||
try {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Please provide a question to answer.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedGoal = goal?.trim() || undefined;
|
||||
|
||||
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
|
||||
goal: trimmedGoal,
|
||||
});
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'ask_question',
|
||||
query: trimmedQuestion,
|
||||
...(trimmedGoal ? { goal: trimmedGoal } : {}),
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
if (!answer || !answer.answer || !('markdown' in answer.answer)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "We couldn't answer this question.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const answerMarkdown = toPageMarkdown(
|
||||
await fromPageMarkdown(context, {
|
||||
markdown: answer.answer.markdown,
|
||||
pagePath: '',
|
||||
})
|
||||
);
|
||||
const sourcesMarkdown = await renderAskSourcesMarkdown(
|
||||
context,
|
||||
answer.sources ?? []
|
||||
);
|
||||
|
||||
let text = answerMarkdown.trim();
|
||||
if (sourcesMarkdown) {
|
||||
text += `\n\n# Sources\n\n${sourcesMarkdown}`;
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
tools.push(
|
||||
defineTool({
|
||||
name: 'sendFeedback',
|
||||
description: `Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
|
||||
inputSchema: {
|
||||
content: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AGENT_FEEDBACK_MAX_LENGTH)
|
||||
.describe(agentFeedbackDescriptions.finding),
|
||||
pageUrl: z
|
||||
.string()
|
||||
.describe(agentFeedbackDescriptions.pageURL(siteUrl))
|
||||
.transform((value, ctx) => {
|
||||
const url = parseAgentFeedbackPageURL(value, siteUrl);
|
||||
if (!url) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return url;
|
||||
}),
|
||||
goal: z
|
||||
.string()
|
||||
.max(AGENT_FEEDBACK_GOAL_MAX_LENGTH)
|
||||
.optional()
|
||||
.describe(agentFeedbackDescriptions.goal),
|
||||
},
|
||||
annotations: {
|
||||
title: 'Send feedback',
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
handler: async ({ content, pageUrl, goal }) => {
|
||||
try {
|
||||
const result = await submitAgentFeedback(context, {
|
||||
feedback: content,
|
||||
goal,
|
||||
page: pageUrl,
|
||||
source: AgentFeedbackSource.Mcp,
|
||||
});
|
||||
|
||||
if (!result.submitted) {
|
||||
return {
|
||||
content: [{ type: 'text', text: result.error }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'agent_feedback',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
page: result.page.pageId,
|
||||
space: result.page.spaceId,
|
||||
revision: result.page.revisionId,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
|
||||
};
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase a tool's argument shape so differently-shaped tools can share one array.
|
||||
*/
|
||||
function defineTool<Args extends ZodRawShape>(tool: SiteMcpTool<Args>): SiteMcpTool {
|
||||
return tool as SiteMcpTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
|
||||
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
|
||||
*/
|
||||
export function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
|
||||
waitUntil(
|
||||
trackServerInsightsEvents(args).catch((error) => {
|
||||
console.error('Failed to track MCP insights event:', error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register tools on the MCP server. Only used by the transport: the server card reads the same
|
||||
* definitions without registering anything.
|
||||
*/
|
||||
export function registerSiteMcpTools(server: McpServer, tools: SiteMcpTool[]) {
|
||||
for (const tool of tools) {
|
||||
server.tool(tool.name, tool.description, tool.inputSchema, tool.annotations, tool.handler);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getExtension } from './paths';
|
||||
import { getExtension, isGitBookInternalPath } from './paths';
|
||||
|
||||
describe('getExtension', () => {
|
||||
it('should return the extension of a path', () => {
|
||||
@@ -15,3 +15,17 @@ describe('getExtension', () => {
|
||||
expect(getExtension('test.with.multiple.dots.txt')).toBe('.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGitBookInternalPath', () => {
|
||||
it('should match paths with a ~gitbook segment', () => {
|
||||
expect(isGitBookInternalPath('/2024.4/~gitbook/pdf')).toBe(true);
|
||||
expect(isGitBookInternalPath('/docs/~gitbook')).toBe(true);
|
||||
expect(isGitBookInternalPath('~gitbook/pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match paths that only contain ~gitbook as part of a segment', () => {
|
||||
expect(isGitBookInternalPath('/docs/getting-started')).toBe(false);
|
||||
expect(isGitBookInternalPath('/docs/~gitbooks/pdf')).toBe(false);
|
||||
expect(isGitBookInternalPath('/docs/my~gitbook/pdf')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user