Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add operation tag pages #63

Merged
merged 1 commit into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' }],
})
})
Loading