diff --git a/src/article-api/README.md b/src/article-api/README.md index 28c749bd6b..38d94318f7 100644 --- a/src/article-api/README.md +++ b/src/article-api/README.md @@ -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 diff --git a/src/article-api/templates/graphql-breaking-changes.template.md b/src/article-api/templates/graphql-breaking-changes.template.md new file mode 100644 index 0000000000..5bfa49a9d0 --- /dev/null +++ b/src/article-api/templates/graphql-breaking-changes.template.md @@ -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 %} diff --git a/src/article-api/templates/graphql-changelog.template.md b/src/article-api/templates/graphql-changelog.template.md new file mode 100644 index 0000000000..b146193071 --- /dev/null +++ b/src/article-api/templates/graphql-changelog.template.md @@ -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 %} diff --git a/src/article-api/templates/graphql-index.template.md b/src/article-api/templates/graphql-index.template.md new file mode 100644 index 0000000000..dd2132c8d9 --- /dev/null +++ b/src/article-api/templates/graphql-index.template.md @@ -0,0 +1,9 @@ +# {{ pageTitle }} + +{{ pageIntro }} + +{{ manualContent }} + +## Reference pages + +{{ childrenLinks }} diff --git a/src/article-api/templates/graphql-reference.template.md b/src/article-api/templates/graphql-reference.template.md new file mode 100644 index 0000000000..955db8e5f2 --- /dev/null +++ b/src/article-api/templates/graphql-reference.template.md @@ -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 %}

**Arguments:**
{% for arg in field.arguments %}- `{{ arg.name }}` ([`{{ arg.type.name }}`]({{ arg.type.href }})): {{ arg.description }}{% if arg.defaultValue %} Default: `{{ arg.defaultValue }}`.{% endif %}
{% 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 %}

**Arguments:**
{% for arg in field.arguments %}- `{{ arg.name }}` ([`{{ arg.type.name }}`]({{ arg.type.href }})): {{ arg.description }}{% if arg.defaultValue %} Default: `{{ arg.defaultValue }}`.{% endif %}
{% 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 %} diff --git a/src/article-api/tests/graphql-transformer.ts b/src/article-api/tests/graphql-transformer.ts new file mode 100644 index 0000000000..8b2dda5362 --- /dev/null +++ b/src/article-api/tests/graphql-transformer.ts @@ -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>>() + + 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('') + expect(res.body).not.toContain('') + expect(res.body).not.toContain('

') + expect(res.body).not.toContain('

') + }) + + 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('

') + expect(res.body).not.toContain('

') + expect(res.body).not.toContain('') + expect(res.body).not.toContain('') + expect(res.body).not.toContain('

') + expect(res.body).not.toContain('

') + }) + }) + + 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') + }) + }) +}) diff --git a/src/article-api/transformers/graphql-transformer.ts b/src/article-api/transformers/graphql-transformer.ts new file mode 100644 index 0000000000..12a2c43a04 --- /dev/null +++ b/src/article-api/transformers/graphql-transformer.ts @@ -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 { + 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 { + 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 { + // 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> = [] + + 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 { + 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

tags if present + if (html.startsWith('

') && html.endsWith('

')) { + 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 { + 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 { + if (!page.markdown) return '' + + const markerIndex = page.markdown.indexOf( + '', + ) + + if (markerIndex <= 0) return '' + + const { content } = matter(page.markdown) + const manualContentMarkerIndex = content.indexOf( + '', + ) + + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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>> { + 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, + })) + } +} diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts index a759723782..1ee146a920 100644 --- a/src/article-api/transformers/index.ts +++ b/src/article-api/transformers/index.ts @@ -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()) diff --git a/src/fixtures/fixtures/content/graphql/index.md b/src/fixtures/fixtures/content/graphql/index.md new file mode 100644 index 0000000000..0f156df84b --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/index.md @@ -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 +--- \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/overview/breaking-changes.md b/src/fixtures/fixtures/content/graphql/overview/breaking-changes.md new file mode 100644 index 0000000000..a00972605c --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/overview/breaking-changes.md @@ -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. + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/overview/changelog.md b/src/fixtures/fixtures/content/graphql/overview/changelog.md new file mode 100644 index 0000000000..248d15f70c --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/overview/changelog.md @@ -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. + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/overview/index.md b/src/fixtures/fixtures/content/graphql/overview/index.md new file mode 100644 index 0000000000..3bf6a4f197 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/overview/index.md @@ -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. \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/enums.md b/src/fixtures/fixtures/content/graphql/reference/enums.md new file mode 100644 index 0000000000..f6a15cadaf --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/enums.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/index.md b/src/fixtures/fixtures/content/graphql/reference/index.md new file mode 100644 index 0000000000..b93b3e6db2 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/index.md @@ -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 +--- + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/input-objects.md b/src/fixtures/fixtures/content/graphql/reference/input-objects.md new file mode 100644 index 0000000000..aa1265a6f1 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/input-objects.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/interfaces.md b/src/fixtures/fixtures/content/graphql/reference/interfaces.md new file mode 100644 index 0000000000..f93c982d7c --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/interfaces.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/mutations.md b/src/fixtures/fixtures/content/graphql/reference/mutations.md new file mode 100644 index 0000000000..6d0894956c --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/mutations.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/objects.md b/src/fixtures/fixtures/content/graphql/reference/objects.md new file mode 100644 index 0000000000..074cd5b87b --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/objects.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/queries.md b/src/fixtures/fixtures/content/graphql/reference/queries.md new file mode 100644 index 0000000000..10427d7cf7 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/queries.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/scalars.md b/src/fixtures/fixtures/content/graphql/reference/scalars.md new file mode 100644 index 0000000000..939b3a6ec6 --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/scalars.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/graphql/reference/unions.md b/src/fixtures/fixtures/content/graphql/reference/unions.md new file mode 100644 index 0000000000..49c919383c --- /dev/null +++ b/src/fixtures/fixtures/content/graphql/reference/unions.md @@ -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). + + \ No newline at end of file diff --git a/src/fixtures/fixtures/content/index.md b/src/fixtures/fixtures/content/index.md index e3b95a0713..7b817882b2 100644 --- a/src/fixtures/fixtures/content/index.md +++ b/src/fixtures/fixtures/content/index.md @@ -32,6 +32,7 @@ children: - actions - rest - webhooks + - graphql - video-transcripts # - account-and-profile # - authentication diff --git a/src/types/types.ts b/src/types/types.ts index 343ce700ab..74e2819256 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -388,6 +388,7 @@ export type Page = { sidebarLink?: SidebarLink type?: string contentType?: string + children?: string[] } export type SidebarLink = {