#!/usr/bin/env tsx import * as fs from "fs"; import * as path from "path"; import { glob } from "glob"; interface ImportInfo { fragmentName: string; importPath: string; variableName: string; } interface GraphQLFile { content: string; imports: ImportInfo[]; operations: Array<{ name: string; content: string }>; } /** * Extracts individual fragments/operations from GraphQL content */ function extractOperations( content: string ): Array<{ name: string; content: string }> { const operations: Array<{ name: string; content: string }> = []; // Split content into lines for processing const lines = content.split("\n"); let currentOperation: { name: string; content: string[] } | null = null; let braceCount = 0; for (const line of lines) { const trimmedLine = line.trim(); // Check if this line starts a new operation const fragmentMatch = trimmedLine.match(/^fragment\s+(\w+)\s+on/); const queryMatch = trimmedLine.match(/^query\s+(\w+)\s*[({]/); const mutationMatch = trimmedLine.match(/^mutation\s+(\w+)\s*[({]/); const subscriptionMatch = trimmedLine.match( /^subscription\s+(\w+)\s*[({]/ ); if (fragmentMatch || queryMatch || mutationMatch || subscriptionMatch) { // Finish previous operation if exists if (currentOperation && currentOperation.content.length > 0) { operations.push({ name: currentOperation.name, content: currentOperation.content.join("\n").trim(), }); } // Start new operation const operationName = (fragmentMatch || queryMatch || mutationMatch || subscriptionMatch)![1]; currentOperation = { name: operationName, content: [line], }; // Count braces in the current line braceCount = (line.match(/{/g) || []).length - (line.match(/}/g) || []).length; } else if (currentOperation) { // Add line to current operation currentOperation.content.push(line); // Update brace count braceCount += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length; // If we've closed all braces, this operation is complete if (braceCount === 0 && trimmedLine.includes("}")) { operations.push({ name: currentOperation.name, content: currentOperation.content.join("\n").trim(), }); currentOperation = null; } } } // Handle case where file ends without closing brace if (currentOperation && currentOperation.content.length > 0) { operations.push({ name: currentOperation.name, content: currentOperation.content.join("\n").trim(), }); } return operations; } /** * Generates TypeScript content from parsed GraphQL */ function generateTypeScriptContent(parsedFile: GraphQLFile): string { const { imports, operations } = parsedFile; let output = 'import { gql } from "graphql-tag"\n'; // Add imports for fragments - group by import path to avoid duplicates if (imports.length > 0) { output += "\n"; const importsByPath = new Map(); for (const imp of imports) { if (!importsByPath.has(imp.importPath)) { importsByPath.set(imp.importPath, []); } if ( !importsByPath.get(imp.importPath)!.includes(imp.variableName) ) { importsByPath.get(imp.importPath)!.push(imp.variableName); } } for (const [importPath, variableNames] of importsByPath) { output += `import { ${variableNames.join(", ")} } from "${importPath}"\n`; } } output += "\n"; // Generate exports for each operation if (operations.length === 0) { // If no operation names found, use a default export const defaultName = "GraphQLDocument"; const fragmentSubstitutions = imports.length > 0 ? "\n" + imports.map((imp) => `\${${imp.variableName}}`).join("\n") : ""; output += `export const ${defaultName} = gql\`\n${parsedFile.content}${fragmentSubstitutions}\n\`\n`; } else { for (let i = 0; i < operations.length; i++) { const operation = operations[i]; const fragmentSubstitutions = imports.length > 0 ? "\n" + imports.map((imp) => `\${${imp.variableName}}`).join("\n") : ""; output += `export const ${operation.name} = gql\`\n${operation.content}${fragmentSubstitutions}\n\`\n`; if (i < operations.length - 1) { output += "\n"; } } } return output; } /** * Converts a GraphQL import path to a TypeScript import path */ function convertImportPath(graphqlPath: string): string { // Remove the .graphql extension and add .graphql const withoutExt = graphqlPath.replace(/\.graphql$/, ""); return `${withoutExt}.graphql`; } /** * Gets the export names from a GraphQL file by analyzing the fragments it contains */ function getExportNamesFromGraphQLFile(filePath: string): string[] { try { const content = fs.readFileSync(filePath, "utf-8"); const operations = extractOperations(content); return operations.map((op) => op.name); } catch { // If file doesn't exist or can't be read, try to infer from path console.warn( `Warning: Could not read ${filePath}, inferring export name from path` ); return [getVariableNameFromPath(filePath)]; } } /** * Extracts the expected variable name from a file path */ function getVariableNameFromPath(filePath: string): string { const basename = path.basename(filePath, ".graphql"); // Convert kebab-case or snake_case to PascalCase return basename .split(/[-_]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(""); } /** * Parses a GraphQL file and extracts imports and content */ function parseGraphQLFile(filePath: string): GraphQLFile { const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); const imports: ImportInfo[] = []; const contentLines: string[] = []; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.startsWith("#import")) { // Extract import path from #import "path" const match = trimmedLine.match(/#import\s+"([^"]+)"/); if (match) { const importPath = match[1]; const fullImportPath = path.resolve( path.dirname(filePath), importPath ); const exportNames = getExportNamesFromGraphQLFile(fullImportPath); const tsImportPath = convertImportPath(importPath); // Add all exports from the imported file for (const exportName of exportNames) { imports.push({ fragmentName: exportName, importPath: tsImportPath, variableName: exportName, }); } } } else if (trimmedLine && !trimmedLine.startsWith("#")) { contentLines.push(line); } } const cleanContent = contentLines.join("\n").trim(); const operations = extractOperations(cleanContent); return { content: cleanContent, imports, operations, }; } /** * Converts a single GraphQL file to TypeScript */ function convertFile(graphqlPath: string): void { try { console.log(`Converting: ${graphqlPath}`); const parsed = parseGraphQLFile(graphqlPath); const tsContent = generateTypeScriptContent(parsed); const tsPath = graphqlPath.replace(/\.graphql$/, ".graphql.ts"); fs.writeFileSync(tsPath, tsContent, "utf-8"); console.log(`✓ Created: ${tsPath}`); // Optionally remove the original .graphql file // Uncomment the next line if you want to delete the original files // fs.unlinkSync(graphqlPath) } catch (error) { console.error(`Error converting ${graphqlPath}:`, error); } } /** * Main function to convert all GraphQL files */ async function main() { const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(` GraphQL to TypeScript Converter Converts GraphQL files (.graphql) to TypeScript files (.graphql.ts) using gql template literals. Usage: tsx convert-graphql-to-ts.ts [options] [pattern] Options: --help, -h Show this help message --dry-run Show what files would be converted without converting them --delete-originals Delete original .graphql files after conversion Examples: tsx convert-graphql-to-ts.ts # Convert all .graphql files tsx convert-graphql-to-ts.ts "packages/trpc/**/*.graphql" # Convert specific pattern tsx convert-graphql-to-ts.ts --dry-run # Preview conversion tsx convert-graphql-to-ts.ts --delete-originals # Convert and delete originals Features: • Converts fragments, queries, mutations, and subscriptions • Handles GraphQL imports (#import) and converts to TypeScript imports • Preserves fragment references and generates proper import statements • Groups multiple imports from the same file • Splits multiple operations into separate exports `); return; } const dryRun = args.includes("--dry-run"); const deleteOriginals = args.includes("--delete-originals"); // Get the pattern from args or use default const pattern = args.find((arg) => !arg.startsWith("--")) || "**/*.graphql"; console.log(`🔍 Searching for GraphQL files with pattern: ${pattern}`); try { const files = await glob(pattern, { ignore: ["**/*.graphql.ts", "**/node_modules/**"], }); if (files.length === 0) { console.log("❌ No GraphQL files found."); return; } console.log(`📁 Found ${files.length} GraphQL files`); if (dryRun) { console.log("\n📋 Files that would be converted:"); files.forEach((file, index) => { console.log( ` ${index + 1}. ${file} → ${file.replace(/\.graphql$/, ".graphql.ts")}` ); }); console.log("\n🔍 --dry-run mode: No files were converted."); return; } console.log("\n🔄 Converting files..."); let successCount = 0; let errorCount = 0; for (let i = 0; i < files.length; i++) { const file = files[i]; try { console.log( `📝 [${i + 1}/${files.length}] Converting: ${file}` ); convertFile(file); successCount++; if (deleteOriginals) { fs.unlinkSync(file); console.log(`🗑️ Deleted: ${file}`); } } catch (error) { console.error(`❌ Error converting ${file}:`, error); errorCount++; } } console.log(`\n✅ Conversion complete!`); console.log(` 📈 Successfully converted: ${successCount} files`); if (errorCount > 0) { console.log(` ❌ Errors: ${errorCount} files`); } if (deleteOriginals && successCount > 0) { console.log(` 🗑️ Deleted: ${successCount} original files`); } } catch (error) { console.error("❌ Error:", error); process.exit(1); } } // Run the script if (import.meta.url === `file://${process.argv[1]}`) { main().catch(console.error); }