Last active
July 21, 2025 01:31
-
-
Save sujan-s/ca6925e897a462bcdd2ac966c66d191a to your computer and use it in GitHub Desktop.
I like to format my files juuuust the way I want it, and most of it is handled by a custom code style setup in Webstorm. This script handles the rest—organise imports by customisable categories, column-indent type and interface blocks, and align the `?` character for easy skimming.
This file contains hidden or 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
| const fs = require("fs"); | |
| const path = require("path"); | |
| const os = require("os"); | |
| /** | |
| * Loads configuration by searching upwards from a starting directory for a .seriseirc file. | |
| * Falls back to default values if no config file is found. | |
| * @param {string} startPath The path of the file being processed. | |
| * @returns {object} The resolved configuration object. | |
| */ | |
| const loadConfig = (startPath) => { | |
| const defaultConfig = { | |
| TO_COLUMN_WIDTH: 120, | |
| HEADER_CHAR: "=", | |
| groups: [ | |
| { name: "// EXTERNAL ", matchers: ["\"react\"", "'react'", "next/", "dnd-kit/", "zustand", "framer-motion", "tiptap", "axios", "tanstack", "vite", "path", "tauri", "react-router-dom", "@react-oauth/google"] }, | |
| { name: "// FICTOAN ", matchers: ["fictoan-react"] }, | |
| { name: "// CONTEXTS ", matchers: ["contexts/"] }, | |
| { name: "// COMPONENTS ", matchers: ["components/"] }, | |
| { name: "// CONFIGS ", matchers: ["configs/"] }, | |
| { name: "// LIB ", matchers: ["lib/"] }, | |
| { name: "// LOGIC ", matchers: ["logic/"] }, | |
| { name: "// SERVICES ", matchers: ["services/"] }, | |
| { name: "// DATA ", matchers: ["mock-data/"] }, | |
| { name: "// HOOKS ", matchers: ["hooks/"] }, | |
| { name: "// ASSETS ", matchers: ["assets/"] }, | |
| { name: "// STORES ", matchers: ["store/"] }, | |
| { name: "// STYLES ", matchers: ["styles/", ".css"] }, | |
| { name: "// TYPES ", matchers: ["types", "typings"] }, | |
| { name: "// UTILS ", matchers: ["utils/"] }, | |
| { name: "// OTHER ", matchers: [] }, | |
| ], | |
| }; | |
| let currentDir = path.dirname(startPath); | |
| let configPath = null; | |
| // Search upwards from the current directory to the root | |
| while (currentDir !== path.parse(currentDir).root) { | |
| const potentialPath = path.join(currentDir, ".seriseirc"); | |
| if (fs.existsSync(potentialPath)) { | |
| configPath = potentialPath; | |
| break; | |
| } | |
| currentDir = path.dirname(currentDir); | |
| } | |
| if (!configPath) { | |
| const rootPath = path.join(path.parse(startPath).root, ".seriseirc"); | |
| if (fs.existsSync(rootPath)) { | |
| configPath = rootPath; | |
| } | |
| } | |
| if (!configPath) { | |
| return defaultConfig; | |
| } | |
| let config = JSON.parse(JSON.stringify(defaultConfig)); | |
| try { | |
| const fileContent = fs.readFileSync(configPath, "utf8"); | |
| const lines = fileContent.split("\n"); | |
| let inGroupsSection = false; | |
| let customGroups = []; | |
| for (const line of lines) { | |
| const trimmedLine = line.trim(); | |
| if (!trimmedLine || trimmedLine.startsWith("#")) { | |
| continue; | |
| } | |
| if (/^\[\s*groups\s*\]$/.test(trimmedLine)) { | |
| inGroupsSection = true; | |
| continue; | |
| } | |
| const parts = trimmedLine.split("="); | |
| const key = parts[0].trim(); | |
| const value = parts.slice(1).join("=").trim(); | |
| if (!key) continue; | |
| if (inGroupsSection) { | |
| const name = key; | |
| // The script now respects quotes in the config file for more precise matching. | |
| const matchers = value | |
| ? value.split(",").map(m => m.trim()).filter(Boolean) | |
| : []; | |
| customGroups.push({ name, matchers }); | |
| } else { | |
| if (key === "TO_COLUMN_WIDTH") { | |
| config.TO_COLUMN_WIDTH = parseInt(value, 10) || defaultConfig.TO_COLUMN_WIDTH; | |
| } else if (key === "HEADER_CHAR") { | |
| config.HEADER_CHAR = value || defaultConfig.HEADER_CHAR; | |
| } | |
| } | |
| } | |
| if (customGroups.length > 0) { | |
| config.groups = customGroups; | |
| } | |
| } catch (e) { | |
| console.error(`Error reading or parsing ${configPath}:`, e); | |
| return defaultConfig; | |
| } | |
| return config; | |
| }; | |
| /** | |
| * Group all import statements found in the file based on the provided configuration. | |
| * | |
| * @param {string} fileContent The full file text. | |
| * @param {object} config The configuration object. | |
| * @returns {string} The file text with the import block replaced by grouped imports. | |
| */ | |
| const groupImports = (fileContent, config) => { | |
| const { TO_COLUMN_WIDTH, HEADER_CHAR, groups: groupDefinitions } = config; | |
| const lines = fileContent.split("\n"); | |
| const headerRegex = new RegExp(`^\\s*//.*\\s[${HEADER_CHAR}]`); | |
| const importStart = lines.findIndex(line => { | |
| const trimmed = line.trim(); | |
| return trimmed !== '' && (headerRegex.test(trimmed) || trimmed.startsWith("import ") || trimmed.startsWith("//") || trimmed.startsWith("/*")); | |
| }); | |
| if (importStart === -1) return fileContent; | |
| const imports = []; | |
| let commentBuffer = []; | |
| let importEnd = -1; | |
| for (let i = importStart; i < lines.length; i++) { | |
| const line = lines[i]; | |
| const trimmedLine = line.trim(); | |
| if (trimmedLine === '') continue; | |
| if (headerRegex.test(trimmedLine)) continue; | |
| if (trimmedLine.startsWith('//') || trimmedLine.startsWith('/*')) { | |
| commentBuffer.push(line); | |
| continue; | |
| } | |
| if (trimmedLine.startsWith('import ')) { | |
| let currentImportLines = [...commentBuffer, line]; | |
| commentBuffer = []; | |
| let braceDepth = (trimmedLine.match(/\{/g) || []).length - (trimmedLine.match(/\}/g) || []).length; | |
| if (!trimmedLine.endsWith(';') || braceDepth > 0) { | |
| for (let j = i + 1; j < lines.length; j++) { | |
| const nextLine = lines[j]; | |
| currentImportLines.push(nextLine); | |
| braceDepth += (nextLine.match(/\{/g) || []).length; | |
| braceDepth -= (nextLine.match(/\}/g) || []).length; | |
| if (nextLine.includes(';') && braceDepth === 0) { | |
| i = j; | |
| break; | |
| } | |
| } | |
| } | |
| imports.push(currentImportLines.join('\n')); | |
| } else { | |
| // This is the first line of code after the imports. | |
| importEnd = i; | |
| // If there were comments before this line, they belong to this code, | |
| // so the import block ended before them. | |
| if (commentBuffer.length > 0) { | |
| const firstCommentLine = commentBuffer[0]; | |
| const firstCommentIndex = lines.indexOf(firstCommentLine, importStart); | |
| if (firstCommentIndex !== -1) { | |
| importEnd = firstCommentIndex; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| if (importEnd === -1) { | |
| importEnd = lines.length; | |
| } | |
| const createHeader = (text) => { | |
| if (text.length >= TO_COLUMN_WIDTH) return text; | |
| return (text.trim() + " ").padEnd(TO_COLUMN_WIDTH, HEADER_CHAR); | |
| }; | |
| const groups = groupDefinitions.map(group => { | |
| const isOtherGroup = group.name.includes("OTHER") || group.matchers.length === 0; | |
| const matcher = isOtherGroup ? () => true : (line) => group.matchers.some(pkg => line.includes(pkg)); | |
| return [createHeader(group.name), matcher]; | |
| }); | |
| const result = []; | |
| const processed = new Set(); | |
| for (const [header, matcher] of groups) { | |
| const matches = imports.filter(imp => { | |
| if (processed.has(imp)) return false; | |
| const fullImport = imp.replace(/\n\s*/g, " "); | |
| return matcher(fullImport); | |
| }); | |
| if (matches.length) { | |
| result.push(header); | |
| result.push(...matches.sort()); | |
| result.push(""); | |
| matches.forEach(imp => processed.add(imp)); | |
| } | |
| } | |
| const preImports = lines.slice(0, importStart); | |
| const postImports = lines.slice(importEnd); | |
| const newLines = [...preImports, ...result, ...postImports]; | |
| return newLines.join("\n"); | |
| }; | |
| /** | |
| * Recursively formats the content of a type or interface block, handling nested objects. | |
| * @param {string[]} blockContentLines - The lines of code inside the { ... } block. | |
| * @param {string} baseIndent - The base indentation of the parent block. | |
| * @returns {string[]} The formatted lines of code. | |
| */ | |
| const formatBlockContent = (blockContentLines, baseIndent) => { | |
| const properties = []; | |
| let currentPropLines = []; | |
| let braceDepth = 0; | |
| // Delimit properties, correctly handling multi-line nested objects. | |
| for (const line of blockContentLines) { | |
| if (line.trim() === '' && currentPropLines.length === 0) continue; | |
| currentPropLines.push(line); | |
| braceDepth += (line.match(/\{/g) || []).length; | |
| braceDepth -= (line.match(/\}/g) || []).length; | |
| if (braceDepth === 0) { | |
| properties.push(currentPropLines); | |
| currentPropLines = []; | |
| } | |
| } | |
| if (currentPropLines.length > 0) properties.push(currentPropLines); | |
| // Calculate alignment for all direct child properties in this block | |
| let maxKeyLength = 0; | |
| let hasOptional = false; | |
| for (const prop of properties) { | |
| const firstLine = prop[0].trim(); | |
| if (firstLine.startsWith('//') || firstLine.startsWith('/*')) continue; | |
| const match = firstLine.match(/^(\[.+?\]|[\w$]+)(\s*\?)?\s*:/); | |
| if (match) { | |
| maxKeyLength = Math.max(maxKeyLength, match[1].length); | |
| if (match[2]) hasOptional = true; | |
| } | |
| } | |
| const propertyIndent = baseIndent + " "; | |
| const formattedLines = []; | |
| // Format each property | |
| for (const prop of properties) { | |
| const firstLine = prop[0]; | |
| const trimmedFirstLine = firstLine.trim(); | |
| if (trimmedFirstLine.startsWith('//') || trimmedFirstLine.startsWith('/*')) { | |
| formattedLines.push(propertyIndent + trimmedFirstLine); | |
| continue; | |
| } | |
| const match = trimmedFirstLine.match(/^(\[.+?\]|[\w$]+)(\s*\?)?\s*:\s*(.*)$/); | |
| if (!match) { | |
| formattedLines.push(...prop.map(l => l)); | |
| continue; | |
| } | |
| const [, key, optional, value] = match; | |
| const paddedKey = key.padEnd(maxKeyLength); | |
| let alignedFirstLine; | |
| if (hasOptional) { | |
| alignedFirstLine = `${propertyIndent}${paddedKey}${optional ? ' ?' : ' '} : ${value}`; | |
| } else { | |
| alignedFirstLine = `${propertyIndent}${paddedKey} : ${value}`; | |
| } | |
| if (prop.length === 1) { | |
| formattedLines.push(alignedFirstLine); | |
| } else { | |
| const firstLineHeader = alignedFirstLine.substring(0, alignedFirstLine.lastIndexOf('{') + 1); | |
| formattedLines.push(firstLineHeader); | |
| const nestedContent = prop.slice(1, -1); | |
| if (nestedContent.length > 0) { | |
| const formattedNestedContent = formatBlockContent(nestedContent, propertyIndent); | |
| formattedLines.push(...formattedNestedContent); | |
| } | |
| const lastLine = prop[prop.length - 1]; | |
| formattedLines.push(propertyIndent + lastLine.trim()); | |
| } | |
| } | |
| return formattedLines; | |
| }; | |
| /** | |
| * Finds and formats all type and interface blocks in the file. | |
| * @param {string} fileContent The full file text. | |
| * @returns {string} The file text with formatted interfaces/types. | |
| */ | |
| const formatTypesAndInterfaces = (fileContent) => { | |
| const lines = fileContent.split("\n"); | |
| const newLines = []; | |
| let i = 0; | |
| while (i < lines.length) { | |
| const line = lines[i]; | |
| const trimmed = line.trim(); | |
| const isBlockStart = /^\s*(export\s+)?(interface|type)\s+[\w$]+.*\{/.test(trimmed); | |
| if (isBlockStart) { | |
| const baseIndent = (line.match(/^\s*/) || [""])[0]; | |
| let blockStartIndex = i; | |
| let braceDepth = 0; | |
| let blockEndIndex = -1; | |
| for (let j = i; j < lines.length; j++) { | |
| braceDepth += (lines[j].match(/\{/g) || []).length; | |
| braceDepth -= (lines[j].match(/\}/g) || []).length; | |
| if (braceDepth === 0) { | |
| blockEndIndex = j; | |
| break; | |
| } | |
| } | |
| if (blockEndIndex !== -1) { | |
| const blockHeader = lines[blockStartIndex]; | |
| const blockFooter = lines[blockEndIndex]; | |
| const blockContent = lines.slice(blockStartIndex + 1, blockEndIndex); | |
| newLines.push(blockHeader); | |
| if (blockContent.length > 0) { | |
| newLines.push(...formatBlockContent(blockContent, baseIndent)); | |
| } | |
| newLines.push(blockFooter); | |
| i = blockEndIndex + 1; | |
| continue; | |
| } | |
| } | |
| newLines.push(line); | |
| i++; | |
| } | |
| return newLines.join("\n"); | |
| }; | |
| /** | |
| * Write file atomically to avoid conflicts with IDEs | |
| * @param {string} filePath - Path to write to | |
| * @param {string} content - Content to write | |
| */ | |
| const writeFileAtomic = (filePath, content) => { | |
| const tempFile = path.join(os.tmpdir(), `${path.basename(filePath)}.${Date.now()}.tmp`); | |
| try { | |
| fs.writeFileSync(tempFile, content, "utf8"); | |
| fs.renameSync(tempFile, filePath); | |
| } catch (error) { | |
| try { | |
| if (fs.existsSync(tempFile)) { | |
| fs.unlinkSync(tempFile); | |
| } | |
| } catch (e) { | |
| // Ignore cleanup errors | |
| } | |
| throw error; | |
| } | |
| }; | |
| /** | |
| * Read the file, apply the import grouping and type/interface formatting, | |
| * then write the formatted code back to the file. | |
| * | |
| * @param {string} filePath The path of the file to process. | |
| */ | |
| const processFile = (filePath) => { | |
| try { | |
| // The loadConfig function now takes the full file path to start its search. | |
| const config = loadConfig(filePath); | |
| let code = fs.readFileSync(filePath, "utf8"); | |
| const originalCode = code; | |
| code = groupImports(code, config); | |
| code = formatTypesAndInterfaces(code); | |
| if (code !== originalCode) { | |
| writeFileAtomic(filePath, code); | |
| } | |
| } catch (error) { | |
| if (error.code === "EBUSY") { | |
| setTimeout(() => processFile(filePath), 100); | |
| } else { | |
| console.error(`Error processing ${filePath}:`, error); | |
| } | |
| } | |
| }; | |
| const filePath = process.argv[2]; | |
| if (!filePath) { | |
| console.error("Please provide a file path to format."); | |
| process.exit(1); | |
| } | |
| processFile(filePath); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
It looks for a
.seriseircfile at the root of your project, where you can add your configs. Here’s a good start: