add transformer pattern & rest transformer for API (#58388)
Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
This commit is contained in:
@@ -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**:
|
||||
|
||||
16
src/article-api/liquid-renderers/index.ts
Normal file
16
src/article-api/liquid-renderers/index.ts
Normal 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'
|
||||
230
src/article-api/liquid-renderers/rest-tags.ts
Normal file
230
src/article-api/liquid-renderers/rest-tags.ts
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
100
src/article-api/templates/rest-page.template.md
Normal file
100
src/article-api/templates/rest-page.template.md
Normal 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 %}
|
||||
309
src/article-api/tests/rest-transformer.ts
Normal file
309
src/article-api/tests/rest-transformer.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
18
src/article-api/transformers/index.ts
Normal file
18
src/article-api/transformers/index.ts
Normal 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'
|
||||
210
src/article-api/transformers/rest-transformer.ts
Normal file
210
src/article-api/transformers/rest-transformer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/article-api/transformers/types.ts
Normal file
103
src/article-api/transformers/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -11,4 +11,5 @@ versions:
|
||||
ghec: '*'
|
||||
children:
|
||||
- /category
|
||||
- /using-workflows
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user