diff --git a/.gitignore b/.gitignore index 078132b..527e5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,11 +42,21 @@ apps/client/src/generated-local/ apps/server/node_modules/@prisma/client/ apps/server/prisma/generated/ -# GraphQL SDL — emitted by @nestjs/graphql's autoSchemaFile at app boot, +# GraphQL SDL - emitted by @nestjs/graphql's autoSchemaFile at app boot, # regenerated explicitly via `pnpm --filter @cv/api schema:generate`. Not # checked in so PR diffs don't accumulate codegen formatting churn. apps/api/schema.gql +# GraphQL API reference MDX - generated by `pnpm --filter @cv/docs gen:api` +# from the SDL above. Same rationale: keep local for the docs site to load, +# off git to avoid churn. +apps/docs/content/api/overview.mdx +apps/docs/content/api/queries.mdx +apps/docs/content/api/mutations.mdx +apps/docs/content/api/types.mdx +apps/docs/content/api/inputs.mdx +apps/docs/content/api/enums.mdx + # Build artifacts apps/client/dist/ apps/server/dist/ diff --git a/README.md b/README.md index f1fd498..47ea430 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ input (PDF / GDPR ZIP / manual) pnpm install pnpm prisma:generate pnpm db:setup # prisma:deploy + seed -pnpm codegen # prisma:generate + GraphQL codegen (needs API running for schema introspection) +pnpm codegen # prisma:generate + GraphQL codegen + docs MDX (needs `apps/api/schema.gql` on disk) pnpm dev # all apps in parallel ``` diff --git a/apps/docs/package.json b/apps/docs/package.json index 8e90879..a4981f1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "lint": "biome check .", "lint:fix": "biome check --write .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "gen:api": "tsx scripts/generate-api-docs.ts" }, "dependencies": { "@catppuccin/palette": "^1.4.0", @@ -36,8 +37,10 @@ "@types/unist": "^3.0.3", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", + "graphql": "^16.12.0", "postcss": "^8.4.47", "tailwindcss": "^4.0.0", + "tsx": "^4.22.2", "typescript": "^5.5.3", "unified": "^11.0.5", "vite": "^7.3.2" diff --git a/apps/docs/scripts/generate-api-docs.ts b/apps/docs/scripts/generate-api-docs.ts new file mode 100644 index 0000000..2b59c85 --- /dev/null +++ b/apps/docs/scripts/generate-api-docs.ts @@ -0,0 +1,516 @@ +/** + * 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(); diff --git a/apps/docs/src/config/navigation.config.ts b/apps/docs/src/config/navigation.config.ts index 1a216d5..85f2350 100644 --- a/apps/docs/src/config/navigation.config.ts +++ b/apps/docs/src/config/navigation.config.ts @@ -104,6 +104,12 @@ export const NAVIGATION: NavigationItem[] = [ items: [ { title: "API Overview", slug: "api" }, { title: "GraphQL Architecture", slug: "docs/graphql-architecture" }, + { title: "GraphQL Reference", slug: "api/overview" }, + { title: "Queries", slug: "api/queries" }, + { title: "Mutations", slug: "api/mutations" }, + { title: "Object Types", slug: "api/types" }, + { title: "Input Types", slug: "api/inputs" }, + { title: "Enums", slug: "api/enums" }, ], }, { diff --git a/package.json b/package.json index 19ed31d..7c2c105 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint": "lerna run lint --stream", "lint:fix": "lerna run lint:fix --stream", "typecheck": "lerna run typecheck --parallel --no-bail --stream", - "codegen": "lerna run prisma:generate --scope=@cv/api && lerna run codegen --scope=@cv/client", + "codegen": "lerna run prisma:generate --scope=@cv/api && lerna run codegen --scope=@cv/client && lerna run gen:api --scope=@cv/docs", "prisma:generate": "lerna run prisma:generate --scope=@cv/api", "prisma:migrate": "lerna run prisma:migrate --scope=@cv/api", "prisma:deploy": "lerna run prisma:deploy --scope=@cv/api", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b556d9e..897461a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,12 +513,18 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.23(postcss@8.5.14) + graphql: + specifier: ^16.12.0 + version: 16.12.0 postcss: specifier: ^8.5.10 version: 8.5.14 tailwindcss: specifier: ^4.0.0 version: 4.1.18 + tsx: + specifier: ^4.22.2 + version: 4.22.2 typescript: specifier: ^5.5.3 version: 5.9.3