Files
web/scripts/convert-graphql-to-ts.ts
Joakim Jäderberg bc5a606289 Merged in feature/turbopack (pull request #3117)
Feature/turbopack

* .

* .

* pin import-in-the-middle

* update marker

* revert back to using *.graphql.ts


Approved-by: Linus Flood
2025-11-11 09:51:40 +00:00

374 lines
12 KiB
TypeScript

#!/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<string, string[]>();
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);
}