3
\$\begingroup\$

Given a CSS file the script generates an HTML preview of all the CSS definitions by recursively drilling down the chain of nested classes, ids and tag names. Rules with position: absolute|fixed|sticky are wrapped into iframe elements in order to avoid overlap.

The output goes to stdout since I wanted to avoid keeping storing too much data in memory. Content that goes inside iframes is printed first to a temporary buffer rather than stdout because that text needs to be html-escaped.

Would be interested to get feedback on:

  • performance and efficiency (CPU, memory): is there anything inherently inefficient/slow with the chosen approach that can be optimised?
  • code organisation
  • obvious (or subtle) code smells
  • naming and legibility
  • possible criteria to check correctness or well-formedness of the generated output
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Generates an HTML file for previewing all styles defined in a CSS file
#
# dependencies: cssutils
# USAGE:
#     css_preview_generator.py style.css > preview.html
import html
import io
import sys

import cssutils

image_placeholder = "data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='150' viewBox='0 0 300 150'%3E%3Crect fill='yellow' width='300' height='150'/%3E%3Ctext fill='rgba(0,0,0,0.5)' x='50%25' y='50%25' text-anchor='middle'%3E300×150%3C/text%3E%3C/svg%3E"


def render(s, out):
    if out and not out.closed:
        return print(s, end='', file=out)
    else:
        print(s, flush=True)


def down_the_rabbit_hole(chunks, full_selector, out=None):
    if len(chunks):
        chunk = chunks.pop(0)
        render_open_tag(chunk, out)
        down_the_rabbit_hole(chunks, full_selector, out)
        render_close_tag(chunk, out)
    else:
        render(full_selector, out)


prefix_map = {
    '.': 'class',
    '#': 'id'
}


def extract_class_id(defn, extracted_attrs=''):
    try:
        for prefix in prefix_map.keys():
            if prefix in defn:
                items = defn.split(prefix)
                value = ' '.join(items[1:])
                # return a tuple of (tagname, 'class="bla blu"') or (tagname, 'id="abc"')
                tag = items[0]
                if any(suffix in tag for suffix in prefix_map.keys()):
                    return extract_class_id(tag, f'{prefix_map[prefix]}="{value}"')
                else:
                    return items[0], f'{extracted_attrs} {prefix_map[prefix]}="{value}"'
    except Exception as e:
        print(e, file=sys.stderr)

    return defn, ''


def render_open_tag(definition, out):
    if definition.startswith(('.', '#')):
        _, class_or_id = extract_class_id(definition)
        render(f'<div {class_or_id}>', out)
    else:
        if definition == 'a' or definition.startswith(('a.', 'a#')):
            tag, class_or_id = extract_class_id(definition)
            render(f'''<a {class_or_id} href="#">''', out)

        elif definition == 'img' or definition.startswith(('img.','img#')):
            render(f'<img src="{image_placeholder}" alt="[image]">', out)
        else:
            tag, class_or_id = extract_class_id(definition)
            if tag.lower() == 'td':
                render(f'<table><thead><tr><th>🟨🟧[th]🟧🟨</th></thead><tbody><tr><td {class_or_id}>🟩🟦[td]🟦🟩<br/>', out)
            else:
                render(f'<{tag} {class_or_id}>', out)


def render_close_tag(definition, out):
    if definition.startswith(('.', '#')):
        render('</div>', out)
    else:
        if definition == 'a' or definition.startswith(('a.', 'a#')):
            render(f'⚓️ {definition}</a>', out)
        else:
            tag, _ = extract_class_id(definition)
            if tag.lower() == 'td':
                render('</td></tr></tbody></table>', out)
            else:
                render(f'</{tag}>', out)


if __name__ == '__main__':

    if len(sys.argv) == 1 or sys.argv[1] in ('-h', '--help'):
        print(f'Usage: {sys.argv[0]} style.css > preview.html')
        sys.exit(-1)

    already_seen = []
    css_file = sys.argv[1]
    sheet = cssutils.parseFile(css_file)
    print(f'''<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CSS preview: {css_file}</title>
    <link href="{css_file}" rel="stylesheet" type="text/css" />
</head>
<body>
''')
    selectors_requiring_iframe = []
    # build a list of absolute & fixed rules
    for rule in sheet:
        if isinstance(rule, cssutils.css.CSSStyleRule):
            position = getattr(rule.style, 'position', None)

            if position in ('fixed', 'absolute', 'sticky'):
                for single_selector in rule.selectorList:  # type: cssutils.css.Selector
                    selectors_requiring_iframe.append(single_selector.selectorText)

    # deduplicate list
    selectors_requiring_iframe = list(dict.fromkeys(selectors_requiring_iframe))

    for rule in sheet:
        if isinstance(rule, cssutils.css.CSSStyleRule):
            selectors: cssutils.css.SelectorList = getattr(rule, 'selectorList', [])
            full_selectors_text = rule.selectorText
            print(f'CSS Rule: {full_selectors_text}', file=sys.stderr)

            for single_selector in selectors:  # type: cssutils.css.Selector

                current_selector_text = single_selector.selectorText
                if not single_selector or current_selector_text.startswith(('html', 'body')):
                    continue

                # 1. convert '>' to space
                # 2.  '~' '*' '+' and '[]' (not supported, ignoring them, convert to space, breaks semantics FIXME)
                for c in '>*~+':
                    if c in current_selector_text:
                        current_selector_text = current_selector_text.replace(c, ' ')

                for c in ':[':
                    if c in current_selector_text:
                        current_selector_text = current_selector_text.split(c)[0]

                if current_selector_text in already_seen:
                    continue
                else:
                    already_seen.append(current_selector_text)

                if '  ' in current_selector_text:
                    current_selector_text = current_selector_text.replace('  ', ' ')

                position = getattr(rule.style, 'position', None)

                # if current selector is a child of an absolute/fixed rule then also wrap it in an iframe
                matching_abs_parents = [sel for sel in selectors_requiring_iframe if sel in current_selector_text]

                need_iframe = position in ('fixed', 'absolute', 'sticky') or len(matching_abs_parents)

                need_table = False

                out = None
                if need_iframe:
                    print(
                        f'''<iframe style="border:1px dotted #acad9e;" width="400" height="300" srcdoc="{html.escape(f'<html><head><link href="{css_file}" rel="stylesheet" type="text/css"/></head><body style="background:#f6f4ee">')}''',
                        end='')
                    out = io.StringIO()


                print(f'\t{current_selector_text}', file=sys.stderr)
                down_the_rabbit_hole(current_selector_text.split(), full_selectors_text, out)

                if need_iframe:
                    print(html.escape(out.getvalue()), end='')
                    out.close()
                    print('"></iframe>')
print('''
</body>
</html>''')

The latest version is here.

\$\endgroup\$
2
  • 1
    \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$
    – Mast
    Jun 13, 2020 at 7:56
  • \$\begingroup\$ @Mast: thanks; wasn't aware of the codereview policy. \$\endgroup\$
    – ccpizza
    Jun 13, 2020 at 10:50

1 Answer 1

2
\$\begingroup\$

Since you only use already_seen to check if current_selector_text has been seen, consider changing the type of already_seen from list to set (slight performance improvement).

The function name down_the_rabbit_hole is unclear as to what it does. This will confuse people who have not heard of this phrase before.

Since you are already checking for arguments with if len(sys.argv) == 1 or sys.argv[1] in ('-h', '--help'):, consider handling the case when more than 1 argument is given (rather than just ignoring the rest of them).

You assign False to need_table, but need_table is unused.

The line # -*- coding: utf-8 -*- is unnecessary in Python 3 under most cases.

You only have 1 type annotation on line 125 (selectors: cssutils.css.SelectorList = getattr(rule, 'selectorList', [])). Is there a reason why none of the other variables have an annotation (or why just this line has one)?

\$\endgroup\$
3
  • \$\begingroup\$ Thanks! What name would you suggest for the method? My first urge was something like recurse or process_definitions but that seemed too dull so I just used a placeholder name. Naming is hard. On a more general level, what would be the best practice for naming recursive methods? In production code I tend to name them as recurseABC or descendABC to make it more obvious, but not sure that's the best approach. \$\endgroup\$
    – ccpizza
    Jun 13, 2020 at 6:27
  • \$\begingroup\$ The single annotation was added in order to ease debugging. Where the types are obvious and easily inferred by the IDE, e.g. bla = [] I normally don't add them explicitly. What is the recommended practice? \$\endgroup\$
    – ccpizza
    Jun 13, 2020 at 6:30
  • 1
    \$\begingroup\$ I got into the habit of using # -*- coding: utf-8 -*- exclusively as a reminder to myself that the source code contains non-ascii characters, not for the IDE support or anything else. \$\endgroup\$
    – ccpizza
    Jun 13, 2020 at 6:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.