├── 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)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