Last active
January 9, 2026 08:59
-
-
Save jmfrancois/16b52b313d35eef589fa2935431d4b70 to your computer and use it in GitHub Desktop.
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
| /** | |
| * Run `node css.js` from this package root to compile every `*.module.scss` to a sibling `*.module.css` and rewrite imports accordingly. | |
| * | |
| * - Scans the package for `*.module.scss` files (ignoring node_modules, lib, lib-esm, .turbo, .git, dist, build, etc.). | |
| * - Compiles each of them with `sass` into a `*.module.css` that sits in the same folder. | |
| * - Updates `.js`, `.jsx`, `.ts`, and `.tsx` files so `.module.scss` imports point to the new `.module.css` files. | |
| * - Optionally deletes the original `*.module.scss` files after successful compilation (use --delete flag). | |
| * | |
| * Usage: | |
| * node css.js # Compile and update imports (keep .scss files) | |
| * node css.js --dry-run # Preview what would be done | |
| * node css.js --delete # Compile, update imports, and delete .scss files | |
| * node css.js --dry-run --delete # Preview with deletion | |
| */ | |
| /* eslint-disable no-console */ | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const sass = require('sass'); | |
| const { pathToFileURL } = require('url'); | |
| const PACKAGE_ROOT = path.resolve(__dirname); | |
| const NODE_MODULES_PATH = path.join(PACKAGE_ROOT, 'node_modules'); | |
| const WORKSPACE_NODE_MODULES = path.resolve(PACKAGE_ROOT, '..', '..', 'node_modules'); | |
| const IGNORED_DIRECTORIES = new Set([ | |
| 'node_modules', | |
| 'lib', | |
| 'lib-esm', | |
| '.turbo', | |
| '.git', | |
| 'dist', | |
| 'build', | |
| '.next', | |
| 'coverage', | |
| '.cache', | |
| 'out', | |
| ]); | |
| function assertPackageRoot() { | |
| const packageJson = path.join(PACKAGE_ROOT, 'package.json'); | |
| if (!fs.existsSync(packageJson)) { | |
| throw new Error( | |
| `No package.json found in ${PACKAGE_ROOT}. Run this script from the package root.`, | |
| ); | |
| } | |
| } | |
| function toRelative(filePath) { | |
| return path.relative(PACKAGE_ROOT, filePath) || '.'; | |
| } | |
| function getPkgRoot(filename) { | |
| let current = path.dirname(filename); | |
| while (true) { | |
| if (fs.existsSync(path.join(current, 'package.json'))) { | |
| return `${current}/`; | |
| } | |
| const parent = path.dirname(current); | |
| if (parent === current) { | |
| throw new Error(`Unable to find package.json for ${filename}`); | |
| } | |
| current = parent; | |
| } | |
| } | |
| function getInfo(importPath) { | |
| const parts = importPath.split('/'); | |
| const isScoped = importPath.startsWith('@'); | |
| const packageName = isScoped ? `${parts[0]}/${parts[1]}` : parts[0]; | |
| const rest = isScoped ? parts.slice(2) : parts.slice(1); | |
| const mainPath = require.resolve(packageName, { paths: [PACKAGE_ROOT] }); | |
| return { | |
| base: getPkgRoot(mainPath), | |
| url: rest.join('/'), | |
| }; | |
| } | |
| function createImporter() { | |
| // https://sass-lang.com/documentation/js-api/interfaces/Options | |
| return { | |
| // Allow tilde-prefixed imports the same way webpack does. | |
| findFileUrl(url) { | |
| if (!url.startsWith('~')) { | |
| return null; // fallback to default resolution via loadPaths | |
| } | |
| const info = getInfo(url.slice(1)); | |
| return new URL(info.url, pathToFileURL(info.base)); | |
| }, | |
| }; | |
| } | |
| function buildSassOptions(sourceFile) { | |
| const loadPaths = [path.dirname(sourceFile), PACKAGE_ROOT, NODE_MODULES_PATH]; | |
| if (fs.existsSync(WORKSPACE_NODE_MODULES)) { | |
| loadPaths.push(WORKSPACE_NODE_MODULES); | |
| } | |
| return { | |
| loadPaths, | |
| sourceMap: false, | |
| importers: [createImporter()], | |
| }; | |
| } | |
| function walk(startDir, matcher, acc = []) { | |
| const entries = fs.readdirSync(startDir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(startDir, entry.name); | |
| if (entry.isDirectory()) { | |
| if (IGNORED_DIRECTORIES.has(entry.name)) { | |
| continue; | |
| } | |
| walk(fullPath, matcher, acc); | |
| continue; | |
| } | |
| if (entry.isFile() && matcher(entry.name, fullPath)) { | |
| acc.push(fullPath); | |
| } | |
| } | |
| return acc; | |
| } | |
| function findModuleScssFiles() { | |
| return walk(PACKAGE_ROOT, name => name.endsWith('.module.scss')); | |
| } | |
| function findCodeFiles() { | |
| const extensions = new Set(['.js', '.jsx', '.ts', '.tsx']); | |
| return walk(PACKAGE_ROOT, name => extensions.has(path.extname(name))); | |
| } | |
| function compileModuleScss(filePath, dryRun = false) { | |
| try { | |
| const targetPath = filePath.replace(/\.module\.scss$/, '.module.css'); | |
| if (dryRun) { | |
| console.log(`[DRY RUN] Would compile ${toRelative(filePath)} -> ${toRelative(targetPath)}`); | |
| return { source: filePath, target: targetPath }; | |
| } | |
| const result = sass.compile(filePath, buildSassOptions(filePath)); | |
| fs.mkdirSync(path.dirname(targetPath), { recursive: true }); | |
| fs.writeFileSync(targetPath, result.css); | |
| console.log(`compiled ${toRelative(filePath)} -> ${toRelative(targetPath)}`); | |
| return { source: filePath, target: targetPath }; | |
| } catch (e) { | |
| console.error(`failed to compile ${toRelative(filePath)}: ${e.message}`); | |
| return null; | |
| } | |
| } | |
| function updateModuleImports(filePath, compiledFiles, dryRun = false) { | |
| // Do not rewrite this script itself | |
| if (path.resolve(filePath) === path.resolve(__filename)) { | |
| return false; | |
| } | |
| const content = fs.readFileSync(filePath, 'utf8'); | |
| if (!content.includes('.module.scss')) { | |
| return false; | |
| } | |
| // Build a map of .scss -> .css paths for compiled files only | |
| const scssToCSS = new Map(); | |
| compiledFiles.forEach(({ source, target }) => { | |
| const relativeSCSS = path.relative(path.dirname(filePath), source); | |
| const relativeCSS = path.relative(path.dirname(filePath), target); | |
| scssToCSS.set(relativeSCSS, relativeCSS); | |
| }); | |
| let updated = content; | |
| let hasChanges = false; | |
| // Only replace imports for successfully compiled files | |
| scssToCSS.forEach((cssPath, scssPath) => { | |
| const scssPattern = scssPath.replace(/\\/g, '/').replace(/\.module\.scss$/, '.module.scss'); | |
| const regex = new RegExp(scssPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); | |
| const newContent = updated.replace(regex, cssPath.replace(/\\/g, '/')); | |
| if (newContent !== updated) { | |
| hasChanges = true; | |
| updated = newContent; | |
| } | |
| }); | |
| // Fallback: simple global replace for remaining .module.scss | |
| const globalReplaced = updated.replace(/\.module\.scss\b/g, '.module.css'); | |
| if (globalReplaced !== updated) { | |
| hasChanges = true; | |
| updated = globalReplaced; | |
| } | |
| if (!hasChanges) { | |
| return false; | |
| } | |
| if (dryRun) { | |
| console.log(`[DRY RUN] Would rewrite imports in ${toRelative(filePath)}`); | |
| } else { | |
| fs.writeFileSync(filePath, updated); | |
| console.log(`rewrote imports in ${toRelative(filePath)}`); | |
| } | |
| return true; | |
| } | |
| function main() { | |
| // Parse CLI arguments | |
| const args = process.argv.slice(2); | |
| const dryRun = args.includes('--dry-run'); | |
| const shouldDelete = args.includes('--delete'); | |
| assertPackageRoot(); | |
| const scssFiles = findModuleScssFiles(); | |
| if (scssFiles.length === 0) { | |
| console.log('No *.module.scss files found.'); | |
| return; | |
| } | |
| console.log(`Found ${scssFiles.length} *.module.scss file(s).`); | |
| if (dryRun) { | |
| console.log('[DRY RUN MODE] - No files will be modified'); | |
| } | |
| if (shouldDelete && !dryRun) { | |
| console.log('[DELETE MODE] - SCSS files will be deleted after successful compilation'); | |
| } | |
| console.log(''); | |
| const results = scssFiles.map(file => compileModuleScss(file, dryRun)); | |
| const compiled = results.filter(Boolean); | |
| const failedCount = results.length - compiled.length; | |
| if (failedCount > 0) { | |
| console.error( | |
| `\nFailed to compile ${failedCount} file(s). Stopping here without updating imports or deleting files.`, | |
| ); | |
| process.exit(1); | |
| } | |
| const codeFiles = findCodeFiles(); | |
| let updatedImports = 0; | |
| codeFiles.forEach(filePath => { | |
| if (updateModuleImports(filePath, compiled, dryRun)) { | |
| updatedImports += 1; | |
| } | |
| }); | |
| // Delete SCSS sources only if --delete flag is provided | |
| let deletedCount = 0; | |
| if (shouldDelete) { | |
| compiled.forEach(({ source, target }) => { | |
| try { | |
| if (fs.existsSync(target) || dryRun) { | |
| if (dryRun) { | |
| console.log(`[DRY RUN] Would delete ${toRelative(source)}`); | |
| deletedCount += 1; | |
| } else { | |
| fs.unlinkSync(source); | |
| deletedCount += 1; | |
| console.log(`deleted ${toRelative(source)}`); | |
| } | |
| } | |
| } catch (e) { | |
| console.warn(`could not delete ${toRelative(source)}: ${e.message}`); | |
| } | |
| }); | |
| } | |
| console.log(''); | |
| if (dryRun) { | |
| console.log('[DRY RUN SUMMARY]'); | |
| } | |
| console.log( | |
| `${dryRun ? 'Would generate' : 'Generated'} ${compiled.length} CSS file(s), ${dryRun ? 'would update' : 'updated'} imports in ${updatedImports} file(s)${shouldDelete ? `, ${dryRun ? 'would delete' : 'deleted'} ${deletedCount} SCSS file(s)` : ''}.`, | |
| ); | |
| if (!shouldDelete && !dryRun) { | |
| console.log('\n💡 Tip: Use --delete flag to remove .scss files after successful compilation'); | |
| } | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment