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