|
| 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