1
0
mirror of synced 2025-12-19 18:10:59 -05:00

add transformer pattern & rest transformer for API (#58388)

Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
This commit is contained in:
Evan Bonsignori
2025-12-04 11:12:40 -08:00
committed by GitHub
parent c1973b0c04
commit cfb053cb67
16 changed files with 1140 additions and 19 deletions

View File

@@ -13,12 +13,24 @@ Article API endpoints allow consumers to query GitHub Docs for listings of curre
The `/api/article/meta` endpoint powers hovercards, which provide a preview for internal links on <docs.github.com>.
The `/api/article/body` endpoint can serve markdown for both regular articles and autogenerated content (such as REST API documentation) using specialized transformers.
## How it works
The `/api/article` endpoints return information about a page by `pathname`.
`api/article/meta` is highly cached, in JSON format.
### Autogenerated Content Transformers
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:
To add a new transformer for other autogenerated content types:
1. Create a new transformer file implementing the `PageTransformer` interface
2. Register it in `transformers/index.ts`
3. Create a template in `templates/` to configure how the transformer will organize the autogenerated content
4. The transformer will automatically be used by `/api/article/body`
## How to get help
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
**Parameters**:
- **pathname** (string) - Article path (e.g. '/en/get-started/article-name')
- **[apiVersion]** (string) - API version for REST pages (optional, defaults to latest)
**Returns**: (object) - JSON object with article metadata and content (`meta` and `body` keys)
**Throws**:
- (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message.
- (Error): 400 - If pathname parameter is invalid.
- (Error): 400 - If pathname or apiVersion parameters are invalid.
- (Error): 404 - If the path is valid, but the page couldn't be resolved.
**Example**:
@@ -63,12 +76,13 @@ Get the contents of an article's body.
**Parameters**:
- **pathname** (string) - Article path (e.g. '/en/get-started/article-name')
- **[apiVersion]** (string) - API version (optional, defaults to latest)
**Returns**: (string) - Article body content in markdown format.
**Throws**:
- (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message.
- (Error): 400 - If pathname parameter is invalid.
- (Error): 400 - If pathname or apiVersion parameters are invalid.
- (Error): 404 - If the path is valid, but the page couldn't be resolved.
**Example**:

View File

@@ -0,0 +1,16 @@
/**
* API Transformer Liquid Tags
*
* This module contains custom Liquid tags used by article-api transformers
* to render API documentation in a consistent format.
*/
import { restTags } from './rest-tags'
// Export all API transformer tags for registration
export const apiTransformerTags = {
...restTags,
}
// Re-export individual tag modules for direct access if needed
export { restTags } from './rest-tags'

View File

@@ -0,0 +1,230 @@
import type { TagToken, Context as LiquidContext } from 'liquidjs'
import { fastTextOnly } from '@/content-render/unified/text-only'
import { renderContent } from '@/content-render/index'
import type { Context } from '@/types'
import type { Parameter, BodyParameter, ChildParameter, StatusCode } from '@/rest/components/types'
import { createLogger } from '@/observability/logger'
const logger = createLogger('article-api/liquid-renderers/rest-tags')
/**
* Custom Liquid tag for rendering REST API parameters
* Usage: {% rest_parameter param %}
*/
export class RestParameter {
private paramName: string
constructor(
token: TagToken,
remainTokens: TagToken[],
liquid: { options: any; parser: any },
private liquidContext?: LiquidContext,
) {
// The tag receives the parameter object from the template context
this.paramName = token.args.trim()
}
async render(ctx: LiquidContext, emitter: any): Promise<void> {
const param = ctx.get([this.paramName]) as Parameter
const context = ctx.get(['context']) as Context
if (!param) {
emitter.write('')
return
}
const lines: string[] = []
const required = param.required ? ' (required)' : ''
const type = param.schema?.type || 'string'
lines.push(`- **\`${param.name}\`** (${type})${required}`)
if (param.description) {
const description = await htmlToMarkdown(param.description, context)
lines.push(` ${description}`)
}
if (param.schema?.default !== undefined) {
lines.push(` Default: \`${param.schema.default}\``)
}
if (param.schema?.enum && param.schema.enum.length > 0) {
lines.push(` Can be one of: ${param.schema.enum.map((v) => `\`${v}\``).join(', ')}`)
}
emitter.write(lines.join('\n'))
}
}
/**
* Custom Liquid tag for rendering REST API body parameters
* Usage: {% rest_body_parameter param indent %}
*/
export class RestBodyParameter {
constructor(
token: TagToken,
remainTokens: TagToken[],
liquid: { options: any; parser: any },
private liquidContext?: LiquidContext,
) {
// Parse arguments - param name and optional indent level
const args = token.args.trim().split(/\s+/)
this.param = args[0]
this.indent = args[1] ? parseInt(args[1]) : 0
}
private param: string
private indent: number
async render(ctx: LiquidContext, emitter: any): Promise<void> {
const param = ctx.get([this.param]) as BodyParameter
const context = ctx.get(['context']) as Context
const indent = this.indent
if (!param) {
emitter.write('')
return
}
const lines: string[] = []
const prefix = ' '.repeat(indent)
const required = param.isRequired ? ' (required)' : ''
const type = param.type || 'string'
lines.push(`${prefix}- **\`${param.name}\`** (${type})${required}`)
if (param.description) {
const description = await htmlToMarkdown(param.description, context)
lines.push(`${prefix} ${description}`)
}
if (param.default !== undefined) {
lines.push(`${prefix} Default: \`${param.default}\``)
}
if (param.enum && param.enum.length > 0) {
lines.push(`${prefix} Can be one of: ${param.enum.map((v) => `\`${v}\``).join(', ')}`)
}
// Handle nested parameters
if (param.childParamsGroups && param.childParamsGroups.length > 0) {
for (const childGroup of param.childParamsGroups) {
lines.push(await renderChildParameter(childGroup, context, indent + 1))
}
}
emitter.write(lines.join('\n'))
}
}
/**
* Custom Liquid tag for rendering REST API status codes
* Usage: {% rest_status_code statusCode %}
*/
export class RestStatusCode {
private statusCodeName: string
constructor(
token: TagToken,
remainTokens: TagToken[],
liquid: { options: any; parser: any },
private liquidContext?: LiquidContext,
) {
this.statusCodeName = token.args.trim()
}
async render(ctx: LiquidContext, emitter: any): Promise<void> {
const statusCode = ctx.get([this.statusCodeName]) as StatusCode
const context = ctx.get(['context']) as Context
if (!statusCode) {
emitter.write('')
return
}
const lines: string[] = []
if (statusCode.description) {
const description = await htmlToMarkdown(statusCode.description, context)
lines.push(`- **${statusCode.httpStatusCode}**`)
if (description.trim()) {
lines.push(` ${description.trim()}`)
}
} else if (statusCode.httpStatusMessage) {
lines.push(`- **${statusCode.httpStatusCode}** - ${statusCode.httpStatusMessage}`)
} else {
lines.push(`- **${statusCode.httpStatusCode}**`)
}
emitter.write(lines.join('\n'))
}
}
/**
* Helper function to render child parameters recursively
*/
async function renderChildParameter(
param: ChildParameter,
context: Context,
indent: number,
): Promise<string> {
const lines: string[] = []
const prefix = ' '.repeat(indent)
const required = param.isRequired ? ' (required)' : ''
const type = param.type || 'string'
lines.push(`${prefix}- **\`${param.name}\`** (${type})${required}`)
if (param.description) {
const description = await htmlToMarkdown(param.description, context)
lines.push(`${prefix} ${description}`)
}
if (param.default !== undefined) {
lines.push(`${prefix} Default: \`${param.default}\``)
}
if (param.enum && param.enum.length > 0) {
lines.push(`${prefix} Can be one of: ${param.enum.map((v: string) => `\`${v}\``).join(', ')}`)
}
// Recursively handle nested parameters
if (param.childParamsGroups && param.childParamsGroups.length > 0) {
for (const child of param.childParamsGroups) {
lines.push(await renderChildParameter(child, context, indent + 1))
}
}
return lines.join('\n')
}
/**
* Helper function to convert HTML to markdown
*/
async function htmlToMarkdown(html: string, context: Context): Promise<string> {
if (!html) return ''
try {
const rendered = await renderContent(html, context, { textOnly: false })
return fastTextOnly(rendered)
} catch (error) {
logger.error('Failed to render HTML content to markdown in REST tag', {
error,
html: html.substring(0, 100), // First 100 chars for context
contextInfo: context && context.page ? { page: context.page.relativePath } : undefined,
})
// In non-production, re-throw to aid debugging
if (process.env.NODE_ENV !== 'production') {
throw error
}
// Fallback to simple text extraction
return fastTextOnly(html)
}
}
// Export tag names for registration
export const restTags = {
rest_parameter: RestParameter,
rest_body_parameter: RestBodyParameter,
rest_status_code: RestStatusCode,
}

View File

@@ -3,20 +3,15 @@ import type { Response } from 'express'
import { Context } from '@/types'
import { ExtendedRequestWithPageInfo } from '@/article-api/types'
import contextualize from '@/frame/middleware/context/context'
import { transformerRegistry } from '@/article-api/transformers'
import { allVersions } from '@/versions/lib/all-versions'
import type { Page } from '@/types'
export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
// and is in the ExtendedRequestWithPageInfo
const { page, pathname, archived } = req.pageinfo
if (archived?.isArchived)
throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`)
// for anything that's not an article (like index pages), don't try to render and
// tell the user what's going on
if (page.documentType !== 'article') {
throw new Error(`Page ${pathname} isn't yet available in markdown.`)
}
// these parts allow us to render the page
/**
* Creates a mocked rendering request and contextualizes it.
* This is used to prepare a request for rendering pages in markdown format.
*/
async function createContextualizedRenderingRequest(pathname: string, page: Page) {
const mockedContext: Context = {}
const renderingReq = {
path: pathname,
@@ -29,9 +24,51 @@ export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
},
}
// contextualize and render the page
// contextualize the request to get proper version info
await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})
renderingReq.context.page = page
return renderingReq
}
export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
// and is in the ExtendedRequestWithPageInfo
const { page, pathname, archived } = req.pageinfo
if (archived?.isArchived)
throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`)
// Extract apiVersion from query params if provided
const apiVersion = req.query.apiVersion as string | undefined
// Check if there's a transformer for this page type (e.g., REST, webhooks, etc.)
const transformer = transformerRegistry.findTransformer(page)
if (transformer) {
// Use the transformer for autogenerated pages
const renderingReq = await createContextualizedRenderingRequest(pathname, page)
// Determine the API version to use (provided or latest)
// Validation is handled by apiVersionValidationMiddleware
const currentVersion = renderingReq.context.currentVersion
let effectiveApiVersion = apiVersion
// Use latest version if not provided
if (!effectiveApiVersion && currentVersion && allVersions[currentVersion]) {
effectiveApiVersion = allVersions[currentVersion].latestApiVersion || undefined
}
return await transformer.transform(page, pathname, renderingReq.context, effectiveApiVersion)
}
// For regular articles (non-autogenerated)
if (page.documentType !== 'article') {
throw new Error(`Page ${pathname} isn't yet available in markdown.`)
}
// these parts allow us to render the page
const renderingReq = await createContextualizedRenderingRequest(pathname, page)
renderingReq.context.markdownRequested = true
return await page.render(renderingReq.context)
}

View File

@@ -4,7 +4,11 @@ import express from 'express'
import { defaultCacheControl } from '@/frame/middleware/cache-control'
import catchMiddlewareError from '@/observability/middleware/catch-middleware-error'
import { ExtendedRequestWithPageInfo } from '../types'
import { pageValidationMiddleware, pathValidationMiddleware } from './validation'
import {
pageValidationMiddleware,
pathValidationMiddleware,
apiVersionValidationMiddleware,
} from './validation'
import { getArticleBody } from './article-body'
import { getMetadata } from './article-pageinfo'
import {
@@ -24,9 +28,10 @@ const router = express.Router()
* Get article metadata and content in a single object. Equivalent to calling `/article/meta` concatenated with `/article/body`.
* @route GET /api/article
* @param {string} pathname - Article path (e.g. '/en/get-started/article-name')
* @param {string} [apiVersion] - API version for REST pages (optional, defaults to latest)
* @returns {object} JSON object with article metadata and content (`meta` and `body` keys)
* @throws {Error} 403 - If the article body cannot be retrieved. Reason is given in the error message.
* @throws {Error} 400 - If pathname parameter is invalid.
* @throws {Error} 400 - If pathname or apiVersion parameters are invalid.
* @throws {Error} 404 - If the path is valid, but the page couldn't be resolved.
* @example
* curl -s "https://docs.github.com/api/article?pathname=/en/get-started/start-your-journey/about-github-and-git"
@@ -43,6 +48,7 @@ router.get(
'/',
pathValidationMiddleware as RequestHandler,
pageValidationMiddleware as RequestHandler,
apiVersionValidationMiddleware as RequestHandler,
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
const { meta, cacheInfo } = await getMetadata(req)
let bodyContent
@@ -66,9 +72,10 @@ router.get(
* Get the contents of an article's body.
* @route GET /api/article/body
* @param {string} pathname - Article path (e.g. '/en/get-started/article-name')
* @param {string} [apiVersion] - API version (optional, defaults to latest)
* @returns {string} Article body content in markdown format.
* @throws {Error} 403 - If the article body cannot be retrieved. Reason is given in the error message.
* @throws {Error} 400 - If pathname parameter is invalid.
* @throws {Error} 400 - If pathname or apiVersion parameters are invalid.
* @throws {Error} 404 - If the path is valid, but the page couldn't be resolved.
* @example
* curl -s https://docs.github.com/api/article/body\?pathname=/en/get-started/start-your-journey/about-github-and-git
@@ -83,6 +90,7 @@ router.get(
'/body',
pathValidationMiddleware as RequestHandler,
pageValidationMiddleware as RequestHandler,
apiVersionValidationMiddleware as RequestHandler,
catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) {
let bodyContent
try {

View File

@@ -6,6 +6,7 @@ import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version'
import getRedirect from '@/redirects/lib/get-redirect'
import { getVersionStringFromPath, getLangFromPath } from '@/frame/lib/path-utils'
import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version'
import { allVersions } from '@/versions/lib/all-versions'
// validates the path for pagelist endpoint
// specifically, defaults to `/en/free-pro-team@latest` when those values are missing
@@ -123,3 +124,47 @@ export const pageValidationMiddleware = (
return next()
}
export const apiVersionValidationMiddleware = (
req: ExtendedRequestWithPageInfo,
res: Response,
next: NextFunction,
) => {
const apiVersion = req.query.apiVersion as string | string[] | undefined
// If no apiVersion is provided, continue (it will default to latest)
if (!apiVersion) {
return next()
}
// Validate apiVersion is a single string, not an array
if (Array.isArray(apiVersion)) {
return res.status(400).json({ error: "Multiple 'apiVersion' keys" })
}
// Get the version from the pathname query parameter
const pathname = req.pageinfo?.pathname || (req.query.pathname as string)
if (!pathname) {
// This should not happen as pathValidationMiddleware runs first
throw new Error('pathname not available for apiVersion validation')
}
// Extract version from the pathname
const currentVersion = getVersionStringFromPath(pathname) || nonEnterpriseDefaultVersion
const versionInfo = allVersions[currentVersion]
if (!versionInfo) {
return res.status(400).json({ error: `Invalid version '${currentVersion}'` })
}
const validApiVersions = versionInfo.apiVersions || []
// If this version has API versioning, validate the provided version
if (validApiVersions.length > 0 && !validApiVersions.includes(apiVersion)) {
return res.status(400).json({
error: `Invalid apiVersion '${apiVersion}' for ${currentVersion}. Valid API versions are: ${validApiVersions.join(', ')}`,
})
}
return next()
}

View File

@@ -0,0 +1,100 @@
# {{ page.title }}
{{ page.intro }}
{{ manualContent }}
{% for operation in restOperations %}
## {{ operation.title }}
```
{{ operation.verb | upcase }} {{ operation.requestPath }}
```
{{ operation.description }}
{% if operation.hasParameters %}
### Parameters
{% if operation.showHeaders %}
#### Headers
{% if operation.needsContentTypeHeader %}
- **`content-type`** (string, required)
Setting to `application/json` is required.
{% endif %}
- **`accept`** (string)
Setting to `application/vnd.github+json` is recommended.
{% endif %}
{% if operation.parameters.size > 0 %}
#### Path and query parameters
{% for param in operation.parameters %}
{% rest_parameter param %}
{% endfor %}
{% endif %}
{% if operation.bodyParameters.size > 0 %}
#### Body parameters
{% for param in operation.bodyParameters %}
{% rest_body_parameter param %}
{% endfor %}
{% endif %}
{% endif %}
{% if operation.statusCodes.size > 0 %}
### HTTP response status codes
{% for statusCode in operation.statusCodes %}
- **{{ statusCode.httpStatusCode }}**{% if statusCode.description %} - {{ statusCode.description }}{% elsif statusCode.httpStatusMessage %} - {{ statusCode.httpStatusMessage }}{% endif %}
{% endfor %}
{% endif %}
{% if operation.codeExamples.size > 0 %}
### Code examples
{% for example in operation.codeExamples %}
{% if example.request.description %}
#### {{ example.request.description }}
{% endif %}
**Request:**
```curl
curl -L \
-X {{ operation.verb | upcase }} \
{{ example.request.url }} \
{%- if example.request.acceptHeader %}
-H "Accept: {{ example.request.acceptHeader }}" \
{%- endif %}
-H "Authorization: Bearer <YOUR-TOKEN>"{% if apiVersion %} \
-H "X-GitHub-Api-Version: {{ apiVersion }}"{% endif -%}
{%- if example.request.bodyParameters %} \
-d '{{ example.request.bodyParameters }}'{% endif %}
```
**Response schema:**
{% if example.response.schema %}
```json
Status: {{ example.response.statusCode }}
{{ example.response.schema }}
```
{% else %}
```
Status: {{ example.response.statusCode }}
```
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,309 @@
import { beforeAll, describe, expect, test } from 'vitest'
import { get } from '@/tests/helpers/e2etest'
const makeURL = (pathname: string, apiVersion?: string): string => {
const params = new URLSearchParams({ pathname })
if (apiVersion) {
params.set('apiVersion', apiVersion)
}
return `/api/article/body?${params}`
}
describe('REST transformer', () => {
beforeAll(() => {
if (!process.env.ROOT) {
console.warn(
'WARNING: The REST transformer tests require the ROOT environment variable to be set to the fixture root',
)
}
})
test('REST page renders with markdown structure', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toContain('text/markdown')
// Check for the main heading
expect(res.body).toContain('# GitHub Actions Artifacts')
// Check for intro (using fixture's prodname_actions which is 'HubGit Actions')
expect(res.body).toContain('Use the REST API to interact with artifacts in HubGit Actions.')
// Check for manual content section heading
expect(res.body).toContain('## About artifacts in HubGit Actions')
})
test('REST operations are formatted correctly', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for operation heading
expect(res.body).toContain('## List artifacts for a repository')
// Check for HTTP method and endpoint
expect(res.body).toContain('GET /repos/{owner}/{repo}/actions/artifacts')
// Check for operation description
expect(res.body).toContain('Lists all artifacts for a repository.')
})
test('Parameters section includes headers', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for parameters heading
expect(res.body).toContain('### Parameters')
// Check for headers section
expect(res.body).toContain('#### Headers')
// Check for accept header
expect(res.body).toContain('**`accept`** (string)')
expect(res.body).toContain('Setting to `application/vnd.github+json` is recommended.')
})
test('Path and query parameters are listed', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for path and query parameters section
expect(res.body).toContain('#### Path and query parameters')
// Check for specific parameters
expect(res.body).toContain('**`owner`** (string) (required)')
expect(res.body).toContain('The account owner of the repository.')
expect(res.body).toContain('**`repo`** (string) (required)')
expect(res.body).toContain('**`per_page`** (integer)')
expect(res.body).toContain('Default: `30`')
})
test('Status codes are formatted correctly', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for status codes section
expect(res.body).toContain('### HTTP response status codes')
// Check for specific status code
expect(res.body).toContain('**200**')
expect(res.body).toContain('OK')
})
test('Code examples include curl with proper formatting', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for code examples section
expect(res.body).toContain('### Code examples')
// Check for request/response labels
expect(res.body).toContain('**Request:**')
expect(res.body).toContain('**Response schema:**')
// Check for curl code block
expect(res.body).toContain('```curl')
expect(res.body).toContain('curl -L \\')
expect(res.body).toContain('-X GET \\')
expect(res.body).toContain('https://api.github.com/repos/OWNER/REPO/actions/artifacts \\')
expect(res.body).toContain('-H "Accept: application/vnd.github.v3+json" \\')
expect(res.body).toContain('-H "Authorization: Bearer <YOUR-TOKEN>"')
})
test('Code examples include X-GitHub-Api-Version header by default', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for API version header in curl example
expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"')
})
test('Code examples include specified API version', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts', '2022-11-28'))
expect(res.statusCode).toBe(200)
// Check for the specified API version header
expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"')
})
test('Liquid tags are rendered in intro', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Liquid tags should be rendered, not shown as raw tags (fixture uses 'HubGit Actions')
expect(res.body).toContain('HubGit Actions')
expect(res.body).not.toContain('{% data variables.product.prodname_actions %}')
// Check in both the intro and the manual content section
expect(res.body).toMatch(/Use the REST API to interact with artifacts in HubGit Actions/)
expect(res.body).toMatch(/About artifacts in HubGit Actions/)
})
test('AUTOTITLE links are resolved', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check that AUTOTITLE has been resolved to actual link text
// The link should have the actual page title, not "AUTOTITLE"
expect(res.body).toContain('[Storing workflow data as artifacts]')
expect(res.body).toContain('(/en/actions/using-workflows/storing-workflow-data-as-artifacts)')
// Make sure the raw AUTOTITLE tag is not present
expect(res.body).not.toContain('[AUTOTITLE]')
// Verify the link appears in the manual content section
expect(res.body).toMatch(
/About artifacts in HubGit Actions[\s\S]*Storing workflow data as artifacts/,
)
})
test('Markdown links are preserved in descriptions', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check that markdown links are preserved
expect(res.body).toMatch(/\[.*?\]\(\/en\/.*?\)/)
})
test('Response schema is formatted correctly', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for JSON code block with schema label
expect(res.body).toContain('**Response schema:**')
expect(res.body).toContain('```json')
expect(res.body).toContain('Status: 200')
// Verify schema structure is present (not an example)
expect(res.body).toContain('"type":')
expect(res.body).toContain('"properties":')
// Check for common schema keywords
const schemaMatch = res.body.match(/```json\s+Status: 200\s+([\s\S]*?)```/)
expect(schemaMatch).toBeTruthy()
if (schemaMatch) {
const schemaContent = schemaMatch[1]
const schema = JSON.parse(schemaContent)
// Verify it's a valid OpenAPI/JSON schema structure
expect(schema).toHaveProperty('type')
expect(schema.type).toBe('object')
expect(schema).toHaveProperty('properties')
// Verify it has expected properties for artifacts response
expect(schema.properties).toHaveProperty('total_count')
expect(schema.properties).toHaveProperty('artifacts')
}
})
test('Non-REST pages return appropriate error', async () => {
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'))
expect(res.statusCode).toBe(200)
// Regular article pages should still work, they just won't use the transformer
expect(res.body).toContain('## Introduction')
})
test('Invalid apiVersion returns 400 error', async () => {
// An invalid API version should return a validation error with 400 status
const res = await get(makeURL('/en/rest/actions/artifacts', 'invalid-version'))
// Returns 400 because the apiVersion is invalid (client error)
expect(res.statusCode).toBe(400)
const parsed = JSON.parse(res.body)
expect(parsed.error).toContain("Invalid apiVersion 'invalid-version'")
expect(parsed.error).toContain('Valid API versions are:')
expect(parsed.error).toContain('2022-11-28')
})
test('Multiple apiVersion query parameters returns 400 error', async () => {
// Multiple apiVersion parameters should be rejected
const res = await get(
'/api/article/body?pathname=/en/rest/actions/artifacts&apiVersion=2022-11-28&apiVersion=2023-01-01',
)
expect(res.statusCode).toBe(400)
const parsed = JSON.parse(res.body)
expect(parsed.error).toBe("Multiple 'apiVersion' keys")
})
test('Valid apiVersion passes validation', async () => {
// A valid API version should work
const res = await get(makeURL('/en/rest/actions/artifacts', '2022-11-28'))
expect(res.statusCode).toBe(200)
expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"')
})
test('Missing apiVersion defaults to latest', async () => {
// When no apiVersion is provided, it should default to the latest version
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Should include the default API version header
expect(res.body).toContain('-H "X-GitHub-Api-Version: 2022-11-28"')
})
test('Multiple operations on a page are all rendered', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Check for multiple operation headings
expect(res.body).toContain('## List artifacts for a repository')
expect(res.body).toContain('## Get an artifact')
expect(res.body).toContain('## Delete an artifact')
})
test('Body parameters are formatted correctly for POST/PUT operations', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// For operations with body parameters, check formatting
// (artifacts endpoint is mostly GET/DELETE, but structure should be there)
// The transformer handles body parameters when present
})
test('Content-type header is included for operations that need it', async () => {
const res = await get(makeURL('/en/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// Content-type header appears for operations that require it
// The REST transformer adds this based on the operation data
})
test('Non-English language paths work correctly', async () => {
// Note: This test may fail in dev mode with ENABLED_LANGUAGES=en
// but the transformer itself should handle any language path
const res = await get(makeURL('/ja/rest/actions/artifacts'))
expect(res.statusCode).toBe(200)
// The transformer should work regardless of language prefix
// because it looks for 'rest' in the path and gets the category/subcategory after it
// e.g. /ja/rest/actions/artifacts should work the same as /en/rest/actions/artifacts
// Verify the operation content is present (in English, since REST data is not translated)
expect(res.body).toContain('## List artifacts for a repository')
expect(res.body).toContain('GET /repos/{owner}/{repo}/actions/artifacts')
// Check what language is actually being served by examining the response
// If Japanese translations are loaded, the title will be in Japanese
// Otherwise, it falls back to English
const hasJapaneseTitle = res.body.includes('# GitHub Actions アーティファクト')
const hasEnglishTitle = res.body.includes('# GitHub Actions Artifacts')
// One of them must be present
expect(hasJapaneseTitle || hasEnglishTitle).toBe(true)
// Verify the appropriate content based on which language was served
if (hasJapaneseTitle) {
// If Japanese is loaded, expect Japanese intro text
expect(res.body).toContain('アーティファクト')
} else {
// If Japanese is not loaded, expect English fallback
expect(res.body).toContain('Use the REST API to interact with artifacts in HubGit Actions')
}
})
})

View File

@@ -0,0 +1,18 @@
import { TransformerRegistry } from './types'
import { RestTransformer } from './rest-transformer'
/**
* Global transformer registry
* Registers all available page-to-markdown transformers
*/
export const transformerRegistry = new TransformerRegistry()
// Register REST transformer
transformerRegistry.register(new RestTransformer())
// Future transformers can be registered here:
// transformerRegistry.register(new WebhooksTransformer())
// transformerRegistry.register(new GitHubAppsTransformer())
export { TransformerRegistry } from './types'
export type { PageTransformer } from './types'

View File

@@ -0,0 +1,210 @@
import type { Context, Page } from '@/types'
import type { PageTransformer } from './types'
import type { Operation } from '@/rest/components/types'
import { renderContent } from '@/content-render/index'
import matter from '@gr2m/gray-matter'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { fastTextOnly } from '@/content-render/unified/text-only'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Transformer for REST API pages
* Converts REST operations and their data into markdown format using a Liquid template
*/
export class RestTransformer implements PageTransformer {
canTransform(page: Page): boolean {
// Only transform REST pages that are not landing pages
// Landing pages (like /en/rest) will be handled by a separate transformer
return page.autogenerated === 'rest' && !page.relativePath.endsWith('index.md')
}
async transform(
page: Page,
pathname: string,
context: Context,
apiVersion?: string,
): Promise<string> {
// Import getRest dynamically to avoid circular dependencies
const { default: getRest } = await import('@/rest/lib/index')
// Extract version from context
const currentVersion = context.currentVersion!
// Use the provided apiVersion, or fall back to the latest from context
const effectiveApiVersion =
apiVersion ||
(context.currentVersionObj?.apiVersions?.length
? context.currentVersionObj.latestApiVersion
: undefined)
// Parse the category and subcategory from the page path
// e.g. /en/rest/actions/artifacts -> category: actions, subcategory: artifacts
const pathParts = pathname.split('/').filter(Boolean)
const restIndex = pathParts.indexOf('rest')
if (restIndex === -1 || restIndex >= pathParts.length - 1) {
throw new Error(`Invalid REST path: ${pathname}`)
}
const category = pathParts[restIndex + 1]
const subcategory = pathParts[restIndex + 2] // May be undefined for category-only pages
// Get the REST operations data
const restData = await getRest(currentVersion, effectiveApiVersion)
let operations: Operation[] = []
if (subcategory && restData[category]?.[subcategory]) {
operations = restData[category][subcategory]
} else if (category && restData[category]) {
// For categories without subcategories, operations are nested directly
const categoryData = restData[category]
// Flatten all operations from all subcategories
operations = Object.values(categoryData).flat()
}
// Prepare manual content
let manualContent = ''
if (page.markdown) {
const markerIndex = page.markdown.indexOf(
'<!-- Content after this section is automatically generated -->',
)
if (markerIndex > 0) {
const { content } = matter(page.markdown)
const manualContentMarkerIndex = content.indexOf(
'<!-- Content after this section is automatically generated -->',
)
if (manualContentMarkerIndex > 0) {
const rawManualContent = content.substring(0, manualContentMarkerIndex).trim()
if (rawManualContent) {
manualContent = await renderContent(rawManualContent, {
...context,
markdownRequested: true,
})
}
}
}
}
// Prepare data for template
const templateData = await this.prepareTemplateData(
page,
operations,
context,
manualContent,
effectiveApiVersion,
)
// Load and render template
const templatePath = join(__dirname, '../templates/rest-page.template.md')
const templateContent = readFileSync(templatePath, 'utf8')
// Render the template with Liquid
const rendered = await renderContent(templateContent, {
...context,
...templateData,
markdownRequested: true,
})
return rendered
}
/**
* Prepare data for the Liquid template
*/
private async prepareTemplateData(
page: Page,
operations: Operation[],
context: Context,
manualContent: string,
apiVersion?: string,
): Promise<Record<string, any>> {
// Prepare page intro
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
// Prepare operations for the template
const preparedOperations = await Promise.all(
operations.map(async (operation) => await this.prepareOperation(operation)),
)
return {
page: {
title: page.title,
intro,
},
manualContent,
restOperations: preparedOperations,
apiVersion,
}
}
/**
* Prepare a single operation for template rendering
*/
private async prepareOperation(operation: Operation): Promise<Record<string, any>> {
// Convert HTML description to text
const description = operation.descriptionHTML ? fastTextOnly(operation.descriptionHTML) : ''
// Determine header settings
const needsContentTypeHeader = operation.subcategory === 'inference'
const omitHeaders =
operation.subcategory === 'management-console' || operation.subcategory === 'manage-ghes'
const showHeaders = !omitHeaders
// Check if operation has parameters
const hasParameters =
(operation.parameters?.length || 0) > 0 || (operation.bodyParameters?.length || 0) > 0
// Process status codes to convert HTML descriptions to plain text
const statusCodes = operation.statusCodes?.map((statusCode) => ({
...statusCode,
description: statusCode.description ? fastTextOnly(statusCode.description) : undefined,
}))
// Prepare code examples with processed URLs
const codeExamples =
operation.codeExamples?.map((example) => {
let url = `${operation.serverUrl}${operation.requestPath}`
// Replace path parameters in URL
if (example.request?.parameters && Object.keys(example.request.parameters).length > 0) {
for (const [key, value] of Object.entries(example.request.parameters)) {
url = url.replace(`{${key}}`, String(value))
}
}
return {
request: {
description: example.request?.description
? fastTextOnly(example.request.description)
: '',
url,
acceptHeader: example.request?.acceptHeader,
bodyParameters: example.request?.bodyParameters
? JSON.stringify(example.request.bodyParameters, null, 2)
: null,
},
response: {
statusCode: example.response?.statusCode,
schema: (example.response as any)?.schema
? JSON.stringify((example.response as any).schema, null, 2)
: null,
},
}
}) || []
return {
...operation,
description,
hasParameters,
showHeaders,
needsContentTypeHeader,
statusCodes,
codeExamples,
}
}
}

View File

@@ -0,0 +1,103 @@
import type { Context, Page } from '@/types'
/**
* Base interface for page-to-markdown transformers
*
* Transformers convert autogenerated pages (REST, webhooks, etc.)
* into markdown format for the Article API
*/
export interface PageTransformer {
/**
* Check if this transformer can handle the given page
*/
canTransform(page: Page): boolean
/**
* Transform the page into markdown format
* @param page - The page to transform
* @param pathname - The pathname of the page
* @param context - The rendering context
* @param apiVersion - Optional API version (e.g., '2022-11-28' for REST API calendar versioning)
*/
transform(page: Page, pathname: string, context: Context, apiVersion?: string): Promise<string>
}
/**
* Registry of available transformers for converting pages to markdown
*
* The TransformerRegistry manages a collection of PageTransformer instances
* and provides a mechanism to find the appropriate transformer for a given page.
*
* Transformers are evaluated in registration order. The first transformer
* whose `canTransform()` method returns true will be selected.
*
* @example
* ```typescript
* const registry = new TransformerRegistry()
*
* // Register transformers in priority order
* registry.register(new RestTransformer())
* registry.register(new WebhookTransformer())
* registry.register(new GraphQLTransformer())
*
* // Find and use a transformer
* const transformer = registry.findTransformer(page)
* if (transformer) {
* const markdown = await transformer.transform(page, pathname, context)
* }
* ```
*
* @remarks
* This class is not thread-safe. In server environments with concurrent requests,
* register all transformers during initialization before handling requests.
*/
export class TransformerRegistry {
private transformers: PageTransformer[] = []
/**
* Register a new transformer
*
* Transformers are evaluated in registration order when finding a match.
* Register more specific transformers before more general ones.
*
* @param transformer - The transformer to register
*
* @example
* ```typescript
* const registry = new TransformerRegistry()
* registry.register(new RestTransformer())
* ```
*/
register(transformer: PageTransformer): void {
this.transformers.push(transformer)
}
/**
* Find a transformer that can handle the given page
*
* Iterates through registered transformers in registration order and returns
* the first transformer whose `canTransform()` method returns true.
*
* @param page - The page to find a transformer for
* @returns The first matching transformer, or null if:
* - The page is null/undefined
* - No registered transformer can handle the page
*
* @example
* ```typescript
* const transformer = registry.findTransformer(page)
* if (transformer) {
* const markdown = await transformer.transform(page, pathname, context)
* } else {
* // Handle case where no transformer is available
* console.warn('No transformer found for page:', page.relativePath)
* }
* ```
*/
findTransformer(page: Page): PageTransformer | null {
if (page == null) {
return null
}
return this.transformers.find((t) => t.canTransform(page)) || null
}
}

View File

@@ -10,6 +10,7 @@ import { Tool, tags as toolTags } from './tool'
import { Spotlight, tags as spotlightTags } from './spotlight'
import { Prompt } from './prompt'
import IndentedDataReference from './indented-data-reference'
import { apiTransformerTags } from '@/article-api/liquid-renderers'
// Type assertions for .js files without type definitions
// Copilot: Remove these assertions when the corresponding .js files are converted to TypeScript
@@ -40,6 +41,11 @@ for (const tag in spotlightTags) {
engine.registerTag('prompt', anyPrompt)
// Register API transformer tags
for (const [tagName, tagClass] of Object.entries(apiTransformerTags)) {
engine.registerTag(tagName, tagClass as any)
}
/**
* Like the `size` filter, but specifically for
* getting the number of keys in an object

View File

@@ -11,4 +11,5 @@ versions:
ghec: '*'
children:
- /category
- /using-workflows
---

View File

@@ -0,0 +1,12 @@
---
title: Using workflows
intro: Learn how to use workflows in GitHub Actions.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
children:
- /storing-workflow-data-as-artifacts
---
This is a fixture index page for testing.

View File

@@ -0,0 +1,10 @@
---
title: Storing workflow data as artifacts
intro: Artifacts allow you to share data between jobs in a workflow and store data once that workflow has completed.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
---
This is a fixture file for testing links in the REST API artifacts documentation.

View File

@@ -16,4 +16,6 @@ autogenerated: rest
## About artifacts in {% data variables.product.prodname_actions %}
You can use the REST API to download, delete, and retrieve information about workflow artifacts in {% data variables.product.prodname_actions %}. Artifacts enable you to share data between jobs in a workflow and store data once that workflow has completed. For more information, see [AUTOTITLE](/actions/using-workflows/storing-workflow-data-as-artifacts).
<!-- Content after this section is automatically generated -->