Created
August 13, 2025 18:16
-
-
Save oscarmarina/94cadf89e1b92c140a1fcf53db28ed9e to your computer and use it in GitHub Desktop.
convert CSS to logical properties
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
| 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