├── .bumpversion.cfg ├── .editorconfig ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── install-dependencies.sh ├── jac ├── __init__.py ├── base.py ├── compat.py ├── compressors │ ├── __init__.py │ ├── coffee.py │ ├── javascript.py │ ├── less.py │ └── sass.py ├── config.py ├── contrib │ ├── __init__.py │ └── flask.py ├── exceptions.py ├── extension.py └── parser.py ├── requirements.txt ├── requirements_tests.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── compilers │ └── __init__.py ├── contrib │ ├── __init__.py │ └── test_flask.py ├── helpers.py ├── test_compiling.py └── test_extensions.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.18.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [*.{bat,cmd,ps1}] 13 | end_of_line = crlf 14 | 15 | [*.{yml}] 16 | indent_size = 2 17 | 18 | [Makefile] 19 | indent_style = tab 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 19 * * 0' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Backups 40 | *~ 41 | .env 42 | .cache/ 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | matrix: 5 | include: 6 | - python: "3.5" 7 | env: 8 | - TOXENV=py35 9 | - python: "3.6" 10 | env: 11 | - TOXENV=py36 12 | - python: "3.7" 13 | env: 14 | - TOXENV=py37 15 | - python: "3.8" 16 | env: 17 | - TOXENV=py38 18 | install: 19 | - pip install tox 20 | script: 21 | - tox -v 22 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | --------- 3 | 4 | 0.17.2 5 | `````` 6 | 7 | - Make sure that flask contrib script always push an app context. 8 | 9 | 0.17.1 10 | `````` 11 | 12 | - Fix error when the blueprint cannot be found (i.e. flask debug toolbar) 13 | 14 | 0.17.0 15 | `````` 16 | 17 | - Fix app factory initialization 18 | - Change how tests and linters run 19 | - Add compress command to `jac.contrib.flask` 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | Copyright (c) 2013 Jayson Reis 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | 23 | jinja-assets-compressor contains code from Django Compressor 24 | ------------------------------------------------------------ 25 | Copyright (c) 2009-2014 Django Compressor authors (see AUTHORS file) 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the "Software"), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in 35 | all copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 43 | THE SOFTWARE. 44 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude *.pyc *.pyo 2 | include requirements.txt 3 | include README.rst 4 | include CHANGELOG.rst 5 | include LICENSE 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: clean 2 | py.test tests ${ARGS} 3 | 4 | coverage: 5 | $(MAKE) test ARGS="--cov=jac --cov=tests ${ARGS}" 6 | 7 | clean: 8 | @find . -iname '*.pyc' -delete 9 | @find . -iname '*.pyo' -delete 10 | @rm -rf build/ dist/ 11 | 12 | lint: 13 | flake8 jac tests setup.py 14 | 15 | isort_fix: 16 | isort -y --recursive jac tests setup.py 17 | 18 | .PHONY: test coverage clean lint isort_fix 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/jaysonsantos/jinja-assets-compressor.svg?branch=master 2 | :target: https://travis-ci.org/jaysonsantos/jinja-assets-compressor 3 | :alt: Build Status 4 | 5 | jinja-assets-compressor 6 | ======================= 7 | 8 | A Jinja2 extension to compile and/or compress your assets. 9 | 10 | 11 | Installing 12 | ---------- 13 | 14 | :: 15 | 16 | pip install jac 17 | 18 | For LESS and CSS support, install `less `_:: 19 | 20 | npm install -g less 21 | 22 | For COFFEE support, install `coffee-script `_:: 23 | 24 | npm install -g coffee-script 25 | 26 | For Sass and SCSS support, install `sass `_:: 27 | 28 | gem install sass 29 | 30 | JavaScript minification is built-in using the Python 31 | `rJsmin `_ package. 32 | 33 | When installing on Mac OS X set this shell variable, because jac dependencies 34 | contain C code:: 35 | 36 | export CFLAGS=-Qunused-arguments 37 | 38 | 39 | Usage 40 | ----- 41 | 42 | To use it, you just have to put your css or js inside a compress tag. 43 | 44 | .. code-block:: python 45 | 46 | {% compress 'css' %} 47 | 50 | 51 | {% endcompress %} 52 | 53 | {% compress 'js' %} 54 | 57 | 58 | {% endcompress %} 59 | 60 | 61 | Configuring Jinja 62 | ----------------- 63 | 64 | You just have to create an environment with jac on it and configure output dir, 65 | static prefix and say where it can find your sources. 66 | 67 | .. code-block:: python 68 | 69 | import jinja2 70 | 71 | from jac import CompressorExtension 72 | 73 | env = jinja2.Environment(extensions=[CompressorExtension]) 74 | env.compressor_output_dir = './static/dist' 75 | env.compressor_static_prefix = '/static' 76 | env.compressor_source_dirs = './static_files' 77 | 78 | After that just use ``template = env.from_string(html); template.render()`` to 79 | get it done. 80 | 81 | 82 | Configuring Flask 83 | ----------------- 84 | 85 | Where you configure your app, just do this: 86 | 87 | .. code-block:: python 88 | 89 | from jac.contrib.flask import JAC 90 | 91 | app = Flask(__name__) 92 | app.config['COMPRESSOR_DEBUG'] = app.config.get('DEBUG') 93 | app.config['COMPRESSOR_OUTPUT_DIR'] = './static/dist' 94 | app.config['COMPRESSOR_STATIC_PREFIX'] = '/static' 95 | jac = JAC(app) 96 | 97 | And you are done. 98 | 99 | 100 | Offline Compression 101 | ------------------- 102 | 103 | JAC supports compressing static assets offline, then deploying to a production 104 | server. Here is a command to compress your static assets if using Flask: :: 105 | 106 | python -m jac.contrib.flask my_flask_module:create_app 107 | 108 | Replace ``my_flask_module`` with the correct import path to find your Flask app. 109 | 110 | 111 | Custom Compressors 112 | ------------------ 113 | 114 | The ``compressor_classes`` template env variable tells jac which compressor to 115 | use for each mimetype. The default value for ``compressor_classes`` is: 116 | 117 | .. code-block:: python 118 | 119 | { 120 | 'text/css': LessCompressor, 121 | 'text/coffeescript': CoffeeScriptCompressor, 122 | 'text/less': LessCompressor, 123 | 'text/javascript': JavaScriptCompressor, 124 | 'text/sass': SassCompressor, 125 | 'text/scss': SassCompressor, 126 | } 127 | 128 | To use an alternate compressor class, provide a class with a ``compile`` class 129 | method accepting arg ``text`` and kwargs ``mimetype``, ``cwd``, ``uri_cwd``, 130 | and ``debug``. For example, to use 131 | `libsass-python `_ for SASS files 132 | instead of the built-in SassCompressor, create your custom compressor class: 133 | 134 | .. code-block:: python 135 | 136 | import sass 137 | 138 | class CustomSassCompressor(object): 139 | """Custom compressor for text/sass mimetype. 140 | 141 | Uses libsass-python for compression. 142 | """ 143 | 144 | @classmethod 145 | def compile(cls, text, cwd=None, **kwargs): 146 | 147 | include_paths = [] 148 | if cwd: 149 | include_paths += [cwd] 150 | 151 | return sass.compile(string=text, include_paths=include_paths) 152 | 153 | Then tell jac to use your custom compressor for ``text/sass`` mimetypes: 154 | 155 | .. code-block:: python 156 | 157 | env.compressor_classes['text/sass'] = CustomSassCompressor 158 | 159 | The equivalent for Flask is: 160 | 161 | .. code-block:: python 162 | 163 | jac.set_compressor('text/sass', CustomSassCompressor) 164 | 165 | To only customize the path of a compressor which forks a subprocess for the 166 | compile step (LessCompressor, CoffeeScriptCompressor, and SassCompressor), just 167 | extend the compressor class and overwrite the ``binary`` class attribute: 168 | 169 | .. code-block:: python 170 | 171 | from jac.compressors import SassCompressor 172 | 173 | class CustomSassCompressor(SassCompressor): 174 | """Custom SASS compressor using Compass binary instead of libsass for text/sass mimetype. 175 | 176 | Uses the faster libsass wrapper sassc for SASS compression. 177 | https://github.com/sass/sassc 178 | """ 179 | 180 | binary = '/usr/bin/sassc' 181 | 182 | # Tell Flask to use our custom SASS compressor 183 | jac.set_compressor('text/sass', CustomSassCompressor) 184 | 185 | 186 | Running Tests 187 | ------------- 188 | 189 | :: 190 | 191 | virtualenv venv 192 | . venv/bin/activate 193 | pip install -r requirements_tests.txt 194 | make coverage 195 | make lint 196 | 197 | Or use tox to run with multiple python versions: 198 | 199 | :: 200 | 201 | pip install tox 202 | tox 203 | -------------------------------------------------------------------------------- /install-dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -x 4 | which sass || gem install sass 5 | which lessc || npm install -g less 6 | which coffee || npm install -g coffeescript 7 | -------------------------------------------------------------------------------- /jac/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Compressor # noqa 2 | from .extension import CompressorExtension # noqa 3 | -------------------------------------------------------------------------------- /jac/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import hashlib 4 | import os 5 | from shutil import copyfile 6 | 7 | from bs4 import BeautifulSoup 8 | 9 | from jac.compat import basestring 10 | from jac.compat import file 11 | from jac.compat import open 12 | from jac.compat import u 13 | from jac.compat import utf8_encode 14 | from jac.config import Config 15 | from jac.exceptions import OfflineGenerationError 16 | from jac.exceptions import TemplateDoesNotExist 17 | from jac.exceptions import TemplateSyntaxError 18 | from jac.parser import Jinja2Parser 19 | 20 | try: 21 | from collections import OrderedDict # Python >= 2.7 22 | except ImportError: 23 | from ordereddict import OrderedDict # Python 2.6 24 | 25 | try: 26 | import lxml # noqa 27 | PARSER = 'lxml' 28 | except ImportError: 29 | PARSER = 'html.parser' 30 | 31 | 32 | class Compressor(object): 33 | 34 | def __init__(self, **kwargs): 35 | if 'environment' in kwargs: 36 | configs = self.get_configs_from_environment(kwargs['environment']) 37 | self.config = Config(**configs) 38 | else: 39 | self.config = Config(**kwargs) 40 | 41 | # Cache output of make_hash in-memory when using offline compress 42 | self.offline_hash_cache = {} 43 | 44 | # Cache compressed contents during offline compress step 45 | self.offline_compress_cache = {} 46 | 47 | def compress(self, html, compression_type): 48 | 49 | if not self.config.compressor_enabled: 50 | return html 51 | 52 | compression_type = compression_type.lower() 53 | html_hash = self.make_hash(html) 54 | 55 | if not os.path.exists(u(self.config.compressor_output_dir)): 56 | os.makedirs(u(self.config.compressor_output_dir)) 57 | 58 | if self.config.compressor_offline_compress and self.config.compressor_cache_dir: 59 | cached_file = os.path.join( 60 | u(self.config.compressor_cache_dir), 61 | u('{hash}.{extension}').format( 62 | hash=html_hash, 63 | extension=compression_type, 64 | ), 65 | ) 66 | output_file = os.path.join( 67 | u(self.config.compressor_output_dir), 68 | u('{hash}.{extension}').format( 69 | hash=html_hash, 70 | extension=compression_type, 71 | ), 72 | ) 73 | if os.path.exists(cached_file) and not os.path.exists(output_file): 74 | copyfile(cached_file, output_file) 75 | 76 | cached_file = os.path.join( 77 | u(self.config.compressor_output_dir), 78 | u('{hash}.{extension}').format( 79 | hash=html_hash, 80 | extension=compression_type, 81 | ), 82 | ) 83 | 84 | if not self.config.compressor_debug and os.path.exists(cached_file): 85 | filename = os.path.join( 86 | u(self.config.compressor_static_prefix), 87 | os.path.basename(cached_file), 88 | ) 89 | return self.render_element(filename, compression_type) 90 | 91 | assets = OrderedDict() 92 | soup = BeautifulSoup(html, PARSER) 93 | for count, c in enumerate(self.find_compilable_tags(soup)): 94 | 95 | url = c.get('src') or c.get('href') 96 | if url: 97 | filename = os.path.basename(u(url)).split('.', 1)[0] 98 | uri_cwd = os.path.join(u(self.config.compressor_static_prefix), os.path.dirname(u(url))) 99 | text = open(self.find_file(u(url)), 'r', encoding='utf-8') 100 | cwd = os.path.dirname(text.name) 101 | cache_key = u(url) if self.config.compressor_offline_compress else None 102 | else: 103 | filename = u('inline{0}').format(count) 104 | uri_cwd = None 105 | text = c.string 106 | cwd = None 107 | cache_key = None 108 | 109 | mimetype = c['type'].lower() 110 | try: 111 | compressor = self.config.compressor_classes[mimetype] 112 | except KeyError: 113 | msg = u('Unsupported type of compression {0}').format(mimetype) 114 | raise RuntimeError(msg) 115 | 116 | if not self.config.compressor_debug: 117 | outfile = cached_file 118 | else: 119 | outfile = os.path.join( 120 | u(self.config.compressor_output_dir), 121 | u('{hash}-{filename}.{extension}').format( 122 | hash=html_hash, 123 | filename=filename, 124 | extension=compression_type, 125 | ), 126 | ) 127 | 128 | if os.path.exists(outfile): 129 | assets[outfile] = None 130 | continue 131 | 132 | if cache_key and cache_key in self.offline_compress_cache: 133 | compressed = self.offline_compress_cache[cache_key] 134 | else: 135 | text = self.get_contents(text) 136 | compressed = compressor.compile(text, 137 | mimetype=mimetype, 138 | cwd=cwd, 139 | uri_cwd=uri_cwd, 140 | debug=self.config.compressor_debug) 141 | if cache_key: 142 | self.offline_compress_cache[cache_key] = compressed 143 | 144 | if not os.path.exists(outfile): 145 | if assets.get(outfile) is None: 146 | assets[outfile] = u('') 147 | assets[outfile] += u("\n") + compressed 148 | 149 | blocks = u('') 150 | for outfile, asset in assets.items(): 151 | if not os.path.exists(outfile): 152 | with open(outfile, 'w', encoding='utf-8') as fh: 153 | fh.write(asset) 154 | filename = os.path.join( 155 | u(self.config.compressor_static_prefix), 156 | os.path.basename(outfile), 157 | ) 158 | blocks += self.render_element(filename, compression_type) 159 | 160 | return blocks 161 | 162 | def make_hash(self, html): 163 | if self.config.compressor_offline_compress and html in self.offline_hash_cache: 164 | return self.offline_hash_cache[html] 165 | 166 | soup = BeautifulSoup(html, PARSER) 167 | compilables = self.find_compilable_tags(soup) 168 | html_hash = hashlib.md5(utf8_encode(html)) 169 | 170 | for c in compilables: 171 | url = c.get('src') or c.get('href') 172 | if url: 173 | with open(self.find_file(url), 'r', encoding='utf-8') as f: 174 | while True: 175 | content = f.read(1024) 176 | if content: 177 | html_hash.update(utf8_encode(content)) 178 | else: 179 | break 180 | 181 | digest = html_hash.hexdigest() 182 | if self.config.compressor_offline_compress: 183 | self.offline_hash_cache[html] = digest 184 | return digest 185 | 186 | def find_file(self, path): 187 | if callable(self.config.compressor_source_dirs): 188 | filename = self.config.compressor_source_dirs(path) 189 | if os.path.exists(filename): 190 | return filename 191 | else: 192 | if isinstance(self.config.compressor_source_dirs, basestring): 193 | dirs = [self.config.compressor_source_dirs] 194 | else: 195 | dirs = self.config.compressor_source_dirs 196 | 197 | for d in dirs: 198 | if self.config.compressor_static_prefix_precompress is not None and path.startswith('/'): 199 | path = path.replace(self.config.compressor_static_prefix_precompress, '', 1).\ 200 | lstrip(os.sep).lstrip('/') 201 | filename = os.path.join(d, path) 202 | if os.path.exists(filename): 203 | return filename 204 | 205 | raise IOError(2, u('File not found {0}').format(path)) 206 | 207 | def find_compilable_tags(self, soup): 208 | tags = ['link', 'style', 'script'] 209 | for tag in soup.find_all(tags): 210 | 211 | # don't compress externally hosted assets 212 | src = tag.get('src') or tag.get('href') 213 | if src and (src.startswith('http') or src.startswith('//')): 214 | continue 215 | 216 | if tag.get('type') is None: 217 | if tag.name == 'script': 218 | tag['type'] = 'text/javascript' 219 | if tag.name == 'style': 220 | tag['type'] = 'text/css' 221 | else: 222 | tag['type'] = tag['type'].lower() 223 | 224 | if tag.get('type') is None: 225 | raise RuntimeError(u('Tags to be compressed must have a type attribute: {0}').format(u(tag))) 226 | 227 | yield tag 228 | 229 | def get_contents(self, src): 230 | if isinstance(src, file): 231 | return u(src.read()) 232 | else: 233 | return u(src) 234 | 235 | def render_element(self, filename, type): 236 | """Returns an html element pointing to filename as a string. 237 | """ 238 | if type.lower() == 'css': 239 | return u('').format(filename) 240 | elif type.lower() == 'js': 241 | return u('').format(filename) 242 | else: 243 | raise RuntimeError(u('Unsupported type of compression {0}').format(type)) 244 | 245 | def get_configs_from_environment(self, environment): 246 | configs = {} 247 | for key in dir(environment): 248 | if key.startswith('compressor_'): 249 | configs[key] = getattr(environment, key) 250 | return configs 251 | 252 | def offline_compress(self, environment, template_dirs): 253 | 254 | if isinstance(template_dirs, basestring): 255 | template_dirs = [template_dirs] 256 | 257 | configs = self.get_configs_from_environment(environment) 258 | self.config.update(**configs) 259 | 260 | compressor_nodes = {} 261 | parser = Jinja2Parser(charset='utf-8', env=environment) 262 | for template_path in self.find_template_files(template_dirs): 263 | try: 264 | template = parser.parse(template_path) 265 | except IOError: # unreadable file -> ignore 266 | continue 267 | except TemplateSyntaxError: # broken template -> ignore 268 | continue 269 | except TemplateDoesNotExist: # non existent template -> ignore 270 | continue 271 | except UnicodeDecodeError: 272 | continue 273 | 274 | try: 275 | nodes = list(parser.walk_nodes(template)) 276 | except (TemplateDoesNotExist, TemplateSyntaxError): 277 | continue 278 | if nodes: 279 | template.template_name = template_path 280 | compressor_nodes.setdefault(template, []).extend(nodes) 281 | 282 | if not compressor_nodes: 283 | raise OfflineGenerationError( 284 | "No 'compress' template tags found in templates. " 285 | "Try setting follow_symlinks to True") 286 | 287 | for template, nodes in compressor_nodes.items(): 288 | for node in nodes: 289 | parser.render_node({}, node, globals=environment.globals) 290 | 291 | def find_template_files(self, template_dirs): 292 | templates = set() 293 | for d in template_dirs: 294 | for root, dirs, files in os.walk(d, followlinks=self.config.compressor_follow_symlinks): 295 | templates.update(os.path.join(root, name) for name in files if not name.startswith('.')) 296 | return templates 297 | -------------------------------------------------------------------------------- /jac/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import codecs 4 | import io 5 | import sys 6 | 7 | is_py2 = (sys.version_info[0] == 2) 8 | is_py3 = (sys.version_info[0] == 3) 9 | 10 | if is_py2: 11 | def u(text): 12 | if isinstance(text, str): 13 | return text.decode('utf-8') 14 | return unicode(text) # noqa 15 | 16 | def utf8_encode(text): 17 | if isinstance(text, unicode): # noqa 18 | return text.encode('utf-8') 19 | return text 20 | 21 | open = codecs.open 22 | basestring = basestring # noqa 23 | file = (file, codecs.Codec, codecs.StreamReaderWriter) # noqa 24 | 25 | elif is_py3: 26 | def u(text): 27 | if isinstance(text, bytes): 28 | return text.decode('utf-8') 29 | return str(text) 30 | 31 | def utf8_encode(text): 32 | if isinstance(text, str): 33 | return text.encode('utf-8') 34 | return text 35 | open = open 36 | basestring = (str, bytes) 37 | file = io.IOBase 38 | -------------------------------------------------------------------------------- /jac/compressors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaysonsantos/jinja-assets-compressor/88d9145c201272bc756d66d266deb20d3b72c0ee/jac/compressors/__init__.py -------------------------------------------------------------------------------- /jac/compressors/coffee.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import errno 4 | import subprocess 5 | 6 | from rjsmin import jsmin 7 | 8 | from jac.compat import file 9 | from jac.compat import u 10 | from jac.compat import utf8_encode 11 | from jac.exceptions import InvalidCompressorError 12 | 13 | 14 | class CoffeeScriptCompressor(object): 15 | """Builtin compressor text/coffeescript mimetype. 16 | 17 | Uses the coffee command line program to generate JavaScript, then 18 | uses rjsmin for minification. 19 | """ 20 | 21 | binary = 'coffee' 22 | extra_args = [] 23 | 24 | @classmethod 25 | def compile(cls, what, mimetype='text/coffeescript', cwd=None, uri_cwd=None, 26 | debug=None): 27 | args = ['--compile', '--stdio'] 28 | 29 | if cls.extra_args: 30 | args.extend(cls.extra_args) 31 | 32 | args.insert(0, cls.binary) 33 | 34 | try: 35 | handler = subprocess.Popen(args, stdout=subprocess.PIPE, 36 | stdin=subprocess.PIPE, 37 | stderr=subprocess.PIPE, cwd=None) 38 | except OSError as e: 39 | msg = '{0} encountered an error when executing {1}: {2}'.format( 40 | cls.__name__, 41 | cls.binary, 42 | u(e), 43 | ) 44 | if e.errno == errno.ENOENT: 45 | msg += ' Make sure {0} is in your PATH.'.format(cls.binary) 46 | raise InvalidCompressorError(msg) 47 | 48 | if isinstance(what, file): 49 | what = what.read() 50 | 51 | (stdout, stderr) = handler.communicate(input=utf8_encode(what)) 52 | stdout = u(stdout) 53 | 54 | if not debug: 55 | stdout = jsmin(stdout) 56 | 57 | if handler.returncode == 0: 58 | return stdout 59 | else: 60 | raise RuntimeError('Test this :S %s' % stderr) 61 | -------------------------------------------------------------------------------- /jac/compressors/javascript.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from rjsmin import jsmin 4 | 5 | 6 | class JavaScriptCompressor(object): 7 | """Builtin compressor for text/javascript mimetype. 8 | 9 | Uses the rjsmin for minification. 10 | """ 11 | 12 | @classmethod 13 | def compile(cls, what, mimetype='text/javascript', cwd=None, uri_cwd=None, 14 | debug=None): 15 | if debug: 16 | return what 17 | return jsmin(what) 18 | -------------------------------------------------------------------------------- /jac/compressors/less.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import errno 4 | import subprocess 5 | 6 | from jac.compat import file 7 | from jac.compat import u 8 | from jac.compat import utf8_encode 9 | from jac.exceptions import InvalidCompressorError 10 | 11 | 12 | class LessCompressor(object): 13 | """Builtin compressor for text/less and text/css mimetypes. 14 | 15 | Uses the lessc command line program for compression. 16 | """ 17 | 18 | binary = 'lessc' 19 | extra_args = [] 20 | 21 | @classmethod 22 | def compile(cls, what, mimetype='text/less', cwd=None, uri_cwd=None, 23 | debug=None): 24 | args = [] 25 | 26 | if not debug: 27 | args += ['--compress'] 28 | 29 | if cwd: 30 | args += ['-ru'] 31 | args += ['--include-path={}'.format(cwd)] 32 | 33 | if uri_cwd: 34 | if not uri_cwd.endswith('/'): 35 | uri_cwd += '/' 36 | args += ['--rootpath={}'.format(uri_cwd)] 37 | 38 | if cls.extra_args: 39 | args.extend(cls.extra_args) 40 | 41 | args += ['-'] 42 | 43 | args.insert(0, cls.binary) 44 | 45 | try: 46 | handler = subprocess.Popen(args, 47 | stdout=subprocess.PIPE, 48 | stdin=subprocess.PIPE, 49 | stderr=subprocess.PIPE, cwd=None) 50 | except OSError as e: 51 | msg = '{0} encountered an error when executing {1}: {2}'.format( 52 | cls.__name__, 53 | cls.binary, 54 | u(e), 55 | ) 56 | if e.errno == errno.ENOENT: 57 | msg += ' Make sure {0} is in your PATH.'.format(cls.binary) 58 | raise InvalidCompressorError(msg) 59 | 60 | if isinstance(what, file): 61 | what = what.read() 62 | (stdout, stderr) = handler.communicate(input=utf8_encode(what)) 63 | stdout = u(stdout) 64 | 65 | if handler.returncode == 0: 66 | return stdout 67 | else: 68 | raise RuntimeError('Test this :S %s' % stderr) 69 | -------------------------------------------------------------------------------- /jac/compressors/sass.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import errno 4 | import subprocess 5 | 6 | from jac.compat import file 7 | from jac.compat import u 8 | from jac.compat import utf8_encode 9 | from jac.exceptions import InvalidCompressorError 10 | 11 | 12 | class SassCompressor(object): 13 | """Builtin compressor for text/sass and text/scss mimetypes. 14 | 15 | Uses the sass command line program for compression. 16 | """ 17 | 18 | binary = 'sass' 19 | extra_args = [] 20 | 21 | @classmethod 22 | def compile(cls, what, mimetype='text/sass', cwd=None, uri_cwd=None, 23 | debug=None): 24 | args = ['-s'] 25 | if mimetype == 'text/scss': 26 | args.append('--scss') 27 | 28 | if cwd: 29 | args += ['-I', cwd] 30 | 31 | if cls.extra_args: 32 | args.extend(cls.extra_args) 33 | 34 | args.insert(0, cls.binary) 35 | 36 | try: 37 | handler = subprocess.Popen(args, stdout=subprocess.PIPE, stdin=subprocess.PIPE, 38 | stderr=subprocess.PIPE, cwd=None) 39 | except OSError as e: 40 | msg = '{0} encountered an error when executing {1}: {2}'.format( 41 | cls.__name__, 42 | cls.binary, 43 | u(e), 44 | ) 45 | if e.errno == errno.ENOENT: 46 | msg += ' Make sure {0} is in your PATH.'.format(cls.binary) 47 | raise InvalidCompressorError(msg) 48 | 49 | if isinstance(what, file): 50 | what = what.read() 51 | (stdout, stderr) = handler.communicate(input=utf8_encode(what)) 52 | stdout = u(stdout) 53 | 54 | if handler.returncode == 0: 55 | return stdout 56 | else: 57 | raise RuntimeError('Test this :S %s' % stderr) 58 | -------------------------------------------------------------------------------- /jac/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .compressors.coffee import CoffeeScriptCompressor 4 | from .compressors.javascript import JavaScriptCompressor 5 | from .compressors.less import LessCompressor 6 | from .compressors.sass import SassCompressor 7 | 8 | 9 | class Config(dict): 10 | 11 | _defaults = { 12 | 'compressor_enabled': True, 13 | 'compressor_offline_compress': False, 14 | 'compressor_cache_dir': '', 15 | 'compressor_follow_symlinks': False, 16 | 'compressor_debug': False, 17 | 'compressor_static_prefix': '/static/dist', 18 | 'compressor_source_dirs': None, 19 | 'compressor_static_prefix_precompress': '/static', 20 | 'compressor_output_dir': 'static/dist', 21 | 'compressor_ignore_blueprint_prefix': False, 22 | 'compressor_classes': { 23 | 'text/css': LessCompressor, 24 | 'text/coffeescript': CoffeeScriptCompressor, 25 | 'text/less': LessCompressor, 26 | 'text/javascript': JavaScriptCompressor, 27 | 'text/sass': SassCompressor, 28 | 'text/scss': SassCompressor, 29 | }, 30 | } 31 | 32 | def __init__(self, **kwargs): 33 | self.update(self._defaults) 34 | self.update(**kwargs) 35 | 36 | def __getattr__(self, key): 37 | return self[key] 38 | -------------------------------------------------------------------------------- /jac/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaysonsantos/jinja-assets-compressor/88d9145c201272bc756d66d266deb20d3b72c0ee/jac/contrib/__init__.py -------------------------------------------------------------------------------- /jac/contrib/flask.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | import os 6 | import shutil 7 | import sys 8 | 9 | from jinja2.utils import import_string 10 | 11 | from jac.compat import u 12 | from jac.compressors.coffee import CoffeeScriptCompressor 13 | from jac.compressors.javascript import JavaScriptCompressor 14 | from jac.compressors.less import LessCompressor 15 | from jac.compressors.sass import SassCompressor 16 | 17 | 18 | def static_finder(app): 19 | def find(path=None): 20 | if path is None: 21 | folders = set() 22 | for blueprint in app.blueprints.values(): 23 | if blueprint.static_folder is not None: 24 | folders.update([blueprint.static_folder]) 25 | folders.update([app.static_folder]) 26 | return folders 27 | else: 28 | for rule in app.url_map.iter_rules(): 29 | if '.' in rule.endpoint: 30 | with_blueprint = True 31 | blueprint_name = rule.endpoint.rsplit('.', 1)[0] 32 | if blueprint_name not in app.blueprints: 33 | continue 34 | blueprint = app.blueprints[blueprint_name] 35 | 36 | data = rule.match(u('{subdomain}|{path}').format( 37 | subdomain=blueprint.subdomain or '', 38 | path=path, 39 | )) 40 | else: 41 | with_blueprint = False 42 | data = rule.match(u('|{0}').format(path)) 43 | 44 | if data: 45 | static_folder = blueprint.static_folder \ 46 | if with_blueprint and blueprint.static_folder is not None \ 47 | else app.static_folder 48 | return os.path.join(static_folder, data['filename']) 49 | 50 | raise IOError(2, u('File not found {0}.').format(path)) 51 | 52 | return find 53 | 54 | 55 | def get_template_dirs(app): 56 | folders = set() 57 | for blueprint in app.blueprints.values(): 58 | if blueprint.template_folder is not None: 59 | folders.update([blueprint.template_folder]) 60 | folders.update([app.template_folder]) 61 | return folders 62 | 63 | 64 | class JAC(object): 65 | """Simple helper class for Jinja Assets Compressor. Has to be created in 66 | advance like a :class:`~flask.Flask` object. 67 | 68 | There are two usage modes which work very similar. One is binding 69 | the instance to a very specific Flask application:: 70 | 71 | app = Flask(__name__) 72 | jac = JAC(app) 73 | 74 | The second possibility is to create the object once and configure the 75 | application later to support it:: 76 | 77 | jac = JAC() 78 | 79 | def create_app(): 80 | app = Flask(__name__) 81 | jac.init_app(app) 82 | return app 83 | 84 | :param app: the application to register. 85 | """ 86 | 87 | def __init__(self, app=None): 88 | self.app = app 89 | if app is not None: 90 | self.init_app(app) 91 | 92 | def init_app(self, app): 93 | app.jinja_env.add_extension('jac.CompressorExtension') 94 | app.jinja_env.compressor_enabled = app.config.get('COMPRESSOR_ENABLED', True) 95 | app.jinja_env.compressor_offline_compress = app.config.get('COMPRESSOR_OFFLINE_COMPRESS', False) 96 | app.jinja_env.compressor_cache_dir = app.config.get('COMPRESSOR_CACHE_DIR', None) 97 | app.jinja_env.compressor_cache_dir = app.config.get('COMPRESSOR_CACHE_DIR') or \ 98 | app.config.get('COMPRESSOR_OUTPUT_DIR') or \ 99 | os.path.join(app.static_folder, 'sdist') 100 | app.jinja_env.compressor_follow_symlinks = app.config.get('COMPRESSOR_FOLLOW_SYMLINKS', False) 101 | app.jinja_env.compressor_debug = app.config.get('COMPRESSOR_DEBUG', False) 102 | app.jinja_env.compressor_output_dir = app.config.get('COMPRESSOR_OUTPUT_DIR') or \ 103 | os.path.join(app.static_folder, 'sdist') 104 | app.jinja_env.compressor_static_prefix = app.config.get('COMPRESSOR_STATIC_PREFIX') or \ 105 | app.static_url_path + '/sdist' 106 | app.jinja_env.compressor_classes = { 107 | 'text/css': LessCompressor, 108 | 'text/coffeescript': CoffeeScriptCompressor, 109 | 'text/less': LessCompressor, 110 | 'text/javascript': JavaScriptCompressor, 111 | 'text/sass': SassCompressor, 112 | 'text/scss': SassCompressor, 113 | } 114 | if isinstance(app.config.get('COMPRESSOR_CLASSES'), dict): 115 | app.jinja_env.compressor_classes.update(app.config.get('COMPRESSOR_CLASSES')) 116 | app.jinja_env.compressor_source_dirs = static_finder(app) 117 | self.app = app 118 | 119 | def set_compressor(self, mimetype, compressor_cls): 120 | if not hasattr(self, 'app') or self.app is None: 121 | raise RuntimeError('Must initialize JAC with a Flask app first.') 122 | self.app.jinja_env.compressor_classes[mimetype] = compressor_cls 123 | 124 | 125 | def offline_compile(app): 126 | env = app.jinja_env 127 | 128 | if os.path.exists(env.compressor_output_dir): 129 | print('Deleting previously compressed files in {output_dir}' 130 | .format(output_dir=env.compressor_output_dir)) 131 | shutil.rmtree(env.compressor_output_dir) 132 | else: 133 | print('No previous compressed files found in {output_dir}' 134 | .format(output_dir=env.compressor_output_dir)) 135 | 136 | template_dirs = [os.path.join(app.root_path, x) 137 | for x in get_template_dirs(app)] 138 | 139 | print('Compressing static assets into {output_dir}' 140 | .format(output_dir=env.compressor_output_dir)) 141 | compressor = env.extensions['jac.extension.CompressorExtension'].compressor 142 | compressor.offline_compress(env, template_dirs) 143 | 144 | print('Finished offline-compressing static assets.') 145 | return 0 146 | 147 | 148 | if __name__ == '__main__': 149 | if len(sys.argv) <= 1: 150 | print('You have to specify the app i.e. my_module:create_app or my_module:app', file=sys.stderr) 151 | exit(1) 152 | 153 | try: 154 | from flask import Flask 155 | except ImportError: 156 | print('Make sure you have flask installed to run this script\n\n', file=sys.stderr) 157 | raise 158 | 159 | app_factory = import_string(sys.argv[1]) 160 | app = app_factory() if callable(app_factory) and not isinstance(app_factory, Flask) else app_factory 161 | with app.app_context(): 162 | sys.exit(offline_compile(app)) 163 | -------------------------------------------------------------------------------- /jac/exceptions.py: -------------------------------------------------------------------------------- 1 | class JACException(Exception): 2 | """ 3 | Base exception class for all JAC related errors. 4 | """ 5 | pass 6 | 7 | 8 | class OfflineGenerationError(JACException): 9 | """ 10 | Offline compression generation related exceptions 11 | """ 12 | pass 13 | 14 | 15 | class TemplateDoesNotExist(JACException): 16 | """ 17 | This exception is raised when a template does not exist. 18 | """ 19 | pass 20 | 21 | 22 | class TemplateSyntaxError(JACException): 23 | """ 24 | This exception is raised when a template syntax error is encountered. 25 | """ 26 | pass 27 | 28 | 29 | class InvalidCompressorError(JACException): 30 | """ 31 | This exception is raised when a compressor is not setup correctly. 32 | """ 33 | pass 34 | -------------------------------------------------------------------------------- /jac/extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from jinja2 import nodes 6 | from jinja2.ext import Extension 7 | 8 | from jac.base import Compressor 9 | from jac.compat import u 10 | 11 | 12 | class CompressorExtension(Extension): 13 | tags = set(['compress']) 14 | 15 | def __init__(self, *args, **kwargs): 16 | super(CompressorExtension, self).__init__(*args, **kwargs) 17 | self.compressor = Compressor(environment=self.environment) 18 | 19 | def parse(self, parser): 20 | 21 | # update configs 22 | configs = self.compressor.get_configs_from_environment(self.environment) 23 | self.compressor.config.update(**configs) 24 | 25 | lineno = next(parser.stream).lineno 26 | args = [parser.parse_expression()] 27 | body = parser.parse_statements(['name:endcompress'], drop_needle=True) 28 | 29 | if len(body) > 1: 30 | raise RuntimeError('Template tags not supported inside compress blocks.') 31 | 32 | if hasattr(self.environment, 'compressor_offline_compress') and self.environment.compressor_offline_compress: 33 | return nodes.CallBlock(self.call_method('_display_block', args), [], [], body).set_lineno(lineno) 34 | else: 35 | return nodes.CallBlock(self.call_method('_compress_block', args), [], [], body).set_lineno(lineno) 36 | 37 | def _compress_block(self, compression_type, caller): 38 | html = caller() 39 | return self.compressor.compress(html, compression_type) 40 | 41 | def _display_block(self, compression_type, caller): 42 | html = caller() 43 | html_hash = self.compressor.make_hash(html) 44 | filename = os.path.join(u('{hash}.{extension}').format( 45 | hash=html_hash, 46 | extension=compression_type, 47 | )) 48 | static_prefix = u(self.compressor.config.compressor_static_prefix) 49 | return self.compressor.render_element(os.path.join(static_prefix, filename), compression_type) 50 | 51 | def set(self, key, val): 52 | self.compressor.config.set(key, val) 53 | -------------------------------------------------------------------------------- /jac/parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import io 4 | 5 | import jinja2 6 | import jinja2.ext 7 | from jinja2.nodes import Call 8 | from jinja2.nodes import CallBlock 9 | from jinja2.nodes import ExtensionAttribute 10 | 11 | from jac.exceptions import TemplateDoesNotExist 12 | from jac.exceptions import TemplateSyntaxError 13 | 14 | 15 | class Jinja2Parser(object): 16 | COMPRESSOR_ID = 'jac.extension.CompressorExtension' 17 | 18 | def __init__(self, charset, env): 19 | self.charset = charset 20 | self.env = env 21 | 22 | def parse(self, template_name): 23 | with io.open(template_name, mode='rb') as file: 24 | try: 25 | template = self.env.parse(file.read().decode(self.charset)) 26 | except jinja2.TemplateSyntaxError as e: 27 | raise TemplateSyntaxError(str(e)) 28 | except jinja2.TemplateNotFound as e: 29 | raise TemplateDoesNotExist(str(e)) 30 | 31 | return template 32 | 33 | def get_nodelist(self, node): 34 | body = getattr(node, "body", getattr(node, "nodes", [])) 35 | 36 | if isinstance(node, jinja2.nodes.If): 37 | return body + node.else_ 38 | 39 | return body 40 | 41 | def _render_nodes(self, context, nodes, globals=None): 42 | if not isinstance(globals, dict): 43 | globals = {} 44 | compiled_node = self.env.compile(jinja2.nodes.Template(nodes)) 45 | template = jinja2.Template.from_code(self.env, compiled_node, globals) 46 | try: 47 | rendered = template.render(context) 48 | except jinja2.exceptions.UndefinedError: 49 | raise RuntimeError('Only global variables are supported inside compress blocks.') 50 | return rendered 51 | 52 | def render_nodelist(self, context, node, globals=None): 53 | return self._render_nodes(context, node.body, globals=globals) 54 | 55 | def render_node(self, context, node, globals=None): 56 | return self._render_nodes(context, [node], globals=globals) 57 | 58 | def walk_nodes(self, node, block_name=None): 59 | for node in self.get_nodelist(node): 60 | if (isinstance(node, CallBlock) and 61 | isinstance(node.call, Call) and 62 | isinstance(node.call.node, ExtensionAttribute) and 63 | node.call.node.identifier == self.COMPRESSOR_ID): 64 | node.call.node.name = '_compress_block' 65 | yield node 66 | else: 67 | for node in self.walk_nodes(node, block_name=block_name): 68 | yield node 69 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2 2 | beautifulsoup4 3 | rjsmin 4 | ordereddict 5 | six 6 | -------------------------------------------------------------------------------- /requirements_tests.txt: -------------------------------------------------------------------------------- 1 | pytest~=6.0.1 2 | pytest-xdist~=2.0.0 3 | pytest-cov~=2.10.1 4 | flake8~=3.4 5 | flake8-isort~=4.0 6 | Flask==0.12.2 7 | -r requirements.txt 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 120 6 | exclude = .git,.tox 7 | 8 | [isort] 9 | force_single_line = True 10 | line_length = 120 11 | known_first_party = jac,tests 12 | default_section = THIRDPARTY 13 | not_skip = __init__.py 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages 4 | from setuptools import setup 5 | 6 | 7 | def read(filename): 8 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 9 | 10 | 11 | setup( 12 | name='jac', 13 | author='Jayson Reis', 14 | author_email='santosdosreis@gmail.com', 15 | version='0.18.0', 16 | packages=find_packages(exclude=('tests*', )), 17 | install_requires=open('requirements.txt').readlines(), 18 | description='A Jinja extension (compatible with Flask and other frameworks) ' 19 | 'to compile and/or compress your assets.', 20 | long_description='{0}\n\n{1}'.format(read('README.rst'), read('CHANGELOG.rst')), 21 | url='https://github.com/jaysonsantos/jinja-assets-compressor', 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaysonsantos/jinja-assets-compressor/88d9145c201272bc756d66d266deb20d3b72c0ee/tests/__init__.py -------------------------------------------------------------------------------- /tests/compilers/__init__.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from six import StringIO 5 | 6 | 7 | class BaseCompilerTest: 8 | subprocess_package = None 9 | 10 | @pytest.fixture 11 | def stdin(self): 12 | return StringIO.StringIO() 13 | 14 | stdout = stdin 15 | 16 | @pytest.fixture 17 | def subprocess(self): 18 | return mock.patch(self.subprocess_package) 19 | -------------------------------------------------------------------------------- /tests/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaysonsantos/jinja-assets-compressor/88d9145c201272bc756d66d266deb20d3b72c0ee/tests/contrib/__init__.py -------------------------------------------------------------------------------- /tests/contrib/test_flask.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from unittest import mock 4 | 5 | import pytest 6 | from flask import Flask 7 | 8 | from jac.contrib.flask import JAC 9 | from jac.contrib.flask import static_finder 10 | from tests.helpers import TempDir 11 | 12 | 13 | @pytest.fixture 14 | def mocked_flask_app(): 15 | return mock.Mock() 16 | 17 | 18 | def test_flask_extension_init_self_app(mocked_flask_app): 19 | ext = JAC(mocked_flask_app) 20 | assert ext.app is mocked_flask_app 21 | 22 | 23 | def test_flask_extension_lazy_init(mocked_flask_app): 24 | """Make sure we save then app when initializing Flask with app factory.""" 25 | ext = JAC() 26 | assert ext.app is None 27 | ext.init_app(mocked_flask_app) 28 | assert ext.app == mocked_flask_app 29 | 30 | 31 | def test_flask_extension_jinja_env_add_extension(mocked_flask_app): 32 | ext = JAC() 33 | ext.init_app(mocked_flask_app) 34 | mocked_flask_app.jinja_env.add_extension.assert_called_once_with('jac.CompressorExtension') 35 | 36 | 37 | def test_flask_extension_jinja_env_compressor_output_dir(mocked_flask_app): 38 | mocked_flask_app.static_folder = '/static/folder' 39 | mocked_flask_app.static_url_path = '/static' 40 | mocked_flask_app.config = dict() 41 | 42 | ext = JAC() 43 | ext.init_app(mocked_flask_app) 44 | assert mocked_flask_app.jinja_env.compressor_output_dir == '/static/folder/sdist' 45 | assert mocked_flask_app.jinja_env.compressor_static_prefix == '/static/sdist' 46 | 47 | 48 | def test_flask_extension_jinja_env_static_prefix(mocked_flask_app): 49 | mocked_flask_app.static_folder = '/static/folder' 50 | mocked_flask_app.static_url_path = '/static-url' 51 | mocked_flask_app.config = dict() 52 | 53 | ext = JAC() 54 | ext.init_app(mocked_flask_app) 55 | assert mocked_flask_app.jinja_env.compressor_output_dir == '/static/folder/sdist' 56 | assert mocked_flask_app.jinja_env.compressor_static_prefix == '/static-url/sdist' 57 | 58 | 59 | def test_flask_extension_jinja_env_source_dirs(mocked_flask_app): 60 | ext = JAC() 61 | ext.init_app(mocked_flask_app) 62 | mocked_flask_app.jinja_env.compressor_source_dirs == static_finder(mocked_flask_app) 63 | 64 | 65 | def test_flask_extension_find_static(): 66 | app = Flask(__name__) 67 | 68 | # Avoid breaking static_finder when an url is registered with an endpoint which does not match with the blueprint 69 | app.add_url_rule('/some/url', 'wrong.blueprint_url') 70 | 71 | JAC(app) 72 | find = static_finder(app) 73 | 74 | with TempDir.with_context() as temp_dir: 75 | static_folder = temp_dir.name 76 | app.static_folder = static_folder 77 | static_file = os.path.join(static_folder, 'some.css') 78 | with open(static_file, 'w') as f: 79 | f.write('html {}') 80 | # This should be findable even if some urls' endpoints use broken names 81 | assert find('/static/some.css') == static_file 82 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import shutil 3 | import tempfile 4 | 5 | 6 | class TempDir(object): 7 | """Fancy tempdir with context to also support old python versions.""" 8 | 9 | def __init__(self): 10 | self.name = tempfile.mkdtemp() 11 | 12 | def close(self): 13 | shutil.rmtree(self.name) 14 | 15 | @classmethod 16 | def with_context(cls): 17 | return contextlib.closing(cls()) 18 | -------------------------------------------------------------------------------- /tests/test_compiling.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jac.compressors.coffee import CoffeeScriptCompressor 4 | from jac.compressors.javascript import JavaScriptCompressor 5 | from jac.compressors.less import LessCompressor 6 | from jac.compressors.sass import SassCompressor 7 | 8 | 9 | class TestSass: 10 | @pytest.fixture 11 | def sample_sass(self): 12 | """ 13 | Returns a simple SAAS script for testing 14 | """ 15 | 16 | return '''$blue: #3bbfce 17 | $margin: 16px 18 | 19 | .content-navigation 20 | border-color: $blue 21 | color: darken($blue, 9%) 22 | 23 | .border 24 | padding: $margin / 2 25 | margin: $margin / 2 26 | border-color: $blue''' 27 | 28 | def test_compiling(self, sample_sass): 29 | compiled_css = """.content-navigation { 30 | border-color: #3bbfce; 31 | color: #2ca2af; } 32 | 33 | .border { 34 | padding: 8px; 35 | margin: 8px; 36 | border-color: #3bbfce; } 37 | """ 38 | 39 | assert SassCompressor.compile(sample_sass, 'text/sass') == compiled_css 40 | 41 | 42 | class TestLess: 43 | @pytest.fixture 44 | def sample_less(self): 45 | """ 46 | Returns a simple LESS script for testing 47 | """ 48 | 49 | return ''' 50 | body { 51 | .test-class { 52 | color: #fff; 53 | } 54 | } 55 | ''' 56 | 57 | def test_compiling(self, sample_less): 58 | compiled_css = 'body .test-class{color:#fff}' 59 | assert LessCompressor.compile(sample_less, 'text/less') == compiled_css 60 | 61 | 62 | class TestCoffee: 63 | @pytest.fixture 64 | def sample_coffee(self): 65 | """ 66 | Returns a simple coffee script for testing 67 | """ 68 | 69 | return ''' 70 | foo = (str) -> 71 | alert str 72 | true 73 | 74 | foo "Hello CoffeeScript!" 75 | ''' 76 | 77 | def test_compiling(self, sample_coffee): 78 | compiled_js = '\ 79 | (function(){var foo;foo=function(str){alert(str);return true;};\ 80 | foo("Hello CoffeeScript!");}).call(this);' 81 | assert CoffeeScriptCompressor.compile(sample_coffee, 'text/coffeescript') == compiled_js 82 | 83 | 84 | class TestJavaScript: 85 | @pytest.fixture 86 | def sample_javascript(self): 87 | """ 88 | Returns some simple JavaScript for testing 89 | """ 90 | 91 | return ''' 92 | var foo = function(str) { 93 | alert(str); 94 | return true; 95 | }; 96 | 97 | foo("Hello CoffeeScript!"); 98 | ''' 99 | 100 | def test_compiling(self, sample_javascript): 101 | compiled_js = 'var foo=function(str){alert(str);return true;};foo("Hello CoffeeScript!");' 102 | assert JavaScriptCompressor.compile(sample_javascript, 'text/javascript') == compiled_js 103 | -------------------------------------------------------------------------------- /tests/test_extensions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import hashlib 4 | import os 5 | from unittest import mock 6 | 7 | import jinja2 8 | import pytest 9 | 10 | from jac.compat import open 11 | from jac.compat import utf8_encode 12 | 13 | 14 | class TestCompression: 15 | @pytest.fixture 16 | def env(self, tmpdir): 17 | from jac import CompressorExtension 18 | env = jinja2.Environment(extensions=[CompressorExtension]) 19 | env.compressor_output_dir = tmpdir 20 | env.compressor_static_prefix = '/static/dist' 21 | return env 22 | 23 | @pytest.fixture 24 | def html_css(self): 25 | return ''' 38 | 43 | ''' 44 | 45 | @pytest.fixture 46 | def html_js(self): 47 | return '''''' 50 | 51 | @pytest.fixture 52 | def html_template(self, html_css): 53 | return '{% compress "css" %}' + html_css + '{% endcompress %}' 54 | 55 | def test_render(self, env, html_template): 56 | template = env.from_string(html_template) 57 | expected = '' 58 | 59 | assert expected == template.render() 60 | 61 | def test_compile(self, tmpdir, html_css): 62 | from jac import CompressorExtension 63 | ext = CompressorExtension(mock.Mock(compressor_output_dir=tmpdir, 64 | compressor_static_prefix='/static', compressor_source_dirs=[])) 65 | 66 | assert ext._compress_block('css', mock.Mock(return_value=html_css)) == \ 67 | '' 68 | 69 | def test_compile_js(self, tmpdir, html_js): 70 | from jac import CompressorExtension 71 | ext = CompressorExtension(mock.Mock(compressor_output_dir=tmpdir, compressor_static_prefix='/static', 72 | compressor_source_dirs=[])) 73 | 74 | assert ext._compress_block('js', mock.Mock(return_value=html_js)) == \ 75 | '' 76 | 77 | def test_compile_file(self, tmpdir): 78 | from jac import CompressorExtension 79 | ext = CompressorExtension(mock.Mock(compressor_output_dir=tmpdir, compressor_static_prefix='/static', 80 | compressor_source_dirs=[str(tmpdir)])) 81 | static_file = os.path.join(str(tmpdir), 'test.sass') 82 | 83 | with open(static_file, 'w', encoding='utf-8') as f: 84 | f.write('''$blue: #3bbfce 85 | $margin: 16px 86 | 87 | .content-navigation 88 | border-color: $blue 89 | color: darken($blue, 9%) 90 | 91 | .border 92 | padding: $margin / 2 93 | margin: $margin / 2 94 | border-color: $blue''') 95 | 96 | html = '' 97 | expected_hash = hashlib.md5(utf8_encode(html)) 98 | with open(static_file) as f: 99 | expected_hash.update(utf8_encode(f.read())) 100 | 101 | assert ext._compress_block('css', mock.Mock(return_value=html)) == \ 102 | ''.format(expected_hash.hexdigest()) 103 | 104 | def test_offline_compress(self, env): 105 | from jac import Compressor 106 | 107 | tmpdir = str(env.compressor_output_dir) 108 | 109 | env.compressor_offline_compress = True 110 | env.compressor_source_dirs = [os.path.join(tmpdir, 'static')] 111 | env.compressor_output_dir = os.path.join(tmpdir, 'dist') 112 | 113 | compressor = Compressor(environment=env) 114 | css = '' 115 | 116 | os.makedirs(os.path.join(tmpdir, 'templates')) 117 | with open(os.path.join(tmpdir, 'templates', 'test.html'), 'w') as fh: 118 | fh.write('{% compress "css" %}' + css + '{% endcompress %}') 119 | 120 | os.makedirs(os.path.join(tmpdir, 'static')) 121 | with open(os.path.join(tmpdir, 'static', 'test.css'), 'w') as fh: 122 | fh.write('html { display: block; }') 123 | 124 | compressor.offline_compress(env, [os.path.join(tmpdir, 'templates')]) 125 | 126 | assert os.path.exists(env.compressor_output_dir) is True 127 | 128 | def test_offline_compress_with_cache(self, env): 129 | from jac import Compressor 130 | 131 | tmpdir = str(env.compressor_output_dir) 132 | 133 | env.compressor_offline_compress = True 134 | env.compressor_source_dirs = [os.path.join(tmpdir, 'static')] 135 | env.compressor_output_dir = os.path.join(tmpdir, 'dist') 136 | env.compressor_cache_dir = os.path.join(tmpdir, 'cache') 137 | 138 | compressor = Compressor(environment=env) 139 | css = '' 140 | 141 | os.makedirs(os.path.join(tmpdir, 'templates')) 142 | with open(os.path.join(tmpdir, 'templates', 'test.html'), 'w') as fh: 143 | fh.write('{% compress "css" %}' + css + '{% endcompress %}') 144 | 145 | os.makedirs(os.path.join(tmpdir, 'static')) 146 | with open(os.path.join(tmpdir, 'static', 'test.css'), 'w') as fh: 147 | fh.write('html { display: block; }') 148 | 149 | compressor.offline_compress(env, [os.path.join(tmpdir, 'templates')]) 150 | 151 | assert os.path.exists(env.compressor_output_dir) is True 152 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38 3 | [testenv] 4 | deps = 5 | -rrequirements_tests.txt 6 | commands = 7 | ./install-dependencies.sh 8 | make coverage 9 | make lint 10 | --------------------------------------------------------------------------------