mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
Add missing link reference to OpenAPI models (#4416)
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/openapi-parser": patch
|
||||||
|
"@gitbook/react-openapi": patch
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add missing link reference to OpenAPI models
|
||||||
@@ -52,6 +52,7 @@ export function getOpenAPIContext(args: {
|
|||||||
check: <Icon icon="check" />,
|
check: <Icon icon="check" />,
|
||||||
lock: <Icon icon="lock" />,
|
lock: <Icon icon="lock" />,
|
||||||
mcp: <Icon icon="mcp" />,
|
mcp: <Icon icon="mcp" />,
|
||||||
|
hashtag: <Icon icon="hashtag" />,
|
||||||
},
|
},
|
||||||
renderCodeBlock: (codeProps) => (
|
renderCodeBlock: (codeProps) => (
|
||||||
<PlainCodeBlock
|
<PlainCodeBlock
|
||||||
|
|||||||
@@ -865,6 +865,30 @@ body:has(.openapi-select-popover) {
|
|||||||
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
|
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model name + its hover hash-link, inline. Carries the anchor id / deep-link scroll target. */
|
||||||
|
.openapi-schemas-model-title {
|
||||||
|
@apply flex items-center gap-2 min-w-0;
|
||||||
|
/* Offset by the trigger's p-5 so a deep-link lands the model's top edge, not its title, below the header. */
|
||||||
|
scroll-margin-top: calc(var(--content-scroll-margin) + 1.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-model-title-name {
|
||||||
|
@apply min-w-0 truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-anchor-link {
|
||||||
|
@apply shrink-0 flex items-center opacity-0 transition-opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-disclosure:hover .openapi-schemas-anchor-link,
|
||||||
|
.openapi-schemas-anchor-link:focus-visible {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-anchor-link svg {
|
||||||
|
@apply size-3 text-tint-subtle hover:text-tint-strong;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
||||||
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
||||||
}
|
}
|
||||||
@@ -965,7 +989,7 @@ body:has(.openapi-select-popover) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-trigger[aria-expanded="true"] svg {
|
.openapi-disclosure-trigger[aria-expanded="true"] .openapi-disclosure-trigger-label svg {
|
||||||
@apply rotate-45;
|
@apply rotate-45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { getOpenAPISchemaAnchorId } from './schemas';
|
||||||
|
|
||||||
|
describe('getOpenAPISchemaAnchorId', () => {
|
||||||
|
it('lowercases a simple name', () => {
|
||||||
|
expect(getOpenAPISchemaAnchorId('User')).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces non-alphanumeric runs with a single dash', () => {
|
||||||
|
expect(getOpenAPISchemaAnchorId('Pet Store')).toBe('pet-store');
|
||||||
|
expect(getOpenAPISchemaAnchorId('User.Profile_v2')).toBe('user-profile-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading and trailing dashes', () => {
|
||||||
|
expect(getOpenAPISchemaAnchorId('#User#')).toBe('user');
|
||||||
|
expect(getOpenAPISchemaAnchorId(' spaced ')).toBe('spaced');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,19 @@ import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
|
|||||||
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
|
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
|
||||||
import type { OpenAPISchema } from './types';
|
import type { OpenAPISchema } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stable, readable anchor id for a model/schema name (e.g. `User` -> `user`).
|
||||||
|
* Shared between the rendered disclosure and the page's table of contents so their ids match.
|
||||||
|
* Names are usually unique within a spec; a page heading could in theory slug to the same value.
|
||||||
|
*/
|
||||||
|
export function getOpenAPISchemaAnchorId(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract selected schemas from the OpenAPI document.
|
* Extract selected schemas from the OpenAPI document.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import clsx from 'classnames';
|
import clsx from 'classnames';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Disclosure, DisclosurePanel } from 'react-aria-components';
|
import { Button, Disclosure, DisclosurePanel } from 'react-aria-components';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,10 +14,36 @@ export function OpenAPIDisclosure(props: {
|
|||||||
label: string | ((isExpanded: boolean) => string);
|
label: string | ((isExpanded: boolean) => string);
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
|
/**
|
||||||
|
* Anchor id used as a deep-link target. When set, the disclosure expands and scrolls the
|
||||||
|
* element carrying this id into view once the URL hash matches it.
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { icon, header, label, children, className, defaultExpanded = false } = props;
|
const { icon, header, label, children, className, defaultExpanded = false, id } = props;
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
// Expand and scroll into view when the URL hash points to this disclosure.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFromHash = () => {
|
||||||
|
if (window.location.hash.slice(1) !== id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsExpanded(true);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
openFromHash();
|
||||||
|
window.addEventListener('hashchange', openFromHash);
|
||||||
|
return () => window.removeEventListener('hashchange', openFromHash);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure
|
<Disclosure
|
||||||
className={clsx('openapi-disclosure', className)}
|
className={clsx('openapi-disclosure', className)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface OpenAPIClientContext {
|
|||||||
check: React.ReactNode;
|
check: React.ReactNode;
|
||||||
lock: React.ReactNode;
|
lock: React.ReactNode;
|
||||||
mcp: React.ReactNode;
|
mcp: React.ReactNode;
|
||||||
|
hashtag: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,20 +8,45 @@ import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
|
|||||||
import { Section } from '../StaticSection';
|
import { Section } from '../StaticSection';
|
||||||
import type { OpenAPIClientContext } from '../context';
|
import type { OpenAPIClientContext } from '../context';
|
||||||
import { getDisclosureLabel } from '../getDisclosureLabel';
|
import { getDisclosureLabel } from '../getDisclosureLabel';
|
||||||
|
import { tString } from '../translate';
|
||||||
|
|
||||||
export function OpenAPISchemaItem(props: {
|
export function OpenAPISchemaItem(props: {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
schema: OpenAPIV3.SchemaObject;
|
schema: OpenAPIV3.SchemaObject;
|
||||||
context: OpenAPIClientContext;
|
context: OpenAPIClientContext;
|
||||||
}) {
|
}) {
|
||||||
const { schema, context, name } = props;
|
const { schema, context, name, id } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpenAPIDisclosure
|
<OpenAPIDisclosure
|
||||||
className="openapi-schemas-disclosure"
|
className="openapi-schemas-disclosure"
|
||||||
key={name}
|
key={name}
|
||||||
|
id={id}
|
||||||
icon={context.icons.plus}
|
icon={context.icons.plus}
|
||||||
header={name}
|
header={
|
||||||
|
id ? (
|
||||||
|
<span id={id} className="openapi-schemas-model-title">
|
||||||
|
<span className="openapi-schemas-model-title-name">{name}</span>
|
||||||
|
<a
|
||||||
|
href={`#${id}`}
|
||||||
|
className="openapi-schemas-anchor-link"
|
||||||
|
aria-label={tString(context.translation, 'direct_link_to_model', name)}
|
||||||
|
// Keep the click from toggling the disclosure or scrolling; just set the URL.
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.history.pushState(null, '', `#${id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{context.icons.hashtag}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
)
|
||||||
|
}
|
||||||
label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })}
|
label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })}
|
||||||
defaultExpanded={context.expandAllModelSections}
|
defaultExpanded={context.expandAllModelSections}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getOpenAPISchemaAnchorId } from '@gitbook/openapi-parser';
|
||||||
import clsx from 'classnames';
|
import clsx from 'classnames';
|
||||||
import { OpenAPIExample } from '../OpenAPIExample';
|
import { OpenAPIExample } from '../OpenAPIExample';
|
||||||
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
|
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
|
||||||
@@ -41,7 +42,9 @@ export function OpenAPISchemas(props: {
|
|||||||
const title = `The ${firstSchema.name} object`;
|
const title = `The ${firstSchema.name} object`;
|
||||||
return (
|
return (
|
||||||
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
|
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
|
||||||
<div className="openapi-summary" id={context.id}>
|
{/* The heading rendered below already carries the anchor id; a second id here would
|
||||||
|
win the fragment target and scroll to the wrong margin. */}
|
||||||
|
<div className="openapi-summary">
|
||||||
{context.renderHeading({
|
{context.renderHeading({
|
||||||
title,
|
title,
|
||||||
deprecated: Boolean(firstSchema.schema.deprecated),
|
deprecated: Boolean(firstSchema.schema.deprecated),
|
||||||
@@ -88,6 +91,7 @@ export function OpenAPISchemas(props: {
|
|||||||
return (
|
return (
|
||||||
<OpenAPISchemaItem
|
<OpenAPISchemaItem
|
||||||
key={name}
|
key={name}
|
||||||
|
id={getOpenAPISchemaAnchorId(name)}
|
||||||
name={name}
|
name={name}
|
||||||
context={clientContext}
|
context={clientContext}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const de = {
|
|||||||
or: 'oder',
|
or: 'oder',
|
||||||
and: 'und',
|
and: 'und',
|
||||||
possible_values: 'Mögliche Werte',
|
possible_values: 'Mögliche Werte',
|
||||||
|
direct_link_to_model: 'Direktlink zu ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const en = {
|
|||||||
properties: 'Properties',
|
properties: 'Properties',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
and: 'and',
|
and: 'and',
|
||||||
|
direct_link_to_model: 'Direct link to ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const es = {
|
|||||||
or: 'o',
|
or: 'o',
|
||||||
and: 'y',
|
and: 'y',
|
||||||
possible_values: 'Valores posibles',
|
possible_values: 'Valores posibles',
|
||||||
|
direct_link_to_model: 'Enlace directo a ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const fr = {
|
|||||||
or: 'ou',
|
or: 'ou',
|
||||||
and: 'et',
|
and: 'et',
|
||||||
possible_values: 'Valeurs possibles',
|
possible_values: 'Valeurs possibles',
|
||||||
|
direct_link_to_model: 'Lien direct vers ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const ja = {
|
|||||||
or: 'または',
|
or: 'または',
|
||||||
and: 'および',
|
and: 'および',
|
||||||
possible_values: '可能な値',
|
possible_values: '可能な値',
|
||||||
|
direct_link_to_model: '${1} への直接リンク',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const nl = {
|
|||||||
or: 'of',
|
or: 'of',
|
||||||
and: 'en',
|
and: 'en',
|
||||||
possible_values: 'Mogelijke waarden',
|
possible_values: 'Mogelijke waarden',
|
||||||
|
direct_link_to_model: 'Directe link naar ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const no = {
|
|||||||
or: 'eller',
|
or: 'eller',
|
||||||
and: 'og',
|
and: 'og',
|
||||||
possible_values: 'Mulige verdier',
|
possible_values: 'Mulige verdier',
|
||||||
|
direct_link_to_model: 'Direktelenke til ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const pt_br = {
|
|||||||
or: 'ou',
|
or: 'ou',
|
||||||
and: 'e',
|
and: 'e',
|
||||||
possible_values: 'Valores possíveis',
|
possible_values: 'Valores possíveis',
|
||||||
|
direct_link_to_model: 'Link direto para ${1}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export const zh = {
|
|||||||
or: '或',
|
or: '或',
|
||||||
and: '和',
|
and: '和',
|
||||||
possible_values: '可能的值',
|
possible_values: '可能的值',
|
||||||
|
direct_link_to_model: '${1} 的直接链接',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user