Last active
January 5, 2024 15:55
-
-
Save chriskalmar/39067ec3c4c4b0f1cea1b2de1faaee9c to your computer and use it in GitHub Desktop.
Kebab-ify: Update all files and folders in your project to kebab-case and update all imports accordingly
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Copyright (c) 2023 - Chris Kalmar | |
* | |
* CAUTION: This script will modify your files and folders. Make sure you have a backup! | |
* | |
* This script will rename all files and folders in a directory to kebab-case. | |
* It will also update all import paths in all files to reflect the renamed paths. | |
* For now only `import` statements are supported. | |
* | |
* Usage: | |
* 1. To rename files and folders: `npx ts-node kebabify.ts rename` | |
* 2. To update import paths: `npx ts-node kebabify.ts imports | |
* | |
* Configuration: | |
* 1. Set the `sourceDirectories` variable to the paths of the directories you want to run this script on. | |
* 2. Set the `extensions` variable to the file extensions you want to include in the search. | |
* | |
*/ | |
import { execSync } from 'child_process' | |
import * as fs from 'fs' | |
import * as path from 'path' | |
const sourceDirectories = ['path_to_your_directory'] // Replace with your directory paths | |
const extensions = ['.ts', '.tsx', '.json', '.svg', '.ts.snap', '.js', '.jsx'] // File extensions to include in the search | |
const ignoreDirectories = new Set(['node_modules', '.git', '.next', 'dist', 'coverage']) | |
const keepDirectoryNames = new Set(['__snapshots__']) | |
type Operation = 'rename' | 'imports' | '' | |
const operation = process.argv[2] as Operation | |
const toKebabCase = (text: string): string => | |
text | |
.replace(/([\da-z])([A-Z])/g, '$1-$2') | |
.replace(/[\s_]+/g, '-') | |
.toLowerCase() | |
const { log } = console | |
const exitWithError = (message: unknown): void => { | |
// eslint-disable-next-line no-console | |
console.error('Error:', message) | |
process.exit(1) | |
} | |
const getFileList = (directory: string, allowedExtensions: Array<string>): Array<string> => { | |
let mutableResults: Array<string> = [] | |
const files = fs.readdirSync(directory, { withFileTypes: true }) | |
files.forEach((file) => { | |
const filePath = path.join(directory, file.name) | |
if (file.isDirectory()) { | |
if (ignoreDirectories.has(file.name)) { | |
return | |
} | |
mutableResults = [...mutableResults, ...getFileList(filePath, allowedExtensions)] | |
} else if (file.isFile() && allowedExtensions.some((ext) => file.name.endsWith(ext))) { | |
mutableResults.push(filePath) | |
} | |
}) | |
return mutableResults | |
} | |
// check recursively if the directory is empty, | |
// empty subdirectories are fine, but no files are allowed | |
const isDirectoryEmpty = (dirPath: string): boolean => { | |
const files = fs.readdirSync(dirPath) | |
if (files.length === 0) { | |
return true | |
} | |
return files.every((file) => { | |
const filePath = path.join(dirPath, file) | |
const stat = fs.statSync(filePath) | |
if (stat.isDirectory()) { | |
return isDirectoryEmpty(filePath) | |
} | |
return false | |
}) | |
} | |
const renameFilesAndFolders = (files: Array<string>) => { | |
const trackedDirs = new Set<string>() | |
let fileRenameCount = 0 | |
let dirRenameCount = 0 | |
files.forEach((filePath) => { | |
const dir = path.dirname(filePath) | |
const file = path.basename(filePath) | |
const kebabCaseFile = toKebabCase(file) | |
const newFilePath = path.join(dir, kebabCaseFile) | |
trackedDirs.add(dir) | |
if (file !== kebabCaseFile) { | |
fileRenameCount += 1 | |
execSync(`git mv ${filePath} ${newFilePath}`) | |
log(`Renamed file: ${filePath} -> ${newFilePath}`) | |
} | |
}) | |
// start with the longest paths first to avoid renaming parent directories before child directories | |
const sortedDirs = [...trackedDirs].sort( | |
(left, right) => right.split(path.sep).length - left.split(path.sep).length, | |
) | |
sortedDirs.forEach((dirPath) => { | |
const parentDir = path.dirname(dirPath) | |
const dir = path.basename(dirPath) | |
const kebabCaseDir = toKebabCase(dir) | |
const newDirPath = path.join(parentDir, kebabCaseDir) | |
if (keepDirectoryNames.has(dir)) { | |
log(`Keeping directory name: ${dirPath}`) | |
return | |
} | |
if (dir !== kebabCaseDir) { | |
dirRenameCount += 1 | |
try { | |
// check first if the directory is empty, otherwise drop it | |
if (fs.existsSync(newDirPath) && isDirectoryEmpty(newDirPath)) { | |
fs.rmdirSync(newDirPath, { recursive: true }) | |
log(`Removed empty directory: ${newDirPath}`) | |
} | |
} catch { | |
exitWithError(`Failed to remove empty directory: ${newDirPath}`) | |
} | |
execSync(`git mv ${dirPath} ${newDirPath}___temp`) | |
execSync(`git mv ${newDirPath}___temp ${newDirPath}`) | |
log(`Renamed directory: ${dirPath} -> ${newDirPath}`) | |
} | |
}) | |
log(`Renamed ${fileRenameCount} files`) | |
log(`Renamed ${dirRenameCount} directories`) | |
} | |
const updateImportPaths = (content: string) => { | |
const importRegex = /import[^"']+["']([.~].*?)["']/g | |
let newContent = content | |
let match = importRegex.exec(content) | |
let hasChanged = false | |
while (match !== null) { | |
const originalPath = match[1] | |
if (originalPath && !originalPath.includes('icons/generated')) { | |
const kebabCasePath = toKebabCase(originalPath) | |
if (originalPath !== kebabCasePath) { | |
log(`Renamed import path: ${originalPath} -> ${kebabCasePath}`) | |
const updatedImport = match[0].replace(originalPath, kebabCasePath) | |
newContent = newContent.replace(match[0], updatedImport) | |
hasChanged = true | |
} | |
} | |
match = importRegex.exec(newContent) | |
} | |
return { | |
hasChanged, | |
newContent, | |
} | |
} | |
const updateExportPaths = (content: string) => { | |
const exportRegex = /export[^"']+["'](\..*?)["']/g | |
let newContent = content | |
let match = exportRegex.exec(content) | |
let hasChanged = false | |
while (match !== null) { | |
const originalPath = match[1] | |
if (originalPath) { | |
const kebabCasePath = toKebabCase(originalPath) | |
if (originalPath !== kebabCasePath) { | |
log(`Renamed export path: ${originalPath} -> ${kebabCasePath}`) | |
const updatedExport = match[0].replace(originalPath, kebabCasePath) | |
newContent = newContent.replace(match[0], updatedExport) | |
hasChanged = true | |
} | |
} | |
match = exportRegex.exec(newContent) | |
} | |
return { | |
hasChanged, | |
newContent, | |
} | |
} | |
const updateImportsAndExports = (files: Array<string>) => { | |
let changeCount = 0 | |
files.forEach((filePath) => { | |
const fileContent = fs.readFileSync(filePath, 'utf8') | |
{ | |
const { hasChanged, newContent } = updateImportPaths(fileContent) | |
if (hasChanged) { | |
changeCount += 1 | |
fs.writeFileSync(filePath, newContent) | |
log(`Updated imports in: ${filePath}`) | |
} | |
} | |
{ | |
const { hasChanged, newContent } = updateExportPaths(fileContent) | |
if (hasChanged) { | |
changeCount += 1 | |
fs.writeFileSync(filePath, newContent) | |
log(`Updated exports in: ${filePath}`) | |
} | |
} | |
}) | |
log(`Updated ${changeCount} files`) | |
} | |
for (const sourceDirectory of sourceDirectories) { | |
if (path.isAbsolute(sourceDirectory)) { | |
exitWithError('Path must be relative') | |
} | |
if (!fs.existsSync(sourceDirectory)) { | |
exitWithError(`Path "${sourceDirectory}" does not exist`) | |
} | |
} | |
try { | |
for (const sourceDirectory of sourceDirectories) { | |
const files = getFileList(sourceDirectory, extensions) | |
if (files.length === 0) { | |
exitWithError(`No files found at path "${sourceDirectory}" with extensions "${extensions.join(', ')}"`) | |
} | |
log(`Found ${files.length} files`) | |
if (operation === 'rename') { | |
renameFilesAndFolders(files) | |
} else if (operation === 'imports') { | |
updateImportsAndExports(files) | |
} else { | |
exitWithError('No/wrong operation provided - Try "rename" or "imports"') | |
} | |
} | |
} catch (error: unknown) { | |
if (error instanceof Error) { | |
exitWithError(error.message) | |
} | |
exitWithError(error) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment