Compare commits

..

2 Commits

Author SHA1 Message Date
Peter White 89f42dfc71 Format 2026-10-28 20:04:09 +00:00
Peter White 7a89696a48 Load the AI assistant's streaming engine and tools on first use (RND-13530) 2026-10-28 20:02:36 +00:00
92 changed files with 769 additions and 1763 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"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.
-5
View File
@@ -1,5 +0,0 @@
---
"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.
-5
View File
@@ -1,5 +0,0 @@
---
"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.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
@@ -1,6 +0,0 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Define a page's repeated icons once in an SVG sprite that each icon references, instead of repeating the full SVG at every heading anchor, expandable and embed placeholder, and pack the class lists every expandable repeats into shared CSS classes.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Load the inline expression runtime only on pages whose code blocks or text use expressions, so other pages ship less JavaScript.
-5
View File
@@ -1,5 +0,0 @@
---
"@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.
-5
View File
@@ -1,5 +0,0 @@
---
"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.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add a search input to filter models by name on OpenAPI models pages.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Set `Partitioned` on visitor cookies set inside an iframe so authenticated embeds work cross-site.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Support a secondary brand colour for secondary buttons and `$secondary` text, background, icon and tag colours. Sites without one render as before.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Resolve dark-mode image URLs in published Markdown.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/openapi-parser": patch
---
Bump @scalar/openapi-parser to 0.29 so valid OpenAPI 3.2 documents no longer report false validation errors.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Only apply a site preview customization override to the section or variant it was made for.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Render soft line breaks in paragraphs that contain buttons.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix visitors being logged out right after signing in on sites with a logout link in the header.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
+21 -75
View File
@@ -251,8 +251,8 @@
"name": "@gitbook/openapi-parser",
"version": "3.0.13",
"dependencies": {
"@scalar/json-magic": "^0.17.0",
"@scalar/openapi-parser": "^0.29.19",
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/openapi-types": "^0.5.3",
},
"devDependencies": {
@@ -356,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.208.0",
"@gitbook/api": "0.206.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.208.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-i5rnGYNvgp8PDGd9FRLUjOx5gS/D7mFfikwp0Lsem4pgEG8cqBtZsDaYbLBdAdupOuZBLJtmEut51zhttok6OQ=="],
"@gitbook/api": ["@gitbook/api@0.206.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ctgnDSQ26ZeZvdU/Zb1xxU97yMquKnhGiA0QblAIzKUmfi/3aYmWYHAwXwvEg0wtBrEtrjzme9pXgX/ACWnANA=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -1148,27 +1148,23 @@
"@scalar/draggable": ["@scalar/draggable@0.2.0", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-UetHRB5Bqo5egVYlS21roWBcICmyk8CKh2htsidO+bFGAjl2e7Te+rY0borhNrMclr0xezHlPuLpEs1dvgLS2g=="],
"@scalar/helpers": ["@scalar/helpers@0.16.4", "", {}, "sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw=="],
"@scalar/helpers": ["@scalar/helpers@0.2.9", "", {}, "sha512-Y4ffJF0yELdwZ0BKgonqn3SumIgRn1WKyYCVHD+TDM7qRFChdGRypyt20+efHs26fmJeyBAIIv2laICj5uimiw=="],
"@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.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/json-magic": ["@scalar/json-magic@0.9.4", "", { "dependencies": { "@scalar/helpers": "0.2.9", "yaml": "^2.8.0" } }, "sha512-PyfyWrH4ZkW0TM1ColiiHj4NRF8hUM61H0UzAkHLhRNnKFxi6hI+oqNrwqPnyk93hrpkpTRHC7Fl5T0BRwuzVg=="],
"@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.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-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-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.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/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.8", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-2xuYLLs0fBadLIk4I1ObjMiCnOyLPEMPf24A1HtHQvhKGDnGlvT63F2rU2Xw8lxCjgHnzveMPnOJEbwIy64RCg=="],
"@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=="],
@@ -1614,7 +1610,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@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": ["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-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -2362,7 +2358,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@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stringify-deterministic": ["json-stringify-deterministic@1.0.12", "", {}, "sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g=="],
@@ -3148,7 +3144,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@8.12.3", "", {}, "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw=="],
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -3262,7 +3258,7 @@
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"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=="],
@@ -3444,6 +3440,8 @@
"@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=="],
@@ -3452,6 +3450,8 @@
"@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,12 +3524,6 @@
"@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=="],
@@ -3542,15 +3536,9 @@
"@scalar/object-utils/@scalar/helpers": ["@scalar/helpers@0.1.1", "", {}, "sha512-eJjuCI5djqU0adTwrHvpDf0xuwNRpwZinCfJ03QjnmIFBM9Ch0u3tn/0EZOhcVNbdEyJ+3yvSVy1dCmmvgtpvQ=="],
"@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.9.8", "", {}, "sha512-pGcpkZObsGBC7nRKYte+6gstoe0C4hp7B2UWQYjg5To1wE68s3OHVvin+muL3tn3ZAoahXeJAkn1+aEcMi802w=="],
"@scalar/openapi-parser/leven": ["leven@4.0.0", "", {}, "sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw=="],
"@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/openapi-types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@scalar/postman-to-openapi/@scalar/helpers": ["@scalar/helpers@0.0.12", "", {}, "sha512-4NDmHShyi1hrVRsJCdRZT/FIpy+/5PFbVbQLRYX/pjpu5cYqHBj9s6n5RI6gGDXEBHAIFi63g9FC6Isgr66l1Q=="],
@@ -3620,8 +3608,6 @@
"@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=="],
@@ -3696,10 +3682,6 @@
"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=="],
@@ -3924,8 +3906,6 @@
"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=="],
@@ -3948,7 +3928,7 @@
"youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"zod-to-json-schema/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
"zod-to-json-schema/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@ampproject/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
@@ -4060,6 +4040,8 @@
"@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=="],
@@ -4094,8 +4076,6 @@
"@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=="],
@@ -4132,30 +4112,8 @@
"@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=="],
@@ -4276,8 +4234,6 @@
"@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=="],
@@ -4298,10 +4254,6 @@
"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=="],
@@ -4452,8 +4404,6 @@
"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=="],
@@ -4500,8 +4450,6 @@
"@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=="],
@@ -4516,8 +4464,6 @@
"@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
View File
@@ -48,7 +48,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.208.0",
"@gitbook/api": "0.206.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -1,3 +1,4 @@
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
@@ -8,7 +9,7 @@ import {
} from '@/app/utils';
import { getVisitorAuthBasePath } from '@/lib/data';
import { resolveUpstreamAuthURL } from '@/lib/site-auth-urls';
import { getVisitorAuthClearCookieHeaders } from '@/lib/visitors';
import { getVisitorAuthCookieName } from '@/lib/visitors';
/**
* Clear the site-scoped auth session cookies and redirect to the upstream auth provider
@@ -18,18 +19,19 @@ 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] = await Promise.all([
const [{ context }, siteURLData, cookieStore] = 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,
@@ -39,12 +41,5 @@ export async function GET(
location: request.nextUrl.searchParams.get('location') ?? '/',
});
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;
return NextResponse.redirect(logoutURL ?? noLogoutFallbackURL);
}
@@ -1,10 +1,6 @@
import type { NextRequest } from 'next/server';
import {
type RouteLayoutParams,
getSiteURLDataFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
export const dynamic = 'force-static';
@@ -22,9 +18,8 @@ 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, visitorAgent: askVisitorAgent });
return serveAskMarkdown(context, question, { goal });
}
@@ -1,10 +1,6 @@
import type { NextRequest } from 'next/server';
import {
type RouteLayoutParams,
getSiteURLDataFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
export const dynamic = 'force-static';
@@ -15,8 +11,7 @@ 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, { visitorAgent: askVisitorAgent });
return serveAskMarkdown(context, question);
}
@@ -0,0 +1,451 @@
'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,6 +1,4 @@
'use server';
import { headers } from 'next/headers';
import {
type AIMessageContext,
AIMessageRole,
@@ -14,7 +12,6 @@ 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';
@@ -75,8 +72,7 @@ export async function* streamAIChatResponse({
? SiteCoreChannelType.Embed
: SiteCoreChannelType.Site,
},
},
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
}
);
return await streamRenderAIMessage(context, rawStream, options);
+47 -404
View File
@@ -1,39 +1,26 @@
'use client';
import assertNever from 'assert-never';
import dynamic from 'next/dynamic';
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 { getInsightsSession, useTrackEvent } from '../Insights';
import { 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, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import type { RenderAIMessageOptions } from './server-actions';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useLeaveAgentFeedbackTool, useLeaveUserFeedbackTool } from './useLeaveFeedbackTools';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import {
type ResponseToRate,
useSubmitAssistantFeedbackTool,
} from './useSubmitAssistantFeedbackTool';
import { useSubmitPageFeedbackTool } from './useSubmitPageFeedbackTool';
import type { ResponseToRate } from './useSubmitAssistantFeedbackTool';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
const noopSetSearchState: UpdateSearchState = () => Promise.resolve(new URLSearchParams());
@@ -189,10 +176,28 @@ 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
const globalState = zustand.create<AIChatState>(() => {
export const globalState = zustand.create<AIChatState>(() => {
return {
opened: false,
responseId: null,
@@ -244,10 +249,6 @@ 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
@@ -258,48 +259,22 @@ 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);
// 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);
// 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;
}
return tools;
}, [
navigateToPageTool,
submitAssistantFeedbackTool,
leaveAgentFeedbackTool,
leaveUserFeedbackTool,
submitPageFeedbackTool,
withPageFeedback,
]);
pendingStreams.push(input);
setEngineRequested(true);
}, []);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
@@ -308,6 +283,7 @@ export function AIChatProvider(props: {
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
setEngineRequested(true);
if (!renderMessageOptions?.asEmbeddable) {
trackEvent({ type: 'ask_view', trigger });
}
@@ -348,307 +324,6 @@ 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 }) => {
@@ -747,6 +422,7 @@ 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,
@@ -863,6 +539,18 @@ 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>
);
}
@@ -918,48 +606,3 @@ 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,
};
}
@@ -1,18 +0,0 @@
@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,7 +2,6 @@ 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';
@@ -94,10 +93,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'gb-block',
'decoration-primary/6',
FULL_WIDTH_BLOCKS.has(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
LIST_BLOCKS.has(node.type) && 'gb-block-list',
!LIST_BLOCKS.has(node.type) && 'print:break-inside-avoid',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -6,10 +6,11 @@ import { useDebounceCallback } from 'usehooks-ts';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import type { BlockProps } from '../Block';
import type { InlineExpressionVariables } from '../InlineExpression/types';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
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';
@@ -19,8 +20,6 @@ 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;
@@ -35,12 +34,18 @@ 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, evaluateInlineExpression, themes, embedded, commentKey } =
const { block, mode, style, inlines, inlineExprVariables, 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]
@@ -1,18 +0,0 @@
'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} />;
}
@@ -1,18 +0,0 @@
'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,7 +10,6 @@ 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';
@@ -131,11 +130,8 @@ export async function CodeBlock(
{isMermaid ? (
<MermaidCodeBlockLazy
{...clientProps}
hasInlineExpression={hasInlineExpression}
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
/>
) : hasInlineExpression ? (
<ClientCodeBlockWithExpressionsLazy {...clientProps} />
) : (
<ClientCodeBlock {...clientProps} />
)}
@@ -8,7 +8,6 @@ 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';
@@ -21,7 +20,6 @@ import { tcls } from '@/lib/tailwind';
export function MermaidCodeBlock(
props: ClientBlockProps & {
mermaidRuntimeURL: string;
hasInlineExpression: boolean;
}
) {
const { block, mode, style, mermaidRuntimeURL } = props;
@@ -171,11 +169,7 @@ export function MermaidCodeBlock(
}, []);
if (error) {
return props.hasInlineExpression ? (
<ClientCodeBlockWithExpressions {...props} />
) : (
<ClientCodeBlock {...props} />
);
return <ClientCodeBlock {...props} />;
}
// The live diagram subtree. It is portaled into a stable host that moves between the
@@ -11,8 +11,6 @@ const MermaidCodeBlock = dynamic(
{ ssr: true }
);
export function MermaidCodeBlockLazy(
props: ClientBlockProps & { mermaidRuntimeURL: string; hasInlineExpression: boolean }
) {
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
return <MermaidCodeBlock {...props} />;
}
@@ -50,8 +50,27 @@ export function Details(props: {
id={id}
data-block-key={props.commentKey}
open={props.open || openFromHash}
// Styles live on `.expandable` in `Expandable.css`; the group name is matched literally.
className={tcls(className, 'expandable', 'group/expandable')}
className={tcls(
className,
'group/expandable',
'bg-tint-base',
'border',
'border-b-0',
'border-tint-subtle',
//all
'mt-0!',
'mb-0',
//select first child
'first:mt-5!',
'first:rounded-t-lg',
//select first in group
'[:not(&)_+_&]:mt-5!',
'[:not(&)_+_&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b'
)}
>
{children}
</details>
@@ -1,43 +0,0 @@
@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 can stack a hundred expandables, so their styles live here rather than in each one's
class attributes. They sit in `components` so the utilities passed as props still override. */
@layer components {
/* Consecutive expandables join into one bordered group: only the first of a run gets the top
margin and corners, and only the last the bottom border, margin and corners. */
.expandable {
@apply scroll-mt-(--content-scroll-margin) border border-b-0 border-tint-subtle bg-tint-base;
@apply mt-0! mb-0 first:mt-5! first:rounded-t-lg;
@apply [:not(&)_+_&]:mt-5! [:not(&)_+_&]:rounded-t-lg;
@apply [&:not(:has(+_&))]:mb-5 [&:not(:has(+_&))]:rounded-b-lg [&:not(:has(+_&))]:border-b;
}
.expandable-summary {
@apply relative cursor-pointer list-none select-none p-4 text-tint transition-colors;
@apply group-hover/expandable:text-tint-strong group-open/expandable:text-tint-strong;
@apply [&::-webkit-details-marker]:hidden;
}
/* Room for the anchor icon pinned to the right edge. */
.expandable-summary-anchored {
@apply pr-10;
}
.expandable-chevron {
@apply mr-3 mb-1 inline-block group-open/expandable:rotate-90;
}
.expandable-anchor {
@apply absolute top-2 right-4 bottom-2 flex items-center dark:shadow-none dark:ring-0;
}
.expandable-anchor-icon {
@apply inline-block size-3 text-transparent transition-colors;
@apply group-hover/expandable:text-tint-subtle contrast-more:group-hover/expandable:text-tint-strong;
}
}
@@ -7,7 +7,6 @@ import { shouldShowHashLinks } from '../HashLinkButton';
import { Inlines } from '../Inlines';
import { getCommentAnchorProps, getCommentKeyProp } from '../utils';
import { Details } from './Details';
import './Expandable.css';
import { ToggleChevron } from '@/components/primitives';
import { getSpaceLanguage, tString } from '@/intl/server';
import { defaultLanguage } from '@/intl/translations';
@@ -41,19 +40,37 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id={id}
{...getCommentKeyProp(context, block.key)}
open={context.mode === 'print' || isDefaultExpanded}
className={style}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary
{...getCommentAnchorProps(context, titleParagraph.key)}
className={tcls(
'expandable-summary',
showHashLink && 'expandable-summary-anchored'
'cursor-pointer',
'px-4',
// Reserve room for the anchor icon pinned to the right edge
showHashLink && 'pr-10',
'py-4',
'relative',
'list-none',
'select-none',
'transition-colors',
'text-tint',
'group-hover/expandable:text-tint-strong',
'group-open/expandable:text-tint-strong',
'[&::-webkit-details-marker]:hidden'
)}
>
<ToggleChevron
orientation="right-to-down"
open={context.mode === 'print'}
className="expandable-chevron"
className={tcls(
'inline-block',
'size-3',
'mr-3',
'mb-1',
'shrink-0',
'group-open/expandable:rotate-90'
)}
/>
<Inlines
nodes={titleParagraph.nodes}
@@ -65,9 +82,28 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className="expandable-anchor"
className={tcls(
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
>
<Icon icon="hashtag" className="expandable-anchor-icon" />
<Icon
icon="hashtag"
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
)}
/>
</a>
) : null}
</summary>
@@ -1,30 +0,0 @@
@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,7 +3,6 @@ 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';
@@ -36,13 +35,53 @@ export function HashLinkButton(props: {
const textStyle = getBlockTextStyle(block);
return (
<span className={tcls('hash', className)}>
<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
)}
>
<Link
href={`#${id}`}
aria-label={label}
className={tcls('hash-link', textStyle.lineHeight)}
className={tcls(
'inline-flex items-center',
'p-1',
'pointer-fine:h-full pointer-fine:p-0',
textStyle.lineHeight
)}
>
<Icon icon="hashtag" className={tcls('hash-icon', iconClassName)} />
<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
)}
/>
</Link>
</span>
);
@@ -12,7 +12,6 @@ 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<
@@ -24,28 +23,6 @@ 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;
@@ -54,7 +31,6 @@ 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 { InlineExpressionValueLazy } from './InlineExpressionValueLazy';
import { InlineExpressionValue } from './InlineExpressionValue';
/**
* Render an inline expression.
@@ -25,7 +25,7 @@ export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
return (
<React.Suspense fallback={null}>
<InlineExpressionValueLazy expression={data.expression} variables={variables} />
<InlineExpressionValue expression={data.expression} variables={variables} />
</React.Suspense>
);
}
@@ -1,18 +0,0 @@
'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,2 +1,3 @@
export * from './InlineExpression';
export * from './types';
export * from './useEvaluateInlineExpression';
@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import type React from 'react';
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
import {
@@ -12,10 +12,6 @@ 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.
@@ -78,51 +74,14 @@ export function OpenAPIBlockClient(props: OpenAPIBlockClientProps) {
);
case 'webhook':
return <BaseOpenAPIWebhook data={props.data} context={context} className={className} />;
case 'schemas': {
const SchemasComponent =
mode !== 'print' && props.data.schemas.length >= MIN_RECORDS_FOR_SEARCH
? OpenAPISchemasWithSearch
: BaseOpenAPISchemas;
case 'schemas':
return (
<SchemasComponent
<BaseOpenAPISchemas
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,7 +4,6 @@ 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';
@@ -24,7 +23,12 @@ export function RecordRow(
role="row"
recordId={record[0]}
visibleClassName="flex"
className={tcls('group/row', 'table-grid-row')}
className={tcls(
'group/row',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
>
{view.columns.map((column) => {
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
@@ -54,16 +58,16 @@ export function RecordRow(
{...getCommentCellProps(context, record[0], column)}
aria-colspan={cellMerge?.merge.colSpan}
className={tcls(
'table-grid-cell',
isStickyFirstColumnCell ? 'table-grid-cell-sticky' : undefined
'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
)}
// 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
}
style={{
width: columnWidth,
minWidth: columnWidth || '100px',
}}
>
<RecordColumnValue
{...props}
@@ -1,28 +0,0 @@
@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,8 +141,7 @@ const TabItemList = memo(function TabItemList(props: {
onSelect: (tabId: string) => void;
}) {
const { tabs, activeTabId, onSelect } = props;
// The active tab's width decides how many others fit, so a new selection re-measures.
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow(activeTabId);
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
const overflowingTabs = useMemo(
() =>
Array.from(overflowing, (id) => {
@@ -182,7 +181,6 @@ const TabItemList = memo(function TabItemList(props: {
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
isMeasuring={isMeasuring}
tab={tab}
onSelect={onSelect}
/>
@@ -241,11 +239,10 @@ 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, isMeasuring, onSelect } = props;
const { ref, tab, isActive, onSelect } = props;
return (
<TabButton
ref={ref}
@@ -253,20 +250,7 @@ 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>
@@ -280,10 +264,9 @@ const TabItem = memo(function TabItem(props: {
function TabButton(
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
isActive?: boolean;
wrapperClassName?: string;
}
) {
const { isActive, wrapperClassName, ...rest } = props;
const { isActive, ...rest } = props;
return (
<div
className={tcls(
@@ -327,15 +310,14 @@ function TabButton(
'[:has(+_&.active-tab)]:after:border-transparent',
'[:has(&_+)]:after:border-transparent',
]
: null,
wrapperClassName
: null
)}
>
<button
{...rest}
type="button"
className={tcls(
'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]',
'relative inline-flex max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
props.className
)}
/>
@@ -38,21 +38,12 @@ export function Text(props: { text: DocumentText }) {
{children}
</Mark>
);
}, withSoftBreaks(leaf.text))}
}, 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,
@@ -1,23 +0,0 @@
@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,22 +1,20 @@
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 'gb-align-start';
return ['text-start', 'self-start', 'justify-start'];
case TextAlignment.Center:
return 'gb-align-center';
return ['text-center', 'self-center', 'justify-center'];
case TextAlignment.End:
return 'gb-align-end';
return ['text-end', 'self-end', 'justify-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, { crossOrigin: 'anonymous' });
ReactDOM.preconnect(GITBOOK_FONTS_URL);
fontData.preloadSources
.flatMap((face) => face.sources)
.forEach(({ url, format }) => {
@@ -92,30 +92,6 @@ 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.
@@ -202,8 +178,7 @@ 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', tintScale.light.color, tintScale.light.options)}
${generateColorVariable('secondary', secondaryScale.light.color, secondaryScale.light.options)}
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { baseStep: tintBaseStep, mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
--header-background: ${
@@ -230,8 +205,7 @@ 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', tintScale.dark.color, tintScale.dark.options)}
${generateColorVariable('secondary', secondaryScale.dark.color, secondaryScale.dark.options)}
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, baseStep: tintBaseStep, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
--header-background: ${hexToRgb(customization.header.backgroundColor?.dark ?? tintColor?.dark ?? customization.styling.primaryColor.dark)};
@@ -1,7 +1,6 @@
'use server';
import { createStreamableValue } from 'ai/rsc';
import { headers } from 'next/headers';
import type * as React from 'react';
import type {
@@ -14,7 +13,6 @@ 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';
@@ -79,8 +77,7 @@ export async function streamAskQuestion({
},
session,
},
{ format: 'document' },
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
{ format: 'document' }
);
const spacePromises = new Map<string, Promise<Revision>>();
@@ -64,12 +64,11 @@ export async function SiteLayout(props: {
// Scripts are disabled when tracking is disabled
const scripts = withTracking ? context.scripts : [];
// 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);
}
});
ReactDOM.preconnect(GITBOOK_API_PUBLIC_URL);
ReactDOM.preconnect(GITBOOK_ICONS_URL);
if (GITBOOK_ASSETS_URL) {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
// 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
@@ -140,7 +140,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
);
return (
<IconsProvider iconSources={iconSources} sprite>
<IconsProvider iconSources={iconSources}>
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{filterableTagSlugs.length > 0 ? (
<UpdatesFilterProvider tagSlugs={filterableTagSlugs}>
@@ -30,10 +30,8 @@ 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(remeasureKey?: unknown): OverflowState {
export function useListOverflow(): 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
@@ -60,12 +58,6 @@ export function useListOverflow(remeasureKey?: unknown): 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, isSiteAuthLink, toNonEmbedLink } from '../utils/link';
import { isExternalLink, toNonEmbedLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
import { tcls } from '@/lib/tailwind';
import { getSamePageAnchor, resolveAnchorURL } from '@/lib/urls';
@@ -93,9 +93,6 @@ 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>) => {
@@ -117,7 +114,7 @@ export function Link(props: LinkProps) {
const isExternalOnClient = isExternalClient(href);
if (insights) {
trackEvent(insights, undefined, { immediate: isExternalOnClient || isAuthLink });
trackEvent(insights, undefined, { immediate: isExternalOnClient });
}
const isInIframe = window.self !== window.top;
@@ -136,7 +133,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 || isAuthLink) {
if (isExternal) {
return (
<a
ref={ref}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { isExternalLink, isSiteAuthLink, toNonEmbedLink } from './link';
import { isExternalLink, toNonEmbedLink } from './link';
describe('isExternalLink', () => {
it('treats anchor links as internal', () => {
@@ -74,22 +74,3 @@ 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,18 +49,3 @@ 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);
}
@@ -1,48 +0,0 @@
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({});
});
});
-36
View File
@@ -1,36 +0,0 @@
/**
* 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 -8
View File
@@ -1,6 +1,5 @@
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';
@@ -16,11 +15,6 @@ 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;
}
/**
@@ -51,8 +45,7 @@ export async function streamSiteAskAnswer(
currentSiteSpace: context.siteSpace.id,
},
},
{ format: 'markdown' },
{ headers: getAskForwardedHeaders(options.visitorAgent) }
{ format: 'markdown' }
);
let latestAnswer: SearchAIAnswer | null = null;
-2
View File
@@ -14,7 +14,6 @@ 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'],
@@ -34,7 +33,6 @@ 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'],
-7
View File
@@ -23,7 +23,6 @@ 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,
@@ -105,12 +104,6 @@ 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;
};
/**
-87
View File
@@ -1,87 +0,0 @@
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();
});
});
+2 -10
View File
@@ -11,7 +11,7 @@ import {
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { cache } from '../cache';
import { DataFetcherError, isTransientDataFetcherError, wrapDataFetcherError } from './errors';
import { DataFetcherError, 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(
})
);
const result = await wrapDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`,
async () => {
@@ -833,14 +833,6 @@ 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;
}
);
+1 -11
View File
@@ -2,17 +2,7 @@ import { describe, expect, it } from 'bun:test';
import { GitBookAPIError } from '@gitbook/api';
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);
});
});
import { extractCacheControl } from './errors';
describe('extractCacheControl', () => {
it('should return undefined when error has no response', () => {
-7
View File
@@ -93,13 +93,6 @@ 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.
*/
@@ -132,27 +132,6 @@ describe('getContentInlineIconSourceRequests', () => {
]);
});
it('collects the icons that blocks repeat once per instance', () => {
const document = {
object: 'document',
nodes: [
{ object: 'block', type: 'heading-2', nodes: [] },
{ object: 'block', type: 'expandable', nodes: [] },
{ object: 'block', type: 'integration', nodes: [] },
{ object: 'block', type: 'paragraph', nodes: [] },
],
} as unknown as JSONDocument;
expect(
getContentInlineIconSourceRequests({ iconStyle: IconStyle.Regular, document })
).toEqual([
{ icon: 'hashtag', iconStyle: IconStyle.Regular },
{ icon: 'hashtag', iconStyle: IconStyle.Regular },
{ icon: 'chevron-right', iconStyle: IconStyle.Regular },
{ icon: 'spinner', iconStyle: IconStyle.Regular },
]);
});
it('ignores invalid, missing, and non-string icon values', () => {
const pages = [
{ icon: 'not-a-real-icon' },
-17
View File
@@ -138,17 +138,6 @@ const customIconSourceRequests = [
'vscode',
] satisfies IconName[];
/**
* Icons a block renders once per instance, so a document with many such blocks repeats them.
*/
const blockIconSourceRequests: Partial<Record<string, IconName[]>> = {
'heading-1': ['hashtag'],
'heading-2': ['hashtag'],
'heading-3': ['hashtag'],
expandable: ['hashtag', 'chevron-right'],
integration: ['spinner'],
};
const explicitStyleIconSourceRequests = [
{ icon: 'check', iconStyle: IconStyle.Solid },
{ icon: 'chevron-right', iconStyle: IconStyle.Solid },
@@ -391,12 +380,6 @@ function collectDocumentIconSourceRequests(
}
}
if (record.object === 'block' && typeof record.type === 'string') {
for (const icon of blockIconSourceRequests[record.type] ?? []) {
addIconSourceRequest(requests, icon, iconStyle);
}
}
for (const value of Object.values(record)) {
if (Array.isArray(value)) {
for (const item of value) {
@@ -1,57 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { Icon, IconStyle, IconsProvider } from '@gitbook/icons';
const hashtag = { viewBox: '0 0 448 512', markup: '<path d="M1 1h2"></path>' };
const star = { viewBox: '0 0 576 512', markup: '<path d="M3 3h4"></path>' };
describe('icon sprite', () => {
it('defines each source once and references it from every icon', () => {
const html = renderToStaticMarkup(
<IconsProvider
iconStyle={IconStyle.Regular}
iconSources={{ 'regular/hashtag': hashtag }}
sprite
>
<Icon icon="hashtag" />
<Icon icon="hashtag" className="size-4" />
</IconsProvider>
);
expect(html.split(hashtag.markup)).toHaveLength(2);
expect(html).toContain('<g id="gb-icon-regular-hashtag">');
expect(html.match(/<use href="#gb-icon-regular-hashtag"><\/use>/g)).toHaveLength(2);
expect(html).toContain('viewBox="0 0 448 512"');
});
it('inlines the markup without a sprite', () => {
const html = renderToStaticMarkup(
<IconsProvider
iconStyle={IconStyle.Regular}
iconSources={{ 'regular/hashtag': hashtag }}
>
<Icon icon="hashtag" />
</IconsProvider>
);
expect(html).not.toContain('<use');
expect(html).not.toContain('data-gb-icon-sprite');
expect(html).toContain(hashtag.markup);
});
it('inlines sources that only an outer provider defines', () => {
const html = renderToStaticMarkup(
<IconsProvider iconStyle={IconStyle.Regular} iconSources={{ 'regular/star': star }}>
<IconsProvider iconSources={{ 'regular/hashtag': hashtag }} sprite>
<Icon icon="star" />
<Icon icon="hashtag" />
</IconsProvider>
</IconsProvider>
);
expect(html).toContain(`>${star.markup}</svg>`);
expect(html).not.toContain('gb-icon-regular-star');
expect(html).toContain('<use href="#gb-icon-regular-hashtag"></use>');
});
});
+1 -24
View File
@@ -18,18 +18,13 @@ 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: [file] },
revision: { pages: [page], files: [] },
linker,
} as unknown as GitBookAnyContext;
@@ -61,21 +56,3 @@ 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('&', '&amp;'))
);
});
});
-12
View File
@@ -33,8 +33,6 @@ 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.
@@ -380,16 +378,6 @@ 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(
-4
View File
@@ -15,7 +15,6 @@ 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';
@@ -262,11 +261,8 @@ 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({
+1 -27
View File
@@ -2,13 +2,7 @@ import { describe, expect, it } from 'bun:test';
import type { PublishedSiteContent } from '@gitbook/api';
import {
getPreviewCookieResponse,
getPreviewRequestIdentifier,
isPreviewRequest,
parsePreviewCustomizationCookie,
serializePreviewCustomizationCookie,
} from './preview';
import { getPreviewCookieResponse, getPreviewRequestIdentifier, isPreviewRequest } from './preview';
describe('isPreviewRequest', () => {
it('should return true for preview requests', () => {
@@ -71,23 +65,3 @@ 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();
});
});
-28
View File
@@ -30,34 +30,6 @@ 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.
*/
+6 -57
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'bun:test';
import type { JwtPayload } from 'jwt-decode';
import { NextResponse } from 'next/server';
import {
type ResponseCookies,
@@ -235,66 +234,21 @@ 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', () => {
const visitorTokenLookup = { source: 'url', token } as const;
expect(
getResponseCookiesForVisitorAuth({ basePath: '/', visitorTokenLookup, requestHeaders })
).toHaveLength(1);
expect(getResponseCookiesForVisitorAuth('/', { source: 'url', token })).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;
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,
});
// @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);
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', () => {
@@ -307,12 +261,7 @@ describe('getResponseCookiesForVisitorAuth chunking', () => {
};
const write = (basePath: string, token: string, previous: ResponseCookies = []) =>
getResponseCookiesForVisitorAuth({
basePath,
visitorTokenLookup: { source: 'url', token },
requestCookies: previous,
requestHeaders: new Headers(),
});
getResponseCookiesForVisitorAuth(basePath, { source: 'url', token }, previous);
const read = (cookies: ResponseCookies, urlPath = '/') =>
getVisitorToken({
+15 -48
View File
@@ -15,7 +15,6 @@ 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.
@@ -29,7 +28,6 @@ export type ResponseCookie = {
secure: boolean;
maxAge: number;
path: string;
partitioned: boolean;
}>;
};
@@ -148,10 +146,7 @@ export function getVisitorData({
}): VisitorDataLookup {
const visitorToken = getVisitorToken({ cookies, headers, url });
const unsignedClaims = getVisitorUnsignedClaims({ cookies, url });
const visitorParamsCookie = getResponseCookieForVisitorParams({
visitorParamsClaims: unsignedClaims.fromVisitorParams,
requestHeaders: headers,
});
const visitorParamsCookie = getResponseCookieForVisitorParams(unsignedClaims.fromVisitorParams);
return {
visitorToken,
@@ -323,13 +318,9 @@ 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,
requestHeaders,
}: {
visitorParamsClaims: Record<string, ClaimPrimitive>;
requestHeaders: Headers;
}): ResponseCookie | undefined {
function getResponseCookieForVisitorParams(
visitorParamsClaims: Record<string, ClaimPrimitive>
): ResponseCookie | undefined {
if (Object.keys(visitorParamsClaims).length === 0) {
return undefined;
}
@@ -337,20 +328,10 @@ function getResponseCookieForVisitorParams({
return {
name: VISITOR_UNSIGNED_CLAIMS_PREFIX,
value: JSON.stringify(visitorParamsClaims),
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',
options: {
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
},
};
}
@@ -363,17 +344,11 @@ function getVisitorCookieCrossSiteOptions(requestHeaders: Headers) {
* 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,
visitorTokenLookup,
requestCookies = [],
requestHeaders,
}: {
basePath: string;
visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>;
requestCookies?: RequestCookies;
requestHeaders: Headers;
}): ResponseCookies {
export function getResponseCookiesForVisitorAuth(
basePath: string,
visitorTokenLookup: Extract<VisitorTokenLookup, { source: 'url' }>,
requestCookies: RequestCookies = []
): ResponseCookies {
let decoded: JwtPayload;
try {
decoded = jwtDecode(visitorTokenLookup.token);
@@ -386,7 +361,8 @@ export function getResponseCookiesForVisitorAuth({
const value = getVisitorAuthCookieValue(basePath, visitorTokenLookup.token);
const options = {
httpOnly: true,
...getVisitorCookieCrossSiteOptions(requestHeaders),
sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : undefined,
secure: process.env.NODE_ENV === 'production',
maxAge: getVisitorAuthCookieMaxAge(decoded),
};
@@ -416,15 +392,6 @@ 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.
*/
+7 -23
View File
@@ -23,7 +23,6 @@ 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';
@@ -52,8 +51,6 @@ import {
getPreviewCookieResponse,
getPreviewRequestIdentifier,
isPreviewRequest,
parsePreviewCustomizationCookie,
serializePreviewCustomizationCookie,
} from '@/lib/preview';
import {
type ResponseCookies,
@@ -353,12 +350,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
) {
if (visitorToken?.source === 'url') {
cookies.push(
...getResponseCookiesForVisitorAuth({
basePath: getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorTokenLookup: visitorToken,
requestCookies: request.cookies.getAll(),
requestHeaders: request.headers,
})
...getResponseCookiesForVisitorAuth(
getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorToken,
request.cookies.getAll()
)
);
}
@@ -425,9 +421,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const customizationCookie = request.cookies.get(MiddlewareHeaders.Customization);
const customization =
siteRequestURL.searchParams.get('customization') ??
(customizationCookie
? parsePreviewCustomizationCookie(customizationCookie.value, siteURLData.siteSpace)
: undefined);
(customizationCookie ? decodeURIComponent(customizationCookie.value) : undefined);
if (
allowCustomizationOverride &&
customization &&
@@ -440,10 +434,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
cookies.push(
getPreviewCookieResponse({
name: MiddlewareHeaders.Customization,
value: serializePreviewCustomizationCookie({
customization,
siteSpace: siteURLData.siteSpace,
}),
value: encodeURIComponent(customization),
mode,
siteRequestURL,
siteURLData,
@@ -479,14 +470,11 @@ 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
@@ -812,8 +800,6 @@ 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));
@@ -955,8 +941,6 @@ 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 -10
View File
@@ -1,6 +1,5 @@
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';
@@ -15,11 +14,6 @@ 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;
}
/**
@@ -47,10 +41,7 @@ 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,
visitorAgent: options.visitorAgent,
});
const latestAnswer = await streamSiteAskAnswer(context, question, { goal });
if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) {
return `We couldn't answer this question.`;
+1 -1
View File
@@ -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 = ['secondary', 'info', 'warning', 'danger', 'success'];
export const semanticColors = ['info', 'warning', 'danger', 'success'];
/**
* Generate a Tailwind color shades from a variable.
+17 -26
View File
@@ -3,7 +3,7 @@
import * as React from 'react';
import { getIconStyle } from './getIconStyle';
import { getIconSpriteId, getInlineIconSourceKey } from './IconSources';
import { getInlineIconSourceKey } from './IconSources';
import { getIconAssetURL, useIcons } from './IconsProvider';
import type { IconName, IconStyle } from './types';
@@ -51,34 +51,25 @@ export const Icon = React.forwardRef(function Icon(
} = props;
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
const sourceKey = getInlineIconSourceKey(iconStyle, icon);
const source = context.iconSources?.[sourceKey];
const source = context.iconSources?.[getInlineIconSourceKey(iconStyle, icon)];
const maskId = React.useId();
if (source) {
const svgProps = {
ref,
...rest,
viewBox: source.viewBox,
fill: 'currentColor',
style: {
overflow: 'visible',
...(size ? { width: size, height: size } : {}),
...rest.style,
},
className: `gb-icon ${className}`,
};
// A page can repeat one icon hundreds of times (heading anchors), so reference the sprite.
if (context.spriteKeys?.has(sourceKey)) {
return (
<svg {...svgProps}>
<use href={`#${getIconSpriteId(sourceKey)}`} />
</svg>
);
}
return <svg {...svgProps} dangerouslySetInnerHTML={{ __html: source.markup }} />;
return (
<svg
ref={ref}
{...rest}
viewBox={source.viewBox}
fill="currentColor"
style={{
overflow: 'visible',
...(size ? { width: size, height: size } : {}),
...rest.style,
}}
className={`gb-icon ${className}`}
dangerouslySetInnerHTML={{ __html: source.markup }}
/>
);
}
const url = getIconAssetURL(context, iconStyle, icon);
-7
View File
@@ -11,10 +11,3 @@ export interface InlineIconSource {
export function getInlineIconSourceKey(style: string, icon: string): string {
return `${style}/${icon}`;
}
/**
* Get the element id an icon's sprite entry is defined under, from its source key.
*/
export function getIconSpriteId(key: string): string {
return `gb-icon-${key.replace(/[^a-z0-9-]/gi, '-')}`;
}
+4 -61
View File
@@ -2,7 +2,7 @@
import * as React from 'react';
import { type InlineIconSource, getIconSpriteId } from './IconSources';
import type { InlineIconSource } from './IconSources';
import { IconStyle } from './types';
import { GITBOOK_ICONS_ASSET_VERSION } from './version';
@@ -18,8 +18,6 @@ export type IconsContextType = Partial<IconsAssetsLocation> & {
assetsByStyles?: Record<string, IconsAssetsLocation>;
/** Server-resolved SVG sources keyed by style and icon. */
iconSources?: Record<string, InlineIconSource>;
/** Source keys defined in an on-page sprite, which icons reference instead of repeating the markup. */
spriteKeys?: ReadonlySet<string>;
/** Current default style for icons */
iconStyle: IconStyle;
};
@@ -31,17 +29,7 @@ const IconsContext = React.createContext<IconsContextType>({
/**
* Provider to control the loading of icons.
*/
export function IconsProvider(
props: React.PropsWithChildren<
Partial<Omit<IconsContextType, 'spriteKeys'>> & {
/**
* Define this provider's own `iconSources` once in a hidden sprite, so icons below
* reference them rather than each repeating the SVG markup.
*/
sprite?: boolean;
}
>
) {
export function IconsProvider(props: React.PropsWithChildren<Partial<IconsContextType>>) {
const parent = React.useContext(IconsContext);
const {
children,
@@ -50,9 +38,7 @@ export function IconsProvider(
iconStyle = parent.iconStyle,
assetsByStyles = parent.assetsByStyles,
iconSources = parent.iconSources,
sprite = false,
} = props;
const spriteSources = sprite ? props.iconSources : undefined;
const value = React.useMemo(() => {
return {
assetsURL,
@@ -63,53 +49,10 @@ export function IconsProvider(
parent.iconSources && iconSources
? { ...parent.iconSources, ...iconSources }
: (iconSources ?? parent.iconSources),
spriteKeys: spriteSources
? new Set([...(parent.spriteKeys ?? []), ...Object.keys(spriteSources)])
: parent.spriteKeys,
};
}, [
assetsURL,
assetsURLToken,
iconStyle,
assetsByStyles,
iconSources,
spriteSources,
parent.iconSources,
parent.spriteKeys,
]);
}, [assetsURL, assetsURLToken, iconStyle, assetsByStyles, iconSources, parent.iconSources]);
return (
<IconsContext.Provider value={value}>
{spriteSources ? <IconSprite sources={spriteSources} /> : null}
{children}
</IconsContext.Provider>
);
}
/**
* Hidden SVG holding one copy of each source, for `<use>` references from icons.
* Entries are groups rather than symbols so they draw in the referencing icon's own viewBox.
*/
function IconSprite(props: { sources: Record<string, InlineIconSource> }) {
return (
<svg
aria-hidden="true"
data-gb-icon-sprite=""
width="0"
height="0"
style={{ position: 'absolute' }}
>
<defs>
{Object.entries(props.sources).map(([key, source]) => (
<g
key={key}
id={getIconSpriteId(key)}
dangerouslySetInnerHTML={{ __html: source.markup }}
/>
))}
</defs>
</svg>
);
return <IconsContext.Provider value={value}>{children}</IconsContext.Provider>;
}
/**
+2 -2
View File
@@ -11,8 +11,8 @@
"version": "3.0.13",
"sideEffects": false,
"dependencies": {
"@scalar/json-magic": "^0.17.0",
"@scalar/openapi-parser": "^0.29.19",
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/openapi-types": "^0.5.3"
},
"devDependencies": {
+1
View File
@@ -1,6 +1,7 @@
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';
-65
View File
@@ -69,69 +69,4 @@ 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' },
},
},
},
},
};
}
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Plugin } from '@scalar/json-magic/bundle';
import type { ErrorObject } from '@scalar/openapi-parser';
import type { AnyObject, ErrorObject } from '@scalar/openapi-parser';
import { OpenAPIParseError } from './error';
import type { AnyObject, Filesystem, OpenAPIV3xDocument } from './types';
import type { Filesystem, OpenAPIV3xDocument } from './types';
import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
import { parseOpenAPIV3 } from './v3';
+1 -1
View File
@@ -30,7 +30,7 @@ describe('#traverse', () => {
return node;
});
expect(specification.info).toHaveProperty('description', 'Hello, world!');
expect(specification.info?.description).toBe('Hello, world!');
});
it('traverses a complete filesystem', async () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AnyObject } from './types';
import type { AnyObject } from '@scalar/openapi-parser';
/**
* Recursively traverses the specification and applies the transform function to each node.
+1 -2
View File
@@ -1,7 +1,6 @@
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.
*/
+1 -1
View File
@@ -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 });
}
/**
+2 -6
View File
@@ -20,12 +20,8 @@ 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 (!version) {
if (!result.version) {
throw new OpenAPIParseError(
'Can’t find supported Swagger/OpenAPI version in the provided document, version must be a string.',
{
@@ -37,7 +33,7 @@ export async function parseOpenAPIV3(input: ParseOpenAPIInput): Promise<ParseOpe
}
// If the version is 2.0, we throw an error to trigger the upgrade.
if (version === '2.0') {
if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', {
code: 'parse-v2-in-v3',
rootURL,
@@ -196,10 +196,6 @@ 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"