diff --git a/bun.lockb b/bun.lockb
index cfd1fe63c..6469f28a4 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json
index d10c570c7..432128cae 100644
--- a/packages/react-openapi/package.json
+++ b/packages/react-openapi/package.json
@@ -2,10 +2,12 @@
"name": "@gitbook/react-openapi",
"exports": "./src/index.ts",
"dependencies": {
- "openapi-types": "^12.1.3",
+ "@scalar/api-client-react": "^0.2.4",
+ "@scalar/oas-utils": "0.1.1",
"classnames": "^2.5.1",
+ "flatted": "^3.2.9",
"json-pointer": "^0.6.2",
- "flatted": "^3.2.9"
+ "openapi-types": "^12.1.3"
},
"devDependencies": {
"@types/json-pointer": "^1.0.34"
diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx
index a0050c47b..7761deeae 100644
--- a/packages/react-openapi/src/InteractiveSection.tsx
+++ b/packages/react-openapi/src/InteractiveSection.tsx
@@ -30,6 +30,8 @@ export function InteractiveSection(props: {
header: React.ReactNode;
/** Body of the section */
children?: React.ReactNode;
+ /** Children to display within the container */
+ overlay?: React.ReactNode;
}) {
const {
id,
@@ -40,6 +42,7 @@ export function InteractiveSection(props: {
defaultTab = tabs[0]?.key,
header,
children,
+ overlay,
toggleOpenIcon = '▶',
toggleCloseIcon = '▼',
} = props;
@@ -120,6 +123,7 @@ export function InteractiveSection(props: {
{tabBody}
) : null}
+ {overlay}
);
}
diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx
index 0489ac543..d7afb222f 100644
--- a/packages/react-openapi/src/OpenAPICodeSample.tsx
+++ b/packages/react-openapi/src/OpenAPICodeSample.tsx
@@ -1,9 +1,11 @@
import { OpenAPIV3 } from 'openapi-types';
+
+import { CodeSampleInput, codeSampleGenerators } from './code-samples';
+import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation';
+import { generateMediaTypeExample } from './generateSchemaExample';
import { InteractiveSection } from './InteractiveSection';
import { getServersURL } from './OpenAPIServerURL';
-import { CodeSampleInput, codeSampleGenerators } from './code-samples';
-import { OpenAPIOperationData } from './fetchOpenAPIOperation';
-import { generateMediaTypeExample } from './generateSchemaExample';
+import { ScalarApiButton } from './ScalarApiButton';
import { OpenAPIContextProps } from './types';
import { noReference } from './utils';
@@ -72,7 +74,19 @@ export function OpenAPICodeSample(props: {
}
});
- return ;
+ async function fetchOperationData() {
+ 'use server';
+ return toJSON(data);
+ }
+
+ return (
+ }
+ />
+ );
}
function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx
index 493341b59..7787e5c67 100644
--- a/packages/react-openapi/src/OpenAPIOperation.tsx
+++ b/packages/react-openapi/src/OpenAPIOperation.tsx
@@ -1,12 +1,13 @@
import classNames from 'classnames';
import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation';
-import { OpenAPIServerURL } from './OpenAPIServerURL';
-import { OpenAPIClientContext, OpenAPIContextProps } from './types';
-import { OpenAPICodeSample } from './OpenAPICodeSample';
-import { OpenAPISpec } from './OpenAPISpec';
-import { OpenAPIResponseExample } from './OpenAPIResponseExample';
import { Markdown } from './Markdown';
+import { OpenAPICodeSample } from './OpenAPICodeSample';
+import { OpenAPIResponseExample } from './OpenAPIResponseExample';
+import { OpenAPIServerURL } from './OpenAPIServerURL';
+import { OpenAPISpec } from './OpenAPISpec';
+import { ScalarApiClient } from './ScalarApiButton';
+import { OpenAPIClientContext, OpenAPIContextProps } from './types';
/**
* Display an interactive OpenAPI operation.
@@ -25,39 +26,40 @@ export function OpenAPIOperation(props: {
};
return (
-
-
-
{operation.summary}
- {operation.description ? (
-
- ) : null}
-
-
- {method.toUpperCase()}
-
-
-
- {path}
-
+
+
+
+
{operation.summary}
+ {operation.description ? (
+
+ ) : null}
+
+
+ {method.toUpperCase()}
+
+
+
+ {path}
+
+
-
-
-
-
-
-
-
+
);
}
diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx
index d8d0f9bd9..8ee8e31b7 100644
--- a/packages/react-openapi/src/OpenAPISpec.tsx
+++ b/packages/react-openapi/src/OpenAPISpec.tsx
@@ -1,14 +1,15 @@
'use client';
import { OpenAPIV3 } from 'openapi-types';
+
+import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
import { OpenAPIResponses } from './OpenAPIResponses';
import { OpenAPISchemaProperties } from './OpenAPISchema';
-import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
+import { OpenAPISecurities } from './OpenAPISecurities';
import { OpenAPIClientContext } from './types';
import { noReference } from './utils';
-import { OpenAPISecurities } from './OpenAPISecurities';
/**
* Client component to render the spec for the request and response.
@@ -18,7 +19,9 @@ import { OpenAPISecurities } from './OpenAPISecurities';
*/
export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext }) {
const { rawData, context } = props;
- const { operation, securities } = fromJSON(rawData) as OpenAPIOperationData;
+
+ const parsedData = fromJSON(rawData) as OpenAPIOperationData;
+ const { operation, securities } = parsedData;
const parameterGroups = groupParameters((operation.parameters || []).map(noReference));
@@ -55,7 +58,6 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext
context={context}
/>
) : null}
-
{operation.responses ? (
) : null}
diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx
new file mode 100644
index 000000000..11d0ea4db
--- /dev/null
+++ b/packages/react-openapi/src/ScalarApiButton.tsx
@@ -0,0 +1,163 @@
+'use client';
+import {
+ Cookie,
+ getHarRequest,
+ getParametersFromOperation,
+ type TransformedOperation,
+ getRequestFromOperation,
+ Query,
+ Header,
+} from '@scalar/oas-utils';
+import React from 'react';
+
+import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
+
+const ApiClientReact = React.lazy(async () => {
+ const mod = await import('@scalar/api-client-react');
+ return { default: mod.ApiClientReact };
+});
+
+const ScalarContext = React.createContext<
+ (fetchOperationData: () => Promise
) => void
+>(() => {});
+
+/**
+ * Button which launches the Scalar API Client
+ */
+export function ScalarApiButton(props: {
+ fetchOperationData: () => Promise;
+}) {
+ const { fetchOperationData } = props;
+ const open = React.useContext(ScalarContext);
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * Wrap the rendering with a context to open the scalar modal.
+ */
+export function ScalarApiClient(props: { children: React.ReactNode }) {
+ const { children } = props;
+
+ const [active, setActive] = React.useState(null);
+
+ const proxy = '/~scalar/proxy';
+
+ const open = React.useCallback(
+ async (fetchOperationData: () => Promise) => {
+ setActive({ operationData: null });
+ const operationData = fromJSON(await fetchOperationData());
+ setActive({ operationData });
+ },
+ [],
+ );
+
+ const onClose = React.useCallback(() => {
+ setActive(null);
+ }, []);
+
+ const request = React.useMemo(() => {
+ const operationData = active?.operationData;
+
+ if (!operationData) {
+ return null;
+ }
+
+ const operationId =
+ operationData.operation.operationId ?? operationData.method + operationData.path;
+
+ const operation = {
+ ...operationData,
+ httpVerb: operationData.method,
+ pathParameters: operationData.operation.parameters,
+ } as TransformedOperation;
+
+ const variables = getParametersFromOperation(operation, 'path', false);
+
+ const request = getHarRequest(
+ {
+ url: operationData.path,
+ },
+ getRequestFromOperation(operation, { requiredOnly: false }),
+ );
+
+ return {
+ id: operationId,
+ type: operationData.method,
+ path: operationData.path,
+ variables,
+ cookies: request.cookies.map((cookie: Cookie) => {
+ return { ...cookie, enabled: true };
+ }),
+ query: request.queryString.map((queryString: Query) => {
+ const query: typeof queryString & { required?: boolean } = queryString;
+ return { ...queryString, enabled: query.required ?? true };
+ }),
+ headers: request.headers.map((header: Header) => {
+ return { ...header, enabled: true };
+ }),
+ url: operationData.servers[0]?.url,
+ body: request.postData?.text,
+ };
+ }, [active]);
+
+ return (
+
+ {children}
+ {active ? (
+
+
+
+
+ {request ? (
+
}>
+
+
+ ) : (
+
+ )}
+
+
onClose()} className="scalar-app-exit">
+
+
+ ) : null}
+
+ );
+}
+
+function ScalarLoading() {
+ return Loading...
;
+}
diff --git a/src/app/~scalar/proxy/route.ts b/src/app/~scalar/proxy/route.ts
new file mode 100644
index 000000000..269820fb4
--- /dev/null
+++ b/src/app/~scalar/proxy/route.ts
@@ -0,0 +1,79 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+type ProxyRequest = {
+ url: string;
+ method: string;
+ headers: Record;
+ data: Record;
+};
+
+export const runtime = 'edge';
+
+/**
+ * Taken from https://github.com/scalar/scalar/tree/main/packages/api-client-proxy
+ */
+export async function POST(req: NextRequest) {
+ const requestBody: ProxyRequest = await req.json();
+
+ const isGetOrHeadRequest = ['get', 'head'].includes(requestBody.method.trim().toLowerCase());
+ const body = isGetOrHeadRequest
+ ? null
+ : requestBody.data
+ ? JSON.stringify(requestBody.data)
+ : null;
+
+ // Default options are marked with *
+ try {
+ const response = await fetch(requestBody.url.trim(), {
+ // *GET, POST, PUT, DELETE, etc.
+ method: requestBody.method.trim(),
+ // no-cors, *cors, same-origin
+ // mode: 'cors', // Not supported on Cloudflare Workers
+ // *default, no-cache, reload, force-cache, only-if-cached
+ cache: 'no-cache',
+ // include, *same-origin, omit
+ // credentials: 'include', // Not supported on Cloudflare Workers
+ headers: requestBody.headers,
+ // manual, *follow, error
+ redirect: 'follow',
+ // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
+ // referrerPolicy: 'no-referrer', // Not supported on Cloudflare Workers
+ // body data type must match "Content-Type" header
+ body,
+ });
+
+ const headers: Record = {};
+
+ const proxyHeaders = [...response.headers];
+ proxyHeaders.forEach(([key, value]) => {
+ if (['access-control-allow-origin'].includes(key.toLowerCase())) {
+ return;
+ }
+
+ headers[key] = value;
+ });
+
+ const text = await response.text();
+
+ return NextResponse.json({
+ statusCode: response.status,
+ // TODO: Do we need body?
+ // body: …
+ data: text,
+ headers: {
+ ...headers,
+ 'X-API-Client-Content-Length': text.length,
+ },
+ // TODO: transform cookie data
+ cookies: response.headers.get('cookies'),
+ });
+ } catch (error) {
+ console.error(
+ 'Scalar API Client Proxy Error',
+ (error as Error).stack ?? (error as Error).message ?? error,
+ );
+ return NextResponse.json({
+ data: 'Scalar API Client Proxy Error',
+ });
+ }
+}
diff --git a/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/src/components/DocumentView/OpenAPI/OpenAPI.tsx
index 9111e6079..a5220dd9b 100644
--- a/src/components/DocumentView/OpenAPI/OpenAPI.tsx
+++ b/src/components/DocumentView/OpenAPI/OpenAPI.tsx
@@ -12,6 +12,7 @@ import { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import './style.css';
+import './scalar.css';
/**
* Render an OpenAPI block.
diff --git a/src/components/DocumentView/OpenAPI/scalar.css b/src/components/DocumentView/OpenAPI/scalar.css
new file mode 100644
index 000000000..17e934494
--- /dev/null
+++ b/src/components/DocumentView/OpenAPI/scalar.css
@@ -0,0 +1,325 @@
+.light .scalar-modal-layout,
+.light .scalar {
+ --theme-color-1: color-mix(
+ in srgb,
+ rgb(var(--primary-base-300, 180 180 180)),
+ rgb(var(--dark-base, 23 23 23)) 96%
+ );
+ --theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72));
+ --theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
+ --theme-color-accent: #007d9c;
+
+ --theme-background-1: rgb(var(--light-base, 255 255 255));
+ --theme-background-2: color-mix(
+ in srgb,
+ rgb(var(--primary-base-800, 30 30 30)),
+ var(--theme-background-1) 96%
+ );
+ --theme-background-3: color-mix(
+ in srgb,
+ rgb(var(--primary-base-800, 30 30 30)),
+ var(--theme-background-1) 90%
+ );
+ --theme-background-accent: #007d9c1f;
+ --theme-code-language-color-supersede: var(--theme-color-1);
+ --theme-code-languages-background-supersede: var(--theme-background-1);
+ --theme-border-color: color-mix(
+ in srgb,
+ var(--theme-color-1),
+ transparent calc(100% - 100% * 0.08)
+ );
+
+ --theme-color-green: #0a6355;
+ --theme-color-red: #dc1b19;
+ --theme-color-yellow: #ffc90d;
+ --theme-color-blue: rgb(var(--primary-color-500, 52 109 219));
+ --theme-color-orange: #ff8d4d;
+ --theme-color-purple: #8250df;
+
+ --theme-scrollbar-color: rgba(255, 255, 255, 0.24);
+ --theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
+}
+.dark .scalar-modal-layout,
+.dark .scalar {
+ --theme-color-1: color-mix(
+ in srgb,
+ rgb(var(--primary-base-700, 70 70 70)),
+ rgb(var(--light-base, 255 255 255)) 100%
+ );
+ --theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64));
+ --theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
+ --theme-color-accent: #50b7e0;
+
+ --theme-background-1: rgb(var(--dark-base, 22 22 22));
+ --theme-background-2: color-mix(
+ in srgb,
+ rgb(var(--primary-base-200, 200 200 200)),
+ var(--theme-background-1) 92%
+ );
+ --theme-background-3: color-mix(
+ in srgb,
+ rgb(var(--primary-base-200, 200 200 200)),
+ var(--theme-background-1) 88%
+ );
+ --theme-background-accent: #8ab4f81f;
+ --theme-code-languages-background-supersede: var(--theme-background-1);
+ --theme-border-color: color-mix(
+ in srgb,
+ var(--theme-color-1),
+ transparent calc(100% - 100% * 0.08)
+ );
+
+ --theme-color-green: #56b6c2;
+ --theme-color-red: rgb(245 124 97);
+ --theme-color-yellow: #edbe20;
+ --theme-color-blue: rgb(var(--primary-color-400, 93 138 226));
+ --theme-color-orange: #d19a66;
+ --theme-color-purple: #5203d1;
+
+ --theme-scrollbar-color: rgba(0, 0, 0, 0.18);
+ --theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
+}
+.scalar-modal-layout,
+.scalar {
+ --theme-font: initial;
+ --theme-font-code: var(--font-mono);
+
+ --theme-paragraph: 16px;
+ --theme-small: 14px;
+ --theme-mini: 13px;
+ --theme-micro: 12px;
+
+ --theme-bold: 600;
+ --theme-semibold: 500;
+ --theme-regular: 400;
+
+ /* Font sizes for interactive applications (not rendered text content) */
+ --theme-font-size-1: 24px;
+ --theme-font-size-2: 16px;
+ --theme-font-size-3: 14px;
+ --theme-font-size-4: 13px;
+ --theme-font-size-5: 12px;
+
+ --theme-line-height-1: 32px;
+ --theme-line-height-2: 24px;
+ --theme-line-height-3: 20px;
+ --theme-line-height-4: 18px;
+ --theme-line-height-5: 16px;
+
+ --scalar-app-header-height: 35px;
+}
+.scalar input::placeholder {
+ color: var(--theme-color-3);
+}
+.scalar .scalar-app-header {
+ width: 100%;
+ z-index: 1000;
+ padding: 6px 12px 6px 12px;
+ border-radius: 0.25rem 0.25rem 0 0;
+ font-size: 14px;
+ height: var(--scalar-app-header-height);
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ gap: 6px;
+}
+.scalar .scalar-api-client {
+ max-height: calc(100dvh - (100px + var(--scalar-app-header-height))) !important;
+ border-radius: 8px;
+}
+.scalar-api-client__close {
+ appearance: none;
+ border: none;
+ outline: none;
+ display: flex;
+ align-items: center;
+ background: transparent;
+ color: var(--theme-color-1);
+ font-size: var(--theme-small);
+ font-weight: var(--theme-semibold);
+}
+.scalar-api-client__close:hover {
+ cursor: pointer;
+}
+.scalar .scalar-app {
+ background: var(--theme-background-3);
+ height: calc(100dvh - 100px);
+ max-width: 1280px;
+ width: 100%;
+ margin: auto;
+ opacity: 0;
+ animation: scalarapiclientfadein 0.35s forwards;
+ z-index: 1002;
+ position: relative;
+ overflow: hidden;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+}
+@keyframes scalarapiclientfadein {
+ from {
+ transform: translate3d(0, 20px, 0) scale(0.985);
+ opacity: 0;
+ }
+ to {
+ transform: translate3d(0, 0, 0) scale(1);
+ opacity: 1;
+ }
+}
+.scalar .scalar-app-exit {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.62);
+ transition: all 0.3s ease-in-out;
+ z-index: 1000;
+ cursor: pointer;
+ animation: scalardrawerexitfadein 0.35s forwards;
+}
+.scalar .scalar-app-exit:before {
+ content: '\00d7';
+ font-family: sans-serif;
+ position: absolute;
+ top: 0;
+ right: 0;
+ font-size: 30px;
+ font-weight: 100;
+ line-height: 50px;
+ right: 12px;
+ text-align: center;
+ color: white;
+ opacity: 0.6;
+}
+.scalar .scalar-app-exit:hover:before {
+ opacity: 1;
+}
+@keyframes scalardrawerexitfadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+.scalar-container {
+ overflow: hidden;
+ visibility: visible;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1001;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.scalar .url-form-input {
+ min-height: auto !important;
+}
+
+.scalar .scalar-container {
+ line-height: normal;
+}
+.scalar .scalar-app-header span {
+ color: var(--theme-color-3);
+}
+.scalar .scalar-app-header a {
+ color: var(--theme-color-1);
+}
+.scalar .scalar-app-header a:hover {
+ text-decoration: underline;
+}
+.scalar-activate {
+ width: fit-content;
+ margin: 0px 0.75rem 0.75rem auto;
+ line-height: 24px;
+ font-size: 0.75rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.scalar-activate-button {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ color: var(--theme-color-blue);
+ appearance: none;
+ outline: none;
+ border: none;
+ background: transparent;
+}
+.scalar-activate-button {
+ padding: 0 0.5rem;
+}
+.scalar-activate:hover .scalar-activate-button {
+ background: var(--theme-background-3);
+ border-radius: 3px;
+}
+
+.scalar-app-loading {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.scalar .request-method {
+ white-space: nowrap;
+}
+/* Use :where to lower specificity to 0 */
+.scalar .custom-scroll {
+ overflow-y: auto;
+ scrollbar-color: transparent transparent;
+ scrollbar-width: thin;
+ -webkit-overflow-scrolling: touch;
+}
+@supports (-moz-appearance: none) {
+ .scalar .custom-scroll {
+ padding-right: 12px;
+ }
+}
+.scalar .custom-scroll:hover {
+ scrollbar-color: rgba(0, 0, 0, 0.24) transparent;
+}
+.dark .scalar .custom-scroll:hover {
+ scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
+}
+.scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
+ background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color));
+ background-clip: content-box;
+ border: 3px solid transparent;
+}
+.scalar .custom-scroll::-webkit-scrollbar-thumb:active {
+ background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
+ background-clip: content-box;
+ border: 3px solid transparent;
+}
+.scalar .custom-scroll::-webkit-scrollbar-corner {
+ background: transparent;
+}
+.scalar .custom-scroll::-webkit-scrollbar {
+ height: 12px;
+ width: 12px;
+}
+.scalar .custom-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+.scalar .custom-scroll::-webkit-scrollbar-thumb {
+ border-radius: 20px;
+ background: transparent;
+ background-clip: content-box;
+ border: 3px solid transparent;
+}
+@media (pointer: coarse) {
+ .scalar .custom-scroll {
+ padding-right: 12px;
+ }
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 9c5b6277b..c159ee8c4 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -29,7 +29,7 @@ import { waitUntil } from './lib/waitUntil';
export const config = {
matcher:
- '/((?!_next/static|_next/image|~gitbook/revalidate|~gitbook/image|~gitbook/monitoring|~gitbook/static).*)',
+ '/((?!_next/static|_next/image|~gitbook/revalidate|~gitbook/image|~gitbook/monitoring|~gitbook/static|~scalar/proxy).*)',
skipTrailingSlashRedirect: true,
};