diff --git a/.changeset/few-parts-lead.md b/.changeset/few-parts-lead.md new file mode 100644 index 000000000..12eb6e774 --- /dev/null +++ b/.changeset/few-parts-lead.md @@ -0,0 +1,7 @@ +--- +"@gitbook/openapi-parser": patch +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Add missing link reference to OpenAPI models diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 110eef2dd..90c962d22 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -52,6 +52,7 @@ export function getOpenAPIContext(args: { check: , lock: , mcp: , + hashtag: , }, renderCodeBlock: (codeProps) => ( .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; } diff --git a/packages/openapi-parser/src/schemas.test.ts b/packages/openapi-parser/src/schemas.test.ts new file mode 100644 index 000000000..dbd771ddb --- /dev/null +++ b/packages/openapi-parser/src/schemas.test.ts @@ -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'); + }); +}); diff --git a/packages/openapi-parser/src/schemas.ts b/packages/openapi-parser/src/schemas.ts index b753cceee..33c22827c 100644 --- a/packages/openapi-parser/src/schemas.ts +++ b/packages/openapi-parser/src/schemas.ts @@ -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. */ diff --git a/packages/react-openapi/src/OpenAPIDisclosure.tsx b/packages/react-openapi/src/OpenAPIDisclosure.tsx index 41b640ac1..fc5ff7fb2 100644 --- a/packages/react-openapi/src/OpenAPIDisclosure.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosure.tsx @@ -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 ( + {name} + e.stopPropagation()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + window.history.pushState(null, '', `#${id}`); + }} + > + {context.icons.hashtag} + + + ) : ( + name + ) + } label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })} defaultExpanded={context.expandAllModelSections} > diff --git a/packages/react-openapi/src/schemas/OpenAPISchemas.tsx b/packages/react-openapi/src/schemas/OpenAPISchemas.tsx index 5d71e2022..045b172ba 100644 --- a/packages/react-openapi/src/schemas/OpenAPISchemas.tsx +++ b/packages/react-openapi/src/schemas/OpenAPISchemas.tsx @@ -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 (
-
+ {/* The heading rendered below already carries the anchor id; a second id here would + win the fragment target and scroll to the wrong margin. */} +
{context.renderHeading({ title, deprecated: Boolean(firstSchema.schema.deprecated), @@ -88,6 +91,7 @@ export function OpenAPISchemas(props: { return (