Files
supabase/apps/docs/features/docs/Reference.ui.tsx
T
Anthony Lio e6bd407e88 fix(docs): collapsible details component (#50065)
## What kind of change does this PR introduce?

nitpick ui bug fix in docs of the collapsible details component +
commentary

## What is the current behavior?

1. data / response / notes collapsibles on reference pages grow taller
when you expand them + also get double padding: the panel pads the
content, and the code block pads itself again inside it

2. commentary that follows a snippet in the example column renders
unstyled, since that column has no prose context. it comes out larger
than the description column and inline code stays as plain text

## What is the new behavior?

├ adds `CodeBlock` a `compact` variant that get appropriate styling when
used within collapsible

| state | preview |
| -------|------|
| before | <video
src="https://github.com/user-attachments/assets/8b70e1c6-9e0e-4371-a2b2-eb4a3d580247"
/> |
| after | <video
src="https://github.com/user-attachments/assets/1e5cdd46-ce7d-4d2d-ac6a-6da680df37e5"
/> |

├ wraps example column in prose so trailing commentary matches the
description font size + inline code styling

| state | preview |
| -------|------|
| before | <img width="1142" height="404" alt="image"
src="https://github.com/user-attachments/assets/8d0f1ac0-087d-47f7-b35d-b8798d589fdb"
/> |
| after | <img width="1142" height="404" alt="image"
src="https://github.com/user-attachments/assets/b9ba4202-a8dd-407d-b535-f34d83f5b24b"
/> |

## Test
- visits `/docs/reference/javascript/using-filters-gt`
- visits `/docs/reference/server/middleware-withsupabaseadminclient`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Documentation code blocks can now be displayed in a compact format
without borders or extra spacing.
- Reference documentation supports customizing code block presentation.
- **Style**
- Improved formatting for example content, including prose wrapping,
spacing, and code block margins.
- Refined collapsible documentation sections with clearer spacing, hover
and focus states, and open/close animations.
- Code-only collapsible content now uses a more compact layout, while
text content receives consistent typography and padding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-10 10:54:21 +03:00

964 lines
30 KiB
TypeScript

import ApiSchema from '~/components/ApiSchema'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { MDXRemoteRefs } from '~/features/docs/Reference.mdx'
import type {
CustomTypePropertyType,
FunctionParameterType,
MethodTypes,
TypeDetails,
} from '~/features/docs/Reference.typeSpec'
import { TYPESPEC_NODE_ANONYMOUS } from '~/features/docs/Reference.typeSpec'
import { ReferenceSectionWrapper } from '~/features/docs/Reference.ui.client'
import { normalizeMarkdown } from '~/features/docs/Reference.utils'
import { isEqual } from 'lodash-es'
import { ChevronRight, XCircle } from 'lucide-react'
import { fromMarkdown } from 'mdast-util-from-markdown'
import type { HTMLAttributes, PropsWithChildren } from 'react'
import ReactMarkdown from 'react-markdown'
import { Badge, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
import { getTypeDisplayFromSchema, IApiEndPoint, type ISchema } from './Reference.api.utils'
import { API_REFERENCE_REQUEST_BODY_SCHEMA_DATA_ATTRIBUTES } from './Reference.ui.shared'
interface SectionProps extends PropsWithChildren {
link: string
slug?: string
columns?: 'single' | 'double'
}
function Section({ slug, link, columns = 'single', children }: SectionProps) {
const singleColumn = columns === 'single'
return (
<ReferenceSectionWrapper
id={slug || ''}
link={link}
className={cn(
'grid grid-cols-[1fr] gap-x-16 gap-y-8',
singleColumn ? 'max-w-3xl' : '@4xl/article:grid-cols-[1fr_1fr]'
)}
>
{children}
</ReferenceSectionWrapper>
)
}
function Details({ children }: PropsWithChildren) {
/*
* `min-w` is necessary because these are used as grid children, which have
* default `min-w-auto`
*/
return <div className="w-full min-w-full">{children}</div>
}
function Examples({ children }: PropsWithChildren) {
/*
* `min-w` is necessary because these are used as grid children, which have
* default `min-w-auto`
*/
return <div className="w-full min-w-full sticky top-32 self-start">{children}</div>
}
function EducationSection({ children, slug, ...props }: SectionProps) {
return (
<ReferenceSectionWrapper id={slug || ''} className={'prose max-w-none'} {...props}>
{children}
</ReferenceSectionWrapper>
)
}
interface EducationRowProps extends PropsWithChildren {
className?: string
}
function EducationRow({ className, children }: EducationRowProps) {
return <div className={cn('grid lg:grid-cols-2 gap-8 lg:gap-16', className)}>{children}</div>
}
export const RefSubLayout = {
Section,
EducationSection,
EducationRow,
Details,
Examples,
}
interface StickyHeaderProps {
title?: React.ReactNode | string
monoFont?: boolean
className?: string
}
export function StickyHeader({ title, monoFont = false, className }: StickyHeaderProps) {
return (
<h2
tabIndex={-1} // For programmatic focus on auto-scroll to section
className={cn(
'sticky top-0 z-1',
'w-full',
// Enough padding to cover the background when stuck to the top,
// then readjust with negative margin to prevent it looking too
// spaced-out in regular position
'pt-[calc(var(--header-height)+1rem)] -mt-[calc(var(--header-height)+1rem-2px)]',
// Same for bottom
'pb-8 -mb-3',
'bg-linear-to-b from-background from-85% to-transparent to-100%',
'text-2xl font-medium text-foreground',
'scroll-mt-[calc(var(--header-height)+1rem)]',
'focus:outline-hidden',
monoFont && 'font-mono',
className
)}
>
{title}
</h2>
)
}
export function CollapsibleDetails({ title, content }: { title: string; content: string }) {
const blocks = fromMarkdown(content).children
const isCodeOnly = blocks.length === 1 && blocks[0].type === 'code'
return (
<Collapsible
className={cn(
'overflow-hidden',
'border border-default rounded-lg bg-surface-100',
'has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-2',
'has-[:focus-visible]:outline-offset-[-2px] has-[:focus-visible]:outline-[var(--ring)]'
)}
>
<CollapsibleTrigger
className={cn(
'group/trigger',
'w-full min-h-8',
'px-2 py-1.5',
'flex items-center gap-2',
'text-xs text-foreground-light',
'cursor-pointer hover:bg-surface-200 hover:text-foreground',
'focus-visible:outline-none',
'transition-[background-color,color] duration-150 ease-out'
)}
>
{title}
<ChevronRight
size={12}
strokeWidth={2}
aria-hidden
className="ms-auto shrink-0 text-foreground-muted group-data-open/trigger:rotate-90 transition-transform duration-200 ease-out motion-reduce:transition-none"
/>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'overflow-hidden',
'data-open:animate-collapsible-down data-closed:animate-collapsible-up',
'motion-reduce:animate-none'
)}
>
<div
className={cn(
'border-t border-default',
'prose max-w-none text-sm',
!isCodeOnly && 'px-4 py-3 [&_:where(p,li)]:text-sm [&_:where(p,li)]:leading-6'
)}
>
<MDXRemoteRefs
source={content}
codeBlockProps={isCodeOnly ? { compact: true } : undefined}
/>
</div>
</CollapsibleContent>
</Collapsible>
)
}
export function FnParameterDetails({
parameters,
altParameters,
className,
}: {
parameters: Array<object> | undefined
altParameters?: Array<Array<FunctionParameterType>>
className?: string
}) {
if (!parameters || parameters.length === 0) return
const combinedParameters = altParameters
? mergeAlternateParameters(parameters, altParameters)
: parameters
return (
<div className={className ?? ''}>
<h3 className="mb-3 text-base text-foreground">Parameters</h3>
<ul>
{combinedParameters.map((parameter, index) => (
<li key={index} className="border-t last-of-type:border-b py-5 flex flex-col gap-3">
<ParamOrTypeDetails paramOrType={parameter} />
</li>
))}
</ul>
</div>
)
}
interface SubContent {
name: string
isOptional?: boolean | 'NA' // not applicable
type?: string
description?: string
subContent: Array<SubContent>
}
function ParamOrTypeDetails({ paramOrType }: { paramOrType: object }) {
if (!('name' in paramOrType)) return
const description: string =
'description' in paramOrType
? (paramOrType.description as string)
: isFromTypespec(paramOrType)
? (paramOrType.comment?.shortText ?? '')
: ''
const subContent =
'subContent' in paramOrType
? (paramOrType.subContent as Array<SubContent>)
: isFromTypespec(paramOrType)
? getSubDetails(paramOrType)
: undefined
const defaultOpen = isDefaultExpanded(paramOrType)
return (
<>
<div className="flex flex-wrap items-baseline gap-3">
<span className="font-mono text-sm font-medium text-foreground">
{paramOrType.name === TYPESPEC_NODE_ANONYMOUS
? '[Anonymous]'
: (paramOrType.name as string)}
</span>
{'isOptional' in paramOrType && paramOrType.isOptional === true ? (
<Badge variant="default">Optional</Badge>
) : 'isOptional' in paramOrType && paramOrType.isOptional === false ? (
<Badge variant="warning">Required</Badge>
) : null}
{/* @ts-ignore */}
{paramOrType?.comment?.tags?.some((tag) => tag.tag === 'deprecated') && (
<span className="text-xs text-warning">Deprecated</span>
)}
<span className="text-xs text-foreground-muted">{getTypeName(paramOrType)}</span>
</div>
{description && (
<div className="prose text-sm">
<MDXRemoteBase source={description} customPreprocess={normalizeMarkdown} />
</div>
)}
{subContent && subContent.length > 0 && (
<TypeSubDetails details={subContent} defaultOpen={defaultOpen || false} />
)}
</>
)
}
export function ReturnTypeDetails({ returnType }: { returnType: MethodTypes['ret'] }) {
// These custom names that aren't defined aren't particularly meaningful, so
// just don't display them.
const isNameOnlyType = returnType?.type?.type === 'nameOnly'
if (isNameOnlyType) return
const subContent = getSubDetails(returnType)
const isDefaultOpen = returnType ? isDefaultExpanded(returnType) : false
return (
<div>
<h3 className="mb-3 text-base text-foreground">Return Type</h3>
<div className="border-t border-b py-5 flex flex-col gap-3">
<div className="text-xs text-foreground-muted">
{returnType ? getTypeName(returnType) : ''}
</div>
{returnType?.comment?.shortText && (
<div className="prose text-sm">
<MDXRemoteBase
source={returnType?.comment?.shortText}
customPreprocess={normalizeMarkdown}
/>
</div>
)}
{subContent && subContent.length > 0 && (
<TypeSubDetails defaultOpen={isDefaultOpen || false} details={subContent} />
)}
</div>
</div>
)
}
function TypeSubDetails({
details,
className,
defaultOpen = false,
}: {
details: Array<SubContent> | Array<CustomTypePropertyType> | Array<TypeDetails>
className?: string
defaultOpen?: boolean
}) {
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger
className={cn(
'group',
'w-fit rounded-full',
'px-5 py-1',
'border border-default',
'flex items-center gap-2',
'text-left text-sm text-foreground-light',
'hover:bg-surface-100',
'data-open:w-full',
'data-open:rounded-b-none data-open:rounded-tl-lg data-open:rounded-tr-lg',
'transition [transition-property:width,background-color]',
className
)}
>
<XCircle
size={14}
className={cn(
'text-foreground-muted',
'group-data-closed:rotate-45',
'transition-transform'
)}
/>
Details
</CollapsibleTrigger>
<CollapsibleContent>
<ul className={cn('border-b border-x border-default', 'rounded-b-lg')}>
{details.map(
(detail: SubContent | CustomTypePropertyType | TypeDetails, index: number) => (
<li
key={index}
className={cn(
'px-5 py-3',
'border-t border-default first:border-t-0',
'flex flex-col gap-3'
)}
>
<ParamOrTypeDetails paramOrType={detail} />
</li>
)
)}
</ul>
</CollapsibleContent>
</Collapsible>
)
}
export function ApiSchemaParamDetails({ param }: { param: IApiEndPoint['parameters'][number] }) {
return (
<li className="border-t last-of-type:border-b py-5 flex flex-col gap-3">
<div className="flex flex-wrap items-baseline gap-3">
<span className="font-mono text-sm font-medium text-foreground break-all">
{param.name}
</span>
{param.required ? (
<Badge variant="warning">Required</Badge>
) : (
<Badge variant="default">Optional</Badge>
)}
{param.schema?.deprecated && <span className="text-xs text-warning">Deprecated</span>}
{param.schema && (
<span className="text-xs text-foreground-muted">
{getTypeDisplayFromSchema(param.schema)?.displayName ?? ''}
</span>
)}
</div>
{param.description && (
<div className="prose wrap-break-word text-sm">
<ReactMarkdown>{param.description}</ReactMarkdown>
</div>
)}
{param.schema && <ApiSchemaParamSubdetails schema={param.schema} />}
</li>
)
}
export function ApiOperationRequestBodyDetails({
requestBody,
}: {
requestBody: IApiEndPoint['requestBody']
}) {
const availableSchemes = Object.keys(requestBody?.content || {}) as Array<
'application/json' | 'application/x-www-form-urlencoded'
>
return (
<>
{availableSchemes.map((scheme, index) => (
<ApiOperationRequestBodyDetailsInternal
key={index}
schema={requestBody?.content?.[scheme]?.schema || ({} as ISchema)}
hidden={index > 0}
{...{
[API_REFERENCE_REQUEST_BODY_SCHEMA_DATA_ATTRIBUTES.KEY]: scheme,
}}
/>
))}
</>
)
}
interface ApiOperationRequestBodyDetailsInternalProps extends HTMLAttributes<HTMLUListElement> {
schema: ISchema
}
// Some specs have allOf/anyOf/oneOf as a single schema object instead of an
// array. Wrap it so it still renders instead of crashing or disappearing.
function asSchemaArray(value: unknown): Array<any> {
if (Array.isArray(value)) return value
if (value && typeof value === 'object') return [value]
return []
}
function ApiOperationRequestBodyDetailsInternal({
schema,
...props
}: ApiOperationRequestBodyDetailsInternalProps) {
if ('allOf' in schema) {
return (
<>
<span className="font-mono text-sm font-medium text-foreground">All of the following:</span>
{asSchemaArray(schema.allOf).map((option, index) => (
<ApiSchemaParamSubdetails key={index} schema={option} />
))}
</>
)
} else if ('anyOf' in schema) {
return (
<>
<span className="font-mono text-sm font-medium text-foreground">Any of the following:</span>
{asSchemaArray(schema.anyOf).map((option, index) => (
<ApiSchemaParamSubdetails key={index} schema={option} />
))}
</>
)
} else if ('oneOf' in schema) {
return (
<>
<span className="font-mono text-sm font-medium text-foreground">One of the following:</span>
{asSchemaArray(schema.oneOf).map((option, index) => (
<ApiSchemaParamSubdetails key={index} schema={option} />
))}
</>
)
} else if ('enum' in schema) {
return (
<span className="font-mono text-sm font-medium text-foreground">
{schema.enum.join(' | ')}
</span>
)
} else if (
schema.type === 'string' ||
schema.type === 'boolean' ||
schema.type === 'number' ||
schema.type === 'integer'
) {
return <span className="font-mono text-sm font-medium text-foreground">{schema.type}</span>
} else if (schema.type === 'array') {
const itemTypeDisplay = getTypeDisplayFromSchema(schema.items)
const displayName = itemTypeDisplay?.displayName ?? 'unknown'
return (
<>
<span className="font-mono text-sm font-medium text-foreground">{`Array of ${displayName}`}</span>
{schema.items &&
!(
'type' in schema.items &&
['string', 'boolean', 'number', 'integer'].includes(schema.items.type)
) && <ApiSchemaParamSubdetails className="mt-4" schema={schema.items} />}
</>
)
} else if (schema.type === 'object') {
return (
<ul {...props}>
{Object.keys(schema.properties || {})
.map((property) => ({
name: property,
required: schema.required?.includes(property) || false,
in: 'body' as const,
schema: schema.properties?.[property] || ({} as ISchema),
}))
.map((property, index) => (
<ApiSchemaParamDetails key={index} param={property} />
))}
</ul>
)
}
}
export function ApiSchemaParamSubdetails({
schema,
className,
}: {
schema: ISchema
className?: string
}) {
if (
!('enum' in schema) &&
'type' in schema &&
(schema.type === 'boolean' ||
((schema.type === 'number' || schema.type === 'integer') &&
!('minimum' in schema || 'maximum' in schema)) ||
(schema.type === 'string' &&
!('minLength' in schema || 'maxLength' in schema || 'pattern' in schema)) ||
(schema.type === 'array' &&
schema.items &&
'type' in schema.items &&
['boolean', 'number', 'integer', 'string', 'file'].includes(schema.items.type)))
) {
return null
}
const rawSubContent =
'enum' in schema
? schema.enum
: 'anyOf' in schema
? schema.anyOf
: 'oneOf' in schema
? schema.oneOf
: 'allOf' in schema
? schema.allOf
: 'type' in schema && schema.type === 'string'
? ['minLength', 'maxLength', 'pattern']
.filter((key) => key in schema)
.map((key) => ({
constraint: key,
value: schema[key],
}))
: 'type' in schema && (schema.type === 'number' || schema.type === 'integer')
? ['minimum', 'maximum']
.filter((key) => key in schema)
.map((key) => ({
constraint: key,
value: schema[key],
}))
: []
const subContent = asSchemaArray(rawSubContent)
return (
<Collapsible>
<CollapsibleTrigger
className={cn(
'group',
'w-fit rounded-full',
'px-5 py-1',
'border border-default',
'flex items-center gap-2',
'text-left text-sm text-foreground-light',
'hover:bg-surface-100',
'data-open:w-full',
'data-open:rounded-b-none data-open:rounded-tl-lg data-open:rounded-tr-lg',
'transition [transition-property:width,background-color]',
className
)}
>
<XCircle
size={14}
className={cn(
'text-foreground-muted',
'group-data-closed:rotate-45',
'transition-transform'
)}
/>
{'enum' in schema
? 'Accepted values'
: 'allOf' in schema || 'anyOf' in schema || 'oneOf' in schema
? 'Options'
: schema.type === 'array'
? 'Items'
: schema.type === 'object'
? 'Object schema'
: 'Details'}
</CollapsibleTrigger>
<CollapsibleContent>
{'type' in schema && schema.type === 'object' ? (
<div className={cn('border-b border-x border-default', 'rounded-b-lg')}>
<div className="p-5 border-b border-default">
<ApiSchema schema={schema} />
</div>
<ApiOperationRequestBodyDetailsInternal schema={schema} className="px-5" />
</div>
) : 'type' in schema &&
schema.type === 'array' &&
'items' in schema &&
schema.items &&
typeof schema.items === 'object' &&
'type' in schema.items &&
schema.items.type === 'object' ? (
<div className={cn('border-b border-x border-default', 'rounded-b-lg')}>
<div className="p-5 border-b border-default">
<ApiSchema schema={schema} />
</div>
<ApiOperationRequestBodyDetailsInternal schema={schema.items} className="px-5" />
</div>
) : (
<ul className={cn('border-b border-x border-default', 'rounded-b-lg')}>
{subContent.map((detail: any, index: number) => (
<li
key={index}
className={cn(
'px-5 py-3',
'border-t border-default first:border-t-0',
'flex flex-col gap-3'
)}
>
{'enum' in schema ? (
<span className="font-mono text-sm font-medium text-foreground">
{String(detail)}
</span>
) : 'type' in schema &&
(schema.type === 'string' ||
schema.type === 'number' ||
schema.type === 'integer') ? (
<span className="text-sm text-foreground flex items-baseline gap-2">
<span className="font-mono text-sm font-medium text-foreground">
{detail.constraint}
</span>
{detail.value}
</span>
) : 'anyOf' in schema || 'allOf' in schema || 'oneOf' in schema ? (
<ApiSchemaParamDetails
param={{ name: '', schema: detail, required: !detail.nullable, in: 'body' }}
/>
) : null}
</li>
))}
</ul>
)}
</CollapsibleContent>
</Collapsible>
)
}
/**
* Whether the param comes from overwritten params in the library spec file or
* directly from the type spec.
*
* We're cheating here, this isn't a full validation but rather just checking
* that it isn't overwritten.
*/
function isFromTypespec(parameter: object): parameter is MethodTypes['params'][number] {
return !('__overwritten' in parameter)
}
function getTypeName(parameter: object): string {
if (!('type' in parameter)) return ''
if (typeof parameter.type === 'string') {
return parameter.type
}
if (
typeof parameter.type !== 'object' ||
parameter.type === null ||
!('type' in parameter.type)
) {
return ''
}
const type = parameter.type
switch (type.type) {
case 'nameOnly':
return nameOrDefault(type, '')
case 'intrinsic':
return nameOrDefault(type, '')
case 'literal':
return 'value' in type ? (type.value === null ? 'null' : `"${type.value as string}"`) : ''
case 'record':
// Needs an extra level of wrapping to fake the wrapping parameter
// @ts-ignore
return `Record<${getTypeName({ type: type.keyType })}, ${getTypeName({ type: type.valueType })}>`
case 'object':
return nameOrDefault(type, 'object')
case 'function':
return 'function'
case 'promise':
// Needs an extra level of wrapping to fake the wrapping parameter
// @ts-ignore
return `Promise<${getTypeName({ type: type.awaited })}>`
case 'union':
return 'One of the following options'
case 'index signature':
// Needs an extra level of wrapping to fake the wrapping parameter
// @ts-ignore
return `{ [key: ${getTypeName({ type: type.keyType })}]: ${getTypeName({ type: type.valueType })} }`
case 'array':
// Needs an extra level of wrapping to fake the wrapping parameter
// @ts-ignore
const innerType = getTypeName({ type: type.elemType })
return innerType ? `Array<${innerType}>` : 'Array'
}
return ''
}
function nameOrDefault(node: object, fallback: string) {
return 'name' in node && node.name !== TYPESPEC_NODE_ANONYMOUS ? (node.name as string) : fallback
}
function getSubDetails(parentType: MethodTypes['params'][number] | MethodTypes['ret']) {
let subDetails: Array<any> = []
switch (parentType?.type?.type) {
case 'object':
subDetails = parentType?.type?.properties
break
case 'function':
subDetails = [
...(parentType?.type?.params?.length === 0
? []
: [
{
name: 'Parameters',
type: 'callback parameters',
isOptional: 'NA',
params: parentType?.type?.params?.map((param) => ({
...param,
isOptional: 'NA',
})),
},
]),
{ name: 'Return', type: parentType?.type?.ret?.type, isOptional: 'NA' },
]
break
// @ts-ignore -- Adding these fake types to take advantage of existing recursion
case 'callback parameters':
// @ts-ignore -- Adding these fake types to take advantage of existing recursion
subDetails = parentType.params
break
case 'union':
subDetails = parentType?.type?.subTypes?.map((subType, index) => ({
name: `Option ${index + 1}`,
type: { ...subType },
isOptional: 'NA',
}))
break
case 'promise':
if (parentType?.type?.awaited?.type === 'union') {
subDetails = parentType?.type?.awaited?.subTypes?.map((subType, index) => ({
name: `Option ${index + 1}`,
type: { ...subType },
isOptional: 'NA',
}))
} else if (
parentType?.type?.awaited?.type === 'object' &&
'properties' in parentType.type.awaited
) {
subDetails = (parentType.type.awaited as any).properties?.map((property) => ({
...property,
isOptional: 'NA',
}))
} else if (parentType?.type?.awaited?.type === 'array') {
subDetails = [
{
name: 'array element',
type: (parentType?.type?.awaited as any)?.elemType,
isOptional: 'NA',
},
]
}
break
case 'array':
if (parentType.type.elemType?.type === 'union') {
subDetails = parentType.type.elemType.subTypes.map((subType, index) => ({
name: `Option ${index + 1}`,
type: { ...subType },
isOptional: 'NA',
}))
}
if (parentType.type.elemType?.type === 'object') {
subDetails = parentType.type.elemType.properties
}
break
}
subDetails?.sort((a, b) => (a.isOptional === true ? 1 : 0) - (b.isOptional === true ? 1 : 0))
return subDetails
}
function mergeAlternateParameters(
parameters: Array<object>,
altParameters: Array<Array<FunctionParameterType>>
) {
const combinedParameters = parameters.map((parameter) => {
if (!isFromTypespec(parameter)) return parameter
const parameterWithoutType = { ...parameter }
if ('type' in parameterWithoutType) {
delete parameterWithoutType.type
}
for (const alternate of altParameters) {
const match = alternate.find((alternateParam) => {
const alternateWithoutType = { ...alternateParam }
if ('type' in alternateWithoutType) {
delete alternateWithoutType.type
}
return isEqual(parameterWithoutType, alternateWithoutType)
})
if (match) {
// @ts-ignore
parameter = applyParameterMergeStrategy(parameter, match)
}
}
return parameter
})
return combinedParameters
}
function applyParameterMergeStrategy(
parameter: Pick<FunctionParameterType, 'type'>,
alternateParameter: Pick<FunctionParameterType, 'type'>
) {
if (!alternateParameter.type) {
// Nothing to merge, abort
return parameter as FunctionParameterType
}
const clonedParameter = JSON.parse(JSON.stringify(parameter)) as FunctionParameterType
if (!clonedParameter.type) {
clonedParameter.type = alternateParameter.type
return clonedParameter
}
switch (clonedParameter.type.type) {
case 'nameOnly':
mergeIntoUnion()
break
case 'literal':
mergeIntoUnion()
break
case 'record':
mergeIntoUnion()
break
case 'union':
if (alternateParameter.type.type === 'union') {
// Both unions, merge them
for (const alternateSubType of alternateParameter.type.subTypes) {
if (
!clonedParameter.type.subTypes.some((subType) => isEqual(subType, alternateSubType))
) {
clonedParameter.type.subTypes.push(alternateSubType)
}
}
} else {
if (
!clonedParameter.type.subTypes.some((subType) =>
isEqual(subType, alternateParameter.type)
)
) {
clonedParameter.type.subTypes.push(alternateParameter.type)
}
}
break
case 'object':
if (alternateParameter.type.type === 'object') {
// Check if the base and alternate parameters have different sets of
// required properties. If so, they can't be merged without loss of
// meaning and have to be represented as a union.
const requiredOriginalProperties = new Set(
clonedParameter.type.properties.filter(
// @ts-ignore -- NA introduced as an additional flag for display logic
(property) => property.isOptional !== true && property.isOptional !== 'NA'
)
)
const requiredAlternateProperties = new Set(
alternateParameter.type.properties.filter(
// @ts-ignore -- NA introduced as an additional flag for display logic
(property) => property.isOptional !== true && property.isOptional !== 'NA'
)
)
if (requiredOriginalProperties.size !== requiredAlternateProperties.size) {
mergeIntoUnion()
break
}
const union = new Set([...requiredOriginalProperties, ...requiredAlternateProperties])
if (union.size !== requiredOriginalProperties.size) {
mergeIntoUnion()
break
}
const clonedParametersByName = new Map(
clonedParameter.type.properties.map((property) => [property.name, property])
)
const alternateParametersByName = new Map(
alternateParameter.type.properties.map((property) => [property.name, property])
)
for (const [key, alternateValue] of alternateParametersByName) {
if (clonedParametersByName.has(key)) {
clonedParametersByName.set(
key,
applyParameterMergeStrategy(clonedParametersByName.get(key)!, alternateValue)
)
} else {
clonedParametersByName.set(key, alternateValue)
}
}
clonedParameter.type.properties = [...clonedParametersByName.values()]
} else {
mergeIntoUnion()
}
}
return clonedParameter as FunctionParameterType
/*********
* Utils *
*********/
function mergeIntoUnion() {
if (alternateParameter.type?.type === 'union') {
const originalType = clonedParameter.type
if (
alternateParameter.type.subTypes.some((subType) => isEqual(subType, clonedParameter.type))
) {
clonedParameter.type = alternateParameter.type
} else {
clonedParameter.type = {
type: 'union',
subTypes: [originalType as TypeDetails, ...(alternateParameter.type?.subTypes || [])],
}
}
} else {
const originalType = parameter.type
if (!isEqual(originalType, alternateParameter.type)) {
clonedParameter.type = {
type: 'union',
subTypes: [originalType as TypeDetails, alternateParameter.type!],
}
}
}
}
}
function isDefaultExpanded(meta: object) {
return (
'type' in meta &&
typeof meta.type === 'object' &&
meta.type &&
'type' in meta.type &&
(meta.type.type == 'union' ||
(meta.type.type === 'promise' &&
'awaited' in meta.type &&
typeof meta.type.awaited === 'object' &&
meta.type.awaited &&
'type' in meta.type.awaited &&
meta.type.awaited.type === 'union'))
)
}