497 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			497 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
"""
 | 
						|
This command finds and creates a report for all the usage of css rules in
 | 
						|
an app. It aims to optimize existing codebase as well as assist the frontend
 | 
						|
developer when designing new components by avoiding unnecessary duplication and
 | 
						|
suggesting more/optimal alternatives.
 | 
						|
 | 
						|
Features:
 | 
						|
    Currently the command can find out and display:
 | 
						|
        - Media Breakpoints used in a stylesheet
 | 
						|
        - Duplicate selectors in a stylesheet
 | 
						|
        - Unused selectors
 | 
						|
    Work in progress to enable these features:
 | 
						|
        - Duplicate style declaration for same selector
 | 
						|
        - DOM validation
 | 
						|
        - Finding out dead styles (those that are always cancelled)
 | 
						|
        - Optimize media declarations
 | 
						|
 | 
						|
Example:
 | 
						|
    $ python manage.py optimize_frontend datacenterlight
 | 
						|
    above command produces a file ../optimize_frontend.html which contains a
 | 
						|
    report with the above mentioned features
 | 
						|
"""
 | 
						|
 | 
						|
# import csv
 | 
						|
import json
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import re
 | 
						|
from collections import Counter, OrderedDict
 | 
						|
# from itertools import zip_longest
 | 
						|
 | 
						|
from django import template
 | 
						|
from django.conf import settings
 | 
						|
from django.contrib.staticfiles import finders
 | 
						|
from django.core.management.base import BaseCommand
 | 
						|
 | 
						|
 | 
						|
logger = logging.getLogger(__name__)
 | 
						|
 | 
						|
RE_PATTERNS = {
 | 
						|
    'view_html': '[\'\"](.*\.html)',
 | 
						|
    'html_html': '{% (?:extends|include) [\'\"]?(.*\.html)',
 | 
						|
    'html_style': '{% static [\'\"]?(.*\.css)',
 | 
						|
    'css_media': (
 | 
						|
        '^\s*\@media([^{]+)\{\s*([\s\S]*?})\s*}'
 | 
						|
    ),
 | 
						|
    'css_selector': (
 | 
						|
        '^\s*([.#\[:_A-Za-z][^{]*?)\s*'
 | 
						|
        '\s*{\s*([\s\S]*?)\s*}'
 | 
						|
    ),
 | 
						|
    'html_class': 'class=[\'\"]([a-zA-Z0-9-_\s]*)',
 | 
						|
    'html_id': 'id=[\'\"]([a-zA-Z0-9-_]*)'
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
class Command(BaseCommand):
 | 
						|
    help = (
 | 
						|
        'Finds unused and duplicate style declarations from the stylesheets '
 | 
						|
        'used in the templates of each app'
 | 
						|
    )
 | 
						|
    requires_system_checks = False
 | 
						|
 | 
						|
    def add_arguments(self, parser):
 | 
						|
        # positional arguments
 | 
						|
        parser.add_argument(
 | 
						|
            'apps', nargs='+', type=str,
 | 
						|
            help='name of the apps to be optimized'
 | 
						|
        )
 | 
						|
 | 
						|
        # Named (optional) arguments
 | 
						|
        parser.add_argument(
 | 
						|
            '--together',
 | 
						|
            action='store_true',
 | 
						|
            help='optimize the apps together'
 | 
						|
        )
 | 
						|
        parser.add_argument(
 | 
						|
            '--css',
 | 
						|
            action='store_true',
 | 
						|
            help='optimize only the css rules declared in each stylesheet'
 | 
						|
        )
 | 
						|
 | 
						|
    def handle(self, *args, **options):
 | 
						|
        apps_list = options['apps']
 | 
						|
        report = {}
 | 
						|
        for app in apps_list:
 | 
						|
            if options['css']:
 | 
						|
                report[app] = self.optimize_css(app)
 | 
						|
        # write report
 | 
						|
        write_report(report)
 | 
						|
 | 
						|
    def optimize_css(self, app_name):
 | 
						|
        """Optimize declarations inside a css stylesheet
 | 
						|
 | 
						|
        Args:
 | 
						|
            app_name (str): The application name
 | 
						|
        """
 | 
						|
        # get html and css files used in the app
 | 
						|
        files = get_files(app_name)
 | 
						|
        # get_selectors_from_css
 | 
						|
        css_selectors = get_selectors_css(files['style'])
 | 
						|
        # get_selectors_from_html
 | 
						|
        html_selectors = get_selectors_html(files['html'])
 | 
						|
        report = {
 | 
						|
            'css_dup': get_css_duplication(css_selectors),
 | 
						|
            'css_unused': get_css_unused(css_selectors, html_selectors)
 | 
						|
        }
 | 
						|
        return report
 | 
						|
 | 
						|
 | 
						|
def get_files(app_name):
 | 
						|
    """Get all the `html` and `css` files used in an app.
 | 
						|
 | 
						|
    Args:
 | 
						|
        app_name (str): The application name
 | 
						|
 | 
						|
    Returns:
 | 
						|
        dict: A dictonary containing Counter of occurence of each
 | 
						|
        html and css file in `html` and `style` fields respectively.
 | 
						|
        For example:
 | 
						|
        {
 | 
						|
            'html': {'datacenterlight/success.html': 1},
 | 
						|
            'style': {'datacenterlight/css/bootstrap.min.css': 2}
 | 
						|
        }
 | 
						|
    """
 | 
						|
    # the view file for the app
 | 
						|
    app_view = os.path.join(settings.PROJECT_DIR, app_name, 'views.py')
 | 
						|
    # get template files called from the view
 | 
						|
    all_html_list = file_match_pattern(app_view, 'view_html')
 | 
						|
    # list of unique template files
 | 
						|
    uniq_html_list = list(OrderedDict.fromkeys(all_html_list).keys())
 | 
						|
    # list of stylesheets
 | 
						|
    all_style_list = []
 | 
						|
    file_patterns = ['html_html', 'html_style']
 | 
						|
    # get html and css files called from within templates
 | 
						|
    i = 0
 | 
						|
    while i < len(uniq_html_list):
 | 
						|
        template_name = uniq_html_list[i]
 | 
						|
        try:
 | 
						|
            temp_files = templates_match_pattern(
 | 
						|
                template_name, file_patterns
 | 
						|
            )
 | 
						|
        except template.exceptions.TemplateDoesNotExist as e:
 | 
						|
            print("template file not found: ", str(e))
 | 
						|
            all_html_list = [
 | 
						|
                h for h in all_html_list if h != template_name
 | 
						|
            ]
 | 
						|
            del uniq_html_list[i]
 | 
						|
        else:
 | 
						|
            all_html_list.extend(temp_files[0])
 | 
						|
            uniq_html_list = list(
 | 
						|
                OrderedDict.fromkeys(all_html_list).keys()
 | 
						|
            )
 | 
						|
            all_style_list.extend(temp_files[1])
 | 
						|
            i += 1
 | 
						|
    # counter dict for the html files called from view
 | 
						|
    result = {
 | 
						|
        'html': Counter(all_html_list),
 | 
						|
        'style': Counter(all_style_list)
 | 
						|
    }
 | 
						|
    # print(result)
 | 
						|
    return result
 | 
						|
 | 
						|
 | 
						|
def get_selectors_css(files):
 | 
						|
    """Gets the selectors and declarations from a stylesheet.
 | 
						|
 | 
						|
    Args:
 | 
						|
        files (list): A list of path of stylesheets.
 | 
						|
 | 
						|
    Returns:
 | 
						|
        dict: A nested dictionary with the structre as
 | 
						|
        `{'file': {'media-selector': [('selectors',`declarations')]}}`
 | 
						|
        For example:
 | 
						|
        {
 | 
						|
            'datacenterlight/css/landing-page.css':{
 | 
						|
                '(min-width: 768px)': [
 | 
						|
                    ('.lead-right', 'text-align: right;'),
 | 
						|
                ]
 | 
						|
            }
 | 
						|
        }
 | 
						|
    """
 | 
						|
    selectors = {}
 | 
						|
    media_selectors = {}
 | 
						|
    # get media selectors and other simple declarations
 | 
						|
    for file in files:
 | 
						|
        if any(vendor in file for vendor in ['bootstrap', 'font-awesome']):
 | 
						|
            continue
 | 
						|
        result = finders.find(file)
 | 
						|
        if result:
 | 
						|
            with open(result) as f:
 | 
						|
                data = f.read()
 | 
						|
            media_selectors[file] = string_match_pattern(data, 'css_media')
 | 
						|
            new_data = string_remove_pattern(data, 'css_media')
 | 
						|
            default_match = string_match_pattern(new_data, 'css_selector')
 | 
						|
            selectors[file] = {
 | 
						|
                'default': [
 | 
						|
                    [' '.join(grp.split()) for grp in m] for m in default_match
 | 
						|
                ]
 | 
						|
            }
 | 
						|
    # get declarations from media queries
 | 
						|
    for file, match_list in media_selectors.items():
 | 
						|
        for match in match_list:
 | 
						|
            query = match[0]
 | 
						|
            block_text = ' '.join(match[1].split())
 | 
						|
            results = string_match_pattern(
 | 
						|
                block_text, 'css_selector'
 | 
						|
            )
 | 
						|
            f_query = ' '.join(query.replace(':', ': ').split())
 | 
						|
            if f_query in selectors[file]:
 | 
						|
                selectors[file][f_query].extend(results)
 | 
						|
            else:
 | 
						|
                selectors[file][f_query] = results
 | 
						|
    return selectors
 | 
						|
 | 
						|
 | 
						|
def get_selectors_html(files):
 | 
						|
    """Get `class` and `id` used in html files.
 | 
						|
 | 
						|
    Args:
 | 
						|
        files (list): A list of html files path.
 | 
						|
 | 
						|
    Returns:
 | 
						|
        dict: a dictonary of all the classes and ids found in the file, in
 | 
						|
        `class` and `id` field respectively.
 | 
						|
    """
 | 
						|
    selectors = {}
 | 
						|
    for file in files:
 | 
						|
        results = templates_match_pattern(file, ['html_class', 'html_id'])
 | 
						|
        class_dict = {c: 1 for match in results[0] for c in match.split()}
 | 
						|
        selectors[file] = {
 | 
						|
            'classes': list(class_dict.keys()),
 | 
						|
            'ids': results[1],
 | 
						|
        }
 | 
						|
    return selectors
 | 
						|
 | 
						|
 | 
						|
def file_match_pattern(file, patterns):
 | 
						|
    """Match a regex pattern in a file
 | 
						|
 | 
						|
    Args:
 | 
						|
        file (str): Complete path of file
 | 
						|
        patterns (list or str): The pattern(s) to be searched in the file
 | 
						|
 | 
						|
    Returns:
 | 
						|
        list: A list of all the matches in the file. Each item is a list of
 | 
						|
        all the captured groups in the pattern. If multiple patterns are given,
 | 
						|
        the returned list is a list of such lists.
 | 
						|
        For example:
 | 
						|
        [('.lead', 'font-size: 18px;'), ('.btn-lg', 'min-width: 180px;')]
 | 
						|
    """
 | 
						|
    with open(file) as f:
 | 
						|
        data = f.read()
 | 
						|
    results = string_match_pattern(data, patterns)
 | 
						|
    return results
 | 
						|
 | 
						|
 | 
						|
def string_match_pattern(data, patterns):
 | 
						|
    """Match a regex pattern in a string
 | 
						|
 | 
						|
    Args:
 | 
						|
        data (str): the string to search for the pattern
 | 
						|
        patterns (list or str): The pattern(s) to be searched in the file
 | 
						|
 | 
						|
    Returns:
 | 
						|
        list: A list of all the matches in the string. Each item is a list of
 | 
						|
        all the captured groups in the pattern. If multiple patterns are given,
 | 
						|
        the returned list is a list of such lists.
 | 
						|
        For example:
 | 
						|
        [('.lead', 'font-size: 18px;'), ('.btn-lg', 'min-width: 180px;')]
 | 
						|
    """
 | 
						|
    if not isinstance(patterns, str):
 | 
						|
        results = []
 | 
						|
        for p in patterns:
 | 
						|
            re_pattern = re.compile(RE_PATTERNS[p], re.MULTILINE)
 | 
						|
            results.append(re.findall(re_pattern, data))
 | 
						|
    else:
 | 
						|
        re_pattern = re.compile(RE_PATTERNS[patterns], re.MULTILINE)
 | 
						|
        results = re.findall(re_pattern, data)
 | 
						|
    return results
 | 
						|
 | 
						|
 | 
						|
def string_remove_pattern(data, patterns):
 | 
						|
    """Remove a pattern from a string
 | 
						|
 | 
						|
    Args:
 | 
						|
        data (str): the string to search for the patter
 | 
						|
        patterns (list or str): The pattern(s) to be removed from the file
 | 
						|
 | 
						|
    Returns:
 | 
						|
        str: The new string with all instance of matching pattern
 | 
						|
        removed from it
 | 
						|
    """
 | 
						|
    if not isinstance(patterns, str):
 | 
						|
        for p in patterns:
 | 
						|
            re_pattern = re.compile(RE_PATTERNS[p], re.MULTILINE)
 | 
						|
            data = re.sub(re_pattern, '', data)
 | 
						|
    else:
 | 
						|
        re_pattern = re.compile(RE_PATTERNS[patterns], re.MULTILINE)
 | 
						|
        data = re.sub(re_pattern, '', data)
 | 
						|
    return data
 | 
						|
 | 
						|
 | 
						|
def templates_match_pattern(template_name, patterns):
 | 
						|
    """Match a regex pattern in the first found template file
 | 
						|
 | 
						|
    Args:
 | 
						|
        file (str): Path of template file
 | 
						|
        patterns (list or str): The pattern(s) to be searched in the file
 | 
						|
 | 
						|
    Returns:
 | 
						|
        list: A list of all the matches in the file. Each item is a list of
 | 
						|
        all the captured groups in the pattern. If multiple patterns are given,
 | 
						|
        the returned list is a list of such lists.
 | 
						|
        For example:
 | 
						|
        [('.lead', 'font-size: 18px;'), ('.btn-lg', 'min-width: 180px;')]
 | 
						|
    """
 | 
						|
    t = template.loader.get_template(template_name)
 | 
						|
    data = t.template.source
 | 
						|
    results = string_match_pattern(data, patterns)
 | 
						|
    return results
 | 
						|
 | 
						|
 | 
						|
def get_css_duplication(css_selectors):
 | 
						|
    """Get duplicate selectors from the same stylesheet
 | 
						|
 | 
						|
    Args:
 | 
						|
        css_selectors (dict): A dictonary containing css selectors from
 | 
						|
        all the files in the app in the below structure.
 | 
						|
        `{'file': {'media-selector': [('selectors',`declarations')]}}`
 | 
						|
 | 
						|
    Returns:
 | 
						|
        dict: A dictonary containing the count of any duplicate selector in
 | 
						|
        each file.
 | 
						|
        `{'file': {'media-selector': {'selector': count}}}`
 | 
						|
    """
 | 
						|
    # duplicate css selectors in stylesheets
 | 
						|
    rule_count = {}
 | 
						|
    for file, media_selectors in css_selectors.items():
 | 
						|
        rule_count[file] = {}
 | 
						|
        for media, rules in media_selectors.items():
 | 
						|
            rules_dict = Counter([rule[0] for rule in rules])
 | 
						|
            dup_rules_dict = {k: v for k, v in rules_dict.items() if v > 1}
 | 
						|
            if dup_rules_dict:
 | 
						|
                rule_count[file][media] = dup_rules_dict
 | 
						|
    return rule_count
 | 
						|
 | 
						|
 | 
						|
def get_css_unused(css_selectors, html_selectors):
 | 
						|
    """Get selectors from stylesheets that are not used in any of the html
 | 
						|
    files in which the stylesheet is used.
 | 
						|
 | 
						|
    Args:
 | 
						|
        css_selectors (dict): A dictonary containing css selectors from
 | 
						|
        all the files in the app in the below structure.
 | 
						|
        `{'file': {'media-selector': [('selectors',`declarations')]}}`
 | 
						|
        html_selectors (dict): A dictonary containing the 'class' and 'id'
 | 
						|
        declarations from all html files
 | 
						|
    """
 | 
						|
    with open('utils/optimize/test.json', 'w') as f:
 | 
						|
        json.dump([html_selectors, css_selectors], f, indent=4)
 | 
						|
    # print(html_selectors, css_selectors)
 | 
						|
 | 
						|
 | 
						|
def write_report(all_reports, filename='frontend'):
 | 
						|
    """Write the generated report to a file for re-use
 | 
						|
 | 
						|
    Args;
 | 
						|
        all_reports (dict): A dictonary of report obtained from different tests
 | 
						|
        filename (str): An optional suffix for the output file
 | 
						|
    """
 | 
						|
    # full_filename = 'utils/optimize/optimize_' + filename + '.html'
 | 
						|
    # output_file = os.path.join(
 | 
						|
    #     settings.PROJECT_DIR, full_filename
 | 
						|
    # )
 | 
						|
    with open('utils/optimize/op_frontend.json', 'w') as f:
 | 
						|
        json.dump(all_reports, f, indent=4)
 | 
						|
    # with open(output_file, 'w', newline='') as f:
 | 
						|
    #     f.write(
 | 
						|
    #         template.loader.render_to_string(
 | 
						|
    #             'utils/report.html', {'all_reports': all_reports}
 | 
						|
    #         )
 | 
						|
    #     )
 | 
						|
        # w = csv.writer(f)
 | 
						|
        # print(zip_longest(*results))
 | 
						|
        # for r in zip_longest(*results):
 | 
						|
        #     w.writerow(r)
 | 
						|
 | 
						|
 | 
						|
# a list of all the html tags (to be moved in a json file)
 | 
						|
html_tags = [
 | 
						|
    "a",
 | 
						|
    "abbr",
 | 
						|
    "address",
 | 
						|
    "article",
 | 
						|
    "area",
 | 
						|
    "aside",
 | 
						|
    "audio",
 | 
						|
    "b",
 | 
						|
    "base",
 | 
						|
    "bdi",
 | 
						|
    "bdo",
 | 
						|
    "blockquote",
 | 
						|
    "body",
 | 
						|
    "br",
 | 
						|
    "button",
 | 
						|
    "canvas",
 | 
						|
    "caption",
 | 
						|
    "cite",
 | 
						|
    "code",
 | 
						|
    "col",
 | 
						|
    "colgroup",
 | 
						|
    "datalist",
 | 
						|
    "dd",
 | 
						|
    "del",
 | 
						|
    "details",
 | 
						|
    "dfn",
 | 
						|
    "div",
 | 
						|
    "dl",
 | 
						|
    "dt",
 | 
						|
    "em",
 | 
						|
    "embed",
 | 
						|
    "fieldset",
 | 
						|
    "figcaption",
 | 
						|
    "figure",
 | 
						|
    "footer",
 | 
						|
    "form",
 | 
						|
    "h1",
 | 
						|
    "h2",
 | 
						|
    "h3",
 | 
						|
    "h4",
 | 
						|
    "h5",
 | 
						|
    "h6",
 | 
						|
    "head",
 | 
						|
    "header",
 | 
						|
    "hgroup",
 | 
						|
    "hr",
 | 
						|
    "html",
 | 
						|
    "i",
 | 
						|
    "iframe",
 | 
						|
    "img",
 | 
						|
    "input",
 | 
						|
    "ins",
 | 
						|
    "kbd",
 | 
						|
    "keygen",
 | 
						|
    "label",
 | 
						|
    "legend",
 | 
						|
    "li",
 | 
						|
    "link",
 | 
						|
    "map",
 | 
						|
    "mark",
 | 
						|
    "menu",
 | 
						|
    "meta",
 | 
						|
    "meter",
 | 
						|
    "nav",
 | 
						|
    "noscript",
 | 
						|
    "object",
 | 
						|
    "ol",
 | 
						|
    "optgroup",
 | 
						|
    "option",
 | 
						|
    "output",
 | 
						|
    "p",
 | 
						|
    "param",
 | 
						|
    "pre",
 | 
						|
    "progress",
 | 
						|
    "q",
 | 
						|
    "rp",
 | 
						|
    "rt",
 | 
						|
    "ruby",
 | 
						|
    "s",
 | 
						|
    "samp",
 | 
						|
    "script",
 | 
						|
    "section",
 | 
						|
    "select",
 | 
						|
    "source",
 | 
						|
    "small",
 | 
						|
    "span",
 | 
						|
    "strong",
 | 
						|
    "style",
 | 
						|
    "sub",
 | 
						|
    "summary",
 | 
						|
    "sup",
 | 
						|
    "textarea",
 | 
						|
    "table",
 | 
						|
    "tbody",
 | 
						|
    "td",
 | 
						|
    "tfoot",
 | 
						|
    "thead",
 | 
						|
    "th",
 | 
						|
    "time",
 | 
						|
    "title",
 | 
						|
    "tr",
 | 
						|
    "u",
 | 
						|
    "ul",
 | 
						|
    "var",
 | 
						|
    "video",
 | 
						|
    "wbr"
 | 
						|
]
 |