Add missing link reference to OpenAPI models (#4416)

This commit is contained in:
Nolann B.
2026-07-20 12:53:28 +02:00
committed by GitHub
parent 484cc11627
commit 03bbacf319
18 changed files with 134 additions and 6 deletions
+7
View File
@@ -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" />,
lock: <Icon icon="lock" />,
mcp: <Icon icon="mcp" />,
hashtag: <Icon icon="hashtag" />,
},
renderCodeBlock: (codeProps) => (
<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;
}
/* 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 {
@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;
}
@@ -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');
});
});
+13
View File
@@ -2,6 +2,19 @@ import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
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.
*/
@@ -1,7 +1,7 @@
'use client';
import clsx from 'classnames';
import type React from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Button, Disclosure, DisclosurePanel } from 'react-aria-components';
/**
@@ -14,10 +14,36 @@ export function OpenAPIDisclosure(props: {
label: string | ((isExpanded: boolean) => string);
className?: string;
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 {
const { icon, header, label, children, className, defaultExpanded = false } = props;
const { icon, header, label, children, className, defaultExpanded = false, id } = props;
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 (
<Disclosure
className={clsx('openapi-disclosure', className)}
+1
View File
@@ -17,6 +17,7 @@ export interface OpenAPIClientContext {
check: React.ReactNode;
lock: React.ReactNode;
mcp: React.ReactNode;
hashtag: React.ReactNode;
};
/**
@@ -8,20 +8,45 @@ import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
import { Section } from '../StaticSection';
import type { OpenAPIClientContext } from '../context';
import { getDisclosureLabel } from '../getDisclosureLabel';
import { tString } from '../translate';
export function OpenAPISchemaItem(props: {
id?: string;
name: string;
schema: OpenAPIV3.SchemaObject;
context: OpenAPIClientContext;
}) {
const { schema, context, name } = props;
const { schema, context, name, id } = props;
return (
<OpenAPIDisclosure
className="openapi-schemas-disclosure"
key={name}
id={id}
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 })}
defaultExpanded={context.expandAllModelSections}
>
@@ -1,3 +1,4 @@
import { getOpenAPISchemaAnchorId } from '@gitbook/openapi-parser';
import clsx from 'classnames';
import { OpenAPIExample } from '../OpenAPIExample';
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
@@ -41,7 +42,9 @@ export function OpenAPISchemas(props: {
const title = `The ${firstSchema.name} object`;
return (
<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({
title,
deprecated: Boolean(firstSchema.schema.deprecated),
@@ -88,6 +91,7 @@ export function OpenAPISchemas(props: {
return (
<OpenAPISchemaItem
key={name}
id={getOpenAPISchemaAnchorId(name)}
name={name}
context={clientContext}
schema={schema}
@@ -45,4 +45,5 @@ export const de = {
or: 'oder',
and: 'und',
possible_values: 'Mögliche Werte',
direct_link_to_model: 'Direktlink zu ${1}',
};
@@ -45,4 +45,5 @@ export const en = {
properties: 'Properties',
or: 'or',
and: 'and',
direct_link_to_model: 'Direct link to ${1}',
};
@@ -45,4 +45,5 @@ export const es = {
or: 'o',
and: 'y',
possible_values: 'Valores posibles',
direct_link_to_model: 'Enlace directo a ${1}',
};
@@ -45,4 +45,5 @@ export const fr = {
or: 'ou',
and: 'et',
possible_values: 'Valeurs possibles',
direct_link_to_model: 'Lien direct vers ${1}',
};
@@ -45,4 +45,5 @@ export const ja = {
or: 'または',
and: 'および',
possible_values: '可能な値',
direct_link_to_model: '${1} への直接リンク',
};
@@ -45,4 +45,5 @@ export const nl = {
or: 'of',
and: 'en',
possible_values: 'Mogelijke waarden',
direct_link_to_model: 'Directe link naar ${1}',
};
@@ -45,4 +45,5 @@ export const no = {
or: 'eller',
and: 'og',
possible_values: 'Mulige verdier',
direct_link_to_model: 'Direktelenke til ${1}',
};
@@ -45,4 +45,5 @@ export const pt_br = {
or: 'ou',
and: 'e',
possible_values: 'Valores possíveis',
direct_link_to_model: 'Link direto para ${1}',
};
@@ -45,4 +45,5 @@ export const zh = {
or: '或',
and: '和',
possible_values: '可能的值',
direct_link_to_model: '${1} 的直接链接',
};