Skip to content

Instantly share code, notes, and snippets.

@oscarmarina
Created August 13, 2025 18:16
Show Gist options
  • Save oscarmarina/94cadf89e1b92c140a1fcf53db28ed9e to your computer and use it in GitHub Desktop.
Save oscarmarina/94cadf89e1b92c140a1fcf53db28ed9e to your computer and use it in GitHub Desktop.
convert CSS to logical properties
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { load as loadHtml } from 'cheerio';
// https://adrianroselli.com/2019/11/css-logical-properties.html
const root = process.cwd();
const write = process.argv.includes('--write');
const includeDirs = [path.join(root, 'packages')];
const includeExts = new Set(['.scss', '.html']);
const excludeFragments = ['node_modules', '.git', '.vscode', '.github', 'dist', 'build', 'coverage', 'lcov-report'];
function propertyReplacer(css) {
const rep = (re, to) => (css = css.replace(re, to));
// Borders and corners
rep(/(^|[;{\s])border-top-left-radius\s*:/gi, '$1border-start-start-radius:');
rep(/(^|[;{\s])border-top-right-radius\s*:/gi, '$1border-start-end-radius:');
rep(/(^|[;{\s])border-bottom-left-radius\s*:/gi, '$1border-end-start-radius:');
rep(/(^|[;{\s])border-bottom-right-radius\s*:/gi, '$1border-end-end-radius:');
// border sides (shorthands)
rep(/(^|[;{\s])border-left(\s*:[^;{]+)/gi, '$1border-inline-start$2');
rep(/(^|[;{\s])border-right(\s*:[^;{]+)/gi, '$1border-inline-end$2');
rep(/(^|[;{\s])border-top(\s*:[^;{]+)/gi, '$1border-block-start$2');
rep(/(^|[;{\s])border-bottom(\s*:[^;{]+)/gi, '$1border-block-end$2');
// border color/style/width per side
rep(/(^|[;{\s])border-left-color\s*:/gi, '$1border-inline-start-color:');
rep(/(^|[;{\s])border-right-color\s*:/gi, '$1border-inline-end-color:');
rep(/(^|[;{\s])border-top-color\s*:/gi, '$1border-block-start-color:');
rep(/(^|[;{\s])border-bottom-color\s*:/gi, '$1border-block-end-color:');
rep(/(^|[;{\s])border-left-style\s*:/gi, '$1border-inline-start-style:');
rep(/(^|[;{\s])border-right-style\s*:/gi, '$1border-inline-end-style:');
rep(/(^|[;{\s])border-top-style\s*:/gi, '$1border-block-start-style:');
rep(/(^|[;{\s])border-bottom-style\s*:/gi, '$1border-block-end-style:');
rep(/(^|[;{\s])border-left-width\s*:/gi, '$1border-inline-start-width:');
rep(/(^|[;{\s])border-right-width\s*:/gi, '$1border-inline-end-width:');
rep(/(^|[;{\s])border-top-width\s*:/gi, '$1border-block-start-width:');
rep(/(^|[;{\s])border-bottom-width\s*:/gi, '$1border-block-end-width:');
// Spacing
rep(/(^|[;{\s])margin-left\s*:/gi, '$1margin-inline-start:');
rep(/(^|[;{\s])margin-right\s*:/gi, '$1margin-inline-end:');
rep(/(^|[;{\s])margin-top\s*:/gi, '$1margin-block-start:');
rep(/(^|[;{\s])margin-bottom\s*:/gi, '$1margin-block-end:');
rep(/(^|[;{\s])padding-left\s*:/gi, '$1padding-inline-start:');
rep(/(^|[;{\s])padding-right\s*:/gi, '$1padding-inline-end:');
rep(/(^|[;{\s])padding-top\s*:/gi, '$1padding-block-start:');
rep(/(^|[;{\s])padding-bottom\s*:/gi, '$1padding-block-end:');
// Size
rep(/(^|[;{\s])width\s*:/gi, '$1inline-size:');
rep(/(^|[;{\s])height\s*:/gi, '$1block-size:');
rep(/(^|[;{\s])min-width\s*:/gi, '$1min-inline-size:');
rep(/(^|[;{\s])max-width\s*:/gi, '$1max-inline-size:');
rep(/(^|[;{\s])min-height\s*:/gi, '$1min-block-size:');
rep(/(^|[;{\s])max-height\s*:/gi, '$1max-block-size:');
// Positioning
rep(/(^|[;{\s])left\s*:/gi, '$1inset-inline-start:');
rep(/(^|[;{\s])right\s*:/gi, '$1inset-inline-end:');
rep(/(^|[;{\s])top\s*:/gi, '$1inset-block-start:');
rep(/(^|[;{\s])bottom\s*:/gi, '$1inset-block-end:');
// Overflow & overscroll
rep(/(^|[;{\s])overflow-x\s*:/gi, '$1overflow-inline:');
rep(/(^|[;{\s])overflow-y\s*:/gi, '$1overflow-block:');
rep(/(^|[;{\s])overscroll-behavior-x\s*:/gi, '$1overscroll-behavior-inline:');
rep(/(^|[;{\s])overscroll-behavior-y\s*:/gi, '$1overscroll-behavior-block:');
// Scroll padding/margin
rep(/(^|[;{\s])scroll-margin-left\s*:/gi, '$1scroll-margin-inline-start:');
rep(/(^|[;{\s])scroll-margin-right\s*:/gi, '$1scroll-margin-inline-end:');
rep(/(^|[;{\s])scroll-margin-top\s*:/gi, '$1scroll-margin-block-start:');
rep(/(^|[;{\s])scroll-margin-bottom\s*:/gi, '$1scroll-margin-block-end:');
rep(/(^|[;{\s])scroll-padding-left\s*:/gi, '$1scroll-padding-inline-start:');
rep(/(^|[;{\s])scroll-padding-right\s*:/gi, '$1scroll-padding-inline-end:');
rep(/(^|[;{\s])scroll-padding-top\s*:/gi, '$1scroll-padding-block-start:');
rep(/(^|[;{\s])scroll-padding-bottom\s*:/gi, '$1scroll-padding-block-end:');
// Flow
rep(/text-align\s*:\s*left\b/gi, 'text-align: start');
rep(/text-align\s*:\s*right\b/gi, 'text-align: end');
rep(/(^|[;{\s])float\s*:\s*left\b/gi, '$1float: inline-start');
rep(/(^|[;{\s])float\s*:\s*right\b/gi, '$1float: inline-end');
rep(/(^|[;{\s])clear\s*:\s*left\b/gi, '$1clear: inline-start');
rep(/(^|[;{\s])clear\s*:\s*right\b/gi, '$1clear: inline-end');
return css;
}
const shouldSkip = (p) => excludeFragments.some((frag) => p.includes(frag));
const shouldProcess = (p) => includeExts.has(path.extname(p).toLowerCase());
async function walk(dir, out = []) {
if (shouldSkip(dir)) return out;
const entries = await readdir(dir);
for (const name of entries) {
const fp = path.join(dir, name);
if (shouldSkip(fp)) continue;
const st = await stat(fp);
if (st.isDirectory()) await walk(fp, out);
else if (st.isFile() && shouldProcess(fp)) out.push(fp);
}
return out;
}
async function main() {
let files = [];
for (const d of includeDirs) {
try {
const s = await stat(d);
if (s.isDirectory()) files.push(...(await walk(d)));
} catch {
// Ignore errors for missing directories
}
}
let changed = 0;
for (const file of files) {
const src = await readFile(file, 'utf8');
const ext = path.extname(file).toLowerCase();
let dst = src;
if (ext === '.scss') {
dst = propertyReplacer(src);
} else if (ext === '.html') {
const $ = loadHtml(src);
let domChanged = false;
// Transform <style> tag contents
$('style').each((_, el) => {
const css = $(el).html() ?? '';
const updated = propertyReplacer(css);
if (updated !== css) {
$(el).text(updated);
domChanged = true;
}
});
// Transform inline style attributes
$('[style]').each((_, el) => {
const styleVal = $(el).attr('style') ?? '';
const updated = propertyReplacer(styleVal);
if (updated !== styleVal) {
$(el).attr('style', updated);
domChanged = true;
}
});
dst = domChanged ? $.html() : src;
}
if (dst !== src) {
changed++;
if (write) {
await writeFile(file, dst, 'utf8');
console.log(`Updated: ${path.relative(root, file)}`);
} else {
console.log(`Would update: ${path.relative(root, file)}`);
}
}
}
console.log(
write
? `Updated ${changed} files to CSS logical properties.`
: `Dry-run complete. ${changed} files would be updated. Run with --write to apply changes.`,
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment