/** * Reads `apps/api/schema.gql`, emits MDX documentation per category into * `apps/docs/content/api/`. One file per category keeps the output reviewable * in a diff and easy to navigate in the sidebar. * * Run via `pnpm --filter @cv/docs gen:api` after `pnpm --filter @cv/api schema:generate`. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSchema, type GraphQLEnumType, type GraphQLField, type GraphQLInputField, type GraphQLInputObjectType, type GraphQLInputType, type GraphQLNamedType, type GraphQLObjectType, type GraphQLOutputType, type GraphQLSchema, isEnumType, isInputObjectType, isObjectType, } from "graphql"; const here = dirname(fileURLToPath(import.meta.url)); const SCHEMA_PATH = join(here, "..", "..", "api", "schema.gql"); const OUT_DIR = join(here, "..", "content", "api"); const ROOT_TYPES = new Set(["Query", "Mutation", "Subscription"]); const BUILTIN_SCALARS = new Set(["String", "Int", "Float", "Boolean", "ID"]); const isUserType = (t: GraphQLNamedType): boolean => !( t.name.startsWith("__") || ROOT_TYPES.has(t.name) || BUILTIN_SCALARS.has(t.name) ); const namedTypeName = (type: GraphQLOutputType | GraphQLInputType): string => String(type).replace(/[[\]!]/g, ""); const categoryFor = ( t: GraphQLNamedType, ): "types" | "inputs" | "enums" | null => { if (isObjectType(t)) { return "types"; } if (isInputObjectType(t)) { return "inputs"; } if (isEnumType(t)) { return "enums"; } return null; }; const anchor = (name: string): string => name.toLowerCase(); const renderTypeRef = ( type: GraphQLOutputType | GraphQLInputType, schema: GraphQLSchema, ): string => { const name = namedTypeName(type); const full = String(type); const target = schema.getType(name); if (!(target && isUserType(target))) { return `\`${full}\``; } const cat = categoryFor(target); if (!cat) { return `\`${full}\``; } return full.replace(name, `[\`${name}\`](/api/${cat}#${anchor(name)})`); }; const formatDefault = (v: unknown): string => { if (v === undefined) { return ""; } return ` _(default: \`${JSON.stringify(v)}\`)_`; }; type ArgLike = { name: string; type: GraphQLInputType; description?: string | null; defaultValue?: unknown; }; const renderArgsTable = ( args: ReadonlyArray, schema: GraphQLSchema, ): string => { if (args.length === 0) { return ""; } const rows = args.map( (a) => `| \`${a.name}\` | ${renderTypeRef(a.type, schema)} | ${a.description ?? ""}${formatDefault(a.defaultValue)} |`, ); return [ "", "| Argument | Type | Description |", "| --- | --- | --- |", ...rows, "", ].join("\n"); }; const renderRootField = ( field: GraphQLField, schema: GraphQLSchema, ): string => { const parts: string[] = []; parts.push(`#### \`${field.name}\``); parts.push(""); if (field.description) { parts.push(field.description, ""); } parts.push(`**Returns:** ${renderTypeRef(field.type, schema)}`); const args = renderArgsTable(field.args, schema); if (args) { parts.push(args); } if (field.deprecationReason) { parts.push("", `> **Deprecated:** ${field.deprecationReason}`); } return parts.join("\n"); }; const KNOWN_VERBS = new Set([ "accept", "add", "archive", "cancel", "change", "complete", "create", "delete", "enqueue", "fetch", "find", "generate", "get", "list", "move", "my", "parse", "reject", "remove", "replace", "request", "reset", "restore", "search", "send", "set", "transition", "update", "upload", "verify", ]); const ACRONYMS = new Set(["CV", "AI", "ID", "URL", "API", "DTO"]); /** Common type-name suffixes stripped before using a return type as a group label. */ const TYPE_SUFFIXES = [ "Connection", "Edge", "Payload", "Response", "Result", "Type", ] as const; /** First line of a description starting with `@group X` overrides automatic grouping. */ const GROUP_TAG_RE = /@group\s+([A-Z][A-Za-z0-9]*)/; /** * Split a camelCase identifier preserving acronym runs. * `aiCallLog` -> [`ai`, `Call`, `Log`]; `renderCV` -> [`render`, `CV`]; * `XMLHttpRequest` -> [`XML`, `Http`, `Request`]. */ const tokenize = (name: string): string[] => name .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .trim() .split(/\s+/); const titleCase = (s: string): string => s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1); /** * Singularise a display label so `Companies` -> `Company`, `Skills` -> `Skill`. * Acronyms (all-caps) pass through untouched. */ const singulariseLabel = (subject: string): string => { if (ACRONYMS.has(subject)) { return subject; } if (subject.endsWith("ies")) { return `${subject.slice(0, -3)}y`; } if ( subject.endsWith("ss") || subject.endsWith("us") || subject.endsWith("is") ) { return subject; } if (subject.endsWith("s")) { return subject.slice(0, -1); } return subject; }; /** * Best-effort group label from a field's return type. Unwraps non-null/list * wrappers, drops common suffixes (`Connection`/`Result`/`Type`/...), so e.g. * `[Skill!]!` -> "Skill" and `SkillConnection` -> "Skill". Returns null for * scalar returns - the caller should fall back to a name-based heuristic. */ const groupFromReturnType = ( type: GraphQLOutputType, schema: GraphQLSchema, ): string | null => { const name = namedTypeName(type); if (BUILTIN_SCALARS.has(name)) { return null; } const target = schema.getType(name); if (!(target && isUserType(target))) { return null; } let base = name; for (const suf of TYPE_SUFFIXES) { if (base.endsWith(suf) && base.length > suf.length) { base = base.slice(0, -suf.length); } } return singulariseLabel(base); }; /** * Fallback when the return type is a scalar: strip leading verbs from the * field name and use the first remaining noun. `login` -> "Login". */ const groupFromName = (name: string): string => { const tokens = tokenize(name); if (tokens.length === 0) { return "Misc"; } let i = 0; while ( i < tokens.length - 1 && KNOWN_VERBS.has((tokens[i] ?? "").toLowerCase()) ) { i++; } const subject = tokens[i] ?? "Misc"; const upper = subject.toUpperCase(); return ACRONYMS.has(upper) ? upper : singulariseLabel(titleCase(subject)); }; /** * Resolve a field's group label. Precedence: * 1. Explicit `@group X` tag in the field's description * 2. Return type (unwrapped + suffix-stripped) * 3. Name-based subject extraction (scalar-returning fields like `login`) */ const groupLabelFor = ( field: GraphQLField, schema: GraphQLSchema, ): string => { const tagged = field.description?.match(GROUP_TAG_RE); if (tagged?.[1]) { return tagged[1]; } const fromReturn = groupFromReturnType(field.type, schema); if (fromReturn) { return fromReturn; } return groupFromName(field.name); }; /** * Normalise a subject for group-key comparison: lowercase + naive singularise. * Keeps `skills` / `skill` / `createSkill` all in the same bucket. */ const groupKey = (subject: string): string => { const lower = subject.toLowerCase(); if (lower.endsWith("ss") || lower.endsWith("us") || lower.endsWith("is")) { return lower; } if (lower.endsWith("ies")) { return `${lower.slice(0, -3)}y`; } if (lower.endsWith("s")) { return lower.slice(0, -1); } return lower; }; type FieldGroup = { label: string; fields: GraphQLField[]; }; const groupRootFields = ( fields: ReadonlyArray>, schema: GraphQLSchema, ): FieldGroup[] => { const buckets = new Map(); for (const f of fields) { const subject = groupLabelFor(f, schema); const key = groupKey(subject); const existing = buckets.get(key); if (existing) { existing.fields.push(f); continue; } buckets.set(key, { label: subject, fields: [f] }); } return [...buckets.values()] .map((g) => ({ label: g.label, fields: sortedByName(g.fields), })) .sort((a, b) => a.label.localeCompare(b.label)); }; const renderGroup = (group: FieldGroup, schema: GraphQLSchema): string => [ `### ${group.label}`, "", group.fields.map((f) => renderRootField(f, schema)).join("\n\n"), ].join("\n"); const renderObjectFieldsTable = ( fields: ReadonlyArray | GraphQLInputField>, schema: GraphQLSchema, ): string => { const header = ["| Field | Type | Description |", "| --- | --- | --- |"]; const rows = fields.map( (f) => `| \`${f.name}\` | ${renderTypeRef(f.type, schema)} | ${f.description ?? ""} |`, ); return [...header, ...rows].join("\n"); }; const renderObjectType = ( t: GraphQLObjectType, schema: GraphQLSchema, ): string => { const fields = Object.values(t.getFields()); return [ `## \`${t.name}\``, "", t.description ?? "", t.description ? "" : null, renderObjectFieldsTable(fields, schema), "", ] .filter((line): line is string => line !== null) .join("\n"); }; const renderInputType = ( t: GraphQLInputObjectType, schema: GraphQLSchema, ): string => { const fields = Object.values(t.getFields()); return [ `## \`${t.name}\``, "", t.description ?? "", t.description ? "" : null, renderObjectFieldsTable(fields, schema), "", ] .filter((line): line is string => line !== null) .join("\n"); }; const renderEnumType = (t: GraphQLEnumType): string => { const rows = t .getValues() .map((v) => `| \`${v.name}\` | ${v.description ?? ""} |`); return [ `## \`${t.name}\``, "", t.description ?? "", t.description ? "" : null, "| Value | Description |", "| --- | --- |", ...rows, "", ] .filter((line): line is string => line !== null) .join("\n"); }; const sortedByName = ( items: ReadonlyArray, ): T[] => [...items].sort((a, b) => a.name.localeCompare(b.name)); const HEADER = (title: string) => [ `# ${title}`, "", "_Generated from `apps/api/schema.gql`. Do not edit - re-run `pnpm codegen`._", "", ].join("\n"); const buildRootPage = ( title: string, root: GraphQLObjectType | null | undefined, schema: GraphQLSchema, ): string => { if (!root) { return `${HEADER(title)}\n_No ${title.toLowerCase()} defined._\n`; } const groups = groupRootFields(Object.values(root.getFields()), schema); return `${HEADER(title)}\n${groups.map((g) => renderGroup(g, schema)).join("\n\n")}\n`; }; const buildTypesPage = ( title: string, category: "types" | "inputs" | "enums", schema: GraphQLSchema, ): string => { const all = Object.values(schema.getTypeMap()) .filter(isUserType) .filter((t): t is GraphQLNamedType => categoryFor(t) === category); if (all.length === 0) { return `${HEADER(title)}\n_No ${title.toLowerCase()} defined._\n`; } const sections = sortedByName(all).map((t) => { if (category === "types" && isObjectType(t)) { return renderObjectType(t, schema); } if (category === "inputs" && isInputObjectType(t)) { return renderInputType(t, schema); } if (category === "enums" && isEnumType(t)) { return renderEnumType(t); } return ""; }); return `${HEADER(title)}\n${sections.join("\n")}`; }; const buildOverview = (schema: GraphQLSchema): string => { const queryCount = Object.keys( schema.getQueryType()?.getFields() ?? {}, ).length; const mutationCount = Object.keys( schema.getMutationType()?.getFields() ?? {}, ).length; const typeMap = schema.getTypeMap(); const userTypes = Object.values(typeMap).filter(isUserType); const objects = userTypes.filter(isObjectType).length; const inputs = userTypes.filter(isInputObjectType).length; const enums = userTypes.filter(isEnumType).length; return [ HEADER("GraphQL API"), "Reference for the CV Generator GraphQL surface. The live endpoint is at `/graphql` on the API host; Apollo Sandbox renders an interactive playground in development.", "", "## Sections", "", `- [Queries](/api/queries) (${queryCount})`, `- [Mutations](/api/mutations) (${mutationCount})`, `- [Object types](/api/types) (${objects})`, `- [Input types](/api/inputs) (${inputs})`, `- [Enums](/api/enums) (${enums})`, "", ].join("\n"); }; const writePage = (filename: string, content: string): void => { const out = join(OUT_DIR, filename); writeFileSync(out, content, "utf8"); console.log(`generate-api-docs: wrote ${out}`); }; const main = (): void => { if (!existsSync(SCHEMA_PATH)) { console.error( `generate-api-docs: ${SCHEMA_PATH} not found.\n` + `Run \`pnpm --filter @cv/api schema:generate\` (needs Postgres) or boot the API in dev mode first.`, ); process.exit(1); } mkdirSync(OUT_DIR, { recursive: true }); const sdl = readFileSync(SCHEMA_PATH, "utf8"); const schema = buildSchema(sdl); writePage("overview.mdx", buildOverview(schema)); writePage( "queries.mdx", buildRootPage("Queries", schema.getQueryType(), schema), ); writePage( "mutations.mdx", buildRootPage("Mutations", schema.getMutationType(), schema), ); writePage("types.mdx", buildTypesPage("Object types", "types", schema)); writePage("inputs.mdx", buildTypesPage("Input types", "inputs", schema)); writePage("enums.mdx", buildTypesPage("Enums", "enums", schema)); }; main();