mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-02 18:23:49 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dca38796d | |||
| e6b8502036 | |||
| 3837c484a6 | |||
| 4dfe630a70 | |||
| 26ec43e870 | |||
| 063e6851e7 | |||
| 04f39f1472 | |||
| 1383a798cb | |||
| 4a72ead12a | |||
| ea018507c7 | |||
| 347a1cc006 | |||
| a512777351 | |||
| 508f1bc132 | |||
| 38f60f2633 | |||
| be658e1b32 | |||
| e745c88166 | |||
| f61578f01f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Preconnect each asset origin once, skip same-origin icon URLs, and open the fonts preconnect in CORS mode so font requests can reuse it.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Stop caching failed searches, so a rate-limited or failing search is retried on the next query instead of being served from cache for 15 minutes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Declare the Tailwind layer order in each component stylesheet, so a route whose first stylesheet is one of them still puts `base` before `components` and preflight does not strip their padding and borders.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Load the inline expression runtime only on pages whose code blocks or text use expressions, so other pages ship less JavaScript.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
---
|
||||
|
||||
Load a webframe only once it is near the viewport. A page can hold hundreds of them inside collapsed expandables, and loading every one on mount held up the first paint.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Pack the class lists that every block, heading anchor and table cell repeats into shared CSS classes, and carry a table cell's width once as a custom property instead of spelling it out as both width and min-width, so a large page carries far less markup.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the MCP `getPage` tool returning "Page not found" for the Markdown URL of a page (`<page>.md`).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add a search input to filter models by name on OpenAPI models pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Set `Partitioned` on visitor cookies set inside an iframe so authenticated embeds work cross-site.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix encoded or non-normalized links on proxied sites redirecting to the internal proxy path instead of the public URL.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support a secondary brand colour for secondary buttons and `$secondary` text, background, icon and tag colours. Sites without one render as before.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Resolve dark-mode image URLs in published Markdown.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
---
|
||||
|
||||
Bump @scalar/openapi-parser to 0.29 so valid OpenAPI 3.2 documents no longer report false validation errors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Only apply a site preview customization override to the section or variant it was made for.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Render soft line breaks in paragraphs that contain buttons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix visitors being logged out right after signing in on sites with a logout link in the header.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
|
||||
@@ -251,8 +251,8 @@
|
||||
"name": "@gitbook/openapi-parser",
|
||||
"version": "3.0.13",
|
||||
"dependencies": {
|
||||
"@scalar/json-magic": "^0.9.4",
|
||||
"@scalar/openapi-parser": "^0.24.5",
|
||||
"@scalar/json-magic": "^0.17.0",
|
||||
"@scalar/openapi-parser": "^0.29.19",
|
||||
"@scalar/openapi-types": "^0.5.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -356,7 +356,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.206.0",
|
||||
"@gitbook/api": "0.208.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -724,7 +724,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.206.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ctgnDSQ26ZeZvdU/Zb1xxU97yMquKnhGiA0QblAIzKUmfi/3aYmWYHAwXwvEg0wtBrEtrjzme9pXgX/ACWnANA=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.208.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-i5rnGYNvgp8PDGd9FRLUjOx5gS/D7mFfikwp0Lsem4pgEG8cqBtZsDaYbLBdAdupOuZBLJtmEut51zhttok6OQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -1148,23 +1148,27 @@
|
||||
|
||||
"@scalar/draggable": ["@scalar/draggable@0.2.0", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-UetHRB5Bqo5egVYlS21roWBcICmyk8CKh2htsidO+bFGAjl2e7Te+rY0borhNrMclr0xezHlPuLpEs1dvgLS2g=="],
|
||||
|
||||
"@scalar/helpers": ["@scalar/helpers@0.2.9", "", {}, "sha512-Y4ffJF0yELdwZ0BKgonqn3SumIgRn1WKyYCVHD+TDM7qRFChdGRypyt20+efHs26fmJeyBAIIv2laICj5uimiw=="],
|
||||
"@scalar/helpers": ["@scalar/helpers@0.16.4", "", {}, "sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw=="],
|
||||
|
||||
"@scalar/icons": ["@scalar/icons@0.4.7", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^22.9.0", "chalk": "^5.4.1", "vue": "^3.5.17" } }, "sha512-0qXPGRdZ180TMfejWCPYy7ILszBrAraq4KBhPtcM12ghc5qkncFWWpTm5yXI/vrbm10t7wvtTK08CLZ36CnXlQ=="],
|
||||
|
||||
"@scalar/import": ["@scalar/import@0.4.31", "", { "dependencies": { "@scalar/helpers": "0.0.12", "@scalar/openapi-parser": "0.22.3", "yaml": "2.8.0" } }, "sha512-3+3dHz+EvS/C4YEQ8eEihZFVxRVZODsMEHsp48tvXTaIs1Qxb4kf3Nkaf9E3k+6rpM2CUrF59K1P8LvhXob48w=="],
|
||||
|
||||
"@scalar/json-magic": ["@scalar/json-magic@0.9.4", "", { "dependencies": { "@scalar/helpers": "0.2.9", "yaml": "^2.8.0" } }, "sha512-PyfyWrH4ZkW0TM1ColiiHj4NRF8hUM61H0UzAkHLhRNnKFxi6hI+oqNrwqPnyk93hrpkpTRHC7Fl5T0BRwuzVg=="],
|
||||
"@scalar/json-magic": ["@scalar/json-magic@0.17.0", "", { "dependencies": { "@scalar/helpers": "0.16.4", "pathe": "^2.0.3", "undici": "8.12.3", "yaml": "^2.9.1" } }, "sha512-YmJAO9NoGCcU/vx7P4Dy9DGv+Vntp+ny3Fh99slHOOh/IZxvbdK5hWeZdzQ3x+ezab9XJNscFoX8H/P6W+H8lA=="],
|
||||
|
||||
"@scalar/json-schema-validator": ["@scalar/json-schema-validator@0.1.17", "", { "dependencies": { "@scalar/helpers": "0.16.4", "@scalar/types": "0.26.0", "ajv": "^8.20.0", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "yaml": "^2.9.1" } }, "sha512-4Kmbvpng0V0kgPyqDWUZ1qstKjtDfz7seUCK1/inHjeQ+EJIcXPhoW47sOXf9yQBPbSjp42Wt8a7MZvizyoCPg=="],
|
||||
|
||||
"@scalar/oas-utils": ["@scalar/oas-utils@0.6.3", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.1.1", "@scalar/json-magic": "0.8.1", "@scalar/object-utils": "1.2.11", "@scalar/openapi-types": "0.5.1", "@scalar/themes": "0.13.23", "@scalar/types": "0.4.0", "@scalar/workspace-store": "0.20.0", "@types/har-format": "^1.2.15", "flatted": "^3.3.3", "js-base64": "^3.7.8", "microdiff": "^1.5.0", "nanoid": "5.1.5", "type-fest": "5.0.0", "yaml": "2.8.0", "zod": "4.1.11" } }, "sha512-+hEMZSLuTeXlrPqpajusZn2R8qs/OrH2Xstllr3GOCnJz7ATwniD69DT7Il49L1j2HJEtX4EpleRX9OMhdoWHg=="],
|
||||
|
||||
"@scalar/object-utils": ["@scalar/object-utils@1.2.11", "", { "dependencies": { "@scalar/helpers": "0.1.1", "flatted": "^3.3.3", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "5.0.0" } }, "sha512-V44CbfjNuYhoVmVZ36DDlTfyBNF8dMYzLTcDz5zvoSsx+SViXE8ScMd+kgUMHCtSJZV6izV2x3UzIbGQY/pWsQ=="],
|
||||
|
||||
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.24.5", "", { "dependencies": { "@scalar/json-magic": "0.9.4", "@scalar/openapi-types": "0.5.3", "@scalar/openapi-upgrader": "0.1.8", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.0" } }, "sha512-pTeKnmhVdSIfG3vysgDm6jsKc7Do1vXdy/4aqp7j8AEzXllf8RZjSgRSUhtvFYFQCr27fDZ117V3WPQUYtgmCw=="],
|
||||
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.29.19", "", { "dependencies": { "@scalar/helpers": "0.16.4", "@scalar/json-magic": "0.17.0", "@scalar/openapi-types": "0.9.8", "@scalar/openapi-upgrader": "0.4.6", "@scalar/openapi-validator": "0.1.17", "@scalar/types": "0.26.0", "yaml": "^2.9.1" } }, "sha512-ajtjKM9sQQOxEKP/VCtLbM9kQCAa0M4XWKsfadVnPvga1p7t1wPtIW+uDph8sHe+nKyydqwYxlZ8Q6RluxhbXw=="],
|
||||
|
||||
"@scalar/openapi-types": ["@scalar/openapi-types@0.5.3", "", { "dependencies": { "zod": "^4.1.11" } }, "sha512-m4n/Su3K01d15dmdWO1LlqecdSPKuNjuokrJLdiQ485kW/hRHbXW1QP6tJL75myhw/XhX5YhYAR+jrwnGjXiMw=="],
|
||||
|
||||
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.8", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-2xuYLLs0fBadLIk4I1ObjMiCnOyLPEMPf24A1HtHQvhKGDnGlvT63F2rU2Xw8lxCjgHnzveMPnOJEbwIy64RCg=="],
|
||||
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.4.6", "", { "dependencies": { "@scalar/helpers": "0.16.4", "@scalar/openapi-types": "0.9.8" } }, "sha512-wkTbowGZAlDA5AhCAnBVft6y3veoE17P+okH/zrwM/BZWBSzPghGpa4BPTCg3+IzRmRP/8ZeaYO7LfxCDAEmcA=="],
|
||||
|
||||
"@scalar/openapi-validator": ["@scalar/openapi-validator@0.1.17", "", { "dependencies": { "@scalar/helpers": "0.16.4", "@scalar/json-schema-validator": "0.1.17", "@scalar/types": "0.26.0", "yaml": "^2.9.1" } }, "sha512-vBZZgD5k5mp5ii3kNdAti3bZZO0kEvUWYbr9E+Dkjdb67LAdgD5y4Ym0pNIik7prN6KUuUVUu1UMFGFvu1Udqw=="],
|
||||
|
||||
"@scalar/postman-to-openapi": ["@scalar/postman-to-openapi@0.3.40", "", { "dependencies": { "@scalar/helpers": "0.0.12", "@scalar/oas-utils": "0.5.2", "@scalar/openapi-types": "0.5.0" } }, "sha512-FP1p2/mb0Y5GM9hc+TI9ldDM44VV9GHwdhJQ8xGpArtlt8nxtyKmncOXcgayBD7qk3ohV6W1Eftsr258Eq7gGQ=="],
|
||||
|
||||
@@ -1610,7 +1614,7 @@
|
||||
|
||||
"ai": ["ai@4.3.0", "", { "dependencies": { "@ai-sdk/provider": "1.1.0", "@ai-sdk/provider-utils": "2.2.4", "@ai-sdk/react": "1.2.6", "@ai-sdk/ui-utils": "1.2.5", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-PxyQYKhWaU3LiZEpeKRaekVonZIbWdKAwgnqm0CSAxy1MFufmYEC5SM5Mc9uiK2DoHcbAL3d1jyaQ2fSDAJL8w=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||
|
||||
@@ -2358,7 +2362,7 @@
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@1.6.4", "", { "dependencies": { "@types/json-schema": "^7.0.6", "ts-toolbelt": "^6.15.5" } }, "sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stringify-deterministic": ["json-stringify-deterministic@1.0.12", "", {}, "sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g=="],
|
||||
|
||||
@@ -3144,7 +3148,7 @@
|
||||
|
||||
"unconfig": ["unconfig@7.3.3", "", { "dependencies": { "@quansync/fs": "^0.1.5", "defu": "^6.1.4", "jiti": "^2.5.1", "quansync": "^0.2.11" } }, "sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA=="],
|
||||
|
||||
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
"undici": ["undici@8.12.3", "", {}, "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
@@ -3258,7 +3262,7 @@
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
|
||||
|
||||
"yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="],
|
||||
|
||||
@@ -3440,8 +3444,6 @@
|
||||
|
||||
"@mapbox/node-pre-gyp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
@@ -3450,8 +3452,6 @@
|
||||
|
||||
"@opennextjs/aws/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@opennextjs/aws/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
|
||||
|
||||
"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@playwright/test/playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||
@@ -3524,6 +3524,12 @@
|
||||
|
||||
"@scalar/import/yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
|
||||
|
||||
"@scalar/json-magic/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"@scalar/json-schema-validator/@scalar/types": ["@scalar/types@0.26.0", "", { "dependencies": { "@scalar/helpers": "0.16.4", "nanoid": "^5.1.16", "type-fest": "^5.10.0", "zod": "^4.6.7" } }, "sha512-T78Dj1Q+W0E1RsMyPoydfR+L+p9IUIkntBCRYlWaRibM98jAxABRYGQoU/qDHX0PR1IAFdE+d7ZqslypZbJzcg=="],
|
||||
|
||||
"@scalar/json-schema-validator/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"@scalar/oas-utils/@scalar/helpers": ["@scalar/helpers@0.1.1", "", {}, "sha512-eJjuCI5djqU0adTwrHvpDf0xuwNRpwZinCfJ03QjnmIFBM9Ch0u3tn/0EZOhcVNbdEyJ+3yvSVy1dCmmvgtpvQ=="],
|
||||
|
||||
"@scalar/oas-utils/@scalar/json-magic": ["@scalar/json-magic@0.8.1", "", { "dependencies": { "@scalar/helpers": "0.1.1", "xxhash-wasm": "^1.1.0", "yaml": "2.8.0" } }, "sha512-PtG0gJxw+iE9492pqy9V+ZbnfRnycIxTAxknKQCOGRyFYfDvYSs0/uyFCHoQPl7HEZoNNA7q/fr0LBZXMRzv7g=="],
|
||||
@@ -3536,9 +3542,15 @@
|
||||
|
||||
"@scalar/object-utils/@scalar/helpers": ["@scalar/helpers@0.1.1", "", {}, "sha512-eJjuCI5djqU0adTwrHvpDf0xuwNRpwZinCfJ03QjnmIFBM9Ch0u3tn/0EZOhcVNbdEyJ+3yvSVy1dCmmvgtpvQ=="],
|
||||
|
||||
"@scalar/openapi-parser/leven": ["leven@4.0.0", "", {}, "sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw=="],
|
||||
"@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.9.8", "", {}, "sha512-pGcpkZObsGBC7nRKYte+6gstoe0C4hp7B2UWQYjg5To1wE68s3OHVvin+muL3tn3ZAoahXeJAkn1+aEcMi802w=="],
|
||||
|
||||
"@scalar/openapi-types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
"@scalar/openapi-parser/@scalar/types": ["@scalar/types@0.26.0", "", { "dependencies": { "@scalar/helpers": "0.16.4", "nanoid": "^5.1.16", "type-fest": "^5.10.0", "zod": "^4.6.7" } }, "sha512-T78Dj1Q+W0E1RsMyPoydfR+L+p9IUIkntBCRYlWaRibM98jAxABRYGQoU/qDHX0PR1IAFdE+d7ZqslypZbJzcg=="],
|
||||
|
||||
"@scalar/openapi-types/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.9.8", "", {}, "sha512-pGcpkZObsGBC7nRKYte+6gstoe0C4hp7B2UWQYjg5To1wE68s3OHVvin+muL3tn3ZAoahXeJAkn1+aEcMi802w=="],
|
||||
|
||||
"@scalar/openapi-validator/@scalar/types": ["@scalar/types@0.26.0", "", { "dependencies": { "@scalar/helpers": "0.16.4", "nanoid": "^5.1.16", "type-fest": "^5.10.0", "zod": "^4.6.7" } }, "sha512-T78Dj1Q+W0E1RsMyPoydfR+L+p9IUIkntBCRYlWaRibM98jAxABRYGQoU/qDHX0PR1IAFdE+d7ZqslypZbJzcg=="],
|
||||
|
||||
"@scalar/postman-to-openapi/@scalar/helpers": ["@scalar/helpers@0.0.12", "", {}, "sha512-4NDmHShyi1hrVRsJCdRZT/FIpy+/5PFbVbQLRYX/pjpu5cYqHBj9s6n5RI6gGDXEBHAIFi63g9FC6Isgr66l1Q=="],
|
||||
|
||||
@@ -3608,6 +3620,8 @@
|
||||
|
||||
"@vercel/backends/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="],
|
||||
|
||||
"@vercel/blob/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@vercel/express/fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="],
|
||||
|
||||
"@vercel/express/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
@@ -3682,6 +3696,10 @@
|
||||
|
||||
"acorn-walk/acorn": ["acorn@8.12.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg=="],
|
||||
|
||||
"ajv-draft-04/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"aria-hidden/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="],
|
||||
@@ -3906,6 +3924,8 @@
|
||||
|
||||
"swr/use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="],
|
||||
|
||||
"table/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"tar/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
@@ -3928,7 +3948,7 @@
|
||||
|
||||
"youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"zod-to-json-schema/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"zod-to-json-schema/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@ampproject/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
@@ -4040,8 +4060,6 @@
|
||||
|
||||
"@jridgewell/source-map/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"@node-minify/core/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
@@ -4076,6 +4094,8 @@
|
||||
|
||||
"@scalar/api-client/@scalar/openapi-parser/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.5.0" } }, "sha512-iROhcgy3vge6zsviPtoTLHale0nYt1PLhuMmJweQwJ0U23ZYyYhV5xgHtAd0OBEXuqT6rjYbJFvKOJZmJOwpNQ=="],
|
||||
|
||||
"@scalar/api-client/@scalar/openapi-parser/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"@scalar/api-client/@scalar/openapi-parser/leven": ["leven@4.0.0", "", {}, "sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw=="],
|
||||
|
||||
"@scalar/api-client/@scalar/workspace-store/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.5.0" } }, "sha512-iROhcgy3vge6zsviPtoTLHale0nYt1PLhuMmJweQwJ0U23ZYyYhV5xgHtAd0OBEXuqT6rjYbJFvKOJZmJOwpNQ=="],
|
||||
@@ -4112,8 +4132,30 @@
|
||||
|
||||
"@scalar/import/@scalar/openapi-parser/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.5.0" } }, "sha512-iROhcgy3vge6zsviPtoTLHale0nYt1PLhuMmJweQwJ0U23ZYyYhV5xgHtAd0OBEXuqT6rjYbJFvKOJZmJOwpNQ=="],
|
||||
|
||||
"@scalar/import/@scalar/openapi-parser/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"@scalar/import/@scalar/openapi-parser/leven": ["leven@4.0.0", "", {}, "sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw=="],
|
||||
|
||||
"@scalar/json-schema-validator/@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/json-schema-validator/@scalar/types/type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="],
|
||||
|
||||
"@scalar/json-schema-validator/@scalar/types/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@scalar/json-schema-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@scalar/openapi-parser/@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/openapi-parser/@scalar/types/type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="],
|
||||
|
||||
"@scalar/openapi-parser/@scalar/types/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@scalar/openapi-validator/@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/openapi-validator/@scalar/types/type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="],
|
||||
|
||||
"@scalar/openapi-validator/@scalar/types/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/json-magic": ["@scalar/json-magic@0.6.1", "", { "dependencies": { "@scalar/helpers": "0.0.12", "yaml": "2.8.0" } }, "sha512-HJMPY5dUU3EXVS4EkjAFXo+uCrby/YFu/gljKDQnhYWRy5zQ0sJWrOEDcHS8nLoJRCIRD5tiVpCxnUnSb6OoAQ=="],
|
||||
|
||||
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/object-utils": ["@scalar/object-utils@1.2.8", "", { "dependencies": { "@scalar/helpers": "0.0.12", "flatted": "^3.3.3", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "5.0.0" } }, "sha512-FjPxEg7Hw0tz2iTKvi+gYt+luZK0TqhX50hUIBuaYa/Ja/OMuKLp9QHhB5U68F1L55CZNP4vwoNNfXeYWnVEZg=="],
|
||||
@@ -4234,6 +4276,8 @@
|
||||
|
||||
"@vercel/rust/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@vercel/static-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
@@ -4254,6 +4298,10 @@
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
|
||||
|
||||
"ajv-draft-04/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"body-parser/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
@@ -4404,6 +4452,8 @@
|
||||
|
||||
"stylelint/postcss/nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
|
||||
|
||||
"table/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
|
||||
|
||||
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
|
||||
@@ -4450,6 +4500,8 @@
|
||||
|
||||
"@opennextjs/aws/express/serve-static/send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
|
||||
|
||||
"@scalar/api-client/@scalar/openapi-parser/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@scalar/components/@scalar/oas-utils/@scalar/workspace-store/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.5.0" } }, "sha512-iROhcgy3vge6zsviPtoTLHale0nYt1PLhuMmJweQwJ0U23ZYyYhV5xgHtAd0OBEXuqT6rjYbJFvKOJZmJOwpNQ=="],
|
||||
|
||||
"@scalar/components/@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.5.0", "", { "dependencies": { "zod": "4.1.11" } }, "sha512-HJBcLa+/mPP+3TCcQngj/iW5UqynRosOQdEETXjmdy6Ngw8wBjwIcT6C86J5jufJ6sI8++HYnt+e7pAvp5FO6A=="],
|
||||
@@ -4464,6 +4516,8 @@
|
||||
|
||||
"@scalar/import/@scalar/openapi-parser/@scalar/openapi-types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@scalar/import/@scalar/openapi-parser/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/workspace-store/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.5.0" } }, "sha512-iROhcgy3vge6zsviPtoTLHale0nYt1PLhuMmJweQwJ0U23ZYyYhV5xgHtAd0OBEXuqT6rjYbJFvKOJZmJOwpNQ=="],
|
||||
|
||||
"@scalar/use-toasts/vue/@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.206.0",
|
||||
"@gitbook/api": "0.208.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
+16
-11
@@ -1,4 +1,3 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
} from '@/app/utils';
|
||||
import { getVisitorAuthBasePath } from '@/lib/data';
|
||||
import { resolveUpstreamAuthURL } from '@/lib/site-auth-urls';
|
||||
import { getVisitorAuthCookieName } from '@/lib/visitors';
|
||||
import { getVisitorAuthClearCookieHeaders } from '@/lib/visitors';
|
||||
|
||||
/**
|
||||
* Clear the site-scoped auth session cookies and redirect to the upstream auth provider
|
||||
@@ -19,19 +18,18 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
// A router prefetch is not a visitor intent to log out, so it must not clear the session.
|
||||
// Next.js sends different values depending on the prefetch kind, hence the presence check.
|
||||
if (request.headers.has('next-router-prefetch')) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const resolvedParams = await params;
|
||||
const [{ context }, siteURLData, cookieStore] = await Promise.all([
|
||||
const [{ context }, siteURLData] = await Promise.all([
|
||||
getDynamicSiteContext(resolvedParams),
|
||||
getSiteURLDataFromParams(resolvedParams),
|
||||
cookies(),
|
||||
]);
|
||||
|
||||
cookieStore.delete(
|
||||
getVisitorAuthCookieName(
|
||||
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
|
||||
)
|
||||
);
|
||||
|
||||
const noLogoutFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
|
||||
const logoutURL = resolveUpstreamAuthURL({
|
||||
siteAuthURL: context.site.urls.logout,
|
||||
@@ -41,5 +39,12 @@ export async function GET(
|
||||
location: request.nextUrl.searchParams.get('location') ?? '/',
|
||||
});
|
||||
|
||||
return NextResponse.redirect(logoutURL ?? noLogoutFallbackURL);
|
||||
const response = NextResponse.redirect(logoutURL ?? noLogoutFallbackURL);
|
||||
// Appended as raw headers: `cookies()` keeps one cookie per name.
|
||||
for (const header of getVisitorAuthClearCookieHeaders(
|
||||
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
|
||||
)) {
|
||||
response.headers.append('set-cookie', header);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,6 +1,10 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getSiteURLDataFromParams,
|
||||
getStaticSiteContext,
|
||||
} from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
@@ -18,8 +22,9 @@ export async function GET(
|
||||
) {
|
||||
const { question: encodedQuestion, goal: encodedGoal } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
const goal = decodeURIComponent(encodedGoal);
|
||||
|
||||
return serveAskMarkdown(context, question, { goal });
|
||||
return serveAskMarkdown(context, question, { goal, visitorAgent: askVisitorAgent });
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,6 +1,10 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getSiteURLDataFromParams,
|
||||
getStaticSiteContext,
|
||||
} from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
@@ -11,7 +15,8 @@ export async function GET(
|
||||
) {
|
||||
const { question: encodedQuestion } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
|
||||
return serveAskMarkdown(context, question);
|
||||
return serveAskMarkdown(context, question, { visitorAgent: askVisitorAgent });
|
||||
}
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
AIMessageRole,
|
||||
AIMessageStepPhase,
|
||||
type AIStreamResponse,
|
||||
type AIMessageContext,
|
||||
type AIStreamResponseToolCallPending,
|
||||
SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { getInsightsSession } from '../Insights';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import {
|
||||
type AIChatMessageActivity,
|
||||
type StreamAIChatInput,
|
||||
type StreamAIChatResponse,
|
||||
globalState,
|
||||
} from './useAIChat';
|
||||
import { useLeaveAgentFeedbackTool, useLeaveUserFeedbackTool } from './useLeaveFeedbackTools';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
import {
|
||||
type ResponseToRate,
|
||||
useSubmitAssistantFeedbackTool,
|
||||
} from './useSubmitAssistantFeedbackTool';
|
||||
import { useSubmitPageFeedbackTool } from './useSubmitPageFeedbackTool';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
|
||||
/**
|
||||
* Streams AI responses and runs the assistant's tools; mounted by `AIChatProvider` on first use.
|
||||
*/
|
||||
export function AIChatEngine(props: {
|
||||
renderMessageOptions?: RenderAIMessageOptions;
|
||||
withPageFeedback: boolean;
|
||||
messageContextRef: React.RefObject<AIMessageContext>;
|
||||
responseToRateRef: React.RefObject<ResponseToRate>;
|
||||
reportedAgentFeedbackRef: React.RefObject<boolean>;
|
||||
postMessageRef: React.RefObject<((input: { message: string }) => void) | null>;
|
||||
engineRef: React.RefObject<StreamAIChatResponse | null>;
|
||||
pendingStreams: StreamAIChatInput[];
|
||||
}) {
|
||||
const {
|
||||
renderMessageOptions,
|
||||
withPageFeedback,
|
||||
messageContextRef,
|
||||
responseToRateRef,
|
||||
reportedAgentFeedbackRef,
|
||||
postMessageRef,
|
||||
engineRef,
|
||||
pendingStreams,
|
||||
} = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const displayContext = renderMessageOptions?.asEmbeddable
|
||||
? SiteInsightsDisplayContext.Embed
|
||||
: SiteInsightsDisplayContext.Site;
|
||||
const getResponseToRate = React.useCallback(() => responseToRateRef.current, []);
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page or
|
||||
// assistant feedback). Each tool has a stable identity, so it can be referenced directly from
|
||||
// the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
const submitPageFeedbackTool = useSubmitPageFeedbackTool({ displayContext });
|
||||
const submitAssistantFeedbackTool = useSubmitAssistantFeedbackTool({
|
||||
displayContext,
|
||||
getResponseToRate,
|
||||
});
|
||||
const leaveAgentFeedbackTool = useLeaveAgentFeedbackTool({
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable,
|
||||
displayContext,
|
||||
reportedRef: reportedAgentFeedbackRef,
|
||||
});
|
||||
const leaveUserFeedbackTool = useLeaveUserFeedbackTool({
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable,
|
||||
displayContext,
|
||||
});
|
||||
|
||||
// The assistant-feedback tool is always available (it mirrors the chat's own thumbs up/down
|
||||
// rating), while the tools carrying the reader's own feedback are gated on the site's
|
||||
// "Was this helpful?" setting.
|
||||
const builtInTools = React.useMemo(() => {
|
||||
const tools = [navigateToPageTool, submitAssistantFeedbackTool, leaveAgentFeedbackTool];
|
||||
if (withPageFeedback) {
|
||||
tools.push(submitPageFeedbackTool, leaveUserFeedbackTool);
|
||||
}
|
||||
return tools;
|
||||
}, [
|
||||
navigateToPageTool,
|
||||
submitAssistantFeedbackTool,
|
||||
leaveAgentFeedbackTool,
|
||||
leaveUserFeedbackTool,
|
||||
submitPageFeedbackTool,
|
||||
withPageFeedback,
|
||||
]);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: StreamAIChatInput) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools(builtInTools);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools(builtInTools);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
|
||||
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
...event.suggestions,
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
if ('createControl' in toolDef) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: toolDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: event.toolCall.input as any,
|
||||
language,
|
||||
send: async (result) => {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
// The confirmation can be a static object or a function that
|
||||
// derives it from the AI-provided input (e.g. dynamic context).
|
||||
// The function call is awaited because, for embed-registered
|
||||
// tools, it arrives as an async proxy over the postMessage channel.
|
||||
const resolvedConfirmation =
|
||||
typeof confirmation === 'function'
|
||||
? await confirmation(event.toolCall.input)
|
||||
: confirmation;
|
||||
const supportingContext =
|
||||
typeof resolvedConfirmation.context === 'string'
|
||||
? resolvedConfirmation.context.slice(0, 512)
|
||||
: undefined;
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: {
|
||||
label: resolvedConfirmation.label,
|
||||
icon: resolvedConfirmation.icon,
|
||||
context: supportingContext,
|
||||
},
|
||||
language,
|
||||
send: async (result) => {
|
||||
const output = ConfirmControlOutputSchema.parse(
|
||||
result.output
|
||||
);
|
||||
switch (output.result) {
|
||||
case 'cancelled': {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: { cancelled: true },
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
resolvedConfirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'confirmed':
|
||||
await executeToolCall(event);
|
||||
break;
|
||||
default:
|
||||
assertNever(output.result);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
|
||||
// Turn settled: send the next queued follow-up (oldest first). Held back while a
|
||||
// control is pending, since posting would throw; it flushes after that resolves.
|
||||
const { queuedMessages, control: activeControl } = globalState.getState();
|
||||
const [next, ...rest] = queuedMessages;
|
||||
if (next !== undefined && !activeControl) {
|
||||
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
|
||||
postMessageRef.current?.({ message: next });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
messageContextRef.current,
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
builtInTools,
|
||||
]
|
||||
);
|
||||
|
||||
engineRef.current = streamResponse;
|
||||
|
||||
// Send what was posted while this module was loading.
|
||||
React.useEffect(() => {
|
||||
for (const input of pendingStreams.splice(0)) {
|
||||
void streamResponse(input);
|
||||
}
|
||||
}, [streamResponse, pendingStreams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateAIChatMessageActivity(
|
||||
activity: AIChatMessageActivity,
|
||||
event: AIStreamResponse
|
||||
): AIChatMessageActivity {
|
||||
switch (event.type) {
|
||||
case 'response_step_start': {
|
||||
return {
|
||||
...activity,
|
||||
currentPhase: event.phase,
|
||||
hasFinalAnswer:
|
||||
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
|
||||
};
|
||||
}
|
||||
case 'response_document': {
|
||||
// A commentary phase can start without ever producing anything visible. Only a
|
||||
// commentary step that emits document content is a real preamble worth collapsing
|
||||
// behind the activity heading, so flag it here rather than on phase start.
|
||||
return {
|
||||
...activity,
|
||||
hasCommentary:
|
||||
activity.hasCommentary ||
|
||||
(activity.currentPhase === AIMessageStepPhase.Commentary &&
|
||||
event.blocks.length > 0),
|
||||
};
|
||||
}
|
||||
case 'response_tool_call': {
|
||||
return {
|
||||
...activity,
|
||||
toolCount: activity.toolCount + 1,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
|
||||
return {
|
||||
currentPhase: undefined,
|
||||
toolCount: 0,
|
||||
hasCommentary: false,
|
||||
hasFinalAnswer: false,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
'use server';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import {
|
||||
type AIMessageContext,
|
||||
AIMessageRole,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -72,7 +75,8 @@ export async function* streamAIChatResponse({
|
||||
? SiteCoreChannelType.Embed
|
||||
: SiteCoreChannelType.Site,
|
||||
},
|
||||
}
|
||||
},
|
||||
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
|
||||
);
|
||||
|
||||
return await streamRenderAIMessage(context, rawStream, options);
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import {
|
||||
AIMessageRole,
|
||||
AIMessageStepPhase,
|
||||
type AIStreamResponse,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
SiteInsightsDisplayContext,
|
||||
type SiteInsightsEventAskView,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { type UpdateSearchState, useSetSearchState } from '../Search';
|
||||
import { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { setIntercomLauncherHidden } from './intercom';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
import type { RenderAIMessageOptions } from './server-actions';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import type { ResponseToRate } from './useSubmitAssistantFeedbackTool';
|
||||
import { useLeaveAgentFeedbackTool, useLeaveUserFeedbackTool } from './useLeaveFeedbackTools';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
import {
|
||||
type ResponseToRate,
|
||||
useSubmitAssistantFeedbackTool,
|
||||
} from './useSubmitAssistantFeedbackTool';
|
||||
import { useSubmitPageFeedbackTool } from './useSubmitPageFeedbackTool';
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
|
||||
const noopSetSearchState: UpdateSearchState = () => Promise.resolve(new URLSearchParams());
|
||||
|
||||
@@ -176,28 +189,10 @@ export type AIChatController = {
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
export type StreamAIChatInput = {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** User-typed prompt; compared against state.query to abort stale streams */
|
||||
userQuery?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
};
|
||||
|
||||
export type StreamAIChatResponse = (input: StreamAIChatInput) => Promise<void>;
|
||||
|
||||
const AIChatEngine = dynamic(() => import('./AIChatEngine').then((mod) => mod.AIChatEngine), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
// Module-level like `globalState`, so a remounted provider still sends what was queued.
|
||||
const pendingStreams: StreamAIChatInput[] = [];
|
||||
|
||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||
|
||||
// Global state store for AI chat
|
||||
export const globalState = zustand.create<AIChatState>(() => {
|
||||
const globalState = zustand.create<AIChatState>(() => {
|
||||
return {
|
||||
opened: false,
|
||||
responseId: null,
|
||||
@@ -249,6 +244,10 @@ export function AIChatProvider(props: {
|
||||
const trackEvent = useTrackEvent();
|
||||
const setSearchStateInURL = useSetSearchState();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
const displayContext = renderMessageOptions?.asEmbeddable
|
||||
? SiteInsightsDisplayContext.Embed
|
||||
: SiteInsightsDisplayContext.Site;
|
||||
|
||||
// The embed keeps its state in its own routes, not in URL search params.
|
||||
const setSearchState = renderMessageOptions?.asEmbeddable
|
||||
@@ -259,22 +258,48 @@ export function AIChatProvider(props: {
|
||||
// (before it overwrites the store's responseId/query), so the self-feedback tool rates that
|
||||
// previous response rather than the one this reaction turn produces.
|
||||
const responseToRateRef = React.useRef<ResponseToRate>({ responseId: null, query: null });
|
||||
const getResponseToRate = React.useCallback(() => responseToRateRef.current, []);
|
||||
|
||||
// Findings reported to the site's team are capped at one per conversation, so the assistant
|
||||
// cannot flood the inbox over a long chat.
|
||||
const reportedAgentFeedbackRef = React.useRef(false);
|
||||
|
||||
// Streaming, tools and their schemas load on first use to keep them out of the initial bundle.
|
||||
const [engineRequested, setEngineRequested] = React.useState(() => pendingStreams.length > 0);
|
||||
const engineRef = React.useRef<StreamAIChatResponse | null>(null);
|
||||
const streamResponse = React.useCallback((input: StreamAIChatInput) => {
|
||||
if (engineRef.current) {
|
||||
void engineRef.current(input);
|
||||
return;
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page or
|
||||
// assistant feedback). Each tool has a stable identity, so it can be referenced directly from
|
||||
// the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
const submitPageFeedbackTool = useSubmitPageFeedbackTool({ displayContext });
|
||||
const submitAssistantFeedbackTool = useSubmitAssistantFeedbackTool({
|
||||
displayContext,
|
||||
getResponseToRate,
|
||||
});
|
||||
const leaveAgentFeedbackTool = useLeaveAgentFeedbackTool({
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable,
|
||||
displayContext,
|
||||
reportedRef: reportedAgentFeedbackRef,
|
||||
});
|
||||
const leaveUserFeedbackTool = useLeaveUserFeedbackTool({
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable,
|
||||
displayContext,
|
||||
});
|
||||
|
||||
// The assistant-feedback tool is always available (it mirrors the chat's own thumbs up/down
|
||||
// rating), while the tools carrying the reader's own feedback are gated on the site's
|
||||
// "Was this helpful?" setting.
|
||||
const builtInTools = React.useMemo(() => {
|
||||
const tools = [navigateToPageTool, submitAssistantFeedbackTool, leaveAgentFeedbackTool];
|
||||
if (withPageFeedback) {
|
||||
tools.push(submitPageFeedbackTool, leaveUserFeedbackTool);
|
||||
}
|
||||
pendingStreams.push(input);
|
||||
setEngineRequested(true);
|
||||
}, []);
|
||||
return tools;
|
||||
}, [
|
||||
navigateToPageTool,
|
||||
submitAssistantFeedbackTool,
|
||||
leaveAgentFeedbackTool,
|
||||
leaveUserFeedbackTool,
|
||||
submitPageFeedbackTool,
|
||||
withPageFeedback,
|
||||
]);
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
@@ -283,7 +308,6 @@ export function AIChatProvider(props: {
|
||||
const onOpen = React.useCallback(
|
||||
(trigger?: AskAITrigger) => {
|
||||
setIntercomLauncherHidden(true);
|
||||
setEngineRequested(true);
|
||||
if (!renderMessageOptions?.asEmbeddable) {
|
||||
trackEvent({ type: 'ask_view', trigger });
|
||||
}
|
||||
@@ -324,6 +348,307 @@ export function AIChatProvider(props: {
|
||||
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
|
||||
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** User-typed prompt; compared against state.query to abort stale streams */
|
||||
userQuery?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools(builtInTools);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools(builtInTools);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
|
||||
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
...event.suggestions,
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
if ('createControl' in toolDef) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: toolDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: event.toolCall.input as any,
|
||||
language,
|
||||
send: async (result) => {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
// The confirmation can be a static object or a function that
|
||||
// derives it from the AI-provided input (e.g. dynamic context).
|
||||
// The function call is awaited because, for embed-registered
|
||||
// tools, it arrives as an async proxy over the postMessage channel.
|
||||
const resolvedConfirmation =
|
||||
typeof confirmation === 'function'
|
||||
? await confirmation(event.toolCall.input)
|
||||
: confirmation;
|
||||
const supportingContext =
|
||||
typeof resolvedConfirmation.context === 'string'
|
||||
? resolvedConfirmation.context.slice(0, 512)
|
||||
: undefined;
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: {
|
||||
label: resolvedConfirmation.label,
|
||||
icon: resolvedConfirmation.icon,
|
||||
context: supportingContext,
|
||||
},
|
||||
language,
|
||||
send: async (result) => {
|
||||
const output = ConfirmControlOutputSchema.parse(
|
||||
result.output
|
||||
);
|
||||
switch (output.result) {
|
||||
case 'cancelled': {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: { cancelled: true },
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
resolvedConfirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'confirmed':
|
||||
await executeToolCall(event);
|
||||
break;
|
||||
default:
|
||||
assertNever(output.result);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
|
||||
// Turn settled: send the next queued follow-up (oldest first). Held back while a
|
||||
// control is pending, since posting would throw; it flushes after that resolves.
|
||||
const { queuedMessages, control: activeControl } = globalState.getState();
|
||||
const [next, ...rest] = queuedMessages;
|
||||
if (next !== undefined && !activeControl) {
|
||||
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
|
||||
postMessageRef.current?.({ message: next });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
messageContextRef.current,
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
builtInTools,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
@@ -422,7 +747,6 @@ export function AIChatProvider(props: {
|
||||
const onClear = React.useCallback(() => {
|
||||
responseToRateRef.current = { responseId: null, query: null };
|
||||
reportedAgentFeedbackRef.current = false;
|
||||
pendingStreams.length = 0;
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
responding: false,
|
||||
@@ -539,18 +863,6 @@ export function AIChatProvider(props: {
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
{engineRequested ? (
|
||||
<AIChatEngine
|
||||
renderMessageOptions={renderMessageOptions}
|
||||
withPageFeedback={withPageFeedback}
|
||||
messageContextRef={messageContextRef}
|
||||
responseToRateRef={responseToRateRef}
|
||||
reportedAgentFeedbackRef={reportedAgentFeedbackRef}
|
||||
postMessageRef={postMessageRef}
|
||||
engineRef={engineRef}
|
||||
pendingStreams={pendingStreams}
|
||||
/>
|
||||
) : null}
|
||||
</AIChatControllerContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -606,3 +918,48 @@ function getLatestAssistantMessage(messages: AIChatMessage[]) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateAIChatMessageActivity(
|
||||
activity: AIChatMessageActivity,
|
||||
event: AIStreamResponse
|
||||
): AIChatMessageActivity {
|
||||
switch (event.type) {
|
||||
case 'response_step_start': {
|
||||
return {
|
||||
...activity,
|
||||
currentPhase: event.phase,
|
||||
hasFinalAnswer:
|
||||
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
|
||||
};
|
||||
}
|
||||
case 'response_document': {
|
||||
// A commentary phase can start without ever producing anything visible. Only a
|
||||
// commentary step that emits document content is a real preamble worth collapsing
|
||||
// behind the activity heading, so flag it here rather than on phase start.
|
||||
return {
|
||||
...activity,
|
||||
hasCommentary:
|
||||
activity.hasCommentary ||
|
||||
(activity.currentPhase === AIMessageStepPhase.Commentary &&
|
||||
event.blocks.length > 0),
|
||||
};
|
||||
}
|
||||
case 'response_tool_call': {
|
||||
return {
|
||||
...activity,
|
||||
toolCount: activity.toolCount + 1,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
|
||||
return {
|
||||
currentPhase: undefined,
|
||||
toolCount: 0,
|
||||
hasCommentary: false,
|
||||
hasFinalAnswer: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@reference "../RootLayout/globals.css";
|
||||
|
||||
/* This file can be the first stylesheet a route loads, and layer order follows first
|
||||
appearance. Without this, `components` would be created here and Tailwind's `base`
|
||||
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
/* Every block on every page carries these, so they live here rather than in each class attribute. */
|
||||
@layer components {
|
||||
.gb-block {
|
||||
@apply decoration-primary/6;
|
||||
}
|
||||
|
||||
/* Lists opt out: a long list is expected to break across printed pages. */
|
||||
.gb-block:not(.gb-block-list) {
|
||||
@apply print:break-inside-avoid;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { DocumentBlock, JSONDocument } from '@gitbook/api';
|
||||
|
||||
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
|
||||
import { Block } from './Block';
|
||||
import './Blocks.css';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { isBlockOffscreen } from './utils';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
@@ -93,10 +94,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
key={node.key || `${node.type}-${index}`}
|
||||
block={node}
|
||||
style={[
|
||||
'decoration-primary/6',
|
||||
'gb-block',
|
||||
FULL_WIDTH_BLOCKS.has(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
|
||||
|
||||
!LIST_BLOCKS.has(node.type) && 'print:break-inside-avoid',
|
||||
LIST_BLOCKS.has(node.type) && 'gb-block-list',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
|
||||
@@ -6,11 +6,10 @@ import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||
import type { InlineExpressionVariables } from '../InlineExpression/types';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import type { HighlightTheme, RenderedInline } from './highlight-tokens';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { Button, ToggleChevron } from '@/components/primitives';
|
||||
@@ -20,6 +19,8 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
/** Only set for blocks with inline expressions, keeping `@gitbook/expr` out of the bundle. */
|
||||
evaluateInlineExpression?: (expression: string) => string;
|
||||
mode: BlockProps<DocumentBlockCode>['context']['mode'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
embedded?: boolean;
|
||||
@@ -34,18 +35,12 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, mode, style, inlines, inlineExprVariables, themes, embedded, commentKey } =
|
||||
const { block, mode, style, inlines, evaluateInlineExpression, themes, embedded, commentKey } =
|
||||
props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const isInViewportRef = useRef(false);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
const evaluateInlineExpression = useEvaluateInlineExpression({
|
||||
visitorClaims,
|
||||
variables: inlineExprVariables,
|
||||
});
|
||||
const plainTheme = useMemo(
|
||||
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
|
||||
[block, inlines, evaluateInlineExpression, themes]
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { ClientCodeBlock, type ClientBlockProps } from './ClientCodeBlock';
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useEvaluateInlineExpression } from '@/components/DocumentView/InlineExpression/useEvaluateInlineExpression';
|
||||
|
||||
/**
|
||||
* Code block with inline expressions; its own module because it pulls in `@gitbook/expr`.
|
||||
*/
|
||||
export function ClientCodeBlockWithExpressions(props: ClientBlockProps) {
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const evaluateInlineExpression = useEvaluateInlineExpression({
|
||||
visitorClaims: getAdaptiveVisitorClaims(),
|
||||
variables: props.inlineExprVariables,
|
||||
});
|
||||
|
||||
return <ClientCodeBlock {...props} evaluateInlineExpression={evaluateInlineExpression} />;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { ClientBlockProps } from './ClientCodeBlock';
|
||||
|
||||
// Keeps `@gitbook/expr` out of the eager bundle; `ssr: true` keeps values in the HTML.
|
||||
const ClientCodeBlockWithExpressions = dynamic(
|
||||
() =>
|
||||
import('./ClientCodeBlockWithExpressions').then(
|
||||
(mod) => mod.ClientCodeBlockWithExpressions
|
||||
),
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function ClientCodeBlockWithExpressionsLazy(props: ClientBlockProps) {
|
||||
return <ClientCodeBlockWithExpressions {...props} />;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { getCommentKeyProp, getCommentMarkerProps } from '../utils';
|
||||
import { ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { ClientCodeBlockWithExpressionsLazy } from './ClientCodeBlockWithExpressionsLazy';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { highlight } from './highlight';
|
||||
import { type RenderedInline, getInlines } from './highlight-tokens';
|
||||
@@ -130,8 +131,11 @@ export async function CodeBlock(
|
||||
{isMermaid ? (
|
||||
<MermaidCodeBlockLazy
|
||||
{...clientProps}
|
||||
hasInlineExpression={hasInlineExpression}
|
||||
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
|
||||
/>
|
||||
) : hasInlineExpression ? (
|
||||
<ClientCodeBlockWithExpressionsLazy {...clientProps} />
|
||||
) : (
|
||||
<ClientCodeBlock {...clientProps} />
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { ClientCodeBlockWithExpressions } from './ClientCodeBlockWithExpressions';
|
||||
import { getPlainCodeBlock } from './highlight-tokens';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
@@ -20,6 +21,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
export function MermaidCodeBlock(
|
||||
props: ClientBlockProps & {
|
||||
mermaidRuntimeURL: string;
|
||||
hasInlineExpression: boolean;
|
||||
}
|
||||
) {
|
||||
const { block, mode, style, mermaidRuntimeURL } = props;
|
||||
@@ -169,7 +171,11 @@ export function MermaidCodeBlock(
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
return props.hasInlineExpression ? (
|
||||
<ClientCodeBlockWithExpressions {...props} />
|
||||
) : (
|
||||
<ClientCodeBlock {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// The live diagram subtree. It is portaled into a stable host that moves between the
|
||||
|
||||
@@ -11,6 +11,8 @@ const MermaidCodeBlock = dynamic(
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
|
||||
export function MermaidCodeBlockLazy(
|
||||
props: ClientBlockProps & { mermaidRuntimeURL: string; hasInlineExpression: boolean }
|
||||
) {
|
||||
return <MermaidCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@reference "../RootLayout/globals.css";
|
||||
|
||||
/* This file can be the first stylesheet a route loads, and layer order follows first
|
||||
appearance. Without this, `components` would be created here and Tailwind's `base`
|
||||
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
/* A page carries one of these per heading, so the shared utilities live here instead of in a class
|
||||
attribute repeated hundreds of times — on a large page that repetition is most of the HTML.
|
||||
They sit in `components` so the utilities passed as props still override them. */
|
||||
@layer components {
|
||||
.hash {
|
||||
@apply relative inline-grid grid-area-1-1 border-0 rounded site-background;
|
||||
@apply pointer-events-none opacity-0 transition-opacity;
|
||||
@apply pointer-fine:h-[1em] pointer-fine:pointer-events-auto;
|
||||
@apply pointer-fine:group-hover/hash:opacity-100 pointer-fine:group-focus-within/hash:opacity-100;
|
||||
@apply [.hash-revealed_&]:pointer-events-auto [.hash-revealed_&]:opacity-100;
|
||||
}
|
||||
|
||||
.hash-link {
|
||||
@apply inline-flex items-center p-1 pointer-fine:h-full pointer-fine:p-0;
|
||||
}
|
||||
|
||||
.hash-icon {
|
||||
@apply size-3 self-center text-transparent transition-colors;
|
||||
@apply pointer-fine:group-hover/hash:text-tint-subtle contrast-more:pointer-fine:group-hover/hash:text-tint-strong;
|
||||
@apply pointer-fine:group-focus-within/hash:text-tint-subtle contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong;
|
||||
@apply [.hash-revealed_&]:text-tint-subtle contrast-more:[.hash-revealed_&]:text-tint-strong;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import type { DocumentContext } from './DocumentView';
|
||||
import './HashLinkButton.css';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -35,53 +36,13 @@ export function HashLinkButton(props: {
|
||||
const textStyle = getBlockTextStyle(block);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={tcls(
|
||||
'relative',
|
||||
'hash',
|
||||
'inline-grid',
|
||||
'grid-area-1-1',
|
||||
'pointer-fine:h-[1em]',
|
||||
'border-0',
|
||||
'opacity-0',
|
||||
'pointer-events-none',
|
||||
'site-background',
|
||||
'rounded',
|
||||
'transition-opacity',
|
||||
'pointer-fine:pointer-events-auto',
|
||||
'pointer-fine:group-hover/hash:opacity-100',
|
||||
'pointer-fine:group-focus-within/hash:opacity-100',
|
||||
'[.hash-revealed_&]:opacity-100',
|
||||
'[.hash-revealed_&]:pointer-events-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={tcls('hash', className)}>
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
aria-label={label}
|
||||
className={tcls(
|
||||
'inline-flex items-center',
|
||||
'p-1',
|
||||
'pointer-fine:h-full pointer-fine:p-0',
|
||||
textStyle.lineHeight
|
||||
)}
|
||||
className={tcls('hash-link', textStyle.lineHeight)}
|
||||
>
|
||||
<Icon
|
||||
icon="hashtag"
|
||||
className={tcls(
|
||||
'size-3',
|
||||
'self-center',
|
||||
'transition-colors',
|
||||
'text-transparent',
|
||||
'pointer-fine:group-hover/hash:text-tint-subtle',
|
||||
'contrast-more:pointer-fine:group-hover/hash:text-tint-strong',
|
||||
'pointer-fine:group-focus-within/hash:text-tint-subtle',
|
||||
'contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong',
|
||||
'[.hash-revealed_&]:text-tint-subtle',
|
||||
'contrast-more:[.hash-revealed_&]:text-tint-strong',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
<Icon icon="hashtag" className={tcls('hash-icon', iconClassName)} />
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getSelectAction } from './selectAction';
|
||||
import { SelectActionButton } from './SelectActionButton';
|
||||
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
|
||||
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
|
||||
const BUTTON_SIZE_MAP: Record<
|
||||
@@ -23,6 +24,28 @@ const BUTTON_SIZE_MAP: Record<
|
||||
large: 'medium',
|
||||
};
|
||||
|
||||
// Only content buttons take the site's secondary colour; the `secondary` variant also styles site chrome.
|
||||
const SECONDARY_COLOR_CLASSES = tcls(
|
||||
'bg-secondary',
|
||||
'depth-flat:bg-secondary-base',
|
||||
'text-secondary',
|
||||
'hover:bg-secondary-hover',
|
||||
'hover:not-disabled:depth-flat:bg-secondary-hover',
|
||||
'hover:not-disabled:text-secondary',
|
||||
'contrast-more:bg-secondary-subtle',
|
||||
'disabled:text-secondary/8',
|
||||
'border-secondary',
|
||||
'hover:border-secondary-hover',
|
||||
'disabled:border-secondary',
|
||||
'shadow-secondary/6',
|
||||
'dark:shadow-secondary-1',
|
||||
'contrast-more:border-secondary-12',
|
||||
'contrast-more:hover:outline-secondary-12',
|
||||
'contrast-more:hover:border-secondary-12',
|
||||
'contrast-more:focus-visible:border-secondary-12',
|
||||
'contrast-more:focus-visible:outline-secondary-12'
|
||||
);
|
||||
|
||||
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
const { inline, context } = props;
|
||||
|
||||
@@ -31,6 +54,7 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
variant: inline.data.kind,
|
||||
icon: inline.data.icon as IconName | undefined,
|
||||
size: BUTTON_SIZE_MAP[inline.data.size ?? 'large'],
|
||||
className: inline.data.kind === 'secondary' ? SECONDARY_COLOR_CLASSES : undefined,
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import type { DocumentInlineExpression } from '@gitbook/api';
|
||||
|
||||
import type { InlineProps } from '../Inline';
|
||||
import { InlineExpressionValue } from './InlineExpressionValue';
|
||||
import { InlineExpressionValueLazy } from './InlineExpressionValueLazy';
|
||||
|
||||
/**
|
||||
* Render an inline expression.
|
||||
@@ -25,7 +25,7 @@ export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<InlineExpressionValue expression={data.expression} variables={variables} />
|
||||
<InlineExpressionValueLazy expression={data.expression} variables={variables} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { InlineExpressionVariables } from './types';
|
||||
|
||||
// Keeps `@gitbook/expr` out of the eager bundle; `ssr: true` keeps values in the HTML.
|
||||
const InlineExpressionValue = dynamic(
|
||||
() => import('./InlineExpressionValue').then((mod) => mod.InlineExpressionValue),
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function InlineExpressionValueLazy(props: {
|
||||
expression: string;
|
||||
variables: InlineExpressionVariables;
|
||||
}) {
|
||||
return <InlineExpressionValue {...props} />;
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './InlineExpression';
|
||||
export * from './types';
|
||||
export * from './useEvaluateInlineExpression';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type React from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
|
||||
import {
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
|
||||
import { ClientPlainCodeBlock } from '../CodeBlock/ClientPlainCodeBlock';
|
||||
import type { DocumentContext } from '../DocumentView';
|
||||
import { matchesText } from '../Table/searchMatch';
|
||||
import { MIN_RECORDS_FOR_SEARCH } from '../Table/shouldShowSearch';
|
||||
import { Input } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
|
||||
// Only what survives the RSC boundary. Rebuilding the renderers on the client is what lets this
|
||||
// whole subtree sit behind a `next/dynamic` chunk instead of the route's eager entry.
|
||||
@@ -74,14 +78,51 @@ export function OpenAPIBlockClient(props: OpenAPIBlockClientProps) {
|
||||
);
|
||||
case 'webhook':
|
||||
return <BaseOpenAPIWebhook data={props.data} context={context} className={className} />;
|
||||
case 'schemas':
|
||||
case 'schemas': {
|
||||
const SchemasComponent =
|
||||
mode !== 'print' && props.data.schemas.length >= MIN_RECORDS_FOR_SEARCH
|
||||
? OpenAPISchemasWithSearch
|
||||
: BaseOpenAPISchemas;
|
||||
return (
|
||||
<BaseOpenAPISchemas
|
||||
<SchemasComponent
|
||||
data={props.data}
|
||||
grouped={props.grouped}
|
||||
context={context}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function OpenAPISchemasWithSearch(props: React.ComponentProps<typeof BaseOpenAPISchemas>) {
|
||||
const { data, ...rest } = props;
|
||||
const language = useLanguage();
|
||||
const [query, setQuery] = React.useState('');
|
||||
// A models page can hold hundreds of schemas; keep typing responsive while the list re-renders.
|
||||
const deferredQuery = React.useDeferredValue(query).trim();
|
||||
|
||||
const schemas = data.schemas.filter(({ name }) => matchesText(name, deferredQuery));
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<Input
|
||||
label={tString(language, 'search')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
leading="magnifying-glass"
|
||||
clearButton
|
||||
sizing="small"
|
||||
keyboardShortcut={false}
|
||||
/>
|
||||
{schemas.length > 0 ? (
|
||||
// `grouped` keeps the list layout when the search narrows it down to one model.
|
||||
<BaseOpenAPISchemas {...rest} data={{ ...data, schemas }} grouped />
|
||||
) : (
|
||||
<div className="mb-14 py-8 text-center text-sm text-tint">
|
||||
{tString(language, 'search_no_results_for', deferredQuery)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getColumnWidth } from './layout';
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import type { TableGridViewProps, TableRecordKV } from './Table';
|
||||
import { TableSearchRecord } from './TableSearch';
|
||||
import './styles.css';
|
||||
import { getColumnVerticalAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -23,12 +24,7 @@ export function RecordRow(
|
||||
role="row"
|
||||
recordId={record[0]}
|
||||
visibleClassName="flex"
|
||||
className={tcls(
|
||||
'group/row',
|
||||
'border-tint-subtle',
|
||||
'transition-colors',
|
||||
'hover:bg-tint-hover'
|
||||
)}
|
||||
className={tcls('group/row', 'table-grid-row')}
|
||||
>
|
||||
{view.columns.map((column) => {
|
||||
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
|
||||
@@ -58,16 +54,16 @@ export function RecordRow(
|
||||
{...getCommentCellProps(context, record[0], column)}
|
||||
aria-colspan={cellMerge?.merge.colSpan}
|
||||
className={tcls(
|
||||
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
|
||||
'border-tint-subtle',
|
||||
isStickyFirstColumnCell
|
||||
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
|
||||
: undefined
|
||||
'table-grid-cell',
|
||||
isStickyFirstColumnCell ? 'table-grid-cell-sticky' : undefined
|
||||
)}
|
||||
style={{
|
||||
width: columnWidth,
|
||||
minWidth: columnWidth || '100px',
|
||||
}}
|
||||
// The cell's width and min-width both come from this, so it is carried once
|
||||
// rather than spelled out twice in every cell's style attribute.
|
||||
style={
|
||||
{
|
||||
'--table-column-width': columnWidth || '100px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<RecordColumnValue
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
@reference "../../RootLayout/globals.css";
|
||||
|
||||
/* This file can be the first stylesheet a route loads, and layer order follows first
|
||||
appearance. Without this, `components` would be created here and Tailwind's `base`
|
||||
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
/* A grid view repeats these on every row and every cell, so a table of any size pays for the class
|
||||
lists many times over in the HTML. They sit in `components` so the utilities passed alongside
|
||||
them still override. */
|
||||
@layer components {
|
||||
.table-grid-row {
|
||||
@apply border-tint-subtle transition-colors hover:bg-tint-hover;
|
||||
}
|
||||
|
||||
.table-grid-cell {
|
||||
@apply relative flex flex-1 border-r border-tint-subtle px-3 py-2 align-middle text-sm;
|
||||
@apply last:border-r-0;
|
||||
|
||||
/* Set per cell by the row; the column's width serves as both the width and the floor. */
|
||||
width: var(--table-column-width);
|
||||
min-width: var(--table-column-width);
|
||||
}
|
||||
|
||||
.table-grid-cell-sticky {
|
||||
@apply sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,8 @@ const TabItemList = memo(function TabItemList(props: {
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
const { tabs, activeTabId, onSelect } = props;
|
||||
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
|
||||
// The active tab's width decides how many others fit, so a new selection re-measures.
|
||||
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow(activeTabId);
|
||||
const overflowingTabs = useMemo(
|
||||
() =>
|
||||
Array.from(overflowing, (id) => {
|
||||
@@ -181,6 +182,7 @@ const TabItemList = memo(function TabItemList(props: {
|
||||
key={tab.id}
|
||||
ref={itemRef}
|
||||
isActive={tab.id === activeTabId}
|
||||
isMeasuring={isMeasuring}
|
||||
tab={tab}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
@@ -239,10 +241,11 @@ function TabsDropdownMenu(props: {
|
||||
const TabItem = memo(function TabItem(props: {
|
||||
ref: React.Ref<HTMLButtonElement>;
|
||||
isActive: boolean;
|
||||
isMeasuring: boolean;
|
||||
tab: TabsItem;
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
const { ref, tab, isActive, onSelect } = props;
|
||||
const { ref, tab, isActive, isMeasuring, onSelect } = props;
|
||||
return (
|
||||
<TabButton
|
||||
ref={ref}
|
||||
@@ -250,7 +253,20 @@ const TabItem = memo(function TabItem(props: {
|
||||
aria-selected={isActive}
|
||||
aria-controls={tab.id}
|
||||
id={getTabButtonId(tab.id)}
|
||||
title={tab.title}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
wrapperClassName={
|
||||
isActive
|
||||
? // Keeps room for the overflow menu button.
|
||||
'max-w-[calc(100%-3rem)]'
|
||||
: isMeasuring
|
||||
? // Measured after the active tab, at their narrowest, so the menu only takes
|
||||
// the tabs that can't fit even truncated.
|
||||
'order-1 max-w-28'
|
||||
: // Equal shares capped at their own width, so no tab drops below that
|
||||
// narrowest size; the high grow leaves the strip's filler only the leftover.
|
||||
'min-w-0 max-w-max basis-0 grow-[1000]'
|
||||
}
|
||||
>
|
||||
{tab.icon && <Icon icon={tab.icon} className="size-4 shrink-0" />}
|
||||
<span className="min-w-0 truncate">{tab.title}</span>
|
||||
@@ -264,9 +280,10 @@ const TabItem = memo(function TabItem(props: {
|
||||
function TabButton(
|
||||
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
|
||||
isActive?: boolean;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
) {
|
||||
const { isActive, ...rest } = props;
|
||||
const { isActive, wrapperClassName, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -310,14 +327,15 @@ function TabButton(
|
||||
'[:has(+_&.active-tab)]:after:border-transparent',
|
||||
'[:has(&_+)]:after:border-transparent',
|
||||
]
|
||||
: null
|
||||
: null,
|
||||
wrapperClassName
|
||||
)}
|
||||
>
|
||||
<button
|
||||
{...rest}
|
||||
type="button"
|
||||
className={tcls(
|
||||
'relative inline-flex max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
|
||||
'relative inline-flex min-w-0 max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -38,12 +38,21 @@ export function Text(props: { text: DocumentText }) {
|
||||
{children}
|
||||
</Mark>
|
||||
);
|
||||
}, leaf.text)}
|
||||
}, withSoftBreaks(leaf.text))}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Paragraphs with buttons are flex containers, where a `\n` alone can't move the next items to a new line.
|
||||
function withSoftBreaks(text: string): React.ReactNode {
|
||||
const lines = text.split('\n');
|
||||
// Keep `\n` in the same text node as a preceding `\r`: split apart, the HTML parser reads the lone `\r` as an extra line.
|
||||
return lines.flatMap((line, index) =>
|
||||
index < lines.length - 1 ? [`${line}\n`, <span key={index} className="basis-full" />] : line
|
||||
);
|
||||
}
|
||||
|
||||
const MARK_STYLES = {
|
||||
bold: Bold,
|
||||
italic: Italic,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@reference "../../RootLayout/globals.css";
|
||||
|
||||
/* This file can be the first stylesheet a route loads, and layer order follows first
|
||||
appearance. Without this, `components` would be created here and Tailwind's `base`
|
||||
only later, letting preflight's `padding: 0` and `border: 0` win over the rules below. */
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
/* Blocks repeat their alignment on every paragraph, heading and cell, so the three utilities each
|
||||
alignment needs are packed into one class. They sit in `components`, so a utility passed
|
||||
alongside one still wins. */
|
||||
@layer components {
|
||||
.gb-align-start {
|
||||
@apply justify-start self-start text-start;
|
||||
}
|
||||
|
||||
.gb-align-center {
|
||||
@apply justify-center self-center text-center;
|
||||
}
|
||||
|
||||
.gb-align-end {
|
||||
@apply justify-end self-end text-end;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import { TextAlignment } from '@gitbook/api';
|
||||
|
||||
import './textAlignment.css';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
import { nullIfNever } from '@/lib/typescript';
|
||||
|
||||
/**
|
||||
* Get the tailwind class for a text alignment.
|
||||
* Each alignment packs three utilities, so it is a single class from `textAlignment.css`.
|
||||
*/
|
||||
export function getTextAlignment(textAlignment: TextAlignment | undefined): ClassValue {
|
||||
switch (textAlignment) {
|
||||
case undefined:
|
||||
case TextAlignment.Start:
|
||||
return ['text-start', 'self-start', 'justify-start'];
|
||||
return 'gb-align-start';
|
||||
case TextAlignment.Center:
|
||||
return ['text-center', 'self-center', 'justify-center'];
|
||||
return 'gb-align-center';
|
||||
case TextAlignment.End:
|
||||
return ['text-end', 'self-end', 'justify-end'];
|
||||
return 'gb-align-end';
|
||||
default:
|
||||
return nullIfNever(textAlignment);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ import { defaultCustomization } from '@/lib/utils';
|
||||
|
||||
function preloadFont(fontData: FontData) {
|
||||
if (fontData.type === 'custom') {
|
||||
ReactDOM.preconnect(GITBOOK_FONTS_URL);
|
||||
ReactDOM.preconnect(GITBOOK_FONTS_URL, { crossOrigin: 'anonymous' });
|
||||
fontData.preloadSources
|
||||
.flatMap((face) => face.sources)
|
||||
.forEach(({ url, format }) => {
|
||||
@@ -92,6 +92,30 @@ export async function CustomizationRootLayout(props: {
|
||||
// (undefined) and keeps a neutral page background.
|
||||
const tintBaseStep =
|
||||
theme === CustomizationTheme.Bold ? undefined : theme === CustomizationTheme.Muted ? 2 : 1;
|
||||
const tintScale = {
|
||||
light: {
|
||||
color: tintColor ? tintColor.light : DEFAULT_TINT_COLOR,
|
||||
options: {
|
||||
baseStep: tintBaseStep,
|
||||
mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light },
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
color: tintColor ? tintColor.dark : DEFAULT_TINT_COLOR,
|
||||
options: {
|
||||
darkMode: true,
|
||||
baseStep: tintBaseStep,
|
||||
mix: mixColor && { color: mixColor.color.dark, ratio: mixColor.ratio.dark },
|
||||
},
|
||||
},
|
||||
};
|
||||
const secondaryColor = customization.styling.secondaryColor;
|
||||
const secondaryScale = secondaryColor
|
||||
? {
|
||||
light: { color: secondaryColor.light, options: {} },
|
||||
dark: { color: secondaryColor.dark, options: { darkMode: true } },
|
||||
}
|
||||
: tintScale;
|
||||
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
|
||||
const fontData = getFontData(customization.styling.font, 'content');
|
||||
// Temporarily add a if here while the cache is being warmed up.
|
||||
@@ -178,7 +202,8 @@ export async function CustomizationRootLayout(props: {
|
||||
>{`
|
||||
:root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
|
||||
${generateColorVariable('primary', customization.styling.primaryColor.light)}
|
||||
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { baseStep: tintBaseStep, mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
|
||||
${generateColorVariable('tint', tintScale.light.color, tintScale.light.options)}
|
||||
${generateColorVariable('secondary', secondaryScale.light.color, secondaryScale.light.options)}
|
||||
${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
|
||||
|
||||
--header-background: ${
|
||||
@@ -205,7 +230,8 @@ export async function CustomizationRootLayout(props: {
|
||||
|
||||
.dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
|
||||
${generateColorVariable('primary', customization.styling.primaryColor.dark, { darkMode: true })}
|
||||
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, baseStep: tintBaseStep, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
|
||||
${generateColorVariable('tint', tintScale.dark.color, tintScale.dark.options)}
|
||||
${generateColorVariable('secondary', secondaryScale.dark.color, secondaryScale.dark.options)}
|
||||
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
|
||||
|
||||
--header-background: ${hexToRgb(customization.header.backgroundColor?.dark ?? tintColor?.dark ?? customization.styling.primaryColor.dark)};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { createStreamableValue } from 'ai/rsc';
|
||||
import { headers } from 'next/headers';
|
||||
import type * as React from 'react';
|
||||
|
||||
import type {
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { isAIEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
@@ -77,7 +79,8 @@ export async function streamAskQuestion({
|
||||
},
|
||||
session,
|
||||
},
|
||||
{ format: 'document' }
|
||||
{ format: 'document' },
|
||||
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
|
||||
);
|
||||
|
||||
const spacePromises = new Map<string, Promise<Revision>>();
|
||||
|
||||
@@ -64,11 +64,12 @@ export async function SiteLayout(props: {
|
||||
// Scripts are disabled when tracking is disabled
|
||||
const scripts = withTracking ? context.scripts : [];
|
||||
|
||||
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
|
||||
ReactDOM.preconnect(GITBOOK_ICONS_URL);
|
||||
if (GITBOOK_ASSETS_URL) {
|
||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
||||
}
|
||||
// React dedupes preconnects by href, so reduce to the origin; relative URLs are same-origin.
|
||||
[GITBOOK_API_PUBLIC_URL, GITBOOK_ICONS_URL, GITBOOK_ASSETS_URL].forEach((url) => {
|
||||
if (url && URL.canParse(url)) {
|
||||
ReactDOM.preconnect(new URL(url).origin);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the search-index download from the HTML itself. `crossOrigin` must match the
|
||||
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
|
||||
|
||||
@@ -30,8 +30,10 @@ interface OverflowState {
|
||||
* shows *because* items overflow (a "more" menu, say) has to be rendered ahead of the items in that
|
||||
* phase: the measurement discounts the space taken before the first item, so a list that fits on its
|
||||
* own is not reported as overflowing merely because that menu was reserving room for itself.
|
||||
*
|
||||
* The list is measured again whenever `remeasureKey` changes.
|
||||
*/
|
||||
export function useListOverflow(): OverflowState {
|
||||
export function useListOverflow(remeasureKey?: unknown): OverflowState {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowing, setOverflowing] = useState<Set<string>>(new Set());
|
||||
// Measuring is a request/completed pair of counters rather than a boolean, because a boolean
|
||||
@@ -58,6 +60,12 @@ export function useListOverflow(): OverflowState {
|
||||
|
||||
const requestMeasure = useCallback(() => setMeasureRequest((request) => request + 1), []);
|
||||
|
||||
const [measuredKey, setMeasuredKey] = useState(remeasureKey);
|
||||
if (measuredKey !== remeasureKey) {
|
||||
setMeasuredKey(remeasureKey);
|
||||
setMeasureRequest((request) => request + 1);
|
||||
}
|
||||
|
||||
// Measure on mount and when container size changes
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from 'react';
|
||||
|
||||
import { NavigationStatusContext, scrollToHash } from '../hooks';
|
||||
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
||||
import { isExternalLink, toNonEmbedLink } from '../utils/link';
|
||||
import { isExternalLink, isSiteAuthLink, toNonEmbedLink } from '../utils/link';
|
||||
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getSamePageAnchor, resolveAnchorURL } from '@/lib/urls';
|
||||
@@ -93,6 +93,9 @@ export function Link(props: LinkProps) {
|
||||
const trackEvent = useTrackEvent();
|
||||
const forwardedClassNames = useClassnames(classNames || []);
|
||||
const isExternal = isExternalServer(href);
|
||||
// Auth routes must not be prefetched (it would log the visitor out) and always leave the
|
||||
// page for the upstream provider, so they get a plain anchor like external links.
|
||||
const isAuthLink = isSiteAuthLink(href);
|
||||
const { target, rel } = getTargetProps(props, { externalTarget, isExternal });
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
@@ -114,7 +117,7 @@ export function Link(props: LinkProps) {
|
||||
const isExternalOnClient = isExternalClient(href);
|
||||
|
||||
if (insights) {
|
||||
trackEvent(insights, undefined, { immediate: isExternalOnClient });
|
||||
trackEvent(insights, undefined, { immediate: isExternalOnClient || isAuthLink });
|
||||
}
|
||||
|
||||
const isInIframe = window.self !== window.top;
|
||||
@@ -133,7 +136,7 @@ export function Link(props: LinkProps) {
|
||||
|
||||
// We test if the link is external, without comparing to the origin
|
||||
// as this will be rendered on the server and it could result in a mismatch.
|
||||
if (isExternal) {
|
||||
if (isExternal || isAuthLink) {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { isExternalLink, toNonEmbedLink } from './link';
|
||||
import { isExternalLink, isSiteAuthLink, toNonEmbedLink } from './link';
|
||||
|
||||
describe('isExternalLink', () => {
|
||||
it('treats anchor links as internal', () => {
|
||||
@@ -74,3 +74,22 @@ describe('toNonEmbedLink', () => {
|
||||
expect(toNonEmbedLink('/docs')).toBe('/docs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSiteAuthLink', () => {
|
||||
it('matches absolute links to the auth routes', () => {
|
||||
expect(isSiteAuthLink('https://example.com/docs/~gitbook/auth/logout')).toBe(true);
|
||||
expect(isSiteAuthLink('https://example.com/~gitbook/auth/login?location=%2F')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches relative links to the auth routes', () => {
|
||||
expect(isSiteAuthLink('/docs/~gitbook/auth/logout/')).toBe(true);
|
||||
expect(isSiteAuthLink('~gitbook/auth/login')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match other links', () => {
|
||||
expect(isSiteAuthLink('https://example.com/docs/~gitbook/pdf')).toBe(false);
|
||||
expect(isSiteAuthLink('/docs/logout')).toBe(false);
|
||||
expect(isSiteAuthLink('/docs/~gitbook/auth/logout-page')).toBe(false);
|
||||
expect(isSiteAuthLink('#section')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,18 @@ export function isExternalLink(href: string, origin: string | null = null) {
|
||||
export function toNonEmbedLink(href: string) {
|
||||
return href.replace(/~gitbook\/embed(\/page)?\/?/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a link points to the site's `~gitbook/auth/login` or `~gitbook/auth/logout` route.
|
||||
*
|
||||
* Deliberately loose: these routes only exist at the site root, but the site's base path isn't
|
||||
* known here, so any prefix matches. A false positive only costs the link its prefetch.
|
||||
*/
|
||||
export function isSiteAuthLink(href: string) {
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
|
||||
if (!URL.canParse?.(href, baseURL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /\/~gitbook\/auth\/(login|logout)\/?$/.test(new URL(href, baseURL).pathname);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getAskForwardedHeaders, getAskVisitorAgent } from './ask-visitor';
|
||||
|
||||
describe('getAskVisitorAgent', () => {
|
||||
it('should read the User-Agent and Signature-Agent headers', () => {
|
||||
expect(
|
||||
getAskVisitorAgent(
|
||||
new Headers({
|
||||
'user-agent': 'claude-code/2.0.0',
|
||||
'signature-agent': '"https://chatgpt.com"',
|
||||
})
|
||||
)
|
||||
).toEqual({ userAgent: 'claude-code/2.0.0', signatureAgent: '"https://chatgpt.com"' });
|
||||
});
|
||||
|
||||
it('should omit missing or empty headers', () => {
|
||||
const agent = getAskVisitorAgent(new Headers({ 'user-agent': '' }));
|
||||
expect(agent).toEqual({});
|
||||
expect('userAgent' in agent).toBe(false);
|
||||
});
|
||||
|
||||
it('should truncate overly long headers', () => {
|
||||
const agent = getAskVisitorAgent(new Headers({ 'user-agent': 'a'.repeat(2000) }));
|
||||
expect(agent.userAgent).toHaveLength(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAskForwardedHeaders', () => {
|
||||
it('should forward the visitor agent in X-Original headers', () => {
|
||||
expect(
|
||||
getAskForwardedHeaders({
|
||||
userAgent: 'claude-code/2.0.0',
|
||||
signatureAgent: '"https://chatgpt.com"',
|
||||
})
|
||||
).toEqual({
|
||||
'X-Original-User-Agent': 'claude-code/2.0.0',
|
||||
'X-Original-Signature-Agent': '"https://chatgpt.com"',
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit headers the visitor did not send', () => {
|
||||
expect(getAskForwardedHeaders({ userAgent: 'curl/8.7.1' })).toEqual({
|
||||
'X-Original-User-Agent': 'curl/8.7.1',
|
||||
});
|
||||
expect(getAskForwardedHeaders()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Headers identifying who asked a question, forwarded so the API can classify the asker
|
||||
* (human, agent, bot) the same way it classifies insights events.
|
||||
*/
|
||||
export interface AskVisitorAgent {
|
||||
userAgent?: string;
|
||||
signatureAgent?: string;
|
||||
}
|
||||
|
||||
// Bounds the header values, as they end up in the static route path of `?ask=`.
|
||||
const MAX_HEADER_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* Read the visitor's `User-Agent` and `Signature-Agent` from the request headers.
|
||||
* Missing headers are omitted rather than set to undefined, so the result can be rison-encoded.
|
||||
*/
|
||||
export function getAskVisitorAgent(headers: Pick<Headers, 'get'>): AskVisitorAgent {
|
||||
const userAgent = headers.get('user-agent')?.slice(0, MAX_HEADER_LENGTH);
|
||||
const signatureAgent = headers.get('signature-agent')?.slice(0, MAX_HEADER_LENGTH);
|
||||
|
||||
return {
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(signatureAgent ? { signatureAgent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request headers forwarding the visitor's agent on ask and Assistant API calls.
|
||||
* Our own `User-Agent` is left as is: the API relies on it to know the published site is calling.
|
||||
*/
|
||||
export function getAskForwardedHeaders(agent: AskVisitorAgent = {}): Record<string, string> {
|
||||
return {
|
||||
...(agent.userAgent ? { 'X-Original-User-Agent': agent.userAgent } : {}),
|
||||
...(agent.signatureAgent ? { 'X-Original-Signature-Agent': agent.signatureAgent } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api';
|
||||
|
||||
import { type AskVisitorAgent, getAskForwardedHeaders } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
@@ -15,6 +16,11 @@ export interface StreamSiteAskOptions {
|
||||
* Used by the backend to steer the answer.
|
||||
*/
|
||||
goal?: string;
|
||||
|
||||
/**
|
||||
* Headers of the visitor asking the question, used by the backend to classify the asker.
|
||||
*/
|
||||
visitorAgent?: AskVisitorAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +51,8 @@ export async function streamSiteAskAnswer(
|
||||
currentSiteSpace: context.siteSpace.id,
|
||||
},
|
||||
},
|
||||
{ format: 'markdown' }
|
||||
{ format: 'markdown' },
|
||||
{ headers: getAskForwardedHeaders(options.visitorAgent) }
|
||||
);
|
||||
|
||||
let latestAnswer: SearchAIAnswer | null = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const textColorToStyle = {
|
||||
cyan: ['text-[#007c7c] dark:text-[#6bcac9]'],
|
||||
orange: ['text-[#ae4300] dark:text-[#eea471]'],
|
||||
$primary: ['text-primary-subtle contrast-more:text-primary'],
|
||||
$secondary: ['text-secondary-subtle contrast-more:text-secondary'],
|
||||
$info: ['text-info-subtle contrast-more:text-info'],
|
||||
$success: ['text-success-subtle contrast-more:text-success'],
|
||||
$warning: ['text-warning-subtle contrast-more:text-warning'],
|
||||
@@ -33,6 +34,7 @@ export const backgroundColorToStyle = {
|
||||
cyan: ['bg-[#ddf8f8] dark:bg-[#093b3b]'],
|
||||
orange: ['bg-[#ffecdc] dark:bg-[#492a13]'],
|
||||
$primary: ['bg-primary'],
|
||||
$secondary: ['bg-secondary'],
|
||||
$info: ['bg-info'],
|
||||
$success: ['bg-success'],
|
||||
$warning: ['bg-warning'],
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import { GITBOOK_URL } from './env';
|
||||
import { type ImageResizer, createImageResizer } from './images';
|
||||
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
|
||||
import type { AskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import {
|
||||
type GitBookDataFetcher,
|
||||
createDataFetcher,
|
||||
@@ -104,6 +105,12 @@ export type SiteURLData = Pick<
|
||||
|
||||
/** Whether the request comes from ChatGPT. Only set for Markdown and LLM routes. */
|
||||
isChatGPT?: boolean;
|
||||
|
||||
/**
|
||||
* Headers of the visitor asking through `?ask=`, forwarded to the API to classify the asker.
|
||||
* Only set for the ask route, which is static and can't read the request headers.
|
||||
*/
|
||||
askVisitorAgent?: AskVisitorAgent;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const cacheLife = mock((_profile: unknown) => {});
|
||||
const cacheTag = mock((..._tags: string[]) => {});
|
||||
|
||||
const realNextCache = await import('next/cache');
|
||||
mock.module('next/cache', () => ({ ...realNextCache, cacheLife, cacheTag }));
|
||||
|
||||
const { createDataFetcher } = await import('./api');
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterAll(() => mock.restore());
|
||||
|
||||
describe('searchSiteContent', () => {
|
||||
beforeEach(() => {
|
||||
cacheLife.mockClear();
|
||||
cacheTag.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function mockFetch(impl: () => Promise<Response>) {
|
||||
globalThis.fetch = mock(impl);
|
||||
}
|
||||
|
||||
function search() {
|
||||
return createDataFetcher().searchSiteContent({
|
||||
organizationId: 'org-1',
|
||||
siteId: 'site-1',
|
||||
query: 'test',
|
||||
scope: { mode: 'all' },
|
||||
});
|
||||
}
|
||||
|
||||
it('caches successful results for hours with the site and response tags', async () => {
|
||||
mockFetch(async () =>
|
||||
Response.json(
|
||||
{ items: [] },
|
||||
{ headers: { 'x-gitbook-cache-tag': 'space:space-1,space:space-2' } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await search();
|
||||
|
||||
expect(result).toEqual({ data: [] });
|
||||
expect(cacheLife.mock.calls).toEqual([['hours']]);
|
||||
expect(cacheTag.mock.calls.flat()).toEqual([
|
||||
'site:site-1',
|
||||
'space:space-1',
|
||||
'space:space-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not cache a transient API error', async () => {
|
||||
mockFetch(async () =>
|
||||
Response.json({ error: { code: 429, message: 'Namespace saturated' } }, { status: 429 })
|
||||
);
|
||||
|
||||
const result = await search();
|
||||
|
||||
expect(result.error?.code).toBe(429);
|
||||
expect(cacheLife.mock.calls).toEqual([[{ stale: 0, revalidate: 0, expire: 0 }]]);
|
||||
});
|
||||
|
||||
it('does not cache a network failure', async () => {
|
||||
mockFetch(() => Promise.reject(new TypeError('fetch failed')));
|
||||
|
||||
const result = await search();
|
||||
|
||||
expect(result.error?.code).toBe(500);
|
||||
expect(cacheLife.mock.calls).toEqual([[{ stale: 0, revalidate: 0, expire: 0 }]]);
|
||||
});
|
||||
|
||||
it('keeps the default cache life for a stable 404', async () => {
|
||||
mockFetch(async () =>
|
||||
Response.json({ error: { code: 404, message: 'Site not found' } }, { status: 404 })
|
||||
);
|
||||
|
||||
const result = await search();
|
||||
|
||||
expect(result.error?.code).toBe(404);
|
||||
expect(cacheLife).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||
|
||||
import { cache } from '../cache';
|
||||
import { DataFetcherError, wrapDataFetcherError } from './errors';
|
||||
import { DataFetcherError, isTransientDataFetcherError, wrapDataFetcherError } from './errors';
|
||||
import type { GitBookDataFetcher } from './types';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
|
||||
import { trace } from '@/lib/tracing';
|
||||
@@ -799,7 +799,7 @@ const searchSiteContent = cache(
|
||||
})
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const result = await wrapDataFetcherError(async () => {
|
||||
return trace(
|
||||
`searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`,
|
||||
async () => {
|
||||
@@ -833,6 +833,14 @@ const searchSiteContent = cache(
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Don't serve transient errors for the default 15 minutes. Only safe because search runs in
|
||||
// request handlers: a page cache calling it would inherit this short life during API incidents.
|
||||
if (result.error && isTransientDataFetcherError(result.error)) {
|
||||
cacheLife({ stale: 0, revalidate: 0, expire: 0 });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,17 @@ import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { GitBookAPIError } from '@gitbook/api';
|
||||
|
||||
import { extractCacheControl } from './errors';
|
||||
import { extractCacheControl, isTransientDataFetcherError } from './errors';
|
||||
|
||||
describe('isTransientDataFetcherError', () => {
|
||||
it.each([408, 429, 500, 502, 503, 504])('should treat %i as transient', (code) => {
|
||||
expect(isTransientDataFetcherError({ code, message: 'Error' })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403, 404, 410])('should treat %i as stable', (code) => {
|
||||
expect(isTransientDataFetcherError({ code, message: 'Error' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCacheControl', () => {
|
||||
it('should return undefined when error has no response', () => {
|
||||
|
||||
@@ -93,6 +93,13 @@ export async function wrapDataFetcherError<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an error may go away on retry: timeouts, rate limits, server errors and network failures.
|
||||
*/
|
||||
export function isTransientDataFetcherError(error: DataFetcherErrorData): boolean {
|
||||
return error.code === 408 || error.code === 429 || error.code >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore some data fetcher errors.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getURLLookup, getURLLookupPathname, normalizeURL } from './urls';
|
||||
import { getURLLookup, getURLLookupPathname, normalizeRequestURL, normalizeURL } from './urls';
|
||||
|
||||
describe('getURLLookupPathname', () => {
|
||||
const previewRoot = 'https://sites.gitbook.com/preview/site_example/section';
|
||||
@@ -162,6 +162,51 @@ describe('getURLLookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRequestURL', () => {
|
||||
it('does not redirect a normalized URL', () => {
|
||||
const url = new URL('https://docs.mycompany.com/hello');
|
||||
expect(normalizeRequestURL(url, 'url-host')).toEqual({ isNormalized: true });
|
||||
});
|
||||
|
||||
it('redirects a non-normalized URL to the normalized one', () => {
|
||||
const result = normalizeRequestURL(
|
||||
new URL('https://docs.mycompany.com/hello/tes%74'),
|
||||
'url-host'
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toBe('https://docs.mycompany.com/hello/test');
|
||||
});
|
||||
|
||||
it('does not redirect a non-normalized proxied URL to the internal proxy URL', () => {
|
||||
const url = new URL(
|
||||
'https://proxy.gitbook.site/sites/site_abc/getting-started/quickstar%74'
|
||||
);
|
||||
expect(normalizeRequestURL(url, 'url-host')).toEqual({ isNormalized: false });
|
||||
});
|
||||
|
||||
it('redirects a non-normalized proxied URL in url mode', () => {
|
||||
const result = normalizeRequestURL(
|
||||
new URL('https://proxy.gitbook.site/sites/site_abc/quickstar%74'),
|
||||
'url'
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).headers.get('location')).toBe(
|
||||
'https://proxy.gitbook.site/sites/site_abc/quickstart'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://docs.mycompany.com/hello/%ZZ',
|
||||
'https://proxy.gitbook.site/sites/site_abc/%ZZ',
|
||||
])('returns a 400 for an invalid URL %s', (url) => {
|
||||
const result = normalizeRequestURL(new URL(url), 'url-host');
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeURL', () => {
|
||||
it('should remove trailing slashes', () => {
|
||||
expect(normalizeURL(new URL('https://docs.mycompany.com/hello/'))).toEqual(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { joinPath, removeTrailingSlash } from '../paths';
|
||||
import { isProxyRootRequest } from '../proxy';
|
||||
import { isProxyRequest, isProxyRootRequest } from '../proxy';
|
||||
import { DataFetcherError, getExposableError } from './errors';
|
||||
|
||||
/**
|
||||
@@ -105,21 +105,28 @@ export function getURLLookupPathname(
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL in a request and redirect if the normalized URL is different from the original one.
|
||||
* Normalize the URL in a request before the lookup. Returns an error response if it is invalid,
|
||||
* or a redirect to the normalized URL if it can be built from the request URL.
|
||||
*/
|
||||
export function normalizeRequestURL(url: URL): Response | null {
|
||||
export function normalizeRequestURL(
|
||||
url: URL,
|
||||
mode: 'url' | 'url-host'
|
||||
): Response | { isNormalized: boolean } {
|
||||
let normalizedURL: URL;
|
||||
try {
|
||||
const normalizedURL = normalizeURL(url);
|
||||
|
||||
if (normalizedURL.toString() !== url.toString()) {
|
||||
return Response.redirect(normalizedURL.toString(), 302);
|
||||
}
|
||||
|
||||
return null;
|
||||
normalizedURL = normalizeURL(url);
|
||||
} catch (error) {
|
||||
const sanitized = getExposableError(error);
|
||||
return new Response(sanitized.message, { status: sanitized.code });
|
||||
}
|
||||
|
||||
const isNormalized = normalizedURL.toString() === url.toString();
|
||||
// A proxied site's public URL is only known after the lookup, which redirects to the canonical URL.
|
||||
if (!isNormalized && !(mode !== 'url' && isProxyRequest(url))) {
|
||||
return Response.redirect(normalizedURL.toString(), 302);
|
||||
}
|
||||
|
||||
return { isNormalized };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,13 +18,18 @@ const linker = linkerWithMarkdownPages(
|
||||
createLinker({ host: 'docs.example.com', spaceBasePath: '/workflows/', siteBasePath: '/' })
|
||||
)
|
||||
);
|
||||
const file = {
|
||||
id: 'dark-image',
|
||||
name: 'dark.png',
|
||||
downloadURL: 'https://example.com/dark.png?alt=media&token=test',
|
||||
};
|
||||
const context = {
|
||||
space: {
|
||||
id: 'workflows',
|
||||
title: 'Workflows',
|
||||
urls: { published: 'https://docs.example.com/workflows/' },
|
||||
},
|
||||
revision: { pages: [page], files: [] },
|
||||
revision: { pages: [page], files: [file] },
|
||||
linker,
|
||||
} as unknown as GitBookAnyContext;
|
||||
|
||||
@@ -56,3 +61,21 @@ describe('HTML links in page markdown', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML images in page markdown', () => {
|
||||
it.each([
|
||||
'/files/dark-image',
|
||||
' /files/dark-image ',
|
||||
'/files/dark-image 1x, /files/dark-image 2x',
|
||||
'/files/dark-image 1x,/files/dark-image 2x',
|
||||
'/files/dark-image, https://example.com/external.png 2x',
|
||||
'/files/dark-image 640w, https://example.com/external.png 1280w',
|
||||
'https://example.com/external.png?crop=1,2 1x, /files/dark-image 2x',
|
||||
'data:image/png;base64,AAAA 1x, /files/dark-image 2x',
|
||||
])('resolves srcset candidates and preserves descriptors in %s', async (srcset) => {
|
||||
const markdown = `<picture><source srcset="${srcset}" media="(prefers-color-scheme: dark)"><img src="/files/dark-image" alt="Example"></picture>\n`;
|
||||
expect(await rewrite(markdown)).toBe(
|
||||
markdown.replaceAll('/files/dark-image', file.downloadURL.replaceAll('&', '&'))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ import { getMarkdownForPagesTree } from '@/routes/llms';
|
||||
const HTML_ANCHOR_RE = /<a\b([^>]*?)href="([^"]*)"([^>]*)>([\s\S]*?)<\/a>/g;
|
||||
const HTML_ANCHOR_OPEN_RE = /<a\b([^>]*?)href="([^"]*)"([^>]*)>/g;
|
||||
const HTML_SRC_RE = /\bsrc="([^"]*)"/g;
|
||||
const HTML_SRCSET_RE = /\bsrcset="([^"]*)"/g;
|
||||
const SRCSET_CANDIDATE_RE = /(\S*[^,\s])(?:\s+[^,]*)?(?:,|$)/g;
|
||||
|
||||
/**
|
||||
* Generate a markdown version of a page.
|
||||
@@ -378,6 +380,16 @@ async function rewriteHTMLRefs(context: GitBookAnyContext, node: Html): Promise<
|
||||
const resolved = await resolveRefURL(context, src);
|
||||
return resolved ? `src="${escapeHTML(resolved.url)}"` : full;
|
||||
});
|
||||
|
||||
node.value = await replaceAsync(node.value, HTML_SRCSET_RE, async (match) => {
|
||||
const [, srcset = ''] = match;
|
||||
const rewritten = await replaceAsync(srcset, SRCSET_CANDIDATE_RE, async (candidate) => {
|
||||
const [full, url = ''] = candidate;
|
||||
const resolved = await resolveRefURL(context, url);
|
||||
return resolved ? escapeHTML(resolved.url) + full.slice(url.length) : full;
|
||||
});
|
||||
return `srcset="${rewritten}"`;
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceAsync(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/lib/agentFeedback';
|
||||
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import { getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
|
||||
@@ -261,8 +262,11 @@ export function createSiteMcpTools(
|
||||
|
||||
const trimmedGoal = goal?.trim() || undefined;
|
||||
|
||||
// The MCP server is stateless, so the `clientInfo` from `initialize` isn't
|
||||
// available here: rely on the HTTP user agent, as the `ask_question` event does.
|
||||
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
|
||||
goal: trimmedGoal,
|
||||
visitorAgent: getAskVisitorAgent(request.headers),
|
||||
});
|
||||
|
||||
trackMcpEvent({
|
||||
|
||||
@@ -50,6 +50,16 @@ describe('extractPagePath', () => {
|
||||
it('returns empty string when URL equals root base URL', () => {
|
||||
expect(extractPagePath('https://docs.example.com/', 'https://docs.example.com/')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts path from the markdown URL of a page', () => {
|
||||
expect(
|
||||
extractPagePath('https://docs.example.com/api/guides/installation.md', baseURL)
|
||||
).toBe('guides/installation');
|
||||
expect(extractPagePath('https://docs.example.com/api/.md', baseURL)).toBe('');
|
||||
expect(extractPagePath('https://docs.example.com/api/notes.md.html', baseURL)).toBe(
|
||||
'notes.md.html'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFirstDocument', () => {
|
||||
|
||||
@@ -412,7 +412,8 @@ function flattenPages(
|
||||
* Returns the path segment after the base, or undefined if the URL doesn't match.
|
||||
*/
|
||||
export function extractPagePath(url: string, baseURL: string): string | undefined {
|
||||
const urlPath = getURLPathname(url);
|
||||
// Pages are also served as Markdown at `<page>.md`, the form llms.txt links to.
|
||||
const urlPath = getURLPathname(url)?.replace(/\.md$/, '');
|
||||
const basePath = getURLPathname(baseURL);
|
||||
|
||||
if (urlPath === undefined || basePath === undefined) {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { PublishedSiteContent } from '@gitbook/api';
|
||||
|
||||
import { getPreviewCookieResponse, getPreviewRequestIdentifier, isPreviewRequest } from './preview';
|
||||
import {
|
||||
getPreviewCookieResponse,
|
||||
getPreviewRequestIdentifier,
|
||||
isPreviewRequest,
|
||||
parsePreviewCustomizationCookie,
|
||||
serializePreviewCustomizationCookie,
|
||||
} from './preview';
|
||||
|
||||
describe('isPreviewRequest', () => {
|
||||
it('should return true for preview requests', () => {
|
||||
@@ -65,3 +71,23 @@ describe('getPreviewCookieResponse', () => {
|
||||
expect(cookie.options?.path).toBe('/docs/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePreviewCustomizationCookie', () => {
|
||||
const customization = "(styling:(primaryColor:(dark:'#ff0000',light:'#ff0000')))";
|
||||
const value = serializePreviewCustomizationCookie({ customization, siteSpace: 'sitesp_a' });
|
||||
|
||||
it('returns the override for the site space it was created for', () => {
|
||||
expect(parsePreviewCustomizationCookie(value, 'sitesp_a')).toBe(customization);
|
||||
});
|
||||
|
||||
it('ignores the override for another site space', () => {
|
||||
expect(parsePreviewCustomizationCookie(value, 'sitesp_b')).toBeUndefined();
|
||||
expect(parsePreviewCustomizationCookie(value, 'sitesp')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a cookie not bound to a site space', () => {
|
||||
expect(
|
||||
parsePreviewCustomizationCookie(encodeURIComponent(customization), 'sitesp_a')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,34 @@ export function getPreviewRequestIdentifier(requestURL: URL): string {
|
||||
throw new Error('Not a preview request');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a customization override into the preview cookie value.
|
||||
* The override holds the full settings of the site space it was created for,
|
||||
* so it is bound to that site space to not leak into the other ones of the preview.
|
||||
*/
|
||||
export function serializePreviewCustomizationCookie(args: {
|
||||
customization: string;
|
||||
siteSpace: string;
|
||||
}): string {
|
||||
return `${args.siteSpace}:${encodeURIComponent(args.customization)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customization override from the preview cookie value,
|
||||
* only if it was created for the given site space.
|
||||
*/
|
||||
export function parsePreviewCustomizationCookie(
|
||||
value: string,
|
||||
siteSpace: string
|
||||
): string | undefined {
|
||||
const prefix = `${siteSpace}:`;
|
||||
if (!value.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return decodeURIComponent(value.slice(prefix.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cookie for the preview request.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createLinker } from './links';
|
||||
import {
|
||||
filterSiteSpacesByLocale,
|
||||
findSiteSpaceBy,
|
||||
findSiteSpaceByUrl,
|
||||
getFallbackSiteSpacePath,
|
||||
getLinkerForSiteSpace,
|
||||
getSiteStructureSections,
|
||||
@@ -114,6 +115,30 @@ describe('site structure traversal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSiteSpaceByUrl', () => {
|
||||
const docsSpace = {
|
||||
id: 'docs',
|
||||
urls: { published: 'https://example.com/docs' },
|
||||
} as SiteSpace;
|
||||
const v2Space = {
|
||||
id: 'v2',
|
||||
urls: { published: 'https://example.com/docs/v2' },
|
||||
} as SiteSpace;
|
||||
|
||||
it.each([
|
||||
{ order: 'broader space first', spaces: [docsSpace, v2Space] },
|
||||
{ order: 'nested space first', spaces: [v2Space, docsSpace] },
|
||||
])('chooses the longest matching published URL with $order', ({ spaces }) => {
|
||||
const match = findSiteSpaceByUrl(
|
||||
{ type: 'siteSpaces', structure: spaces },
|
||||
'https://example.com/docs/v2/install'
|
||||
);
|
||||
|
||||
expect(match?.siteSpace).toBe(v2Space);
|
||||
expect(match?.pagePath).toBe('install');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSiteSpacesByLocale', () => {
|
||||
it('returns all spaces on a single-language site', () => {
|
||||
const spaces = [makeSiteSpace(undefined), makeSiteSpace(undefined)];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { JwtPayload } from 'jwt-decode';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
type ResponseCookies,
|
||||
@@ -234,21 +235,66 @@ describe('getResponseCookiesForVisitorAuth write policy', () => {
|
||||
const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
const token = `${base64url({ alg: 'none' })}.${base64url({ exp: 2_000_000_000 })}.sig`;
|
||||
|
||||
const requestHeaders = new Headers();
|
||||
|
||||
it('persists a token that arrived in the URL', () => {
|
||||
expect(getResponseCookiesForVisitorAuth('/', { source: 'url', token })).toHaveLength(1);
|
||||
const visitorTokenLookup = { source: 'url', token } as const;
|
||||
expect(
|
||||
getResponseCookiesForVisitorAuth({ basePath: '/', visitorTokenLookup, requestHeaders })
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('only accepts a token that arrived in the URL', () => {
|
||||
const fromVACookie = { source: 'visitor-auth-cookie', basePath: '/', token } as const;
|
||||
const fromCustomCookie = { source: 'gitbook-visitor-cookie', token } as const;
|
||||
|
||||
// @ts-expect-error a token read back from a VA cookie must never be persisted again
|
||||
const buildFromVACookie = () => getResponseCookiesForVisitorAuth('/', fromVACookie);
|
||||
// @ts-expect-error a custom visitor cookie is owned by the customer's backend
|
||||
const buildFromCustom = () => getResponseCookiesForVisitorAuth('/', fromCustomCookie);
|
||||
const buildFromVACookie = () =>
|
||||
getResponseCookiesForVisitorAuth({
|
||||
basePath: '/',
|
||||
// @ts-expect-error a token read back from a VA cookie must never be persisted again
|
||||
visitorTokenLookup: fromVACookie,
|
||||
requestHeaders,
|
||||
});
|
||||
const buildFromCustom = () =>
|
||||
getResponseCookiesForVisitorAuth({
|
||||
basePath: '/',
|
||||
// @ts-expect-error a custom visitor cookie is owned by the customer's backend
|
||||
visitorTokenLookup: fromCustomCookie,
|
||||
requestHeaders,
|
||||
});
|
||||
|
||||
expect([buildFromVACookie, buildFromCustom]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('partitions the cookie only inside an iframe, and clears both kinds', async () => {
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const previousEnv = env.NODE_ENV;
|
||||
env.NODE_ENV = 'production';
|
||||
// A fresh module instance, as `NODE_ENV` is read once at import.
|
||||
const visitors = (await import(`./visitors?${'production'}`).finally(() => {
|
||||
env.NODE_ENV = previousEnv;
|
||||
})) as typeof import('./visitors');
|
||||
|
||||
const setCookie = (secFetchDest: string) => {
|
||||
const response = new NextResponse();
|
||||
for (const cookie of visitors.getResponseCookiesForVisitorAuth({
|
||||
basePath: '/',
|
||||
visitorTokenLookup: { source: 'url', token },
|
||||
requestHeaders: new Headers({ 'sec-fetch-dest': secFetchDest }),
|
||||
})) {
|
||||
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
return response.headers.get('set-cookie');
|
||||
};
|
||||
expect(setCookie('iframe')).toContain('Partitioned');
|
||||
expect(setCookie('document')).not.toContain('Partitioned');
|
||||
|
||||
const clear = `${getVisitorAuthCookieName('/')}=; Path=/; Max-Age=0; Secure; SameSite=None`;
|
||||
expect(visitors.getVisitorAuthClearCookieHeaders('/')).toEqual([
|
||||
`${clear}; Partitioned`,
|
||||
clear,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseCookiesForVisitorAuth chunking', () => {
|
||||
@@ -261,7 +307,12 @@ describe('getResponseCookiesForVisitorAuth chunking', () => {
|
||||
};
|
||||
|
||||
const write = (basePath: string, token: string, previous: ResponseCookies = []) =>
|
||||
getResponseCookiesForVisitorAuth(basePath, { source: 'url', token }, previous);
|
||||
getResponseCookiesForVisitorAuth({
|
||||
basePath,
|
||||
visitorTokenLookup: { source: 'url', token },
|
||||
requestCookies: previous,
|
||||
requestHeaders: new Headers(),
|
||||
});
|
||||
|
||||
const read = (cookies: ResponseCookies, urlPath = '/') =>
|
||||
getVisitorToken({
|
||||
|
||||
@@ -15,6 +15,7 @@ const VISITOR_AUTH_PARAM = 'jwt_token';
|
||||
const VISITOR_PARAM_PREFIX = 'visitor.';
|
||||
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
|
||||
const VISITOR_UNSIGNED_CLAIMS_PREFIX = 'gitbook-visitor-public';
|
||||
const IS_SECURE = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Typing for a cookie, matching the internal type of Next.js.
|
||||
@@ -28,6 +29,7 @@ export type ResponseCookie = {
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
path: string;
|
||||
partitioned: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -146,7 +148,10 @@ export function getVisitorData({
|
||||
}): VisitorDataLookup {
|
||||
const visitorToken = getVisitorToken({ cookies, headers, url });
|
||||
const unsignedClaims = getVisitorUnsignedClaims({ cookies, url });
|
||||
const visitorParamsCookie = getResponseCookieForVisitorParams(unsignedClaims.fromVisitorParams);
|
||||
const visitorParamsCookie = getResponseCookieForVisitorParams({
|
||||
visitorParamsClaims: unsignedClaims.fromVisitorParams,
|
||||
requestHeaders: headers,
|
||||
});
|
||||
|
||||
return {
|
||||
visitorToken,
|
||||
@@ -318,9 +323,13 @@ function parseVisitorQueryParamValue(value: string): ClaimPrimitive {
|
||||
/**
|
||||
* Returns to cookie response to use in order to persist visitor params that were passed to the URL.
|
||||
*/
|
||||
function getResponseCookieForVisitorParams(
|
||||
visitorParamsClaims: Record<string, ClaimPrimitive>
|
||||
): ResponseCookie | undefined {
|
||||
function getResponseCookieForVisitorParams({
|
||||
visitorParamsClaims,
|
||||
requestHeaders,
|
||||
}: {
|
||||
visitorParamsClaims: Record<string, ClaimPrimitive>;
|
||||
requestHeaders: Headers;
|
||||
}): ResponseCookie | undefined {
|
||||
if (Object.keys(visitorParamsClaims).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -328,10 +337,20 @@ function getResponseCookieForVisitorParams(
|
||||
return {
|
||||
name: VISITOR_UNSIGNED_CLAIMS_PREFIX,
|
||||
value: JSON.stringify(visitorParamsClaims),
|
||||
options: {
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
options: getVisitorCookieCrossSiteOptions(requestHeaders),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes letting visitor cookies be stored inside a cross-site iframe (authenticated embeds).
|
||||
* `Partitioned` (CHIPS) is kept to iframes: Safari drops partitioned cookies after cross-site
|
||||
* redirect chains (WebKit 306194), which top-level login flows go through.
|
||||
*/
|
||||
function getVisitorCookieCrossSiteOptions(requestHeaders: Headers) {
|
||||
return {
|
||||
sameSite: IS_SECURE ? ('none' as const) : undefined,
|
||||
secure: IS_SECURE,
|
||||
partitioned: IS_SECURE && requestHeaders.get('sec-fetch-dest') === 'iframe',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -344,11 +363,17 @@ function getResponseCookieForVisitorParams(
|
||||
* custom `gitbook-visitor-token` cookie is owned by the customer's backend and is never copied,
|
||||
* to keep a single source of truth.
|
||||
*/
|
||||
export function getResponseCookiesForVisitorAuth(
|
||||
basePath: string,
|
||||
visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>,
|
||||
requestCookies: RequestCookies = []
|
||||
): ResponseCookies {
|
||||
export function getResponseCookiesForVisitorAuth({
|
||||
basePath,
|
||||
visitorTokenLookup,
|
||||
requestCookies = [],
|
||||
requestHeaders,
|
||||
}: {
|
||||
basePath: string;
|
||||
visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>;
|
||||
requestCookies?: RequestCookies;
|
||||
requestHeaders: Headers;
|
||||
}): ResponseCookies {
|
||||
let decoded: JwtPayload;
|
||||
try {
|
||||
decoded = jwtDecode(visitorTokenLookup.token);
|
||||
@@ -361,8 +386,7 @@ export function getResponseCookiesForVisitorAuth(
|
||||
const value = getVisitorAuthCookieValue(basePath, visitorTokenLookup.token);
|
||||
const options = {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
...getVisitorCookieCrossSiteOptions(requestHeaders),
|
||||
maxAge: getVisitorAuthCookieMaxAge(decoded),
|
||||
};
|
||||
|
||||
@@ -392,6 +416,15 @@ export function getVisitorAuthCookieName(basePath: string): string {
|
||||
return getPathScopedCookieName(VISITOR_TOKEN_COOKIE, basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Set-Cookie` values clearing the visitor authentication cookie. Browsers keep partitioned and
|
||||
* unpartitioned cookies apart, so both are cleared whenever `Partitioned` can be set.
|
||||
*/
|
||||
export function getVisitorAuthClearCookieHeaders(basePath: string): string[] {
|
||||
const header = `${getVisitorAuthCookieName(basePath)}=; Path=/; Max-Age=0${IS_SECURE ? '; Secure; SameSite=None' : ''}`;
|
||||
return IS_SECURE ? [`${header}; Partitioned`, header] : [header];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of the visitor authentication cookie for a given base path and token.
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './lib/tracking';
|
||||
import { AI_CATALOG_PATH, AI_CATALOG_WELL_KNOWN_PATH } from '@/lib/aiCatalog/paths';
|
||||
import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie';
|
||||
import { type AskVisitorAgent, getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import { isChatGPTRequest } from '@/lib/chatgpt';
|
||||
import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies';
|
||||
import type { SiteURLData } from '@/lib/context';
|
||||
@@ -51,6 +52,8 @@ import {
|
||||
getPreviewCookieResponse,
|
||||
getPreviewRequestIdentifier,
|
||||
isPreviewRequest,
|
||||
parsePreviewCustomizationCookie,
|
||||
serializePreviewCustomizationCookie,
|
||||
} from '@/lib/preview';
|
||||
import {
|
||||
type ResponseCookies,
|
||||
@@ -166,10 +169,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
}
|
||||
|
||||
// Normalize URL after extracting the URL from the request to make sure the client is redirected to the proper one
|
||||
const normalizationResponse = normalizeRequestURL(siteRequestURL);
|
||||
if (normalizationResponse) {
|
||||
return normalizationResponse;
|
||||
const normalization = normalizeRequestURL(siteRequestURL, mode);
|
||||
if (normalization instanceof Response) {
|
||||
return normalization;
|
||||
}
|
||||
const isNormalizedURL = normalization.isNormalized;
|
||||
|
||||
const imagesContextId = getImageResizingContextId(siteRequestURL);
|
||||
/**
|
||||
@@ -335,6 +339,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup,
|
||||
// and of any visitor.* params that may have been passed to the URL.
|
||||
// Proxied requests that were not normalized are also redirected here, to the canonical URL.
|
||||
//
|
||||
// We only redirect if the request is not coming from the revalidation worker, as we don't
|
||||
// want to redirect in that case. It can carry unsigned claims without any token, so we rely
|
||||
@@ -345,16 +350,17 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
//
|
||||
const normalizedVisitorURL = normalizeVisitorURL(incomingURL);
|
||||
if (
|
||||
normalizedVisitorURL.toString() !== incomingURL.toString() &&
|
||||
(!isNormalizedURL || normalizedVisitorURL.toString() !== incomingURL.toString()) &&
|
||||
!isRevalidationRequest(request.headers)
|
||||
) {
|
||||
if (visitorToken?.source === 'url') {
|
||||
cookies.push(
|
||||
...getResponseCookiesForVisitorAuth(
|
||||
getVisitorAuthBasePath(siteRequestURL, siteURLData),
|
||||
visitorToken,
|
||||
request.cookies.getAll()
|
||||
)
|
||||
...getResponseCookiesForVisitorAuth({
|
||||
basePath: getVisitorAuthBasePath(siteRequestURL, siteURLData),
|
||||
visitorTokenLookup: visitorToken,
|
||||
requestCookies: request.cookies.getAll(),
|
||||
requestHeaders: request.headers,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -421,7 +427,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
const customizationCookie = request.cookies.get(MiddlewareHeaders.Customization);
|
||||
const customization =
|
||||
siteRequestURL.searchParams.get('customization') ??
|
||||
(customizationCookie ? decodeURIComponent(customizationCookie.value) : undefined);
|
||||
(customizationCookie
|
||||
? parsePreviewCustomizationCookie(customizationCookie.value, siteURLData.siteSpace)
|
||||
: undefined);
|
||||
if (
|
||||
allowCustomizationOverride &&
|
||||
customization &&
|
||||
@@ -434,7 +442,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
cookies.push(
|
||||
getPreviewCookieResponse({
|
||||
name: MiddlewareHeaders.Customization,
|
||||
value: encodeURIComponent(customization),
|
||||
value: serializePreviewCustomizationCookie({
|
||||
customization,
|
||||
siteSpace: siteURLData.siteSpace,
|
||||
}),
|
||||
mode,
|
||||
siteRequestURL,
|
||||
siteURLData,
|
||||
@@ -470,11 +481,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
events,
|
||||
isAiAgent,
|
||||
isChatGPT,
|
||||
askVisitorAgent,
|
||||
} = encodePathInSiteContent(siteURLData, request);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
// Only set for Markdown and LLM routes, so these request-specific variants are cached separately.
|
||||
stableSiteURLData.isAiAgent = isAiAgent;
|
||||
stableSiteURLData.isChatGPT = isChatGPT;
|
||||
// Splits the cache of `?ask=` answers per user agent, so each asker is classified correctly.
|
||||
stableSiteURLData.askVisitorAgent = askVisitorAgent;
|
||||
|
||||
// Apply a forced theme (`?theme=`/cookie). For the docs embed we thread it through the
|
||||
// route context (`embedTheme`) so those routes stay statically rendered — it becomes part
|
||||
@@ -800,6 +814,8 @@ function encodePathInSiteContent(
|
||||
isAiAgent?: boolean;
|
||||
/** Only set for Markdown and LLM routes, where the output content type depends on ChatGPT. */
|
||||
isChatGPT?: boolean;
|
||||
/** Only set for the ask route, where the API classifies the asker from these headers. */
|
||||
askVisitorAgent?: AskVisitorAgent;
|
||||
} {
|
||||
let pathname = removeLeadingSlash(removeTrailingSlash(siteURLData.pathname));
|
||||
|
||||
@@ -941,6 +957,8 @@ function encodePathInSiteContent(
|
||||
// Left undefined for non-agents to avoid splitting the static cache for them.
|
||||
isAiAgent: isAiAgent || undefined,
|
||||
isChatGPT: isChatGPTRequest(request) || undefined,
|
||||
askVisitorAgent:
|
||||
typeof ask === 'string' ? getAskVisitorAgent(request.headers) : undefined,
|
||||
// TODO: track pageId / spaceId when possible
|
||||
// We don't do it at the moment as we can't easily extract it from the URL.
|
||||
events: ask
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import type { AskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { linkerWithMarkdownPages } from '@/lib/links';
|
||||
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
|
||||
@@ -14,6 +15,11 @@ export interface ServeAskMarkdownOptions {
|
||||
* passed via the `?goal=` search parameter. Used by the backend to steer the answer.
|
||||
*/
|
||||
goal?: string;
|
||||
|
||||
/**
|
||||
* Headers of the visitor asking the question, forwarded to the backend to classify the asker.
|
||||
*/
|
||||
visitorAgent?: AskVisitorAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +47,10 @@ export async function serveAskMarkdown(
|
||||
return 'You forgot to pass a question in the `?ask=` parameter. Append a question to the URL in the `?ask=<question>` search parameter to get a complete answer and associated sources.';
|
||||
}
|
||||
|
||||
const latestAnswer = await streamSiteAskAnswer(context, question, { goal });
|
||||
const latestAnswer = await streamSiteAskAnswer(context, question, {
|
||||
goal,
|
||||
visitorAgent: options.visitorAgent,
|
||||
});
|
||||
|
||||
if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) {
|
||||
return `We couldn't answer this question.`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ColorCategory, hexToRgb, scale, shadesOfColor } from '@gitbook/colors';
|
||||
export const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
export const opacities = [0, 4, 8, 12, 16, 24, 40, 64, 72, 88, 96, 100];
|
||||
|
||||
export const semanticColors = ['info', 'warning', 'danger', 'success'];
|
||||
export const semanticColors = ['secondary', 'info', 'warning', 'danger', 'success'];
|
||||
|
||||
/**
|
||||
* Generate a Tailwind color shades from a variable.
|
||||
|
||||
@@ -55,6 +55,29 @@ it(
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should get a page through MCP from its markdown URL',
|
||||
async () => {
|
||||
const client = await connectMCPClient(
|
||||
getContentTestURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/~gitbook/mcp/auth'
|
||||
)
|
||||
);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'getPage',
|
||||
arguments: {
|
||||
url: 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/3.0/other-page.md',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.isError).toBeFalsy();
|
||||
// @ts-expect-error - response.content is of type unknown
|
||||
expect(response.content[0]?.text).toContain('# Other Page');
|
||||
},
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
|
||||
describe('MCP on a site behind visitor authentication', () => {
|
||||
const VA_SITE_URL = 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback';
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"version": "3.0.13",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@scalar/json-magic": "^0.9.4",
|
||||
"@scalar/openapi-parser": "^0.24.5",
|
||||
"@scalar/json-magic": "^0.17.0",
|
||||
"@scalar/openapi-parser": "^0.29.19",
|
||||
"@scalar/openapi-types": "^0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { parseOpenAPI } from './parse';
|
||||
|
||||
export { dereference, isJson, isYaml } from '@scalar/openapi-parser';
|
||||
export type { AnyObject } from '@scalar/openapi-parser';
|
||||
export type * from '@scalar/openapi-types';
|
||||
export * from './error';
|
||||
export * from './helpers/shouldIgnoreEntity';
|
||||
|
||||
@@ -69,4 +69,69 @@ describe('#parseOpenAPI', () => {
|
||||
JSON.stringify(result.filesystem);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('validates OpenAPI 3.2 schema objects as schemas', async () => {
|
||||
const result = await parseOpenAPI({ value: specV32(), rootURL: null });
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a 3.2 parameter missing `in`', async () => {
|
||||
const result = await parseOpenAPI({
|
||||
value: specV32({ name: 'id', required: true, schema: { type: 'string' } }),
|
||||
rootURL: null,
|
||||
});
|
||||
expect(result.errors.map((error) => error.message)).toContain(
|
||||
"must have required property 'in'"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function specV32(
|
||||
idParameter: object = {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string', minLength: 1 },
|
||||
}
|
||||
) {
|
||||
return {
|
||||
openapi: '3.2.0',
|
||||
info: { title: 'Users', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users/{id}': {
|
||||
get: {
|
||||
parameters: [
|
||||
idParameter,
|
||||
{
|
||||
name: 'role',
|
||||
in: 'query',
|
||||
schema: { $ref: '#/components/schemas/Role' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'OK',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/User' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Role: { type: 'string', minLength: 1 },
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', minLength: 1 },
|
||||
role: { $ref: '#/components/schemas/Role' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Plugin } from '@scalar/json-magic/bundle';
|
||||
import type { AnyObject, ErrorObject } from '@scalar/openapi-parser';
|
||||
import type { ErrorObject } from '@scalar/openapi-parser';
|
||||
|
||||
import { OpenAPIParseError } from './error';
|
||||
import type { Filesystem, OpenAPIV3xDocument } from './types';
|
||||
import type { AnyObject, Filesystem, OpenAPIV3xDocument } from './types';
|
||||
import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
|
||||
import { parseOpenAPIV3 } from './v3';
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('#traverse', () => {
|
||||
return node;
|
||||
});
|
||||
|
||||
expect(specification.info?.description).toBe('Hello, world!');
|
||||
expect(specification.info).toHaveProperty('description', 'Hello, world!');
|
||||
});
|
||||
|
||||
it('traverses a complete filesystem', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyObject } from '@scalar/openapi-parser';
|
||||
import type { AnyObject } from './types';
|
||||
|
||||
/**
|
||||
* Recursively traverses the specification and applies the transform function to each node.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AnyObject } from '@scalar/openapi-parser';
|
||||
import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
|
||||
|
||||
export type AnyObject = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Custom properties that can be defined at the entire spec level.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function convertOpenAPIV2ToOpenAPIV3(
|
||||
input: ParseOpenAPIInput
|
||||
): Promise<ParseOpenAPIResult> {
|
||||
const result = upgradeFromInput(input);
|
||||
return parseOpenAPIV3({ ...input, rootURL: input.rootURL, value: result.specification });
|
||||
return parseOpenAPIV3({ ...input, rootURL: input.rootURL, value: result.specification! });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,8 +20,12 @@ export async function parseOpenAPIV3(input: ParseOpenAPIInput): Promise<ParseOpe
|
||||
});
|
||||
});
|
||||
|
||||
// Ajv can't compile on Cloudflare Workers (no `new Function`), and Scalar then omits the version.
|
||||
const version =
|
||||
result.version ?? result.specification?.swagger ?? result.specification?.openapi;
|
||||
|
||||
// If there is no version, we consider it invalid instantely.
|
||||
if (!result.version) {
|
||||
if (!version) {
|
||||
throw new OpenAPIParseError(
|
||||
'Can’t find supported Swagger/OpenAPI version in the provided document, version must be a string.',
|
||||
{
|
||||
@@ -33,7 +37,7 @@ export async function parseOpenAPIV3(input: ParseOpenAPIInput): Promise<ParseOpe
|
||||
}
|
||||
|
||||
// If the version is 2.0, we throw an error to trigger the upgrade.
|
||||
if (result.version === '2.0') {
|
||||
if (version === '2.0') {
|
||||
throw new OpenAPIParseError('Only OpenAPI v3 is supported', {
|
||||
code: 'parse-v2-in-v3',
|
||||
rootURL,
|
||||
|
||||
@@ -196,6 +196,10 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
||||
// Rendered without a source before mount so it reserves its size in the SSR layout.
|
||||
src={mounted ? element.source.url : undefined}
|
||||
title={element.source.url}
|
||||
// A page can hold hundreds of these, most of them inside collapsed expandables.
|
||||
// Loading them all on mount delays the first paint by seconds, so each one waits
|
||||
// until it is near the viewport.
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
allow="clipboard-write"
|
||||
className="contentkit-webframe block h-full w-full max-w-full border-none"
|
||||
|
||||
Reference in New Issue
Block a user