Skip to content

Commit b19e249

Browse files
authored
Add operation tag pages (#63)
1 parent 0423001 commit b19e249

File tree

9 files changed

+149
-37
lines changed

9 files changed

+149
-37
lines changed

.changeset/brown-news-bake.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'starlight-openapi': minor
3+
---
4+
5+
Adds overview pages for operations grouped by tags defined with a `description` or `externalDocs` fields displaying the tag's information.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
import type { OperationTag } from '../libs/operation'
3+
4+
import ExternalDocs from './ExternalDocs.astro'
5+
import Md from './Md.astro'
6+
7+
interface Props {
8+
tag: OperationTag
9+
}
10+
11+
const { tag } = Astro.props
12+
---
13+
14+
<h2 id="overview">{tag.name}</h2>
15+
16+
{tag.description && <Md text={tag.description} />}
17+
{tag.externalDocs && <ExternalDocs docs={tag.externalDocs} />}

packages/starlight-openapi/components/Route.astro

+20-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getSchemaStaticPaths } from '../libs/route'
77
import { getPageProps } from '../libs/starlight'
88
99
import Operation from './operation/Operation.astro'
10+
import OperationTag from './OperationTag.astro'
1011
import Overview from './Overview.astro'
1112
1213
export const prerender = true
@@ -22,10 +23,26 @@ const { schema, type } = Astro.props
2223
schema.document = await OpenAPIParser.dereference(schema.document)
2324
2425
const isOverview = type === 'overview'
26+
const isOperationTag = type === 'operation-tag'
2527
26-
const title = isOverview ? 'Overview' : Astro.props.operation.title
28+
const title = isOverview || isOperationTag ? 'Overview' : Astro.props.operation.title
2729
---
2830

29-
<StarlightPage {...getPageProps(title, schema, isOverview ? undefined : Astro.props.operation)}>
30-
{isOverview ? <Overview {schema} /> : <Operation {schema} operation={Astro.props.operation} />}
31+
<StarlightPage
32+
{...getPageProps(
33+
title,
34+
schema,
35+
isOverview || isOperationTag ? undefined : Astro.props.operation,
36+
isOperationTag ? Astro.props.tag : undefined,
37+
)}
38+
>
39+
{
40+
isOverview ? (
41+
<Overview {schema} />
42+
) : isOperationTag ? (
43+
<OperationTag tag={Astro.props.tag} />
44+
) : (
45+
<Operation {schema} operation={Astro.props.operation} />
46+
)
47+
}
3148
</StarlightPage>

packages/starlight-openapi/libs/operation.ts

+12-7
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const defaultOperationTag = 'Operations'
99
const operationHttpMethods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const
1010

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

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

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

37-
operations.push({
37+
operations.entries.push({
3838
method,
3939
operation,
4040
path: pathItemPath,
@@ -52,18 +52,18 @@ export function getOperationsByTag(document: Schema['document']) {
5252
}
5353

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

6060
return orderA - orderB
6161
})
6262

6363
operationsByTag.clear()
6464

6565
for (const [tag, operations] of operationsByTagArray) {
66-
operationsByTag.set(tag, operations)
66+
operationsByTag.set(tag, { ...operations, tag: orderedTags.get(tag)?.tag ?? operations.tag })
6767
}
6868
}
6969

@@ -110,6 +110,10 @@ export function isPathItemOperation<TMethod extends OperationHttpMethod>(
110110
return method in pathItem
111111
}
112112

113+
export function isMinimalOperationTag(tag: OperationTag): boolean {
114+
return (tag.description === undefined || tag.description.length === 0) && tag.externalDocs === undefined
115+
}
116+
113117
export function getOperationURLs(document: Document, { operation, path, pathItem }: PathItemOperation): OperationURL[] {
114118
const urls: OperationURL[] = []
115119

@@ -159,6 +163,7 @@ export interface PathItemOperation {
159163

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

163168
interface OperationURL {
164169
description?: string | undefined

packages/starlight-openapi/libs/pathItem.ts

+11-9
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
import { getOperationsByTag, getWebhooksOperations } from './operation'
2-
import { getBasePath } from './path'
1+
import { getOperationsByTag, getWebhooksOperations, isMinimalOperationTag } from './operation'
2+
import { getBasePath, slug } from './path'
33
import type { Schema } from './schema'
44
import { makeSidebarGroup, makeSidebarLink, type SidebarManualGroup } from './starlight'
55

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

10-
return [...operations.entries()].map(([tag, operations]) =>
11-
makeSidebarGroup(
12-
tag,
13-
operations.map(({ slug, title }) => makeSidebarLink(title, baseLink + slug)),
14-
config.collapsed,
15-
),
16-
)
10+
return [...operations.entries()].map(([tag, operations]) => {
11+
const items = operations.entries.map(({ slug, title }) => makeSidebarLink(title, baseLink + slug))
12+
13+
if (!isMinimalOperationTag(operations.tag)) {
14+
items.unshift(makeSidebarLink('Overview', `${baseLink}operations/tags/${slug(operations.tag.name)}`))
15+
}
16+
17+
return makeSidebarGroup(tag, items, config.collapsed)
18+
})
1719
}
1820

1921
export function getWebhooksSidebarGroups({ config, document }: Schema): SidebarManualGroup['items'] {

packages/starlight-openapi/libs/route.ts

+44-15
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import schemas from 'virtual:starlight-openapi-schemas'
22

3-
import { getOperationsByTag, getWebhooksOperations, type PathItemOperation } from './operation'
4-
import { getBasePath, stripLeadingAndTrailingSlashes } from './path'
3+
import {
4+
getOperationsByTag,
5+
getWebhooksOperations,
6+
isMinimalOperationTag,
7+
type OperationTag,
8+
type PathItemOperation,
9+
} from './operation'
10+
import { getBasePath, slug, stripLeadingAndTrailingSlashes } from './path'
511
import type { Schema } from './schema'
612

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

27-
return [...operations.entries()].flatMap(([, operations]) =>
28-
operations.map((operation) => ({
29-
params: {
30-
openAPISlug: stripLeadingAndTrailingSlashes(baseLink + operation.slug),
31-
},
32-
props: {
33-
operation,
34-
schema,
35-
type: 'operation',
36-
},
37-
})),
38-
)
33+
return [...operations.entries()].flatMap(([, operations]) => {
34+
const paths: StarlighOpenAPIRoute[] = operations.entries.map((operation) => {
35+
return {
36+
params: {
37+
openAPISlug: stripLeadingAndTrailingSlashes(baseLink + operation.slug),
38+
},
39+
props: {
40+
operation,
41+
schema,
42+
type: 'operation',
43+
},
44+
}
45+
})
46+
47+
if (!isMinimalOperationTag(operations.tag)) {
48+
paths.unshift({
49+
params: {
50+
openAPISlug: stripLeadingAndTrailingSlashes(`${baseLink}operations/tags/${slug(operations.tag.name)}`),
51+
},
52+
props: {
53+
schema,
54+
tag: operations.tag,
55+
type: 'operation-tag',
56+
},
57+
})
58+
}
59+
60+
return paths
61+
})
3962
}
4063

4164
function getWebhooksStaticPaths(schema: Schema): StarlighOpenAPIRoute[] {
@@ -58,7 +81,7 @@ interface StarlighOpenAPIRoute {
5881
params: {
5982
openAPISlug: string
6083
}
61-
props: StarlighOpenAPIRouteOverviewProps | StarlighOpenAPIRouteOperationProps
84+
props: StarlighOpenAPIRouteOverviewProps | StarlighOpenAPIRouteOperationProps | StarlighOpenAPIRouteOperationTagProps
6285
}
6386

6487
interface StarlighOpenAPIRouteOverviewProps {
@@ -71,3 +94,9 @@ interface StarlighOpenAPIRouteOperationProps {
7194
schema: Schema
7295
type: 'operation'
7396
}
97+
98+
interface StarlighOpenAPIRouteOperationTagProps {
99+
schema: Schema
100+
tag: OperationTag
101+
type: 'operation-tag'
102+
}

packages/starlight-openapi/libs/starlight.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { StarlightUserConfig } from '@astrojs/starlight/types'
22
import type { MarkdownHeading } from 'astro'
33

4-
import type { PathItemOperation } from './operation'
4+
import type { OperationTag, PathItemOperation } from './operation'
55
import { getParametersByLocation } from './parameter'
66
import { slug } from './path'
77
import { hasRequestBody } from './requestBody'
@@ -22,14 +22,24 @@ export function getSidebarGroupsPlaceholder(): SidebarGroup[] {
2222
]
2323
}
2424

25-
export function getPageProps(title: string, schema: Schema, pathItemOperation?: PathItemOperation): StarlightPageProps {
25+
export function getPageProps(
26+
title: string,
27+
schema: Schema,
28+
pathItemOperation?: PathItemOperation,
29+
tag?: OperationTag,
30+
): StarlightPageProps {
2631
const isOverview = pathItemOperation === undefined
32+
const isOperationTag = tag !== undefined
2733

2834
return {
2935
frontmatter: {
3036
title,
3137
},
32-
headings: isOverview ? getOverviewHeadings(schema) : getOperationHeadings(schema, pathItemOperation),
38+
headings: isOperationTag
39+
? getOperationTagHeadings(tag)
40+
: isOverview
41+
? getOverviewHeadings(schema)
42+
: getOperationHeadings(schema, pathItemOperation),
3343
}
3444
}
3545

@@ -96,6 +106,10 @@ function getOverviewHeadings({ document }: Schema): MarkdownHeading[] {
96106
return makeHeadings(items)
97107
}
98108

109+
function getOperationTagHeadings(tag: OperationTag): MarkdownHeading[] {
110+
return [makeHeading(2, tag.name, 'overview')]
111+
}
112+
99113
function getOperationHeadings(schema: Schema, { operation, pathItem }: PathItemOperation): MarkdownHeading[] {
100114
const items: MarkdownHeading[] = []
101115

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { expect, test } from './test'
2+
3+
test('displays an operation tag overview', async ({ docPage }) => {
4+
await docPage.goto('/1password/operations/tags/items/')
5+
6+
await docPage.expectToHaveTitle('Overview')
7+
8+
await expect(docPage.getByRole('heading', { level: 2, name: 'Items' })).toBeVisible()
9+
10+
await expect(docPage.getByText('Access and manage items inside 1Password Vaults')).toBeVisible()
11+
})

packages/starlight-openapi/tests/sidebar.test.ts

+12
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,15 @@ test('respects tags order', async ({ sidebarPage }) => {
6767
{ collapsed: true, label: 'Files' },
6868
])
6969
})
70+
71+
test('create operation tag overview page for non-minimal tags', async ({ sidebarPage }) => {
72+
await sidebarPage.goto()
73+
74+
const items = await sidebarPage.getSidebarGroupItems('1Password Connect')
75+
76+
expect(items[2]).toMatchObject({
77+
collapsed: true,
78+
label: 'Vaults',
79+
items: [{ name: 'Overview' }, { name: 'Get all Vaults' }, { name: 'Get Vault details and metadata' }],
80+
})
81+
})

0 commit comments

Comments
 (0)