Skip to content

Instantly share code, notes, and snippets.

@johnloy
Created August 28, 2025 16:29
Show Gist options
  • Save johnloy/b70a6cfa6b7c143c004baeea63de86b0 to your computer and use it in GitHub Desktop.
Save johnloy/b70a6cfa6b7c143c004baeea63de86b0 to your computer and use it in GitHub Desktop.
Script to reorganize JS/TS imports
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function isNodeModulesImport(importPath) {
return !importPath.startsWith(".") && !importPath.startsWith("@/");
}
function isAtSlashImport(importPath) {
return importPath.startsWith("@/");
}
function isRelativeImport(importPath) {
return importPath.startsWith("./") || importPath.startsWith("../");
}
function isStyledImport(importPath) {
return importPath.includes(".styled");
}
function getRelativeDepth(importPath) {
const parts = importPath.split("/");
let depth = 0;
for (const part of parts) {
if (part === "..") {
depth++;
} else if (part === ".") {
// current directory
} else {
break;
}
}
return depth;
}
function isTypeOnlyImport(importText) {
// Check if import uses 'type' keyword for type-only imports
return /import\s+type\s+/.test(importText);
}
function shouldBeTypeOnlyImport(importText, fileContent) {
// Extract imported names from the import statement
const match = importText.match(/import\s+(?:type\s+)?([^}]+)\s+from/);
if (!match) return false;
const importClause = match[1].trim();
// Handle different import patterns
let importedNames = [];
if (importClause.includes("{")) {
// Named imports: import { a, b, type c } from 'module'
const namedMatch = importClause.match(/\{([^}]+)\}/);
if (namedMatch) {
importedNames = namedMatch[1]
.split(",")
.map((name) => name.trim().replace(/^type\s+/, ""))
.filter((name) => name);
}
} else if (importClause.includes(" as ")) {
// Namespace import: import * as Name from 'module'
const namespaceMatch = importClause.match(/\*\s+as\s+(\w+)/);
if (namespaceMatch) {
importedNames = [namespaceMatch[1]];
}
} else {
// Default import: import Name from 'module'
importedNames = [importClause];
}
// Remove the import line to avoid false positives
const contentWithoutImport = fileContent.replace(importText, "");
// Check if all imported names are used only in type contexts
for (const name of importedNames) {
if (!name) continue;
// Look for usage patterns that indicate value usage
const valueUsagePatterns = [
new RegExp(`\\b${name}\\s*\\(`, "g"), // Function call
new RegExp(`\\b${name}\\s*\\.`, "g"), // Property access
new RegExp(`new\\s+${name}\\b`, "g"), // Constructor
new RegExp(`instanceof\\s+${name}\\b`, "g"), // instanceof
new RegExp(`\\[${name}\\]`, "g"), // Array/object access
new RegExp(`${name}\\s*=`, "g"), // Assignment (but exclude type annotations)
];
// Check for value usage
const hasValueUsage = valueUsagePatterns.some((pattern) => {
const matches = contentWithoutImport.match(pattern);
if (!matches) return false;
// Filter out type annotations
return matches.some((match) => {
const index = contentWithoutImport.indexOf(match);
const beforeMatch = contentWithoutImport.substring(
Math.max(0, index - 10),
index
);
// Skip if it's in a type annotation context
return !beforeMatch.includes(":") && !beforeMatch.includes("type ");
});
});
if (hasValueUsage) {
return false; // Found value usage, should not be type-only
}
}
return importedNames.length > 0; // Should be type-only if we have imported names and no value usage
}
function addTypeDeclarationIfNeeded(importText, fileContent) {
if (isTypeOnlyImport(importText)) {
return importText; // Already has type declaration
}
if (shouldBeTypeOnlyImport(importText, fileContent)) {
// Add type declaration
return importText.replace(/^import\s+/, "import type ");
}
return importText;
}
function parseImports(content) {
const lines = content.split("\n");
const imports = [];
let i = 0;
// Skip initial comments and eslint directives
while (i < lines.length) {
const line = lines[i].trim();
if (
line.startsWith("//") ||
line.startsWith("/*") ||
line.startsWith("*") ||
line === "" ||
line.startsWith("///")
) {
i++;
continue;
}
break;
}
const preImportLines = lines.slice(0, i);
while (i < lines.length) {
const line = lines[i].trim();
// Skip empty lines between imports
if (line === "") {
i++;
continue;
}
// Check for import statements
if (line.startsWith("import ")) {
let importText = line;
let lineEnd = i;
// Handle multi-line imports
while (
!importText.includes(";") &&
!importText.match(/from\s+['"`][^'"`]+['"`]/) &&
lineEnd < lines.length - 1
) {
lineEnd++;
importText += "\n" + lines[lineEnd];
}
if (importText.includes(" from ")) {
const match = importText.match(/from\s+['"`]([^'"`]+)['"`]/);
if (match) {
imports.push({
text: importText,
path: match[1],
startLine: i,
endLine: lineEnd,
});
}
}
i = lineEnd + 1;
} else {
break; // Stop at first non-import line
}
}
return {
preImportLines,
imports,
remainingContent: lines.slice(i).join("\n"),
};
}
function organizeImports(imports, fileContent) {
const groups = {
nodeModules: [],
atSlash: [],
relative: [],
styled: [],
};
for (const imp of imports) {
// Add type declaration if needed
const updatedImport = {
...imp,
text: addTypeDeclarationIfNeeded(imp.text, fileContent),
};
if (isStyledImport(imp.path)) {
groups.styled.push(updatedImport);
} else if (isNodeModulesImport(imp.path)) {
groups.nodeModules.push(updatedImport);
} else if (isAtSlashImport(imp.path)) {
groups.atSlash.push(updatedImport);
} else if (isRelativeImport(imp.path)) {
groups.relative.push(updatedImport);
}
}
// Sort each group alphabetically by import specifier (path)
groups.nodeModules.sort((a, b) => a.path.localeCompare(b.path));
groups.atSlash.sort((a, b) => a.path.localeCompare(b.path));
// Sort relative imports from most distant ancestors to current directory
groups.relative.sort((a, b) => {
const depthA = getRelativeDepth(a.path);
const depthB = getRelativeDepth(b.path);
if (depthA !== depthB) {
return depthB - depthA; // Higher depth (more distant) first
}
return a.path.localeCompare(b.path);
});
groups.styled.sort((a, b) => a.path.localeCompare(b.path));
return groups;
}
function formatImportGroups(groups) {
const result = [];
if (groups.nodeModules.length > 0) {
result.push(groups.nodeModules.map((imp) => imp.text).join("\n"));
}
if (groups.atSlash.length > 0) {
result.push(groups.atSlash.map((imp) => imp.text).join("\n"));
}
if (groups.relative.length > 0) {
result.push(groups.relative.map((imp) => imp.text).join("\n"));
}
if (groups.styled.length > 0) {
result.push(groups.styled.map((imp) => imp.text).join("\n"));
}
return result.join("\n\n");
}
function processFile(filePath) {
try {
const content = fs.readFileSync(filePath, "utf8");
const { preImportLines, imports, remainingContent } = parseImports(content);
if (imports.length === 0) {
return false; // No changes needed
}
const organizedGroups = organizeImports(imports, content);
const formattedImports = formatImportGroups(organizedGroups);
let newContent = "";
if (preImportLines.length > 0) {
newContent += preImportLines.join("\n") + "\n";
}
newContent += formattedImports;
if (remainingContent.trim()) {
newContent += "\n\n" + remainingContent;
}
if (newContent !== content) {
fs.writeFileSync(filePath, newContent);
return true;
}
return false;
} catch (error) {
console.error(`Error processing ${filePath}:`, error.message);
return false;
}
}
// Main execution
if (import.meta.url === `file://${process.argv[1]}`) {
const srcDir = "/usr/pic1/src/github.anim.dreamworks.com/webtools/shoppe/src";
function findTypeScriptFiles(dir) {
const files = [];
const entries = fs.readdirSync(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...findTypeScriptFiles(fullPath));
} else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) {
files.push(fullPath);
}
}
return files;
}
const tsFiles = findTypeScriptFiles(srcDir);
let processedCount = 0;
let changedCount = 0;
console.log(`Found ${tsFiles.length} TypeScript files to process...`);
for (const file of tsFiles) {
try {
const changed = processFile(file);
processedCount++;
if (changed) {
changedCount++;
console.log(`✓ Updated: ${file}`);
}
} catch (error) {
console.error(`✗ Error processing ${file}:`, error.message);
}
}
console.log(
`\nProcessed ${processedCount} files, ${changedCount} files were updated.`
);
}
export { processFile };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment