#!/usr/bin/env python3 """ This scripts handles all the heavy-lifting of parsing CSS files from ``fomantic-ui-css`` and: 1. Replace hardcoded values by their CSS vars counterparts, for easier theming 2. Strip unused styles and icons to reduce the final size of CSS Updated files are not modified in place, but instead copied to another directory (``fomantic-ui-css/tweaked``), in order to allow easy comparison detection of changes. If you change this file, you'll need to run ``yarn run fix-fomantic-css`` manually for the changes to be picked up. If the ``NOSTRIP`` environment variable is set, the second step will be skipped. """ import argparse import os STRIP_UNUSED = "NOSTRIP" not in os.environ # Perform a blind replacement of some strings in all fomantic CSS files GLOBAL_REPLACES = [ # some selectors are repeated in the stylesheet, for some reason (".ui.ui.ui.ui", ".ui"), (".ui.ui.ui", ".ui"), (".ui.ui", ".ui"), (".icon.icon.icon.icon", ".icon"), (".icon.icon.icon", ".icon"), (".icon.icon", ".icon"), # actually useful stuff ("'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif", "var(--font-family)"), (".orange", ".vibrant"), ("#F2711C", "var(--vibrant-color)"), ("#FF851B", "var(--vibrant-color)"), ("#f26202", "var(--vibrant-hover-color)"), ("#e76b00", "var(--vibrant-hover-color)"), ("#cf590c", "var(--vibrant-active-color)"), ("#f56100", "var(--vibrant-active-color)"), ("#e76b00", "var(--vibrant-active-color)"), ("#e55b00", "var(--vibrant-focus-color)"), ("#f17000", "var(--vibrant-focus-color)"), (".teal", ".accent"), ("#00B5AD", "var(--accent-color)"), ("#009c95", "var(--accent-hover-color)"), ("#00827c", "var(--accent-active-color)"), ("#008c86", "var(--accent-focus-color)"), (".green", ".success"), ("#21BA45", "var(--success-color)"), ("#2ECC40", "var(--success-color)"), ("#16ab39", "var(--success-hover-color)"), ("#1ea92e", "var(--success-hover-color)"), ("#198f35", "var(--success-active-color)"), ("#25a233", "var(--success-active-color)"), ("#0ea432", "var(--success-focus-color)"), ("#19b82b", "var(--success-focus-color)"), (".blue", ".primary"), ("#2185D0", "var(--primary-color)"), ("#54C8FF", "var(--primary-color)"), ("#54C8FF", "var(--primary-color)"), ("#1678c2", "var(--primary-hover-color)"), ("#21b8ff", "var(--primary-hover-color)"), ("#1a69a4", "var(--primary-active-color)"), ("#0d71bb", "var(--primary-focus-color)"), ("#2bbbff", "var(--primary-focus-color)"), (".yellow", ".warning"), ("#FBBD08", "var(--warning-color)"), ("#FFE21F", "var(--warning-color)"), ("#eaae00", "var(--warning-hover-color)"), ("#ebcd00", "var(--warning-hover-color)"), ("#cd9903", "var(--warning-active-color)"), ("#ebcd00", "var(--warning-active-color)"), ("#daa300", "var(--warning-focus-color)"), ("#f5d500", "var(--warning-focus-color)"), (".red.", ".danger."), ("#DB2828", "var(--danger-color)"), ("#FF695E", "var(--danger-color)"), ("#d01919", "var(--danger-hover-color)"), ("#ff392b", "var(--danger-hover-color)"), ("#b21e1e", "var(--danger-active-color)"), ("#ca1010", "var(--danger-focus-color)"), ("#ff4335", "var(--danger-focus-color)"), ] def discard_unused_icons(rule): """ Add an icon to this list if you want to use it in the app. """ used_icons = [ ".angle", ".arrow", ".at", ".ban", ".bell", ".book", ".bookmark", ".check", ".clock", ".close", ".cloud", ".code", ".comment", ".copy", ".copyright", ".danger", ".database", ".delete", ".disc", ".down angle", ".download", ".dropdown", ".edit", ".ellipsis", ".eraser", ".external", ".eye", ".feed", ".file", ".folder", ".forward", ".globe", ".hashtag", ".headphones", ".heart", ".home", ".hourglass", ".info", ".layer", ".lines", ".link", ".list", ".loading", ".lock", ".minus", ".mobile", ".music", ".paper", ".pause", ".pencil", ".play", ".plus", ".podcast", ".question", ".question ", ".random", ".redo", ".refresh", ".repeat", ".rss", ".search", ".server", ".share", ".shield", ".sidebar", ".sign", ".spinner", ".step", ".stream", ".track", ".trash", ".undo", ".upload", ".user", ".users", ".volume", ".wikipedia", ".wrench", ".x", ".key", ".cog", ".life.ring", ".language", ".palette", ".sun", ".moon", ".gitlab", ".chevron", ".right", ".left", ".compress", ".expand", ".image", ] if ":before" not in rule["lines"][0]: return False return not match(rule, used_icons) """ Below is the main configuration object that is used for fine-grained replacement of properties in component files. It also handles removal of unused selectors. Example config for a component: REPLACEMENTS = { # applies to fomantic-ui-css/components/component-name.css "component-name": { # Discard any CSS rule matching one of the selectors listed below # matching is done using a simple string search, so ``.pink`` will remove # rules applied to ``.pink``, ``.pink.button`` and `.pinkdark` "skip": [ ".unused.variation", ".pink", ], # replace some CSS properties values in specific selectors (".inverted", ".dark"): [ ("background", "var(--inverted-background)"), ("color", "var(--inverted-color)"), ], (".active"): [ ("font-size", "var(--active-font-size)"), ], } } Given the previous config, the following style sheet: .. code-block:: css .unused.variation { color: yellow; } .primary { color: white; } .primary.pink { color: pink; } .inverted.primary { background: black; color: white; border-top: 1px solid red; } .inverted.primary.active { font-size: 12px; } Would be converted to: .. code-block:: css .primary { color: white; } .inverted.primary { background: var(--inverted-background); color: var(--inverted-color); border-top: 1px solid red; } .inverted.primary.active { font-size: var(--active-font-size); } """ REPLACEMENTS = { "site": { ("a",): [ ("color", "var(--link-color)"), ("text-decoration", "var(--link-text-decoration)"), ], ("a:hover",): [ ("color", "var(--link-hover-color)"), ("text-decoration", "var(--link-hover-text-decoration)"), ], ("body",): [ ("background", "var(--site-background)"), ("color", "var(--text-color)"), ], ( "::-webkit-selection", "::-moz-selection", "::selection", ): [ ("color", "var(--text-selection-color)"), ("background-color", "var(--text-selection-background)"), ], ( "textarea::-webkit-selection", "input::-webkit-selection", "textarea::-moz-selection", "input::-moz-selection", "textarea::selection", "input::selection", ): [ ("color", "var(--input-selection-color)"), ("background-color", "var(--input-selection-background)"), ], }, "button": { "skip": [ ".vertical", ".animated", ".active", ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".positive", ".negative", ".secondary", ".tertiary", ".facebook", ".twitter", ".google.plus", ".vk", ".linkedin", ".instagram", ".youtube", ".whatsapp", ".telegram", ], (".ui.orange.button", ".ui.orange.button:hover"): [ ("background-color", "var(--button-orange-background)") ], (".ui.basic.button",): [ ("background", "var(--button-basic-background)"), ("color", "var(--button-basic-color)"), ("box-shadow", "var(--button-basic-box-shadow)"), ], (".ui.basic.button:hover",): [ ("background", "var(--button-basic-hover-background)"), ("color", "var(--button-basic-hover-color)"), ("box-shadow", "var(--button-basic-hover-box-shadow)"), ], (".ui.basic.button:focus",): [ ("background", "var(--button-basic-hover-background)"), ("color", "var(--button-basic-hover-color)"), ("box-shadow", "var(--button-basic-hover-box-shadow)"), ], }, "card": { "skip": [ ".inverted", ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".pink", ".black", ".vibrant", ".success", ".warning", ".danger", ".primary", ".secondary", ".horizontal", ".raised", ] }, "checkbox": { ( ".ui.toggle.checkbox label", ".ui.toggle.checkbox input:checked ~ label", '.ui.checkbox input[type="checkbox"]', ".ui.checkbox input:focus ~ label", ".ui.toggle.checkbox input:focus:checked ~ label", ".ui.checkbox input:active ~ label", ): [ ("color", "var(--form-label-color)"), ], (".ui.toggle.checkbox label:before",): [ ("background", "var(--input-background)"), ], }, "divider": { (".ui.divider:not(.vertical):not(.horizontal)",): [ ("border-top", "var(--divider)"), ("border-bottom", "var(--divider)"), ], (".ui.divider",): [ ("color", "var(--text-color)"), ], }, "dimmer": { (".ui.inverted.dimmer",): [ ("background-color", "var(--dimmer-background)"), ("color", "var(--dropdown-color)"), ], }, "dropdown": { "skip": [ ".error", ".info", ".success", ".warning", ], ( ".ui.selection.dropdown", ".ui.selection.visible.dropdown > .text:not(.default)", ".ui.dropdown .menu", ): [ ("background", "var(--dropdown-background)"), ("color", "var(--dropdown-color)"), ], (".ui.dropdown .menu > .item",): [ ("color", "var(--dropdown-item-color)"), ], (".ui.dropdown .menu > .item:hover",): [ ("color", "var(--dropdown-item-hover-color)"), ("background", "var(--dropdown-item-hover-background)"), ], (".ui.dropdown .menu .selected.item",): [ ("color", "var(--dropdown-item-selected-color)"), ("background", "var(--dropdown-item-selected-background)"), ], (".ui.dropdown .menu > .header:not(.ui)",): [ ("color", "var(--dropdown-header-color)"), ], (".ui.dropdown .menu > .divider",): [ ("border-top", "var(--divider)"), ], }, "form": { "skip": [ ".inverted", ".success", ".warning", ".error", ".info", ], ('.ui.form input[type="text"]', ".ui.form select", ".ui.input textarea"): [ ("background", "var(--input-background)"), ("color", "var(--input-color)"), ], ( '.ui.form input[type="text"]:focus', ".ui.form select:focus", ".ui.form textarea:focus", ): [ ("background", "var(--input-focus-background)"), ("color", "var(--input-focus-color)"), ], ( ".ui.form ::-webkit-input-placeholder", ".ui.form :-ms-input-placeholder", ".ui.form ::-moz-placeholder", ): [ ("color", "var(--input-placeholder-color)"), ], ( ".ui.form :focus::-webkit-input-placeholder", ".ui.form :focus:-ms-input-placeholder", ".ui.form :focus::-moz-placeholder", ): [ ("color", "var(--input-focus-placeholder-color)"), ], ( ".ui.form .field > label", ".ui.form .inline.fields .field > label", ): [ ("color", "var(--form-label-color)"), ], }, "grid": { "skip": [ "wide tablet", "screen", "mobile only", "tablet only", "computer only", "computer reversed", "tablet reversed", "wide computer", "wide mobile", "wide tablet", "vertically", ".celled", ".doubling", ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".positive", ".negative", ".secondary", ".tertiary", ".danger", ".vibrant", ".warning", ".primary", ".success", ".justified", ".centered", ] }, "icon": {"skip": discard_unused_icons}, "input": { (".ui.input > input",): [ ("background", "var(--input-background)"), ("color", "var(--input-color)"), ], (".ui.input > input:focus",): [ ("background", "var(--input-focus-background)"), ("color", "var(--input-focus-color)"), ], ( ".ui.input > input::-webkit-input-placeholder", ".ui.input > input::-moz-placeholder", ".ui.input > input:-ms-input-placeholder", ): [ ("color", "var(--input-placeholder-color)"), ], ( ".ui.input > input:focus::-webkit-input-placeholder", ".ui.input > input:focus::-moz-placeholder", ".ui.input > input:focus:-ms-input-placeholder", ): [ ("color", "var(--input-focus-placeholder-color)"), ], }, "item": { (".ui.divided.items > .item",): [ ("border-top", "var(--divider)"), ], (".ui.items > .item > .content",): [ ("color", "var(--text-color)"), ], (".ui.items > .item .extra",): [ ("color", "var(--really-discrete-text-color)"), ], }, "header": { "skip": [ ".inverted", ".block", ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".pink", ], (".ui.header",): [ ("color", "var(--header-color)"), ], (".ui.header .sub.header",): [ ("color", "var(--header-color)"), ], }, "label": { "skip": [ ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".positive", ".negative", ".secondary", ".tertiary", ".facebook", ".twitter", ".google.plus", ".vk", ".linkedin", ".instagram", ".youtube", ".whatsapp", ".telegram", ".corner", "ribbon", "pointing", "attached", ], }, "list": { "skip": [ ".mini", ".tiny", ".small", ".large", ".big", ".huge", ".massive", ".celled", ".horizontal", ".bulleted", ".ordered", ".suffixed", ".inverted", ".fitted", "aligned", ], (".ui.list .list > .item a.header", ".ui.list .list > a.item"): [ ("color", "var(--link-color)"), ("text-decoration", "var(--link-text-decoration)"), ], ("a:hover", ".ui.list .list > a.item:hover"): [ ("color", "var(--link-hover-color)"), ("text-decoration", "var(--link-hover-text-decoration)"), ], }, "loader": { "skip": [ ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".pink", ".primary", ".vibrant", ".warning", ".success", ".danger", ".elastic", ], (".ui.inverted.dimmer > .ui.loader",): [ ("color", "var(--dimmer-color)"), ], }, "message": { "skip": [ ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".pink", ".vibrant", ".primary", ".secondary", ".floating", ], }, "menu": { "skip": [ ".inverted.pointing", ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".vertical.tabular", ".primary.menu", ".pink.menu", ".vibrant.menu", ".warning.menu", ".success.menu", ".danger.menu", ".fitted", "fixed", ], (".ui.menu .item",): [ ("color", "var(--menu-item-color)"), ], (".ui.vertical.inverted.menu .menu .item", ".ui.inverted.menu .item"): [ ("color", "var(--inverted-menu-item-color)"), ], (".inverted-ui.menu .active.item",): [ ("color", "var(--menu-inverted-active-item-color)"), ], (".ui.secondary.pointing.menu .active.item",): [ ("color", "var(--secondary-menu-active-item-color)"), ], ( ".ui.secondary.pointing.menu a.item:hover", ".ui.secondary.pointing.menu .active.item:hover", ): [ ("color", "var(--secondary-menu-hover-item-color)"), ], (".ui.menu .ui.dropdown .menu > .item",): [ ("color", "var(--dropdown-item-color) !important"), ], (".ui.menu .ui.dropdown .menu > .item:hover",): [ ("color", "var(--dropdown-item-hover-color) !important"), ("background", "var(--dropdown-item-hover-background) !important"), ], (".ui.menu .dropdown.item .menu",): [ ("color", "var(--dropdown--color)"), ("background", "var(--dropdown-background)"), ], (".ui.menu .ui.dropdown .menu > .active.item",): [ ("color", "var(--dropdown-item-selected-color)"), ("background", "var(--dropdown-item-selected-background) !important"), ], }, "modal": { (".ui.modal", ".ui.modal > .actions", ".ui.modal > .content"): [ ("background", "var(--modal-background)"), ("border-bottom", "var(--divider)"), ("border-top", "var(--divider)"), ], (".ui.modal > .close.inside",): [ ("color", "var(--text-color)"), ], (".ui.modal > .header",): [ ("color", "var(--header-color)"), ("background", "var(--modal-background)"), ("border-bottom", "var(--divider)"), ("border-top", "var(--divider)"), ], }, "search": { ( ".ui.search > .results", ".ui.search > .results .result", ".ui.category.search > .results .category .results", ".ui.category.search > .results .category", ".ui.category.search > .results .category > .name", ".ui.search > .results > .message .header", ".ui.search > .results > .message .description", ): [ ("background", "var(--dropdown-background)"), ("color", "var(--dropdown-item-color)"), ], ( ".ui.search > .results .result .title", ".ui.search > .results .result .description", ): [ ("color", "var(--dropdown-item-color)"), ], (".ui.search > .results .result:hover",): [ ("color", "var(--dropdown-item-hover-color)"), ("background", "var(--dropdown-item-hover-background)"), ], }, "segment": { "skip": [ ".stacked", ".horizontal.segment", ".inverted.segment", ".circular", ".piled", ], }, "sidebar": { (".ui.left.visible.sidebar",): [ ("box-shadow", "var(--sidebar-box-shadow)"), ] }, "statistic": { (".ui.statistic > .value", ".ui.statistic > .label"): [ ("color", "var(--text-color)"), ], }, "progress": { (".ui.progress.success > .label",): [ ("color", "var(--text-color)"), ], }, "table": { "skip": [ ".marked", ".olive", ".brown", ".teal", ".violet", ".purple", ".brown", ".grey", ".black", ".padded", ".column.table", ".inverted", ".definition", ".error", ".negative", ".structured", "tablet stackable", ], ( ".ui.table", ".ui.table > thead > tr > th", ): [ ("color", "var(--text-color)"), ("background", "var(--table-background)"), ], (".ui.table > tr > td", ".ui.table > tbody + tbody tr:first-child > td"): [ ("border-top", "var(--table-border)"), ], }, } def match(rule, skip): if hasattr(skip, "__call__"): return skip(rule) for s in skip: for rs in rule["selectors"]: if s in rs: return True return False def rules_from_media_query(rule): internal = rule["lines"][1:-1] return parse_rules("\n".join(internal)) def wraps(rule, internal_rules): return { "lines": [rule["lines"][0]] + [line for r in internal_rules for line in r["lines"]] + ["}"] } def set_vars(component_name, rules): """ Given rules parsed via ``parse_rules``, replace properties values when needed using ``REPLACEMENTS`` and ``GLOBAL_REPLACES``. Also remove unused styles if STRIP_UNUSED is set to True. """ final_rules = [] try: conf = REPLACEMENTS[component_name] except KeyError: return rules selectors = list(conf.keys()) + list() skip = None if STRIP_UNUSED: skip = conf.get("skip", []) try: skip = set(skip) except TypeError: pass for rule in rules: if rule["lines"][0].startswith("@media"): # manual handling of media queries, because our parser is really # simplistic internal_rules = rules_from_media_query(rule) internal_rules = set_vars(component_name, internal_rules) rule = wraps(rule, internal_rules) if len(rule["lines"]) > 2: final_rules.append(rule) continue if skip and match(rule, skip): # discard rule entirely continue matching = [] for s in selectors: if set(s) & set(rule["selectors"]): matching.append(s) if not matching: # no replacements to apply, keep rule as is final_rules.append(rule) continue new_rule = {"lines": []} for m in matching: # the block match one of our replacement rules, so we loop on each line # and replace values if needed. replacements = conf[m] for line in rule["lines"]: for property, new_value in replacements: if line.strip().startswith(f"{property}:"): new_property = f"{property}: {new_value};" indentation = " " * (len(line) - len(line.lstrip(" "))) line = indentation + new_property break new_rule["lines"].append(line) final_rules.append(new_rule) return final_rules def parse_rules(text): """ Really basic CSS parsers that stores selectors and corresponding properties. Only works because the source files have coma-separated selectors (one per line), and one property/value per line. Returns a list of dictionaries, each dictionarry containing the selectors and lines of of each block. """ rules = [] current_rule = None opened_brackets = 0 current_selector = [] for line in text.splitlines(): if not current_rule and line.endswith(","): current_selector.append(line.rstrip(",").strip()) elif line.endswith(" {"): # for media queries opened_brackets += 1 if not current_rule: current_selector.append(line.rstrip("{").strip()) current_rule = { "lines": [",\n".join(current_selector) + " {"], "selectors": current_selector, } else: current_rule["lines"].append(line) elif current_rule: current_rule["lines"].append(line) if line.strip() == "}": opened_brackets -= 1 if not opened_brackets: # move on to next rule rules.append(current_rule) current_rule = None current_selector = [] return rules def serialize_rules(rules): """ Convert rules back to valid CSS. """ lines = [] for rule in rules: for line in rule["lines"]: lines.append(line) return "\n".join(lines) def iter_components(dir): for dname, dirs, files in os.walk(dir): for fname in files: if fname.endswith(".min.css"): continue if fname.endswith(".js"): continue if "semantic" in fname: continue if fname.endswith(".css"): yield os.path.join(dname, fname) def replace_vars(source, dest): components = list(sorted(iter_components(os.path.join(source, "components")))) for c in components: with open(c) as f: text = f.read() for s, r in GLOBAL_REPLACES: text = text.replace(s, r) text = text.replace(s.lower(), r) text = text.replace(s.upper(), r) rules = parse_rules(text) name = c.split("/")[-1].split(".")[0] updated_rules = set_vars(name, rules) text = serialize_rules(updated_rules) with open(os.path.join(dest, f"{name}.css"), "w") as f: f.write(text) if __name__ == "__main__": parser = argparse.ArgumentParser( description="Replace hardcoded values by CSS vars and strip unused rules" ) parser.add_argument( "source", help="Source path of the fomantic-ui-less distribution to fix" ) parser.add_argument( "dest", help="Destination directory where fixed files should be written" ) args = parser.parse_args() replace_vars(source=args.source, dest=args.dest)