Skip to content

Commit cfb053c

Browse files
Ebonsignoriheiskr
andauthored
add transformer pattern & rest transformer for API (#58388)
Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
1 parent c1973b0 commit cfb053c

File tree

16 files changed

+1140
-19
lines changed

16 files changed

+1140
-19
lines changed

src/article-api/README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,24 @@ Article API endpoints allow consumers to query GitHub Docs for listings of curre
1313

1414
The `/api/article/meta` endpoint powers hovercards, which provide a preview for internal links on <docs.github.com>.
1515

16+
The `/api/article/body` endpoint can serve markdown for both regular articles and autogenerated content (such as REST API documentation) using specialized transformers.
17+
1618
## How it works
1719

1820
The `/api/article` endpoints return information about a page by `pathname`.
1921

2022
`api/article/meta` is highly cached, in JSON format.
2123

24+
### Autogenerated Content Transformers
25+
26+
For autogenerated pages (REST, landing pages, audit logs, webhooks, GraphQL, etc), the Article API uses specialized transformers to convert the rendered content into markdown format. These transformers are located in `src/article-api/transformers/` and use an extensible architecture:
27+
28+
To add a new transformer for other autogenerated content types:
29+
1. Create a new transformer file implementing the `PageTransformer` interface
30+
2. Register it in `transformers/index.ts`
31+
3. Create a template in `templates/` to configure how the transformer will organize the autogenerated content
32+
4. The transformer will automatically be used by `/api/article/body`
33+
2234
## How to get help
2335

2436
For internal folks ask in the Docs Engineering slack channel.
@@ -34,12 +46,13 @@ Get article metadata and content in a single object. Equivalent to calling `/art
3446

3547
**Parameters**:
3648
- **pathname** (string) - Article path (e.g. '/en/get-started/article-name')
49+
- **[apiVersion]** (string) - API version for REST pages (optional, defaults to latest)
3750

3851
**Returns**: (object) - JSON object with article metadata and content (`meta` and `body` keys)
3952

4053
**Throws**:
4154
- (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message.
42-
- (Error): 400 - If pathname parameter is invalid.
55+
- (Error): 400 - If pathname or apiVersion parameters are invalid.
4356
- (Error): 404 - If the path is valid, but the page couldn't be resolved.
4457

4558
**Example**:
@@ -63,12 +76,13 @@ Get the contents of an article's body.
6376

6477
**Parameters**:
6578
- **pathname** (string) - Article path (e.g. '/en/get-started/article-name')
79+
- **[apiVersion]** (string) - API version (optional, defaults to latest)
6680

6781
**Returns**: (string) - Article body content in markdown format.
6882

6983
**Throws**:
7084
- (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message.
71-
- (Error): 400 - If pathname parameter is invalid.
85+
- (Error): 400 - If pathname or apiVersion parameters are invalid.
7286
- (Error): 404 - If the path is valid, but the page couldn't be resolved.
7387

7488
**Example**:
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* API Transformer Liquid Tags
3+
*
4+
* This module contains custom Liquid tags used by article-api transformers
5+
* to render API documentation in a consistent format.
6+
*/
7+
8+
import { restTags } from './rest-tags'
9+
10+
// Export all API transformer tags for registration
11+
export const apiTransformerTags = {
12+
...restTags,
13+
}
14+
15+
// Re-export individual tag modules for direct access if needed
16+
export { restTags } from './rest-tags'
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type { TagToken, Context as LiquidContext } from 'liquidjs'
2+
import { fastTextOnly } from '@/content-render/unified/text-only'
3+
import { renderContent } from '@/content-render/index'
4+
import type { Context } from '@/types'
5+
import type { Parameter, BodyParameter, ChildParameter, StatusCode } from '@/rest/components/types'
6+
import { createLogger } from '@/observability/logger'
7+
8+
const logger = createLogger('article-api/liquid-renderers/rest-tags')
9+
10+
/**
11+
* Custom Liquid tag for rendering REST API parameters
12+
* Usage: {% rest_parameter param %}
13+
*/
14+
export class RestParameter {
15+
private paramName: string
16+
17+
constructor(
18+
token: TagToken,
19+
remainTokens: TagToken[],
20+
liquid: { options: any; parser: any },
21+
private liquidContext?: LiquidContext,
22+
) {
23+
// The tag receives the parameter object from the template context
24+
this.paramName = token.args.trim()
25+
}
26+
27+
async render(ctx: LiquidContext, emitter: any): Promise<void> {
28+
const param = ctx.get([this.paramName]) as Parameter
29+
const context = ctx.get(['context']) as Context
30+
31+
if (!param) {
32+
emitter.write('')
33+
return
34+
}
35+
36+
const lines: string[] = []
37+
const required = param.required ? ' (required)' : ''
38+
const type = param.schema?.type || 'string'
39+
40+
lines.push(`- **\`${param.name}\`** (${type})${required}`)
41+
42+
if (param.description) {
43+
const description = await htmlToMarkdown(param.description, context)
44+
lines.push(` ${description}`)
45+
}
46+
47+
if (param.schema?.default !== undefined) {
48+
lines.push(` Default: \`${param.schema.default}\``)
49+
}
50+
51+
if (param.schema?.enum && param.schema.enum.length > 0) {
52+
lines.push(` Can be one of: ${param.schema.enum.map((v) => `\`${v}\``).join(', ')}`)
53+
}
54+
55+
emitter.write(lines.join('\n'))
56+
}
57+
}
58+
59+
/**
60+
* Custom Liquid tag for rendering REST API body parameters
61+
* Usage: {% rest_body_parameter param indent %}
62+
*/
63+
export class RestBodyParameter {
64+
constructor(
65+
token: TagToken,
66+
remainTokens: TagToken[],
67+
liquid: { options: any; parser: any },
68+
private liquidContext?: LiquidContext,
69+
) {
70+
// Parse arguments - param name and optional indent level
71+
const args = token.args.trim().split(/\s+/)
72+
this.param = args[0]
73+
this.indent = args[1] ? parseInt(args[1]) : 0
74+
}
75+
76+
private param: string
77+
private indent: number
78+
79+
async render(ctx: LiquidContext, emitter: any): Promise<void> {
80+
const param = ctx.get([this.param]) as BodyParameter
81+
const context = ctx.get(['context']) as Context
82+
const indent = this.indent
83+
84+
if (!param) {
85+
emitter.write('')
86+
return
87+
}
88+
89+
const lines: string[] = []
90+
const prefix = ' '.repeat(indent)
91+
const required = param.isRequired ? ' (required)' : ''
92+
const type = param.type || 'string'
93+
94+
lines.push(`${prefix}- **\`${param.name}\`** (${type})${required}`)
95+
96+
if (param.description) {
97+
const description = await htmlToMarkdown(param.description, context)
98+
lines.push(`${prefix} ${description}`)
99+
}
100+
101+
if (param.default !== undefined) {
102+
lines.push(`${prefix} Default: \`${param.default}\``)
103+
}
104+
105+
if (param.enum && param.enum.length > 0) {
106+
lines.push(`${prefix} Can be one of: ${param.enum.map((v) => `\`${v}\``).join(', ')}`)
107+
}
108+
109+
// Handle nested parameters
110+
if (param.childParamsGroups && param.childParamsGroups.length > 0) {
111+
for (const childGroup of param.childParamsGroups) {
112+
lines.push(await renderChildParameter(childGroup, context, indent + 1))
113+
}
114+
}
115+
116+
emitter.write(lines.join('\n'))
117+
}
118+
}
119+
120+
/**
121+
* Custom Liquid tag for rendering REST API status codes
122+
* Usage: {% rest_status_code statusCode %}
123+
*/
124+
export class RestStatusCode {
125+
private statusCodeName: string
126+
127+
constructor(
128+
token: TagToken,
129+
remainTokens: TagToken[],
130+
liquid: { options: any; parser: any },
131+
private liquidContext?: LiquidContext,
132+
) {
133+
this.statusCodeName = token.args.trim()
134+
}
135+
136+
async render(ctx: LiquidContext, emitter: any): Promise<void> {
137+
const statusCode = ctx.get([this.statusCodeName]) as StatusCode
138+
const context = ctx.get(['context']) as Context
139+
140+
if (!statusCode) {
141+
emitter.write('')
142+
return
143+
}
144+
145+
const lines: string[] = []
146+
147+
if (statusCode.description) {
148+
const description = await htmlToMarkdown(statusCode.description, context)
149+
lines.push(`- **${statusCode.httpStatusCode}**`)
150+
if (description.trim()) {
151+
lines.push(` ${description.trim()}`)
152+
}
153+
} else if (statusCode.httpStatusMessage) {
154+
lines.push(`- **${statusCode.httpStatusCode}** - ${statusCode.httpStatusMessage}`)
155+
} else {
156+
lines.push(`- **${statusCode.httpStatusCode}**`)
157+
}
158+
159+
emitter.write(lines.join('\n'))
160+
}
161+
}
162+
163+
/**
164+
* Helper function to render child parameters recursively
165+
*/
166+
async function renderChildParameter(
167+
param: ChildParameter,
168+
context: Context,
169+
indent: number,
170+
): Promise<string> {
171+
const lines: string[] = []
172+
const prefix = ' '.repeat(indent)
173+
const required = param.isRequired ? ' (required)' : ''
174+
const type = param.type || 'string'
175+
176+
lines.push(`${prefix}- **\`${param.name}\`** (${type})${required}`)
177+
178+
if (param.description) {
179+
const description = await htmlToMarkdown(param.description, context)
180+
lines.push(`${prefix} ${description}`)
181+
}
182+
183+
if (param.default !== undefined) {
184+
lines.push(`${prefix} Default: \`${param.default}\``)
185+
}
186+
187+
if (param.enum && param.enum.length > 0) {
188+
lines.push(`${prefix} Can be one of: ${param.enum.map((v: string) => `\`${v}\``).join(', ')}`)
189+
}
190+
191+
// Recursively handle nested parameters
192+
if (param.childParamsGroups && param.childParamsGroups.length > 0) {
193+
for (const child of param.childParamsGroups) {
194+
lines.push(await renderChildParameter(child, context, indent + 1))
195+
}
196+
}
197+
198+
return lines.join('\n')
199+
}
200+
201+
/**
202+
* Helper function to convert HTML to markdown
203+
*/
204+
async function htmlToMarkdown(html: string, context: Context): Promise<string> {
205+
if (!html) return ''
206+
207+
try {
208+
const rendered = await renderContent(html, context, { textOnly: false })
209+
return fastTextOnly(rendered)
210+
} catch (error) {
211+
logger.error('Failed to render HTML content to markdown in REST tag', {
212+
error,
213+
html: html.substring(0, 100), // First 100 chars for context
214+
contextInfo: context && context.page ? { page: context.page.relativePath } : undefined,
215+
})
216+
// In non-production, re-throw to aid debugging
217+
if (process.env.NODE_ENV !== 'production') {
218+
throw error
219+
}
220+
// Fallback to simple text extraction
221+
return fastTextOnly(html)
222+
}
223+
}
224+
225+
// Export tag names for registration
226+
export const restTags = {
227+
rest_parameter: RestParameter,
228+
rest_body_parameter: RestBodyParameter,
229+
rest_status_code: RestStatusCode,
230+
}

src/article-api/middleware/article-body.ts

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,15 @@ import type { Response } from 'express'
33
import { Context } from '@/types'
44
import { ExtendedRequestWithPageInfo } from '@/article-api/types'
55
import contextualize from '@/frame/middleware/context/context'
6+
import { transformerRegistry } from '@/article-api/transformers'
7+
import { allVersions } from '@/versions/lib/all-versions'
8+
import type { Page } from '@/types'
69

7-
export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
8-
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
9-
// and is in the ExtendedRequestWithPageInfo
10-
const { page, pathname, archived } = req.pageinfo
11-
12-
if (archived?.isArchived)
13-
throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`)
14-
// for anything that's not an article (like index pages), don't try to render and
15-
// tell the user what's going on
16-
if (page.documentType !== 'article') {
17-
throw new Error(`Page ${pathname} isn't yet available in markdown.`)
18-
}
19-
// these parts allow us to render the page
10+
/**
11+
* Creates a mocked rendering request and contextualizes it.
12+
* This is used to prepare a request for rendering pages in markdown format.
13+
*/
14+
async function createContextualizedRenderingRequest(pathname: string, page: Page) {
2015
const mockedContext: Context = {}
2116
const renderingReq = {
2217
path: pathname,
@@ -29,9 +24,51 @@ export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
2924
},
3025
}
3126

32-
// contextualize and render the page
27+
// contextualize the request to get proper version info
3328
await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})
3429
renderingReq.context.page = page
30+
31+
return renderingReq
32+
}
33+
34+
export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
35+
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
36+
// and is in the ExtendedRequestWithPageInfo
37+
const { page, pathname, archived } = req.pageinfo
38+
39+
if (archived?.isArchived)
40+
throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`)
41+
42+
// Extract apiVersion from query params if provided
43+
const apiVersion = req.query.apiVersion as string | undefined
44+
45+
// Check if there's a transformer for this page type (e.g., REST, webhooks, etc.)
46+
const transformer = transformerRegistry.findTransformer(page)
47+
48+
if (transformer) {
49+
// Use the transformer for autogenerated pages
50+
const renderingReq = await createContextualizedRenderingRequest(pathname, page)
51+
52+
// Determine the API version to use (provided or latest)
53+
// Validation is handled by apiVersionValidationMiddleware
54+
const currentVersion = renderingReq.context.currentVersion
55+
let effectiveApiVersion = apiVersion
56+
57+
// Use latest version if not provided
58+
if (!effectiveApiVersion && currentVersion && allVersions[currentVersion]) {
59+
effectiveApiVersion = allVersions[currentVersion].latestApiVersion || undefined
60+
}
61+
62+
return await transformer.transform(page, pathname, renderingReq.context, effectiveApiVersion)
63+
}
64+
65+
// For regular articles (non-autogenerated)
66+
if (page.documentType !== 'article') {
67+
throw new Error(`Page ${pathname} isn't yet available in markdown.`)
68+
}
69+
70+
// these parts allow us to render the page
71+
const renderingReq = await createContextualizedRenderingRequest(pathname, page)
3572
renderingReq.context.markdownRequested = true
3673
return await page.render(renderingReq.context)
3774
}

0 commit comments

Comments
 (0)