Feature/turbopack * . * . * pin import-in-the-middle * update marker * revert back to using *.graphql.ts Approved-by: Linus Flood
374 lines
12 KiB
TypeScript
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);
|
|
}
|