Last active
May 10, 2024 02:39
-
-
Save nyurik/d438cb56a9059a0660ce4176ef94576f to your computer and use it in GitHub Desktop.
Script to convert SCSS files from physical to logical values for RTL and vertical languages
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
# | |
# This script converts margins, padding, and borders to logical values, | |
# allowing RTL and vertical languages to show correctly. | |
# Supports both *.css and *.scss files. | |
# | |
# Some renames are not yet implemented widely, and may require CSS plugin | |
# https://github.com/csstools/postcss-logical | |
# They have been commented out for now, but feel free to try them out. | |
# | |
# Full spec: https://drafts.csswg.org/css-logical/ | |
# Good blogs: https://adrianroselli.com/2019/11/css-logical-properties.html | |
# https://css-tricks.com/css-logical-properties/ | |
# | |
# Usage (recursive): python3 convert-scss.py -r <path-to-scss-files-dir> | |
# | |
# License: MIT | |
from pathlib import Path | |
import getopt, sys, re | |
def error(msg): | |
print(msg) | |
exit(1) | |
sides = { | |
'-top': '-block-start', | |
'-right': '-inline-end', | |
'-bottom': '-block-end', | |
'-left': '-inline-start', | |
} | |
renames = { | |
# These are mostly unimplemented, might require https://github.com/csstools/postcss-logical | |
# 'left': 'inset-inline-start', | |
# 'right': 'inset-inline-end', | |
# 'top': 'inset-block-start', | |
# 'bottom': 'inset-block-end', | |
'min-height': 'min-block-size', | |
'max-height': 'max-block-size', | |
'min-width': 'min-inline-size', | |
'max-width': 'max-inline-size', | |
} | |
inline_start_end = { | |
'left': 'inline-start', | |
'right': 'inline-end', | |
} | |
aligns = { | |
'text-align': { | |
'left': 'start', | |
'right': 'end', | |
}, | |
# These are mostly unimplemented, might require https://github.com/csstools/postcss-logical | |
# 'float': inline_start_end, | |
# 'clear': inline_start_end, | |
} | |
def collapse_if_equal(val1, val2): | |
return val1 if val1 == val2 else f"{val1} {val2}" | |
def replacer(match): | |
# ^( *)(margin|padding|border)(-(?:left|right|top|bottom))?(-(?:size|style|color))?( *: *)([^;\n]+);( *//.*)?$ | |
original = match.group(0) | |
spaces1 = match.group(1) | |
typ = match.group(2) | |
side = match.group(3) or '' | |
extra = match.group(4) or '' | |
spaces2 = match.group(5) | |
values = match.group(6) | |
comment = match.group(7) or '' | |
# special lint controlling comment should be repeated | |
dup_comment = comment if ('sass-lint:' in comment) else '' | |
if not side: | |
if typ == 'border': | |
return original | |
tokens = tokenize(values) | |
important = ' ' + tokens[-1] if tokens[-1] == '!important' else '' | |
if important: | |
tokens = tokens[:-1] | |
if len(tokens) == 1: | |
return original # single token stays as is | |
if len(tokens) == 2: # top-bottom right-left | |
# The *-block shorthand is not yet supported, see | |
# https://developer.mozilla.org/en-US/docs/Web/CSS/margin-block | |
return f"{spaces1}{typ}-block-start{extra}{spaces2}{tokens[0]}{important};{comment}\n" + \ | |
f"{spaces1}{typ}-block-end{extra}{spaces2}{tokens[0]}{important};{dup_comment}\n" + \ | |
f"{spaces1}{typ}-inline-start{extra}{spaces2}{tokens[1]}{important};{dup_comment}\n" + \ | |
f"{spaces1}{typ}-inline-end{extra}{spaces2}{tokens[1]}{important};{dup_comment}" | |
if len(tokens) == 3: # top left-right bottom | |
return f"{spaces1}{typ}-block-start{extra}{spaces2}{tokens[0]}{important};{comment}\n" + \ | |
f"{spaces1}{typ}-block-end{extra}{spaces2}{tokens[2]}{important};{dup_comment}\n" + \ | |
f"{spaces1}{typ}-inline-start{extra}{spaces2}{tokens[1]}{important};{dup_comment}\n" + \ | |
f"{spaces1}{typ}-inline-end{extra}{spaces2}{tokens[1]}{important};{dup_comment}" | |
if len(tokens) == 4: # top left-right bottom | |
return f"{spaces1}{typ}-block-start{extra}{spaces2}{tokens[0]}{important};{dup_comment}{comment}\n" + \ | |
f"{spaces1}{typ}-block-end{extra}{spaces2}{tokens[2]}{important};{dup_comment}\n" + \ | |
f"{spaces1}{typ}-inline-start{extra}{spaces2}{tokens[3]}{important};{dup_comment}\n" + \ | |
f"{spaces1}{typ}-inline-end{extra}{spaces2}{tokens[1]}{important};{dup_comment}" | |
raise ValueError(f'Unexpected number of tokens {len(tokens)} in {original} -- {tokens}') | |
return f"{spaces1}{typ}{sides[side]}{extra}{spaces2}{values};{comment}" | |
def renamer(match): | |
# ^( *)(' + '|'.join(renames.keys()) + r')( *: *)([^;\n]+);( *//.*)?$ | |
original = match.group(0) | |
spaces1 = match.group(1) | |
typ = match.group(2) | |
spaces2 = match.group(3) | |
values = match.group(4) | |
comment = match.group(5) or '' | |
try: | |
return f"{spaces1}{renames[typ]}{spaces2}{values};{comment}" | |
except KeyError: | |
return original | |
def aligner(match): | |
# ^( *)(text-align|float|clear)( *: *)(left|right)( *); | |
original = match.group(0) | |
spaces1 = match.group(1) | |
typ = match.group(2) | |
spaces2 = match.group(3) | |
values = match.group(4) | |
spaces3 = match.group(5) | |
try: | |
return f"{spaces1}{typ}{spaces2}{aligns[typ][values]}{spaces3};" | |
except KeyError: | |
return original | |
def tokenize(string): | |
# Original idea from https://stackoverflow.com/a/38212061/177275 | |
# Assume correct syntax / matching brackets | |
brackets = 0 | |
start = 0 | |
results = [] | |
for idx, char in enumerate(string): | |
if char == ' ': | |
if start is not None and brackets == 0: | |
results.append(string[start:idx]) | |
start = None | |
else: | |
if start is None: | |
start = idx | |
if char == '(' or char == '{': | |
brackets += 1 | |
elif char == ')' or char == '}': | |
brackets -= 1 | |
if brackets < 0: | |
raise ValueError(f'failed to tokenize "{string}"') | |
if start is not None: | |
results.append(string[start:]) | |
results2 = [] | |
idx = 0 | |
while idx < len(results): | |
if len(results[idx]) > 1 or results[idx] not in ('-', '+', '/', '*'): | |
results2.append(results[idx]) | |
else: | |
results2[-1] += ' ' + results[idx] + ' ' + results[idx + 1] | |
idx += 1 | |
idx += 1 | |
return results2 | |
def process(file_path): | |
print(f"Processing {file_path}...") | |
code = file_path.read_text() | |
res = re.sub( | |
r'^( *)(margin|padding|border)(-(?:left|right|top|bottom))?(-(?:size|style|color))?( *: *)([^;\n]+);( *//.*)?$', | |
replacer, code, flags=re.MULTILINE) | |
res = re.sub( | |
r'^( *)(' + '|'.join(renames.keys()) + r')( *: *)([^;\n]+);( *//.*)?$', | |
renamer, res, flags=re.MULTILINE) | |
res = re.sub( | |
r'^( *)(text-align|float|clear)( *: *)(left|right)( *);', | |
aligner, res, flags=re.MULTILINE) | |
if res != code: | |
print(f"Modifying {file_path}") | |
file_path.write_text(res) | |
# Use -r for recursive | |
opts, args = getopt.getopt(sys.argv[1:], 'r') | |
if len(args) != 1: | |
error("Usage: [-r] <directory-or-file>\n\nUse -r for recursive") | |
recursive = opts and opts[0][0] == '-r' | |
root_path = Path(args[0]) | |
if root_path.is_file(): | |
process(root_path) | |
else: | |
for pattern in ('*.scss', '*.css'): | |
for file in (root_path.rglob(pattern) if recursive else root_path.glob(pattern)): | |
if file.is_file(): | |
process(root_path / file) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment