Skip to content

Instantly share code, notes, and snippets.

@chriskalmar
Last active January 5, 2024 15:55
Show Gist options
  • Save chriskalmar/39067ec3c4c4b0f1cea1b2de1faaee9c to your computer and use it in GitHub Desktop.
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
/**
* 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