Skip to content

Instantly share code, notes, and snippets.

@jmfrancois
Last active January 9, 2026 08:59
Show Gist options
  • Select an option

  • Save jmfrancois/16b52b313d35eef589fa2935431d4b70 to your computer and use it in GitHub Desktop.

Select an option

Save jmfrancois/16b52b313d35eef589fa2935431d4b70 to your computer and use it in GitHub Desktop.
/**
* 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