diff --git a/.changeset/healthy-houses-judge.md b/.changeset/healthy-houses-judge.md
new file mode 100644
index 0000000000..a93a415431
--- /dev/null
+++ b/.changeset/healthy-houses-judge.md
@@ -0,0 +1,6 @@
+---
+'@gitbook/react-openapi': patch
+'gitbook': patch
+---
+
+Update OpenAPI operation path design
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css
index d2142ecef8..3724dfb951 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css
@@ -264,14 +264,18 @@
gap: 6px;
}
.scalar-activate-button {
- @apply flex gap-1.5 items-center;
+ @apply flex gap-2 items-center;
@apply bg-primary-solid text-contrast-primary-solid hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none place-self-start;
@apply ring-1 ring-tint hover:ring-tint-hover;
@apply shadow-sm shadow-tint dark:shadow-tint-1 hover:shadow-md active:shadow-none;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:scale-105 active:scale-100 transition-all;
@apply grow-0 shrink-0 truncate;
- @apply text-sm px-2.5 py-1;
+ @apply text-[13px] px-2 py-0.5 font-mono font-medium [word-spacing:-2px];
+}
+
+.scalar-activate-button svg {
+ @apply size-2.5;
}
.scalar-app-loading {
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
index edaccf8156..baf61b081a 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
@@ -1,6 +1,6 @@
/* Layout Components */
.openapi-operation {
- @apply flex-1 flex flex-col gap-4 mb-14;
+ @apply flex-1 flex flex-col gap-8 mb-14;
}
.openapi-schemas {
@@ -17,7 +17,7 @@
}
.openapi-summary {
- @apply flex flex-col items-start justify-start gap-2;
+ @apply flex flex-col items-start justify-start gap-3;
}
.openapi-deprecated {
@@ -391,17 +391,30 @@
@apply flex flex-row items-center h-fit;
}
+.openapi-codesample-footer {
+ @apply flex w-full justify-end;
+}
+
/* Path */
.openapi-path {
- @apply flex items-center bg-transparent text-sm gap-2 p-2 border rounded-md border-tint-subtle;
+ @apply flex items-center text-sm gap-2 h-fit;
+}
+
+.openapi-path-variable {
+ @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:!content-none after:!content-none;
+}
+
+.openapi-path-server {
+ @apply text-tint hidden md:inline;
}
.openapi-path .openapi-method {
- @apply text-[0.813rem] m-0 px-1;
+ @apply text-[0.813rem] m-0 h-full items-center flex px-2;
}
.openapi-path-title {
- @apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong;
+ @apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong/10;
+ @apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -520,6 +533,10 @@
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
}
+.openapi-tabs-footer .openapi-markdown {
+ @apply text-[0.813rem] text-tint;
+}
+
/* Disclosure group */
.openapi-disclosure-group {
@apply border-b border-tint-subtle relative;
@@ -631,3 +648,37 @@
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema {
@apply p-2.5;
}
+
+.openapi-tooltip {
+ @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md font-medium px-1.5 py-0.5 shadow-sm text-[13px];
+}
+
+.openapi-tooltip svg {
+ @apply size-3 text-tint-strong;
+}
+
+.openapi-tooltip[data-entering] {
+ animation: tooltip-enter 0.2s ease-in-out forwards;
+}
+
+.openapi-tooltip[data-exiting] {
+ animation: tooltip-leave 0.2s ease-in-out forwards;
+}
+
+@keyframes tooltip-enter {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes tooltip-leave {
+ 0% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx
index 0f4161a2c5..bb6042e52a 100644
--- a/packages/react-openapi/src/OpenAPICodeSample.tsx
+++ b/packages/react-openapi/src/OpenAPICodeSample.tsx
@@ -1,4 +1,6 @@
+import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
+import { ScalarApiButton } from './ScalarApiButton';
import { StaticSection } from './StaticSection';
import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
@@ -79,6 +81,7 @@ export function OpenAPICodeSample(props: {
code: generator.generate(input),
syntax: generator.syntax,
}),
+ footer: ,
}));
// Use custom samples if defined
@@ -105,6 +108,7 @@ export function OpenAPICodeSample(props: {
code: sample.source,
syntax: sample.lang,
}),
+ footer: ,
}));
}
});
@@ -128,6 +132,30 @@ export function OpenAPICodeSample(props: {
);
}
+function OpenAPICodeSampleFooter(props: {
+ data: OpenAPIOperationData;
+ context: OpenAPIContextProps;
+}) {
+ const { data, context } = props;
+ const { method, path } = data;
+ const { specUrl } = context;
+ const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
+
+ if (hideTryItPanel) {
+ return null;
+ }
+
+ if (!validateHttpMethod(method)) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
[key: string]: string;
} {
@@ -169,3 +197,7 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
}
}
}
+
+function validateHttpMethod(method: string): method is OpenAPIV3.HttpMethods {
+ return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
+}
diff --git a/packages/react-openapi/src/OpenAPICopyButton.tsx b/packages/react-openapi/src/OpenAPICopyButton.tsx
new file mode 100644
index 0000000000..6cf93b3116
--- /dev/null
+++ b/packages/react-openapi/src/OpenAPICopyButton.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { useState } from 'react';
+import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-components';
+
+export function OpenAPICopyButton(
+ props: ButtonProps & {
+ value: string;
+ }
+) {
+ const { value } = props;
+ const { children, onPress, className } = props;
+ const [copied, setCopied] = useState(false);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleCopy = () => {
+ if (!value) return;
+ navigator.clipboard.writeText(value).then(() => {
+ setIsOpen(true);
+ setCopied(true);
+
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ });
+ };
+
+ return (
+
+
+
+
+ {copied ? 'Copied' : 'Copy to clipboard'}{' '}
+
+
+ );
+}
diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx
index 103a41b7fc..6c186f1a5f 100644
--- a/packages/react-openapi/src/OpenAPIOperation.tsx
+++ b/packages/react-openapi/src/OpenAPIOperation.tsx
@@ -35,6 +35,7 @@ export function OpenAPIOperation(props: {
title: operation.summary,
})
: null}
+
{operation.deprecated && Deprecated
}
@@ -49,7 +50,6 @@ export function OpenAPIOperation(props: {
) : null}
-
diff --git a/packages/react-openapi/src/OpenAPIPath.tsx b/packages/react-openapi/src/OpenAPIPath.tsx
index 528d561bdb..7fc24490eb 100644
--- a/packages/react-openapi/src/OpenAPIPath.tsx
+++ b/packages/react-openapi/src/OpenAPIPath.tsx
@@ -1,7 +1,6 @@
-import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
-import type React from 'react';
-import { ScalarApiButton } from './ScalarApiButton';
+import { OpenAPICopyButton } from './OpenAPICopyButton';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
+import { getDefaultServerURL } from './util/server';
/**
* Display the path of an operation.
@@ -10,63 +9,62 @@ export function OpenAPIPath(props: {
data: OpenAPIOperationData;
context: OpenAPIContextProps;
}) {
- const { data, context } = props;
- const { method, path } = data;
- const { specUrl } = context;
- const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
+ const { data } = props;
+ const { method, path, operation } = data;
+
+ const server = getDefaultServerURL(data.servers);
+ const formattedPath = formatPath(path);
return (
{method}
-
- {!hideTryItPanel && validateHttpMethod(method) && (
-
- )}
+
+
+ {server}
+ {formattedPath}
+
);
}
-function validateHttpMethod(method: string): method is OpenAPIV3_1.HttpMethods {
- return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
-}
-
-// Format the path to highlight placeholders
+/**
+ * Format the path by wrapping placeholders in
tags.
+ */
function formatPath(path: string) {
// Matches placeholders like {id}, {userId}, etc.
- const regex = /\{(\w+)\}/g;
+ const regex = /\{\s*(\w+)\s*\}|:\w+/g;
const parts: (string | React.JSX.Element)[] = [];
let lastIndex = 0;
- // Replace placeholders with tags
- path.replace(regex, (match, key, offset) => {
- parts.push(path.slice(lastIndex, offset));
- parts.push({`{${key}}`});
+ //Wrap the variables in tags and maintain either {variable} or :variable
+ path.replace(regex, (match, _, offset) => {
+ if (offset > lastIndex) {
+ parts.push(path.slice(lastIndex, offset));
+ }
+ parts.push(
+
+ {match}
+
+ );
lastIndex = offset + match.length;
return match;
});
- // Push remaining text after the last placeholder
- parts.push(path.slice(lastIndex));
-
- // Join parts with separators wrapped in
- const formattedPath = parts.reduce(
- (acc, part, index) => {
- if (typeof part === 'string' && index > 0 && part === '/') {
- acc.push(
-
- /
-
- );
- }
+ if (lastIndex < path.length) {
+ parts.push(path.slice(lastIndex));
+ }
- acc.push(part);
- return acc;
- },
- [] as (string | React.JSX.Element)[]
- );
+ const formattedPath = parts.map((part, index) => {
+ if (typeof part === 'string') {
+ return {part};
+ }
+ return part;
+ });
- return {formattedPath};
+ return formattedPath;
}
diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx
index d6bb785e74..fe7f2666e2 100644
--- a/packages/react-openapi/src/OpenAPIResponseExample.tsx
+++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx
@@ -1,4 +1,5 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
+import { Markdown } from './Markdown';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection';
import { generateSchemaExample } from './generateSchemaExample';
@@ -39,44 +40,40 @@ export function OpenAPIResponseExample(props: {
return Number(a) - Number(b);
});
- const tabs = responses
- .map(([key, responseObject]) => {
- const description = resolveDescription(responseObject);
-
- if (checkIsReference(responseObject)) {
- return {
- key: key,
- label: key,
- description,
- body: (
-
- ),
- };
- }
-
- if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
- return {
- key: key,
- label: key,
- description,
- body: ,
- };
- }
+ const tabs = responses.map(([key, responseObject]) => {
+ const description = resolveDescription(responseObject);
+ if (checkIsReference(responseObject)) {
return {
key: key,
label: key,
- description: resolveDescription(responseObject),
- body: ,
+ body: (
+
+ ),
+ footer: description ? : undefined,
};
- })
- .filter((val): val is { key: string; label: string; body: any; description: string } =>
- Boolean(val)
- );
+ }
+
+ if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
+ return {
+ key: key,
+ label: key,
+ body: ,
+ footer: description ? : undefined,
+ };
+ }
+
+ return {
+ key: key,
+ label: key,
+ body: ,
+ footer: description ? : undefined,
+ };
+ });
if (tabs.length === 0) {
return null;
diff --git a/packages/react-openapi/src/OpenAPITabs.tsx b/packages/react-openapi/src/OpenAPITabs.tsx
index 28bd5fd3fb..700dcfe817 100644
--- a/packages/react-openapi/src/OpenAPITabs.tsx
+++ b/packages/react-openapi/src/OpenAPITabs.tsx
@@ -3,14 +3,13 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
import { useEventCallback } from 'usehooks-ts';
-import { Markdown } from './Markdown';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
export type TabItem = {
key: Key;
label: string;
body: React.ReactNode;
- description?: string;
+ footer?: React.ReactNode;
};
type OpenAPITabsContextData = {
@@ -140,9 +139,19 @@ export function OpenAPITabsPanels() {
return (
{selectedTab.body}
- {selectedTab.description ? (
-
+ {selectedTab.footer ? (
+ {selectedTab.footer}
) : null}
);
}
+
+/**
+ * The OpenAPI Tabs panel footer component.
+ * This component should be used as a child of the OpenAPITabs component.
+ */
+function OpenAPITabsPanelFooter(props: { children: React.ReactNode }) {
+ const { children } = props;
+
+ return {children}
;
+}
diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx
index 28118447dc..04d7bb1aaa 100644
--- a/packages/react-openapi/src/ScalarApiButton.tsx
+++ b/packages/react-openapi/src/ScalarApiButton.tsx
@@ -27,14 +27,14 @@ export function ScalarApiButton(props: {
setIsOpen(true);
}}
>
-