Skip to content

Commit

Permalink
feat: add operation tag pages
Browse files Browse the repository at this point in the history
  • Loading branch information
HiDeoo committed Jan 8, 2025
1 parent 0423001 commit 7448b06
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 37 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-news-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'starlight-openapi': minor
---

Adds overview pages for operations grouped by tags defined with a `description` or `externalDocs` fields displaying the tag's information.
17 changes: 17 additions & 0 deletions packages/starlight-openapi/components/OperationTag.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import type { OperationTag } from '../libs/operation'
import ExternalDocs from './ExternalDocs.astro'
import Md from './Md.astro'
interface Props {
tag: OperationTag
}
const { tag } = Astro.props
---

<h2 id="overview">{tag.name}</h2>

{tag.description && <Md text={tag.description} />}
{tag.externalDocs && <ExternalDocs docs={tag.externalDocs} />}
23 changes: 20 additions & 3 deletions packages/starlight-openapi/components/Route.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getSchemaStaticPaths } from '../libs/route'
import { getPageProps } from '../libs/starlight'
import Operation from './operation/Operation.astro'
import OperationTag from './OperationTag.astro'
import Overview from './Overview.astro'
export const prerender = true
Expand All @@ -22,10 +23,26 @@ const { schema, type } = Astro.props
schema.document = await OpenAPIParser.dereference(schema.document)
const isOverview = type === 'overview'
const isOperationTag = type === 'operation-tag'
const title = isOverview ? 'Overview' : Astro.props.operation.title
const title = isOverview || isOperationTag ? 'Overview' : Astro.props.operation.title
---

<StarlightPage {...getPageProps(title, schema, isOverview ? undefined : Astro.props.operation)}>
{isOverview ? <Overview {schema} /> : <Operation {schema} operation={Astro.props.operation} />}
<StarlightPage
{...getPageProps(
title,
schema,
isOverview || isOperationTag ? undefined : Astro.props.operation,
isOperationTag ? Astro.props.tag : undefined,
)}
>
{
isOverview ? (
<Overview {schema} />
) : isOperationTag ? (
<OperationTag tag={Astro.props.tag} />
) : (
<Operation {schema} operation={Astro.props.operation} />
)
}
</StarlightPage>
19 changes: 12 additions & 7 deletions packages/starlight-openapi/libs/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const defaultOperationTag = 'Operations'
const operationHttpMethods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const

export function getOperationsByTag(document: Schema['document']) {
const operationsByTag = new Map<string, PathItemOperation[]>()
const operationsByTag = new Map<string, { entries: PathItemOperation[]; tag: OperationTag }>()

for (const [pathItemPath, pathItem] of Object.entries(document.paths ?? {})) {
if (!isPathItem(pathItem)) {
Expand All @@ -32,9 +32,9 @@ export function getOperationsByTag(document: Schema['document']) {
const operationIdSlug = slug(operationId)

for (const tag of operation.tags ?? [defaultOperationTag]) {
const operations = operationsByTag.get(tag) ?? []
const operations = operationsByTag.get(tag) ?? { entries: [], tag: { name: tag } }

operations.push({
operations.entries.push({
method,
operation,
path: pathItemPath,
Expand All @@ -52,18 +52,18 @@ export function getOperationsByTag(document: Schema['document']) {
}

if (document.tags) {
const orderedTags = new Map(document.tags.map((tag, index) => [tag.name, index]))
const orderedTags = new Map(document.tags.map((tag, index) => [tag.name, { index, tag }]))
const operationsByTagArray = [...operationsByTag.entries()].sort(([tagA], [tagB]) => {
const orderA = orderedTags.get(tagA) ?? Number.POSITIVE_INFINITY
const orderB = orderedTags.get(tagB) ?? Number.POSITIVE_INFINITY
const orderA = orderedTags.get(tagA)?.index ?? Number.POSITIVE_INFINITY
const orderB = orderedTags.get(tagB)?.index ?? Number.POSITIVE_INFINITY

return orderA - orderB
})

operationsByTag.clear()

for (const [tag, operations] of operationsByTagArray) {
operationsByTag.set(tag, operations)
operationsByTag.set(tag, { ...operations, tag: orderedTags.get(tag)?.tag ?? operations.tag })
}
}

Expand Down Expand Up @@ -110,6 +110,10 @@ export function isPathItemOperation<TMethod extends OperationHttpMethod>(
return method in pathItem
}

export function isMinimalOperationTag(tag: OperationTag): boolean {
return (tag.description === undefined || tag.description.length === 0) && tag.externalDocs === undefined
}

export function getOperationURLs(document: Document, { operation, path, pathItem }: PathItemOperation): OperationURL[] {
const urls: OperationURL[] = []

Expand Down Expand Up @@ -159,6 +163,7 @@ export interface PathItemOperation {

export type Operation = OpenAPI.Operation
export type OperationHttpMethod = (typeof operationHttpMethods)[number]
export type OperationTag = NonNullable<Document['tags']>[number]

interface OperationURL {
description?: string | undefined
Expand Down
20 changes: 11 additions & 9 deletions packages/starlight-openapi/libs/pathItem.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { getOperationsByTag, getWebhooksOperations } from './operation'
import { getBasePath } from './path'
import { getOperationsByTag, getWebhooksOperations, isMinimalOperationTag } from './operation'
import { getBasePath, slug } from './path'
import type { Schema } from './schema'
import { makeSidebarGroup, makeSidebarLink, type SidebarManualGroup } from './starlight'

export function getPathItemSidebarGroups({ config, document }: Schema): SidebarManualGroup['items'] {
const baseLink = getBasePath(config)
const operations = getOperationsByTag(document)

return [...operations.entries()].map(([tag, operations]) =>
makeSidebarGroup(
tag,
operations.map(({ slug, title }) => makeSidebarLink(title, baseLink + slug)),
config.collapsed,
),
)
return [...operations.entries()].map(([tag, operations]) => {
const items = operations.entries.map(({ slug, title }) => makeSidebarLink(title, baseLink + slug))

if (!isMinimalOperationTag(operations.tag)) {
items.unshift(makeSidebarLink('Overview', `${baseLink}operations/tags/${slug(operations.tag.name)}`))
}

return makeSidebarGroup(tag, items, config.collapsed)
})
}

export function getWebhooksSidebarGroups({ config, document }: Schema): SidebarManualGroup['items'] {
Expand Down
59 changes: 44 additions & 15 deletions packages/starlight-openapi/libs/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import schemas from 'virtual:starlight-openapi-schemas'

import { getOperationsByTag, getWebhooksOperations, type PathItemOperation } from './operation'
import { getBasePath, stripLeadingAndTrailingSlashes } from './path'
import {
getOperationsByTag,
getWebhooksOperations,
isMinimalOperationTag,
type OperationTag,
type PathItemOperation,
} from './operation'
import { getBasePath, slug, stripLeadingAndTrailingSlashes } from './path'
import type { Schema } from './schema'

export function getSchemaStaticPaths(): StarlighOpenAPIRoute[] {
Expand All @@ -24,18 +30,35 @@ function getPathItemStaticPaths(schema: Schema): StarlighOpenAPIRoute[] {
const baseLink = getBasePath(schema.config)
const operations = getOperationsByTag(schema.document)

return [...operations.entries()].flatMap(([, operations]) =>
operations.map((operation) => ({
params: {
openAPISlug: stripLeadingAndTrailingSlashes(baseLink + operation.slug),
},
props: {
operation,
schema,
type: 'operation',
},
})),
)
return [...operations.entries()].flatMap(([, operations]) => {
const paths: StarlighOpenAPIRoute[] = operations.entries.map((operation) => {
return {
params: {
openAPISlug: stripLeadingAndTrailingSlashes(baseLink + operation.slug),
},
props: {
operation,
schema,
type: 'operation',
},
}
})

if (!isMinimalOperationTag(operations.tag)) {
paths.unshift({
params: {
openAPISlug: stripLeadingAndTrailingSlashes(`${baseLink}operations/tags/${slug(operations.tag.name)}`),
},
props: {
schema,
tag: operations.tag,
type: 'operation-tag',
},
})
}

return paths
})
}

function getWebhooksStaticPaths(schema: Schema): StarlighOpenAPIRoute[] {
Expand All @@ -58,7 +81,7 @@ interface StarlighOpenAPIRoute {
params: {
openAPISlug: string
}
props: StarlighOpenAPIRouteOverviewProps | StarlighOpenAPIRouteOperationProps
props: StarlighOpenAPIRouteOverviewProps | StarlighOpenAPIRouteOperationProps | StarlighOpenAPIRouteOperationTagProps
}

interface StarlighOpenAPIRouteOverviewProps {
Expand All @@ -71,3 +94,9 @@ interface StarlighOpenAPIRouteOperationProps {
schema: Schema
type: 'operation'
}

interface StarlighOpenAPIRouteOperationTagProps {
schema: Schema
tag: OperationTag
type: 'operation-tag'
}
20 changes: 17 additions & 3 deletions packages/starlight-openapi/libs/starlight.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { StarlightUserConfig } from '@astrojs/starlight/types'
import type { MarkdownHeading } from 'astro'

import type { PathItemOperation } from './operation'
import type { OperationTag, PathItemOperation } from './operation'
import { getParametersByLocation } from './parameter'
import { slug } from './path'
import { hasRequestBody } from './requestBody'
Expand All @@ -22,14 +22,24 @@ export function getSidebarGroupsPlaceholder(): SidebarGroup[] {
]
}

export function getPageProps(title: string, schema: Schema, pathItemOperation?: PathItemOperation): StarlightPageProps {
export function getPageProps(
title: string,
schema: Schema,
pathItemOperation?: PathItemOperation,
tag?: OperationTag,
): StarlightPageProps {
const isOverview = pathItemOperation === undefined
const isOperationTag = tag !== undefined

return {
frontmatter: {
title,
},
headings: isOverview ? getOverviewHeadings(schema) : getOperationHeadings(schema, pathItemOperation),
headings: isOperationTag
? getOperationTagHeadings(tag)
: isOverview
? getOverviewHeadings(schema)
: getOperationHeadings(schema, pathItemOperation),
}
}

Expand Down Expand Up @@ -96,6 +106,10 @@ function getOverviewHeadings({ document }: Schema): MarkdownHeading[] {
return makeHeadings(items)
}

function getOperationTagHeadings(tag: OperationTag): MarkdownHeading[] {
return [makeHeading(2, tag.name, 'overview')]
}

function getOperationHeadings(schema: Schema, { operation, pathItem }: PathItemOperation): MarkdownHeading[] {
const items: MarkdownHeading[] = []

Expand Down
11 changes: 11 additions & 0 deletions packages/starlight-openapi/tests/operationTag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, test } from './test'

test('displays an operation tag overview', async ({ docPage }) => {
await docPage.goto('/1password/operations/tags/items/')

await docPage.expectToHaveTitle('Overview')

await expect(docPage.getByRole('heading', { level: 2, name: 'Items' })).toBeVisible()

await expect(docPage.getByText('Access and manage items inside 1Password Vaults')).toBeVisible()
})
12 changes: 12 additions & 0 deletions packages/starlight-openapi/tests/sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,15 @@ test('respects tags order', async ({ sidebarPage }) => {
{ collapsed: true, label: 'Files' },
])
})

test('create operation tag overview page for non-minimal tags', async ({ sidebarPage }) => {
await sidebarPage.goto()

const items = await sidebarPage.getSidebarGroupItems('1Password Connect')

expect(items[2]).toMatchObject({
collapsed: true,
label: 'Vaults',
items: [{ name: 'Overview' }, { name: 'Get all Vaults' }, { name: 'Get Vault details and metadata' }],
})
})

0 comments on commit 7448b06

Please sign in to comment.