Something went wrong. Try again.
because I got bored of customising my CV for every job
Something went wrong. Try again.
14 kB · 516 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517/** * 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<ArgLike>, 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<unknown, unknown>, 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<unknown, unknown>, 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<unknown, unknown>[];};
const groupRootFields = ( fields: ReadonlyArray<GraphQLField<unknown, unknown>>, schema: GraphQLSchema,): FieldGroup[] => { const buckets = new Map<string, FieldGroup>(); 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<GraphQLField<unknown, unknown> | 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 = <T extends { name: string }>( items: ReadonlyArray<T>,): 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();