Skip to content

Instantly share code, notes, and snippets.

@nyurik
Last active January 16, 2023 08:29
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nyurik/d438cb56a9059a0660ce4176ef94576f to your computer and use it in GitHub Desktop.
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 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