├── compressor ├── tests │ ├── __init__.py │ ├── static │ │ ├── js │ │ │ ├── one.js │ │ │ ├── nonasc.js │ │ │ ├── one.coffee │ │ │ └── nonasc-latin1.js │ │ ├── css │ │ │ ├── two.css │ │ │ ├── one.css │ │ │ ├── nonasc.css │ │ │ ├── url │ │ │ │ ├── 2 │ │ │ │ │ └── url2.css │ │ │ │ ├── test.css │ │ │ │ ├── nonasc.css │ │ │ │ └── url1.css │ │ │ └── datauri.css │ │ └── img │ │ │ ├── add.png │ │ │ └── python.png │ ├── test_templates │ │ ├── test_block_super_multiple │ │ │ ├── base2.html │ │ │ ├── test_compressor_offline.html │ │ │ └── base.html │ │ ├── test_block_super_multiple_cached │ │ │ ├── base2.html │ │ │ ├── test_compressor_offline.html │ │ │ └── base.html │ │ ├── test_error_handling │ │ │ ├── with_coffeescript.html │ │ │ ├── buggy_template.html │ │ │ ├── missing_extends.html │ │ │ ├── buggy_extends.html │ │ │ └── test_compressor_offline.html │ │ ├── basic │ │ │ └── test_compressor_offline.html │ │ ├── test_templatetag │ │ │ └── test_compressor_offline.html │ │ ├── test_with_context │ │ │ └── test_compressor_offline.html │ │ ├── test_inline_non_ascii │ │ │ └── test_compressor_offline.html │ │ ├── test_condition │ │ │ └── test_compressor_offline.html │ │ ├── test_static_templatetag │ │ │ └── test_compressor_offline.html │ │ ├── test_block_super │ │ │ ├── base.html │ │ │ └── test_compressor_offline.html │ │ ├── test_block_super_extra │ │ │ ├── base.html │ │ │ └── test_compressor_offline.html │ │ └── test_complex │ │ │ └── test_compressor_offline.html │ ├── test_templates_jinja2 │ │ ├── test_block_super_multiple │ │ │ ├── base2.html │ │ │ ├── test_compressor_offline.html │ │ │ └── base.html │ │ ├── test_block_super_multiple_cached │ │ │ ├── base2.html │ │ │ ├── test_compressor_offline.html │ │ │ └── base.html │ │ ├── test_error_handling │ │ │ ├── with_coffeescript.html │ │ │ ├── buggy_template.html │ │ │ ├── missing_extends.html │ │ │ ├── buggy_extends.html │ │ │ └── test_compressor_offline.html │ │ ├── basic │ │ │ └── test_compressor_offline.html │ │ ├── test_templatetag │ │ │ └── test_compressor_offline.html │ │ ├── test_with_context │ │ │ └── test_compressor_offline.html │ │ ├── test_inline_non_ascii │ │ │ └── test_compressor_offline.html │ │ ├── test_condition │ │ │ └── test_compressor_offline.html │ │ ├── test_static_templatetag │ │ │ └── test_compressor_offline.html │ │ ├── test_block_super │ │ │ ├── test_compressor_offline.html │ │ │ └── base.html │ │ ├── test_block_super_extra │ │ │ ├── base.html │ │ │ └── test_compressor_offline.html │ │ ├── test_coffin │ │ │ └── test_compressor_offline.html │ │ ├── test_jingo │ │ │ └── test_compressor_offline.html │ │ └── test_complex │ │ │ └── test_compressor_offline.html │ ├── precompiler.py │ ├── test_storages.py │ ├── test_signals.py │ ├── test_parsers.py │ └── test_jinja2ext.py ├── contrib │ ├── __init__.py │ ├── sekizai.py │ └── jinja2ext.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── mtime_cache.py ├── offline │ ├── __init__.py │ ├── jinja2.py │ └── django.py ├── templatetags │ ├── __init__.py │ └── compress.py ├── __init__.py ├── models.py ├── templates │ └── compressor │ │ ├── js_file.html │ │ ├── js_inline.html │ │ ├── css_inline.html │ │ └── css_file.html ├── signals.py ├── filters │ ├── __init__.py │ ├── jsmin │ │ ├── slimit.py │ │ └── __init__.py │ ├── csstidy.py │ ├── closure.py │ ├── template.py │ ├── cssmin │ │ ├── __init__.py │ │ └── cssmin.py │ ├── yuglify.py │ ├── yui.py │ ├── datauri.py │ ├── css_default.py │ └── base.py ├── finders.py ├── test_settings.py ├── utils │ ├── staticfiles.py │ ├── decorators.py │ └── __init__.py ├── js.py ├── exceptions.py ├── parser │ ├── base.py │ ├── __init__.py │ ├── beautifulsoup.py │ ├── html5lib.py │ ├── default_htmlparser.py │ └── lxml.py ├── css.py ├── storage.py ├── conf.py └── cache.py ├── requirements └── tests.txt ├── .gitignore ├── MANIFEST.in ├── Makefile ├── .travis.yml ├── docs ├── django-sekizai.txt ├── index.txt ├── behind-the-scenes.txt ├── scenarios.txt ├── quickstart.txt ├── remote-storages.txt ├── Makefile ├── make.bat ├── jinja2.txt ├── contributing.txt ├── conf.py └── usage.txt ├── AUTHORS ├── tox.ini ├── README.rst ├── setup.py └── LICENSE /compressor/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/offline/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/tests/static/js/one.js: -------------------------------------------------------------------------------- 1 | obj = {}; -------------------------------------------------------------------------------- /compressor/tests/static/css/two.css: -------------------------------------------------------------------------------- 1 | body { color:#fff; } -------------------------------------------------------------------------------- /compressor/tests/static/css/one.css: -------------------------------------------------------------------------------- 1 | body { background:#990; } -------------------------------------------------------------------------------- /compressor/tests/static/js/nonasc.js: -------------------------------------------------------------------------------- 1 | var test_value = "—"; 2 | -------------------------------------------------------------------------------- /compressor/tests/static/js/one.coffee: -------------------------------------------------------------------------------- 1 | # this is a comment. 2 | -------------------------------------------------------------------------------- /compressor/__init__.py: -------------------------------------------------------------------------------- 1 | # following PEP 386 2 | __version__ = "1.4a1" 3 | -------------------------------------------------------------------------------- /compressor/tests/static/css/nonasc.css: -------------------------------------------------------------------------------- 1 | .byline:before { content: " — "; } -------------------------------------------------------------------------------- /compressor/models.py: -------------------------------------------------------------------------------- 1 | from compressor.conf import CompressorConf # noqa 2 | -------------------------------------------------------------------------------- /compressor/tests/static/css/url/test.css: -------------------------------------------------------------------------------- 1 | p { background: url('/static/images/image.gif') } -------------------------------------------------------------------------------- /compressor/templates/compressor/js_file.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/templates/compressor/js_inline.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/tests/static/css/url/nonasc.css: -------------------------------------------------------------------------------- 1 | p { background: url( '../../images/test.png' ); } 2 | .byline:before { content: " — "; } -------------------------------------------------------------------------------- /compressor/tests/static/img/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikebryant/django-compressor/develop/compressor/tests/static/img/add.png -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_multiple/base2.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block css %}{% endblock %} 4 | -------------------------------------------------------------------------------- /compressor/tests/static/img/python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikebryant/django-compressor/develop/compressor/tests/static/img/python.png -------------------------------------------------------------------------------- /compressor/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | 4 | post_compress = django.dispatch.Signal(providing_args=['type', 'mode', 'context']) 5 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_multiple_cached/base2.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block css %}{% endblock %} 4 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_multiple/base2.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block css %}{% endblock %} 4 | -------------------------------------------------------------------------------- /compressor/tests/static/js/nonasc-latin1.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikebryant/django-compressor/develop/compressor/tests/static/js/nonasc-latin1.js -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_multiple_cached/base2.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block css %}{% endblock %} 4 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | coverage 3 | html5lib 4 | mock 5 | jinja2 6 | lxml 7 | BeautifulSoup 8 | unittest2 9 | coffin 10 | jingo 11 | -------------------------------------------------------------------------------- /compressor/templates/compressor/css_inline.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/templates/compressor/css_file.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compressor/filters/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from compressor.filters.base import (FilterBase, CallbackOutputFilter, 3 | CompilerFilter, FilterError) 4 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_error_handling/with_coffeescript.html: -------------------------------------------------------------------------------- 1 | {% compress js %} 2 | 5 | {% endcompress %} 6 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_error_handling/with_coffeescript.html: -------------------------------------------------------------------------------- 1 | {% load compress %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/basic/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %} 8 | {% endspaceless %} 9 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/basic/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress %}{% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %} 8 | {% endspaceless %} 9 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_error_handling/buggy_template.html: -------------------------------------------------------------------------------- 1 | {% compress css %} 2 | 7 | {% endcompress %} 8 | 9 | 10 | {% fail %} 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_templatetag/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_with_context/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_error_handling/missing_extends.html: -------------------------------------------------------------------------------- 1 | {% extends "missing.html" %} 2 | 3 | {% compress css %} 4 | 9 | {% endcompress %} 10 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_templatetag/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress %}{% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_with_context/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress %}{% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_error_handling/buggy_extends.html: -------------------------------------------------------------------------------- 1 | {% extends "buggy_template.html" %} 2 | 3 | {% compress css %} 4 | 9 | {% endcompress %} 10 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_inline_non_ascii/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% compress js, inline %} 4 | 7 | {% endcompress %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_error_handling/buggy_template.html: -------------------------------------------------------------------------------- 1 | {% load compress %} 2 | 3 | {% compress css %} 4 | 9 | {% endcompress %} 10 | 11 | 12 | {% fail %} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | compressor/tests/static/CACHE 3 | compressor/tests/static/custom 4 | compressor/tests/static/js/066cd253eada.js 5 | compressor/tests/static/test.txt* 6 | 7 | dist 8 | MANIFEST 9 | *.pyc 10 | *.egg-info 11 | *.egg 12 | docs/_build/ 13 | .sass-cache 14 | .coverage 15 | .tox 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_error_handling/missing_extends.html: -------------------------------------------------------------------------------- 1 | {% extends "missing.html" %} 2 | {% load compress %} 3 | 4 | {% compress css %} 5 | 10 | {% endcompress %} 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_inline_non_ascii/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress %}{% spaceless %} 2 | 3 | {% compress js inline %} 4 | 7 | {% endcompress %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_error_handling/buggy_extends.html: -------------------------------------------------------------------------------- 1 | {% extends "buggy_template.html" %} 2 | {% load compress %} 3 | 4 | {% compress css %} 5 | 10 | {% endcompress %} 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_condition/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% if condition %} 4 | {% compress js%} 5 | 6 | {% endcompress %} 7 | {% endif %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_condition/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress %}{% spaceless %} 2 | 3 | {% if condition %} 4 | {% compress js%} 5 | 6 | {% endcompress %} 7 | {% endif %}{% endspaceless %} 8 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_static_templatetag/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress static %}{% spaceless %} 2 | 3 | {% compress js %} 4 | 5 | 6 | {% endcompress %}{% endspaceless %} 7 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_error_handling/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %} 8 | {% endspaceless %} 9 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_static_templatetag/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% compress js %} 4 | 5 | 6 | {% endcompress %}{% endspaceless %} 7 | -------------------------------------------------------------------------------- /compressor/filters/jsmin/slimit.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from compressor.filters import CallbackOutputFilter 3 | 4 | 5 | class SlimItFilter(CallbackOutputFilter): 6 | dependencies = ["slimit"] 7 | callback = "slimit.minify" 8 | kwargs = { 9 | "mangle": True, 10 | } 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_error_handling/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress %}{% spaceless %} 2 | 3 | {% compress js %} 4 | 7 | {% endcompress %} 8 | {% endspaceless %} 9 | -------------------------------------------------------------------------------- /compressor/tests/static/css/url/url1.css: -------------------------------------------------------------------------------- 1 | p { background: url('../../img/python.png'); } 2 | p { background: url(../../img/python.png); } 3 | p { background: url( ../../img/python.png ); } 4 | p { background: url( '../../img/python.png' ); } 5 | p { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../../img/python.png'); } 6 | -------------------------------------------------------------------------------- /compressor/tests/static/css/url/2/url2.css: -------------------------------------------------------------------------------- 1 | p { background: url('../../../img/add.png'); } 2 | p { background: url(../../../img/add.png); } 3 | p { background: url( ../../../img/add.png ); } 4 | p { background: url( '../../../img/add.png' ); } 5 | p { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='../../../img/add.png'); } 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include README.rst 3 | include LICENSE 4 | include Makefile 5 | include tox.ini 6 | recursive-include docs * 7 | recursive-include requirements * 8 | recursive-include compressor/templates/compressor *.html 9 | recursive-include compressor/tests/media *.js *.css *.png *.coffee 10 | recursive-include compressor/tests/test_templates *.html 11 | -------------------------------------------------------------------------------- /compressor/filters/csstidy.py: -------------------------------------------------------------------------------- 1 | from compressor.conf import settings 2 | from compressor.filters import CompilerFilter 3 | 4 | 5 | class CSSTidyFilter(CompilerFilter): 6 | command = "{binary} {infile} {args} {outfile}" 7 | options = ( 8 | ("binary", settings.COMPRESS_CSSTIDY_BINARY), 9 | ("args", settings.COMPRESS_CSSTIDY_ARGUMENTS), 10 | ) 11 | -------------------------------------------------------------------------------- /compressor/filters/closure.py: -------------------------------------------------------------------------------- 1 | from compressor.conf import settings 2 | from compressor.filters import CompilerFilter 3 | 4 | 5 | class ClosureCompilerFilter(CompilerFilter): 6 | command = "{binary} {args}" 7 | options = ( 8 | ("binary", settings.COMPRESS_CLOSURE_COMPILER_BINARY), 9 | ("args", settings.COMPRESS_CLOSURE_COMPILER_ARGUMENTS), 10 | ) 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_multiple/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base2.html" %} 2 | 3 | {% block js %}{% spaceless %} 4 | {% compress js %} 5 | {{ super() }} 6 | 9 | {% endcompress %} 10 | {% endspaceless %}{% endblock %} 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_multiple_cached/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base2.html" %} 2 | 3 | {% block js %}{% spaceless %} 4 | {% compress js %} 5 | {{ super() }} 6 | 9 | {% endcompress %} 10 | {% endspaceless %}{% endblock %} 11 | -------------------------------------------------------------------------------- /compressor/filters/jsmin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from compressor.filters import CallbackOutputFilter 3 | from compressor.filters.jsmin.slimit import SlimItFilter # noqa 4 | 5 | 6 | class rJSMinFilter(CallbackOutputFilter): 7 | callback = "compressor.filters.jsmin.rjsmin.jsmin" 8 | 9 | # This is for backwards compatibility 10 | JSMinFilter = rJSMinFilter 11 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_multiple/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base2.html" %} 2 | {% load compress %} 3 | 4 | {% block js %}{% spaceless %} 5 | {% compress js %} 6 | {{ block.super }} 7 | 10 | {% endcompress %} 11 | {% endspaceless %}{% endblock %} 12 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_extra/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_multiple_cached/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base2.html" %} 2 | {% load compress %} 3 | 4 | {% block js %}{% spaceless %} 5 | {% compress js %} 6 | {{ block.super }} 7 | 10 | {% endcompress %} 11 | {% endspaceless %}{% endblock %} 12 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block js %}{% spaceless %} 4 | {% compress js %} 5 | {{ super() }} 6 | 9 | {% endcompress %} 10 | {% endspaceless %}{% endblock %} 11 | 12 | {% block css %}{% endblock %} 13 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_extra/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load compress %} 3 | 4 | {% block js %}{% spaceless %} 5 | {% compress js %} 6 | {{ block.super }} 7 | 10 | {% endcompress %} 11 | {% endspaceless %}{% endblock %} 12 | 13 | {% block css %}{% endblock %} 14 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_multiple/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_multiple_cached/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_multiple/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /compressor/filters/template.py: -------------------------------------------------------------------------------- 1 | from django.template import Template, Context 2 | from django.conf import settings 3 | 4 | from compressor.filters import FilterBase 5 | 6 | 7 | class TemplateFilter(FilterBase): 8 | 9 | def input(self, filename=None, basename=None, **kwargs): 10 | template = Template(self.content) 11 | context = Context(settings.COMPRESS_TEMPLATE_FILTER_CONTEXT) 12 | return template.render(context) 13 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_multiple_cached/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% block js %} 3 | 6 | {% endblock %} 7 | 8 | {% block css %} 9 | 14 | {% endblock %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | testenv: 2 | pip install -e . 3 | pip install -r requirements/tests.txt 4 | pip install Django 5 | 6 | test: 7 | flake8 compressor --ignore=E501,E128,E701,E261,E301,E126,E127,E131 8 | coverage run --branch --source=compressor `which django-admin.py` test --settings=compressor.test_settings compressor 9 | coverage report --omit=compressor/test*,compressor/filters/jsmin/rjsmin*,compressor/filters/cssmin/cssmin*,compressor/utils/stringformat* 10 | 11 | .PHONY: test 12 | -------------------------------------------------------------------------------- /compressor/filters/cssmin/__init__.py: -------------------------------------------------------------------------------- 1 | from compressor.filters import CallbackOutputFilter 2 | 3 | 4 | class CSSMinFilter(CallbackOutputFilter): 5 | """ 6 | A filter that utilizes Zachary Voase's Python port of 7 | the YUI CSS compression algorithm: http://pypi.python.org/pypi/cssmin/ 8 | """ 9 | callback = "compressor.filters.cssmin.cssmin.cssmin" 10 | 11 | 12 | class rCSSMinFilter(CallbackOutputFilter): 13 | callback = "compressor.filters.cssmin.rcssmin.cssmin" 14 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_coffin/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {%- load compress -%} 2 | {% spaceless %} 3 | {% compress js%} 4 | 7 | {% with "js/one.js" as name -%} 8 | 9 | {%- endwith %} 10 | {% endcompress %} 11 | {% endspaceless %} 12 | -------------------------------------------------------------------------------- /compressor/finders.py: -------------------------------------------------------------------------------- 1 | from compressor.utils import staticfiles 2 | from compressor.storage import CompressorFileStorage 3 | 4 | 5 | class CompressorFinder(staticfiles.finders.BaseStorageFinder): 6 | """ 7 | A staticfiles finder that looks in COMPRESS_ROOT 8 | for compressed files, to be used during development 9 | with staticfiles development file server or during 10 | deployment. 11 | """ 12 | storage = CompressorFileStorage 13 | 14 | def list(self, ignore_patterns): 15 | return [] 16 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_jingo/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% compress js%} 3 | 7 | {% with name="js/one.js" -%} 8 | 9 | {%- endwith %} 10 | {% endcompress %} 11 | {% endspaceless %} 12 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_block_super_extra/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block js %}{% spaceless %} 4 | {% compress js %} 5 | 8 | {% endcompress %} 9 | 10 | {% compress js %} 11 | {{ super() }} 12 | 15 | {% endcompress %} 16 | {% endspaceless %}{% endblock %} 17 | 18 | {% block css %}{% endblock %} 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | before_install: 3 | - sudo apt-get update 4 | - sudo apt-get install csstidy libxml2-dev libxslt-dev 5 | install: 6 | - pip install tox coveralls 7 | script: 8 | - tox 9 | env: 10 | - TOXENV=py33-1.6.X 11 | - TOXENV=py32-1.6.X 12 | - TOXENV=py27-1.6.X 13 | - TOXENV=py26-1.6.X 14 | - TOXENV=py33-1.5.X 15 | - TOXENV=py32-1.5.X 16 | - TOXENV=py27-1.5.X 17 | - TOXENV=py26-1.5.X 18 | - TOXENV=py27-1.4.X 19 | - TOXENV=py26-1.4.X 20 | notifications: 21 | irc: "irc.freenode.org#django-compressor" 22 | after_success: coveralls 23 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_block_super_extra/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load compress %} 3 | 4 | {% block js %}{% spaceless %} 5 | {% compress js %} 6 | 9 | {% endcompress %} 10 | 11 | {% compress js %} 12 | {{ block.super }} 13 | 16 | {% endcompress %} 17 | {% endspaceless %}{% endblock %} 18 | 19 | {% block css %}{% endblock %} 20 | -------------------------------------------------------------------------------- /compressor/tests/static/css/datauri.css: -------------------------------------------------------------------------------- 1 | .add { background-image: url("../img/add.png"); } 2 | .add-with-hash { background-image: url("../img/add.png#add"); } 3 | .python { background-image: url("../img/python.png"); } 4 | .datauri { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9YGARc5KB0XV+IAAAAddEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIFRoZSBHSU1Q72QlbgAAAF1JREFUGNO9zL0NglAAxPEfdLTs4BZM4DIO4C7OwQg2JoQ9LE1exdlYvBBeZ7jqch9//q1uH4TLzw4d6+ErXMMcXuHWxId3KOETnnXXV6MJpcq2MLaI97CER3N0 vr4MkhoXe0rZigAAAABJRU5ErkJggg=="); } 5 | -------------------------------------------------------------------------------- /compressor/filters/yuglify.py: -------------------------------------------------------------------------------- 1 | from compressor.conf import settings 2 | from compressor.filters import CompilerFilter 3 | 4 | 5 | class YUglifyFilter(CompilerFilter): 6 | command = "{binary} {args}" 7 | 8 | def __init__(self, *args, **kwargs): 9 | super(YUglifyFilter, self).__init__(*args, **kwargs) 10 | self.command += ' --type=%s' % self.type 11 | 12 | 13 | class YUglifyCSSFilter(YUglifyFilter): 14 | type = 'css' 15 | options = ( 16 | ("binary", settings.COMPRESS_YUGLIFY_BINARY), 17 | ("args", settings.COMPRESS_YUGLIFY_CSS_ARGUMENTS), 18 | ) 19 | 20 | 21 | class YUglifyJSFilter(YUglifyFilter): 22 | type = 'js' 23 | options = ( 24 | ("binary", settings.COMPRESS_YUGLIFY_BINARY), 25 | ("args", settings.COMPRESS_YUGLIFY_JS_ARGUMENTS), 26 | ) 27 | -------------------------------------------------------------------------------- /compressor/tests/test_templates/test_complex/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% load compress static %}{% spaceless %} 2 | 3 | {% if condition %} 4 | {% compress js%} 5 | 6 | {% with names=my_names %}{% spaceless %} 7 | {% for name in names %} 8 | 9 | {% endfor %} 10 | {% endspaceless %}{% endwith %} 11 | {% endcompress %} 12 | {% endif %}{% if not condition %} 13 | {% compress js %} 14 | 15 | {% endcompress %} 16 | {% else %} 17 | {% compress js %} 18 | 19 | {% endcompress %} 20 | {% endif %}{% endspaceless %} 21 | -------------------------------------------------------------------------------- /docs/django-sekizai.txt: -------------------------------------------------------------------------------- 1 | .. _django-sekizai_support: 2 | 3 | django-sekizai Support 4 | ====================== 5 | 6 | Django Compressor comes with support for _django-sekizai via an extension. 7 | _django-sekizai provides the ability to include template code, from within 8 | any block, to a parent block. It is primarily used to include js/css from 9 | included templates to the master template. 10 | 11 | It requires _django-sekizai to installed. Refer to the _django-sekizai _docs 12 | for how to use ``render_block`` 13 | 14 | Usage 15 | ----- 16 | 17 | .. code-block:: django 18 | 19 | {% load sekizai_tags %} 20 | {% render_block "" postprocessor "compressor.contrib.sekizai.compress" %} 21 | 22 | 23 | .. _django-sekizai: https://github.com/ojii/django-sekizai 24 | .. _docs: http://django-sekizai.readthedocs.org/en/latest/ 25 | -------------------------------------------------------------------------------- /compressor/contrib/sekizai.py: -------------------------------------------------------------------------------- 1 | """ 2 | source: https://gist.github.com/1311010 3 | Get django-sekizai, django-compessor (and django-cms) playing nicely together 4 | re: https://github.com/ojii/django-sekizai/issues/4 5 | using: https://github.com/django-compressor/django-compressor.git 6 | and: https://github.com/ojii/django-sekizai.git@0.6 or later 7 | """ 8 | from compressor.templatetags.compress import CompressorNode 9 | from django.template.base import Template 10 | 11 | 12 | def compress(context, data, name): 13 | """ 14 | Data is the string from the template (the list of js files in this case) 15 | Name is either 'js' or 'css' (the sekizai namespace) 16 | Basically passes the string through the {% compress 'js' %} template tag 17 | """ 18 | return CompressorNode(nodelist=Template(data).nodelist, kind=name, mode='file').render(context=context) 19 | -------------------------------------------------------------------------------- /compressor/filters/yui.py: -------------------------------------------------------------------------------- 1 | from compressor.conf import settings 2 | from compressor.filters import CompilerFilter 3 | 4 | 5 | class YUICompressorFilter(CompilerFilter): 6 | command = "{binary} {args}" 7 | 8 | def __init__(self, *args, **kwargs): 9 | super(YUICompressorFilter, self).__init__(*args, **kwargs) 10 | self.command += ' --type=%s' % self.type 11 | if self.verbose: 12 | self.command += ' --verbose' 13 | 14 | 15 | class YUICSSFilter(YUICompressorFilter): 16 | type = 'css' 17 | options = ( 18 | ("binary", settings.COMPRESS_YUI_BINARY), 19 | ("args", settings.COMPRESS_YUI_CSS_ARGUMENTS), 20 | ) 21 | 22 | 23 | class YUIJSFilter(YUICompressorFilter): 24 | type = 'js' 25 | options = ( 26 | ("binary", settings.COMPRESS_YUI_BINARY), 27 | ("args", settings.COMPRESS_YUI_JS_ARGUMENTS), 28 | ) 29 | -------------------------------------------------------------------------------- /compressor/tests/test_templates_jinja2/test_complex/test_compressor_offline.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% if condition %} 4 | {% compress js%} 5 | 6 | {% with names=[] -%} 7 | {%- do names.append("js/one.js") -%} 8 | {%- do names.append("js/nonasc.js") -%} 9 | {% for name in names -%} 10 | 11 | {%- endfor %} 12 | {%- endwith %} 13 | {% endcompress %} 14 | {% endif %} 15 | {% if not condition -%} 16 | {% compress js %} 17 | 18 | {% endcompress %} 19 | {%- else -%} 20 | {% compress js %} 21 | 22 | {% endcompress %} 23 | {%- endif %} 24 | {% endspaceless %} 25 | -------------------------------------------------------------------------------- /compressor/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import django 3 | 4 | TEST_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'tests') 5 | 6 | COMPRESS_CACHE_BACKEND = 'locmem://' 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': ':memory:', 12 | } 13 | } 14 | 15 | INSTALLED_APPS = [ 16 | 'compressor', 17 | 'coffin', 18 | 'jingo', 19 | ] 20 | 21 | STATIC_URL = '/static/' 22 | 23 | 24 | STATIC_ROOT = os.path.join(TEST_DIR, 'static') 25 | 26 | TEMPLATE_DIRS = ( 27 | # Specifically choose a name that will not be considered 28 | # by app_directories loader, to make sure each test uses 29 | # a specific template without considering the others. 30 | os.path.join(TEST_DIR, 'test_templates'), 31 | ) 32 | 33 | if django.VERSION[:2] < (1, 6): 34 | TEST_RUNNER = 'discover_runner.DiscoverRunner' 35 | 36 | SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" 37 | 38 | PASSWORD_HASHERS = ( 39 | 'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', 40 | ) 41 | -------------------------------------------------------------------------------- /compressor/tests/precompiler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import with_statement 3 | import optparse 4 | import sys 5 | 6 | 7 | def main(): 8 | p = optparse.OptionParser() 9 | p.add_option('-f', '--file', action="store", 10 | type="string", dest="filename", 11 | help="File to read from, defaults to stdin", default=None) 12 | p.add_option('-o', '--output', action="store", 13 | type="string", dest="outfile", 14 | help="File to write to, defaults to stdout", default=None) 15 | 16 | options, arguments = p.parse_args() 17 | 18 | if options.filename: 19 | f = open(options.filename) 20 | content = f.read() 21 | f.close() 22 | else: 23 | content = sys.stdin.read() 24 | 25 | content = content.replace('background:', 'color:') 26 | 27 | if options.outfile: 28 | with open(options.outfile, 'w') as f: 29 | f.write(content) 30 | else: 31 | print(content) 32 | 33 | 34 | if __name__ == '__main__': 35 | main() 36 | -------------------------------------------------------------------------------- /compressor/utils/staticfiles.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from compressor.conf import settings 6 | 7 | INSTALLED = ("staticfiles" in settings.INSTALLED_APPS or 8 | "django.contrib.staticfiles" in settings.INSTALLED_APPS) 9 | 10 | if INSTALLED: 11 | if "django.contrib.staticfiles" in settings.INSTALLED_APPS: 12 | from django.contrib.staticfiles import finders 13 | else: 14 | try: 15 | from staticfiles import finders # noqa 16 | except ImportError: 17 | # Old (pre 1.0) and incompatible version of staticfiles 18 | INSTALLED = False 19 | 20 | if (INSTALLED and "compressor.finders.CompressorFinder" 21 | not in settings.STATICFILES_FINDERS): 22 | raise ImproperlyConfigured( 23 | "When using Django Compressor together with staticfiles, " 24 | "please add 'compressor.finders.CompressorFinder' to the " 25 | "STATICFILES_FINDERS setting.") 26 | else: 27 | finders = None # noqa 28 | -------------------------------------------------------------------------------- /compressor/js.py: -------------------------------------------------------------------------------- 1 | from compressor.conf import settings 2 | from compressor.base import Compressor, SOURCE_HUNK, SOURCE_FILE 3 | 4 | 5 | class JsCompressor(Compressor): 6 | 7 | def __init__(self, content=None, output_prefix="js", context=None): 8 | super(JsCompressor, self).__init__(content, output_prefix, context) 9 | self.filters = list(settings.COMPRESS_JS_FILTERS) 10 | self.type = output_prefix 11 | 12 | def split_contents(self): 13 | if self.split_content: 14 | return self.split_content 15 | for elem in self.parser.js_elems(): 16 | attribs = self.parser.elem_attribs(elem) 17 | if 'src' in attribs: 18 | basename = self.get_basename(attribs['src']) 19 | filename = self.get_filename(basename) 20 | content = (SOURCE_FILE, filename, basename, elem) 21 | self.split_content.append(content) 22 | else: 23 | content = self.parser.elem_content(elem) 24 | self.split_content.append((SOURCE_HUNK, content, None, elem)) 25 | return self.split_content 26 | -------------------------------------------------------------------------------- /compressor/exceptions.py: -------------------------------------------------------------------------------- 1 | class CompressorError(Exception): 2 | """ 3 | A general error of the compressor 4 | """ 5 | pass 6 | 7 | 8 | class UncompressableFileError(Exception): 9 | """ 10 | This exception is raised when a file cannot be compressed 11 | """ 12 | pass 13 | 14 | 15 | class FilterError(Exception): 16 | """ 17 | This exception is raised when a filter fails 18 | """ 19 | pass 20 | 21 | 22 | class ParserError(Exception): 23 | """ 24 | This exception is raised when the parser fails 25 | """ 26 | pass 27 | 28 | 29 | class OfflineGenerationError(Exception): 30 | """ 31 | Offline compression generation related exceptions 32 | """ 33 | pass 34 | 35 | 36 | class FilterDoesNotExist(Exception): 37 | """ 38 | Raised when a filter class cannot be found. 39 | """ 40 | pass 41 | 42 | 43 | class TemplateDoesNotExist(Exception): 44 | """ 45 | This exception is raised when a template does not exist. 46 | """ 47 | pass 48 | 49 | 50 | class TemplateSyntaxError(Exception): 51 | """ 52 | This exception is raised when a template syntax error is encountered. 53 | """ 54 | pass 55 | -------------------------------------------------------------------------------- /compressor/parser/base.py: -------------------------------------------------------------------------------- 1 | class ParserBase(object): 2 | """ 3 | Base parser to be subclassed when creating an own parser. 4 | """ 5 | def __init__(self, content): 6 | self.content = content 7 | 8 | def css_elems(self): 9 | """ 10 | Return an iterable containing the css elements to handle 11 | """ 12 | raise NotImplementedError 13 | 14 | def js_elems(self): 15 | """ 16 | Return an iterable containing the js elements to handle 17 | """ 18 | raise NotImplementedError 19 | 20 | def elem_attribs(self, elem): 21 | """ 22 | Return the dictionary like attribute store of the given element 23 | """ 24 | raise NotImplementedError 25 | 26 | def elem_content(self, elem): 27 | """ 28 | Return the content of the given element 29 | """ 30 | raise NotImplementedError 31 | 32 | def elem_name(self, elem): 33 | """ 34 | Return the name of the given element 35 | """ 36 | raise NotImplementedError 37 | 38 | def elem_str(self, elem): 39 | """ 40 | Return the string representation of the given elem 41 | """ 42 | raise NotImplementedError 43 | -------------------------------------------------------------------------------- /compressor/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils import six 2 | from django.utils.functional import LazyObject 3 | from django.utils.importlib import import_module 4 | 5 | # support legacy parser module usage 6 | from compressor.parser.base import ParserBase # noqa 7 | from compressor.parser.lxml import LxmlParser 8 | from compressor.parser.default_htmlparser import DefaultHtmlParser as HtmlParser 9 | from compressor.parser.beautifulsoup import BeautifulSoupParser # noqa 10 | from compressor.parser.html5lib import Html5LibParser # noqa 11 | 12 | 13 | class AutoSelectParser(LazyObject): 14 | options = ( 15 | # TODO: make lxml.html parser first again 16 | (six.moves.html_parser.__name__, HtmlParser), # fast and part of the Python stdlib 17 | ('lxml.html', LxmlParser), # lxml, extremely fast 18 | ) 19 | 20 | def __init__(self, content): 21 | self._wrapped = None 22 | self._setup(content) 23 | 24 | def __getattr__(self, name): 25 | return getattr(self._wrapped, name) 26 | 27 | def _setup(self, content): 28 | for dependency, parser in self.options: 29 | try: 30 | import_module(dependency) 31 | self._wrapped = parser(content) 32 | break 33 | except ImportError: 34 | continue 35 | -------------------------------------------------------------------------------- /docs/index.txt: -------------------------------------------------------------------------------- 1 | ================= 2 | Django Compressor 3 | ================= 4 | 5 | Compresses linked and inline JavaScript or CSS into a single cached file. 6 | 7 | Why another static file combiner for Django? 8 | ============================================ 9 | 10 | Short version: None of them did exactly what I needed. 11 | 12 | Long version: 13 | 14 | **JS/CSS belong in the templates** 15 | Every static combiner for Django I've seen makes you configure 16 | your static files in your ``settings.py``. While that works, it doesn't make 17 | sense. Static files are for display. And it's not even an option if your 18 | settings are in completely different repositories and use different deploy 19 | processes from the templates that depend on them. 20 | 21 | **Flexibility** 22 | Django Compressor doesn't care if different pages use different combinations 23 | of statics. It doesn't care if you use inline scripts or styles. It doesn't 24 | get in the way. 25 | 26 | **Automatic regeneration and cache-foreverable generated output** 27 | Statics are never stale and browsers can be told to cache the output forever. 28 | 29 | **Full test suite** 30 | I has one. 31 | 32 | Contents 33 | ======== 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | 38 | quickstart 39 | usage 40 | scenarios 41 | settings 42 | remote-storages 43 | behind-the-scenes 44 | jinja2 45 | django-sekizai 46 | contributing 47 | changelog 48 | -------------------------------------------------------------------------------- /compressor/parser/beautifulsoup.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.utils import six 4 | from django.utils.encoding import smart_text 5 | 6 | from compressor.exceptions import ParserError 7 | from compressor.parser import ParserBase 8 | from compressor.utils.decorators import cached_property 9 | 10 | 11 | class BeautifulSoupParser(ParserBase): 12 | 13 | @cached_property 14 | def soup(self): 15 | try: 16 | if six.PY3: 17 | from bs4 import BeautifulSoup 18 | else: 19 | from BeautifulSoup import BeautifulSoup 20 | return BeautifulSoup(self.content) 21 | except ImportError as err: 22 | raise ImproperlyConfigured("Error while importing BeautifulSoup: %s" % err) 23 | except Exception as err: 24 | raise ParserError("Error while initializing Parser: %s" % err) 25 | 26 | def css_elems(self): 27 | if six.PY3: 28 | return self.soup.find_all({'link': True, 'style': True}) 29 | else: 30 | return self.soup.findAll({'link': True, 'style': True}) 31 | 32 | def js_elems(self): 33 | if six.PY3: 34 | return self.soup.find_all('script') 35 | else: 36 | return self.soup.findAll('script') 37 | 38 | def elem_attribs(self, elem): 39 | return dict(elem.attrs) 40 | 41 | def elem_content(self, elem): 42 | return elem.string 43 | 44 | def elem_name(self, elem): 45 | return elem.name 46 | 47 | def elem_str(self, elem): 48 | return smart_text(elem) 49 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Christian Metts 2 | Carl Meyer 3 | Jannis Leidel 4 | Mathieu Pillard 5 | 6 | 7 | Django Compressor's filters started life as the filters from Andreas Pelme's 8 | django-compress. 9 | 10 | Contributors: 11 | 12 | Aaron Godfrey 13 | Adam "Cezar" Jenkins 14 | Adrian Holovaty 15 | Alen Mujezinovic 16 | Alex Kessinger 17 | Andreas Pelme 18 | Antti Hirvonen 19 | Apostolos Bessas 20 | Ashley Camba Garrido 21 | Atamert Ölçgen 22 | Aymeric Augustin 23 | Bartek Ciszkowski 24 | Ben Firshman 25 | Ben Spaulding 26 | Benjamin Gilbert 27 | Benjamin Wohlwend 28 | Bojan Mihelac 29 | Boris Shemigon 30 | Brad Whittington 31 | Bruno Renié 32 | Cassus Adam Banko 33 | Chris Adams 34 | Chris Streeter 35 | Clay McClure 36 | David Medina 37 | David Ziegler 38 | Eugene Mirotin 39 | Fenn Bailey 40 | Francisco Souza 41 | Gert Van Gool 42 | Greg McGuire 43 | Harro van der Klauw 44 | Isaac Bythewood 45 | Iván Raskovsky 46 | Jaap Roes 47 | James Roe 48 | Jason Davies 49 | Jens Diemer 50 | Jeremy Dunck 51 | Jervis Whitley 52 | John-Scott Atlakson 53 | Jonas von Poser 54 | Jonathan Lukens 55 | Julian Scheid 56 | Julien Phalip 57 | Justin Lilly 58 | Lucas Tan 59 | Luis Nell 60 | Lukas Lehner 61 | Łukasz Balcerzak 62 | Łukasz Langa 63 | Maciek Szczesniak 64 | Maor Ben-Dayan 65 | Mark Lavin 66 | Marsel Mavletkulov 67 | Matt Schick 68 | Matthew Tretter 69 | Mehmet S. Catalbas 70 | Michael van de Waeter 71 | Mike Yumatov 72 | Nicolas Charlot 73 | Niran Babalola 74 | Paul McMillan 75 | Petar Radosevic 76 | Peter Bengtsson 77 | Peter Lundberg 78 | Philipp Bosch 79 | Philipp Wollermann 80 | Rich Leland 81 | Sam Dornan 82 | Saul Shanabrook 83 | Selwin Ong 84 | Shabda Raaj 85 | Stefano Brentegani 86 | Sébastien Piquemal 87 | Thom Linton 88 | Thomas Schreiber 89 | Tino de Bruijn 90 | Ulrich Petri 91 | Ulysses V 92 | Vladislav Poluhin 93 | wesleyb 94 | Wilson Júnior 95 | -------------------------------------------------------------------------------- /compressor/filters/datauri.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import os 3 | import re 4 | import mimetypes 5 | from base64 import b64encode 6 | 7 | from compressor.conf import settings 8 | from compressor.filters import FilterBase 9 | 10 | 11 | class DataUriFilter(FilterBase): 12 | """Filter for embedding media as data: URIs. 13 | 14 | Settings: 15 | COMPRESS_DATA_URI_MAX_SIZE: Only files that are smaller than this 16 | value will be embedded. Unit; bytes. 17 | 18 | 19 | Don't use this class directly. Use a subclass. 20 | """ 21 | def input(self, filename=None, **kwargs): 22 | if not filename or not filename.startswith(settings.COMPRESS_ROOT): 23 | return self.content 24 | output = self.content 25 | for url_pattern in self.url_patterns: 26 | output = url_pattern.sub(self.data_uri_converter, output) 27 | return output 28 | 29 | def get_file_path(self, url): 30 | # strip query string of file paths 31 | if "?" in url: 32 | url = url.split("?")[0] 33 | if "#" in url: 34 | url = url.split("#")[0] 35 | return os.path.join( 36 | settings.COMPRESS_ROOT, url[len(settings.COMPRESS_URL):]) 37 | 38 | def data_uri_converter(self, matchobj): 39 | url = matchobj.group(1).strip(' \'"') 40 | if not url.startswith('data:') and not url.startswith('//'): 41 | path = self.get_file_path(url) 42 | if os.stat(path).st_size <= settings.COMPRESS_DATA_URI_MAX_SIZE: 43 | with open(path, 'rb') as file: 44 | data = b64encode(file.read()).decode('ascii') 45 | return 'url("data:%s;base64,%s")' % ( 46 | mimetypes.guess_type(path)[0], data) 47 | return 'url("%s")' % url 48 | 49 | 50 | class CssDataUriFilter(DataUriFilter): 51 | """Filter for embedding media as data: URIs in CSS files. 52 | 53 | See DataUriFilter. 54 | """ 55 | url_patterns = ( 56 | re.compile(r'url\(([^\)]+)\)'), 57 | ) 58 | -------------------------------------------------------------------------------- /compressor/parser/html5lib.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.utils.encoding import smart_text 4 | 5 | from compressor.exceptions import ParserError 6 | from compressor.parser import ParserBase 7 | from compressor.utils.decorators import cached_property 8 | 9 | 10 | class Html5LibParser(ParserBase): 11 | 12 | def __init__(self, content): 13 | super(Html5LibParser, self).__init__(content) 14 | import html5lib 15 | self.html5lib = html5lib 16 | 17 | def _serialize(self, elem): 18 | return self.html5lib.serialize( 19 | elem, tree="etree", quote_attr_values=True, 20 | omit_optional_tags=False, use_trailing_solidus=True, 21 | ) 22 | 23 | def _find(self, *names): 24 | for elem in self.html: 25 | if elem.tag in names: 26 | yield elem 27 | 28 | @cached_property 29 | def html(self): 30 | try: 31 | return self.html5lib.parseFragment(self.content, treebuilder="etree") 32 | except ImportError as err: 33 | raise ImproperlyConfigured("Error while importing html5lib: %s" % err) 34 | except Exception as err: 35 | raise ParserError("Error while initializing Parser: %s" % err) 36 | 37 | def css_elems(self): 38 | return self._find('{http://www.w3.org/1999/xhtml}link', 39 | '{http://www.w3.org/1999/xhtml}style') 40 | 41 | def js_elems(self): 42 | return self._find('{http://www.w3.org/1999/xhtml}script') 43 | 44 | def elem_attribs(self, elem): 45 | return elem.attrib 46 | 47 | def elem_content(self, elem): 48 | return smart_text(elem.text) 49 | 50 | def elem_name(self, elem): 51 | if '}' in elem.tag: 52 | return elem.tag.split('}')[1] 53 | return elem.tag 54 | 55 | def elem_str(self, elem): 56 | # This method serializes HTML in a way that does not pass all tests. 57 | # However, this method is only called in tests anyway, so it doesn't 58 | # really matter. 59 | return smart_text(self._serialize(elem)) 60 | -------------------------------------------------------------------------------- /compressor/utils/decorators.py: -------------------------------------------------------------------------------- 1 | class cached_property(object): 2 | """Property descriptor that caches the return value 3 | of the get function. 4 | 5 | *Examples* 6 | 7 | .. code-block:: python 8 | 9 | @cached_property 10 | def connection(self): 11 | return Connection() 12 | 13 | @connection.setter # Prepares stored value 14 | def connection(self, value): 15 | if value is None: 16 | raise TypeError("Connection must be a connection") 17 | return value 18 | 19 | @connection.deleter 20 | def connection(self, value): 21 | # Additional action to do at del(self.attr) 22 | if value is not None: 23 | print("Connection %r deleted" % (value, )) 24 | """ 25 | def __init__(self, fget=None, fset=None, fdel=None, doc=None): 26 | self.__get = fget 27 | self.__set = fset 28 | self.__del = fdel 29 | self.__doc__ = doc or fget.__doc__ 30 | self.__name__ = fget.__name__ 31 | self.__module__ = fget.__module__ 32 | 33 | def __get__(self, obj, type=None): 34 | if obj is None: 35 | return self 36 | try: 37 | return obj.__dict__[self.__name__] 38 | except KeyError: 39 | value = obj.__dict__[self.__name__] = self.__get(obj) 40 | return value 41 | 42 | def __set__(self, obj, value): 43 | if obj is None: 44 | return self 45 | if self.__set is not None: 46 | value = self.__set(obj, value) 47 | obj.__dict__[self.__name__] = value 48 | 49 | def __delete__(self, obj): 50 | if obj is None: 51 | return self 52 | try: 53 | value = obj.__dict__.pop(self.__name__) 54 | except KeyError: 55 | pass 56 | else: 57 | if self.__del is not None: 58 | self.__del(obj, value) 59 | 60 | def setter(self, fset): 61 | return self.__class__(self.__get, fset, self.__del) 62 | 63 | def deleter(self, fdel): 64 | return self.__class__(self.__get, self.__set, fdel) 65 | -------------------------------------------------------------------------------- /compressor/contrib/jinja2ext.py: -------------------------------------------------------------------------------- 1 | from jinja2 import nodes 2 | from jinja2.ext import Extension 3 | from jinja2.exceptions import TemplateSyntaxError 4 | 5 | from compressor.templatetags.compress import OUTPUT_FILE, CompressorMixin 6 | 7 | 8 | class CompressorExtension(CompressorMixin, Extension): 9 | 10 | tags = set(['compress']) 11 | 12 | def parse(self, parser): 13 | lineno = next(parser.stream).lineno 14 | kindarg = parser.parse_expression() 15 | # Allow kind to be defined as jinja2 name node 16 | if isinstance(kindarg, nodes.Name): 17 | kindarg = nodes.Const(kindarg.name) 18 | args = [kindarg] 19 | if args[0].value not in self.compressors: 20 | raise TemplateSyntaxError('compress kind may be one of: %s' % 21 | (', '.join(self.compressors.keys())), 22 | lineno) 23 | if parser.stream.skip_if('comma'): 24 | modearg = parser.parse_expression() 25 | # Allow mode to be defined as jinja2 name node 26 | if isinstance(modearg, nodes.Name): 27 | modearg = nodes.Const(modearg.name) 28 | args.append(modearg) 29 | else: 30 | args.append(nodes.Const('file')) 31 | 32 | body = parser.parse_statements(['name:endcompress'], drop_needle=True) 33 | 34 | # Skip the kind if used in the endblock, by using the kind in the 35 | # endblock the templates are slightly more readable. 36 | parser.stream.skip_if('name:' + kindarg.value) 37 | return nodes.CallBlock(self.call_method('_compress_normal', args), [], [], 38 | body).set_lineno(lineno) 39 | 40 | def _compress_forced(self, kind, mode, caller): 41 | return self._compress(kind, mode, caller, True) 42 | 43 | def _compress_normal(self, kind, mode, caller): 44 | return self._compress(kind, mode, caller, False) 45 | 46 | def _compress(self, kind, mode, caller, forced): 47 | mode = mode or OUTPUT_FILE 48 | original_content = caller() 49 | context = { 50 | 'original_content': original_content 51 | } 52 | return self.render_compressed(context, kind, mode, forced=forced) 53 | 54 | def get_original_content(self, context): 55 | return context['original_content'] 56 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [deps] 2 | two = 3 | flake8 4 | coverage 5 | html5lib 6 | mock 7 | jinja2 8 | lxml 9 | BeautifulSoup 10 | unittest2 11 | jingo 12 | coffin 13 | three = 14 | flake8 15 | coverage 16 | html5lib 17 | mock 18 | jinja2 19 | lxml 20 | BeautifulSoup4 21 | jingo 22 | coffin 23 | three_two = 24 | flake8 25 | coverage 26 | html5lib 27 | mock 28 | jinja2==2.6 29 | lxml 30 | BeautifulSoup4 31 | jingo 32 | coffin 33 | 34 | [tox] 35 | envlist = 36 | py33-1.6.X, 37 | py32-1.6.X, 38 | py27-1.6.X, 39 | py26-1.6.X, 40 | py33-1.5.X, 41 | py32-1.5.X, 42 | py27-1.5.X, 43 | py26-1.5.X, 44 | py27-1.4.X, 45 | py26-1.4.X 46 | 47 | [testenv] 48 | setenv = 49 | CPPFLAGS=-O0 50 | usedevelop = true 51 | whitelist_externals = /usr/bin/make 52 | downloadcache = {toxworkdir}/_download/ 53 | commands = 54 | django-admin.py --version 55 | make test 56 | 57 | [testenv:py33-1.6.X] 58 | basepython = python3.3 59 | deps = 60 | Django>=1.6,<1.7 61 | {[deps]three} 62 | 63 | [testenv:py32-1.6.X] 64 | basepython = python3.2 65 | deps = 66 | Django>=1.6,<1.7 67 | {[deps]three_two} 68 | 69 | [testenv:py27-1.6.X] 70 | basepython = python2.7 71 | deps = 72 | Django>=1.6,<1.7 73 | {[deps]two} 74 | 75 | [testenv:py26-1.6.X] 76 | basepython = python2.6 77 | deps = 78 | Django>=1.6,<1.7 79 | {[deps]two} 80 | 81 | [testenv:py33-1.5.X] 82 | basepython = python3.3 83 | deps = 84 | Django>=1.5,<1.6 85 | django-discover-runner 86 | {[deps]three} 87 | 88 | [testenv:py32-1.5.X] 89 | basepython = python3.2 90 | deps = 91 | Django>=1.5,<1.6 92 | django-discover-runner 93 | {[deps]three_two} 94 | 95 | [testenv:py27-1.5.X] 96 | basepython = python2.7 97 | deps = 98 | Django>=1.5,<1.6 99 | django-discover-runner 100 | {[deps]two} 101 | 102 | [testenv:py26-1.5.X] 103 | basepython = python2.6 104 | deps = 105 | Django>=1.5,<1.6 106 | django-discover-runner 107 | {[deps]two} 108 | 109 | [testenv:py27-1.4.X] 110 | basepython = python2.7 111 | deps = 112 | Django>=1.4,<1.5 113 | django-discover-runner 114 | {[deps]two} 115 | 116 | [testenv:py26-1.4.X] 117 | basepython = python2.6 118 | deps = 119 | Django>=1.4,<1.5 120 | django-discover-runner 121 | {[deps]two} 122 | -------------------------------------------------------------------------------- /compressor/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | import os 4 | 5 | from django.utils import six 6 | 7 | from compressor.exceptions import FilterError 8 | 9 | 10 | def get_class(class_string, exception=FilterError): 11 | """ 12 | Convert a string version of a function name to the callable object. 13 | """ 14 | if not hasattr(class_string, '__bases__'): 15 | try: 16 | class_string = str(class_string) 17 | mod_name, class_name = get_mod_func(class_string) 18 | if class_name: 19 | return getattr(__import__(mod_name, {}, {}, [str('')]), class_name) 20 | except (ImportError, AttributeError): 21 | raise exception('Failed to import %s' % class_string) 22 | 23 | raise exception("Invalid class path '%s'" % class_string) 24 | 25 | 26 | def get_mod_func(callback): 27 | """ 28 | Converts 'django.views.news.stories.story_detail' to 29 | ('django.views.news.stories', 'story_detail') 30 | """ 31 | try: 32 | dot = callback.rindex('.') 33 | except ValueError: 34 | return callback, '' 35 | return callback[:dot], callback[dot + 1:] 36 | 37 | 38 | def get_pathext(default_pathext=None): 39 | """ 40 | Returns the path extensions from environment or a default 41 | """ 42 | if default_pathext is None: 43 | default_pathext = os.pathsep.join(['.COM', '.EXE', '.BAT', '.CMD']) 44 | return os.environ.get('PATHEXT', default_pathext) 45 | 46 | 47 | def find_command(cmd, paths=None, pathext=None): 48 | """ 49 | Searches the PATH for the given command and returns its path 50 | """ 51 | if paths is None: 52 | paths = os.environ.get('PATH', '').split(os.pathsep) 53 | if isinstance(paths, six.string_types): 54 | paths = [paths] 55 | # check if there are funny path extensions for executables, e.g. Windows 56 | if pathext is None: 57 | pathext = get_pathext() 58 | pathext = [ext for ext in pathext.lower().split(os.pathsep)] 59 | # don't use extensions if the command ends with one of them 60 | if os.path.splitext(cmd)[1].lower() in pathext: 61 | pathext = [''] 62 | # check if we find the command on PATH 63 | for path in paths: 64 | # try without extension first 65 | cmd_path = os.path.join(path, cmd) 66 | for ext in pathext: 67 | # then including the extension 68 | cmd_path_ext = cmd_path + ext 69 | if os.path.isfile(cmd_path_ext): 70 | return cmd_path_ext 71 | if os.path.isfile(cmd_path): 72 | return cmd_path 73 | return None 74 | -------------------------------------------------------------------------------- /compressor/css.py: -------------------------------------------------------------------------------- 1 | from compressor.base import Compressor, SOURCE_HUNK, SOURCE_FILE 2 | from compressor.conf import settings 3 | 4 | 5 | class CssCompressor(Compressor): 6 | 7 | def __init__(self, content=None, output_prefix="css", context=None): 8 | super(CssCompressor, self).__init__(content=content, 9 | output_prefix=output_prefix, context=context) 10 | self.filters = list(settings.COMPRESS_CSS_FILTERS) 11 | self.type = output_prefix 12 | 13 | def split_contents(self): 14 | if self.split_content: 15 | return self.split_content 16 | self.media_nodes = [] 17 | for elem in self.parser.css_elems(): 18 | data = None 19 | elem_name = self.parser.elem_name(elem) 20 | elem_attribs = self.parser.elem_attribs(elem) 21 | if elem_name == 'link' and elem_attribs['rel'].lower() == 'stylesheet': 22 | basename = self.get_basename(elem_attribs['href']) 23 | filename = self.get_filename(basename) 24 | data = (SOURCE_FILE, filename, basename, elem) 25 | elif elem_name == 'style': 26 | data = (SOURCE_HUNK, self.parser.elem_content(elem), None, elem) 27 | if data: 28 | self.split_content.append(data) 29 | media = elem_attribs.get('media', None) 30 | # Append to the previous node if it had the same media type 31 | append_to_previous = self.media_nodes and self.media_nodes[-1][0] == media 32 | # and we are not just precompiling, otherwise create a new node. 33 | if append_to_previous and settings.COMPRESS_ENABLED: 34 | self.media_nodes[-1][1].split_content.append(data) 35 | else: 36 | node = self.__class__(content=self.parser.elem_str(elem), 37 | context=self.context) 38 | node.split_content.append(data) 39 | self.media_nodes.append((media, node)) 40 | return self.split_content 41 | 42 | def output(self, *args, **kwargs): 43 | if (settings.COMPRESS_ENABLED or settings.COMPRESS_PRECOMPILERS or 44 | kwargs.get('forced', False)): 45 | # Populate self.split_content 46 | self.split_contents() 47 | if hasattr(self, 'media_nodes'): 48 | ret = [] 49 | for media, subnode in self.media_nodes: 50 | subnode.extra_context.update({'media': media}) 51 | ret.append(subnode.output(*args, **kwargs)) 52 | return ''.join(ret) 53 | return super(CssCompressor, self).output(*args, **kwargs) 54 | -------------------------------------------------------------------------------- /compressor/tests/test_storages.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement, unicode_literals 2 | import errno 3 | import os 4 | 5 | from django.core.files.base import ContentFile 6 | from django.core.files.storage import get_storage_class 7 | from django.test import TestCase 8 | from django.utils.functional import LazyObject 9 | 10 | from compressor import storage 11 | from compressor.conf import settings 12 | from compressor.tests.test_base import css_tag 13 | from compressor.tests.test_templatetags import render 14 | 15 | 16 | class GzipStorage(LazyObject): 17 | def _setup(self): 18 | self._wrapped = get_storage_class('compressor.storage.GzipCompressorFileStorage')() 19 | 20 | 21 | class StorageTestCase(TestCase): 22 | def setUp(self): 23 | self.old_enabled = settings.COMPRESS_ENABLED 24 | settings.COMPRESS_ENABLED = True 25 | self.default_storage = storage.default_storage 26 | storage.default_storage = GzipStorage() 27 | 28 | def tearDown(self): 29 | storage.default_storage = self.default_storage 30 | settings.COMPRESS_ENABLED = self.old_enabled 31 | 32 | def test_gzip_storage(self): 33 | storage.default_storage.save('test.txt', ContentFile('yeah yeah')) 34 | self.assertTrue(os.path.exists(os.path.join(settings.COMPRESS_ROOT, 'test.txt'))) 35 | self.assertTrue(os.path.exists(os.path.join(settings.COMPRESS_ROOT, 'test.txt.gz'))) 36 | 37 | def test_css_tag_with_storage(self): 38 | template = """{% load compress %}{% compress css %} 39 | 40 | 41 | 42 | {% endcompress %} 43 | """ 44 | context = {'STATIC_URL': settings.COMPRESS_URL} 45 | out = css_tag("/static/CACHE/css/1d4424458f88.css") 46 | self.assertEqual(out, render(template, context)) 47 | 48 | def test_race_condition_handling(self): 49 | # Hold on to original os.remove 50 | original_remove = os.remove 51 | 52 | def race_remove(path): 53 | "Patched os.remove to raise ENOENT (No such file or directory)" 54 | original_remove(path) 55 | raise OSError(errno.ENOENT, 'Fake ENOENT') 56 | 57 | try: 58 | os.remove = race_remove 59 | self.default_storage.save('race.file', ContentFile('Fake ENOENT')) 60 | self.default_storage.delete('race.file') 61 | self.assertFalse(self.default_storage.exists('race.file')) 62 | finally: 63 | # Restore os.remove 64 | os.remove = original_remove 65 | -------------------------------------------------------------------------------- /compressor/tests/test_signals.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from mock import Mock 4 | 5 | from compressor.conf import settings 6 | from compressor.css import CssCompressor 7 | from compressor.js import JsCompressor 8 | from compressor.signals import post_compress 9 | 10 | 11 | class PostCompressSignalTestCase(TestCase): 12 | def setUp(self): 13 | settings.COMPRESS_ENABLED = True 14 | settings.COMPRESS_PRECOMPILERS = () 15 | settings.COMPRESS_DEBUG_TOGGLE = 'nocompress' 16 | self.css = """\ 17 | 18 | 19 | """ 20 | self.css_node = CssCompressor(self.css) 21 | 22 | self.js = """\ 23 | 24 | """ 25 | self.js_node = JsCompressor(self.js) 26 | 27 | def tearDown(self): 28 | post_compress.disconnect() 29 | 30 | def test_js_signal_sent(self): 31 | def listener(sender, **kwargs): 32 | pass 33 | callback = Mock(wraps=listener) 34 | post_compress.connect(callback) 35 | self.js_node.output() 36 | args, kwargs = callback.call_args 37 | self.assertEqual(JsCompressor, kwargs['sender']) 38 | self.assertEqual('js', kwargs['type']) 39 | self.assertEqual('file', kwargs['mode']) 40 | context = kwargs['context'] 41 | assert 'url' in context['compressed'] 42 | 43 | def test_css_signal_sent(self): 44 | def listener(sender, **kwargs): 45 | pass 46 | callback = Mock(wraps=listener) 47 | post_compress.connect(callback) 48 | self.css_node.output() 49 | args, kwargs = callback.call_args 50 | self.assertEqual(CssCompressor, kwargs['sender']) 51 | self.assertEqual('css', kwargs['type']) 52 | self.assertEqual('file', kwargs['mode']) 53 | context = kwargs['context'] 54 | assert 'url' in context['compressed'] 55 | 56 | def test_css_signal_multiple_media_attributes(self): 57 | css = """\ 58 | 59 | 60 | """ 61 | css_node = CssCompressor(css) 62 | 63 | def listener(sender, **kwargs): 64 | pass 65 | callback = Mock(wraps=listener) 66 | post_compress.connect(callback) 67 | css_node.output() 68 | self.assertEqual(3, callback.call_count) 69 | -------------------------------------------------------------------------------- /docs/behind-the-scenes.txt: -------------------------------------------------------------------------------- 1 | .. _behind_the_scenes: 2 | 3 | Behind the scenes 4 | ================= 5 | 6 | This document assumes you already have an up and running instance of 7 | Django Compressor, and that you understand how to use it in your templates. 8 | The goal is to explain what the main template tag, {% compress %}, does 9 | behind the scenes, to help you debug performance problems for instance. 10 | 11 | Offline cache 12 | ------------- 13 | 14 | If offline cache is activated, the first thing {% compress %} tries to do is 15 | retrieve the compressed version for its nodelist from the offline manifest 16 | cache. It doesn't parse, doesn't check the modified times of the files, doesn't 17 | even know which files are concerned actually, since it doesn't look inside the 18 | nodelist of the template block enclosed by the ``compress`` template tag. 19 | The offline cache manifest is just a json file, stored on disk inside the 20 | directory that holds the compressed files. The format of the manifest is simply 21 | a key <=> value dictionary, with the hash of the nodelist being the key, 22 | and the HTML containing the element code for the combined file or piece of code 23 | being the value. Generating the offline manifest, using the ``compress`` 24 | management command, also generates the combined files referenced in the manifest. 25 | 26 | If offline cache is activated and the nodelist hash can not be found inside the 27 | manifest, {% compress %} will raise an ``OfflineGenerationError``. 28 | 29 | If offline cache is de-activated, the following happens: 30 | 31 | First step: parsing and file list 32 | --------------------------------- 33 | 34 | A compressor instance is created, which in turns instantiates the HTML parser. 35 | The parser is used to determine a file or code hunk list. Each file mtime is 36 | checked, first in cache and then on disk/storage, and this is used to 37 | determine an unique cache key. 38 | 39 | Second step: Checking the "main" cache 40 | -------------------------------------- 41 | 42 | Compressor checks if it can get some info about the combined file/hunks 43 | corresponding to its instance, using the cache key obtained in the previous 44 | step. The cache content here will actually be the HTML containing the final 45 | element code, just like in the offline step before. 46 | 47 | Everything stops here if the cache entry exists. 48 | 49 | Third step: Generating the combined file if needed 50 | -------------------------------------------------- 51 | 52 | The file is generated if necessary. All precompilers are called and all 53 | filters are executed, and a hash is determined from the contents. This in 54 | turns helps determine the file name, which is only saved if it didn't exist 55 | already. Then the HTML output is returned (and also saved in the cache). 56 | And that's it! 57 | -------------------------------------------------------------------------------- /docs/scenarios.txt: -------------------------------------------------------------------------------- 1 | .. _scenarios: 2 | 3 | Common Deployment Scenarios 4 | =========================== 5 | 6 | This document presents the most typical scenarios in which Django Compressor 7 | can be configured, and should help you decide which method you may want to 8 | use for your stack. 9 | 10 | In-Request Compression 11 | ---------------------- 12 | 13 | This is the default method of compression. Where-in Django Compressor will 14 | go through the steps outlined in :ref:`behind_the_scenes`. You will find 15 | in-request compression beneficial if: 16 | 17 | * Using a single server setup, where the application and static files are on 18 | the same machine. 19 | 20 | * Prefer a simple configuration. By default, there is no configuration 21 | required. 22 | 23 | Caveats 24 | ------- 25 | 26 | * If deploying to a multi-server setup and using 27 | :attr:`~django.conf.settings.COMPRESS_PRECOMPILERS`, each binary is 28 | required to be installed on each application server. 29 | 30 | * Application servers may not have permissions to write to your static 31 | directories. For example, if deploying to a CDN (e.g. Amazon S3) 32 | 33 | Offline Compression 34 | ------------------- 35 | 36 | This method decouples the compression outside of the request 37 | (see :ref:`behind_the_Scenes`) and can prove beneficial in the speed, 38 | and in many scenarios, the maintainability of your deployment. 39 | You will find offline compression beneficial if: 40 | 41 | * Using a multi-server setup. A common scenario for this may be multiple 42 | application servers and a single static file server (CDN included). 43 | With offline compression, you typically run ``manage.py compress`` 44 | on a single utility server, meaning you only maintain 45 | :attr:`~django.conf.settings.COMPRESS_PRECOMPILERS` binaries in one 46 | location. 47 | 48 | * You store compressed files on a CDN. 49 | 50 | Caveats 51 | ------- 52 | 53 | * If your templates have complex logic in how template inheritance is done 54 | (e.g. ``{% extends context_variable %}``), then this becomes a problem, 55 | as offline compression will not have the context, unless you set it in 56 | :attr:`~django.conf.settings.COMPRESS_OFFLINE_CONTEXT` 57 | 58 | * Due to the way the manifest file is used, while deploying across a 59 | multi-server setup, your application may use old templates with a new 60 | manifest, possibly rendering your pages incoherent. The current suggested 61 | solution for this is to change the 62 | :attr:`~django.conf.settings.COMPRESS_OFFLINE_MANIFEST` path for each new 63 | version of your code. This will ensure that the old code uses old 64 | compressed output, and the new one appropriately as well. 65 | 66 | Every setup is unique, and your scenario may differ slightly. Choose what 67 | is the most sane to maintain for your situation. 68 | -------------------------------------------------------------------------------- /compressor/parser/default_htmlparser.py: -------------------------------------------------------------------------------- 1 | from django.utils import six 2 | from django.utils.encoding import smart_text 3 | 4 | from compressor.exceptions import ParserError 5 | from compressor.parser import ParserBase 6 | 7 | 8 | class DefaultHtmlParser(ParserBase, six.moves.html_parser.HTMLParser): 9 | def __init__(self, content): 10 | six.moves.html_parser.HTMLParser.__init__(self) 11 | self.content = content 12 | self._css_elems = [] 13 | self._js_elems = [] 14 | self._current_tag = None 15 | try: 16 | self.feed(self.content) 17 | self.close() 18 | except Exception as err: 19 | lineno = err.lineno 20 | line = self.content.splitlines()[lineno] 21 | raise ParserError("Error while initializing HtmlParser: %s (line: %s)" % (err, repr(line))) 22 | 23 | def handle_starttag(self, tag, attrs): 24 | tag = tag.lower() 25 | if tag in ('style', 'script'): 26 | if tag == 'style': 27 | tags = self._css_elems 28 | elif tag == 'script': 29 | tags = self._js_elems 30 | tags.append({ 31 | 'tag': tag, 32 | 'attrs': attrs, 33 | 'attrs_dict': dict(attrs), 34 | 'text': '' 35 | }) 36 | self._current_tag = tag 37 | elif tag == 'link': 38 | self._css_elems.append({ 39 | 'tag': tag, 40 | 'attrs': attrs, 41 | 'attrs_dict': dict(attrs), 42 | 'text': None 43 | }) 44 | 45 | def handle_endtag(self, tag): 46 | if self._current_tag and self._current_tag == tag.lower(): 47 | self._current_tag = None 48 | 49 | def handle_data(self, data): 50 | if self._current_tag == 'style': 51 | self._css_elems[-1]['text'] = data 52 | elif self._current_tag == 'script': 53 | self._js_elems[-1]['text'] = data 54 | 55 | def css_elems(self): 56 | return self._css_elems 57 | 58 | def js_elems(self): 59 | return self._js_elems 60 | 61 | def elem_name(self, elem): 62 | return elem['tag'] 63 | 64 | def elem_attribs(self, elem): 65 | return elem['attrs_dict'] 66 | 67 | def elem_content(self, elem): 68 | return smart_text(elem['text']) 69 | 70 | def elem_str(self, elem): 71 | tag = {} 72 | tag.update(elem) 73 | tag['attrs'] = '' 74 | if len(elem['attrs']): 75 | tag['attrs'] = ' %s' % ' '.join(['%s="%s"' % (name, value) for name, value in elem['attrs']]) 76 | if elem['tag'] == 'link': 77 | return '<%(tag)s%(attrs)s />' % tag 78 | else: 79 | return '<%(tag)s%(attrs)s>%(text)s' % tag 80 | -------------------------------------------------------------------------------- /compressor/parser/lxml.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.utils import six 5 | from django.utils.encoding import smart_text 6 | 7 | from compressor.exceptions import ParserError 8 | from compressor.parser import ParserBase 9 | from compressor.utils.decorators import cached_property 10 | 11 | 12 | class LxmlParser(ParserBase): 13 | """ 14 | LxmlParser will use `lxml.html` parser to parse rendered contents of 15 | {% compress %} tag. Under python 2 it will also try to use beautiful 16 | soup parser in case of any problems with encoding. 17 | """ 18 | def __init__(self, content): 19 | try: 20 | from lxml.html import fromstring 21 | from lxml.etree import tostring 22 | except ImportError as err: 23 | raise ImproperlyConfigured("Error while importing lxml: %s" % err) 24 | except Exception as err: 25 | raise ParserError("Error while initializing parser: %s" % err) 26 | 27 | if not six.PY3: 28 | # soupparser uses Beautiful Soup 3 which does not run on python 3.x 29 | try: 30 | from lxml.html import soupparser 31 | except ImportError as err: 32 | soupparser = None 33 | except Exception as err: 34 | raise ParserError("Error while initializing parser: %s" % err) 35 | else: 36 | soupparser = None 37 | 38 | self.soupparser = soupparser 39 | self.fromstring = fromstring 40 | self.tostring = tostring 41 | super(LxmlParser, self).__init__(content) 42 | 43 | @cached_property 44 | def tree(self): 45 | """ 46 | Document tree. 47 | """ 48 | content = '%s' % self.content 49 | tree = self.fromstring(content) 50 | try: 51 | self.tostring(tree, encoding=six.text_type) 52 | except UnicodeDecodeError: 53 | if self.soupparser: # use soup parser on python 2 54 | tree = self.soupparser.fromstring(content) 55 | else: # raise an error on python 3 56 | raise 57 | return tree 58 | 59 | def css_elems(self): 60 | return self.tree.xpath('//link[re:test(@rel, "^stylesheet$", "i")]|style', 61 | namespaces={"re": "http://exslt.org/regular-expressions"}) 62 | 63 | def js_elems(self): 64 | return self.tree.findall('script') 65 | 66 | def elem_attribs(self, elem): 67 | return elem.attrib 68 | 69 | def elem_content(self, elem): 70 | return smart_text(elem.text) 71 | 72 | def elem_name(self, elem): 73 | return elem.tag 74 | 75 | def elem_str(self, elem): 76 | elem_as_string = smart_text( 77 | self.tostring(elem, method='html', encoding=six.text_type)) 78 | if elem.tag == 'link': 79 | # This makes testcases happy 80 | return elem_as_string.replace('>', ' />') 81 | return elem_as_string 82 | -------------------------------------------------------------------------------- /docs/quickstart.txt: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Installation 5 | ------------ 6 | 7 | * Install Django Compressor with your favorite Python package manager:: 8 | 9 | pip install django_compressor 10 | 11 | * Add ``'compressor'`` to your ``INSTALLED_APPS`` setting:: 12 | 13 | INSTALLED_APPS = ( 14 | # other apps 15 | "compressor", 16 | ) 17 | 18 | * See the list of :ref:`settings` to modify Django Compressor's 19 | default behaviour and make adjustments for your website. 20 | 21 | * In case you use Django's staticfiles_ contrib app (or its standalone 22 | counterpart django-staticfiles_) you have to add Django Compressor's file 23 | finder to the ``STATICFILES_FINDERS`` setting, for example with 24 | ``django.contrib.staticfiles``: 25 | 26 | .. code-block:: python 27 | 28 | STATICFILES_FINDERS = ( 29 | 'django.contrib.staticfiles.finders.FileSystemFinder', 30 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 31 | # other finders.. 32 | 'compressor.finders.CompressorFinder', 33 | ) 34 | 35 | * Define :attr:`COMPRESS_ROOT ` in settings 36 | if you don't have already ``STATIC_ROOT`` or if you want it in a different 37 | folder. 38 | 39 | .. _staticfiles: http://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/ 40 | .. _django-staticfiles: http://pypi.python.org/pypi/django-staticfiles 41 | 42 | .. _dependencies: 43 | 44 | Dependencies 45 | ------------ 46 | 47 | Required 48 | ^^^^^^^^ 49 | 50 | In case you're installing Django Compressor differently 51 | (e.g. from the Git repo), make sure to install the following 52 | dependencies. 53 | 54 | - django-appconf_ 55 | 56 | Used internally to handle Django's settings, this is 57 | automatically installed when following the above 58 | installation instructions. 59 | 60 | pip install django-appconf 61 | 62 | Optional 63 | ^^^^^^^^ 64 | 65 | - BeautifulSoup_ 66 | 67 | For the :attr:`parser ` 68 | ``compressor.parser.BeautifulSoupParser`` and 69 | ``compressor.parser.LxmlParser``:: 70 | 71 | pip install "BeautifulSoup<4.0" 72 | 73 | - lxml_ 74 | 75 | For the :attr:`parser ` 76 | ``compressor.parser.LxmlParser``, also requires libxml2_:: 77 | 78 | STATIC_DEPS=true pip install lxml 79 | 80 | - html5lib_ 81 | 82 | For the :attr:`parser ` 83 | ``compressor.parser.Html5LibParser``:: 84 | 85 | pip install html5lib 86 | 87 | - `Slim It`_ 88 | 89 | For the :ref:`Slim It filter ` 90 | ``compressor.filters.jsmin.SlimItFilter``:: 91 | 92 | pip install slimit 93 | 94 | .. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/ 95 | .. _lxml: http://codespeak.net/lxml/ 96 | .. _libxml2: http://xmlsoft.org/ 97 | .. _html5lib: http://code.google.com/p/html5lib/ 98 | .. _`Slim It`: http://slimit.org/ 99 | .. _django-appconf: http://pypi.python.org/pypi/django-appconf/ 100 | .. _versiontools: http://pypi.python.org/pypi/versiontools/ 101 | -------------------------------------------------------------------------------- /compressor/storage.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import errno 3 | import gzip 4 | import os 5 | from datetime import datetime 6 | import time 7 | 8 | from django.core.files.storage import FileSystemStorage, get_storage_class 9 | from django.utils.functional import LazyObject, SimpleLazyObject 10 | 11 | from compressor.conf import settings 12 | 13 | 14 | class CompressorFileStorage(FileSystemStorage): 15 | """ 16 | Standard file system storage for files handled by django-compressor. 17 | 18 | The defaults for ``location`` and ``base_url`` are ``COMPRESS_ROOT`` and 19 | ``COMPRESS_URL``. 20 | 21 | """ 22 | def __init__(self, location=None, base_url=None, *args, **kwargs): 23 | if location is None: 24 | location = settings.COMPRESS_ROOT 25 | if base_url is None: 26 | base_url = settings.COMPRESS_URL 27 | super(CompressorFileStorage, self).__init__(location, base_url, 28 | *args, **kwargs) 29 | 30 | def accessed_time(self, name): 31 | return datetime.fromtimestamp(os.path.getatime(self.path(name))) 32 | 33 | def created_time(self, name): 34 | return datetime.fromtimestamp(os.path.getctime(self.path(name))) 35 | 36 | def modified_time(self, name): 37 | return datetime.fromtimestamp(os.path.getmtime(self.path(name))) 38 | 39 | def get_available_name(self, name): 40 | """ 41 | Deletes the given file if it exists. 42 | """ 43 | if self.exists(name): 44 | self.delete(name) 45 | return name 46 | 47 | def delete(self, name): 48 | """ 49 | Handle deletion race condition present in Django prior to 1.4 50 | https://code.djangoproject.com/ticket/16108 51 | """ 52 | try: 53 | super(CompressorFileStorage, self).delete(name) 54 | except OSError as e: 55 | if e.errno != errno.ENOENT: 56 | raise 57 | 58 | 59 | compressor_file_storage = SimpleLazyObject( 60 | lambda: get_storage_class('compressor.storage.CompressorFileStorage')()) 61 | 62 | 63 | class GzipCompressorFileStorage(CompressorFileStorage): 64 | """ 65 | The standard compressor file system storage that gzips storage files 66 | additionally to the usual files. 67 | """ 68 | def save(self, filename, content): 69 | filename = super(GzipCompressorFileStorage, self).save(filename, content) 70 | orig_path = self.path(filename) 71 | compressed_path = '%s.gz' % orig_path 72 | 73 | f_in = open(orig_path, 'rb') 74 | f_out = open(compressed_path, 'wb') 75 | try: 76 | f_out = gzip.GzipFile(fileobj=f_out) 77 | f_out.write(f_in.read()) 78 | finally: 79 | f_out.close() 80 | f_in.close() 81 | # Ensure the file timestamps match. 82 | # os.stat() returns nanosecond resolution on Linux, but os.utime() 83 | # only sets microsecond resolution. Set times on both files to 84 | # ensure they are equal. 85 | stamp = time.time() 86 | os.utime(orig_path, (stamp, stamp)) 87 | os.utime(compressed_path, (stamp, stamp)) 88 | 89 | return filename 90 | 91 | 92 | class DefaultStorage(LazyObject): 93 | def _setup(self): 94 | self._wrapped = get_storage_class(settings.COMPRESS_STORAGE)() 95 | 96 | default_storage = DefaultStorage() 97 | -------------------------------------------------------------------------------- /compressor/management/commands/mtime_cache.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | from optparse import make_option 4 | 5 | from django.core.management.base import NoArgsCommand, CommandError 6 | 7 | from compressor.conf import settings 8 | from compressor.cache import cache, get_mtime, get_mtime_cachekey 9 | 10 | 11 | class Command(NoArgsCommand): 12 | help = "Add or remove all mtime values from the cache" 13 | option_list = NoArgsCommand.option_list + ( 14 | make_option('-i', '--ignore', action='append', default=[], 15 | dest='ignore_patterns', metavar='PATTERN', 16 | help="Ignore files or directories matching this glob-style " 17 | "pattern. Use multiple times to ignore more."), 18 | make_option('--no-default-ignore', action='store_false', 19 | dest='use_default_ignore_patterns', default=True, 20 | help="Don't ignore the common private glob-style patterns 'CVS', " 21 | "'.*' and '*~'."), 22 | make_option('--follow-links', dest='follow_links', action='store_true', 23 | help="Follow symlinks when traversing the COMPRESS_ROOT " 24 | "(which defaults to STATIC_ROOT). Be aware that using this " 25 | "can lead to infinite recursion if a link points to a parent " 26 | "directory of itself."), 27 | make_option('-c', '--clean', dest='clean', action='store_true', 28 | help="Remove all items"), 29 | make_option('-a', '--add', dest='add', action='store_true', 30 | help="Add all items"), 31 | ) 32 | 33 | def is_ignored(self, path): 34 | """ 35 | Return True or False depending on whether the ``path`` should be 36 | ignored (if it matches any pattern in ``ignore_patterns``). 37 | """ 38 | for pattern in self.ignore_patterns: 39 | if fnmatch.fnmatchcase(path, pattern): 40 | return True 41 | return False 42 | 43 | def handle_noargs(self, **options): 44 | ignore_patterns = options['ignore_patterns'] 45 | if options['use_default_ignore_patterns']: 46 | ignore_patterns += ['CVS', '.*', '*~'] 47 | options['ignore_patterns'] = ignore_patterns 48 | self.ignore_patterns = ignore_patterns 49 | 50 | if (options['add'] and options['clean']) or (not options['add'] and not options['clean']): 51 | raise CommandError('Please specify either "--add" or "--clean"') 52 | 53 | if not settings.COMPRESS_MTIME_DELAY: 54 | raise CommandError('mtime caching is currently disabled. Please ' 55 | 'set the COMPRESS_MTIME_DELAY setting to a number of seconds.') 56 | 57 | files_to_add = set() 58 | keys_to_delete = set() 59 | 60 | for root, dirs, files in os.walk(settings.COMPRESS_ROOT, followlinks=options['follow_links']): 61 | for dir_ in dirs: 62 | if self.is_ignored(dir_): 63 | dirs.remove(dir_) 64 | for filename in files: 65 | common = "".join(root.split(settings.COMPRESS_ROOT)) 66 | if common.startswith(os.sep): 67 | common = common[len(os.sep):] 68 | if self.is_ignored(os.path.join(common, filename)): 69 | continue 70 | filename = os.path.join(root, filename) 71 | keys_to_delete.add(get_mtime_cachekey(filename)) 72 | if options['add']: 73 | files_to_add.add(filename) 74 | 75 | if keys_to_delete: 76 | cache.delete_many(list(keys_to_delete)) 77 | print("Deleted mtimes of %d files from the cache." % len(keys_to_delete)) 78 | 79 | if files_to_add: 80 | for filename in files_to_add: 81 | get_mtime(filename) 82 | print("Added mtimes of %d files to cache." % len(files_to_add)) 83 | -------------------------------------------------------------------------------- /docs/remote-storages.txt: -------------------------------------------------------------------------------- 1 | .. _remote_storages: 2 | 3 | Remote storages 4 | --------------- 5 | 6 | In some cases it's useful to use a CDN_ for serving static files such as 7 | those generated by Django Compressor. Due to the way Django Compressor 8 | processes files, it requires the files to be processed (in the 9 | ``{% compress %}`` block) to be available in a local file system cache. 10 | 11 | Django Compressor provides hooks to automatically have compressed files 12 | pushed to a remote storage backend. Simply set the storage backend 13 | that saves the result to a remote service (see 14 | :attr:`~django.conf.settings.COMPRESS_STORAGE`). 15 | 16 | django-storages 17 | ^^^^^^^^^^^^^^^ 18 | 19 | So assuming your CDN is `Amazon S3`_, you can use the boto_ storage backend 20 | from the 3rd party app `django-storages`_. Some required settings are:: 21 | 22 | AWS_ACCESS_KEY_ID = 'XXXXXXXXXXXXXXXXXXXXX' 23 | AWS_SECRET_ACCESS_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 24 | AWS_STORAGE_BUCKET_NAME = 'compressor-test' 25 | 26 | Next, you need to specify the new CDN base URL and update the URLs to the 27 | files in your templates which you want to compress:: 28 | 29 | COMPRESS_URL = "http://compressor-test.s3.amazonaws.com/" 30 | 31 | .. note:: 32 | 33 | For staticfiles just set ``STATIC_URL = COMPRESS_URL`` 34 | 35 | The storage backend to save the compressed files needs to be changed, too:: 36 | 37 | COMPRESS_STORAGE = 'storages.backends.s3boto.S3BotoStorage' 38 | 39 | Using staticfiles 40 | ^^^^^^^^^^^^^^^^^ 41 | 42 | If you are using Django's staticfiles_ contrib app or the standalone 43 | app django-staticfiles_, you'll need to use a temporary filesystem cache 44 | for Django Compressor to know which files to compress. Since staticfiles 45 | provides a management command to collect static files from various 46 | locations which uses a storage backend, this is where both apps can be 47 | integrated. 48 | 49 | #. Make sure the :attr:`~django.conf.settings.COMPRESS_ROOT` and STATIC_ROOT_ 50 | settings are equal since both apps need to look at the same directories 51 | when to do their job. 52 | 53 | #. You need to create a subclass of the remote storage backend you want 54 | to use; below is an example of the boto S3 storage backend from 55 | django-storages_:: 56 | 57 | from django.core.files.storage import get_storage_class 58 | from storages.backends.s3boto import S3BotoStorage 59 | 60 | class CachedS3BotoStorage(S3BotoStorage): 61 | """ 62 | S3 storage backend that saves the files locally, too. 63 | """ 64 | def __init__(self, *args, **kwargs): 65 | super(CachedS3BotoStorage, self).__init__(*args, **kwargs) 66 | self.local_storage = get_storage_class( 67 | "compressor.storage.CompressorFileStorage")() 68 | 69 | def save(self, name, content): 70 | name = super(CachedS3BotoStorage, self).save(name, content) 71 | self.local_storage._save(name, content) 72 | return name 73 | 74 | #. Set your :attr:`~django.conf.settings.COMPRESS_STORAGE` and STATICFILES_STORAGE_ 75 | settings to the dotted path of your custom cached storage backend, e.g. 76 | ``'mysite.storage.CachedS3BotoStorage'``. 77 | 78 | #. To have Django correctly render the URLs to your static files, set the 79 | STATIC_URL_ setting to the same value as 80 | :attr:`~django.conf.settings.COMPRESS_URL` (e.g. 81 | ``"http://compressor-test.s3.amazonaws.com/"``). 82 | 83 | .. _CDN: http://en.wikipedia.org/wiki/Content_delivery_network 84 | .. _Amazon S3: https://s3.amazonaws.com/ 85 | .. _boto: http://boto.cloudhackers.com/ 86 | .. _django-storages: http://code.welldev.org/django-storages/ 87 | .. _django-staticfiles: http://github.com/jezdez/django-staticfiles/ 88 | .. _staticfiles: http://docs.djangoproject.com/en/dev/howto/static-files/ 89 | .. _STATIC_ROOT: http://docs.djangoproject.com/en/dev/ref/settings/#static-root 90 | .. _STATIC_URL: http://docs.djangoproject.com/en/dev/ref/settings/#static-url 91 | .. _STATICFILES_STORAGE: http://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-storage 92 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Compressor 2 | ================= 3 | 4 | .. image:: https://coveralls.io/repos/django-compressor/django-compressor/badge.png?branch=develop 5 | :target: https://coveralls.io/r/django-compressor/django-compressor?branch=develop 6 | 7 | .. image:: https://pypip.in/v/django_compressor/badge.png 8 | :target: https://pypi.python.org/pypi/django_compressor 9 | 10 | .. image:: https://pypip.in/d/django_compressor/badge.png 11 | :target: https://pypi.python.org/pypi/django_compressor 12 | 13 | .. image:: https://secure.travis-ci.org/django-compressor/django-compressor.png?branch=develop 14 | :alt: Build Status 15 | :target: http://travis-ci.org/django-compressor/django-compressor 16 | 17 | Django Compressor combines and compresses linked and inline Javascript 18 | or CSS in a Django template into cacheable static files by using the 19 | ``compress`` template tag. 20 | 21 | HTML in between ``{% compress js/css %}`` and ``{% endcompress %}`` is 22 | parsed and searched for CSS or JS. These styles and scripts are subsequently 23 | processed with optional, configurable compilers and filters. 24 | 25 | The default filter for CSS rewrites paths to static files to be absolute 26 | and adds a cache busting timestamp. For Javascript the default filter 27 | compresses it using ``jsmin``. 28 | 29 | As the final result the template tag outputs a `` 101 | 102 | {% endcompress %}""") 103 | context = {'STATIC_URL': settings.COMPRESS_URL} 104 | out = '' 105 | self.assertEqual(out, template.render(context)) 106 | 107 | def test_nonascii_js_tag(self): 108 | template = self.env.from_string("""{% compress js -%} 109 | 110 | 111 | {% endcompress %}""") 112 | context = {'STATIC_URL': settings.COMPRESS_URL} 113 | out = '' 114 | self.assertEqual(out, template.render(context)) 115 | 116 | def test_nonascii_latin1_js_tag(self): 117 | template = self.env.from_string("""{% compress js -%} 118 | 119 | 120 | {% endcompress %}""") 121 | context = {'STATIC_URL': settings.COMPRESS_URL} 122 | out = '' 123 | self.assertEqual(out, template.render(context)) 124 | 125 | def test_css_inline(self): 126 | template = self.env.from_string("""{% compress css, inline -%} 127 | 128 | 129 | {% endcompress %}""") 130 | context = {'STATIC_URL': settings.COMPRESS_URL} 131 | out = '\n'.join([ 132 | '', 134 | ]) 135 | self.assertEqual(out, template.render(context)) 136 | 137 | def test_js_inline(self): 138 | template = self.env.from_string("""{% compress js, inline -%} 139 | 140 | 141 | {% endcompress %}""") 142 | context = {'STATIC_URL': settings.COMPRESS_URL} 143 | out = '' 144 | self.assertEqual(out, template.render(context)) 145 | 146 | def test_nonascii_inline_css(self): 147 | org_COMPRESS_ENABLED = settings.COMPRESS_ENABLED 148 | settings.COMPRESS_ENABLED = False 149 | template = self.env.from_string('{% compress css %}' 150 | '{% endcompress %}') 153 | out = '' 154 | settings.COMPRESS_ENABLED = org_COMPRESS_ENABLED 155 | context = {'STATIC_URL': settings.COMPRESS_URL} 156 | self.assertEqual(out, template.render(context)) 157 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-compressor documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jan 21 11:47:42 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath('..')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | # extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.txt' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'Django Compressor' 45 | copyright = u'2014, Django Compressor authors' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | try: 52 | from compressor import __version__ 53 | # The short X.Y version. 54 | version = '.'.join(__version__.split('.')[:2]) 55 | # The full version, including alpha/beta/rc tags. 56 | release = __version__ 57 | except ImportError: 58 | version = release = 'dev' 59 | 60 | # The language for content autogenerated by Sphinx. Refer to documentation 61 | # for a list of supported languages. 62 | #language = None 63 | 64 | # There are two options for replacing |today|: either, you set today to some 65 | # non-false value, then it is used: 66 | #today = '' 67 | # Else, today_fmt is used as the format for a strftime call. 68 | #today_fmt = '%B %d, %Y' 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | exclude_patterns = ['_build'] 73 | 74 | # The reST default role (used for this markup: `text`) to use for all documents. 75 | #default_role = None 76 | 77 | # If true, '()' will be appended to :func: etc. cross-reference text. 78 | #add_function_parentheses = True 79 | 80 | # If true, the current module name will be prepended to all description 81 | # unit titles (such as .. function::). 82 | #add_module_names = True 83 | 84 | # If true, sectionauthor and moduleauthor directives will be shown in the 85 | # output. They are ignored by default. 86 | #show_authors = False 87 | 88 | # The name of the Pygments (syntax highlighting) style to use. 89 | pygments_style = 'murphy' 90 | 91 | # A list of ignored prefixes for module index sorting. 92 | #modindex_common_prefix = [] 93 | 94 | 95 | # -- Options for HTML output --------------------------------------------------- 96 | 97 | # The theme to use for HTML and HTML Help pages. See the documentation for 98 | # a list of builtin themes. 99 | # html_theme = 'default' 100 | RTD_NEW_THEME = True 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | #html_theme_options = {} 106 | 107 | # Add any paths that contain custom themes here, relative to this directory. 108 | # html_theme_path = ['_theme'] 109 | 110 | # The name for this set of Sphinx documents. If None, it defaults to 111 | # " v documentation". 112 | #html_title = None 113 | 114 | # A shorter title for the navigation bar. Default is the same as html_title. 115 | #html_short_title = None 116 | 117 | # The name of an image file (relative to this directory) to place at the top 118 | # of the sidebar. 119 | #html_logo = None 120 | 121 | # The name of an image file (within the static path) to use as favicon of the 122 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 123 | # pixels large. 124 | #html_favicon = None 125 | 126 | # Add any paths that contain custom static files (such as style sheets) here, 127 | # relative to this directory. They are copied after the builtin static files, 128 | # so a file named "default.css" will overwrite the builtin "default.css". 129 | # html_static_path = ['_static'] 130 | 131 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 132 | # using the given strftime format. 133 | #html_last_updated_fmt = '%b %d, %Y' 134 | 135 | # If true, SmartyPants will be used to convert quotes and dashes to 136 | # typographically correct entities. 137 | #html_use_smartypants = True 138 | 139 | # Custom sidebar templates, maps document names to template names. 140 | #html_sidebars = {} 141 | 142 | # Additional templates that should be rendered to pages, maps page names to 143 | # template names. 144 | #html_additional_pages = {} 145 | 146 | # If false, no module index is generated. 147 | #html_domain_indices = True 148 | 149 | # If false, no index is generated. 150 | #html_use_index = True 151 | 152 | # If true, the index is split into individual pages for each letter. 153 | #html_split_index = False 154 | 155 | # If true, links to the reST sources are added to the pages. 156 | #html_show_sourcelink = True 157 | 158 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 159 | #html_show_sphinx = True 160 | 161 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 162 | #html_show_copyright = True 163 | 164 | # If true, an OpenSearch description file will be output, and all pages will 165 | # contain a tag referring to it. The value of this option must be the 166 | # base URL from which the finished HTML is served. 167 | #html_use_opensearch = '' 168 | 169 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 170 | #html_file_suffix = None 171 | 172 | # Output file base name for HTML help builder. 173 | htmlhelp_basename = 'django-compressordoc' 174 | 175 | 176 | # -- Options for LaTeX output -------------------------------------------------- 177 | 178 | # The paper size ('letter' or 'a4'). 179 | #latex_paper_size = 'letter' 180 | 181 | # The font size ('10pt', '11pt' or '12pt'). 182 | #latex_font_size = '10pt' 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'django-compressor.tex', u'Django Compressor Documentation', 188 | u'Django Compressor authors', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Additional stuff for the LaTeX preamble. 206 | #latex_preamble = '' 207 | 208 | # Documents to append as an appendix to all manuals. 209 | #latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | #latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ('index', 'django-compressor', u'Django Compressor Documentation', 221 | [u'Django Compressor authors'], 1) 222 | ] 223 | -------------------------------------------------------------------------------- /compressor/templatetags/compress.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.utils import six 4 | 5 | from compressor.cache import (cache_get, cache_set, get_offline_hexdigest, 6 | get_offline_manifest, get_templatetag_cachekey) 7 | from compressor.conf import settings 8 | from compressor.exceptions import OfflineGenerationError 9 | from compressor.utils import get_class 10 | 11 | register = template.Library() 12 | 13 | OUTPUT_FILE = 'file' 14 | OUTPUT_INLINE = 'inline' 15 | OUTPUT_MODES = (OUTPUT_FILE, OUTPUT_INLINE) 16 | 17 | 18 | class CompressorMixin(object): 19 | 20 | def get_original_content(self, context): 21 | raise NotImplementedError 22 | 23 | @property 24 | def compressors(self): 25 | return { 26 | 'js': settings.COMPRESS_JS_COMPRESSOR, 27 | 'css': settings.COMPRESS_CSS_COMPRESSOR, 28 | } 29 | 30 | def compressor_cls(self, kind, *args, **kwargs): 31 | if kind not in self.compressors.keys(): 32 | raise template.TemplateSyntaxError( 33 | "The compress tag's argument must be 'js' or 'css'.") 34 | return get_class(self.compressors.get(kind), 35 | exception=ImproperlyConfigured)(*args, **kwargs) 36 | 37 | def get_compressor(self, context, kind): 38 | return self.compressor_cls(kind, 39 | content=self.get_original_content(context), context=context) 40 | 41 | def debug_mode(self, context): 42 | if settings.COMPRESS_DEBUG_TOGGLE: 43 | # Only check for the debug parameter 44 | # if a RequestContext was used 45 | request = context.get('request', None) 46 | if request is not None: 47 | return settings.COMPRESS_DEBUG_TOGGLE in request.GET 48 | 49 | def is_offline_compression_enabled(self, forced): 50 | """ 51 | Check if offline compression is enabled or forced 52 | 53 | Defaults to just checking the settings and forced argument, 54 | but can be overridden to completely disable compression for 55 | a subclass, for instance. 56 | """ 57 | return (settings.COMPRESS_ENABLED and 58 | settings.COMPRESS_OFFLINE) or forced 59 | 60 | def render_offline(self, context, forced): 61 | """ 62 | If enabled and in offline mode, and not forced check the offline cache 63 | and return the result if given 64 | """ 65 | if self.is_offline_compression_enabled(forced) and not forced: 66 | key = get_offline_hexdigest(self.get_original_content(context)) 67 | offline_manifest = get_offline_manifest() 68 | if key in offline_manifest: 69 | return offline_manifest[key] 70 | else: 71 | raise OfflineGenerationError('You have offline compression ' 72 | 'enabled but key "%s" is missing from offline manifest. ' 73 | 'You may need to run "python manage.py compress".' % key) 74 | 75 | def render_cached(self, compressor, kind, mode, forced=False): 76 | """ 77 | If enabled checks the cache for the given compressor's cache key 78 | and return a tuple of cache key and output 79 | """ 80 | if settings.COMPRESS_ENABLED and not forced: 81 | cache_key = get_templatetag_cachekey(compressor, mode, kind) 82 | cache_content = cache_get(cache_key) 83 | return cache_key, cache_content 84 | return None, None 85 | 86 | def render_compressed(self, context, kind, mode, forced=False): 87 | 88 | # See if it has been rendered offline 89 | cached_offline = self.render_offline(context, forced=forced) 90 | if cached_offline: 91 | return cached_offline 92 | 93 | # Take a shortcut if we really don't have anything to do 94 | if ((not settings.COMPRESS_ENABLED and 95 | not settings.COMPRESS_PRECOMPILERS) and not forced): 96 | return self.get_original_content(context) 97 | 98 | context['compressed'] = {'name': getattr(self, 'name', None)} 99 | compressor = self.get_compressor(context, kind) 100 | 101 | # Prepare the actual compressor and check cache 102 | cache_key, cache_content = self.render_cached(compressor, kind, mode, forced=forced) 103 | if cache_content is not None: 104 | return cache_content 105 | 106 | # call compressor output method and handle exceptions 107 | try: 108 | rendered_output = self.render_output(compressor, mode, forced=forced) 109 | if cache_key: 110 | cache_set(cache_key, rendered_output) 111 | assert isinstance(rendered_output, six.string_types) 112 | return rendered_output 113 | except Exception: 114 | if settings.DEBUG or forced: 115 | raise 116 | 117 | # Or don't do anything in production 118 | return self.get_original_content(context) 119 | 120 | def render_output(self, compressor, mode, forced=False): 121 | return compressor.output(mode, forced=forced) 122 | 123 | 124 | class CompressorNode(CompressorMixin, template.Node): 125 | 126 | def __init__(self, nodelist, kind=None, mode=OUTPUT_FILE, name=None): 127 | self.nodelist = nodelist 128 | self.kind = kind 129 | self.mode = mode 130 | self.name = name 131 | 132 | def get_original_content(self, context): 133 | return self.nodelist.render(context) 134 | 135 | def debug_mode(self, context): 136 | if settings.COMPRESS_DEBUG_TOGGLE: 137 | # Only check for the debug parameter 138 | # if a RequestContext was used 139 | request = context.get('request', None) 140 | if request is not None: 141 | return settings.COMPRESS_DEBUG_TOGGLE in request.GET 142 | 143 | def render(self, context, forced=False): 144 | 145 | # Check if in debug mode 146 | if self.debug_mode(context): 147 | return self.get_original_content(context) 148 | 149 | return self.render_compressed(context, self.kind, self.mode, forced=forced) 150 | 151 | 152 | @register.tag 153 | def compress(parser, token): 154 | """ 155 | Compresses linked and inline javascript or CSS into a single cached file. 156 | 157 | Syntax:: 158 | 159 | {% compress %} 160 | 161 | {% endcompress %} 162 | 163 | Examples:: 164 | 165 | {% compress css %} 166 | 167 | 168 | 169 | {% endcompress %} 170 | 171 | Which would be rendered something like:: 172 | 173 | 174 | 175 | or:: 176 | 177 | {% compress js %} 178 | 179 | 180 | {% endcompress %} 181 | 182 | Which would be rendered something like:: 183 | 184 | 185 | 186 | Linked files must be on your COMPRESS_URL (which defaults to STATIC_URL). 187 | If DEBUG is true off-site files will throw exceptions. If DEBUG is false 188 | they will be silently stripped. 189 | """ 190 | 191 | nodelist = parser.parse(('endcompress',)) 192 | parser.delete_first_token() 193 | 194 | args = token.split_contents() 195 | 196 | if not len(args) in (2, 3, 4): 197 | raise template.TemplateSyntaxError( 198 | "%r tag requires either one, two or three arguments." % args[0]) 199 | 200 | kind = args[1] 201 | 202 | if len(args) >= 3: 203 | mode = args[2] 204 | if mode not in OUTPUT_MODES: 205 | raise template.TemplateSyntaxError( 206 | "%r's second argument must be '%s' or '%s'." % 207 | (args[0], OUTPUT_FILE, OUTPUT_INLINE)) 208 | else: 209 | mode = OUTPUT_FILE 210 | if len(args) == 4: 211 | name = args[3] 212 | else: 213 | name = None 214 | return CompressorNode(nodelist, kind, mode, name) 215 | -------------------------------------------------------------------------------- /compressor/filters/cssmin/cssmin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # `cssmin.py` - A Python port of the YUI CSS compressor. 5 | # 6 | # Copyright (c) 2010 Zachary Voase 7 | # 8 | # Permission is hereby granted, free of charge, to any person 9 | # obtaining a copy of this software and associated documentation 10 | # files (the "Software"), to deal in the Software without 11 | # restriction, including without limitation the rights to use, 12 | # copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the 14 | # Software is furnished to do so, subject to the following 15 | # conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be 18 | # included in all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 21 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 22 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 23 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 24 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 25 | # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 26 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 27 | # OTHER DEALINGS IN THE SOFTWARE. 28 | # 29 | """`cssmin` - A Python port of the YUI CSS compressor.""" 30 | 31 | import re 32 | 33 | __version__ = '0.1.4' 34 | 35 | 36 | def remove_comments(css): 37 | """Remove all CSS comment blocks.""" 38 | 39 | iemac = False 40 | preserve = False 41 | comment_start = css.find("/*") 42 | while comment_start >= 0: 43 | # Preserve comments that look like `/*!...*/`. 44 | # Slicing is used to make sure we don"t get an IndexError. 45 | preserve = css[comment_start + 2:comment_start + 3] == "!" 46 | 47 | comment_end = css.find("*/", comment_start + 2) 48 | if comment_end < 0: 49 | if not preserve: 50 | css = css[:comment_start] 51 | break 52 | elif comment_end >= (comment_start + 2): 53 | if css[comment_end - 1] == "\\": 54 | # This is an IE Mac-specific comment; leave this one and the 55 | # following one alone. 56 | comment_start = comment_end + 2 57 | iemac = True 58 | elif iemac: 59 | comment_start = comment_end + 2 60 | iemac = False 61 | elif not preserve: 62 | css = css[:comment_start] + css[comment_end + 2:] 63 | else: 64 | comment_start = comment_end + 2 65 | comment_start = css.find("/*", comment_start) 66 | 67 | return css 68 | 69 | 70 | def remove_unnecessary_whitespace(css): 71 | """Remove unnecessary whitespace characters.""" 72 | 73 | def pseudoclasscolon(css): 74 | 75 | """ 76 | Prevents 'p :link' from becoming 'p:link'. 77 | 78 | Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'; this is 79 | translated back again later. 80 | """ 81 | 82 | regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)") 83 | match = regex.search(css) 84 | while match: 85 | css = ''.join([ 86 | css[:match.start()], 87 | match.group().replace(":", "___PSEUDOCLASSCOLON___"), 88 | css[match.end():]]) 89 | match = regex.search(css) 90 | return css 91 | 92 | css = pseudoclasscolon(css) 93 | # Remove spaces from before things. 94 | css = re.sub(r"\s+([!{};:>+\(\)\],])", r"\1", css) 95 | 96 | # If there is a `@charset`, then only allow one, and move to the beginning. 97 | css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css) 98 | css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css) 99 | 100 | # Put the space back in for a few cases, such as `@media screen` and 101 | # `(-webkit-min-device-pixel-ratio:0)`. 102 | css = re.sub(r"\band\(", "and (", css) 103 | 104 | # Put the colons back. 105 | css = css.replace('___PSEUDOCLASSCOLON___', ':') 106 | 107 | # Remove spaces from after things. 108 | css = re.sub(r"([!{}:;>+\(\[,])\s+", r"\1", css) 109 | 110 | return css 111 | 112 | 113 | def remove_unnecessary_semicolons(css): 114 | """Remove unnecessary semicolons.""" 115 | 116 | return re.sub(r";+\}", "}", css) 117 | 118 | 119 | def remove_empty_rules(css): 120 | """Remove empty rules.""" 121 | 122 | return re.sub(r"[^\}\{]+\{\}", "", css) 123 | 124 | 125 | def normalize_rgb_colors_to_hex(css): 126 | """Convert `rgb(51,102,153)` to `#336699`.""" 127 | 128 | regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)") 129 | match = regex.search(css) 130 | while match: 131 | colors = map(lambda s: s.strip(), match.group(1).split(",")) 132 | hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors)) 133 | css = css.replace(match.group(), hexcolor) 134 | match = regex.search(css) 135 | return css 136 | 137 | 138 | def condense_zero_units(css): 139 | """Replace `0(px, em, %, etc)` with `0`.""" 140 | 141 | return re.sub(r"([\s:])(0)(px|em|%|in|cm|mm|pc|pt|ex)", r"\1\2", css) 142 | 143 | 144 | def condense_multidimensional_zeros(css): 145 | """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`.""" 146 | 147 | css = css.replace(":0 0 0 0;", ":0;") 148 | css = css.replace(":0 0 0;", ":0;") 149 | css = css.replace(":0 0;", ":0;") 150 | 151 | # Revert `background-position:0;` to the valid `background-position:0 0;`. 152 | css = css.replace("background-position:0;", "background-position:0 0;") 153 | 154 | return css 155 | 156 | 157 | def condense_floating_points(css): 158 | """Replace `0.6` with `.6` where possible.""" 159 | 160 | return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css) 161 | 162 | 163 | def condense_hex_colors(css): 164 | """Shorten colors from #AABBCC to #ABC where possible.""" 165 | 166 | regex = re.compile(r"([^\"'=\s])(\s*)#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])") 167 | match = regex.search(css) 168 | while match: 169 | first = match.group(3) + match.group(5) + match.group(7) 170 | second = match.group(4) + match.group(6) + match.group(8) 171 | if first.lower() == second.lower(): 172 | css = css.replace(match.group(), match.group(1) + match.group(2) + '#' + first) 173 | match = regex.search(css, match.end() - 3) 174 | else: 175 | match = regex.search(css, match.end()) 176 | return css 177 | 178 | 179 | def condense_whitespace(css): 180 | """Condense multiple adjacent whitespace characters into one.""" 181 | 182 | return re.sub(r"\s+", " ", css) 183 | 184 | 185 | def condense_semicolons(css): 186 | """Condense multiple adjacent semicolon characters into one.""" 187 | 188 | return re.sub(r";;+", ";", css) 189 | 190 | 191 | def wrap_css_lines(css, line_length): 192 | """Wrap the lines of the given CSS to an approximate length.""" 193 | 194 | lines = [] 195 | line_start = 0 196 | for i, char in enumerate(css): 197 | # It's safe to break after `}` characters. 198 | if char == '}' and (i - line_start >= line_length): 199 | lines.append(css[line_start:i + 1]) 200 | line_start = i + 1 201 | 202 | if line_start < len(css): 203 | lines.append(css[line_start:]) 204 | return '\n'.join(lines) 205 | 206 | 207 | def cssmin(css, wrap=None): 208 | css = remove_comments(css) 209 | css = condense_whitespace(css) 210 | # A pseudo class for the Box Model Hack 211 | # (see http://tantek.com/CSS/Examples/boxmodelhack.html) 212 | css = css.replace('"\\"}\\""', "___PSEUDOCLASSBMH___") 213 | css = remove_unnecessary_whitespace(css) 214 | css = remove_unnecessary_semicolons(css) 215 | css = condense_zero_units(css) 216 | css = condense_multidimensional_zeros(css) 217 | css = condense_floating_points(css) 218 | css = normalize_rgb_colors_to_hex(css) 219 | css = condense_hex_colors(css) 220 | if wrap is not None: 221 | css = wrap_css_lines(css, wrap) 222 | css = css.replace("___PSEUDOCLASSBMH___", '"\\"}\\""') 223 | css = condense_semicolons(css) 224 | return css.strip() 225 | 226 | 227 | def main(): 228 | import optparse 229 | import sys 230 | 231 | p = optparse.OptionParser( 232 | prog="cssmin", version=__version__, 233 | usage="%prog [--wrap N]", 234 | description="""Reads raw CSS from stdin, and writes compressed CSS to stdout.""") 235 | 236 | p.add_option( 237 | '-w', '--wrap', type='int', default=None, metavar='N', 238 | help="Wrap output to approximately N chars per line.") 239 | 240 | options, args = p.parse_args() 241 | sys.stdout.write(cssmin(sys.stdin.read(), wrap=options.wrap)) 242 | 243 | 244 | if __name__ == '__main__': 245 | main() 246 | -------------------------------------------------------------------------------- /docs/usage.txt: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | Usage 4 | ===== 5 | 6 | .. code-block:: django 7 | 8 | {% load compress %} 9 | {% compress [ [block_name]] %} 10 | 11 | {% endcompress %} 12 | 13 | Examples 14 | -------- 15 | 16 | .. code-block:: django 17 | 18 | {% load compress %} 19 | 20 | {% compress css %} 21 | 22 | 23 | 24 | {% endcompress %} 25 | 26 | Which would be rendered something like: 27 | 28 | .. code-block:: django 29 | 30 | 31 | 32 | or: 33 | 34 | .. code-block:: django 35 | 36 | {% load compress %} 37 | 38 | {% compress js %} 39 | 40 | 41 | {% endcompress %} 42 | 43 | Which would be rendered something like: 44 | 45 | .. code-block:: django 46 | 47 | 48 | 49 | .. note:: 50 | 51 | Remember that django-compressor will try to :ref:`group ouputs by media `. 52 | 53 | Linked files **must** be accessible via 54 | :attr:`~django.conf.settings.COMPRESS_URL`. 55 | 56 | If the :attr:`~django.conf.settings.COMPRESS_ENABLED` setting is ``False`` 57 | (defaults to the opposite of DEBUG) the ``compress`` template tag does nothing 58 | and simply returns exactly what it was given. 59 | 60 | .. note:: 61 | 62 | If you've configured any 63 | :attr:`precompilers ` 64 | setting :attr:`~django.conf.settings.COMPRESS_ENABLED` to ``False`` won't 65 | affect the processing of those files. Only the 66 | :attr:`CSS ` and 67 | :attr:`JavaScript filters ` 68 | will be disabled. 69 | 70 | If both DEBUG and :attr:`~django.conf.settings.COMPRESS_ENABLED` are set to 71 | ``True``, incompressible files (off-site or non existent) will throw an 72 | exception. If DEBUG is ``False`` these files will be silently stripped. 73 | 74 | .. warning:: 75 | 76 | For production sites it is **strongly recommended** to use a real cache 77 | backend such as memcached_ to speed up the checks of compressed files. 78 | Make sure you set your Django cache backend appropriately (also see 79 | :attr:`~django.conf.settings.COMPRESS_CACHE_BACKEND` and 80 | Django's `caching documentation`_). 81 | 82 | The compress template tag supports a second argument specifying the output 83 | mode and defaults to saving the result in a file. Alternatively you can 84 | pass '``inline``' to the template tag to return the content directly to the 85 | rendered page, e.g.: 86 | 87 | .. code-block:: django 88 | 89 | {% load compress %} 90 | 91 | {% compress js inline %} 92 | 93 | 94 | {% endcompress %} 95 | 96 | would be rendered something like:: 97 | 98 | 102 | 103 | The compress template tag also supports a third argument for naming the output 104 | of that particular compress tag. This is then added to the context so you can 105 | access it in the `post_compress signal `. 106 | 107 | .. _memcached: http://memcached.org/ 108 | .. _caching documentation: http://docs.djangoproject.com/en/1.2/topics/cache/#memcached 109 | 110 | .. _pre-compression: 111 | 112 | Pre-compression 113 | --------------- 114 | 115 | Django Compressor comes with an optional ``compress`` management command to 116 | run the compression outside of the request/response loop -- independent 117 | from user requests. This allows to pre-compress CSS and JavaScript files and 118 | works just like the automatic compression with the ``{% compress %}`` tag. 119 | 120 | To compress the files "offline" and update the offline cache you have 121 | to use the ``compress`` management command, ideally during deployment. 122 | Also make sure to enable the :attr:`django.conf.settings.COMPRESS_OFFLINE` 123 | setting. In case you don't use the ``compress`` management command, Django 124 | Compressor will automatically fallback to the automatic compression using 125 | the template tag. 126 | 127 | The command parses all templates that can be found with the template 128 | loader (as specified in the TEMPLATE_LOADERS_ setting) and looks for 129 | ``{% compress %}`` blocks. It then will use the context as defined in 130 | :attr:`django.conf.settings.COMPRESS_OFFLINE_CONTEXT` to render its 131 | content. So if you use any variables inside the ``{% compress %}`` blocks, 132 | make sure to list all values you require in ``COMPRESS_OFFLINE_CONTEXT``. 133 | It's similar to a template context and should be used if a variable is used 134 | in the blocks, e.g.: 135 | 136 | .. code-block:: django 137 | 138 | {% load compress %} 139 | {% compress js %} 140 | 141 | {% endcompress %} 142 | 143 | Since this template requires a variable (``path_to_files``) you need to 144 | specify this in your settings before using the ``compress`` management 145 | command:: 146 | 147 | COMPRESS_OFFLINE_CONTEXT = { 148 | 'path_to_files': '/static/js/', 149 | } 150 | 151 | If not specified, the ``COMPRESS_OFFLINE_CONTEXT`` will by default contain 152 | the commonly used setting to refer to saved files ``STATIC_URL``. 153 | 154 | The result of running the ``compress`` management command will be cached 155 | in a file called ``manifest.json`` using the :attr:`configured storage 156 | ` to be able to be transfered from your developement 157 | computer to the server easily. 158 | 159 | .. _TEMPLATE_LOADERS: http://docs.djangoproject.com/en/dev/ref/settings/#template-loaders 160 | 161 | .. _signals: 162 | 163 | Signals 164 | ------- 165 | 166 | .. function:: compressor.signals.post_compress(sender, type, mode, context) 167 | 168 | Django Compressor includes a ``post_compress`` signal that enables you to 169 | listen for changes to your compressed CSS/JS. This is useful, for example, if 170 | you need the exact filenames for use in an HTML5 manifest file. The signal 171 | sends the following arguments: 172 | 173 | ``sender`` 174 | Either :class:`compressor.css.CssCompressor` or 175 | :class:`compressor.js.JsCompressor`. 176 | 177 | .. versionchanged:: 1.2 178 | 179 | The sender is now one of the supported Compressor classes for 180 | easier limitation to only one of them, previously it was a string 181 | named ``'django-compressor'``. 182 | 183 | ``type`` 184 | Either "``js``" or "``css``". 185 | 186 | ``mode`` 187 | Either "``file``" or "``inline``". 188 | 189 | ``context`` 190 | The context dictionary used to render the output of the compress template 191 | tag. 192 | 193 | If ``mode`` is "``file``" the dictionary named ``compressed`` in the 194 | context will contain a "``url``" key that maps to the relative URL for 195 | the compressed asset. 196 | 197 | If ``type`` is "``css``", the dictionary named ``compressed`` in the 198 | context will additionally contain a "``media``" key with a value of 199 | ``None`` if no media attribute is specified on the link/style tag and 200 | equal to that attribute if one is specified. 201 | 202 | Additionally, ``context['compressed']['name']`` will be the third 203 | positional argument to the template tag, if provided. 204 | 205 | .. note:: 206 | 207 | When compressing CSS, the ``post_compress`` signal will be called once for 208 | every different media attribute on the tags within the ``{% compress %}`` 209 | tag in question. 210 | 211 | .. _css_notes: 212 | 213 | CSS Notes 214 | --------- 215 | 216 | All relative ``url()`` bits specified in linked CSS files are automatically 217 | converted to absolute URLs while being processed. Any local absolute URLs (those 218 | starting with a ``'/'``) are left alone. 219 | 220 | Stylesheets that are ``@import``'d are not compressed into the main file. 221 | They are left alone. 222 | 223 | If the media attribute is set on