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" />,
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} 的直接链接',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user