Compare commits

...

17 Commits

Author SHA1 Message Date
taranvohra 3b1d083554 changeset 2026-11-01 21:17:45 +05:30
taranvohra fd6a22a0b3 Pack expandable styles into CSS classes
An expandable carried around 750 characters of classes across its
details, summary, chevron and anchor, repeated for every one on a page.
They now live in `Expandable.css`. `group/expandable` stays a literal
class, since the children's hover and open styles match it by name.
2026-11-01 21:17:28 +05:30
taranvohra 5fb928a149 Define a page's repeated icons once in an SVG sprite
Every icon inlined its full SVG path, so a page repeated the same markup
at each heading anchor, expandable chevron and embed placeholder: on a
page with 700 headings the anchor icon alone came to almost half a
megabyte. The page's icon provider now draws its sources once in a hidden
sprite, and icons reference them with `<use>`. Those sources are the
document's own icons plus the ones its blocks repeat once per instance.

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