1
0
mirror of synced 2025-12-19 09:57:42 -05:00

Add GraphQL transformer for Article API (#58719)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Kevin Heis
2025-12-11 07:38:55 -08:00
committed by GitHub
parent 0b2516e377
commit bb7e473a53
23 changed files with 1264 additions and 1 deletions

View File

@@ -23,7 +23,12 @@ The `/api/article` endpoints return information about a page by `pathname`.
### 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:
For autogenerated pages (REST, GraphQL, webhooks, landing pages, audit logs, 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:
#### Current Transformers
- **REST Transformer** (`rest-transformer.ts`) - Converts REST API operations into markdown, including endpoints, parameters, status codes, and code examples
- **GraphQL Transformer** (`graphql-transformer.ts`) - Converts GraphQL schema documentation into markdown, including queries, mutations, objects, interfaces, enums, unions, input objects, scalars, changelog, and breaking changes
To add a new transformer for other autogenerated content types:
1. Create a new transformer file implementing the `PageTransformer` interface

View File

@@ -0,0 +1,21 @@
# {{ pageTitle }}
{{ pageIntro }}
{{ manualContent }}
{% for change in breakingChangesByDate %}
## {{ change.heading }}
{% for item in change.items %}
- {% if item.criticality == 'breaking' %}**Breaking**{% else %}**Dangerous**{% endif %} A change will be made to `{{ item.location }}`.
**Description:** {{ item.description }}
**Reason:** {{ item.reason }}
{% endfor %}
{% endfor %}

View File

@@ -0,0 +1,38 @@
# {{ pageTitle }}
{{ pageIntro }}
{{ manualContent }}
{% for item in changelogItems %}
## Schema changes for {{ item.date }}
{% for schemaChange in item.schemaChanges %}
### {{ schemaChange.title }}
{% for change in schemaChange.changes %}- {{ change }}
{% endfor %}
{% endfor %}
{% for previewChange in item.previewChanges %}
### {{ previewChange.title }}
{% for change in previewChange.changes %}- {{ change }}
{% endfor %}
{% endfor %}
{% for upcomingChange in item.upcomingChanges %}
### {{ upcomingChange.title }}
{% for change in upcomingChange.changes %}- {{ change }}
{% endfor %}
{% endfor %}
{% endfor %}

View File

@@ -0,0 +1,9 @@
# {{ pageTitle }}
{{ pageIntro }}
{{ manualContent }}
## Reference pages
{{ childrenLinks }}

View File

@@ -0,0 +1,120 @@
# {{ pageTitle }}
{{ pageIntro }}
{{ manualContent }}
{% for item in items %}
## {{ item.name }}
{{ item.description }}
{% if item.isDeprecated %}
> [!WARNING]
> **Deprecation notice:** {{ item.deprecationReason }}
{% endif %}
{% if pageType == 'queries' %}
**Type:** [{{ item.type }}]({{ item.href }})
{% if item.args.size > 0 %}
### Arguments for `{{ item.name }}`
| Name | Type | Description |
| --- | --- | --- |
{% for arg in item.args %}| `{{ arg.name }}` | [`{{ arg.type }}`]({{ arg.href }}) | {{ arg.description }} |
{% endfor %}
{% endif %}
{% elsif pageType == 'mutations' %}
{% if item.inputFields.size > 0 %}
### Input fields for `{{ item.name }}`
| Name | Type | Description |
| --- | --- | --- |
{% for field in item.inputFields %}| `{{ field.name }}` | [`{{ field.type }}`]({{ field.href }}) | {{ field.description }}{% if field.defaultValue %} Default: `{{ field.defaultValue }}`.{% endif %} |
{% endfor %}
{% endif %}
{% if item.returnFields.size > 0 %}
### Return fields for `{{ item.name }}`
| Name | Type | Description |
| --- | --- | --- |
{% for field in item.returnFields %}| `{{ field.name }}` | [`{{ field.type }}`]({{ field.href }}) | {{ field.description }}{% if field.defaultValue %} Default: `{{ field.defaultValue }}`.{% endif %}{% if field.isDeprecated %} **Deprecated:** {{ field.deprecationReason }}{% endif %} |
{% endfor %}
{% endif %}
{% elsif pageType == 'objects' %}
{% if item.implements.size > 0 %}
### Implements
{% for impl in item.implements %}- [`{{ impl.name }}`]({{ impl.href }})
{% endfor %}
{% endif %}
{% if item.fields.size > 0 %}
### Fields for `{{ item.name }}`
| Name | Type | Description |
| --- | --- | --- |
{% for field in item.fields %}| `{{ field.name }}` | [`{{ field.type }}`]({{ field.href }}) | {{ field.description }}{% if field.defaultValue %} Default: `{{ field.defaultValue }}`.{% endif %}{% if field.isDeprecated %} **Deprecated:** {{ field.deprecationReason }}{% endif %}{% if field.arguments.size > 0 %}<br><br>**Arguments:**<br>{% for arg in field.arguments %}- `{{ arg.name }}` ([`{{ arg.type.name }}`]({{ arg.type.href }})): {{ arg.description }}{% if arg.defaultValue %} Default: `{{ arg.defaultValue }}`.{% endif %}<br>{% endfor %}{% endif %} |
{% endfor %}
{% endif %}
{% elsif pageType == 'interfaces' %}
{% if item.fields.size > 0 %}
### Fields for `{{ item.name }}`
| Name | Type | Description |
| --- | --- | --- |
{% for field in item.fields %}| `{{ field.name }}` | [`{{ field.type }}`]({{ field.href }}) | {{ field.description }}{% if field.defaultValue %} Default: `{{ field.defaultValue }}`.{% endif %}{% if field.isDeprecated %} **Deprecated:** {{ field.deprecationReason }}{% endif %}{% if field.arguments.size > 0 %}<br><br>**Arguments:**<br>{% for arg in field.arguments %}- `{{ arg.name }}` ([`{{ arg.type.name }}`]({{ arg.type.href }})): {{ arg.description }}{% if arg.defaultValue %} Default: `{{ arg.defaultValue }}`.{% endif %}<br>{% endfor %}{% endif %} |
{% endfor %}
{% endif %}
{% elsif pageType == 'enums' %}
{% if item.values.size > 0 %}
### Values for `{{ item.name }}`
{% for value in item.values %}**`{{ value.name }}`**
{{ value.description }}
{% endfor %}
{% endif %}
{% elsif pageType == 'unions' %}
{% if item.possibleTypes.size > 0 %}
### Possible types for `{{ item.name }}`
{% for type in item.possibleTypes %}- [`{{ type.name }}`]({{ type.href }})
{% endfor %}
{% endif %}
{% elsif pageType == 'inputObjects' %}
{% if item.inputFields.size > 0 %}
### Input fields for `{{ item.name }}`
| Name | Type | Description |
| --- | --- | --- |
{% for field in item.inputFields %}| `{{ field.name }}` | [`{{ field.type }}`]({{ field.href }}) | {{ field.description }}{% if field.defaultValue %} Default: `{{ field.defaultValue }}`.{% endif %}{% if field.isDeprecated %} **Deprecated:** {{ field.deprecationReason }}{% endif %} |
{% endfor %}
{% endif %}
{% elsif pageType == 'scalars' %}
{%- comment -%}Scalars typically just have name and description{%- endcomment -%}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,370 @@
import { beforeAll, describe, expect, test } from 'vitest'
import { get } from '@/tests/helpers/e2etest'
const makeURL = (pathname: string): string => {
const params = new URLSearchParams({ pathname })
return `/api/article/body?${params}`
}
describe('GraphQL transformer', { timeout: 10000 }, () => {
// Cache expensive responses to avoid duplicate requests
const responseCache = new Map<string, Awaited<ReturnType<typeof get>>>()
const getCached = async (url: string) => {
if (!responseCache.has(url)) {
responseCache.set(url, await get(makeURL(url)))
}
return responseCache.get(url)!
}
beforeAll(() => {
if (!process.env.ROOT) {
console.warn(
'WARNING: The GraphQL transformer tests require the ROOT environment variable to be set to the fixture root',
)
}
})
describe('Reference pages', () => {
test('queries page renders with markdown structure', async () => {
const res = await getCached('/en/graphql/reference/queries')
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toContain('text/markdown')
// Check for the main heading
expect(res.body).toContain('# Queries')
// Check for intro
expect(res.body).toContain(
'The query type defines GraphQL operations that retrieve data from the server.',
)
// Check for manual content section
expect(res.body).toContain('## About queries')
expect(res.body).toContain('Every GraphQL schema has a root type')
})
test('queries are formatted correctly', async () => {
const res = await getCached('/en/graphql/reference/queries')
expect(res.statusCode).toBe(200)
// Check for query heading
expect(res.body).toContain('## repository')
// Check for query description
expect(res.body).toContain('Lookup a given repository by the owner and repository name.')
// Check for type link
expect(res.body).toContain('**Type:** [Repository](/en/graphql/reference/objects#repository)')
})
test('query arguments are listed in table format', async () => {
const res = await getCached('/en/graphql/reference/queries')
expect(res.statusCode).toBe(200)
// Check for arguments table for codeOfConduct query
expect(res.body).toContain('### Arguments for `codeOfConduct`')
expect(res.body).toMatch(/\|\s*Name\s*\|\s*Type\s*\|\s*Description\s*\|/)
expect(res.body).toMatch(/\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|/)
// Check for specific arguments
expect(res.body).toMatch(/\|\s*`key`\s*\|/)
expect(res.body).toContain('[`String!`](/en/graphql/reference/scalars#string)')
expect(res.body).toContain("The code of conduct's key.")
})
test('mutations page renders correctly', async () => {
const res = await getCached('/en/graphql/reference/mutations')
expect(res.statusCode).toBe(200)
// Check for mutation heading
expect(res.body).toContain('## createRepository')
// Check for mutation description
expect(res.body).toContain('Create a new repository.')
// Check for input fields table
expect(res.body).toContain('### Input fields for `createRepository`')
expect(res.body).toContain('| `input` |')
// Check for return fields table
expect(res.body).toContain('### Return fields for `createRepository`')
expect(res.body).toMatch(/\|\s*`repository`\s*\|/)
expect(res.body).toContain('The new repository.')
})
test('objects page renders with implements and fields', async () => {
const res = await getCached('/en/graphql/reference/objects')
expect(res.statusCode).toBe(200)
// Check for object heading - AddedToMergeQueueEvent has implements
expect(res.body).toContain('## AddedToMergeQueueEvent')
// Check for implements section
expect(res.body).toContain('### Implements')
expect(res.body).toMatch(/[*-]\s*\[`Node`\]\(\/.*graphql\/reference\/interfaces#node\)/)
// Check for fields table
expect(res.body).toContain('### Fields for `AddedToMergeQueueEvent`')
expect(res.body).toMatch(/\|\s*`id`\s*\|/)
expect(res.body).toMatch(/\|\s*`actor`\s*\|/)
expect(res.body).toMatch(/\|\s*`createdAt`\s*\|/)
})
test('objects page shows field arguments inline', async () => {
const res = await getCached('/en/graphql/reference/objects')
expect(res.statusCode).toBe(200)
// Check for User object with repositories field that has arguments
expect(res.body).toContain('## User')
expect(res.body).toContain('| `repositories` |')
// Check for inline arguments formatting
expect(res.body).toContain('**Arguments:**')
expect(res.body).toContain('- `first`')
expect(res.body).toContain('Returns the first n elements from the list.')
expect(res.body).toContain('- `orderBy`')
})
test('interfaces page renders correctly', async () => {
const res = await getCached('/en/graphql/reference/interfaces')
expect(res.statusCode).toBe(200)
// Check for interface heading
expect(res.body).toContain('## Node')
// Check for interface description
expect(res.body).toContain('An object with an ID.')
// Check for fields table
expect(res.body).toContain('### Fields for `Node`')
expect(res.body).toContain('| `id` |')
expect(res.body).toContain('ID of the object.')
})
test('enums page renders with values', async () => {
const res = await getCached('/en/graphql/reference/enums')
expect(res.statusCode).toBe(200)
// Check for enum heading
expect(res.body).toContain('## RepositoryVisibility')
// Check for enum description
expect(res.body).toContain("The repository's visibility level.")
// Check for values section
expect(res.body).toContain('### Values for `RepositoryVisibility`')
expect(res.body).toContain('**`PUBLIC`**')
expect(res.body).toContain('The repository is visible to everyone.')
expect(res.body).toContain('**`PRIVATE`**')
expect(res.body).toContain('The repository is visible only to those with explicit access.')
expect(res.body).toContain('**`INTERNAL`**')
})
test('unions page renders with possible types', async () => {
const res = await getCached('/en/graphql/reference/unions')
expect(res.statusCode).toBe(200)
// Check for union heading
expect(res.body).toContain('## SearchResultItem')
// Check for union description
expect(res.body).toContain('The results of a search.')
// Check for possible types
expect(res.body).toContain('### Possible types for `SearchResultItem`')
expect(res.body).toMatch(/[*-]\s*\[`Bot`\]\(\/.*graphql\/reference\/objects#bot\)/)
expect(res.body).toMatch(
/[*-]\s*\[`PullRequest`\]\(\/.*graphql\/reference\/objects#pullrequest\)/,
)
expect(res.body).toMatch(/[*-]\s*\[`User`\]\(\/.*graphql\/reference\/objects#user\)/)
})
test('input-objects page renders correctly', async () => {
const res = await getCached('/en/graphql/reference/input-objects')
expect(res.statusCode).toBe(200)
// Check for input object heading
expect(res.body).toContain('## AbortQueuedMigrationsInput')
// Check for input object description
expect(res.body).toContain('Autogenerated input type of CreateRepository.')
// Check for input fields table
expect(res.body).toContain('### Input fields for `AbortQueuedMigrationsInput`')
expect(res.body).toMatch(/\|\s*`ownerId`\s*\|/)
expect(res.body).toContain('The ID of the organization that is running the migrations.')
})
test('scalars page renders correctly', async () => {
const res = await getCached('/en/graphql/reference/scalars')
expect(res.statusCode).toBe(200)
// Check for scalar heading
expect(res.body).toContain('## Boolean')
// Check for scalar description
expect(res.body).toContain('Represents true or false values.')
// Check for other scalars
expect(res.body).toContain('## String')
expect(res.body).toContain('## ID')
expect(res.body).toContain('## Int')
})
test('reference index page renders', async () => {
const res = await getCached('/en/graphql/reference')
expect(res.statusCode).toBe(200)
// Check for main heading
expect(res.body).toContain('# Reference')
// Check for intro with liquid variable rendered
expect(res.body).toMatch(/(GitHub|HubGit) GraphQL API schema/)
})
})
describe('Overview pages', () => {
test('changelog page renders with changes', async () => {
const res = await getCached('/en/graphql/overview/changelog')
expect(res.statusCode).toBe(200)
// Check for main heading
expect(res.body).toContain('# Changelog')
// Check for intro
expect(res.body).toContain(
'The GraphQL schema changelog is a list of recent and upcoming changes',
)
// Check for manual content
expect(res.body).toContain(
'Breaking changes include changes that will break existing queries',
)
// Check for date-based changelog sections
expect(res.body).toContain('## Schema changes for 2025-11-30')
// Check for change items
expect(res.body).toContain('### The GraphQL schema includes these changes:')
expect(res.body).toContain('Type SuggestedReviewerActor was added')
})
test('changelog removes HTML tags from changes', async () => {
const res = await getCached('/en/graphql/overview/changelog')
expect(res.statusCode).toBe(200)
// Check that HTML tags are removed
expect(res.body).toContain('Field suggestedReviewerActors was added')
expect(res.body).not.toContain('<code>')
expect(res.body).not.toContain('</code>')
expect(res.body).not.toContain('<p>')
expect(res.body).not.toContain('</p>')
})
test('breaking changes page renders with scheduled changes', async () => {
const res = await getCached('/en/graphql/overview/breaking-changes')
expect(res.statusCode).toBe(200)
// Check for main heading
expect(res.body).toContain('# Breaking changes')
// Check for intro
expect(res.body).toContain('Learn about recent and upcoming breaking changes')
// Check for manual content
expect(res.body).toContain('## About breaking changes')
expect(res.body).toContain('Breaking:** Changes that will break existing queries')
// Check for date-based sections
expect(res.body).toContain('## Changes scheduled for 2025-04-01')
expect(res.body).toContain('## Changes scheduled for 2026-04-01')
})
test('breaking changes shows criticality levels', async () => {
const res = await getCached('/en/graphql/overview/breaking-changes')
expect(res.statusCode).toBe(200)
// Check for breaking criticality
expect(res.body).toMatch(/\*\*Breaking\*\*\s+A change will be made to `\w+\.\w+`\./)
expect(res.body).toMatch(/\*\*Description:\*\*.*will be removed/)
expect(res.body).toMatch(/\*\*Reason:\*\*/)
})
test('breaking changes removes HTML tags', async () => {
const res = await getCached('/en/graphql/overview/breaking-changes')
expect(res.statusCode).toBe(200)
expect(res.body).toContain('scheduled for')
// Check that HTML tags are removed from descriptions
expect(res.body).not.toContain('<p>')
expect(res.body).not.toContain('</p>')
expect(res.body).not.toContain('<code>')
expect(res.body).not.toContain('</code>')
expect(res.body).not.toContain('<p>')
expect(res.body).not.toContain('</p>')
})
})
describe('Liquid tags', () => {
test('AUTOTITLE links are resolved in manual content', async () => {
const res = await getCached('/en/graphql/reference/queries')
expect(res.statusCode).toBe(200)
// Check that AUTOTITLE has been resolved
expect(res.body).toMatch(/(Forming calls with GraphQL|Hello World)/)
expect(res.body).toContain('(/en/get-started/start-your-journey/hello-world)')
// Make sure the raw AUTOTITLE tag is not present
expect(res.body).not.toContain('[AUTOTITLE]')
})
test('Liquid variables are rendered in intro', async () => {
const res = await getCached('/en/graphql/reference')
expect(res.statusCode).toBe(200)
// Liquid variables should be rendered
expect(res.body).toMatch(/(GitHub|HubGit) GraphQL API schema/)
expect(res.body).not.toContain('{% data variables.product.prodname_dotcom %}')
})
test('Liquid variables are rendered in breaking changes', async () => {
const res = await getCached('/en/graphql/overview/breaking-changes')
expect(res.statusCode).toBe(200)
// Check that liquid variables in intro are rendered
expect(res.body).toMatch(/(GitHub|HubGit) GraphQL API/)
expect(res.body).not.toContain('{% data variables.product.prodname_dotcom %}')
})
})
describe('Multiple items', () => {
test('multiple queries are all rendered', async () => {
const res = await getCached('/en/graphql/reference/queries')
expect(res.statusCode).toBe(200)
// Check for multiple query headings
expect(res.body).toContain('## repository')
expect(res.body).toContain('## viewer')
})
test('multiple objects are all rendered', async () => {
const res = await getCached('/en/graphql/reference/objects')
expect(res.statusCode).toBe(200)
// Check for multiple object headings
expect(res.body).toContain('## Repository')
expect(res.body).toContain('## User')
})
test('multiple enums are all rendered', async () => {
const res = await getCached('/en/graphql/reference/enums')
expect(res.statusCode).toBe(200)
// Check for multiple enum headings
expect(res.body).toContain('## RepositoryVisibility')
expect(res.body).toContain('## OrderDirection')
})
})
})

View File

@@ -0,0 +1,478 @@
import type { Context, Page } from '@/types'
import type { PageTransformer } from './types'
import type {
QueryT,
MutationT,
ObjectT,
InterfaceT,
EnumT,
UnionT,
InputObjectT,
ScalarT,
ChangelogItemT,
BreakingChangesT,
FieldT,
} from '@/graphql/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'
import GithubSlugger from 'github-slugger'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Transformer for GraphQL pages
* Converts GraphQL schema data into markdown format using Liquid templates
*/
export class GraphQLTransformer implements PageTransformer {
canTransform(page: Page): boolean {
return page.autogenerated === 'graphql'
}
async transform(page: Page, pathname: string, context: Context): Promise<string> {
const currentVersion = context.currentVersion!
// Determine the page type from the pathname
const pathParts = pathname.split('/').filter(Boolean)
const graphqlIndex = pathParts.indexOf('graphql')
if (graphqlIndex === -1) {
throw new Error(`Invalid GraphQL path: ${pathname}`)
}
const section = pathParts[graphqlIndex + 1] // 'reference' or 'overview'
const pageType = pathParts[graphqlIndex + 2] // specific page like 'queries', 'changelog', etc.
// Handle different GraphQL page types
if (section === 'overview' && pageType === 'changelog') {
return await this.transformChangelog(page, currentVersion, context)
} else if (section === 'overview' && pageType === 'breaking-changes') {
return await this.transformBreakingChanges(page, currentVersion, context)
} else if (section === 'reference' && pageType) {
return await this.transformReference(page, currentVersion, context, pageType)
} else if (section === 'reference' && !pageType) {
// Index page - just render the intro and manual content
return await this.transformIndexPage(page, context)
}
throw new Error(`Unsupported GraphQL page type: ${pathname}`)
}
/**
* Transform the GraphQL reference index page
*/
private async transformIndexPage(page: Page, context: Context): Promise<string> {
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
const manualContent = await this.extractManualContent(page, context)
// Get children links from page metadata
const children = page.children || []
const childrenLinks = children
.map((child) => {
const childPath = child.startsWith('/') ? child : `/${child}`
const childName = childPath.split('/').pop() || ''
const displayName = childName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return `- [${displayName}](${childPath})`
})
.join('\n')
const templateData = {
pageTitle: page.title,
pageIntro: intro,
manualContent,
childrenLinks,
}
const templatePath = join(__dirname, '../templates/graphql-index.template.md')
const templateContent = readFileSync(templatePath, 'utf8')
return await renderContent(templateContent, {
...context,
...templateData,
markdownRequested: true,
})
}
/**
* Transform GraphQL reference pages (queries, mutations, objects, etc.)
*/
private async transformReference(
page: Page,
currentVersion: string,
context: Context,
pageType: string,
): Promise<string> {
// Import GraphQL data functions dynamically
const { getGraphqlSchema } = await import('@/graphql/lib/index')
// Map URL-friendly page type to internal schema key
const schemaKey = pageType === 'input-objects' ? 'inputObjects' : pageType
const schema = getGraphqlSchema(currentVersion, schemaKey)
// Prepare intro and manual content
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
const manualContent = await this.extractManualContent(page, context)
// Prepare the schema items based on page type
let preparedItems: Array<Record<string, unknown>> = []
switch (schemaKey) {
case 'queries':
preparedItems = await Promise.all(
(schema as QueryT[]).map((item) => this.prepareQuery(item)),
)
break
case 'mutations':
preparedItems = await Promise.all(
(schema as MutationT[]).map((item) => this.prepareMutation(item)),
)
break
case 'objects':
preparedItems = await Promise.all(
(schema as ObjectT[]).map((item) => this.prepareObject(item)),
)
break
case 'interfaces':
preparedItems = await Promise.all(
(schema as InterfaceT[]).map((item) => this.prepareInterface(item)),
)
break
case 'enums':
preparedItems = await Promise.all((schema as EnumT[]).map((item) => this.prepareEnum(item)))
break
case 'unions':
preparedItems = await Promise.all(
(schema as UnionT[]).map((item) => this.prepareUnion(item)),
)
break
case 'inputObjects':
preparedItems = await Promise.all(
(schema as InputObjectT[]).map((item) => this.prepareInputObject(item)),
)
break
case 'scalars':
preparedItems = await Promise.all(
(schema as ScalarT[]).map((item) => this.prepareScalar(item)),
)
break
}
const templateData = {
pageTitle: page.title,
pageIntro: intro,
manualContent,
items: preparedItems,
pageType: schemaKey,
}
const templatePath = join(__dirname, '../templates/graphql-reference.template.md')
const templateContent = readFileSync(templatePath, 'utf8')
return await renderContent(templateContent, {
...context,
...templateData,
markdownRequested: true,
})
}
/**
* Transform changelog page
*/
private async transformChangelog(
page: Page,
currentVersion: string,
context: Context,
): Promise<string> {
const { getGraphqlChangelog } = await import('@/graphql/lib/index')
const schema = getGraphqlChangelog(currentVersion) as ChangelogItemT[]
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
const manualContent = await this.extractManualContent(page, context)
// Process changelog items
const changelogItems = schema.map((item) => {
const processChanges = (changes: Array<{ title: string; changes: string[] }>) =>
changes.map((change) => ({
title: change.title,
changes: change.changes.map((html: string) => {
// Remove wrapping <p> tags if present
if (html.startsWith('<p>') && html.endsWith('</p>')) {
return fastTextOnly(html.slice(3, -4))
}
return fastTextOnly(html)
}),
}))
return {
date: item.date,
schemaChanges: processChanges(item.schemaChanges || []),
previewChanges: processChanges(item.previewChanges || []),
upcomingChanges: processChanges(item.upcomingChanges || []),
}
})
const templateData = {
pageTitle: page.title,
pageIntro: intro,
manualContent,
changelogItems,
}
const templatePath = join(__dirname, '../templates/graphql-changelog.template.md')
const templateContent = readFileSync(templatePath, 'utf8')
return await renderContent(templateContent, {
...context,
...templateData,
markdownRequested: true,
})
}
/**
* Transform breaking changes page
*/
private async transformBreakingChanges(
page: Page,
currentVersion: string,
context: Context,
): Promise<string> {
const { getGraphqlBreakingChanges } = await import('@/graphql/lib/index')
const schema = getGraphqlBreakingChanges(currentVersion) as BreakingChangesT
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
const manualContent = await this.extractManualContent(page, context)
const slugger = new GithubSlugger()
// Process breaking changes by date
const breakingChangesByDate = Object.keys(schema).map((date) => {
const items = schema[date]
const heading = `Changes scheduled for ${date}`
const slug = slugger.slug(heading)
return {
date,
heading,
slug,
items: items.map((item) => ({
location: item.location,
description: fastTextOnly(item.description),
reason: fastTextOnly(item.reason),
criticality: item.criticality,
})),
}
})
const templateData = {
pageTitle: page.title,
pageIntro: intro,
manualContent,
breakingChangesByDate,
}
const templatePath = join(__dirname, '../templates/graphql-breaking-changes.template.md')
const templateContent = readFileSync(templatePath, 'utf8')
return await renderContent(templateContent, {
...context,
...templateData,
markdownRequested: true,
})
}
/**
* Extract manual content from page markdown
*/
private async extractManualContent(page: Page, context: Context): Promise<string> {
if (!page.markdown) return ''
const markerIndex = page.markdown.indexOf(
'<!-- Content after this section is automatically generated -->',
)
if (markerIndex <= 0) return ''
const { content } = matter(page.markdown)
const manualContentMarkerIndex = content.indexOf(
'<!-- Content after this section is automatically generated -->',
)
if (manualContentMarkerIndex <= 0) return ''
const rawManualContent = content.substring(0, manualContentMarkerIndex).trim()
if (!rawManualContent) return ''
return await renderContent(rawManualContent, {
...context,
markdownRequested: true,
})
}
/**
* Prepare a query item for rendering
*/
private async prepareQuery(query: QueryT): Promise<Record<string, unknown>> {
return {
name: query.name,
slug: query.name.toLowerCase(),
description: query.description ? fastTextOnly(query.description) : '',
type: query.type,
href: query.href,
isDeprecated: query.isDeprecated || false,
deprecationReason: query.deprecationReason
? fastTextOnly(query.deprecationReason)
: undefined,
args: query.args.map((arg) => ({
name: arg.name,
type: arg.type,
href: arg.href,
description: arg.description ? fastTextOnly(arg.description) : '',
})),
}
}
/**
* Prepare a mutation item for rendering
*/
private async prepareMutation(mutation: MutationT): Promise<Record<string, unknown>> {
return {
name: mutation.name,
slug: mutation.name.toLowerCase(),
description: mutation.description ? fastTextOnly(mutation.description) : '',
isDeprecated: mutation.isDeprecated || false,
deprecationReason: mutation.deprecationReason
? fastTextOnly(mutation.deprecationReason)
: undefined,
inputFields: await this.prepareFields(mutation.inputFields),
returnFields: await this.prepareFields(mutation.returnFields),
}
}
/**
* Prepare an object item for rendering
*/
private async prepareObject(object: ObjectT): Promise<Record<string, unknown>> {
return {
name: object.name,
slug: object.name.toLowerCase(),
description: object.description ? fastTextOnly(object.description) : '',
isDeprecated: object.isDeprecated || false,
deprecationReason: object.deprecationReason
? fastTextOnly(object.deprecationReason)
: undefined,
implements: object.implements || [],
fields: await this.prepareFields(object.fields),
}
}
/**
* Prepare an interface item for rendering
*/
private async prepareInterface(item: InterfaceT): Promise<Record<string, unknown>> {
return {
name: item.name,
slug: item.name.toLowerCase(),
description: item.description ? fastTextOnly(item.description) : '',
isDeprecated: item.isDeprecated || false,
deprecationReason: item.deprecationReason ? fastTextOnly(item.deprecationReason) : undefined,
fields: await this.prepareFields(item.fields),
}
}
/**
* Prepare an enum item for rendering
*/
private async prepareEnum(item: EnumT): Promise<Record<string, unknown>> {
return {
name: item.name,
slug: item.name.toLowerCase(),
description: item.description ? fastTextOnly(item.description) : '',
isDeprecated: item.isDeprecated || false,
deprecationReason: item.deprecationReason ? fastTextOnly(item.deprecationReason) : undefined,
values: item.values.map((value) => ({
name: value.name,
description: value.description ? fastTextOnly(value.description) : '',
})),
}
}
/**
* Prepare a union item for rendering
*/
private async prepareUnion(item: UnionT): Promise<Record<string, unknown>> {
return {
name: item.name,
slug: item.name.toLowerCase(),
description: item.description ? fastTextOnly(item.description) : '',
isDeprecated: item.isDeprecated || false,
deprecationReason: item.deprecationReason ? fastTextOnly(item.deprecationReason) : undefined,
possibleTypes: item.possibleTypes || [],
}
}
/**
* Prepare an input object item for rendering
*/
private async prepareInputObject(item: InputObjectT): Promise<Record<string, unknown>> {
return {
name: item.name,
slug: item.name.toLowerCase(),
description: item.description ? fastTextOnly(item.description) : '',
isDeprecated: item.isDeprecated || false,
deprecationReason: item.deprecationReason ? fastTextOnly(item.deprecationReason) : undefined,
inputFields: await this.prepareFields(item.inputFields),
}
}
/**
* Prepare a scalar item for rendering
*/
private async prepareScalar(item: ScalarT): Promise<Record<string, unknown>> {
return {
name: item.name,
slug: item.name.toLowerCase(),
description: item.description ? fastTextOnly(item.description) : '',
isDeprecated: item.isDeprecated || false,
deprecationReason: item.deprecationReason ? fastTextOnly(item.deprecationReason) : undefined,
}
}
/**
* Prepare fields for rendering
*/
private async prepareFields(fields: FieldT[]): Promise<Array<Record<string, unknown>>> {
return fields.map((field) => ({
name: field.name,
type: field.type,
href: field.href,
description: field.description ? fastTextOnly(field.description) : '',
defaultValue: field.defaultValue,
isDeprecated: field.isDeprecated || false,
deprecationReason: field.deprecationReason
? fastTextOnly(field.deprecationReason)
: undefined,
arguments: field.arguments
? field.arguments.map((arg) => ({
name: arg.name,
description: arg.description ? fastTextOnly(arg.description) : '',
defaultValue: arg.defaultValue,
type: {
name: arg.type.name,
href: arg.type.href,
},
}))
: undefined,
}))
}
}

View File

@@ -1,5 +1,6 @@
import { TransformerRegistry } from './types'
import { RestTransformer } from './rest-transformer'
import { GraphQLTransformer } from './graphql-transformer'
/**
* Global transformer registry
@@ -10,6 +11,9 @@ export const transformerRegistry = new TransformerRegistry()
// Register REST transformer
transformerRegistry.register(new RestTransformer())
// Register GraphQL transformer
transformerRegistry.register(new GraphQLTransformer())
// Future transformers can be registered here:
// transformerRegistry.register(new WebhooksTransformer())
// transformerRegistry.register(new GitHubAppsTransformer())

View File

@@ -0,0 +1,12 @@
---
title: GitHub GraphQL API documentation
intro: 'To create integrations, retrieve data, and automate your workflows, use the {% data variables.product.prodname_dotcom %} GraphQL API.'
shortTitle: GraphQL API
versions:
fpt: '*'
ghec: '*'
ghes: '*'
children:
- /overview
- /reference
---

View File

@@ -0,0 +1,20 @@
---
title: Breaking changes
intro: Learn about recent and upcoming breaking changes to the {% data variables.product.prodname_dotcom %} GraphQL API.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About breaking changes
Breaking changes are any changes that might require action from our integrators. We divide these changes into two categories:
* **Breaking:** Changes that will break existing queries to the GraphQL API. For example, removing a field would be a breaking change.
* **Dangerous:** Changes that won't break existing queries but could affect the runtime behavior of clients. Adding an enum value is an example of a dangerous change.
We'll announce upcoming breaking changes at least three months before making changes to the GraphQL schema, to give integrators time to make the necessary adjustments.
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,13 @@
---
title: Changelog
intro: The GraphQL schema changelog is a list of recent and upcoming changes to our GraphQL API schema.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
Breaking changes include changes that will break existing queries or could affect the runtime behavior of clients. For a list of breaking changes and when they will occur, see our breaking changes log.
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,15 @@
---
title: Overview
intro: Learn about the GraphQL API overview topics.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
children:
- /breaking-changes
- /changelog
---
## GraphQL API overview
The GraphQL API provides a powerful way to query GitHub data.

View File

@@ -0,0 +1,17 @@
---
title: Enums
intro: Enums represent possible sets of values for a field.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About enums
Enums are special types that define a set of possible values. When a field has an enum type, it can only be set to one of the predefined values.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,20 @@
---
title: Reference
intro: View reference documentation to learn about the data types available in the {% data variables.product.prodname_dotcom %} GraphQL API schema.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
children:
- /queries
- /mutations
- /objects
- /interfaces
- /enums
- /unions
- /input-objects
- /scalars
autogenerated: graphql
---
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Input objects
intro: Input objects are special types that allow you to pass complex objects as arguments to queries and mutations.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About input objects
Input objects are used when you need to pass a structured set of values as an argument to a field. They are similar to regular objects, but they are specifically designed to be used as input arguments.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Interfaces
intro: Interfaces are abstract types that include a certain set of fields that other types must include if they implement the interface.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About interfaces
Interfaces allow you to define a set of fields that multiple object types can implement. They help ensure consistency across your schema.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Mutations
intro: The mutation type defines GraphQL operations that create, update, or delete data on the server.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About mutations
Every GraphQL schema has a root type for both queries and mutations. The mutation type defines GraphQL operations that modify data on the server.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Objects
intro: Objects in GraphQL represent the resources you can access. An object can contain a list of fields, which are specifically typed.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About objects
Objects are the most common type in a GraphQL schema. They represent the resources you can access and contain fields that define the data you can query.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Queries
intro: The query type defines GraphQL operations that retrieve data from the server.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About queries
Every GraphQL schema has a root type for both queries and mutations. The query type defines GraphQL operations that retrieve data from the server.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Scalars
intro: Scalars are primitive values in GraphQL. They represent the leaves of a query.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About scalars
Scalars are primitive types that represent concrete values. GraphQL comes with a set of default scalar types, including String, Int, Float, Boolean, and ID.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -0,0 +1,17 @@
---
title: Unions
intro: Unions represent an object that could be one of multiple types.
versions:
fpt: '*'
ghec: '*'
ghes: '*'
autogenerated: graphql
---
## About unions
A union type is a special type that represents an object that could be one of a listed set of types. When you query a field that returns a union type, you need to use conditional fragments to query any fields.
For more information, see [AUTOTITLE](/get-started/start-your-journey/hello-world).
<!-- Content after this section is automatically generated -->

View File

@@ -32,6 +32,7 @@ children:
- actions
- rest
- webhooks
- graphql
- video-transcripts
# - account-and-profile
# - authentication

View File

@@ -388,6 +388,7 @@ export type Page = {
sidebarLink?: SidebarLink
type?: string
contentType?: string
children?: string[]
}
export type SidebarLink = {