├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.mit ├── README.md ├── compressor_toolkit ├── __init__.py ├── apps.py ├── filters.py └── precompilers.py ├── package.json ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── integration_tests └── test_views.py ├── resources └── images │ ├── icon.jpg │ ├── icon.png │ ├── icon.svg │ └── large.svg ├── test_project ├── manage.py └── test_project │ ├── __init__.py │ ├── app │ ├── __init__.py │ ├── static │ │ └── app │ │ │ ├── layout.scss │ │ │ └── scripts.js │ └── templates │ │ └── app │ │ ├── template-with-es6-file.html │ │ ├── template-with-es6-inline.html │ │ ├── template-with-scss-file.html │ │ └── template-with-scss-inline.html │ ├── base │ ├── __init__.py │ └── static │ │ └── base │ │ ├── framework.js │ │ ├── utils.js │ │ └── variables.scss │ ├── settings.py │ └── urls.py ├── unit_tests ├── test_filters.py └── test_precompilers.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled 2 | *.pyc 3 | 4 | # Tests 5 | /tests/test_project/compressor/ 6 | /.coverage 7 | /coverage.xml 8 | /htmlcov/ 9 | 10 | # Dist 11 | /dist/ 12 | /*.egg-info/ 13 | /.eggs/ 14 | /.cache/ 15 | 16 | # Vagrant 17 | /.vagrant/ 18 | /Vagrantfile 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.4 5 | before_install: 6 | - nvm install 6.0 7 | - npm install 8 | install: 9 | - pip install -e .[test] 10 | script: 11 | - py.test tests/unit_tests tests/integration_tests 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.5.0 - 2015-12-31 4 | 5 | ### Added 6 | 7 | - pre-compiler for SCSS 8 | - pre-compiler for ES6 9 | - Django app infrastructure 10 | -------------------------------------------------------------------------------- /LICENSE.mit: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rostyslav Bryzgunov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-compressor-toolkit 2 | 3 | [![Build Status](https://travis-ci.org/kottenator/django-compressor-toolkit.svg?branch=master)](https://travis-ci.org/kottenator/django-compressor-toolkit) 4 | 5 | Set of add-ons for [django-compressor](https://github.com/django-compressor/django-compressor/) 6 | that simply enables SCSS and ES6 in your Django project. 7 | 8 | ## Installation 9 | 10 | ```sh 11 | pip install django-compressor-toolkit 12 | ``` 13 | 14 | ```py 15 | // settings.py 16 | 17 | INSTALLED_APPS += ('compressor_toolkit',) 18 | ``` 19 | 20 | ## Add-ons 21 | 22 | ### SCSS pre-compiler 23 | 24 | [SCSS](http://sass-lang.com/) is a great language that saves your time and brings joy to CSS development. 25 | 26 | The add-on does next: 27 | SCSS → ( 28 | [node-sass](https://github.com/sass/node-sass) + 29 | [Autoprefixer](https://github.com/postcss/autoprefixer) 30 | ) → CSS. 31 | 32 | It also enables Django static imports in SCSS, see the example below. 33 | 34 | #### Usage 35 | 36 | ```py 37 | // settings.py 38 | 39 | COMPRESS_PRECOMPILERS = ( 40 | ('text/x-scss', 'compressor_toolkit.precompilers.SCSSCompiler'), 41 | ) 42 | ``` 43 | 44 | ```html 45 | {# Django template #} 46 | 47 | {% load compress %} 48 | 49 | {% compress css %} 50 | 51 | {% endcompress %} 52 | ``` 53 | 54 | ```scss 55 | /* app/static/app/layout.scss */ 56 | 57 | @import "base/variables"; 58 | 59 | .title { 60 | font: bold $title-size Arial, sans-serif; 61 | } 62 | ``` 63 | 64 | ```scss 65 | /* base/static/base/variables.scss */ 66 | 67 | $title-size: 30px; 68 | ``` 69 | 70 | #### Requisites 71 | 72 | You need `node-sass`, `postcss-cli` and `autoprefixer` to be installed. Quick install: 73 | 74 | ```sh 75 | npm install node-sass postcss-cli autoprefixer 76 | ``` 77 | 78 | Or you can install them globally (you need to set `COMPRESS_LOCAL_NPM_INSTALL = False`): 79 | 80 | ```sh 81 | npm install -g node-sass postcss-cli autoprefixer 82 | ``` 83 | 84 | ### ES6 pre-compiler 85 | 86 | ES6 is a new standard for JavaScript that brings 87 | [great new features](https://hacks.mozilla.org/category/es6-in-depth/). 88 | 89 | The standard was approved in July 2015 and not all modern browsers fully support it for now. 90 | But there is a way to use it: transpilers that compile ES6 into good old ES5 syntax. 91 | 92 | The add-on does next: 93 | ES6 → ( 94 | [Browserify](http://browserify.org/) + 95 | [Babelify](https://github.com/babel/babelify) 96 | ) → ES5. 97 | 98 | It also enables Django static imports in ES6, see the example below. 99 | 100 | #### Usage 101 | 102 | ```py 103 | // settings.py 104 | 105 | COMPRESS_PRECOMPILERS = ( 106 | ('module', 'compressor_toolkit.precompilers.ES6Compiler'), 107 | ) 108 | ``` 109 | 110 | ```html 111 | {# Django template #} 112 | 113 | {% load compress %} 114 | 115 | {% compress js %} 116 | 117 | {% endcompress %} 118 | ``` 119 | 120 | ```js 121 | // app/static/app/scripts.js 122 | 123 | import Framework from 'base/framework'; 124 | 125 | new Framework; 126 | new Framework('1.0.1'); 127 | ``` 128 | 129 | ```js 130 | // base/static/base/framework.js 131 | 132 | export let version = '1.0'; 133 | 134 | export default class { 135 | constructor(customVersion) { 136 | console.log(`Framework v${customVersion || version} initialized`); 137 | } 138 | } 139 | ``` 140 | 141 | #### Requisites 142 | 143 | You need `browserify`, `babelify` and `babel-preset-es2015` to be installed. Quick install: 144 | 145 | ```sh 146 | npm install browserify babelify babel-preset-es2015 147 | ``` 148 | 149 | Or you can install them globally (you need to set `COMPRESS_LOCAL_NPM_INSTALL = False`): 150 | 151 | ```sh 152 | npm install -g browserify babelify babel-preset-es2015 153 | ``` 154 | 155 | ## Django settings 156 | 157 | ### `COMPRESS_LOCAL_NPM_INSTALL` 158 | 159 | Whether you install required NPM packages _locally_. 160 | 161 | Default: `True`. 162 | 163 | ### `COMPRESS_NODE_MODULES` 164 | 165 | Path to `node_modules` where `babelify`, `autoprefixer`, etc, libs are installed. 166 | 167 | Default: `./node_modules` if `COMPRESS_LOCAL_NPM_INSTALL` else `/usr/lib/node_modules`. 168 | 169 | ### `COMPRESS_NODE_SASS_BIN` 170 | 171 | `node-sass` executable. It may be just the executable name (if it's on `PATH`) or the executable path. 172 | 173 | Default: `./node_modules/.bin/node-sass` if `COMPRESS_LOCAL_NPM_INSTALL` else `node-sass`. 174 | 175 | ### `COMPRESS_POSTCSS_BIN` 176 | 177 | `postcss` executable. It may be just the executable name (if it's on `PATH`) or the executable path. 178 | 179 | Default: `./node_modules/.bin/postcss` if `COMPRESS_LOCAL_NPM_INSTALL` else `postcss`. 180 | 181 | ### `COMPRESS_AUTOPREFIXER_BROWSERS` 182 | 183 | Browser versions config for Autoprefixer. 184 | 185 | Default: `ie >= 9, > 5%`. 186 | 187 | ### `COMPRESS_SCSS_COMPILER_CMD` 188 | 189 | Command that will be executed to transform SCSS into CSS code. 190 | 191 | Default: 192 | 193 | ```py 194 | '{node_sass_bin} --output-style expanded {paths} "{infile}" "{outfile}" && ' 195 | '{postcss_bin} --use "{node_modules}/autoprefixer" --autoprefixer.browsers "{autoprefixer_browsers}" -r "{outfile}"' 196 | ``` 197 | 198 | Placeholders (i.e. they **can be re-used** in custom `COMPRESS_SCSS_COMPILER_CMD` string): 199 | - `{node_sass_bin}` - value from `COMPRESS_NODE_SASS_BIN` 200 | - `{postcss_bin}` - value from `COMPRESS_POSTCSS_BIN` 201 | - `{infile}` - input file path 202 | - `{outfile}` - output file path 203 | - `{paths}` - specially for `node-sass`, include all Django app static folders: 204 | `--include-path=/path/to/app-1/static/ --include-path=/path/to/app-2/static/ ...` 205 | - `{node_modules}` - see `COMPRESS_NODE_MODULES` setting 206 | - `{autoprefixer_browsers}` - value from `COMPRESS_AUTOPREFIXER_BROWSERS` 207 | 208 | ### `COMPRESS_BROWSERIFY_BIN` 209 | 210 | `browserify` executable. It may be just the executable name (if it's on `PATH`) or the executable path. 211 | 212 | Default: `./node_modules/.bin/browserify` if `COMPRESS_LOCAL_NPM_INSTALL` else `browserify`. 213 | 214 | ### `COMPRESS_ES6_COMPILER_CMD` 215 | 216 | Command that will be executed to transform ES6 into ES5 code. 217 | 218 | Default: 219 | 220 | ```py 221 | 'export NODE_PATH="{paths}" && ' 222 | '{browserify_bin} "{infile}" -o "{outfile}" ' 223 | '-t [ "{node_modules}/babelify" --presets="{node_modules}/babel-preset-es2015" ]' 224 | ``` 225 | 226 | Placeholders: 227 | - `{browserify_bin}` - value from `COMPRESS_BROWSERIFY_BIN` 228 | - `{infile}` - input file path 229 | - `{outfile}` - output file path 230 | - `{paths}` - specially for `browserify`, include all Django app static folders: 231 | `/path/to/app-1/static/:/path/to/app-2/static/:...` (like `PATH` variable) 232 | - `{node_modules}` - see `COMPRESS_NODE_MODULES` setting 233 | 234 | ## Contribute and test 235 | 236 | ```sh 237 | git clone https://github.com/kottenator/django-compressor-toolkit.git 238 | cd django-compressor-toolkit 239 | pip install -e '.[test]' 240 | npm install 241 | py.test 242 | ``` 243 | -------------------------------------------------------------------------------- /compressor_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | # PEP 440 - version number format 2 | VERSION = (0, 6, 1, 'dev0') 3 | 4 | # PEP 396 - module version variable 5 | __version__ = '.'.join(map(str, VERSION)) 6 | 7 | default_app_config = 'compressor_toolkit.apps.CompressorToolkitConfig' 8 | -------------------------------------------------------------------------------- /compressor_toolkit/apps.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.apps.config import AppConfig 4 | from django.conf import settings 5 | 6 | 7 | class CompressorToolkitConfig(AppConfig): 8 | name = 'compressor_toolkit' 9 | 10 | LOCAL_NPM_INSTALL = getattr(settings, 'COMPRESS_LOCAL_NPM_INSTALL', True) 11 | 12 | # Path to 'node_modules' where browserify, babelify, autoprefixer, etc, are installed 13 | NODE_MODULES = getattr( 14 | settings, 15 | 'COMPRESS_NODE_MODULES', 16 | os.path.abspath('node_modules') if LOCAL_NPM_INSTALL else '/usr/lib/node_modules' 17 | ) 18 | 19 | # node-sass executable 20 | NODE_SASS_BIN = getattr( 21 | settings, 22 | 'COMPRESS_NODE_SASS_BIN', 23 | 'node_modules/.bin/node-sass' if LOCAL_NPM_INSTALL else 'node-sass' 24 | ) 25 | 26 | # postcss executable 27 | POSTCSS_BIN = getattr( 28 | settings, 29 | 'COMPRESS_POSTCSS_BIN', 30 | 'node_modules/.bin/postcss' if LOCAL_NPM_INSTALL else 'postcss' 31 | ) 32 | 33 | # Browser versions config for Autoprefixer 34 | AUTOPREFIXER_BROWSERS = getattr(settings, 'COMPRESS_AUTOPREFIXER_BROWSERS', 'ie >= 9, > 5%') 35 | 36 | # Custom SCSS transpiler command 37 | SCSS_COMPILER_CMD = getattr(settings, 'COMPRESS_SCSS_COMPILER_CMD', ( 38 | '{node_sass_bin} --output-style expanded {paths} "{infile}" > "{outfile}" && ' 39 | '{postcss_bin} --use "{node_modules}/autoprefixer" ' 40 | '--autoprefixer.browsers "{autoprefixer_browsers}" -r "{outfile}"' 41 | )) 42 | 43 | # browserify executable 44 | BROWSERIFY_BIN = getattr( 45 | settings, 46 | 'COMPRESS_BROWSERIFY_BIN', 47 | 'node_modules/.bin/browserify' if LOCAL_NPM_INSTALL else 'browserify' 48 | ) 49 | 50 | # Custom ES6 transpiler command 51 | ES6_COMPILER_CMD = getattr(settings, 'COMPRESS_ES6_COMPILER_CMD', ( 52 | 'export NODE_PATH="{paths}" && ' 53 | '{browserify_bin} "{infile}" -o "{outfile}" ' 54 | '-t [ "{node_modules}/babelify" --presets="{node_modules}/babel-preset-es2015" ]' 55 | )) 56 | -------------------------------------------------------------------------------- /compressor_toolkit/filters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | 5 | from compressor.filters.css_default import CssAbsoluteFilter 6 | from compressor.filters.datauri import CssDataUriFilter as BaseCssDataUriFilter 7 | from django.apps import apps 8 | from django.conf import settings 9 | 10 | 11 | app_config = apps.get_app_config('compressor_toolkit') 12 | logger = logging.getLogger(__file__) 13 | 14 | 15 | class CssRelativeFilter(CssAbsoluteFilter): 16 | """ 17 | Do similar to ``CssAbsoluteFilter`` URL processing 18 | but replace ``settings.COMPRESS_URL`` prefix with '../' * (N + 1), 19 | where N is the *depth* of ``settings.COMPRESS_OUTPUT_DIR`` folder. 20 | 21 | E.g. by default ``settings.COMPRESS_OUTPUT_DIR == 'CACHE'``, 22 | its depth N == 1, prefix == '../' * (1 + 1) == '../../'. 23 | 24 | If ``settings.COMPRESS_OUTPUT_DIR == 'my/compiled/data'``, 25 | its depth N == 3, prefix == '../' * (3 + 1) == '../../../../'. 26 | 27 | How does it work: 28 | 29 | - original file URL: '/static/my-app/style.css' 30 | - it has an image link: ``url(images/logo.svg)`` 31 | - compiled file URL: '/static/CACHE/css/abcdef123456.css' 32 | - replaced image link URL: ``url(../../my-app/images/logo.svg)`` 33 | """ 34 | def add_suffix(self, url): 35 | url = super(CssRelativeFilter, self).add_suffix(url) 36 | old_prefix = self.url 37 | if self.has_scheme: 38 | old_prefix = '{}{}'.format(self.protocol, old_prefix) 39 | # One level up from 'css' / 'js' folder 40 | new_prefix = '..' 41 | # N levels up from ``settings.COMPRESS_OUTPUT_DIR`` 42 | new_prefix += '/..' * len(list(filter( 43 | None, os.path.normpath(settings.COMPRESS_OUTPUT_DIR).split(os.sep) 44 | ))) 45 | return re.sub('^{}'.format(old_prefix), new_prefix, url) 46 | 47 | 48 | class CssDataUriFilter(BaseCssDataUriFilter): 49 | """ 50 | Override default ``compressor.filters.datauri.CssDataUriFilter``: 51 | 52 | - fix https://github.com/django-compressor/django-compressor/issues/776 53 | - introduce new settings - ``COMPRESS_DATA_URI_INCLUDE_PATHS`` and 54 | ``COMPRESS_DATA_URI_EXCLUDE_PATHS`` - to filter only specific file paths or extensions, 55 | e.g. ``settings.COMPRESS_DATA_URI_INCLUDE_PATHS = '\.svg$'``. 56 | """ 57 | def input(self, filename=None, **kwargs): 58 | if not filename: 59 | return self.content 60 | # Store filename - we'll use it to build file paths 61 | self.filename = filename 62 | output = self.content 63 | for url_pattern in self.url_patterns: 64 | output = url_pattern.sub(self.data_uri_converter, output) 65 | return output 66 | 67 | def data_uri_converter(self, matchobj): 68 | url = matchobj.group(1).strip(' \'"') 69 | 70 | # Don't process URLs that start with: 'data:', 'http://', 'https://' and '/'. 71 | # We're interested only in relative URLs like 'images/icon.png' or '../images/icon.svg' 72 | if not re.match('^(data:|https?://|/)', url): 73 | file_path = self.get_file_path(url) 74 | 75 | # Include specific file paths (optional) 76 | file_path_included = bool( 77 | not hasattr(settings, 'COMPRESS_DATA_URI_INCLUDE_PATHS') or 78 | re.match(settings.COMPRESS_DATA_URI_INCLUDE_PATHS, file_path) 79 | ) 80 | 81 | # Exclude specific file paths (optional) 82 | file_path_excluded = bool( 83 | hasattr(settings, 'COMPRESS_DATA_URI_EXCLUDE_PATHS') and 84 | re.match(settings.COMPRESS_DATA_URI_EXCLUDE_PATHS, file_path) 85 | ) 86 | 87 | if file_path_included and not file_path_excluded: 88 | try: 89 | return super(CssDataUriFilter, self).data_uri_converter(matchobj) 90 | except OSError: 91 | logger.warning('"{}" file not found'.format(file_path)) 92 | 93 | return 'url("{}")'.format(url) 94 | 95 | def get_file_path(self, url): 96 | file_path = re.sub('[#?].*$', '', url) 97 | return os.path.abspath(os.path.join(os.path.dirname(self.filename), file_path)) 98 | -------------------------------------------------------------------------------- /compressor_toolkit/precompilers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from compressor.filters import CompilerFilter 4 | from django.apps import apps 5 | from django.conf import settings 6 | from django.contrib.staticfiles import finders 7 | from django.core.files.temp import NamedTemporaryFile 8 | 9 | 10 | app_config = apps.get_app_config('compressor_toolkit') 11 | 12 | 13 | def get_all_static(): 14 | """ 15 | Get all the static files directories found by ``STATICFILES_FINDERS`` 16 | 17 | :return: set of paths (top-level folders only) 18 | """ 19 | static_dirs = set() 20 | 21 | for finder in settings.STATICFILES_FINDERS: 22 | finder = finders.get_finder(finder) 23 | 24 | if hasattr(finder, 'storages'): 25 | for storage in finder.storages.values(): 26 | static_dirs.add(storage.location) 27 | 28 | if hasattr(finder, 'storage'): 29 | static_dirs.add(finder.storage.location) 30 | 31 | return static_dirs 32 | 33 | 34 | class BaseCompiler(CompilerFilter): 35 | # Temporary input file extension 36 | infile_ext = '' 37 | 38 | def input(self, **kwargs): 39 | """ 40 | Specify temporary input file extension. 41 | 42 | Browserify requires explicit file extension (".js" or ".json" by default). 43 | https://github.com/substack/node-browserify/issues/1469 44 | """ 45 | if self.infile is None and "{infile}" in self.command: 46 | if self.filename is None: 47 | self.infile = NamedTemporaryFile(mode='wb', suffix=self.infile_ext) 48 | self.infile.write(self.content.encode(self.default_encoding)) 49 | self.infile.flush() 50 | self.options += ( 51 | ('infile', self.infile.name), 52 | ) 53 | return super(BaseCompiler, self).input(**kwargs) 54 | 55 | 56 | class SCSSCompiler(BaseCompiler): 57 | """ 58 | django-compressor pre-compiler for SCSS files. 59 | 60 | Consists of 2 steps: 61 | 62 | 1. ``node-sass input.scss output.css`` 63 | 2. ``postcss --use autoprefixer -r output.css`` 64 | 65 | Includes all available 'static' dirs: 66 | 67 | node-sass --include-path path/to/app-1/static/ --include-path path/to/app-2/static/ ... 68 | 69 | So you can do imports inside your SCSS files: 70 | 71 | @import "app-1/scss/mixins"; 72 | @import "app-2/scss/variables"; 73 | 74 | .page-title { 75 | font-size: $title-font-size; 76 | } 77 | """ 78 | command = app_config.SCSS_COMPILER_CMD 79 | options = ( 80 | ('node_sass_bin', app_config.NODE_SASS_BIN), 81 | ('postcss_bin', app_config.POSTCSS_BIN), 82 | ('paths', ' '.join(['--include-path {}'.format(s) for s in get_all_static()])), 83 | ('node_modules', app_config.NODE_MODULES), 84 | ('autoprefixer_browsers', app_config.AUTOPREFIXER_BROWSERS), 85 | ) 86 | infile_ext = '.scss' 87 | 88 | 89 | class ES6Compiler(BaseCompiler): 90 | """ 91 | django-compressor pre-compiler for ES6 files. 92 | 93 | Transforms ES6 to ES5 using Browserify + Babel. 94 | 95 | Includes all available 'static' dirs: 96 | 97 | export NODE_PATH="path/to/app-1/static/:path/to/app-2/static/" && browserify ... 98 | 99 | So you can do imports inside your ES6 modules: 100 | 101 | import controller from 'app-1/page-controller'; 102 | import { login, signup } from 'app-2/pages'; 103 | 104 | controller.registerPages(login, signup); 105 | """ 106 | command = app_config.ES6_COMPILER_CMD 107 | options = ( 108 | ('browserify_bin', app_config.BROWSERIFY_BIN), 109 | ('paths', os.pathsep.join(get_all_static())), 110 | ('node_modules', app_config.NODE_MODULES) 111 | ) 112 | infile_ext = '.js' 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "node-sass": "~3.10", 5 | "postcss-cli": "~2.6", 6 | "autoprefixer": "~6.5", 7 | "browserify": "~13.1", 8 | "babelify": "~7.3", 9 | "babel-preset-es2015": "~6.16" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | python_paths = tests/test_project 3 | testpaths = tests/unit_tests tests/integration_tests 4 | addopts = 5 | --ds test_project.settings 6 | --cov compressor_toolkit 7 | --cov-report term-missing 8 | --cov-report html 9 | --cov-report xml 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | import compressor_toolkit 4 | 5 | 6 | setup( 7 | name='django-compressor-toolkit', 8 | version=compressor_toolkit.__version__, 9 | description='Set of add-ons for django-compressor', 10 | long_description=( 11 | 'Simply enable SCSS and ES6 in your Django project. ' 12 | 'Read more on `project\'s GitHub page ' 13 | '`_.' 14 | ), 15 | url='https://github.com/kottenator/django-compressor-toolkit', 16 | author='Rostyslav Bryzgunov', 17 | author_email='kottenator@gmail.com', 18 | license='MIT', 19 | packages=find_packages(exclude=['tests', 'tests.*']), 20 | include_package_data=True, 21 | install_requires=[ 22 | 'django-compressor>=1.5' 23 | ], 24 | extras_require={ 25 | 'test': [ 26 | 'django~=1.8', 27 | 'pytest~=3.0', 28 | 'pytest-django~=3.0', 29 | 'pytest-cov~=2.4', 30 | 'pytest-pythonpath~=0.7' 31 | ] 32 | }, 33 | classifiers=[ 34 | 'Framework :: Django', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Operating System :: OS Independent', 38 | 'Programming Language :: Python :: 2', 39 | 'Programming Language :: Python :: 3' 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kottenator/django-compressor-toolkit/e7bfdaa354e9c9189db0e4ba4fa049045adad91b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from compressor.base import Compressor 5 | from django.conf import settings 6 | import pytest 7 | 8 | 9 | COMPRESSOR_HASH = 'test-hash' 10 | 11 | 12 | def pytest_configure(): 13 | shutil.rmtree(settings.COMPRESS_ROOT, ignore_errors=True) 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def fake_compressor_filename(monkeypatch): 18 | monkeypatch.setattr('compressor.base.get_hexdigest', lambda text, length: COMPRESSOR_HASH) 19 | 20 | 21 | @pytest.fixture 22 | def precompiled(): 23 | """ 24 | Fixture that does the following steps: 25 | 26 | - searches given static file, e.g. 'app/layout.scss' 27 | - finds corresponding pre-compiled file generated by ``django-compressor`` 28 | - returns pre-compiled file contents 29 | 30 | Usage: 31 | 32 | assert precompiled('app/layout.scss', 'css') == '.title {\n font-size: 30px;\n}\n' 33 | """ 34 | def runner(original_file_path, file_type): 35 | """ 36 | :param original_file_path: e.g. 'app/layout.scss' 37 | :param file_type: 'css' or 'js' 38 | """ 39 | original_file_name = os.path.basename(original_file_path) 40 | compiled_file_path = os.path.join( 41 | settings.COMPRESS_ROOT, 42 | Compressor(output_prefix=file_type).get_filepath('...', original_file_name) 43 | ) 44 | with open(compiled_file_path) as f: 45 | return f.read() 46 | return runner 47 | -------------------------------------------------------------------------------- /tests/integration_tests/test_views.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.urlresolvers import reverse 4 | 5 | 6 | def test_view_with_scss_file(client, precompiled): 7 | """ 8 | Test view that renders *SCSS file* that *imports SCSS file from another Django app*. 9 | 10 | :param client: ``pytest-django`` fixture: Django test client 11 | :param precompiled: custom fixture that asserts pre-compiled content 12 | """ 13 | response = client.get(reverse('scss-file')) 14 | assert response.status_code == 200 15 | assert precompiled('app/layout.scss', 'css').strip() == \ 16 | '.title {\n font: bold 30px Arial, sans-serif;\n}' 17 | 18 | 19 | def test_view_with_inline_scss(client): 20 | """ 21 | Test view that renders *inline SCSS* that *imports SCSS file from another Django app*. 22 | 23 | :param client: ``pytest-django`` fixture: Django test client 24 | """ 25 | response = client.get(reverse('scss-inline')) 26 | assert response.status_code == 200 27 | assert re.search( 28 | r'', 29 | response.content.decode('utf8') 30 | ) 31 | 32 | 33 | def test_view_with_es6_file(client, precompiled): 34 | """ 35 | Test view that renders *ES6 file* into *ES5 file*. 36 | 37 | :param client: ``pytest-django`` fixture: Django test client 38 | :param precompiled: custom fixture that asserts pre-compiled content 39 | """ 40 | response = client.get(reverse('es6-file')) 41 | assert response.status_code == 200 42 | assert precompiled('app/scripts.js', 'js') == ( 43 | '(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require==' 44 | '"function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=' 45 | 'new Error("Cannot find module \'"+o+"\'");throw f.code="MODULE_NOT_FOUND",f}' 46 | 'var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];' 47 | 'return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require==' 48 | '"function"&&require;for(var o=0;o 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/resources/images/large.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kottenator/django-compressor-toolkit/e7bfdaa354e9c9189db0e4ba4fa049045adad91b/tests/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /tests/test_project/test_project/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kottenator/django-compressor-toolkit/e7bfdaa354e9c9189db0e4ba4fa049045adad91b/tests/test_project/test_project/app/__init__.py -------------------------------------------------------------------------------- /tests/test_project/test_project/app/static/app/layout.scss: -------------------------------------------------------------------------------- 1 | @import "base/variables"; 2 | 3 | .title { 4 | font: bold $title-size Arial, sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /tests/test_project/test_project/app/static/app/scripts.js: -------------------------------------------------------------------------------- 1 | import Framework from 'base/framework'; 2 | 3 | new Framework; 4 | new Framework('1.0.1'); 5 | -------------------------------------------------------------------------------- /tests/test_project/test_project/app/templates/app/template-with-es6-file.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load static %} 4 | {% load compress %} 5 | 6 | 7 | 8 | 9 | Compressor Tookit Tests: ES6 file → ES5 file 10 | 11 | {% compress js %} 12 | 13 | {% endcompress %} 14 | 15 | 16 |

17 | Compressor Tookit Tests: ES6 file transformed to ES5 file 18 |

19 |

20 | Expected result: JS (ES5) file generated and JS console output: 21 |

22 |

23 |     > Framework v1.0 initialized
24 |     > Framework v1.0.1 initialized
25 |   
26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/test_project/test_project/app/templates/app/template-with-es6-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load static %} 4 | {% load compress %} 5 | 6 | 7 | 8 | 9 | Compressor Tookit Tests: Inline ES6 → inline ES5 10 | 11 | {% compress js %} 12 | 16 | {% endcompress %} 17 | 18 | 19 |

20 | Compressor Tookit Tests: Inline ES6 transformed to inline ES5 21 |

22 |

23 | Expected result: inline JS (ES5) code generated and JS console output: 24 |

25 |

26 |     > Square of 2: 4
27 |   
28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/test_project/test_project/app/templates/app/template-with-scss-file.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load static %} 4 | {% load compress %} 5 | 6 | 7 | 8 | 9 | Compressor Tookit Tests: SCSS file → CSS file 10 | 11 | {% compress css %} 12 | 13 | {% endcompress %} 14 | 15 | 16 |

17 | Compressor Tookit Tests: SCSS file, transformed to CSS file 18 |

19 |

20 | Expected result: CSS file created and styles applied to the title above 21 |

22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/test_project/test_project/app/templates/app/template-with-scss-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load static %} 4 | {% load compress %} 5 | 6 | 7 | 8 | 9 | Compressor Tookit Tests: Inline SCSS → inline CSS 10 | 11 | {% compress css %} 12 | 19 | {% endcompress %} 20 | 21 | 22 |

23 | Compressor Tookit Tests: Inline SCSS, transformed to inline CSS 24 |

25 |

26 | Expected result: inline CSS code generated and styles applied to the title above 27 |

28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/test_project/test_project/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kottenator/django-compressor-toolkit/e7bfdaa354e9c9189db0e4ba4fa049045adad91b/tests/test_project/test_project/base/__init__.py -------------------------------------------------------------------------------- /tests/test_project/test_project/base/static/base/framework.js: -------------------------------------------------------------------------------- 1 | export let version = '1.0'; 2 | 3 | export default class { 4 | constructor(customVersion) { 5 | console.log(`Framework v${customVersion || version} initialized`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/test_project/test_project/base/static/base/utils.js: -------------------------------------------------------------------------------- 1 | define(['exports'], function(exports) { 2 | exports.key = 'abc123'; 3 | }); 4 | -------------------------------------------------------------------------------- /tests/test_project/test_project/base/static/base/variables.scss: -------------------------------------------------------------------------------- 1 | $title-size: 30px; 2 | -------------------------------------------------------------------------------- /tests/test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | DEBUG = True 8 | SECRET_KEY = '*****' 9 | INSTALLED_APPS = ( 10 | 'django.contrib.staticfiles', 11 | 'compressor', 12 | 'compressor_toolkit', 13 | 'test_project.base', 14 | 'test_project.app' 15 | ) 16 | ROOT_URLCONF = 'test_project.urls' 17 | STATIC_URL = '/static/' 18 | STATICFILES_FINDERS = ( 19 | 'django.contrib.staticfiles.finders.FileSystemFinder', 20 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 21 | 'compressor.finders.CompressorFinder' 22 | ) 23 | 24 | if django.VERSION < (1, 8): 25 | TEMPLATE_CONTEXT_PROCESSORS = [ 26 | 'django.template.context_processors.static' 27 | ] 28 | else: 29 | TEMPLATES = [{ 30 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 31 | 'APP_DIRS': True, 32 | 'OPTIONS': { 33 | 'context_processors': [ 34 | 'django.template.context_processors.static' 35 | ] 36 | } 37 | }] 38 | 39 | if django.VERSION < (1, 10): 40 | MIDDLEWARE_CLASSES = [] 41 | else: 42 | MIDDLEWARE = [] 43 | 44 | # django-compressor settings 45 | COMPRESS_ROOT = os.path.join(BASE_DIR, 'compressor') 46 | COMPRESS_PRECOMPILERS = ( 47 | ('text/x-scss', 'compressor_toolkit.precompilers.SCSSCompiler'), 48 | ('module', 'compressor_toolkit.precompilers.ES6Compiler') 49 | ) 50 | COMPRESS_ENABLED = False 51 | 52 | # django-compressor-toolkit settings; see compressor_toolkit/apps.py for details 53 | if 'COMPRESS_NODE_MODULES' in os.environ: 54 | COMPRESS_NODE_MODULES = os.getenv('COMPRESS_NODE_MODULES') 55 | -------------------------------------------------------------------------------- /tests/test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.generic.base import TemplateView 3 | 4 | 5 | urlpatterns = [ 6 | url( 7 | '^scss-file/$', 8 | TemplateView.as_view(template_name='app/template-with-scss-file.html'), 9 | name='scss-file' 10 | ), 11 | url( 12 | '^scss-inline/$', 13 | TemplateView.as_view(template_name='app/template-with-scss-inline.html'), 14 | name='scss-inline' 15 | ), 16 | url( 17 | '^es6-file/$', 18 | TemplateView.as_view(template_name='app/template-with-es6-file.html'), 19 | name='es6-file' 20 | ), 21 | url( 22 | '^es6-inline/$', 23 | TemplateView.as_view(template_name='app/template-with-es6-inline.html'), 24 | name='es6-inline' 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /tests/unit_tests/test_filters.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import pytest 4 | 5 | from compressor_toolkit.filters import CssRelativeFilter, CssDataUriFilter 6 | 7 | from tests.utils import TESTS_DIR 8 | 9 | 10 | @pytest.mark.parametrize('original_url, processed_url', [ 11 | ('images/icon.svg', '../../app/images/icon.svg'), 12 | ('./images/icon.svg', '../../app/images/icon.svg'), 13 | ('../images/icon.svg', '../../images/icon.svg'), 14 | ('/images/icon.svg', '/images/icon.svg') 15 | ], ids=['letter', 'one dot', 'two dots', 'slash']) 16 | def test_css_relative_url_filter(original_url, processed_url): 17 | """ 18 | Test ``compressor_toolkit.filters.CssRelativeFilter``. 19 | 20 | :param original_url: Test function parameter: URL in the original CSS file 21 | which is located in '$PROJECT_ROOT/app/static/app/style.css' 22 | and will be collected to '$STATIC_ROOT/app/style.css' 23 | :param processed_url: Test function parameter: URL in the processed CSS file 24 | which is located in '$STATIC_ROOT/CACHE/css/abcd1234.css' 25 | """ 26 | template = ''' 27 | .a {{ 28 | background: url('{}'); 29 | }} 30 | ''' 31 | input_css = template.format(original_url) 32 | output_css = template.format(processed_url) 33 | 34 | # ``filename`` and ``basename`` are fakes - file existence doesn't matter for this test 35 | # ``filename`` must be not empty to make the filter work 36 | # ``basename`` contains Django app name 'app', which will be a part of the output URL 37 | assert CssRelativeFilter(input_css).input( 38 | filename='...', 39 | basename='app/style.css' 40 | ) == output_css 41 | 42 | 43 | @pytest.mark.parametrize('image_path, is_processed', [ 44 | ('images/icon.svg', True), 45 | ('images/icon.png', True), 46 | ('images/icon.jpg', False), 47 | ('images/skip/icon.svg', False), 48 | ('images/large.svg', False) 49 | ], ids=['ok svg', 'ok png', 'skip jpg', 'skip folder', 'skip large']) 50 | def test_css_data_uri_filter(settings, image_path, is_processed): 51 | """ 52 | Test ``compressor_toolkit.filters.CssRelativeFilter``. 53 | 54 | :param settings: ``pytest-django`` fixture: mutable Django settings 55 | :param image_path: Test function parameter: relative path to the image file 56 | :param is_processed: Test function parameter: is data URI transformation applied? 57 | """ 58 | # configure related settings 59 | settings.COMPRESS_DATA_URI_MAX_SIZE = 5 * 1024 60 | settings.COMPRESS_DATA_URI_INCLUDE_PATHS = '.+\.(svg|png)$' 61 | settings.COMPRESS_DATA_URI_EXCLUDE_PATHS = '/skip/' 62 | 63 | file_dir = os.path.join(TESTS_DIR, 'resources') 64 | file_path = os.path.join(file_dir, 'style.css') 65 | 66 | if is_processed: 67 | with open(os.path.join(file_dir, image_path), 'rb') as image_file: 68 | processed_image = 'data:image/{};base64,{}'.format( 69 | 'svg+xml' if image_path.endswith('.svg') else 'png', 70 | base64.b64encode(image_file.read()).decode() 71 | ) 72 | else: 73 | processed_image = image_path 74 | 75 | template = ''' 76 | .a {{ 77 | background: url("{}"); 78 | }} 79 | ''' 80 | input_css = template.format(image_path) 81 | output_css = template.format(processed_image) 82 | 83 | assert CssDataUriFilter(input_css).input(filename=file_path) == output_css 84 | -------------------------------------------------------------------------------- /tests/unit_tests/test_precompilers.py: -------------------------------------------------------------------------------- 1 | from compressor_toolkit.precompilers import SCSSCompiler, ES6Compiler 2 | 3 | 4 | def test_scss_compiler(): 5 | """ 6 | Test ``compressor_toolkit.precompilers.SCSSCompiler`` on simple SCSS input. 7 | """ 8 | input_scss = ''' 9 | .a { 10 | .b { 11 | padding: { 12 | left: 5px; 13 | right: 6px; 14 | } 15 | } 16 | } 17 | ''' 18 | output_css = '.a .b {\n padding-left: 5px;\n padding-right: 6px;\n}' 19 | assert SCSSCompiler(input_scss, {}).input().strip() == output_css 20 | 21 | 22 | def test_es6_compiler(): 23 | """ 24 | Test ``compressor_toolkit.precompilers.ES6Compiler`` on simple ES6 input. 25 | """ 26 | input_es6 = 'export let CONST = 1' 27 | output_es5 = ( 28 | '"use strict";\n' 29 | '\n' 30 | 'Object.defineProperty(exports, "__esModule", {\n' 31 | ' value: true\n' 32 | '});\n' 33 | 'var CONST = exports.CONST = 1;\n' 34 | ) 35 | assert output_es5 in ES6Compiler(input_es6, {}).input() 36 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | TESTS_DIR = os.path.dirname(__file__) 5 | --------------------------------------------------------------------------------