Skip to content

Commit 9e2a4c5

Browse files
heiskrCopilot
andauthored
Automate GraphQL category content files in schema sync (#61515)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b6c3a4f commit 9e2a4c5

4 files changed

Lines changed: 384 additions & 0 deletions

File tree

content/graphql/reference/index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ redirect_from:
1111
- /graphql/reference/unions
1212
- /graphql/reference/input-objects
1313
- /graphql/reference/scalars
14+
- /graphql/reference/audit-log
15+
- /graphql/reference/billing
16+
- /graphql/reference/code-scanning
17+
- /graphql/reference/code-security
18+
- /graphql/reference/codespaces
19+
- /graphql/reference/collaborators
20+
- /graphql/reference/interactions
21+
- /graphql/reference/pages
22+
- /graphql/reference/scim
23+
- /graphql/reference/secret-scanning
1424
versions:
1525
fpt: '*'
1626
ghec: '*'

src/graphql/scripts/sync.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import processPreviews from './utils/process-previews'
1010
import processUpcomingChanges from './utils/process-upcoming-changes'
1111
import processSchemas from './utils/process-schemas'
1212
import { bucketSchemaByCategory, writeCategoryFiles } from './utils/bucket-by-category'
13+
import { syncCategoryContentFiles, type CategoryPresence } from './utils/sync-category-content'
14+
import { ALL_KIND_KEYS } from '@/graphql/lib/categories'
1315
import {
1416
prependDatedEntry,
1517
createChangelogEntry,
@@ -65,6 +67,12 @@ if (!process.env.GITHUB_TOKEN) {
6567

6668
const versionsToBuild = Object.keys(allVersions)
6769

70+
// Tracks, per category, the set of docs versions in which the category has at
71+
// least one type. Populated inside the per-version loop and consumed after it
72+
// to manage the per-category content pages. Declared before `main()` runs so
73+
// the loop never reads it in the temporal dead zone.
74+
const categoryPresence: CategoryPresence = new Map()
75+
6876
main()
6977

7078
const allIgnoredChanges: IgnoredChange[] = []
@@ -145,6 +153,17 @@ async function main() {
145153
const perCategoryFiles = bucketSchemaByCategory(schemaJsonPerVersion)
146154
await writeCategoryFiles(path.join(graphqlStaticDir, graphqlVersion), perCategoryFiles)
147155

156+
// Record which categories have at least one type in this version so the
157+
// content pages and their `versions` frontmatter can be managed after the
158+
// loop. `version` is the docs version key (e.g. `enterprise-server@3.22`),
159+
// which is the format `convertVersionsToFrontmatter` expects.
160+
for (const [cat, bucket] of perCategoryFiles.entries()) {
161+
const hasTypes = ALL_KIND_KEYS.some((kind) => (bucket[kind]?.length ?? 0) > 0)
162+
if (!hasTypes) continue
163+
if (!categoryPresence.has(cat)) categoryPresence.set(cat, new Set())
164+
categoryPresence.get(cat)!.add(version)
165+
}
166+
148167
// 4. UPDATE CHANGELOG
149168
if (allVersions[version].nonEnterpriseDefault) {
150169
// The changelog is only built for free-pro-team@latest
@@ -173,6 +192,11 @@ async function main() {
173192
}
174193
}
175194

195+
// Manage the per-category content pages (create new categories, delete
196+
// emptied ones, narrow `versions` frontmatter) plus the reference index
197+
// children and disappearance redirects, based on the presence collected above.
198+
await syncCategoryContentFiles(categoryPresence)
199+
176200
// Ensure the YAML linter runs before checkinging in files
177201
execSync('npx prettier -w "**/*.{yml,yaml}"')
178202

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import fs from 'fs/promises'
2+
import path from 'path'
3+
import walk from 'walk-sync'
4+
import matter from '@gr2m/gray-matter'
5+
import { isEqual } from 'lodash-es'
6+
7+
import {
8+
updateContentDirectory,
9+
convertVersionsToFrontmatter,
10+
} from '@/automated-pipelines/lib/update-markdown'
11+
import { CATEGORIES, OTHER_CATEGORY, categoryTitle } from '@/graphql/lib/categories'
12+
13+
// Default directory holding the per-category GraphQL reference content pages.
14+
// Overridable via options for tests; production always uses this path.
15+
const DEFAULT_CONTENT_DIR = path.join('content', 'graphql', 'reference')
16+
// Value of the `autogenerated` frontmatter on managed category pages. The
17+
// content-directory helper uses this to know which files it owns (and may
18+
// therefore delete when a category empties).
19+
const AUTOGENERATED_TYPE = 'graphql'
20+
// Breadcrumb category the reference pages sit under in the sidebar.
21+
const CATEGORY_BREADCRUMB = 'Explore the schema reference'
22+
23+
// Maps a category slug to the set of docs version keys (e.g.
24+
// `free-pro-team@latest`, `enterprise-server@3.22`) in which the category has
25+
// at least one type. Built by sync.ts from the per-version buckets.
26+
export type CategoryPresence = Map<string, Set<string>>
27+
28+
const categoryUrlPath = (cat: string) => `/graphql/reference/${cat}`
29+
30+
// Matches a bare category reference URL (no fragment), e.g.
31+
// `/graphql/reference/code-scanning`. Kind pages like
32+
// `/graphql/reference/queries` also match this shape but are filtered out
33+
// because their slug is not in CATEGORIES.
34+
const CATEGORY_URL_RE = /^\/graphql\/reference\/([a-z][a-z0-9-]*)$/
35+
36+
function isPresentInAnyVersion(presence: CategoryPresence, cat: string): boolean {
37+
return (presence.get(cat)?.size ?? 0) > 0
38+
}
39+
40+
// Read the `redirect_from` of every managed category page before the content
41+
// helper potentially deletes those files, so redirect chains aren't lost when a
42+
// category disappears. Returns a map of category slug -> redirect_from entries.
43+
async function captureCategoryRedirects(contentDir: string): Promise<Map<string, string[]>> {
44+
const captured = new Map<string, string[]>()
45+
let files: string[] = []
46+
try {
47+
files = walk(contentDir, {
48+
includeBasePath: true,
49+
directories: false,
50+
globs: ['**/*.md'],
51+
ignore: ['**/index.md', '**/README.md'],
52+
})
53+
} catch {
54+
return captured
55+
}
56+
for (const file of files) {
57+
try {
58+
const { data } = matter(await fs.readFile(file, 'utf8'))
59+
if (data.autogenerated !== AUTOGENERATED_TYPE) continue
60+
const entries = normalizeRedirects(data.redirect_from)
61+
if (entries.length > 0) captured.set(path.basename(file, '.md'), entries)
62+
} catch {
63+
// Unreadable/unparseable file; nothing to capture.
64+
}
65+
}
66+
return captured
67+
}
68+
69+
function normalizeRedirects(value: unknown): string[] {
70+
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
71+
if (typeof value === 'string') return [value]
72+
return []
73+
}
74+
75+
// Build the `sourceContent` map the content-directory helper expects:
76+
// `{ <targetFile>: { data: <frontmatter>, content: <body> } }`. Only categories
77+
// that are non-empty in at least one version get a page; emptied categories are
78+
// omitted so the helper deletes their stale files.
79+
async function buildSourceContent(presence: CategoryPresence, contentDir: string) {
80+
const sourceContent: Record<string, { data: Record<string, unknown>; content: string }> = {}
81+
for (const cat of CATEGORIES) {
82+
const versionsSet = presence.get(cat)
83+
if (!versionsSet || versionsSet.size === 0) continue
84+
const versions = await convertVersionsToFrontmatter([...versionsSet])
85+
const title = categoryTitle(cat)
86+
const file = path.join(contentDir, `${cat}.md`)
87+
// For pages that already exist, the helper only refreshes `versions` and the
88+
// autogenerated body, preserving any writer edits to title/intro/category.
89+
// These values therefore only seed brand-new category pages.
90+
sourceContent[file] = {
91+
data: {
92+
title,
93+
shortTitle: title,
94+
intro: `Reference documentation for GraphQL schema types in the ${title} category.`,
95+
versions,
96+
autogenerated: AUTOGENERATED_TYPE,
97+
category: [CATEGORY_BREADCRUMB],
98+
},
99+
content: '',
100+
}
101+
}
102+
return sourceContent
103+
}
104+
105+
// Reconcile the reference index `redirect_from` so that a bare category URL
106+
// redirects to the reference root when (and only when) that category is empty in
107+
// every version. Categories present in at least one version must NOT have a
108+
// redirect, otherwise a still-valid versioned page would be shadowed.
109+
async function reconcileIndexRedirects(
110+
presence: CategoryPresence,
111+
capturedRedirects: Map<string, string[]>,
112+
indexFile: string,
113+
): Promise<void> {
114+
let raw: string
115+
try {
116+
raw = await fs.readFile(indexFile, 'utf8')
117+
} catch {
118+
return
119+
}
120+
const { data, content } = matter(raw)
121+
const existing = normalizeRedirects(data.redirect_from)
122+
123+
// Drop redirects for managed categories that are now present (e.g. a category
124+
// that previously emptied and has since come back). Leave kind-page redirects
125+
// (queries, mutations, ...) and non-category redirects (/v4/reference) intact.
126+
const next = existing.filter((entry) => {
127+
const match = CATEGORY_URL_RE.exec(entry)
128+
if (!match) return true
129+
const cat = match[1]
130+
if (!(CATEGORIES as readonly string[]).includes(cat)) return true
131+
return !isPresentInAnyVersion(presence, cat)
132+
})
133+
134+
// Add a root redirect for every managed category that is empty in all
135+
// versions. `other` is always present (un-annotated types), so it never
136+
// disappears, but guard against it defensively.
137+
for (const cat of CATEGORIES) {
138+
if (cat === OTHER_CATEGORY) continue
139+
if (isPresentInAnyVersion(presence, cat)) continue
140+
const url = categoryUrlPath(cat)
141+
if (!next.includes(url)) next.push(url)
142+
// Preserve any redirect_from the deleted category page carried so existing
143+
// inbound redirect chains keep resolving.
144+
for (const inherited of capturedRedirects.get(cat) ?? []) {
145+
if (!next.includes(inherited)) next.push(inherited)
146+
}
147+
}
148+
149+
if (isEqual(next, existing)) return
150+
data.redirect_from = next
151+
await fs.writeFile(indexFile, matter.stringify(content, data))
152+
}
153+
154+
// Entry point used by sync.ts after it has bucketed every version. Creates,
155+
// updates, and deletes the per-category content pages, refreshes the reference
156+
// index children, and reconciles disappearance redirects. `contentDir` is
157+
// overridable for tests; production uses the default reference directory.
158+
export async function syncCategoryContentFiles(
159+
presence: CategoryPresence,
160+
options: { contentDir?: string } = {},
161+
): Promise<void> {
162+
const contentDir = options.contentDir ?? DEFAULT_CONTENT_DIR
163+
const indexFile = path.join(contentDir, 'index.md')
164+
const capturedRedirects = await captureCategoryRedirects(contentDir)
165+
const sourceContent = await buildSourceContent(presence, contentDir)
166+
167+
await updateContentDirectory({
168+
targetDirectory: contentDir,
169+
sourceContent,
170+
frontmatter: {
171+
autogenerated: AUTOGENERATED_TYPE,
172+
versions: { fpt: '*', ghec: '*', ghes: '*' },
173+
},
174+
})
175+
176+
await reconcileIndexRedirects(presence, capturedRedirects, indexFile)
177+
}

0 commit comments

Comments
 (0)