Compare commits

..

14 Commits

Author SHA1 Message Date
Samy Pessé 13514bf6fa Version Packages (#2907)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-04 13:42:32 +01:00
Samy Pessé 05ffd0e321 Proper data cache tagging for computed content (#2915) 2025-03-04 12:26:09 +01:00
Samy Pessé 701eaad92a Fix use of server actions with proxied domains by using middleware (#2913) 2025-03-04 10:00:08 +01:00
Greg Bergé c66ef5dd10 Fix OpenAPI tabs selection (#2914) 2025-03-03 21:10:24 +00:00
Greg Bergé f774f9b3a0 Fix all tabs behaviour in OpenAPI blocks (#2912) 2025-03-03 17:11:54 +00:00
Samy Pessé 29dc7ccb1a Fix next.js configuration for serverActions 2025-03-03 17:05:46 +01:00
Samy Pessé 80237c34f9 Allow all origins (including sub-domains) for server actions 2025-03-03 16:54:16 +01:00
Samy Pessé 979233c5c0 Allow all origins for serverActions in v2 2025-03-03 16:21:05 +01:00
Samy Pessé 2f03e6acee Define GITBOOK_URL as serverActions.allowedOrigins (#2911) 2025-03-03 15:24:31 +01:00
Greg Bergé c492f09a19 Fix outline not visible on OpenAPI blocks (#2910) 2025-03-03 14:56:19 +01:00
Greg Bergé 2c3af5e73c Improve scrolling with lot of highlighted blocks (#2909) 2025-03-03 13:47:30 +00:00
Samy Pessé 6597f8508d V2: search and ask AI (#2889) 2025-03-03 11:53:32 +01:00
Nolann B. 8beb5d6bcf Add input elements to ContentKit (#2882) 2025-02-28 17:50:20 +00:00
Greg Bergé 53f5dbea26 Fix typing of shouldIgnoreEntity (#2906) 2025-02-28 17:38:58 +00:00
68 changed files with 1428 additions and 786 deletions
+60 -15
View File
@@ -22,6 +22,17 @@
"wrangler": "^3.109.2",
},
},
"packages/cache-tags": {
"name": "@gitbook/cache-tags",
"version": "0.0.0",
"dependencies": {
"@gitbook/api": "0.96.1",
"assert-never": "^1.2.1",
},
"devDependencies": {
"typescript": "^5.5.3",
},
},
"packages/colors": {
"name": "@gitbook/colors",
"version": "0.2.0",
@@ -38,10 +49,11 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.6.3",
"version": "0.6.4",
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -57,6 +69,7 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1",
"ai": "^4.1.46",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
@@ -118,11 +131,12 @@
},
"packages/gitbook-v2": {
"name": "gitbook-v2",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"next": "canary",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"server-only": "^0.0.1",
@@ -156,7 +170,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "2.0.0",
"version": "2.0.1",
"dependencies": {
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
@@ -181,6 +195,7 @@
"version": "0.6.0",
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/icons": "workspace:*",
"assert-never": "^1.2.1",
"classnames": "^2.5.1",
},
@@ -210,7 +225,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.0.3",
"version": "1.0.4",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "1.0.87",
@@ -242,6 +257,14 @@
"react-dom": "18.3.1",
},
"packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@1.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-jie6ZJT2ZR0uVOVCDc9R2xCX5I/Dum/wEK28lx21PJx6ZnFAN9EzD2WsPhcDWfCgGx3OAZZ0GyM3CEobXpa9LA=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.1.10", "", { "dependencies": { "@ai-sdk/provider": "1.0.9", "eventsource-parser": "^3.0.0", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-4GZ8GHjOFxePFzkl3q42AU0DQOtTQ5w09vmaWUf/pKFXJPizlnzKSUkF0f+VkapIUfDugyMqPMT1ge8XQzVI7Q=="],
"@ai-sdk/react": ["@ai-sdk/react@1.1.19", "", { "dependencies": { "@ai-sdk/provider-utils": "2.1.10", "@ai-sdk/ui-utils": "1.1.16", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.0.0" }, "optionalPeers": ["react", "zod"] }, "sha512-zqSOWmJxpB45ZrwZ04+Q7Uo3xeGM0tva2eUYz2T4gv9Yk6MQAfOBA6+scsKg8CyIuUy4M4/C4pCY3eWQf7sfQg=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.1.16", "", { "dependencies": { "@ai-sdk/provider": "1.0.9", "@ai-sdk/provider-utils": "2.1.10", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-jfblR2yZVISmNK2zyNzJZFtkgX57WDAUQXcmn3XUBJyo8LFsADu+/vYMn5AOyBi9qJT0RBk11PEtIxIqvByw3Q=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -608,6 +631,8 @@
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
"@gitbook/colors": ["@gitbook/colors@workspace:packages/colors"],
"@gitbook/emoji-codepoints": ["@gitbook/emoji-codepoints@workspace:packages/emoji-codepoints"],
@@ -1350,6 +1375,8 @@
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
@@ -1530,6 +1557,8 @@
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"ai": ["ai@4.1.47", "", { "dependencies": { "@ai-sdk/provider": "1.0.9", "@ai-sdk/provider-utils": "2.1.10", "@ai-sdk/react": "1.1.19", "@ai-sdk/ui-utils": "1.1.16", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.0.0" }, "optionalPeers": ["react", "zod"] }, "sha512-9UZ8Mkv1HlprCJfQ0Kq+rgKbfkrkDtJjslr1WOHBRzvC70jDJJYp8r0Qq4vlF7zs9VBkGyy8Rhm5zQJJIUjvgw=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -1772,6 +1801,8 @@
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -2194,6 +2225,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"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=="],
@@ -2204,6 +2237,8 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
@@ -2746,6 +2781,8 @@
"schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"semver-diff": ["semver-diff@3.1.1", "", { "dependencies": { "semver": "^6.3.0" } }, "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg=="],
@@ -2856,6 +2893,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swr": ["swr@2.3.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA=="],
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
"tailwind-merge": ["tailwind-merge@2.5.5", "", {}, "sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA=="],
@@ -2880,6 +2919,8 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"time-span": ["time-span@4.0.0", "", { "dependencies": { "convert-hrtime": "^3.0.0" } }, "sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g=="],
"timers-ext": ["timers-ext@0.1.8", "", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="],
@@ -3098,10 +3139,14 @@
"zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
"zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@argos-ci/core/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -4238,7 +4283,7 @@
"gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"gitbook-v2/next": ["next@15.2.1-canary.2", "", { "dependencies": { "@next/env": "15.2.1-canary.2", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.2.1-canary.2", "@next/swc-darwin-x64": "15.2.1-canary.2", "@next/swc-linux-arm64-gnu": "15.2.1-canary.2", "@next/swc-linux-arm64-musl": "15.2.1-canary.2", "@next/swc-linux-x64-gnu": "15.2.1-canary.2", "@next/swc-linux-x64-musl": "15.2.1-canary.2", "@next/swc-win32-arm64-msvc": "15.2.1-canary.2", "@next/swc-win32-x64-msvc": "15.2.1-canary.2", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Vrl8pSN3I7fuUWYWSqbzRsItKYm0X0OXPwm6/DuX7Pgi6uyYT6uwpvEGiPHSGV5Up1MOOtlsCjIbEQlNJ3t1pg=="],
"gitbook-v2/next": ["next@15.2.0", "", { "dependencies": { "@next/env": "15.2.0", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.2.0", "@next/swc-darwin-x64": "15.2.0", "@next/swc-linux-arm64-gnu": "15.2.0", "@next/swc-linux-arm64-musl": "15.2.0", "@next/swc-linux-x64-gnu": "15.2.0", "@next/swc-linux-x64-musl": "15.2.0", "@next/swc-win32-arm64-msvc": "15.2.0", "@next/swc-win32-x64-msvc": "15.2.0", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaiM7sZYX8KIAHBrRGSFytKknkrexNfGb8GlG6e93JqueCspuGte8i4ybn8z4ww1x3f2uzY4YpTaBEW4/hvsoQ=="],
"google-auth-library/jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="],
@@ -5138,23 +5183,23 @@
"gaxios/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"gitbook-v2/next/@next/env": ["@next/env@15.2.1-canary.2", "", {}, "sha512-8bs9W8rN9ln19PA3IdglEkuRxNI+VAjY/Gxkkp24qpSO19uKHbORFz3I5DGyRzHzTRDpCRGHQDKNbcy2b0buOQ=="],
"gitbook-v2/next/@next/env": ["@next/env@15.2.0", "", {}, "sha512-eMgJu1RBXxxqqnuRJQh5RozhskoNUDHBFybvi+Z+yK9qzKeG7dadhv/Vp1YooSZmCnegf7JxWuapV77necLZNA=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.2.1-canary.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PvoxT5tucaeREa7uhTxEaSW9sL4Sr8WfluVfSS+bj2axZpQ567gB/I+Eias67FPRW80cZaPMGsorNjyaq+cJuA=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rlp22GZwNJjFCyL7h5wz9vtpBVuCt3ZYjFWpEPBGzG712/uL1bbSkS675rVAUCRZ4hjoTJ26Q7IKhr5DfJrHDA=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.2.1-canary.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPg9F3bT5MMalZpLfd+paiWWaWLvUaWAFZfIGEBgozYgIBd66pROkb+dXsJVd73IlYoBDh3mYcK7me2uYImBOA=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-DiU85EqSHogCz80+sgsx90/ecygfCSGl5P3b4XDRVZpgujBm5lp4ts7YaHru7eVTyZMjHInzKr+w0/7+qDrvMA=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.2.1-canary.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-LmnZN/psunvUXofINdGENFNDv7yRIET2szi2FREMr1epKL3NsAQD0k4hv3pwRLwkVb8z6Rj+TXzX0pFChHOlhg=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VnpoMaGukiNWVxeqKHwi8MN47yKGyki5q+7ql/7p/3ifuU2341i/gDwGK1rivk0pVYbdv5D8z63uu9yMw0QhpQ=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.2.1-canary.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Ma1ATOCdgei1LRAbziBYciunyuG0uUASoiz7DDw40Ox5YuDD4Tznbg3re5ltWRwMAl9NNQa8ns3u+Q3Zs1NsQ=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ka97/ssYE5nPH4Qs+8bd8RlYeNeUVBhcnsNUmFM6VWEob4jfN9FTr0NBhXVi1XEJpj3cMfgSRW+LdE3SUZbPrw=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.2.1-canary.2", "", { "os": "linux", "cpu": "x64" }, "sha512-R8bbqvKkVtzjN/KWogQQvWXgBTyWXcOEVgiPMXKgx7/zfybWeL+WjSXeepw4xYyDlQ1vC//HwEqLfFX41Jht4Q=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zY1JduE4B3q0k2ZCE+DAF/1efjTXUsKP+VXRtrt/rJCTgDlUyyryx7aOgYXNc1d8gobys/Lof9P9ze8IyRDn7Q=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.2.1-canary.2", "", { "os": "linux", "cpu": "x64" }, "sha512-wWr41f7g3H5FcLguoAS7bvqSqaVkhK97/fk7ANG1l5yW13F9qVPboVZvgVFrRsPFZgqANxs4AmhKAjjjT+mL1g=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QqvLZpurBD46RhaVaVBepkVQzh8xtlUN00RlG4Iq1sBheNugamUNPuZEH1r9X1YGQo1KqAe1iiShF0acva3jHQ=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.2.1-canary.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-ogzvioGSaJ9F2VWZXiLQR4K4G6fbBUdlZfKowOJyFKfBhPK+WuwJlEnzWfPAe5YjtiQU2H+eMdZdC25DXDXo6g=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ODZ0r9WMyylTHAN6pLtvUtQlGXBL9voljv6ujSlcsjOxhtXPI1Ag6AhZK0SE8hEpR1374WZZ5w33ChpJd5fsjw=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.2.1-canary.2", "", { "os": "win32", "cpu": "x64" }, "sha512-6sss7xGWck4DrJC7h/Gc2LFN3xh2dxnXxYmkxOD9gOq/yFdX7NSrNdCfNvXg5+6EFcTPURRg9gS9VgkzOCxp8Q=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-8+4Z3Z7xa13NdUuUAcpVNA6o76lNPniBd9Xbo02bwXQXnZgFvEopwY2at5+z7yHl47X9qbZpvwatZ2BRo3EdZw=="],
"gitbook-v2/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+1
View File
@@ -0,0 +1 @@
dist/
+7
View File
@@ -0,0 +1,7 @@
# @gitbook/cache-tags
## 0.1.0
### Minor Changes
- 05ffd0e: Initial version of the package
+3
View File
@@ -0,0 +1,3 @@
# `@gitbook/cache-tags`
Utility to generate cache tags for GitBook Open.
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@gitbook/cache-tags",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "0.96.1",
"assert-never": "^1.2.1"
},
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
+175
View File
@@ -0,0 +1,175 @@
import type { ComputedContentSource } from '@gitbook/api';
import assertNever from 'assert-never';
/**
* Get a stringified cache tag for a given object.
*/
export function getCacheTag(
spec: /**
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'user';
user: string;
}
/**
* All data related to a space
*/
| {
tag: 'space';
space: string;
}
/**
* All data related to an integration.
*/
| {
tag: 'integration';
integration: string;
}
/**
* All data related to a change request
*/
| {
tag: 'change-request';
space: string;
changeRequest: string;
}
/**
* Immutable data related to a revision
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'revision';
space: string;
revision: string;
}
/**
* Immutable data related to a document
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'document';
space: string;
document: string;
}
/**
* Immutable data related to a computed document
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'computed-document';
space: string;
integration: string;
}
/**
* All data related to the URL of a content
*/
| {
tag: 'url';
hostname: string;
}
/**
* All data related to a site
*/
| {
tag: 'site';
site: string;
}
/**
* All data related to an OpenAPI spec
*/
| {
tag: 'openapi';
organization: string;
openAPISpec: string;
}
): string {
switch (spec.tag) {
case 'user':
return `user:${spec.user}`;
case 'url':
return `url:${spec.hostname}`;
case 'space':
return `space:${spec.space}`;
case 'change-request':
return `space:${spec.space}:change-request:${spec.changeRequest}`;
case 'revision':
return `space:${spec.space}:revision:${spec.revision}`;
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.integration}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
default:
assertNever(spec);
}
}
/**
* Get the tags for a computed content source.
*/
export function getComputedContentSourceCacheTags(
inContext: {
spaceId: string;
organizationId: string;
},
source: ComputedContentSource
) {
const tags: string[] = [];
// We add the dependencies as tags, to ensure that the computed content is invalidated
// when the dependencies are updated.
const dependencies = Object.values(source.dependencies ?? {});
if (dependencies.length > 0) {
dependencies.forEach((dependency) => {
switch (dependency.ref.kind) {
case 'space':
tags.push(
getCacheTag({
tag: 'space',
space: dependency.ref.space,
})
);
break;
case 'openapi':
tags.push(
getCacheTag({
tag: 'openapi',
organization: inContext.organizationId,
openAPISpec: dependency.ref.spec,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
break;
}
});
} else {
// Push a dummy tag, as the v1 is only using the first tag
tags.push(
getCacheTag({
tag: 'computed-document',
space: inContext.spaceId,
integration: source.integration,
})
);
}
// We invalidate the computed content when a new version of the integration is deployed.
tags.push(
getCacheTag({
tag: 'integration',
integration: source.integration,
})
);
return tags;
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+8
View File
@@ -1,5 +1,13 @@
# gitbook-v2
## 0.1.2
### Patch Changes
- 05ffd0e: Improving data cache management for computed content
- Updated dependencies [05ffd0e]
- @gitbook/cache-tags@0.1.0
## 0.1.1
### Patch Changes
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "gitbook-v2",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"dependencies": {
"next": "canary",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "0.96.1",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1"
},
@@ -6,7 +6,7 @@ import {
} from '@/components/SiteLayout';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -18,6 +18,7 @@ export default async function SiteDynamicLayout({
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const context = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
@@ -25,6 +26,7 @@ export default async function SiteDynamicLayout({
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={visitorAuthToken}
>
{children}
</SiteLayout>
@@ -3,8 +3,8 @@ import {
generateSitePageMetadata,
generateSitePageViewport,
} from '@/components/SitePage';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import { getSiteCacheTag } from '@v2/lib/cache';
import type { Metadata, Viewport } from 'next';
import { unstable_cacheTag as cacheTag } from 'next/cache';
@@ -21,7 +21,12 @@ export default async function Page(props: PageProps) {
const context = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(getSiteCacheTag(context.site.id));
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
@@ -4,8 +4,8 @@ import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
import { getSiteCacheTag } from '@v2/lib/cache';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { unstable_cacheTag as cacheTag } from 'next/cache';
@@ -21,11 +21,20 @@ export default async function SiteStaticLayout({
const context = await getStaticSiteContext(await params);
cacheTag(getSiteCacheTag(context.site.id));
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout context={context} withTracking={!GITBOOK_DISABLE_TRACKING}>
<SiteLayout
context={context}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={null}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
+22 -85
View File
@@ -1,9 +1,9 @@
import { fetchSiteContextByURL } from '@v2/lib/context';
import { createDataFetcher } from '@v2/lib/data';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from '@v2/lib/env';
import { createImageResizer } from '@v2/lib/images';
import { createLinker } from '@v2/lib/links';
import { headers } from 'next/headers';
import {
fetchSiteContextByURL,
fetchSiteContextByURLLookup,
getBaseContext,
} from '@v2/lib/context';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
export type RouteParamMode = 'url-host' | 'url';
@@ -21,29 +21,19 @@ export type RouteParams = RouteLayoutParams & {
/**
* Get the static context when rendering statically a site.
*/
export async function getStaticSiteContext(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const dataFetcher = createDataFetcher();
const { linker, host } = createLinkerFromParams(params);
const context = await fetchSiteContextByURL(
export function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
return fetchSiteContextByURL(
getBaseContext({
siteURL,
urlMode: getModeFromParams(params.mode),
}),
{
dataFetcher,
linker,
},
{
url: url.toString(),
url: siteURL.toString(),
visitorAuthToken: null,
redirectOnError: false,
}
);
context.imageResizer = createImageResizer({
host,
linker: context.linker,
});
return context;
}
/**
@@ -51,36 +41,16 @@ export async function getStaticSiteContext(params: RouteLayoutParams) {
* The context will depend on the request.
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const headersSet = await headers();
const siteURL = getSiteURLFromParams(params);
const siteURLData = await getSiteURLDataFromMiddleware();
const dataFetcher = createDataFetcher({
apiToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
apiEndpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
});
const { linker, host } = createLinkerFromParams(params);
const context = await fetchSiteContextByURL(
{
dataFetcher,
linker,
},
{
url: url.toString(),
visitorAuthToken: headersSet.get('x-gitbook-visitor-token'),
// TODO: set it only when the token comes from the cookies.
redirectOnError: true,
}
return fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
context.imageResizer = createImageResizer({
host,
linker: context.linker,
});
return context;
}
/**
@@ -91,39 +61,6 @@ export function getPagePathFromParams(params: RouteParams) {
return decoded;
}
function createLinkerFromParams(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const mode = getModeFromParams(params.mode);
if (mode === 'url-host') {
return {
linker: createLinker({
host: url.host,
pathname: '/',
}),
host: url.host,
};
}
const gitbookURL = new URL(GITBOOK_URL);
const linker = createLinker({
protocol: gitbookURL.protocol,
host: gitbookURL.host,
pathname: `/url/${url.host}`,
});
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}`;
};
return {
linker,
host: gitbookURL.host,
};
}
function getSiteURLFromParams(params: RouteLayoutParams) {
const decoded = decodeURIComponent(params.siteURL);
const url = new URL(`https://${decoded}`);
-34
View File
@@ -1,34 +0,0 @@
/**
* Get the cache tag for a site.
*/
export function getSiteCacheTag(siteId: string) {
return `site:${siteId}`;
}
/**
* Get the cache tag for a hostname.
*/
export function getHostnameCacheTag(hostname: string) {
return `url:${hostname}`;
}
/**
* Get the cache tag for an OpenAPI spec.
*/
export function getOpenAPISpecCacheTag(organizationId: string, slug: string) {
return `organization:${organizationId}:openapi:${slug}`;
}
/**
* Get the cache tag for a space.
*/
export function getSpaceCacheTag(spaceId: string) {
return `space:${spaceId}`;
}
/**
* Get the cache tag for a change request.
*/
export function getChangeRequestCacheTag(spaceId: string, changeRequestId: string) {
return `space:${spaceId}:change-request:${changeRequestId}`;
}
+69 -18
View File
@@ -1,6 +1,7 @@
import { getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
PublishedSiteContentLookup,
RevisionPage,
RevisionPageDocument,
Site,
@@ -15,8 +16,9 @@ import type {
import { type GitBookDataFetcher, createDataFetcher } from '@v2/lib/data';
import { redirect } from 'next/navigation';
import { assert } from 'ts-essentials';
import type { ImageResizer } from './images';
import { type GitBookSpaceLinker, appendBasePathToLinker } from './links';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookSpaceLinker, createLinker } from './links';
/**
* Generic context when rendering content.
@@ -90,9 +92,6 @@ export type GitBookSiteContext = GitBookSpaceContext & {
/** Scripts to load for the site. */
scripts: SiteIntegrationScript[];
/** Visitor token used to fetch the site */
visitorAuthToken: string | null;
};
/**
@@ -102,6 +101,58 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
page: RevisionPageDocument;
};
/**
* Get the base context for a request.
*/
export function getBaseContext(input: {
siteURL: URL | string;
urlMode: 'url' | 'url-host';
apiToken?: string | null;
}) {
const url = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const urlMode = input.urlMode;
const dataFetcher = createDataFetcher({
apiToken: input.apiToken ?? GITBOOK_API_TOKEN,
apiEndpoint: GITBOOK_API_URL,
});
const gitbookURL = new URL(GITBOOK_URL);
const linker =
urlMode === 'url-host'
? createLinker({
host: url.host,
pathname: url.pathname,
})
: createLinker({
protocol: gitbookURL.protocol,
host: gitbookURL.host,
pathname: `/url/${url.host}${url.pathname}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}`;
};
}
const imageResizer = createImageResizer({
host: urlMode === 'url-host' ? url.host : gitbookURL.host,
// To ensure image resizing work for proxied sites,
// we serve images from the root of the site.
linker: linker,
});
return {
dataFetcher,
linker,
imageResizer,
};
}
/**
* Fetch the context of a site for a given URL and a base context.
*/
@@ -120,15 +171,25 @@ export async function fetchSiteContextByURL(
redirectOnError: input.redirectOnError,
});
return fetchSiteContextByURLLookup(baseContext, data);
}
/**
* Fetch the context of a site using the resolution of a URL
*/
export async function fetchSiteContextByURLLookup(
baseContext: GitBookBaseContext,
data: PublishedSiteContentLookup
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
if ('redirect' in data) {
redirect(data.redirect);
}
const context = await fetchSiteContextByIds(
return await fetchSiteContextByIds(
{
...baseContext,
dataFetcher: createDataFetcher({
apiEndpoint: dataFetcher.apiEndpoint,
dataFetcher: dataFetcher.withToken({
apiToken: data.apiToken,
}),
},
@@ -141,16 +202,8 @@ export async function fetchSiteContextByURL(
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
visitorAuthToken: input.visitorAuthToken,
}
);
const siteContext = {
...context,
linker: appendBasePathToLinker(context.linker, data.basePath),
};
return siteContext;
}
/**
@@ -167,7 +220,6 @@ export async function fetchSiteContextByIds(
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
visitorAuthToken: string | null;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
@@ -233,7 +285,6 @@ export async function fetchSiteContextByIds(
structure: siteStructure,
sections,
scripts,
visitorAuthToken: ids.visitorAuthToken,
};
}
+88 -15
View File
@@ -1,13 +1,7 @@
import { type ComputedContentSource, GitBookAPI } from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import {
getChangeRequestCacheTag,
getHostnameCacheTag,
getOpenAPISpecCacheTag,
getSiteCacheTag,
getSpaceCacheTag,
} from '../cache';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
@@ -35,6 +29,17 @@ export function createDataFetcher(input: DataFetcherInput = commonInput): GitBoo
return {
apiEndpoint: input.apiEndpoint,
async api() {
return getAPI(input);
},
withToken({ apiToken }) {
return createDataFetcher({
...input,
apiToken,
});
},
//
// API that are tied to the token
//
@@ -114,6 +119,7 @@ export function createDataFetcher(input: DataFetcherInput = commonInput): GitBoo
},
getComputedDocument(params) {
return getComputedDocument(input, {
organizationId: params.organizationId,
spaceId: params.spaceId,
source: params.source,
});
@@ -124,6 +130,9 @@ export function createDataFetcher(input: DataFetcherInput = commonInput): GitBoo
spaceId: params.spaceId,
});
},
searchSiteContent(params) {
return searchSiteContent(input, params);
},
//
// API that are not tied to the token
@@ -169,7 +178,12 @@ async function getSpace(
'use cache';
cacheLife('days');
cacheTag(getSpaceCacheTag(params.spaceId));
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
@@ -193,7 +207,13 @@ async function getChangeRequest(
params.spaceId,
params.changeRequestId
);
cacheTag(getChangeRequestCacheTag(params.spaceId, res.data.id));
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
@@ -321,14 +341,24 @@ async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
organizationId: string;
source: ComputedContentSource;
}
) {
'use cache';
// TODO: we need to resolve dependencies and pass them in the cache key
cacheLife('days');
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
source: params.source,
});
@@ -372,7 +402,13 @@ async function getLatestOpenAPISpecVersionContent(
) {
'use cache';
cacheTag(getOpenAPISpecCacheTag(params.organizationId, params.slug));
cacheTag(
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
cacheLife('days');
try {
@@ -403,7 +439,12 @@ async function getPublishedContentByUrl(
const { url, visitorAuthToken, redirectOnError } = params;
const hostname = new URL(url).hostname;
cacheTag(getHostnameCacheTag(hostname));
cacheTag(
getCacheTag({
tag: 'url',
hostname,
})
);
cacheLife('days');
const res = await getAPI(input).urls.getPublishedContentByUrl({
@@ -413,7 +454,12 @@ async function getPublishedContentByUrl(
});
if ('site' in res.data) {
cacheTag(getSiteCacheTag(res.data.site));
cacheTag(
getCacheTag({
tag: 'site',
site: res.data.site,
})
);
}
return res.data;
@@ -429,7 +475,12 @@ async function getPublishedContentSite(
) {
'use cache';
cacheTag(getSiteCacheTag(params.siteId));
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
const res = await getAPI(input).orgs.getPublishedContentSite(
@@ -453,7 +504,12 @@ async function getSiteRedirectBySource(
) {
'use cache';
cacheTag(getSiteCacheTag(params.siteId));
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
try {
@@ -498,6 +554,23 @@ async function getEmbedByUrl(
return res.data;
}
async function searchSiteContent(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) {
'use cache';
const { organizationId, siteId, query, scope } = params;
cacheLife('days');
const res = await getAPI(input).orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
});
return res.data.items;
}
function getAPI(input: DataFetcherInput) {
const { apiEndpoint, apiToken } = input;
const api = new GitBookAPI({
+28
View File
@@ -10,6 +10,18 @@ export interface GitBookDataFetcher {
*/
apiEndpoint: string;
/**
* Get an API client for the current context.
*/
api(): Promise<api.GitBookAPI>;
/**
* Create a data fetcher authenticated with a specific token.
*/
withToken(input: {
apiToken: string;
}): GitBookDataFetcher;
/**
* Get a user by its ID.
*/
@@ -91,6 +103,7 @@ export interface GitBookDataFetcher {
* Get a computed document by its space ID and computed source.
*/
getComputedDocument(params: {
organizationId: string;
spaceId: string;
source: api.ComputedContentSource;
}): Promise<api.JSONDocument>;
@@ -126,4 +139,19 @@ export interface GitBookDataFetcher {
* Get an embed by its URL.
*/
getEmbedByUrl(params: { url: string; spaceId: string }): Promise<api.Embed>;
/**
* Search content in a site.
*/
searchSiteContent(params: {
organizationId: string;
siteId: string;
query: string;
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
/** Cache bust to ensure the search results are fresh when the space is updated. */
cacheBust?: string;
}): Promise<api.SearchSpaceResult[]>;
}
+8 -4
View File
@@ -1,4 +1,4 @@
import type { RevisionPageDocument } from '@gitbook/api';
import type { RevisionPageDocument, Space } from '@gitbook/api';
import type { GitBookDataFetcher } from './types';
/**
@@ -6,14 +6,18 @@ import type { GitBookDataFetcher } from './types';
*/
export async function getPageDocument(
dataFetcher: GitBookDataFetcher,
spaceId: string,
space: Space,
page: RevisionPageDocument
) {
if (page.documentId) {
return dataFetcher.getDocument({ spaceId, documentId: page.documentId });
return dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId });
}
if (page.computed) {
return dataFetcher.getComputedDocument({ spaceId, source: page.computed });
return dataFetcher.getComputedDocument({
organizationId: space.organization,
spaceId: space.id,
source: page.computed,
});
}
return null;
+84 -1
View File
@@ -1,14 +1,51 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
import { headers } from 'next/headers';
export enum MiddlewareHeaders {
/**
* Type of the route (static or dynamic)
*/
RouteType = 'x-gitbook-route-type',
/**
* The URL of the site (without the pathname)
*/
SiteURL = 'x-gitbook-site-url',
/**
* The data associated with the URL.
*/
SiteURLData = 'x-gitbook-site-url-data',
/**
* The mode of the URL (url or url-host)
*/
URLMode = 'x-gitbook-url-mode',
/**
* The theme of the page (light or dark)
*/
Theme = 'x-gitbook-theme',
/**
* The customization override to apply.
*/
Customization = 'x-gitbook-customization',
/**
* Token to use for the API.
*/
APIToken = 'x-gitbook-token',
/**
* The visitor token used to access this content
*/
VisitorAuthToken = 'x-gitbook-visitor-token',
}
/**
* Get the URL mode from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
const headersList = await headers();
@@ -20,8 +57,40 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
return mode as 'url' | 'url-host';
}
/**
* Get the site URL data from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
const headersList = await headers();
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
if (!siteURLData) {
throw new Error(
'Site URL data is not set by the middleware. This should only be called in a server action or a dynamic route.'
);
}
return JSON.parse(siteURLData);
}
/**
* Get the URL from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getSiteURLFromMiddleware(): Promise<string> {
const headersList = await headers();
const siteURL = headersList.get(MiddlewareHeaders.SiteURL);
if (!siteURL) {
throw new Error('URL mode is not set by the middleware');
}
return siteURL;
}
/**
* For preview, the theme can be set via query string (?theme=light).
* This function should only be called in a dynamic route.
*/
export async function getThemeFromMiddleware() {
const headersList = await headers();
@@ -34,3 +103,17 @@ export async function getThemeFromMiddleware() {
? CustomizationThemeMode.Light
: CustomizationThemeMode.Dark;
}
/**
* Get the visitor auth token from the middleware headers.
* This function should only be called in a dynamic route.
*/
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
const headersList = await headers();
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
if (!visitorAuthToken) {
return null;
}
return visitorAuthToken;
}
@@ -0,0 +1,31 @@
import { type GitBookBaseContext, fetchSiteContextByURLLookup, getBaseContext } from './context';
import {
getSiteURLDataFromMiddleware,
getSiteURLFromMiddleware,
getURLModeFromMiddleware,
} from './middleware';
/**
* Get the base context for a server action.
* This function should only be called in a server action.
*/
export async function getServerActionBaseContext() {
const siteURL = await getSiteURLFromMiddleware();
const siteURLData = await getSiteURLDataFromMiddleware();
const urlMode = await getURLModeFromMiddleware();
return getBaseContext({
siteURL,
urlMode,
apiToken: siteURLData.apiToken,
});
}
/**
* Fetch the context for a site in a server action.
* The server action is always dynamic and the request is passed through the middleware.
*/
export async function fetchServerActionSiteContext(baseContext: GitBookBaseContext) {
const siteURLData = await getSiteURLDataFromMiddleware();
return fetchSiteContextByURLLookup(baseContext, siteURLData);
}
+16 -4
View File
@@ -64,26 +64,38 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
return NextResponse.redirect(data.redirect);
}
const routeType = dynamicHeaders ? 'dynamic' : 'static';
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(MiddlewareHeaders.SiteURL, `${url.origin}${data.basePath}`);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
if (dynamicHeaders) {
for (const [key, value] of Object.entries(dynamicHeaders)) {
requestHeaders.set(key, value);
}
}
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const route = [
'sites',
dynamicHeaders ? 'dynamic' : 'static',
routeType,
mode,
encodeURIComponent(url.host + data.basePath),
encodePathInSiteContent(data.pathname),
].join('/');
console.log('route', route);
console.log(`rewriting to ${route}`);
const response = NextResponse.rewrite(new URL(`/${route}`, request.url), {
headers: requestHeaders,
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
const response = NextResponse.rewrite(rewrittenURL, {
request: {
headers: requestHeaders,
},
});
// Add Content Security Policy header
+14
View File
@@ -1,5 +1,19 @@
# gitbook
## 0.6.5
### Patch Changes
- 05ffd0e: Improving data cache management for computed content
- 8beb5d6: Add input elements to ContentKit
- Updated dependencies [53f5dbe]
- Updated dependencies [05ffd0e]
- Updated dependencies [8beb5d6]
- @gitbook/openapi-parser@2.0.2
- @gitbook/cache-tags@0.1.0
- @gitbook/react-contentkit@0.6.1
- @gitbook/react-openapi@1.0.5
## 0.6.4
### Patch Changes
-5
View File
@@ -225,11 +225,6 @@ const testCases: TestsCase[] = [
baseUrl: 'https://docs.raydium.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.solv.finance',
baseUrl: 'https://docs.solv.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.fluentbit.io',
baseUrl: 'https://docs.fluentbit.io',
+15
View File
@@ -155,6 +155,21 @@ export function runTestCases(testCases: TestsCase[]) {
);
}
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -18,6 +18,7 @@
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -62,7 +63,8 @@
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
"usehooks-ts": "^3.1.0",
"ai": "^4.1.46"
},
"devDependencies": {
"@argos-ci/playwright": "^3.10.0",
@@ -1,4 +1,4 @@
import { getThemeFromMiddleware } from '@v2/lib/middleware';
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
import type { Metadata, Viewport } from 'next';
import type React from 'react';
@@ -22,12 +22,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
const context = await fetchLayoutData();
const queryStringTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
return (
<SiteLayout
context={context}
forcedTheme={queryStringTheme}
withTracking={await shouldTrackEvents()}
visitorAuthToken={visitorAuthToken}
>
{children}
</SiteLayout>
@@ -231,7 +231,7 @@ async function PDFPageDocument(props: {
}) {
const { page, context } = props;
const { space } = context;
const document = await getPageDocument(context.dataFetcher, space.id, page);
const document = await getPageDocument(context.dataFetcher, space, page);
return (
<PrintPage id={getPagePDFContainerId(page)}>
@@ -5,6 +5,8 @@ import { headers } from 'next/headers';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering';
import { AdPixels } from './AdPixels';
@@ -41,7 +43,7 @@ interface FetchPlaceholderAdOptions {
* and properly access user-agent and IP.
*/
export async function renderAd(options: FetchAdOptions) {
const baseContext = await getV1BaseContext();
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
@@ -64,9 +66,9 @@ export async function renderAd(options: FetchAdOptions) {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={baseContext} />
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={baseContext} />
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
@@ -3,8 +3,9 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight';
@@ -21,6 +22,8 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines } = props;
const blockRef = useRef<HTMLDivElement>(null);
const processedRef = useRef(false);
const isInViewportRef = useRef<boolean | null>(null);
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block, []));
// Preload the highlighter when the block is mounted.
@@ -28,39 +31,44 @@ export function ClientCodeBlock(props: ClientBlockProps) {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
// Check if the block is in the viewport to start highlighting it.
const hasBeenInViewport = useHasBeenInViewport(blockRef, {
rootMargin: '200px',
});
// Highlight the block when it's in the viewport.
useEffect(() => {
if (hasBeenInViewport) {
let canceled = false;
import('./highlight').then(({ highlight }) => {
// We use requestIdleCallback to avoid blocking the main thread
// when scrolling.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() =>
highlight(block, inlines).then((result) => {
if (!canceled) {
setLines(result);
}
})
);
} else {
highlight(block, inlines).then((result) => {
if (!canceled) {
setLines(result);
}
});
}
});
return () => {
canceled = true;
};
const runHighlight = useEventCallback(() => {
if (processedRef.current) {
return;
}
}, [hasBeenInViewport, block, inlines]);
if (typeof window !== 'undefined') {
import('./highlight').then(({ highlight }) => {
highlight(block, inlines).then((lines) => {
setLines(lines);
processedRef.current = true;
});
});
}
});
const debouncedRunHighlight = useDebounceCallback(runHighlight, 1000);
useInViewportListener(
blockRef,
(isInViewport, disconnect) => {
// Disconnect once in viewport
if (isInViewport) {
disconnect();
// If it's initially in viewport, we need to run the highlight
if (isInViewportRef.current === null) {
runHighlight();
}
}
isInViewportRef.current = isInViewport;
},
{ rootMargin: '200px' }
);
const handleScroll = useDebounceCallback(() => {
if (isInViewportRef.current) {
debouncedRunHighlight();
}
}, 80);
useScrollListener(handleScroll, useRef(typeof window !== 'undefined' ? window : null));
return <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines} />;
}
@@ -3,8 +3,13 @@
@apply flex gap-4;
}
/** Add margin bottom to all stacks that are not the last child and have a form */
.contentkit-stack:not(:last-child):has(.contentkit-form) {
@apply mb-4;
}
.contentkit-hstack {
@apply flex-row gap-4 items-center;
@apply flex-row gap-3 items-center;
}
.contentkit-hstack.contentkit-stack-align-center {
@apply justify-center;
@@ -88,17 +93,23 @@
/** Buttons */
.contentkit-button {
@apply flex gap-2 items-center justify-center px-2 py-1 rounded
border border-tint hover:border-tint-hover
text-tint hover:text-tint-strong;
@apply text-sm px-3 h-8 text-center inline-block py-1.5 rounded-md straight-corners:rounded-none place-self-start ring-1 ring-tint hover:ring-tint-hover shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12 hover:scale-105 active:scale-100 transition-all grow-0 shrink-0 truncate w-full;
}
.contentkit-button-style-primary {
@apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover ring-0 contrast-more:ring-1;
}
.contentkit-button-style-secondary {
@apply bg-tint text-tint hover:bg-tint-hover hover:text-primary contrast-more:bg-tint-subtle;
}
.contentkit-button .contentkit-icon {
@apply w-4 h-4;
}
.contentkit-button .contentkit-button-label {
@apply text-sm font-medium text-tint-strong;
.contentkit-button-loading {
@apply size-5 flex mx-auto animate-spin;
}
/** Modals */
@@ -143,7 +154,21 @@
/** Text input */
.contentkit-textinput {
@apply w-full px-2 py-1 rounded border border-tint text-sm text-tint;
@apply w-full rounded border border-tint text-tint-strong placeholder:text-tint flex resize-none flex-1 px-2 py-1.5 text-sm bg-transparent whitespace-pre-line;
@apply focus:outline-primary focus:border-primary;
}
/** Form */
.contentkit-form {
@apply flex flex-col gap-1;
}
.contentkit-form:not(:first-of-type) {
@apply mt-4;
}
.contentkit-label {
@apply text-sm font-medium text-tint-strong;
}
/** Dividers */
@@ -92,6 +92,7 @@ const getBlockHeight = memoize((block: DocumentBlock | undefined): number => {
case 'images':
case 'image':
case 'swagger':
case 'openapi-operation':
return 768 / (16 / 9);
case 'file':
case 'expandable':
@@ -1,14 +1,13 @@
'use client';
import { Icon } from '@gitbook/icons';
import { readStreamableValue } from 'ai/rsc';
import React from 'react';
import { Loading } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import { iterateStreamResponse } from '@/lib/actions';
import type { SiteContentPointer } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights';
@@ -32,14 +31,13 @@ export type SearchAskState =
/**
* Fetch and render the answers to a question.
*/
export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: string }) {
const { pointer, query } = props;
export function SearchAskAnswer(props: { query: string }) {
const { query } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
const [, setSearchState] = useSearch();
const [askState, setAskState] = useSearchAskContext();
const { organizationId, siteId, siteSpaceId } = pointer;
React.useEffect(() => {
let cancelled = false;
@@ -52,19 +50,19 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str
query,
});
const response = streamAskQuestion({ pointer, question: query });
const stream = iterateStreamResponse(response);
// When we pass in "ask" mode, the query could still be updated by the client
// we ensure that the query is up-to-date before starting the stream.
setSearchState((prev) => (prev ? { ...prev, query, ask: true } : null));
for await (const chunk of stream) {
const { stream } = await streamAskQuestion({ question: query });
for await (const chunk of readStreamableValue(stream)) {
if (cancelled) {
return;
}
setAskState({ type: 'answer', answer: chunk });
if (chunk) {
setAskState({ type: 'answer', answer: chunk });
}
}
})().catch(() => {
if (cancelled) {
@@ -81,7 +79,7 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str
cancelled = true;
}
};
}, [organizationId, siteId, siteSpaceId, query, setAskState, setSearchState, trackEvent]);
}, [query, setAskState, setSearchState, trackEvent]);
React.useEffect(() => {
return () => {
@@ -7,7 +7,6 @@ import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { tString, useLanguage } from '@/intl/client';
import type { SiteContentPointer } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { LoadingPane } from '../primitives/LoadingPane';
@@ -18,11 +17,9 @@ import { SearchScopeToggle } from './SearchScopeToggle';
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
interface SearchModalProps {
revisionId: string;
spaceTitle: string;
isMultiVariants: boolean;
withAsk: boolean;
pointer: SiteContentPointer;
}
/**
@@ -144,16 +141,7 @@ function SearchModalBody(
onClose: (to?: string) => void;
}
) {
const {
pointer,
revisionId,
spaceTitle,
withAsk,
isMultiVariants,
state,
setSearchState,
onClose,
} = props;
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose } = props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
@@ -299,17 +287,13 @@ function SearchModalBody(
{!state.ask || !withAsk ? (
<SearchResults
ref={resultsRef}
pointer={pointer}
revisionId={revisionId}
global={isMultiVariants && state.global}
query={state.query}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
) : null}
{state.query && state.ask && withAsk ? (
<SearchAskAnswer pointer={pointer} query={state.query} />
) : null}
{state.query && state.ask && withAsk ? <SearchAskAnswer query={state.query} /> : null}
</motion.div>
);
}
@@ -1,12 +1,11 @@
'use client';
import { captureException } from '@sentry/nextjs';
import { readStreamableValue } from 'ai/rsc';
import assertNever from 'assert-never';
import React from 'react';
import { t, useLanguage } from '@/intl/client';
import { iterateStreamResponse } from '@/lib/actions';
import type { SiteContentPointer } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights';
@@ -48,15 +47,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
props: {
children?: React.ReactNode;
query: string;
revisionId: string;
global: boolean;
withAsk: boolean;
pointer: SiteContentPointer;
onSwitchToAsk: () => void;
},
ref: React.Ref<SearchResultsRef>
) {
const { children, query, pointer, revisionId, withAsk, global, onSwitchToAsk } = props;
const { children, query, withAsk, global, onSwitchToAsk } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
@@ -93,10 +90,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
return;
}
const response = streamRecommendedQuestions(pointer.organizationId, pointer.siteId);
const stream = iterateStreamResponse(response);
const response = await streamRecommendedQuestions();
for await (const entry of readStreamableValue(response.stream)) {
if (!entry) {
continue;
}
for await (const { question } of stream) {
const { question } = entry;
if (questions.has(question)) {
continue;
}
@@ -124,8 +124,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
let cancelled = false;
const timeout = setTimeout(async () => {
const results = await (global
? searchAllSiteContent(query, pointer)
: searchSiteSpaceContent(query, pointer, revisionId));
? searchAllSiteContent(query)
: searchSiteSpaceContent(query));
if (cancelled) {
return;
@@ -154,7 +154,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
cancelled = true;
clearTimeout(timeout);
};
}, [query, global, pointer, revisionId, withAsk, trackEvent]);
}, [query, global, withAsk, trackEvent]);
const results: ResultType[] = React.useMemo(() => {
if (!withAsk) {
@@ -3,22 +3,24 @@
import type {
RevisionPage,
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
SearchPageResult,
SiteSpace,
Space,
} from '@gitbook/api';
import type { GitBookSiteContext } from '@v2/lib/context';
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { createStreamableValue } from 'ai/rsc';
import type * as React from 'react';
import { assert } from 'ts-essentials';
import { streamResponse } from '@/lib/actions';
import * as api from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { DocumentView } from '../DocumentView';
export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult;
@@ -54,70 +56,13 @@ export interface AskAnswerResult {
sources: AskAnswerSource[];
}
/**
* Search for content in a site by scoping the search to all content, a specific spaces or current space.
*/
async function searchSiteContent(args: {
pointer: api.SiteContentPointer;
query: string;
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
cacheBust?: string;
}): Promise<OrderedComputedResult[]> {
const { pointer, scope, query, cacheBust } = args;
if (query.length <= 1) {
return [];
}
const needsStructure =
scope.mode === 'all' ||
scope.mode === 'current' ||
(scope.mode === 'specific' && scope.siteSpaceIds.length > 1);
const [searchResults, siteData] = await Promise.all([
api.searchSiteContent(pointer.organizationId, pointer.siteId, query, scope, cacheBust),
needsStructure ? fetchV1ContextForSitePointer(pointer) : null,
]);
const siteStructure = siteData?.structure;
if (siteStructure) {
// We are searching all of this Site's content
return (
await Promise.all(
searchResults.items.map(async (spaceItem) => {
const siteSpace = findSiteSpaceById(siteStructure, spaceItem.id);
return Promise.all(
spaceItem.pages.map((item) =>
transformSitePageResult(item, siteSpace ?? undefined)
)
);
})
)
).flat(2);
}
return (
await Promise.all(
searchResults.items.map((spaceItem) => {
return Promise.all(spaceItem.pages.map((item) => transformPageResult(item)));
})
)
).flat(2);
}
/**
* Server action to search content in the entire site.
*/
export async function searchAllSiteContent(
query: string,
pointer: api.SiteContentPointer
): Promise<OrderedComputedResult[]> {
return await searchSiteContent({
pointer,
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
return await searchSiteContent(context, {
query,
scope: { mode: 'all' },
});
@@ -126,118 +71,191 @@ export async function searchAllSiteContent(
/**
* Server action to search content in a space.
*/
export async function searchSiteSpaceContent(
query: string,
pointer: api.SiteContentPointer,
revisionId: string
): Promise<OrderedComputedResult[]> {
const siteSpaceId = pointer.siteSpaceId;
assert(siteSpaceId, 'Expected siteSpaceId for searchSiteSpaceContent');
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
return await searchSiteContent({
pointer,
return await searchSiteContent(context, {
query,
// If we have a siteSectionId that means its a sections site use `current` mode
// which searches in the current space + all default spaces of sections
scope: pointer.siteSectionId
? { mode: 'current', siteSpaceId }
: { mode: 'specific', siteSpaceIds: [siteSpaceId] },
// We want to break cache for this specific space if the revisionId is different so use it as a cache busting key
cacheBust: revisionId,
scope: siteURLData.siteSection
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
});
}
/**
* Server action to ask a question in a space.
*/
export const streamAskQuestion = streamResponse(async function* ({
pointer,
export async function streamAskQuestion({
question,
}: {
pointer: api.SiteContentPointer;
question: string;
}) {
const { organizationId, siteId, siteSpaceId } = pointer;
const [apiCtx, context] = await Promise.all([api.api(), fetchV1ContextForSitePointer(pointer)]);
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
const stream = apiCtx.client.orgs.streamAskInSite(
organizationId,
siteId,
{
question,
context: siteSpaceId
? {
siteSpaceId,
}
: undefined,
scope: {
mode: 'default',
(async () => {
const context = await fetchServerActionSiteContext(
isV2() ? await getServerActionBaseContext() : await getV1BaseContext()
);
// Include the current site space regardless.
includedSiteSpaces: siteSpaceId ? [siteSpaceId] : undefined,
const apiClient = await context.dataFetcher.api();
const stream = apiClient.orgs.streamAskInSite(
context.organizationId,
context.site.id,
{
question,
context: {
siteSpaceId: context.siteSpace.id,
},
scope: {
mode: 'default',
// Include the current site space regardless.
includedSiteSpaces: [context.siteSpace.id],
},
},
},
{ format: 'document' }
);
{ format: 'document' }
);
const spacePromises = new Map<string, Promise<RevisionPage[]>>();
for await (const chunk of stream) {
const answer = chunk.answer;
const spacePromises = new Map<string, Promise<RevisionPage[]>>();
for await (const chunk of stream) {
const answer = chunk.answer;
// Register the space of each page source into the promise queue.
const spaces = answer.sources
.map((source) => {
if (source.type !== 'page') {
return null;
}
// Register the space of each page source into the promise queue.
const spaces = answer.sources
.map((source) => {
if (source.type !== 'page') {
return null;
}
if (!spacePromises.has(source.space)) {
spacePromises.set(
source.space,
context.dataFetcher.getRevisionPages({
spaceId: source.space,
revisionId: source.revision,
metadata: false,
})
);
}
if (!spacePromises.has(source.space)) {
spacePromises.set(
source.space,
context.dataFetcher.getRevisionPages({
spaceId: source.space,
revisionId: source.revision,
metadata: false,
})
);
}
return source.space;
})
.filter(filterOutNullable);
return source.space;
})
.filter(filterOutNullable);
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const pages = await spacePromises.get(space);
return { space, pages };
})
).then((results) => {
return results.reduce((map, result) => {
if (result.pages) {
map.set(result.space, result.pages);
}
return map;
}, new Map<string, RevisionPage[]>());
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const pages = await spacePromises.get(space);
return { space, pages };
})
).then((results) => {
return results.reduce((map, result) => {
if (result.pages) {
map.set(result.space, result.pages);
}
return map;
}, new Map<string, RevisionPage[]>());
});
responseStream.update(
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
yield await transformAnswer(context, { answer: chunk.answer, spacePages: pages });
}
});
return {
stream: responseStream.value,
};
}
/**
* Stream a list of suggested questions for the site.
*/
export const streamRecommendedQuestions = streamResponse(async function* (
organizationId: string,
siteId: string
) {
const apiCtx = await api.api();
const stream = apiCtx.client.orgs.streamRecommendedQuestionsInSite(organizationId, siteId);
export async function streamRecommendedQuestions() {
const siteURLData = await getSiteURLDataFromMiddleware();
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
for await (const chunk of stream) {
yield chunk;
const responseStream = createStreamableValue<SearchAIRecommendedQuestionStream | undefined>();
(async () => {
const apiClient = await context.dataFetcher.api();
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
siteURLData.organization,
siteURLData.site
);
for await (const chunk of apiStream) {
responseStream.update(chunk);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
return { stream: responseStream.value };
}
/**
* Search for content in a site by scoping the search to all content, a specific spaces or current space.
*/
async function searchSiteContent(
context: GitBookBaseContext,
args: {
query: string;
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
}
});
): Promise<OrderedComputedResult[]> {
const { dataFetcher } = context;
const siteURLData = await getSiteURLDataFromMiddleware();
const { scope, query } = args;
if (query.length <= 1) {
return [];
}
const [searchResults, { structure }] = await Promise.all([
dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
query,
scope,
}),
dataFetcher.getPublishedContentSite({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
siteShareKey: siteURLData.shareKey,
}),
]);
return (
await Promise.all(
searchResults.map(async (spaceItem) => {
const siteSpace = findSiteSpaceById(structure, spaceItem.id);
return Promise.all(
spaceItem.pages.map((item) =>
transformSitePageResult(item, siteSpace ?? undefined)
)
);
})
)
).flat(2);
}
async function transformAnswer(
context: GitBookSiteContext,
@@ -343,16 +361,6 @@ async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteS
return [page, ...sections];
}
async function transformPageResult(item: SearchPageResult, space?: Space) {
const [page, sections] = await transformSectionsAndPage({
item,
space,
spaceURL: space?.urls.published ?? space?.urls.app,
});
return [page, ...sections];
}
// Resolve a relative path to an absolute URL
// if the search result is relative to another space, we use the space URL
async function getURLWithSections(path: string, spaceURL?: string) {
@@ -24,9 +24,10 @@ export async function SiteLayout(props: {
context: GitBookSiteContext;
forcedTheme?: CustomizationThemeMode | null;
withTracking: boolean;
visitorAuthToken: string | null;
children: React.ReactNode;
}) {
const { context, nonce, forcedTheme, withTracking, children } = props;
const { context, nonce, forcedTheme, withTracking, visitorAuthToken, children } = props;
const { scripts, customization } = context;
@@ -51,7 +52,11 @@ export async function SiteLayout(props: {
(customization.themes.toggeable ? undefined : customization.themes.default)
}
>
<SpaceLayout context={context} withTracking={withTracking}>
<SpaceLayout
context={context}
withTracking={withTracking}
visitorAuthToken={visitorAuthToken}
>
{children}
</SpaceLayout>
@@ -67,7 +67,7 @@ export async function SitePage(props: SitePageProps) {
const withSections = Boolean(sections && sections.list.length > 0);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
const document = await getPageDocument(context.dataFetcher, context.space.id, page);
const document = await getPageDocument(context.dataFetcher, context.space, page);
return (
<>
@@ -10,7 +10,6 @@ import { CONTAINER_STYLE } from '@/components/layout';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { getSitePointerFromContext } from '@/lib/v1';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
@@ -25,10 +24,13 @@ export function SpaceLayout(props: {
/** Whether to enable tracking of events into site insights. */
withTracking: boolean;
/** The visitor token used to access this content */
visitorAuthToken: string | null;
/** The children of the layout. */
children: React.ReactNode;
}) {
const { context, withTracking, children } = props;
const { context, withTracking, visitorAuthToken, children } = props;
const { siteSpace, customization, sections, siteSpaces } = context;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -46,7 +48,7 @@ export function SpaceLayout(props: {
<InsightsProvider
enabled={withTracking}
apiHost={context.dataFetcher.apiEndpoint}
visitorAuthToken={context.visitorAuthToken}
visitorAuthToken={visitorAuthToken}
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
@@ -129,11 +131,9 @@ export function SpaceLayout(props: {
<React.Suspense fallback={null}>
<SearchModal
revisionId={context.revisionId}
spaceTitle={siteSpace.title}
withAsk={customization.aiSearch.enabled}
isMultiVariants={isMultiVariants}
pointer={getSitePointerFromContext(context)}
/>
</React.Suspense>
</InsightsProvider>
@@ -0,0 +1,49 @@
'use client';
import { useEffect, useLayoutEffect, useRef } from 'react';
const HAS_INTERSECTION_OBSERVER = typeof IntersectionObserver !== 'undefined';
/**
* Watch an element to know when it is in the viewport.
*/
export function useInViewportListener(
containerRef: React.RefObject<HTMLElement>,
listener: (isIntersecting: boolean, disconnect: () => void) => void,
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>
) {
const listenerRef = useRef(listener);
useLayoutEffect(() => {
listenerRef.current = listener;
});
const isIntersectingRef = useRef(false);
useEffect(() => {
// We set the element as visible if the IntersectionObserver API is not available.
// we have to do it in the `useEffect` to be SSR compatible.
if (!HAS_INTERSECTION_OBSERVER) {
listenerRef.current(true, () => {});
return;
}
if (!containerRef.current) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
isIntersectingRef.current = entry.isIntersecting;
listenerRef.current(entry.isIntersecting, () => {
observer.disconnect();
});
},
{
root: options?.root,
rootMargin: options?.rootMargin,
threshold: options?.threshold,
}
);
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [containerRef, options?.root, options?.rootMargin, options?.threshold]);
}
-49
View File
@@ -1,49 +0,0 @@
export type StreamResponseChunk<T> = {
iteratorResult: IteratorResult<T>;
next?: Promise<StreamResponseChunk<T>>;
};
/**
* Stream the response of an async generator function.
* This function should be used to wrap an async generator server action.
*
* See https://github.com/vercel/next.js/discussions/51282
*/
export function streamResponse<T, P extends any[]>(
createGenerator: (...args: P) => AsyncGenerator<T>
) {
return async (...args: Parameters<typeof createGenerator>) => {
const generator = createGenerator(...args);
return streamChunk<T>(generator);
};
}
/**
* Iterate over the response of an async generator function.
*/
export function iterateStreamResponse<T>(streamResponse: Promise<StreamResponseChunk<T>>) {
return {
[Symbol.asyncIterator]: () => ({
current: streamResponse,
async next() {
const { iteratorResult, next } = await this.current;
if (next) this.current = next;
else iteratorResult.done = true;
return iteratorResult;
},
}),
};
}
async function streamChunk<T>(generator: AsyncGenerator<T>) {
const next = generator.next();
return new Promise<StreamResponseChunk<T>>((resolve, reject) => {
next.then((res) => {
if (res.done) resolve({ iteratorResult: res });
else resolve({ iteratorResult: res, next: streamChunk(generator) });
});
next.catch((error) => reject(error));
});
}
+33 -119
View File
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import 'server-only';
import {
@@ -14,7 +15,6 @@ import {
type RevisionReusableContent,
} from '@gitbook/api';
import type { GitBookDataFetcher } from '@v2/lib/data/types';
import assertNever from 'assert-never';
import { headers } from 'next/headers';
import { batch } from './async';
@@ -171,7 +171,7 @@ export type PublishedContentWithCache =
export const getUserById = cache({
name: 'api.getUserById',
tag: (userId) =>
getAPICacheTag({
getCacheTag({
tag: 'user',
user: userId,
}),
@@ -204,7 +204,7 @@ export const getUserById = cache({
export const getLatestOpenAPISpecVersionContent = cache({
name: 'api.getLatestOpenApiSpecVersionContent',
tag: (organization, openAPISpec) =>
getAPICacheTag({
getCacheTag({
tag: 'openapi',
organization,
openAPISpec,
@@ -240,7 +240,7 @@ export const getLatestOpenAPISpecVersionContent = cache({
export const getPublishedContentByUrl = cache({
name: 'api.getPublishedContentByUrl.v4',
tag: (url) =>
getAPICacheTag({
getCacheTag({
tag: 'url',
hostname: new URL(url).hostname,
}),
@@ -301,7 +301,7 @@ export const getPublishedContentByUrl = cache({
*/
export const getSpace = cache({
name: 'api.getSpace',
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
tag: (spaceId) => getCacheTag({ tag: 'space', space: spaceId }),
get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
const apiCtx = await api();
const response = await apiCtx.client.spaces.getSpaceById(
@@ -330,7 +330,7 @@ function checkHasErrorCode(error: unknown, code: number) {
export const getChangeRequest = cache({
name: 'api.getChangeRequest',
tag: (spaceId, changeRequestId) =>
getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }),
getCacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }),
get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
const apiCtx = await api();
try {
@@ -379,7 +379,7 @@ const getAPIContextId = async () => {
export const getRevision = cache({
name: 'api.getRevision.v2',
tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getKeySuffix: getAPIContextId,
get: async (
spaceId: string,
@@ -411,7 +411,7 @@ export const getRevision = cache({
export const getRevisionPages = cache({
name: 'api.getRevisionPages.v4',
tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getKeySuffix: getAPIContextId,
get: async (
spaceId: string,
@@ -446,7 +446,7 @@ export const getRevisionPages = cache({
export const getRevisionPageByPath = cache({
name: 'api.getRevisionPageByPath.v3',
tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getKeySuffix: getAPIContextId,
get: async (
spaceId: string,
@@ -492,7 +492,7 @@ export const getRevisionPageByPath = cache({
const getRevisionFileById = cache({
name: 'api.getRevisionFile.v3',
tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (
spaceId: string,
revisionId: string,
@@ -528,7 +528,7 @@ const getRevisionFileById = cache({
const getRevisionReusableContentById = cache({
name: 'api.getRevisionReusableContentById.v1',
tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getKeySuffix: getAPIContextId,
get: async (
spaceId: string,
@@ -569,7 +569,7 @@ const getRevisionReusableContentById = cache({
const getRevisionAllFiles = cache({
name: 'api.getRevisionAllFiles.v2',
tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
getCacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
const response = await getAll(
async (params) => {
@@ -684,7 +684,7 @@ export const getReusableContent = async (
export const getDocument = cache({
name: 'api.getDocument.v2',
tag: (spaceId, documentId) =>
getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }),
getCacheTag({ tag: 'document', space: spaceId, document: documentId }),
getKeySuffix: getAPIContextId,
get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
const apiCtx = await api();
@@ -712,14 +712,21 @@ export const getDocument = cache({
*/
export const getComputedDocument = cache({
name: 'api.getComputedDocument',
tag: (spaceId, source) =>
getAPICacheTag({
tag: 'computed-document',
space: spaceId,
integration: source.integration,
}),
tag: (organizationId, spaceId, source) =>
getComputedContentSourceCacheTags(
{
organizationId,
spaceId,
},
source
)[0],
getKeySuffix: getAPIContextId,
get: async (spaceId: string, source: ComputedContentSource, options: CacheFunctionOptions) => {
get: async (
_organizationId: string,
spaceId: string,
source: ComputedContentSource,
options: CacheFunctionOptions
) => {
const apiCtx = await api();
const response = await apiCtx.client.spaces.getComputedDocument(
spaceId,
@@ -750,7 +757,7 @@ function validateSiteRedirectSource(source: string) {
*/
export const getSiteRedirectBySource = cache({
name: 'api.getSiteRedirectBySource',
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
tag: ({ siteId }) => getCacheTag({ tag: 'site', site: siteId }),
getKeySuffix: getAPIContextId,
get: async (
args: {
@@ -811,7 +818,7 @@ export const getSiteRedirectBySource = cache({
*/
export const getSite = cache({
name: 'api.getSite',
tag: (_organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
tag: (_organizationId, siteId) => getCacheTag({ tag: 'site', site: siteId }),
getKeySuffix: getAPIContextId,
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const apiCtx = await api();
@@ -830,7 +837,7 @@ export const getSite = cache({
*/
export const getPublishedContentSite = cache({
name: 'api.getPublishedContentSite',
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
tag: ({ siteId }) => getCacheTag({ tag: 'site', site: siteId }),
getKeySuffix: getAPIContextId,
get: async (
args: {
@@ -907,7 +914,7 @@ export async function getSpaceContentData(
*/
export const searchSiteContent = cache({
name: 'api.searchSiteContent',
tag: (_organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
tag: (_organizationId, siteId) => getCacheTag({ tag: 'site', site: siteId }),
getKeySuffix: getAPIContextId,
get: async (
organizationId: string,
@@ -947,7 +954,7 @@ export const searchSiteContent = cache({
*/
export const renderIntegrationUi = cache({
name: 'api.renderIntegrationUi',
tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }),
tag: (integrationName) => getCacheTag({ tag: 'integration', integration: integrationName }),
get: async (
integrationName: string,
request: RequestRenderIntegrationUI,
@@ -971,7 +978,7 @@ export const renderIntegrationUi = cache({
*/
export const getEmbedByUrlInSpace = cache({
name: 'api.getEmbedByUrlInSpace',
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
tag: (spaceId) => getCacheTag({ tag: 'space', space: spaceId }),
get: async (spaceId: string, url: string, options: CacheFunctionOptions) => {
const apiCtx = await api();
const response = await apiCtx.client.spaces.getEmbedByUrlInSpace(
@@ -986,99 +993,6 @@ export const getEmbedByUrlInSpace = cache({
},
});
/**
* Create a cache tag for the API.
*/
export function getAPICacheTag(
spec: // All data related to a user
| {
tag: 'user';
user: string;
}
// All data related to a space
| {
tag: 'space';
space: string;
}
// All data related to an integration
| {
tag: 'integration';
integration: string;
}
// All data related to a change request
| {
tag: 'change-request';
space: string;
changeRequest: string;
}
// Immutable data related to a revision
| {
tag: 'revision';
space: string;
revision: string;
}
// Immutable data related to a document
| {
tag: 'document';
space: string;
document: string;
}
// Immutable data related to a computed document
| {
tag: 'computed-document';
space: string;
integration: string;
}
// All data related to the URL of a content
| {
tag: 'url';
hostname: string;
}
// All data related to a collection
| {
tag: 'collection';
collection: string;
}
// All data related to a site
| {
tag: 'site';
site: string;
}
// All data related to an OpenAPI spec
| {
tag: 'openapi';
organization: string;
openAPISpec: string;
}
): string {
switch (spec.tag) {
case 'user':
return `user:${spec.user}`;
case 'url':
return `url:${spec.hostname}`;
case 'space':
return `space:${spec.space}`;
case 'change-request':
return `space:${spec.space}:change-request:${spec.changeRequest}`;
case 'revision':
return `space:${spec.space}:revision:${spec.revision}`;
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.integration}`;
case 'collection':
return `collection:${spec.collection}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
default:
assertNever(spec);
}
}
/**
* Return the user agent to use for API requests.
*/
@@ -37,7 +37,7 @@ export async function getDocumentSections(
});
}
if (block.type === 'swagger' && block.meta?.id) {
if ((block.type === 'swagger' || block.type === 'openapi-operation') && block.meta?.id) {
const { data: operation } = await resolveOpenAPIBlock({
block,
context,
+3 -2
View File
@@ -22,7 +22,7 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
if (node.data && 'fullWidth' in node.data && node.data.fullWidth) {
return true;
}
if (node.type === 'swagger') {
if (node.type === 'swagger' || node.type === 'openapi-operation') {
return true;
}
}
@@ -120,7 +120,8 @@ export function getBlockTitle(block: DocumentBlock): string {
return block.data.title ?? '';
}
case 'swagger': {
case 'swagger':
case 'openapi-operation': {
return `${block.data.method?.toUpperCase()} ${block.data.path}`;
}
default:
+1 -1
View File
@@ -130,7 +130,7 @@ export async function resolveContentRef(
text = `#${anchor}`;
if (resolveAnchorText) {
const document = await getPageDocument(dataFetcher, space.id, page);
const document = await getPageDocument(dataFetcher, space, page);
if (document) {
const block = getBlockById(document, anchor);
if (block) {
+22 -5
View File
@@ -7,7 +7,6 @@ import {
import type { GitBookDataFetcher } from '@v2/lib/data/types';
import { createImageResizer } from '@v2/lib/images';
import { createLinker } from '@v2/lib/links';
import { headers } from 'next/headers';
import {
type SiteContentPointer,
@@ -28,6 +27,7 @@ import {
getSiteRedirectBySource,
getSpace,
getUserById,
searchSiteContent,
} from './api';
import { getDynamicCustomizationSettings } from './customization';
import { getBasePath, getHost } from './links';
@@ -74,9 +74,20 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
const apiClient = await api();
return {
const dataFetcher: GitBookDataFetcher = {
apiEndpoint: apiClient.client.endpoint,
async api() {
const result = await api();
return result.client;
},
withToken() {
// In v1, the token is global and controlled by the middleware.
// We don't need to do anything special here.
return dataFetcher;
},
getUserById(userId) {
return getUserById(userId);
},
@@ -117,7 +128,7 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
},
getComputedDocument(params) {
return getComputedDocument(params.spaceId, params.source);
return getComputedDocument(params.organizationId, params.spaceId, params.source);
},
getRevisionPages(params) {
@@ -145,7 +156,15 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
getEmbedByUrl(params) {
return getEmbedByUrlInSpace(params.spaceId, params.url);
},
async searchSiteContent(params) {
const { organizationId, siteId, query, cacheBust, scope } = params;
const result = await searchSiteContent(organizationId, siteId, query, scope, cacheBust);
return result.items;
},
};
return dataFetcher;
}
/**
@@ -153,7 +172,6 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
*/
export async function fetchV1ContextForSitePointer(pointer: SiteContentPointer) {
const baseContext = await getV1BaseContext();
const headersList = await headers();
const context = await fetchSiteContextByIds(baseContext, {
organization: pointer.organizationId,
@@ -164,7 +182,6 @@ export async function fetchV1ContextForSitePointer(pointer: SiteContentPointer)
shareKey: pointer.siteShareKey,
changeRequest: pointer.changeRequestId,
revision: pointer.revisionId,
visitorAuthToken: headersList.get('x-gitbook-visitor-token'),
});
context.customization = await getDynamicCustomizationSettings(context.customization);
+26 -11
View File
@@ -32,6 +32,7 @@ import {
import { joinPath, normalizePathname } from '@/lib/paths';
import { getProxyModeBasePath } from '@/lib/proxy';
import { MiddlewareHeaders } from '@v2/lib/middleware';
export const config = {
matcher:
@@ -83,6 +84,8 @@ export type LookupResult = PublishedContentWithCache & {
cookies?: LookupCookies;
/** Visitor authentication token */
visitorToken?: string;
/** URL of the site */
siteURL?: string;
};
/**
@@ -199,6 +202,13 @@ export async function middleware(request: NextRequest) {
if (resolved.shareKey) {
headers.set('x-gitbook-content-site-share-key', resolved.shareKey);
}
// For server actions that use v2 code
headers.set(MiddlewareHeaders.SiteURLData, JSON.stringify(resolved));
}
if (resolved.visitorToken) {
headers.set(MiddlewareHeaders.VisitorAuthToken, resolved.visitorToken);
}
// For tests, we make it possible to enable search indexation
@@ -231,10 +241,6 @@ export async function middleware(request: NextRequest) {
headers.set('x-gitbook-api', apiEndpoint);
}
if (resolved.visitorToken) {
headers.set('x-gitbook-visitor-token', resolved.visitorToken);
}
const target = new URL(joinPath('/middleware', rewritePathname), request.nextUrl.toString());
target.search = url.search;
@@ -761,14 +767,23 @@ async function lookupSiteByAPI(
return null;
});
return (
result ?? {
error: {
code: 404,
message: 'No content found',
},
if (result) {
if ('site' in result) {
return {
...result,
siteURL: `${lookupURL.origin}${result.basePath}`,
};
}
);
return result;
}
return {
error: {
code: 404,
message: 'No content found',
},
};
}
/**
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/openapi-parser
## 2.0.2
### Patch Changes
- 53f5dbe: Fix typing of `shouldIgnoreEntity`
## 2.0.1
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js"
}
},
"version": "2.0.1",
"version": "2.0.2",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.10.4",
@@ -4,10 +4,7 @@ import type { OpenAPIV3 } from '@scalar/openapi-types';
* Check if an entity should be ignored
*/
export function shouldIgnoreEntity(
data:
| undefined
| Pick<OpenAPIV3.TagObject, 'x-internal' | 'x-gitbook-ignore'>
| Pick<OpenAPIV3.OperationObject, 'x-internal' | 'x-gitbook-ignore'>
data: undefined | OpenAPIV3.TagObject | OpenAPIV3.OperationObject
) {
return data?.['x-internal'] === true || data?.['x-gitbook-ignore'] === true;
}
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/react-contentkit
## 0.6.1
### Patch Changes
- 8beb5d6: Add input elements to ContentKit
## 0.6.0
### Minor Changes
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@gitbook/react-contentkit",
"version": "0.6.0",
"version": "0.6.1",
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -11,7 +11,8 @@
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "0.96.1",
"assert-never": "^1.2.1"
"assert-never": "^1.2.1",
"@gitbook/icons": "workspace:*"
},
"peerDependencies": {
"react": "*"
@@ -13,6 +13,7 @@ import { ElementCodeBlock } from './ElementCodeBlock';
import { ElementDivider } from './ElementDivider';
import { ElementIcon } from './ElementIcon';
import { ElementImage } from './ElementImage';
import { ElementInput } from './ElementInput';
import { ElementMarkdown } from './ElementMarkdown';
import { ElementModal } from './ElementModal';
import { ElementStack } from './ElementStack';
@@ -152,6 +153,8 @@ export function Element(props: {
return <ElementCodeBlock element={element} context={context} state={state} />;
case 'webframe':
return <ElementWebframe element={element} />;
case 'input':
return <ElementInput element={element} context={context} state={state} />;
default:
return (
<pre
@@ -1,6 +1,7 @@
'use client';
import type { ContentKitButton } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react';
import classNames from 'classnames';
@@ -19,16 +20,14 @@ export function ElementButton(
const [loading, setLoading] = React.useState(false);
// TODO:
// - loading
// - confirm
// - confirm dialog
return (
<button
title={element.tooltip}
className={classNames(
'contentkit-button',
`contentkit-button-style-${element.style ?? 'secondary'}`,
loading ? 'contentkit-button-loading' : null
`contentkit-button-style-${element.style ?? 'secondary'}`
)}
onClick={(event) => {
if (element.disabled || loading) {
@@ -43,11 +42,17 @@ export function ElementButton(
});
}}
>
{icon}
{element.label ? (
<span className="contentkit-button-label">{element.label}</span>
) : null}
{trailingIcon}
{loading ? (
<Icon icon="spinner" className="contentkit-button-loading" />
) : (
<>
{icon}
{element.label ? (
<span className="contentkit-button-label">{element.label}</span>
) : null}
{trailingIcon}
</>
)}
</button>
);
}
@@ -0,0 +1,74 @@
import type {
ContentKitButton,
ContentKitCheckbox,
ContentKitCodeBlock,
ContentKitInput,
ContentKitRadio,
ContentKitSelect,
ContentKitSwitch,
ContentKitTextInput,
} from '@gitbook/api';
import classNames from 'classnames';
import { ElementButton } from './ElementButton';
import { ElementCodeBlock } from './ElementCodeBlock';
import { ElementIcon } from './ElementIcon';
import { ElementTextInput } from './ElementTextInput';
import type { ContentKitServerContext } from './types';
export function ElementInput(props: {
element: ContentKitInput;
context: ContentKitServerContext;
state: object;
}) {
const { element, context, state } = props;
const { label, element: childElement } = element;
return (
<div className={classNames('contentkit-form')}>
{label ? <label className="contentkit-label">{label}</label> : null}
{getInputElement({ element: childElement, context, state })}
</div>
);
}
function getInputElement(props: {
element:
| ContentKitTextInput
| ContentKitSelect
| ContentKitSwitch
| ContentKitRadio
| ContentKitCheckbox
| ContentKitButton
| ContentKitCodeBlock;
context: ContentKitServerContext;
state: object;
}) {
const { element, context, state } = props;
//!!TODO add support for:
// - select
// - switch
// - radio
// - checkbox
switch (element.type) {
case 'textinput':
return <ElementTextInput element={element} />;
case 'button':
return (
<ElementButton
element={element}
icon={
element.icon ? <ElementIcon icon={element.icon} context={context} /> : null
}
trailingIcon={
element.trailingIcon ? (
<ElementIcon icon={element.trailingIcon} context={context} />
) : null
}
/>
);
case 'codeblock':
return <ElementCodeBlock element={element} context={context} state={state} />;
}
}
@@ -29,6 +29,7 @@ export function ElementTextInput(props: ContentKitClientElementProps<ContentKitT
value={value}
placeholder={element.placeholder}
onChange={onChange}
rows={4}
/>
);
}
+7
View File
@@ -1,5 +1,12 @@
# @gitbook/react-openapi
## 1.0.5
### Patch Changes
- Updated dependencies [53f5dbe]
- @gitbook/openapi-parser@2.0.2
## 1.0.4
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "1.0.4",
"version": "1.0.5",
"sideEffects": false,
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
@@ -4,6 +4,7 @@ import clsx from 'clsx';
import { useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately';
import { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection';
interface InteractiveSectionTab {
key: string;
@@ -63,7 +64,7 @@ export function InteractiveSection(props: {
const { isFocusVisible, focusProps } = useFocusRing();
return (
<div
<Section
id={id}
className={clsx(
'openapi-section',
@@ -73,20 +74,15 @@ export function InteractiveSection(props: {
)}
>
{header ? (
<div
<SectionHeader
onClick={() => {
if (toggeable) {
state.toggle();
}
}}
className={clsx('openapi-section-header', `${className}-header`)}
className={className}
>
<div
className={clsx(
'openapi-section-header-content',
`${className}-header-content`
)}
>
<SectionHeaderContent className={className}>
{(children || selectedTab?.body) && toggeable ? (
<button
{...mergeProps(buttonProps, focusProps)}
@@ -102,7 +98,7 @@ export function InteractiveSection(props: {
</button>
) : null}
{header}
</div>
</SectionHeaderContent>
<div
className={clsx(
'openapi-section-header-controls',
@@ -133,23 +129,19 @@ export function InteractiveSection(props: {
</select>
) : null}
</div>
</div>
</SectionHeader>
) : null}
{(!toggeable || state.isExpanded) && (children || selectedTab?.body) ? (
<div
ref={panelRef}
{...panelProps}
className={clsx('openapi-section-body', `${className}-body`)}
>
<SectionBody ref={panelRef} {...panelProps} className={className}>
{children}
{selectedTab?.body}
</div>
</SectionBody>
) : null}
{overlay ? (
<div className={clsx('openapi-section-overlay', `${className}-overlay`)}>
{overlay}
</div>
) : null}
</div>
</Section>
);
}
@@ -1,5 +1,5 @@
import { InteractiveSection } from './InteractiveSection';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection';
import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
import { stringifyOpenAPI } from './stringifyOpenAPI';
@@ -117,9 +117,9 @@ export function OpenAPICodeSample(props: {
return (
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-codesample">
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -1,3 +1,4 @@
'use client';
import type React from 'react';
import { useRef } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
@@ -1,3 +1,14 @@
'use client';
import { createContext, useContext, useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria';
import {
type DisclosureGroupProps,
type DisclosureGroupState,
useDisclosureGroupState,
useDisclosureState,
} from 'react-stately';
interface Props {
groups: TDisclosureGroup[];
icon?: React.ReactNode;
@@ -13,15 +24,6 @@ type TDisclosureGroup = {
}[];
};
import { createContext, useContext, useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria';
import {
type DisclosureGroupProps,
type DisclosureGroupState,
useDisclosureGroupState,
useDisclosureState,
} from 'react-stately';
const DisclosureGroupStateContext = createContext<DisclosureGroupState | null>(null);
/**
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection';
import { generateSchemaExample } from './generateSchemaExample';
import { json2xml } from './json2xml';
import { stringifyOpenAPI } from './stringifyOpenAPI';
@@ -84,9 +84,9 @@ export function OpenAPIResponseExample(props: {
return (
<OpenAPITabs stateKey={createStateKey('response-example')} items={tabs}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example">
<StaticSection header={<OpenAPITabsList />} className="openapi-response-example">
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -134,12 +134,9 @@ function OpenAPIResponse(props: {
return (
<OpenAPITabs stateKey={createStateKey('response-media-types')} items={tabs}>
<InteractiveSection
header={<OpenAPITabsList />}
className="openapi-response-media-types"
>
<StaticSection header={<OpenAPITabsList />} className="openapi-response-media-types">
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -173,23 +170,19 @@ function OpenAPIResponseMediaType(props: {
key: example.key,
label: example.example.summary || example.key,
body: (
<OpenAPIExample
example={firstExample.example}
context={props.context}
syntax={syntax}
/>
<OpenAPIExample example={example.example} context={props.context} syntax={syntax} />
),
};
});
return (
<OpenAPITabs stateKey={createStateKey('response-media-type-examples')} items={tabs}>
<InteractiveSection
<StaticSection
header={<OpenAPITabsList />}
className="openapi-response-media-type-examples"
>
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -1,8 +1,8 @@
import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup';
import { OpenAPIResponse } from './OpenAPIResponse';
import { StaticSection } from './StaticSection';
import type { OpenAPIClientContext } from './types';
/**
@@ -15,7 +15,7 @@ export function OpenAPIResponses(props: {
const { responses, context } = props;
return (
<InteractiveSection header="Responses" className="openapi-responses">
<StaticSection header="Responses" className="openapi-responses">
<OpenAPIDisclosureGroup
allowsMultipleExpanded
icon={context.icons.chevronRight}
@@ -58,6 +58,6 @@ export function OpenAPIResponses(props: {
}
)}
/>
</InteractiveSection>
</StaticSection>
);
}
+2 -2
View File
@@ -119,9 +119,9 @@ export function OpenAPISchemaProperties(props: {
return (
<div id={id} className="openapi-schema-properties">
{properties.map((property) => (
{properties.map((property, index) => (
<OpenAPISchemaProperty
key={property.propertyName}
key={index}
circularRefs={circularRefs}
{...property}
context={context}
+3 -5
View File
@@ -1,12 +1,10 @@
'use client';
import type { OpenAPI } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
import { OpenAPIResponses } from './OpenAPIResponses';
import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISecurities } from './OpenAPISecurities';
import { StaticSection } from './StaticSection';
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { parameterToProperty } from './utils';
@@ -32,7 +30,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
{parameterGroups.map((group) => {
return (
<InteractiveSection
<StaticSection
key={group.key}
className="openapi-parameters"
header={group.label}
@@ -41,7 +39,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
properties={group.parameters.map(parameterToProperty)}
context={context}
/>
</InteractiveSection>
</StaticSection>
);
})}
+53 -64
View File
@@ -1,10 +1,10 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
import { useIntersectionObserver } from 'usehooks-ts';
import { useEventCallback } from 'usehooks-ts';
import { Markdown } from './Markdown';
import { useSyncedTabsGlobalState } from './useSyncedTabsGlobalState';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
export type TabItem = {
key: Key;
@@ -15,7 +15,7 @@ export type TabItem = {
type OpenAPITabsContextData = {
items: TabItem[];
selectedTab: TabItem;
selectedTab: TabItem | null;
};
const OpenAPITabsContext = createContext<OpenAPITabsContextData | null>(null);
@@ -35,68 +35,54 @@ export function OpenAPITabs(
props: React.PropsWithChildren<TabsProps & { items: TabItem[]; stateKey?: string }>
) {
const { children, items, stateKey } = props;
const [ref, isIntersectionVisible] = useIntersectionObserver({
threshold: 0.1,
rootMargin: '200px',
});
const isVisible = stateKey ? isIntersectionVisible : true;
const defaultTab = items[0] as TabItem;
const [syncedTabs, setSyncedTabs] = useSyncedTabsGlobalState<TabItem>();
const [selectedTabKey, setSelectedTabKey] = useState(() => {
if (isVisible && stateKey && syncedTabs && syncedTabs.has(stateKey)) {
const tabFromState = syncedTabs.get(stateKey);
return tabFromState?.key ?? items[0]?.key;
}
return items[0]?.key;
});
const [selectedTab, setSelectedTab] = useState<TabItem>(defaultTab);
const handleSelectionChange = (key: Key) => {
setSelectedTabKey(key);
if (stateKey) {
const tab = items.find((item) => item.key === key);
if (!tab) {
return;
const [tabKey, setTabKey] = useState<Key | null>(() => {
if (stateKey && typeof window !== 'undefined') {
const store = getOrCreateTabStoreByKey(stateKey);
const tabKey = store.getState().tabKey;
if (tabKey) {
return tabKey;
}
setSyncedTabs((state) => {
const newState = new Map(state);
newState.set(stateKey, tab);
return newState;
});
}
};
return items[0]?.key ?? null;
});
const selectTab = useEventCallback((key: Key | null) => {
if (!key || key === tabKey) {
return;
}
const tab = items.find((item) => item.key === key);
if (!tab) {
return;
}
setTabKey(key);
});
const selectedTab = items.find((item) => item.key === tabKey) ?? items[0] ?? null;
const cancelDeferRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (isVisible && stateKey && syncedTabs && syncedTabs.has(stateKey)) {
const tabFromState = syncedTabs.get(stateKey);
if (!items.some((item) => item.key === tabFromState?.key)) {
return setSelectedTab(defaultTab);
}
if (tabFromState && tabFromState?.key !== selectedTab?.key) {
const tabFromItems = items.find((item) => item.key === tabFromState.key);
if (!tabFromItems) {
return;
}
setSelectedTab(tabFromItems);
}
if (!stateKey) {
return undefined;
}
}, [isVisible, stateKey, syncedTabs, selectedTabKey]);
const store = getOrCreateTabStoreByKey(stateKey);
return store.subscribe((state) => {
cancelDeferRef.current?.();
cancelDeferRef.current = defer(() => selectTab(state.tabKey));
});
}, [stateKey, selectTab]);
useEffect(() => {
return () => cancelDeferRef.current?.();
}, []);
const contextValue = useMemo(() => ({ items, selectedTab }), [items, selectedTab]);
return (
<OpenAPITabsContext.Provider value={contextValue}>
<Tabs
ref={ref}
className="openapi-tabs"
onSelectionChange={handleSelectionChange}
selectedKey={selectedTab?.key}
onSelectionChange={(tabKey) => {
selectTab(tabKey);
if (stateKey) {
const store = getOrCreateTabStoreByKey(stateKey);
store.setState({ tabKey });
}
}}
selectedKey={tabKey}
>
{children}
</Tabs>
@@ -104,6 +90,11 @@ export function OpenAPITabs(
);
}
const defer = (fn: () => void) => {
const id = setTimeout(fn, 0);
return () => clearTimeout(id);
};
/**
* The OpenAPI Tabs list component.
* This component should be used as a child of the OpenAPITabs component.
@@ -116,14 +107,14 @@ export function OpenAPITabsList() {
<TabList className="openapi-tabs-list">
{items.map((tab) => (
<Tab
key={tab.key}
id={tab.key}
style={({ isFocusVisible }) => ({
outline: isFocusVisible
? '2px solid rgb(var(--primary-color-500)/0.4)'
: 'none',
})}
className="openapi-tabs-tab"
key={`Tab-${tab.key}`}
id={tab.key}
>
{tab.label}
</Tab>
@@ -144,12 +135,10 @@ export function OpenAPITabsPanels() {
return null;
}
const key = selectedTab.key.toString();
return (
<TabPanel
key={`TabPanel-${selectedTab.key}`}
id={selectedTab.key.toString()}
className="openapi-tabs-panel"
>
<TabPanel key={key} id={key} className="openapi-tabs-panel">
{selectedTab.body}
{selectedTab.description ? (
<Markdown source={selectedTab.description} className="openapi-tabs-footer" />
@@ -0,0 +1,59 @@
import clsx from 'clsx';
import { type ComponentPropsWithoutRef, forwardRef } from 'react';
export function Section(props: ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx('openapi-section', props.className)} />;
}
export function SectionHeader(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
'openapi-section-header',
props.className && `${props.className}-header`
)}
/>
);
}
export function SectionHeaderContent(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
'openapi-section-header-content',
props.className && `${props.className}-header-content`
)}
/>
);
}
export const SectionBody = forwardRef(function SectionBody(
props: ComponentPropsWithoutRef<'div'>,
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<div
ref={ref}
{...props}
className={clsx('openapi-section-body', props.className && `${props.className}-body`)}
/>
);
});
export function StaticSection(props: {
className: string;
header: React.ReactNode;
children: React.ReactNode;
}) {
const { className, header, children } = props;
return (
<Section className={className}>
<SectionHeader className={className}>
<SectionHeaderContent className={className}>{header}</SectionHeaderContent>
</SectionHeader>
<SectionBody className={className}>{children}</SectionBody>
</Section>
);
}
@@ -1,23 +1,35 @@
'use client';
import { create } from 'zustand';
import { createStore } from 'zustand';
interface SyncedTabsState<T> {
tabs: Map<string, T>;
setTabs: (updater: (tabs: Map<string, T>) => Map<string, T>) => void;
}
type Key = string | number;
const useSyncedTabsStore = create<SyncedTabsState<any>>()((set) => ({
tabs: new Map<string, any>(),
setTabs: (updater) =>
set((state) => ({
tabs: updater(new Map(state.tabs)), // Ensure a new Map is created for reactivity
})),
}));
type TabState = {
tabKey: Key | null;
};
// Selector for better performance - only re-renders when tabs change
export function useSyncedTabsGlobalState<T>() {
const tabs = useSyncedTabsStore((state) => state.tabs as Map<string, T>);
const setTabs = useSyncedTabsStore((state) => state.setTabs as SyncedTabsState<T>['setTabs']);
return [tabs, setTabs] as const;
}
type TabActions = { setTabKey: (tab: Key | null) => void };
type TabStore = TabState & TabActions;
const createTabStore = (initialTab?: Key) => {
return createStore<TabStore>()((set) => ({
tabKey: initialTab ?? null,
setTabKey: (tabKey) => {
set(() => ({ tabKey }));
},
}));
};
const defaultTabStores = new Map<string, ReturnType<typeof createTabStore>>();
const createTabStoreFactory = (stores: typeof defaultTabStores) => {
return (storeKey: string, initialKey?: Key) => {
if (!stores.has(storeKey)) {
stores.set(storeKey, createTabStore(initialKey));
}
return stores.get(storeKey)!;
};
};
export const getOrCreateTabStoreByKey = createTabStoreFactory(defaultTabStores);