UNPKG

@syncify/codegen

Version:

Shopify GraphQL codegen handling for the sane developer.

831 lines (722 loc) 25.9 kB
import type { CodegenPlugin } from '@graphql-codegen/plugin-helpers'; import { plugin } from '@graphql-codegen/typescript'; import { format } from 'prettier'; import ts from 'typescript'; import { Create, gray } from '@syncify/ansi'; import { TypesToPick } from './types'; /** * Configuration options for the custom GraphQL codegen plugin. * Defines which types to include in the output and how dependencies are handled. * * @example * ```json * { * "pickTypes": ["Market", "Translation"], * "depthInclusion": 2, * "skipTypeName": true * } * ``` */ export interface SyncifyPluginConfig { /** * An array of type names to explicitly include in the generated TypeScript output. * These types, along with their dependencies (up to the specified depth), will be extracted * from the schema. Default types (`DisplayableError`, `PageInfo`, `UserError`, `Node`) * are always included regardless of this list, but can be overridden here if needed. * * @remarks * - Duplicate entries are automatically removed. * - If a type is not found in the schema, a warning is logged, but processing continues. * * @example * ```json * { * "pickTypes": ["Market", "Translation"] * } * ``` * This includes `Market`, `Translation`, and the default types in the output. */ pickTypes: TypesToPick; /** * The maximum depth to which type dependencies are included in the output. * Controls how many levels of referenced types are extracted beyond the explicitly picked types. * If unspecified, all dependencies are included (infinite depth). * * @default 1 * * @remarks * - Depth 0 includes only the picked types. * - Depth 1 includes picked types and their direct dependencies. * - Negative values are treated as 0. * * @example * ```json * { * "pickTypes": ["Translation"], * "depthInclusion": 1 * } * ``` * Includes `Translation` and its direct dependencies (e.g., `Market`), but not `Market`’s dependencies. */ depthInclusion?: number; /** * Whether to exclude `__typename` fields from the generated TypeScript types. * This option is passed through to the `@graphql-codegen/typescript` plugin to control * whether `__typename` properties are included in the output. * * @default true * @remarks * - Useful for reducing clutter in the output when `__typename` isn’t needed. * - Only affects types generated by the underlying TypeScript plugin. * * @example * ```json * { * "pickTypes": ["Market"], * "skipTypeName": true * } * ``` * Excludes `__typename` from `Market` and related types. */ skipTypeName?: boolean; } // Built-in types that shouldn't be transformed const BUILT_IN_TYPES = new Set([ 'string', 'number', 'boolean', 'null', 'undefined', 'any', 'never', 'unknown', 'Array' ]); // Default types to always include const DEFAULT_TYPES = [ 'DisplayableError', 'PageInfo', 'UserError', 'Node', 'Maybe' ]; // Scalar type mappings const SCALAR_MAPPING: Record<string, string> = { ID: 'string', String: 'string', URL: 'string', Date: 'string', DateTime: 'string', Color: 'string', UnsignedInt64: 'string', UtcOffset: 'string', Decimal: 'string', Money: 'string', FormattedString: 'string', HTML: 'string', Int: 'number', Float: 'number', BigInt: 'number', Boolean: 'boolean', ARN: 'any', StorefrontID: 'any', JSON: 'Record<string, any>' }; /** * Ensures unique type names in the array */ function uniqueTypes (types: TypesToPick) { return Array.from(new Set([ ...DEFAULT_TYPES, ...types ])); } /** * The main codegen plugin implementation */ export default <CodegenPlugin<SyncifyPluginConfig>>{ plugin: async (schema, documents, config, info) => { const log = Create() .Break() .Top('Generating Types') .Newline() .toLog({ clear: true }); // Ensure pickTypes are unique config.pickTypes = uniqueTypes(config.pickTypes) as TypesToPick; try { // Generate TypeScript output const typescriptOutput = await plugin(schema, documents, { skipTypename: config?.skipTypeName ?? true }, info); // Transform the types const { transformedContent, typeLiterals, includedTypes } = await transformTypes( typescriptOutput.content, config, log ); // Generate model types if needed if (typeLiterals.length > 0 && info?.outputFile) { logOptionalTypeLiterals(typeLiterals, info.outputFile, log); } logEnd(includedTypes.size, log); return { content: transformedContent }; } catch (error) { log.Error(`Failed to generate types: ${error instanceof Error ? error.message : String(error)}`); throw error; } } }; /** * Main transformation function */ async function transformTypes (content: string, config: SyncifyPluginConfig, log: any) { const sourceFile = ts.createSourceFile('temp.ts', content, ts.ScriptTarget.Latest, true); log.Line(`Base TypeScript output generated (${content.length} chars)`); // Collect types from source const { typeMap, enumNames, typeLiterals } = collectTypes(sourceFile, log); // Find types to include based on config const includedTypes = collectIncludedTypes(typeMap, config, log); // Transform nodes const transformedNodes = transformNodes(typeMap, includedTypes, enumNames, sourceFile, log); // Generate output let fileContent = printNodes(transformedNodes, sourceFile, log); fileContent = replaceScalars(fileContent, log); fileContent = cleanUpContent(fileContent, log); // Format the output const transformedContent = await format( '/* eslint-disable no-use-before-define */\n\n' + fileContent, { parser: 'typescript', tabWidth: 2, printWidth: 120, singleQuote: true, useTabs: false } ); return { transformedContent, typeLiterals, includedTypes }; } /** * Collects all types from the source file */ function collectTypes (sourceFile: ts.SourceFile, log: any) { const typeMap = new Map<string, ts.TypeAliasDeclaration | ts.InterfaceDeclaration>(); const enumNames = new Set<string>(); const typeLiterals: string[] = []; // Use a visitor pattern for more efficient traversal function collectTypesVisitor (node: ts.Node) { if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) { const name = node.name?.text; if (name) { typeMap.set(name, node); typeLiterals.push(name); log.Line(`Found type: ${name} (${ts.isInterfaceDeclaration(node) ? 'interface' : 'type alias'})`); } } else if (ts.isEnumDeclaration(node) && node.name) { enumNames.add(node.name.text); log.Line(`Found enum: ${node.name.text} - will be replaced with 'string'`); } ts.forEachChild(node, collectTypesVisitor); } collectTypesVisitor(sourceFile); return { typeMap, enumNames, typeLiterals }; } /** * Determines which types should be included based on dependencies */ function collectIncludedTypes ( typeMap: Map<string, ts.TypeAliasDeclaration | ts.InterfaceDeclaration>, config: SyncifyPluginConfig, log: any ) { const includedTypes = new Map<string, number>(); const maxDepth = config.depthInclusion || Infinity; // Initialize with selected types config.pickTypes.forEach(type => includedTypes.set(type, 0)); // Recursively collect type references function collectReferencedTypes (node: ts.Node, depth: number) { // Early termination if we're beyond max depth if (depth > maxDepth) return; // Helper to process type references function processTypeReference (typeName: string, currentDepth: number) { if (!typeMap.has(typeName) || BUILT_IN_TYPES.has(typeName)) return; const existingDepth = includedTypes.get(typeName); // Only process if this is a shorter path to the type if (existingDepth === undefined || existingDepth > currentDepth) { includedTypes.set(typeName, currentDepth); log.Line(`Including ${typeName} at depth ${currentDepth}`); // Process the referenced type's dependencies if it exists const referencedType = typeMap.get(typeName); if (referencedType && (currentDepth < maxDepth || config.pickTypes.includes(typeName))) { collectReferencedTypes(referencedType, currentDepth); } } } // Process specific node types if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { processTypeReference(node.typeName.text, depth + 1); } else if (ts.isArrayTypeNode(node)) { collectReferencedTypes(node.elementType, depth); } else if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { node.types.forEach(type => collectReferencedTypes(type, depth)); } else if (ts.isParenthesizedTypeNode(node)) { collectReferencedTypes(node.type, depth); } // Continue traversal ts.forEachChild(node, child => collectReferencedTypes(child, depth)); } // Start collection from picked types config.pickTypes.forEach(type => { if (typeMap.has(type)) { collectReferencedTypes(typeMap.get(type)!, 0); } else { log.Warn(`Type ${type} not found in source`); } }); return includedTypes; } /** * Transform nodes for output */ function transformNodes ( typeMap: Map<string, ts.TypeAliasDeclaration | ts.InterfaceDeclaration>, includedTypes: Map<string, number>, enumNames: Set<string>, sourceFile: ts.SourceFile, log: any ) { const transformedNodes: ts.Node[] = []; // Create type visitor function const visitType = createTypeVisitor(enumNames, sourceFile, log); // Transform each included type includedTypes.forEach((_, type) => { const node = typeMap.get(type); if (!node) { log.Warn(`Type ${type} not found in typeMap`); return; } transformedNodes.push(transformNode(node, type, visitType, sourceFile, log)); }); // Generate Query types augmentQueryRoot(typeMap.get('QueryRoot'), transformedNodes, visitType, sourceFile, log); // Final node transformations return finalizeNodes(transformedNodes, includedTypes, sourceFile, log); } /** * Creates a reusable type visitor function */ function createTypeVisitor (enumNames: Set<string>, sourceFile: ts.SourceFile, log: any) { return function visitType (node: ts.TypeNode): ts.TypeNode { log.Line(`Visiting type node: ${node.getText(sourceFile)}`); // Handle Maybe type unwrapping if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { const refType = node.typeName.text; if (refType === 'Maybe' && node.typeArguments?.length) { log.Line(`Unwrapping Maybe<${node.typeArguments[0].getText(sourceFile)}>`); return visitType(node.typeArguments[0]); } else if (enumNames.has(refType)) { log.Line(`Replacing enum ${refType} with 'string'`); return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); } } // Handle array types if (ts.isArrayTypeNode(node)) { const elementType = visitType(node.elementType); if (elementType !== node.elementType) { return ts.factory.createArrayTypeNode(elementType); } } else if (ts.isUnionTypeNode(node)) { // Handle union types const types = node.types.map(visitType); const allAny = types.every(t => t.kind === ts.SyntaxKind.AnyKeyword); const hasNonAny = types.some(t => t.kind !== ts.SyntaxKind.AnyKeyword); if (allAny) { return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } if (hasNonAny) { const cleanedTypes = types.filter(t => t.kind !== ts.SyntaxKind.AnyKeyword); if (cleanedTypes.length === 0) { return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } if (cleanedTypes.length !== types.length) { return cleanedTypes.length === 1 ? cleanedTypes[0] : ts.factory.createUnionTypeNode([ ...cleanedTypes, ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword) ] as ts.TypeNode[]); } } } return node; }; } /** * Transform a single node (type alias or interface) */ function transformNode ( node: ts.TypeAliasDeclaration | ts.InterfaceDeclaration, type: string, visitType: (node: ts.TypeNode) => ts.TypeNode, sourceFile: ts.SourceFile, log: any ) { let transformedNode: ts.TypeAliasDeclaration | ts.InterfaceDeclaration; // Special handling for Payload types if (type.endsWith('Payload')) { log.Line(`Transforming Payload type: ${type}`); const prefix = type.replace(/Payload$/, ''); const lowerPrefix = prefix.charAt(0).toLowerCase() + prefix.slice(1); const newTypeName = `Mutation${prefix}`; // Create the transformed type const originalType = ts.isTypeAliasDeclaration(node) ? visitType(node.type) : ts.factory.createTypeLiteralNode( node.members.map(m => { if (ts.isPropertySignature(m) && m.type) { return ts.factory.createPropertySignature( m.modifiers, m.name, m.questionToken, visitType(m.type) ); } return m; }) ); const wrappedType = ts.factory.createTypeLiteralNode([ ts.factory.createPropertySignature( undefined, lowerPrefix, undefined, originalType ) ]); transformedNode = ts.factory.createTypeAliasDeclaration( [ ts.factory.createModifier(ts.SyntaxKind.ExportKeyword) ], ts.factory.createIdentifier(newTypeName), undefined, wrappedType ); } else { // Handle regular types (non-payload) if (ts.isTypeAliasDeclaration(node)) { transformedNode = ts.factory.createTypeAliasDeclaration( [ ts.factory.createModifier(ts.SyntaxKind.ExportKeyword) ], node.name, node.typeParameters, visitType(node.type) ); } else { // Process heritage clauses (extends/implements) const heritageClauses = processHeritageClauses(node, log); // Process members const members = node.members.map(member => { if (ts.isPropertySignature(member) && member.type) { return ts.factory.createPropertySignature( member.modifiers, member.name, member.questionToken, visitType(member.type) ); } return member; }); transformedNode = ts.factory.createInterfaceDeclaration( [ ts.factory.createModifier(ts.SyntaxKind.ExportKeyword) ], node.name, node.typeParameters, heritageClauses?.length > 0 ? heritageClauses : undefined, members ); } } // Apply JSDoc comments return addJsDocComment(transformedNode, node, sourceFile, log); } /** * Process heritage clauses, removing any references to 'any' */ function processHeritageClauses (node: ts.InterfaceDeclaration, log: any) { if (!node.heritageClauses) return []; return node.heritageClauses .map(clause => { const types = clause.types .filter(type => { if (ts.isIdentifier(type.expression) && type.expression.text === 'any') { log.Line(`Removing 'any' from heritage clause for ${node.name.text}`); return false; } return true; }); return types.length > 0 ? ts.factory.createHeritageClause(clause.token, types) : null; }) .filter(Boolean) as ts.HeritageClause[]; } /** * Add JSDoc comment to a node */ function addJsDocComment ( node: ts.Node, originalNode: ts.Node, sourceFile: ts.SourceFile, log: any ) { // Clear any existing comments let result = ts.setSyntheticLeadingComments(node, undefined); // Extract JSDoc from original node const jsDoc = getJsDoc(originalNode, sourceFile); if (jsDoc) { const nodeName = ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) ? node.name.text : 'node'; log.Line(`Adding JSDoc to ${nodeName}: "${jsDoc}"`); result = ts.addSyntheticLeadingComment( result, ts.SyntaxKind.MultiLineCommentTrivia, `* ${jsDoc}\n `, true ); } return result; } /** * Generate Query types from QueryRoot properties */ function augmentQueryRoot ( queryRootNode: ts.TypeAliasDeclaration | ts.InterfaceDeclaration | undefined, transformedNodes: ts.Node[], visitType: (node: ts.TypeNode) => ts.TypeNode, sourceFile: ts.SourceFile, log: any ) { if (!queryRootNode) { log.Warn('QueryRoot not found in typeMap'); return; } // Process each property in QueryRoot const processProperty = (prop: ts.PropertySignature) => { if (!prop.name || !ts.isIdentifier(prop.name) || !prop.type) { log.Line(`Skipping property in QueryRoot: ${prop.name ? prop.name.getText(sourceFile) : 'unnamed'} - missing required fields`, gray); return; } const propName = prop.name.getText(sourceFile); const typeName = `Query${propName.charAt(0).toUpperCase() + propName.slice(1)}`; const jsDoc = getJsDoc(prop, sourceFile); // Process the inner type const innerType = visitType(prop.type); // Handle Maybe types const isMaybeType = ( innerType.kind === ts.SyntaxKind.TypeReference && ts.isIdentifier((innerType as ts.TypeReferenceNode).typeName) && (innerType as ts.TypeReferenceNode).typeName.getText(sourceFile) === 'Maybe' && (innerType as ts.TypeReferenceNode).typeArguments?.length ); // Create the actual type const unwrappedType = isMaybeType ? (innerType as ts.TypeReferenceNode).typeArguments![0] : innerType; const objectType = ts.factory.createTypeLiteralNode([ ts.factory.createPropertySignature( undefined, propName, undefined, unwrappedType ) ]); const finalType = isMaybeType ? ts.factory.createTypeReferenceNode('Maybe', [ objectType ]) : objectType; // Create the new type alias let newTypeAlias = ts.factory.createTypeAliasDeclaration( [ ts.factory.createModifier(ts.SyntaxKind.ExportKeyword) ], ts.factory.createIdentifier(typeName), undefined, finalType ); // Add JSDoc if available if (jsDoc) { log.Line(`Adding JSDoc to ${typeName}: "${jsDoc}"`); newTypeAlias = ts.addSyntheticLeadingComment( newTypeAlias, ts.SyntaxKind.MultiLineCommentTrivia, `* ${jsDoc}\n `, true ); } transformedNodes.push(newTypeAlias); log.Line(`Generated ${typeName} for QueryRoot property ${propName}`); }; // Process QueryRoot based on its type if (ts.isInterfaceDeclaration(queryRootNode)) { log.Line('Processing QueryRoot (interface) for augmentation'); queryRootNode.members.filter(ts.isPropertySignature).forEach(processProperty); } else if (ts.isTypeAliasDeclaration(queryRootNode) && ts.isTypeLiteralNode(queryRootNode.type)) { log.Line('Processing QueryRoot (type alias) for augmentation'); queryRootNode.type.members.filter(ts.isPropertySignature).forEach(processProperty); } else { log.Line('QueryRoot type is not a type literal or interface, skipping augmentation'); } } /** * Convert nodes to text */ function printNodes (nodes: ts.Node[], sourceFile: ts.SourceFile, log: any) { const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); return nodes.map(node => { const text = printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); log.Line(`Processed node: ${ts.SyntaxKind[node.kind]}`); return text; }).join('\n\n'); } /** * Final processing of nodes */ function finalizeNodes ( transformedNodes: ts.Node[], includedTypes: Map<string, number>, sourceFile: ts.SourceFile, log: any ) { const definedTypes = new Set(includedTypes.keys()); // Create transformer for post-processing const transformer = (context: ts.TransformationContext) => (root: ts.Node): ts.Node => { const visit = (node: ts.Node): ts.Node => { // Replace undefined type references with 'any' if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { const refType = node.typeName.text; if (!definedTypes.has(refType) && !BUILT_IN_TYPES.has(refType)) { log.Line(`Post-processing: Replacing undefined ${refType} with 'any'`); return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } } else if ( ts.isArrayTypeNode(node) && ts.isTypeReferenceNode(node.elementType) && ts.isIdentifier(node.elementType.typeName)) { // Replace undefined array element types const elemTypeName = node.elementType.typeName.text; if (!definedTypes.has(elemTypeName) && !BUILT_IN_TYPES.has(elemTypeName)) { log.Line(`Post-processing: Replacing Array<${elemTypeName}> with Array<any>`); return ts.factory.createArrayTypeNode(ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); } } else if (ts.isIntersectionTypeNode(node)) { // Simplify intersection types const types = node.types.map(visit); // Simplify intersections with 'any' const allAny = types.every(t => t.kind === ts.SyntaxKind.AnyKeyword); if (allAny) { return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } // Remove redundant 'any' from intersections const cleanedTypes = types.filter(t => t.kind !== ts.SyntaxKind.AnyKeyword); if (cleanedTypes.length === 0) { return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } if (cleanedTypes.length < types.length) { return cleanedTypes.length === 1 ? cleanedTypes[0] : ts.factory.createIntersectionTypeNode(cleanedTypes as ts.TypeNode[]); } return ts.factory.createIntersectionTypeNode(types as ts.TypeNode[]); } else if (ts.isUnionTypeNode(node)) { // Simplify union types const types = node.types.map(visit); // Simplify unions with 'any' const allAny = types.every(t => t.kind === ts.SyntaxKind.AnyKeyword); if (allAny) { return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } // Remove redundant types when 'any' is present const hasAny = types.some(t => t.kind === ts.SyntaxKind.AnyKeyword); if (hasAny) { return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); } } else if (ts.isInterfaceDeclaration(node) && node.heritageClauses) { // Clean up interface heritage clauses const cleanedHeritage = processHeritageClauses(node, log); if (cleanedHeritage.length !== node.heritageClauses.length) { return ts.factory.createInterfaceDeclaration( node.modifiers, node.name, node.typeParameters, cleanedHeritage.length > 0 ? cleanedHeritage : undefined, node.members ); } } return ts.visitEachChild(node, visit, context); }; return ts.visitNode(root, visit) as typeof root; }; // Apply transformations const final = transformedNodes.map(node => { const result = ts.transform(node, [ transformer ]).transformed[0]; return result; }); // Filter out unnecessary types return final.filter(node => { if (ts.isTypeAliasDeclaration(node)) { const name = node.name.text; if (name === 'InputMaybe' || name === 'Scalars') { log.Line(`Removing ${name} type definition`); return false; } } return true; }); } /** * Replace scalar type references */ function replaceScalars (content: string, log: any) { return content.replace(/Scalars\['([^']+)'\]\['(input|output)'\]/g, (match, scalarName) => { const replacement = SCALAR_MAPPING[scalarName] || 'any'; log.Line(`Replacing ${match} with ${replacement}`); return replacement; }); } /** * Clean up the generated content */ function cleanUpContent (content: string, log: any) { return content // Remove redundant comment markers .replace(/\* \*/g, '*') // Remove duplicate JSDoc comments .replace(/(\/\*\*.*?\*\/)\s*\1/g, '$1') // Unwrap InputMaybe types .replace(/InputMaybe<([^]+?)>(?=;)/g, '$1') // Remove Maybe type definition .replace(/export type Maybe<T> = null \| any;/, '') // Unwrap Maybe types .replace(/(?<=(?:<|: ))Maybe<([^]+?)>(?=(?:;|>))/g, '$1'); } /** * Extract JSDoc comment from a node */ function getJsDoc (node: ts.Node, sourceFile: ts.SourceFile) { const leadingComments = ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()); if (!leadingComments || leadingComments.length === 0) return undefined; // Find the first JSDoc-style comment const jsDocComment = leadingComments .filter(comment => comment.kind === ts.SyntaxKind.MultiLineCommentTrivia) .map(comment => sourceFile.text.slice(comment.pos + 2, comment.end - 2).trim()) .find(text => text.startsWith('*')); return jsDocComment?.replace(/^\*/, '').trim(); } /** * Generate optional type literals */ function logOptionalTypeLiterals (typeLiterals: string[], outputFile: string, log: any) { const uniqueLiterals = Array.from(new Set(typeLiterals)) .map(name => `'${name}'`) .join(' | '); // eslint-disable-next-line const dtsContent = [ 'import type { LiteralUnion } from \'type-fest\';', '', `export type Models = LiteralUnion<${uniqueLiterals}, string>;` ].join('\n'); log.Line(`Models type generated with ${Array.from(new Set(typeLiterals)).length} unique type literals`); } /** * Log completion message */ function logEnd (typeCount: number, log: any) { log.Line(`Extracted ${typeCount} types (including dependencies)`) .Newline() .End('Generated Types') .Break(); }